From 57c1ba677c3dc46c742052d3e56e2aaea1685fac Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:31:19 +0100 Subject: [PATCH 001/132] fix(missions): emit canonical agent ids in mission presentation agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions-base/src/mission.rs | 47 +++++--- crates/fractal-missions/src/store/missions.rs | 36 +++++-- .../src/store/work_renew_tests.rs | 44 ++++++++ crates/fractal-schema/src/lib.rs | 36 +++++++ crates/fractal-store/src/store/missions.rs | 10 +- .../src/missions/presentation.rs | 42 ++++++-- .../src/missions/presentation/tests.rs | 101 ++++++++++++++++++ crates/fractal/tests/client_v0_contract.rs | 35 ++++++ release-notes/mission-agentid-canonical.json | 11 ++ 9 files changed, 323 insertions(+), 39 deletions(-) create mode 100644 release-notes/mission-agentid-canonical.json diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index 855cd02db27..bbfebda9b57 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -368,10 +368,10 @@ fn parse_mission( format!("mission `{id}` repeats `assigned-to`"), )); } - assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)); + assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)?); } "available-to" => { - let agent = normalize_assignee(&first_string(child)?, default_host); + let agent = normalize_assignee(&first_string(child)?, default_host)?; if available_to.contains(&agent) { return Err(FractalError::new( "duplicate-work-agent", @@ -1378,10 +1378,10 @@ fn parse_step( baselines.push(baseline); } "assigned-to" => { - assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)) + assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)?) } "available-to" => { - let agent = normalize_assignee(&first_string(child)?, default_host); + let agent = normalize_assignee(&first_string(child)?, default_host)?; if available_to.contains(&agent) { return Err(FractalError::new( "duplicate-work-agent", @@ -1569,10 +1569,10 @@ fn parse_queue( format!("queue `{id}` repeats `assigned-to`"), )); } - assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)); + assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)?); } "available-to" => { - let agent = normalize_assignee(&first_string(child)?, default_host); + let agent = normalize_assignee(&first_string(child)?, default_host)?; if available_to.contains(&agent) { return Err(FractalError::new( "duplicate-work-agent", @@ -2684,18 +2684,17 @@ fn normalize_subject(value: &str, kind: &str) -> String { } } -fn normalize_assignee(value: &str, default_host: &str) -> String { - if value.starts_with("person/") { - return value.to_owned(); - } - let identity = value.strip_prefix("agent/").unwrap_or(value); - if identity.contains('.') || identity.contains("${") { - format!("agent/{identity}") - } else if identity.contains('/') { - value.to_owned() +fn normalize_assignee(value: &str, default_host: &str) -> Result { + let qualified = if value.contains('/') { + std::borrow::Cow::Borrowed(value) + } else if value.contains('.') || value.contains("${") { + std::borrow::Cow::Owned(format!("agent/{value}")) } else { - format!("agent/{default_host}.{identity}") - } + std::borrow::Cow::Owned(format!("agent/{default_host}.{value}")) + }; + fractal_schema::canonical_agent_actor(&qualified) + .map(std::borrow::Cow::into_owned) + .ok_or_else(|| FractalError::new("invalid-assignee", format!("invalid agent actor `{value}`"))) } fn validate_id(value: &str, kind: &str) -> Result<(), FractalError> { @@ -3129,6 +3128,20 @@ fn validate_deferred_declaration_in(node: &KdlNode, parent: Option<&str>) -> Res #[cfg(test)] mod tests { + #[test] + fn assignees_canonicalize_legacy_slash_ids() { + for (input, expected) in [ + ("fleet/worker", "agent/fleet/worker"), + ("agent/fleet/worker", "agent/fleet/worker"), + ("person/reviewer", "person/reviewer"), + ("daemon/scheduler", "daemon/scheduler"), + ("worker", "agent/host.worker"), + ] { + assert_eq!(super::normalize_assignee(input, "host").unwrap(), expected); + } + assert!(super::normalize_assignee("fleet/worker name", "host").is_err()); + } + #[test] fn nested_loop_paths_follow_their_distinct_owning_steps() { let source = r#"mission "collision" state="ready" { goal "Keep nested histories separate."; diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 99efb25997d..d82078cf106 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -80,8 +80,17 @@ impl ObservationBatches { } fn physical_actor(connection: &Connection, actor: &str) -> Result { - let actor = normalize_actor(actor, "agent"); - resolve_reference_conn(connection, &actor) + let actor = canonical_work_actor(actor)?; + let resolved = resolve_reference_conn(connection, &actor)?; + canonical_work_actor(&resolved) +} + +fn canonical_work_actor(actor: &str) -> Result { + fractal_schema::canonical_agent_actor(actor) + .map(std::borrow::Cow::into_owned) + .ok_or_else(|| { + FractalError::new("invalid-work-actor", format!("invalid agent actor `{actor}`")) + }) } // One query preserves the native collection's indexed latest/live probes and shallow @@ -5889,12 +5898,11 @@ impl MissionsStore for Store { )); } let subject = normalize_step_run(subject); - let actor = normalize_actor( + let actor = canonical_work_actor( request.actor.as_deref().ok_or_else(|| { FractalError::new("missing-work-actor", "a work action needs an actor") })?, - "agent", - ); + )?; let requested_incarnation = request .incarnation .as_deref() @@ -5955,6 +5963,12 @@ impl MissionsStore for Store { Some(successor) => (successor.subject.clone(), successor), None => (subject, current), }; + let legacy_claimant = current.claimant.as_deref().is_some_and(|claimant| { + fractal_schema::canonical_agent_actor(claimant).as_deref() != Some(claimant) + }); + current.claimant = current.claimant.as_deref() + .and_then(fractal_schema::canonical_agent_actor) + .map(std::borrow::Cow::into_owned); if handoff.is_some() { enrich_step_summaries_at(transaction, &mut current, now).map_err(internal)?; } @@ -6033,11 +6047,11 @@ impl MissionsStore for Store { if action == "claim" && adhoc_work::pending_handoff(transaction, ¤t)? { return Err(FractalError::new("handoff-not-acknowledged", "read the handoff note and acknowledge its message before claiming this work")); } - let eligible = current.assigned_to.as_deref() == Some(actor.as_str()) - || current - .available_to - .iter() - .any(|candidate| candidate == &actor); + let eligible = current.assigned_to.as_deref().is_some_and(|assignee| { + fractal_schema::canonical_agent_actor(assignee).as_deref() == Some(actor.as_str()) + }) || current.available_to.iter().any(|candidate| { + fractal_schema::canonical_agent_actor(candidate).as_deref() == Some(actor.as_str()) + }); if current.agentless || !eligible { return Err(FractalError::new( "work-not-available", @@ -6252,7 +6266,7 @@ impl MissionsStore for Store { } else { None }; - let publish = !quiet_renewal + let publish = !quiet_renewal || legacy_claimant || last_replicated_expiry.is_none_or(|expiry| expiry <= now.saturating_add(300_000)); let readiness_epoch = current .readiness_epoch diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index c1295f15c04..2ba4ba5dec5 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -508,6 +508,50 @@ fn ready_step_with_mail(mail: usize) -> Renewal { Renewal { _directory: directory, store, subject } } +#[test] +fn work_claim_canonicalizes_legacy_actor_and_stored_assignee() { + for (actor, assignee) in [ + ("node/worker", "agent/node/worker"), + ("agent/node/worker", "node/worker"), + ] { + let step = ready_step_with_mail(0); + // Model both current rows and persisted rows predating actor canonicalization. + step.store.connection.write().execute( + "UPDATE step_runs SET assignee=?2 WHERE subject=?1", + params![step.subject, assignee], + ).unwrap(); + assert_eq!( + step.store.step_run(&step.subject).unwrap().unwrap().assigned_to.as_deref(), + Some(assignee), + ); + let mut claim_request = request("canonical-actor-claim", None); + claim_request.actor = Some(actor.into()); + let claimed = step.store.work_action(&step.subject, "claim", &claim_request).unwrap(); + assert_eq!(claimed.claimant.as_deref(), Some("agent/node/worker")); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.claimant.as_deref(), Some("agent/node/worker")); + let claims = step.store.claims_for(&step.subject, Some("work.claimed")).unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].actor.as_deref(), Some("agent/node/worker")); + assert_eq!(claims[0].body["fields"]["claimant"], json!("agent/node/worker")); + } +} + +#[test] +fn work_claim_rejects_malformed_actor_without_claiming_work() { + let step = ready_step_with_mail(0); + for (index, actor) in ["", "worker", "agent/", "/worker", "node/worker name"].iter().enumerate() { + let mut claim_request = request(&format!("malformed-actor-claim-{index}"), None); + claim_request.actor = Some((*actor).into()); + let error = step.store.work_action(&step.subject, "claim", &claim_request).unwrap_err(); + assert_eq!(error.code, "invalid-work-actor", "{actor:?}"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, "ready"); + assert!(persisted.claimant.is_none()); + assert!(step.store.claims_for(&step.subject, Some("work.claimed")).unwrap().is_empty()); + } +} + #[test] fn a_claim_and_a_completion_hold_the_writer_for_the_same_work_whatever_the_assignees_mail() { let mut costs = Vec::new(); diff --git a/crates/fractal-schema/src/lib.rs b/crates/fractal-schema/src/lib.rs index 72c046ae1b6..3fb79d819d5 100644 --- a/crates/fractal-schema/src/lib.rs +++ b/crates/fractal-schema/src/lib.rs @@ -33,6 +33,42 @@ use sha2::{Digest as _, Sha256}; pub const SCHEMA_NAME: &str = "st3.v1"; +/// Canonicalize an actor used for agent work without changing an explicit actor kind. +/// Bare names need a host before admission; legacy slash-qualified names need `agent/`. +pub fn canonical_agent_actor(id: &str) -> Option> { + if id.chars().any(char::is_whitespace) { + return None; + } + let (kind, name) = id.split_once('/')?; + if kind.is_empty() || name.is_empty() { + return None; + } + if matches!(kind, "agent" | "person" | "daemon") { + Some(std::borrow::Cow::Borrowed(id)) + } else { + Some(std::borrow::Cow::Owned(format!("agent/{id}"))) + } +} + +#[cfg(test)] +mod canonical_agent_actor_tests { + use super::canonical_agent_actor; + + #[test] + fn preserves_actor_kinds_and_prefixes_legacy_agent_ids() { + for id in ["agent/fleet/worker", "person/reviewer", "daemon/scheduler"] { + assert_eq!(canonical_agent_actor(id).as_deref(), Some(id)); + } + assert_eq!( + canonical_agent_actor("fleet/worker").as_deref(), + Some("agent/fleet/worker") + ); + for id in ["", "worker", "agent/", "person/", "/worker", "fleet/worker name"] { + assert!(canonical_agent_actor(id).is_none(), "{id:?}"); + } + } +} + /// Local SQLite compatibility is separate from the replicated claim vocabulary. pub use fractal_storage_version::STORAGE_VERSION; pub const LOCAL_STORAGE_SCHEMA: &str = /* sqlite-large-value: allow-for-now lv-h98d3e9599416 owner=fractal_schema TODO=chunk-or-reference-variable-columns */ r#"## Local checkpoint capture storage diff --git a/crates/fractal-store/src/store/missions.rs b/crates/fractal-store/src/store/missions.rs index e121db31e25..e18eb21f43a 100644 --- a/crates/fractal-store/src/store/missions.rs +++ b/crates/fractal-store/src/store/missions.rs @@ -451,7 +451,15 @@ impl Store { for (step, selector, constraints) in flat { let (assignee, available_to, agentless) = interpolate_selector(&selector, &variables)?; let storage_party = |party: String| { - identities::resolve_reference_conn(&transaction, &party) + let canonical = |id: &str| { + fractal_schema::canonical_agent_actor(id) + .map(std::borrow::Cow::into_owned) + .ok_or_else(|| FractalError::new( + "invalid-work-actor", format!("invalid agent actor `{id}`"), + )) + }; + let party = canonical(&party)?; + canonical(&identities::resolve_reference_conn(&transaction, &party)?) }; let assignee = assignee.map(storage_party).transpose()?; let available_to = available_to.into_iter().map(storage_party) diff --git a/crates/fractal-ui-model/src/missions/presentation.rs b/crates/fractal-ui-model/src/missions/presentation.rs index ccf366bc01b..76504bb5b81 100644 --- a/crates/fractal-ui-model/src/missions/presentation.rs +++ b/crates/fractal-ui-model/src/missions/presentation.rs @@ -190,26 +190,45 @@ fn agent(seat: &Seat) -> Actor { } } +fn canonical_seat(mut seat: Seat) -> Option { + let id = fractal_schema::canonical_agent_actor(&seat.id)?; + if !id.starts_with("agent/") { + return None; + } + if let std::borrow::Cow::Owned(id) = id { + seat.id = id; + } + Some(seat) +} + /// The step's seat: its claimant, else its assignee; for a ready unclaimed step, the agent /// queuing it. fn seat_of(step: &MissionStep, fleet: &dyn Fleet) -> Option { if step.agentless { return None; } - let held = step.claimant.as_deref().or(step.assignee.as_deref()); + let held = step + .claimant + .as_deref() + .or(step.assignee.as_deref()) + .and_then(fractal_schema::canonical_agent_actor) + .filter(|id| id.starts_with("agent/")); let seat = held.map(|id| { - fleet.seat(id).unwrap_or_else(|| Seat { - id: id.to_owned(), - label: id.trim_start_matches("agent/").to_owned(), - state: "unknown".into(), - fault: false, - busy_with: None, - }) + fleet + .seat(&id) + .and_then(canonical_seat) + .unwrap_or_else(|| Seat { + label: id.trim_start_matches("agent/").to_owned(), + id: id.into_owned(), + state: "unknown".into(), + fault: false, + busy_with: None, + }) }); match seat { Some(seat) if step.claimant.is_some() || step.state != "ready" => Some(seat), // A ready step waits in whichever queue holds it, whoever it was assigned to. - held => fleet.queued_for(&step.id).or(held), + held => fleet.queued_for(&step.id).and_then(canonical_seat).or(held), } } @@ -228,6 +247,7 @@ pub fn present(mission: &fractal_client::Mission, gates: &[Gate], fleet: &dyn Fl let word = word(mission, &work, !gates.is_empty(), |step| { fleet .queued_for(step) + .and_then(canonical_seat) .map(|seat| QueueOwner { down: seat.down() }) }); let mut steps = work @@ -237,7 +257,8 @@ pub fn present(mission: &fractal_client::Mission, gates: &[Gate], fleet: &dyn Fl let seat = seat_of(step, fleet); let queued = (step.state == "ready" && step.claimant.is_none()) .then(|| fleet.queued_for(&step.id)) - .flatten(); + .flatten() + .and_then(canonical_seat); let note = queued .map(|seat| match &seat.busy_with { Some((mission, path)) if !seat.down() => format!( @@ -313,6 +334,7 @@ pub fn present(mission: &fractal_client::Mission, gates: &[Gate], fleet: &dyn Fl work.iter() .find(|step| step.state == "ready" && step.claimant.is_none()) .and_then(|step| fleet.queued_for(&step.id)) + .and_then(canonical_seat) }) .flatten() .map(|seat| QueuedBehind { diff --git a/crates/fractal-ui-model/src/missions/presentation/tests.rs b/crates/fractal-ui-model/src/missions/presentation/tests.rs index eb8aa09d4e6..38bdc690911 100644 --- a/crates/fractal-ui-model/src/missions/presentation/tests.rs +++ b/crates/fractal-ui-model/src/missions/presentation/tests.rs @@ -209,6 +209,107 @@ fn a_failed_step_offers_a_retry_and_names_its_seat() { assert_eq!(shown.progress, Progress { done: 1, total: 2 }); } +#[test] +fn legacy_assignees_and_claimants_resolve_the_canonical_roster_seat() { + let mut agents = Agents::default().with("agent/fleet/keeper", "stopped", None, &[]); + agents.seats.get_mut("agent/fleet/keeper").unwrap().label = "Roster keeper".into(); + for holder in [ + json!({"assignee": "fleet/keeper"}), + json!({"claimant": "fleet/keeper", "assignee": "agent/fleet/other"}), + ] { + let mission = mission("running", vec![step("fix", "failed", holder)]); + let shown = present(&mission, &[], &agents); + let owner = Actor::Agent { + agent: "agent/fleet/keeper".into(), + label: "Roster keeper".into(), + }; + assert_eq!(shown.who, owner); + assert_eq!(shown.steps[0].owner, owner); + assert_eq!( + shown.stuck.unwrap().agent.unwrap(), + SeatRef { + id: "agent/fleet/keeper".into(), + label: "Roster keeper".into(), + state: "stopped".into(), + } + ); + for action in shown + .actions + .iter() + .filter(|action| matches!(action.id, Offer::RestartAgent | Offer::Chat)) + { + assert_eq!(action.target, "agent/fleet/keeper"); + } + } +} + +#[test] +fn legacy_assignees_and_claimants_have_canonical_fallbacks() { + for holder in [ + json!({"assignee": "fleet/keeper"}), + json!({"claimant": "fleet/keeper"}), + ] { + let mission = mission("running", vec![step("fix", "failed", holder)]); + let shown = present(&mission, &[], &Agents::default()); + let owner = Actor::Agent { + agent: "agent/fleet/keeper".into(), + label: "fleet/keeper".into(), + }; + assert_eq!(shown.who, owner); + assert_eq!(shown.steps[0].owner, owner); + assert_eq!( + shown.stuck.unwrap().agent.unwrap().id, + "agent/fleet/keeper" + ); + } +} + +#[test] +fn legacy_queued_seats_emit_canonical_agent_ids() { + let mission = mission("running", vec![step("build", "ready", json!({}))]); + let agents = Agents::default().with( + "fleet/keeper", + "running", + Some(("mission/fleet/example/ship", "deploy")), + &["step-run/audit/build"], + ); + let shown = present(&mission, &[], &agents); + let owner = Actor::Agent { + agent: "agent/fleet/keeper".into(), + label: "fleet/keeper".into(), + }; + assert_eq!(shown.word, Word::Queued); + assert_eq!(shown.who, owner); + assert_eq!(shown.steps[0].owner, owner); + assert_eq!(shown.stuck.unwrap().agent.unwrap().id, "agent/fleet/keeper"); + assert_eq!(shown.queued_behind.unwrap().agent, "agent/fleet/keeper"); +} + +#[test] +fn invalid_or_non_agent_holders_never_emit_agent_actors() { + for id in ["", "keeper", "fleet/keep er", "agent/", "person/avery", "daemon/st"] { + for holder in [json!({"assignee": id}), json!({"claimant": id})] { + let mission = mission("running", vec![step("fix", "failed", holder)]); + let agents = Agents::default().with(id, "stopped", None, &[]); + let shown = present(&mission, &[], &agents); + assert_eq!(shown.who, Actor::Nobody, "{id}"); + assert_eq!(shown.steps[0].owner, Actor::Nobody, "{id}"); + assert_eq!(shown.stuck.unwrap().agent, None, "{id}"); + } + let mission = mission("running", vec![step("build", "ready", json!({}))]); + let agents = Agents::default().with( + id, + "running", + Some(("mission/fleet/example/ship", "deploy")), + &["step-run/audit/build"], + ); + let shown = present(&mission, &[], &agents); + assert_eq!(shown.who, Actor::Nobody, "{id}"); + assert_eq!(shown.steps[0].owner, Actor::Nobody, "{id}"); + assert_eq!(shown.queued_behind, None, "{id}"); + } +} + #[test] fn working_names_who_holds_it_and_watching_is_st() { let working = mission( diff --git a/crates/fractal/tests/client_v0_contract.rs b/crates/fractal/tests/client_v0_contract.rs index b2553017af0..7e18d5f86d2 100644 --- a/crates/fractal/tests/client_v0_contract.rs +++ b/crates/fractal/tests/client_v0_contract.rs @@ -3618,6 +3618,17 @@ async fn step_states_in_every_client_projection_belong_to_the_contract() { .unwrap(); } } + // Older stores can retain slash-qualified assignees without the actor prefix. + let legacy_step = run.steps.iter().find(|step| step.step == "ready").unwrap(); + let connection = rusqlite::Connection::open(root.path().join("claims.sqlite3")).unwrap(); + assert_eq!( + connection.execute( + "UPDATE step_runs SET assignee = ?1 WHERE subject = ?2", + rusqlite::params!["legacy/builder", legacy_step.subject], + ).unwrap(), + 1 + ); + drop(connection); let app = fractal_daemon::api::router(state); for path in ["/v1/client/missions", "/v1/client/missions/state-contract"] { let (status, response) = client_json(app.clone(), path).await; @@ -3644,6 +3655,30 @@ async fn step_states_in_every_client_projection_belong_to_the_contract() { .iter() .any(|step| step["state"] == "claimed") ); + // Use the same presentation emitter as the published mission cards, not a hand-built + // presentation fixture, and validate the enclosing resource including /presentation. + let mut card = mission.clone(); + fractal_daemon::api::published_lists::present(&mut card, &[], Some(&Default::default())); + assert!(card["presentation"].is_object(), "{card}"); + assert_conforms(&contract_validator("Mission"), path, &card); + let presented_steps = card["presentation"]["steps"].as_array().unwrap(); + assert_eq!(presented_steps.len(), states.len()); + for step in presented_steps { + assert_eq!(step["owner"]["kind"], "agent", "{step}"); + assert_conforms( + &contract_validator("AgentId"), + "/presentation/steps/*/owner/agent", + &step["owner"]["agent"], + ); + } + let legacy = presented_steps.iter().find(|step| step["path"] == "ready").unwrap(); + assert_eq!(legacy["owner"]["agent"], "agent/legacy/builder"); + let mut invalid = card.clone(); + invalid["presentation"]["steps"][0]["owner"]["agent"] = serde_json::json!("legacy/builder"); + assert!( + !contract_validator("Mission").is_valid(&invalid), + "strict /presentation AgentId contract must reject an unprefixed actor" + ); } let (status, response) = client_json(app.clone(), "/v1/client/work?history=true").await; assert_eq!(status, StatusCode::OK, "{response}"); diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json new file mode 100644 index 00000000000..59d79ad364f --- /dev/null +++ b/release-notes/mission-agentid-canonical.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Emit canonical agent identities in mission presentation and admit canonical mission work actors", + "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read."}, + "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, + "rules": {"status": "changed", "detail": "Mission assignees, run-start actors and work actions canonicalize slash-qualified agent identities. Work eligibility compares canonical identities and malformed work actors are rejected."}, + "compatibility": {"status": "changed", "detail": "Legacy unprefixed assignees and claimants resolve canonical roster seats and emit agent-prefixed presentation identities, so strict clients retain mission rows."}, + "downtime": {"status": "none", "detail": "No data migration or coordinated client rollout is required."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "Existing transaction rollback and lease fencing remain unchanged."} +} From 3f969ab271efafb60118f1d27eaeb2478d1e33c3 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:24:42 +0100 Subject: [PATCH 002/132] fix(missions): canonicalize work filters and explicit fixture assignments agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-client-v0/src/api/client_v0.rs | 2 +- .../src/api/published_lists_tests.rs | 4 ++++ crates/fractal-missions/src/store/tests.rs | 8 ++++---- crates/fractal-reconcile/src/reconcile.rs | 14 +++++++------- .../src/reconcile/tests/differential.rs | 2 +- .../tests/final_cancel_missing_assignee.rs | 2 +- crates/fractal-seats/src/store/tests.rs | 4 ++-- .../src/store/tests_shared_missions.rs | 4 ++-- crates/fractal-store/src/store/work_list.rs | 11 ++++++----- crates/fractal/tests/client_v0_cli.rs | 8 ++++---- crates/fractal/tests/client_v0_contract.rs | 9 +++++---- 11 files changed, 37 insertions(+), 31 deletions(-) diff --git a/crates/fractal-api-client-v0/src/api/client_v0.rs b/crates/fractal-api-client-v0/src/api/client_v0.rs index 7d91d643362..8c82a73292d 100644 --- a/crates/fractal-api-client-v0/src/api/client_v0.rs +++ b/crates/fractal-api-client-v0/src/api/client_v0.rs @@ -16635,7 +16635,7 @@ mission "example/looped" state="ready" { let root = tempfile::tempdir().unwrap(); let state = test_state_named(root.path(), "live-run-node"); let steps = (0..24).map(|i| format!( - " step \"step-{i:02}\" {{ assigned-to \"agent/worker-{i:02}\" }}\n" + " step \"step-{i:02}\" {{ assigned-to \"agent/live-run-node.worker-{i:02}\" }}\n" )).collect::(); let source = format!( "version 2\nmission \"example/live\" state=\"ready\" {{\n goal \"Keep live metadata.\"\n concurrent-runs max=10\n{steps}}}" diff --git a/crates/fractal-api-core/src/api/published_lists_tests.rs b/crates/fractal-api-core/src/api/published_lists_tests.rs index f08a29010f5..3402b68d218 100644 --- a/crates/fractal-api-core/src/api/published_lists_tests.rs +++ b/crates/fractal-api-core/src/api/published_lists_tests.rs @@ -287,6 +287,10 @@ fn fold_work_checked(store: &Store) -> (Arc>, bool) { } } } + let (legacy, _) = publication.rows.actor_rows("garden/birch"); + let (canonical, built) = publication.rows.actor_rows("agent/garden/birch"); + assert!(!built, "a canonical actor reuses its legacy filter's order"); + assert!(Arc::ptr_eq(&legacy, &canonical), "both spellings share one actor order"); (publication, changed) } diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index c23da913bde..0443cc2c702 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -5096,7 +5096,7 @@ version 2 mission "execution-timing" state="ready" { goal "Measure only active worker execution." step "work" timeout="1h" { - assigned-to "agent/worker" + assigned-to "agent/source.worker" retry { attempts 2 } } } @@ -5210,7 +5210,7 @@ version 2 mission "external-blocker" state="ready" { goal "Wait truthfully for the external host repair." - step "automated-proof" { assigned-to "agent/ios-owner" } + step "automated-proof" { assigned-to "agent/source.ios-owner" } } "#; let intent = crate::graph::parse_test_intent(source, "source").unwrap(); @@ -6422,7 +6422,7 @@ agent "worker" { workspace "/tmp"; command "true" } mission "nested-work" state="ready" { goal "Complete nested work." step "outer" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" mission "work" { goal "Complete the child work." step "first" { } @@ -7060,7 +7060,7 @@ agent "worker" { workspace "/tmp"; command "true" } mission "retry" state="ready" { goal "Retry failed work." step "work" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" retry { attempts 3 } gate "the result is ready" { exists "resource/result" } } diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index 8b1b86a3a86..7514abbe3e9 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -23766,7 +23766,7 @@ agent "worker" { workspace "/tmp"; command "true" } mission "nested-wake" state="ready" { goal "Complete nested work." step "outer" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" mission "work" { goal "Complete the child work." step "first" { } @@ -24500,7 +24500,7 @@ version 2 goal "Complete mission product." completion { when "all-steps-exhausted" } step "publish" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" produces { resource "mission-run/${ST_MISSION_RUN}/change" { kind "custom.st3.product-test" @@ -24580,7 +24580,7 @@ version 2 .append_claim(&ClaimInput { subject: format!("resource/mission-run/{}/change", run.id), kind: "resource.observed".into(), - actor: Some("agent/worker".into()), + actor: Some("agent/node.worker".into()), fields: BTreeMap::from([ ( "kind".into(), @@ -24618,11 +24618,11 @@ version 2 goal "Complete mission bootstrap." completion { when "all-steps-exhausted" } step "compile" { - assigned-to "agent/planner" + assigned-to "agent/node.planner" produces-mission "project/work" } step "execute" { - assigned-to "agent/planner" + assigned-to "agent/node.planner" depends-on { step "compile" completed } uses-mission output-of="compile" } @@ -24859,7 +24859,7 @@ version 2 mission "assignment" state="ready" { goal "Complete mission assignment." - step "work" { assigned-to "agent/worker" } + step "work" { assigned-to "agent/node.worker" } } "#, @@ -30473,7 +30473,7 @@ mission "publish" state="ready" { r#"version 2 mission "asks" state="ready" { goal "Ask a person for a decision while working." - step "work" { assigned-to "agent/asker" } + step "work" { assigned-to "agent/node.asker" } } "#, "asks-mission", diff --git a/crates/fractal-reconcile/src/reconcile/tests/differential.rs b/crates/fractal-reconcile/src/reconcile/tests/differential.rs index 52029007984..6c49ecdf1a5 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/differential.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/differential.rs @@ -48,7 +48,7 @@ mission "diff/flow" state="ready" timeout="2h" { depends-on { step "review" completed } } step "work" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" depends-on { step "prepare" completed } } step "team" { diff --git a/crates/fractal-reconcile/src/reconcile/tests/final_cancel_missing_assignee.rs b/crates/fractal-reconcile/src/reconcile/tests/final_cancel_missing_assignee.rs index bd74867b9a2..e6e4fdd27f7 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/final_cancel_missing_assignee.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/final_cancel_missing_assignee.rs @@ -12,7 +12,7 @@ mission "heron/close-out" state="ready" { gate "work waits" { field "state" "resource/heron-work" is "ready" } } finally { - step "close-out" { assigned-to "agent/heron-requester" } + step "close-out" { assigned-to "agent/node.heron-requester" } } }"#; diff --git a/crates/fractal-seats/src/store/tests.rs b/crates/fractal-seats/src/store/tests.rs index ca230ea139f..41def2cd490 100644 --- a/crates/fractal-seats/src/store/tests.rs +++ b/crates/fractal-seats/src/store/tests.rs @@ -1547,8 +1547,8 @@ version 2 mission "lease" state="ready" { goal "Complete mission lease." - step "work" { assigned-to "agent/worker" } - step "other" { assigned-to "agent/worker" } + step "work" { assigned-to "agent/node.worker" } + step "other" { assigned-to "agent/node.worker" } } "#, diff --git a/crates/fractal-store/src/store/tests_shared_missions.rs b/crates/fractal-store/src/store/tests_shared_missions.rs index 4e78d45b4b3..b0c14a64853 100644 --- a/crates/fractal-store/src/store/tests_shared_missions.rs +++ b/crates/fractal-store/src/store/tests_shared_missions.rs @@ -49,8 +49,8 @@ version 2 mission "carry" state="ready" { goal "Keep claimed work across a revision." - step "work" { assigned-to "agent/worker"; goal "Do the claimed work." } - step "other" { assigned-to "agent/worker"; goal "Use the first goal." } + step "work" { assigned-to "agent/node.worker"; goal "Do the claimed work." } + step "other" { assigned-to "agent/node.worker"; goal "Use the first goal." } } "#; diff --git a/crates/fractal-store/src/store/work_list.rs b/crates/fractal-store/src/store/work_list.rs index 191c074d724..92cab2b908c 100644 --- a/crates/fractal-store/src/store/work_list.rs +++ b/crates/fractal-store/src/store/work_list.rs @@ -357,10 +357,11 @@ impl WorkRows { } /// The rows `actor` sees, as indexes into `order`, in the order the direct read gives it: - /// its ready work by its seat queue at this publication's own cut. Built on the actor's - /// first read of this publication; says whether this read built it. + /// its ready work by its seat queue at this publication's own cut. Legacy slash-qualified + /// agent IDs share the canonical actor's memo. Says whether this read built it. pub fn actor_rows(&self, actor: &str) -> (Arc>, bool) { - let viewer = smallclaims::store::normalize_actor(actor, "agent"); + let viewer = fractal_schema::canonical_agent_actor(actor) + .unwrap_or(std::borrow::Cow::Borrowed(actor)); self.actors.get_or_build(&viewer, || self.actor_order(&viewer)) } @@ -374,8 +375,8 @@ impl WorkRows { .map(|(index, step)| (index as u32, &self.rows[step].view)) .filter(|(_, view)| visible_to(view, viewer)) .collect::>(); - // The direct read hands `select` its own seat string (the actor if it names a kind, else - // `agent/ACTOR`, which is `viewer`) and looks the queue up by the normalized seat. + // The direct read canonicalizes legacy agent IDs before selecting ready work and + // looks the queue up by the same normalized seat. let queue = self.seat_orders.get(&viewer_seat(viewer)).map_or(&[][..], |order| order.as_slice()); let steps = shown .iter() diff --git a/crates/fractal/tests/client_v0_cli.rs b/crates/fractal/tests/client_v0_cli.rs index c5c1ee28c8e..099cb5703e2 100644 --- a/crates/fractal/tests/client_v0_cli.rs +++ b/crates/fractal/tests/client_v0_cli.rs @@ -2857,7 +2857,7 @@ agent "queue-chief" { mission "queued-work" state="ready" { concurrent-runs goal "Give the durable seat one step in each run." - step "work" { assigned-to "agent/queue-seat" } + step "work" { assigned-to "agent/client-v0-cli.queue-seat" } } "#; let intent = fractal_daemon::graph::parse_intent(source, "client-v0-cli").unwrap(); @@ -3147,10 +3147,10 @@ async fn agents_and_missions_show_print_what_the_worker_last_reported() { agent "reporter" { workspace "/tmp"; command "true" } mission "reported-work" state="ready" { goal "Show what the worker said." - step "docs" { assigned-to "agent/reporter" } + step "docs" { assigned-to "agent/client-v0-cli.reporter" } step "build" { title "Build the parser" - assigned-to "agent/reporter" + assigned-to "agent/client-v0-cli.reporter" } } "#; @@ -3339,7 +3339,7 @@ agent "queued-alias-seat" { workspace "/tmp"; command "true" } mission "queued-alias-work" state="ready" { concurrent-runs goal "Give the durable seat one step." - step "work" { assigned-to "agent/queued-alias-seat" } + step "work" { assigned-to "agent/client-v0-cli.queued-alias-seat" } } "#; let intent = fractal_daemon::graph::parse_intent(source, "client-v0-cli").unwrap(); diff --git a/crates/fractal/tests/client_v0_contract.rs b/crates/fractal/tests/client_v0_contract.rs index 7e18d5f86d2..4f5b1c45164 100644 --- a/crates/fractal/tests/client_v0_contract.rs +++ b/crates/fractal/tests/client_v0_contract.rs @@ -3526,7 +3526,7 @@ async fn step_states_in_every_client_projection_belong_to_the_contract() { ]; let steps = states .iter() - .map(|(internal, _)| format!("step {internal:?} {{ assigned-to \"agent/builder\" }}")) + .map(|(internal, _)| format!("step {internal:?} {{ assigned-to \"agent/client-v0-baseline.builder\" }}")) .collect::>() .join("\n"); let source = format!( @@ -3621,6 +3621,7 @@ async fn step_states_in_every_client_projection_belong_to_the_contract() { // Older stores can retain slash-qualified assignees without the actor prefix. let legacy_step = run.steps.iter().find(|step| step.step == "ready").unwrap(); let connection = rusqlite::Connection::open(root.path().join("claims.sqlite3")).unwrap(); + smallclaims::store::projection_digest::register(&connection).unwrap(); assert_eq!( connection.execute( "UPDATE step_runs SET assignee = ?1 WHERE subject = ?2", @@ -3720,7 +3721,7 @@ version 2 agent "ios-owner" { workspace "/tmp"; command "true" } mission "ios-proof" state="ready" { goal "Run the physical simulator proof." - step "automated-proof" { assigned-to "agent/ios-owner" } + step "automated-proof" { assigned-to "agent/client-v0-baseline.ios-owner" } } "#; let intent = fractal_daemon::graph::parse_intent(source, "client-v0-baseline").unwrap(); @@ -3834,7 +3835,7 @@ version 2 agent "release-owner" { workspace "/tmp"; command "true" } mission "release-proof" state="ready" { goal "Publish the release." - step "tag-proof" { assigned-to "agent/release-owner"; goal "Prove the published tag builds." } + step "tag-proof" { assigned-to "agent/client-v0-baseline.release-owner"; goal "Prove the published tag builds." } } "#; let intent = fractal_daemon::graph::parse_intent(source, "client-v0-baseline").unwrap(); @@ -4001,7 +4002,7 @@ agent "queue-seat" { workspace "/tmp"; command "true" } mission "queued-work" state="ready" { concurrent-runs goal "Give the durable seat one step in each run." - step "work" { assigned-to "agent/queue-seat" } + step "work" { assigned-to "agent/client-v0-baseline.queue-seat" } } "#; let intent = fractal_daemon::graph::parse_intent(source, "client-v0-baseline").unwrap(); From fa63ecc9c8a6fa27feca761dbc956871095b8dfa Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:31:27 +0100 Subject: [PATCH 003/132] fix(missions): canonicalize legacy client actors and queue selection agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-client-v0/src/api/client_v0.rs | 10 +++++----- crates/fractal-api-core/src/api.rs | 13 ++++++------- .../src/api/published_lists_tests.rs | 5 +++++ crates/fractal-missions/src/store/tests.rs | 12 ++++++------ .../fractal-missions/src/store/work_renew_tests.rs | 2 +- crates/fractal-reconcile/src/reconcile.rs | 4 ++-- docs/st3/large-value-sites.json | 1 + 7 files changed, 26 insertions(+), 21 deletions(-) diff --git a/crates/fractal-api-client-v0/src/api/client_v0.rs b/crates/fractal-api-client-v0/src/api/client_v0.rs index 8c82a73292d..cb6116cf705 100644 --- a/crates/fractal-api-client-v0/src/api/client_v0.rs +++ b/crates/fractal-api-client-v0/src/api/client_v0.rs @@ -3279,8 +3279,8 @@ fn mission_resources_filtered( json!({ "id": step.subject, "title": step.title, - "assignee": step.assigned_to, - "claimant": step.claimant, + "assignee": step.assigned_to.as_deref().and_then(fractal_schema::canonical_agent_actor), + "claimant": step.claimant.as_deref().and_then(fractal_schema::canonical_agent_actor), "state": client_work_state(&step.status), "since": client_timestamp(step.updated_at_unix_ms), "progress_at": step.progress_at_unix_ms.map(client_timestamp), @@ -3356,7 +3356,7 @@ fn mission_resources_filtered( && !matches!(step.status.as_str(), "completed" | "failed" | "cancelled" | "skipped") }).map(|wake| { json!({ - "assignee": wake.assignee, + "assignee": fractal_schema::canonical_agent_actor(&wake.assignee), "assignee_state": wake.assignee_state, "incarnation_id": wake.incarnation_id, "attempts": wake.attempts, @@ -3371,8 +3371,8 @@ fn mission_resources_filtered( "title": step.title, "state": client_work_state(&step.status), "attempt": step.attempt, - "assignee": step.assigned_to, - "claimant": step.claimant, + "assignee": step.assigned_to.as_deref().and_then(fractal_schema::canonical_agent_actor), + "claimant": step.claimant.as_deref().and_then(fractal_schema::canonical_agent_actor), "agentless": step.agentless, "keep_open": crate::mission::step_keeps_open(definition.map(|(definition, _)| definition), &step.step), "since": client_timestamp(step.updated_at_unix_ms), diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index e34a2e9e484..75b10c202a8 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -2730,11 +2730,10 @@ pub fn client_work_resources( // that chooses its next work and wake. let seat_ready = match actor { Some(actor) => { - let seat = if actor.contains('/') { - actor.to_owned() - } else { - format!("agent/{actor}") - }; + // Match the canonical actor used by the store's work filter before selecting + // ready steps; a legacy slash-qualified ID is not itself a seat actor. + let seat = fractal_schema::canonical_agent_actor(actor) + .unwrap_or(std::borrow::Cow::Borrowed(actor)); let order = store.seat_run_order(&seat)?; let steps = work .iter() @@ -2963,7 +2962,7 @@ fn client_work_values( "definition_id": work.definition_hash, "path": work.step, "title": work.title, - "assigned_to": work.assigned_to, + "assigned_to": work.assigned_to.as_deref().and_then(fractal_schema::canonical_agent_actor), "last_progress": work.progress_summary, "progress_at": work.progress_at_unix_ms.map(client_timestamp), "state": state, @@ -2971,7 +2970,7 @@ fn client_work_values( "gate_kind": gate_kind, "attempt": work.attempt, "readiness_epoch": work.readiness_epoch, - "claimant": work.claimant, + "claimant": work.claimant.as_deref().and_then(fractal_schema::canonical_agent_actor), "claim_incarnation": work.claim_incarnation, "submitted_incarnation": work.submitted_incarnation, "claim_expires_at_unix_ms": work.claim_expires_at_unix_ms, diff --git a/crates/fractal-api-core/src/api/published_lists_tests.rs b/crates/fractal-api-core/src/api/published_lists_tests.rs index 3402b68d218..26940e98c03 100644 --- a/crates/fractal-api-core/src/api/published_lists_tests.rs +++ b/crates/fractal-api-core/src/api/published_lists_tests.rs @@ -287,6 +287,11 @@ fn fold_work_checked(store: &Store) -> (Arc>, bool) { } } } + assert_eq!( + work_oracle(store, time, Some("garden/birch"), usize::MAX), + work_oracle(store, time, Some("agent/garden/birch"), usize::MAX), + "legacy and canonical actor filters preserve the same direct seat-queue order" + ); let (legacy, _) = publication.rows.actor_rows("garden/birch"); let (canonical, built) = publication.rows.actor_rows("agent/garden/birch"); assert!(!built, "a canonical actor reuses its legacy filter's order"); diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index 0443cc2c702..ede2a24ace0 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -5589,11 +5589,11 @@ version 2 mission "terminal-owner" state="ready" { goal "Make terminal ownership authoritative." - step "active" { assigned-to "agent/worker" } - step "expired" { assigned-to "agent/worker" } - step "ready" { assigned-to "agent/worker" } - step "blocked" { assigned-to "agent/worker" } - step "pending" { assigned-to "agent/worker" } + step "active" { assigned-to "agent/source.worker" } + step "expired" { assigned-to "agent/source.worker" } + step "ready" { assigned-to "agent/source.worker" } + step "blocked" { assigned-to "agent/source.worker" } + step "pending" { assigned-to "agent/source.worker" } } "#; @@ -8465,7 +8465,7 @@ agent "worker" { workspace "/tmp"; command "true" } mission "summaries" state="ready" { goal "Show what the worker reported." step "work" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" retry { attempts 2 } gate "the result is ready" { exists "resource/result" } } diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index 2ba4ba5dec5..d41179ca460 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -517,7 +517,7 @@ fn work_claim_canonicalizes_legacy_actor_and_stored_assignee() { let step = ready_step_with_mail(0); // Model both current rows and persisted rows predating actor canonicalization. step.store.connection.write().execute( - "UPDATE step_runs SET assignee=?2 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-h755979e290e9 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2 WHERE subject=?1", params![step.subject, assignee], ).unwrap(); assert_eq!( diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index 7514abbe3e9..b53eac5a1b0 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -29463,7 +29463,7 @@ mission "proof" state="ready" {{ goal "Land the change." completion {{ when "all-steps-exhausted" }} step "land" {{ - assigned-to "agent/worker" + assigned-to "agent/node.worker" goal "Land the change on main." gate "the suite passes on main" {{ exec {gate:?} @@ -34664,7 +34664,7 @@ mission "absent-stop" state="ready" { mission "release" state="ready" { goal "Complete mission release." step "publish" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" depends-on { field "decision" "resource/approval" "is" "approve" } diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 16da371d466..d20152b6a04 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -1532,6 +1532,7 @@ {"id": "lv-h5c4c48063d7f", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "5b561fcc182b1026f663954fd63a044a0fbbfb912c11dfc59ba6402df925f799", "owner": "fractal_missions::store::work_renew_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 287, "excerpt": "query , [ subject ] , | row | row . get ( 0 )"}, {"id": "lv-h70d2d054ccd8", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d564ed6ae137d4958c7f0751a4a1e5eac72c732ba498342d49bb3f7f9e06849c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 350, "excerpt": "UPDATE step_runs SET status=?2,lease_expires_at_unix_ms=?3 WHERE subject=?1"}, {"id": "lv-h72871ed13568", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1d84c831a4aa0ece376bf0e40064fb0bd36cd58e7f04211c859df103c1e40e8c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 367, "excerpt": "UPDATE step_runs SET status=?2 WHERE subject=?1"}, + {"id": "lv-h755979e290e9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 520, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, {"id": "lv-haa4a50f8ccd9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "5a9caadd18a47cc8540d557e3d5eead5e9f21cf15b57a415bfb07cfeddbc56b6", "owner": "fractal_missions::store::work_renew_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 322, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {}\" , last_replicated_lease_query ( ) )"}, {"id": "lv-haebfd1e4985b", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "004b63826474bc335db8f27b5d87f4a6dff9a32f4303f80ce1d7e3f10e5bee0d", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 138, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?2, updated_at_unix_ms=?3 WHERE subject=?1"}, {"id": "lv-hc363ee66ca25", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "cb91d38d30c6a413535ed4934c5d9f94b13f9574b4b2de6d6f1ba51b8f0156f9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 121, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?2 WHERE subject=?1"}, From 62bb716d4b75cb7e7728a60f34d496cc7fb54b63 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:05:25 +0100 Subject: [PATCH 004/132] fix(missions): complete canonical mission card and fixture cutover agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-core/src/api/client_v0.rs | 4 ++- crates/fractal-missions-base/src/mission.rs | 6 ++-- crates/fractal-missions/src/store/tests.rs | 28 +++++++-------- crates/fractal-schema/src/lib.rs | 8 ++--- .../src/missions/presentation/tests.rs | 36 +++++++++---------- 5 files changed, 42 insertions(+), 40 deletions(-) diff --git a/crates/fractal-api-core/src/api/client_v0.rs b/crates/fractal-api-core/src/api/client_v0.rs index 47582be1f22..8deb5a73af7 100644 --- a/crates/fractal-api-core/src/api/client_v0.rs +++ b/crates/fractal-api-core/src/api/client_v0.rs @@ -1022,7 +1022,9 @@ pub fn mission_list_cards_with( else if steps.iter().any(|s| s.status=="blocked") {"blocked"} else {"system"}; let shown=steps.iter().map(|step| json!({ "id":step.subject,"path":step.step,"title":step.title,"state":client_work_state(&step.status), - "attempt":step.attempt,"assignee":step.assigned_to,"claimant":step.claimant, + "attempt":step.attempt, + "assignee":step.assigned_to.as_deref().and_then(fractal_schema::canonical_agent_actor), + "claimant":step.claimant.as_deref().and_then(fractal_schema::canonical_agent_actor), "agentless":step.agentless,"since":client_timestamp(step.updated_at_unix_ms), "started_at":timing.get(step.subject.as_str()).and_then(|times| times.0), "completed_at":timing.get(step.subject.as_str()).and_then(|times| times.1), diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index bbfebda9b57..27b74cefc14 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -3131,15 +3131,15 @@ mod tests { #[test] fn assignees_canonicalize_legacy_slash_ids() { for (input, expected) in [ - ("fleet/worker", "agent/fleet/worker"), - ("agent/fleet/worker", "agent/fleet/worker"), + ("example/worker", "agent/example/worker"), + ("agent/example/worker", "agent/example/worker"), ("person/reviewer", "person/reviewer"), ("daemon/scheduler", "daemon/scheduler"), ("worker", "agent/host.worker"), ] { assert_eq!(super::normalize_assignee(input, "host").unwrap(), expected); } - assert!(super::normalize_assignee("fleet/worker name", "host").is_err()); + assert!(super::normalize_assignee("example/worker name", "host").is_err()); } #[test] diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index ede2a24ace0..5f8d20c0474 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -566,7 +566,7 @@ mission "clock-parent" state="ready" { mission "clock-worker" state="ready" { goal "Start the loop clock when work is claimed." concurrent-runs max=20 - step "work" { assigned-to "agent/worker" } + step "work" { assigned-to "agent/node.worker" } step "automatic" { agentless } } mission "clock-automatic" state="ready" { @@ -655,7 +655,7 @@ mission "clock-automatic" state="ready" { .append_claim(&ClaimInput { subject: step.subject.clone(), kind: "work.claimed".into(), - actor: Some("agent/worker".into()), + actor: Some("agent/node.worker".into()), fields: BTreeMap::from([("attempt".into(), Value::from(index + 1))]), evidence: Vec::new(), expected_subject: None, @@ -1099,10 +1099,10 @@ version 2 mission "held-work" state="ready" { goal "Hold some steps." concurrent-runs max=2 - step "active" { assigned-to "agent/worker" } - step "expired" { assigned-to "agent/worker" } - step "ready" { assigned-to "agent/worker" } - step "pending" { assigned-to "agent/worker" } + step "active" { assigned-to "agent/node.worker" } + step "expired" { assigned-to "agent/node.worker" } + step "ready" { assigned-to "agent/node.worker" } + step "pending" { assigned-to "agent/node.worker" } step "watch" { agentless } } @@ -5797,7 +5797,7 @@ fn terminal_roots_reap_nested_orphans_idempotently_across_restart_and_replicatio r#"version 2 mission "tree-child" state="ready" { goal "Expose nested work." - step "work" { assigned-to "agent/worker" } + step "work" { assigned-to "agent/source.worker" } }"#, "terminal-tree-child", ); @@ -6331,7 +6331,7 @@ fn an_active_nested_lease_can_publish_its_parent_mission_output() { mission "bootstrap" state="ready" { goal "Complete mission bootstrap." step "compile" { - assigned-to "agent/planner" + assigned-to "agent/node.planner" produces-mission "project/work" mission "work" { goal "Complete mission work."; step "publish" { } } } @@ -8589,7 +8589,7 @@ version 2 mission "revision" state="ready" { goal "Complete mission revision." agent "worker" { workspace "."; command "true" } - step "owned" { assigned-to "agent/worker"; goal "First goal." } + step "owned" { assigned-to "agent/${ST_MISSION_RUN}/worker"; goal "First goal." } step "unrelated" { } step "join" { depends-on { step "owned" completed; step "unrelated" completed } } } @@ -8622,7 +8622,7 @@ version 2 goal "Complete mission revision." agent "worker" { workspace "."; command "true" } step "owned" { - assigned-to "agent/worker" + assigned-to "agent/${ST_MISSION_RUN}/worker" goal "First goal." constraint "Do not push." } @@ -9043,7 +9043,7 @@ fn revise_notes_carry_progress_for_active_and_completed_steps() { let store = Store::open_memory("node").unwrap(); let source = CARRY_SOURCE.replace( "step \"other\"", - "step \"done\" { assigned-to \"agent/worker\"; goal \"Finish the work.\" }\n step \"other\"", + "step \"done\" { assigned-to \"agent/node.worker\"; goal \"Finish the work.\" }\n step \"other\"", ); publish_carry(&store, &source, "notes-one"); let run = store @@ -9359,7 +9359,7 @@ version 2 mission "carry" state="ready" {{ goal "Keep submitted work across a gate revision." step "work" {{ - assigned-to "agent/worker" + assigned-to "agent/node.worker" goal {goal:?} gate "checked" {{ exec {gate:?}; host "node"; workspace "/tmp" }} }} @@ -9570,7 +9570,7 @@ version 2 goal "Test protected revision." agent "worker" {{ workspace "."; command "true" }} step "work" revisions="human-only" revision-reviewer="person/step-reviewer" {{ - assigned-to "agent/worker" + assigned-to "agent/${{ST_MISSION_RUN}}/worker" goal {goal:?} }} }} @@ -9883,7 +9883,7 @@ version 2 agent "worker" { workspace "."; command "true" } mission "drain-child" state="ready" { goal "Keep descendant work in the idle boundary." - step "active" { assigned-to "agent/worker" } + step "active" { assigned-to "agent/node.worker" } } "#; diff --git a/crates/fractal-schema/src/lib.rs b/crates/fractal-schema/src/lib.rs index 3fb79d819d5..1370088a7e9 100644 --- a/crates/fractal-schema/src/lib.rs +++ b/crates/fractal-schema/src/lib.rs @@ -56,14 +56,14 @@ mod canonical_agent_actor_tests { #[test] fn preserves_actor_kinds_and_prefixes_legacy_agent_ids() { - for id in ["agent/fleet/worker", "person/reviewer", "daemon/scheduler"] { + for id in ["agent/example/worker", "person/reviewer", "daemon/scheduler"] { assert_eq!(canonical_agent_actor(id).as_deref(), Some(id)); } assert_eq!( - canonical_agent_actor("fleet/worker").as_deref(), - Some("agent/fleet/worker") + canonical_agent_actor("example/worker").as_deref(), + Some("agent/example/worker") ); - for id in ["", "worker", "agent/", "person/", "/worker", "fleet/worker name"] { + for id in ["", "worker", "agent/", "person/", "/worker", "example/worker name"] { assert!(canonical_agent_actor(id).is_none(), "{id:?}"); } } diff --git a/crates/fractal-ui-model/src/missions/presentation/tests.rs b/crates/fractal-ui-model/src/missions/presentation/tests.rs index 38bdc690911..16fb6c3cf2c 100644 --- a/crates/fractal-ui-model/src/missions/presentation/tests.rs +++ b/crates/fractal-ui-model/src/missions/presentation/tests.rs @@ -211,16 +211,16 @@ fn a_failed_step_offers_a_retry_and_names_its_seat() { #[test] fn legacy_assignees_and_claimants_resolve_the_canonical_roster_seat() { - let mut agents = Agents::default().with("agent/fleet/keeper", "stopped", None, &[]); - agents.seats.get_mut("agent/fleet/keeper").unwrap().label = "Roster keeper".into(); + let mut agents = Agents::default().with("agent/example/keeper", "stopped", None, &[]); + agents.seats.get_mut("agent/example/keeper").unwrap().label = "Roster keeper".into(); for holder in [ - json!({"assignee": "fleet/keeper"}), - json!({"claimant": "fleet/keeper", "assignee": "agent/fleet/other"}), + json!({"assignee": "example/keeper"}), + json!({"claimant": "example/keeper", "assignee": "agent/example/other"}), ] { let mission = mission("running", vec![step("fix", "failed", holder)]); let shown = present(&mission, &[], &agents); let owner = Actor::Agent { - agent: "agent/fleet/keeper".into(), + agent: "agent/example/keeper".into(), label: "Roster keeper".into(), }; assert_eq!(shown.who, owner); @@ -228,7 +228,7 @@ fn legacy_assignees_and_claimants_resolve_the_canonical_roster_seat() { assert_eq!( shown.stuck.unwrap().agent.unwrap(), SeatRef { - id: "agent/fleet/keeper".into(), + id: "agent/example/keeper".into(), label: "Roster keeper".into(), state: "stopped".into(), } @@ -238,7 +238,7 @@ fn legacy_assignees_and_claimants_resolve_the_canonical_roster_seat() { .iter() .filter(|action| matches!(action.id, Offer::RestartAgent | Offer::Chat)) { - assert_eq!(action.target, "agent/fleet/keeper"); + assert_eq!(action.target, "agent/example/keeper"); } } } @@ -246,20 +246,20 @@ fn legacy_assignees_and_claimants_resolve_the_canonical_roster_seat() { #[test] fn legacy_assignees_and_claimants_have_canonical_fallbacks() { for holder in [ - json!({"assignee": "fleet/keeper"}), - json!({"claimant": "fleet/keeper"}), + json!({"assignee": "example/keeper"}), + json!({"claimant": "example/keeper"}), ] { let mission = mission("running", vec![step("fix", "failed", holder)]); let shown = present(&mission, &[], &Agents::default()); let owner = Actor::Agent { - agent: "agent/fleet/keeper".into(), - label: "fleet/keeper".into(), + agent: "agent/example/keeper".into(), + label: "example/keeper".into(), }; assert_eq!(shown.who, owner); assert_eq!(shown.steps[0].owner, owner); assert_eq!( shown.stuck.unwrap().agent.unwrap().id, - "agent/fleet/keeper" + "agent/example/keeper" ); } } @@ -268,26 +268,26 @@ fn legacy_assignees_and_claimants_have_canonical_fallbacks() { fn legacy_queued_seats_emit_canonical_agent_ids() { let mission = mission("running", vec![step("build", "ready", json!({}))]); let agents = Agents::default().with( - "fleet/keeper", + "example/keeper", "running", Some(("mission/fleet/example/ship", "deploy")), &["step-run/audit/build"], ); let shown = present(&mission, &[], &agents); let owner = Actor::Agent { - agent: "agent/fleet/keeper".into(), - label: "fleet/keeper".into(), + agent: "agent/example/keeper".into(), + label: "example/keeper".into(), }; assert_eq!(shown.word, Word::Queued); assert_eq!(shown.who, owner); assert_eq!(shown.steps[0].owner, owner); - assert_eq!(shown.stuck.unwrap().agent.unwrap().id, "agent/fleet/keeper"); - assert_eq!(shown.queued_behind.unwrap().agent, "agent/fleet/keeper"); + assert_eq!(shown.stuck.unwrap().agent.unwrap().id, "agent/example/keeper"); + assert_eq!(shown.queued_behind.unwrap().agent, "agent/example/keeper"); } #[test] fn invalid_or_non_agent_holders_never_emit_agent_actors() { - for id in ["", "keeper", "fleet/keep er", "agent/", "person/avery", "daemon/st"] { + for id in ["", "keeper", "example/keep er", "agent/", "person/avery", "daemon/st"] { for holder in [json!({"assignee": id}), json!({"claimant": id})] { let mission = mission("running", vec![step("fix", "failed", holder)]); let agents = Agents::default().with(id, "stopped", None, &[]); From 9a4bd69ba15cb6be21fda6cbee58e7397d35bef9 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:21:03 +0100 Subject: [PATCH 005/132] fix(missions): preserve historical assignees when reparsing declarations agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions-base/src/mission.rs | 57 +++++++++++----- crates/fractal-reconcile/src/reconcile.rs | 70 ++++++++++++++++++++ release-notes/mission-agentid-canonical.json | 2 +- 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index 27b74cefc14..e9c8f518fb4 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -368,10 +368,10 @@ fn parse_mission( format!("mission `{id}` repeats `assigned-to`"), )); } - assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)?); + assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)); } "available-to" => { - let agent = normalize_assignee(&first_string(child)?, default_host)?; + let agent = normalize_assignee(&first_string(child)?, default_host); if available_to.contains(&agent) { return Err(FractalError::new( "duplicate-work-agent", @@ -1378,10 +1378,10 @@ fn parse_step( baselines.push(baseline); } "assigned-to" => { - assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)?) + assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)) } "available-to" => { - let agent = normalize_assignee(&first_string(child)?, default_host)?; + let agent = normalize_assignee(&first_string(child)?, default_host); if available_to.contains(&agent) { return Err(FractalError::new( "duplicate-work-agent", @@ -1569,10 +1569,10 @@ fn parse_queue( format!("queue `{id}` repeats `assigned-to`"), )); } - assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)?); + assigned_to = Some(normalize_assignee(&first_string(child)?, default_host)); } "available-to" => { - let agent = normalize_assignee(&first_string(child)?, default_host)?; + let agent = normalize_assignee(&first_string(child)?, default_host); if available_to.contains(&agent) { return Err(FractalError::new( "duplicate-work-agent", @@ -2684,17 +2684,23 @@ fn normalize_subject(value: &str, kind: &str) -> String { } } -fn normalize_assignee(value: &str, default_host: &str) -> Result { - let qualified = if value.contains('/') { - std::borrow::Cow::Borrowed(value) - } else if value.contains('.') || value.contains("${") { - std::borrow::Cow::Owned(format!("agent/{value}")) +fn normalize_assignee(value: &str, default_host: &str) -> String { + if let Some(canonical) = fractal_schema::canonical_agent_actor(value) { + return canonical.into_owned(); + } + // Stored declarations are parsed again during reconciliation. Preserve their historical + // normalization when an actor cannot be canonicalized; writers validate new work actors. + if value.starts_with("person/") { + return value.to_owned(); + } + let identity = value.strip_prefix("agent/").unwrap_or(value); + if identity.contains('.') || identity.contains("${") { + format!("agent/{identity}") + } else if identity.contains('/') { + value.to_owned() } else { - std::borrow::Cow::Owned(format!("agent/{default_host}.{value}")) - }; - fractal_schema::canonical_agent_actor(&qualified) - .map(std::borrow::Cow::into_owned) - .ok_or_else(|| FractalError::new("invalid-assignee", format!("invalid agent actor `{value}`"))) + format!("agent/{default_host}.{identity}") + } } fn validate_id(value: &str, kind: &str) -> Result<(), FractalError> { @@ -3137,9 +3143,24 @@ mod tests { ("daemon/scheduler", "daemon/scheduler"), ("worker", "agent/host.worker"), ] { - assert_eq!(super::normalize_assignee(input, "host").unwrap(), expected); + assert_eq!(super::normalize_assignee(input, "host"), expected); + } + } + + #[test] + fn stored_assignees_preserve_historical_malformed_normalization() { + for (input, expected) in [ + ("agent/", "agent/host."), + ("", "agent/host."), + ("worker name", "agent/host.worker name"), + ("example/worker name", "example/worker name"), + ("agent/example/worker name", "agent/example/worker name"), + ("example/worker.name ", "agent/example/worker.name "), + ("person/reviewer name", "person/reviewer name"), + ("person/", "person/"), + ] { + assert_eq!(super::normalize_assignee(input, "host"), expected); } - assert!(super::normalize_assignee("example/worker name", "host").is_err()); } #[test] diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index b53eac5a1b0..10b50d16eb5 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -24607,6 +24607,76 @@ version 2 ); } + #[test] + fn stored_mission_declarations_preserve_legacy_invalid_assignee_normalization() { + let store = Arc::new(Store::open_memory("node").unwrap()); + let source = r#"version 2 +mission "legacy-declarations" state="ready" { + goal "Materialize declarations stored before canonical agent IDs." + step "wait" { agentless } +} +"#; + let declarations = r#"version 2 +mission "legacy-child" state="ready" { + goal "Preserve historical assignee normalization." + step "empty-agent-name" { assigned-to "agent/" } + step "whitespace" { assigned-to "example/worker name" } +} +"#; + // Persist the historical declaration source without admitting it through today's parser. + let mut intent = parse_intent(source, "node").unwrap(); + intent.missions.get_mut("legacy-declarations").unwrap().declarations_kdl = + Some(declarations.into()); + let planned = store + .mission( + &intent, + crate::model::IntentInput { + kdl: source.into(), + source_name: None, + }, + ) + .unwrap(); + store + .apply(&intent, &planned.subject_tokens, "publish-legacy-declarations") + .unwrap(); + let mission = store + .mission_spec("legacy-declarations", None) + .unwrap() + .unwrap(); + assert_eq!(mission.declarations_kdl.as_deref(), Some(declarations)); + let run = store + .create_mission_run(&crate::model::MissionRunRequest { + mission: "legacy-declarations".into(), + revision: None, + workspace: "/tmp".into(), + requester: Some("person/test".into()), + mode: Some("run".into()), + inputs: BTreeMap::new(), + idempotency_key: "run-legacy-declarations".into(), + }) + .unwrap(); + let reconciler = Reconciler::new( + store.clone(), + Arc::new(FakeRuntime::default()), + "node".into(), + Arc::new(Notify::new()), + ); + assert!( + reconciler + .materialize_mission_declarations(&run, &mission, None) + .unwrap() + ); + let nested = store.mission_spec("legacy-child", None).unwrap().unwrap(); + assert_eq!( + nested.steps["empty-agent-name"].assigned_to.as_deref(), + Some("agent/node.") + ); + assert_eq!( + nested.steps["whitespace"].assigned_to.as_deref(), + Some("example/worker name") + ); + } + #[test] fn a_step_uses_the_exact_mission_revision_produced_by_an_earlier_step() { let store = Arc::new(Store::open_memory("node").unwrap()); diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json index 59d79ad364f..40d466f15de 100644 --- a/release-notes/mission-agentid-canonical.json +++ b/release-notes/mission-agentid-canonical.json @@ -1,7 +1,7 @@ { "version": 1, "summary": "Emit canonical agent identities in mission presentation and admit canonical mission work actors", - "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read."}, + "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical assignee normalization for malformed values rather than rejecting them."}, "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, "rules": {"status": "changed", "detail": "Mission assignees, run-start actors and work actions canonicalize slash-qualified agent identities. Work eligibility compares canonical identities and malformed work actors are rejected."}, "compatibility": {"status": "changed", "detail": "Legacy unprefixed assignees and claimants resolve canonical roster seats and emit agent-prefixed presentation identities, so strict clients retain mission rows."}, From 99f571e5ba8b13d091ff64c574a8d6818c86a48b Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:27:46 +0100 Subject: [PATCH 006/132] test(missions): assert stored declaration work selectors agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-reconcile/src/reconcile.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index 10b50d16eb5..fb760cf4d23 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -24668,12 +24668,16 @@ mission "legacy-child" state="ready" { ); let nested = store.mission_spec("legacy-child", None).unwrap().unwrap(); assert_eq!( - nested.steps["empty-agent-name"].assigned_to.as_deref(), - Some("agent/node.") + nested.steps["empty-agent-name"].work_selector, + Some(WorkSelector::Assigned { + agent: "agent/node.".into(), + }) ); assert_eq!( - nested.steps["whitespace"].assigned_to.as_deref(), - Some("example/worker name") + nested.steps["whitespace"].work_selector, + Some(WorkSelector::Assigned { + agent: "example/worker name".into(), + }) ); } From 6961e9cf6b47d3a889db35a8bcfe611e6e7d3400 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:34:12 +0100 Subject: [PATCH 007/132] fix(missions): preserve mixed-version actor ownership and comparisons agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../src/api/published_lists_tests.rs | 36 +++ crates/fractal-missions-base/src/mission.rs | 26 +- crates/fractal-missions/src/store.rs | 8 +- .../fractal-missions/src/store/adhoc_work.rs | 2 +- crates/fractal-missions/src/store/missions.rs | 118 +++++---- crates/fractal-missions/src/store/tests.rs | 51 +++- .../src/store/work_renew_tests.rs | 245 +++++++++++++++++- crates/fractal-store/src/store.rs | 34 ++- .../src/store/reconcile_work_context.rs | 8 +- crates/fractal-store/src/store/work_list.rs | 20 +- release-notes/mission-agentid-canonical.json | 6 +- 11 files changed, 468 insertions(+), 86 deletions(-) diff --git a/crates/fractal-api-core/src/api/published_lists_tests.rs b/crates/fractal-api-core/src/api/published_lists_tests.rs index 26940e98c03..a58e26aed12 100644 --- a/crates/fractal-api-core/src/api/published_lists_tests.rs +++ b/crates/fractal-api-core/src/api/published_lists_tests.rs @@ -299,6 +299,42 @@ fn fold_work_checked(store: &Store) -> (Arc>, bool) { (publication, changed) } +#[test] +fn published_work_keeps_persisted_legacy_assignments_and_available_seats_visible() { + let _clock = Clock::at(start_time()); + for available in [false, true] { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("graph.db"); + let store = Store::open(&path, "cedar").unwrap(); + mission(&store, "garden/alpha"); + let run = start(&store, "garden/alpha", "legacy-work-list"); + let (subject, _) = step(&run, "plant"); + store.set_step_state(&subject, "ready", None).unwrap(); + // Retain an actual older member's projection spelling, rather than testing two empty + // actor filters against a row already canonicalized by the current writer. + let connection = rusqlite::Connection::open(&path).unwrap(); + smallclaims::store::projection_digest::register(&connection).unwrap(); + let assignee = (!available).then_some("garden/ash"); + let candidates = if available { r#"["garden/ash"]"# } else { "[]" }; + connection.execute( + "UPDATE step_runs SET assignee=?2, available_to=?3 WHERE subject=?1", + rusqlite::params![subject, assignee, candidates], + ).unwrap(); + drop(connection); + let (publication, _) = fold_work_checked(&store); + for actor in ["garden/ash", "agent/garden/ash"] { + let (indexes, _) = publication.rows.actor_rows(actor); + assert_eq!(indexes.len(), 1, "{actor}, available={available}"); + assert_eq!(publication.rows.order[indexes[0] as usize], subject); + assert_eq!( + work_oracle(&store, publication.rows.time_unix_ms, Some(actor), usize::MAX).0.len(), + 1, + "the persisted legacy row is visible in the direct read too" + ); + } + } +} + #[test] fn published_work_matches_the_direct_read_for_every_actor_as_time_passes() { let started = start_time(); diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index e9c8f518fb4..97e3aafde61 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -2685,6 +2685,12 @@ fn normalize_subject(value: &str, kind: &str) -> String { } fn normalize_assignee(value: &str, default_host: &str) -> String { + // Declaration shorthand is host-relative, including the historical `agent/worker` spelling. + // Qualify it before accepting an already-prefixed actor as canonical. + let identity = value.strip_prefix("agent/").unwrap_or(value); + if !identity.contains('.') && !identity.contains("${") && !identity.contains('/') { + return format!("agent/{default_host}.{identity}"); + } if let Some(canonical) = fractal_schema::canonical_agent_actor(value) { return canonical.into_owned(); } @@ -2693,7 +2699,6 @@ fn normalize_assignee(value: &str, default_host: &str) -> String { if value.starts_with("person/") { return value.to_owned(); } - let identity = value.strip_prefix("agent/").unwrap_or(value); if identity.contains('.') || identity.contains("${") { format!("agent/{identity}") } else if identity.contains('/') { @@ -3142,11 +3147,30 @@ mod tests { ("person/reviewer", "person/reviewer"), ("daemon/scheduler", "daemon/scheduler"), ("worker", "agent/host.worker"), + ("agent/worker", "agent/host.worker"), ] { assert_eq!(super::normalize_assignee(input, "host"), expected); } } + #[test] + fn declaration_assignees_qualify_host_relative_agent_names() { + let source = r#"mission "relative" state="ready" { + goal "Resolve host-relative declaration assignees."; + step "prefixed" { assigned-to "agent/worker"; } + step "bare" { assigned-to "worker"; } + }"#; + let missions = super::parse_missions(&source.parse().unwrap(), "host").unwrap(); + for step in ["prefixed", "bare"] { + assert_eq!( + missions["relative"].steps[step].work_selector, + Some(super::WorkSelector::Assigned { + agent: "agent/host.worker".into(), + }) + ); + } + } + #[test] fn stored_assignees_preserve_historical_malformed_normalization() { for (input, expected) in [ diff --git a/crates/fractal-missions/src/store.rs b/crates/fractal-missions/src/store.rs index e8a12f92160..168e0c20b55 100644 --- a/crates/fractal-missions/src/store.rs +++ b/crates/fractal-missions/src/store.rs @@ -824,7 +824,7 @@ fn agent_working_since_at( Ok(since.and_then(|time| time.parse().ok())) } -/// The current generation's steps of actor `?1` (every actor when null), with finished and +/// The current generation's steps of actor spelling JSON `?1` (every actor when null), with finished and /// pending ones when `?2`, and agentless ones when `?3` and no actor is named. With `open_only` /// the finished steps are left out in SQL, word for word the predicate of /// `step_runs_open_index`, so the read covers the fleet's open steps and not every step it ran. @@ -850,9 +850,9 @@ fn work_at_snapshot_query_of(open_only: bool, selected: &str) -> String { WHERE {selected} (agentless=0 OR (?3 AND ?1 IS NULL)) AND generation_id=(SELECT current_generation_id FROM mission_runs WHERE id=step_runs.run_id) AND (?1 IS NULL - OR assignee=?1 - OR lease_owner=?1 - OR EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value=?1)) + OR assignee IN (SELECT value FROM json_each(?1)) + OR lease_owner IN (SELECT value FROM json_each(?1)) + OR EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value IN (SELECT value FROM json_each(?1)))) AND (?2 OR status NOT IN ('pending','completed','failed','cancelled')) {open} ORDER BY created_at_unix_ms, step_path" diff --git a/crates/fractal-missions/src/store/adhoc_work.rs b/crates/fractal-missions/src/store/adhoc_work.rs index 2f7baa825a1..ddb2e18578d 100644 --- a/crates/fractal-missions/src/store/adhoc_work.rs +++ b/crates/fractal-missions/src/store/adhoc_work.rs @@ -347,7 +347,7 @@ impl AdhocWorkStore for Store { } return Err(FractalError::new("missing-work-actor", "the calling seat needs a live declaration")); } - let active: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM step_runs WHERE lease_owner=?1 AND status IN ('claimed','working','verifying','blocked') AND CAST(lease_expires_at_unix_ms AS INTEGER)>?2)", params![input.actor, now_ms() as i64], |row| row.get(0)).map_err(internal)?; + let active: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM step_runs WHERE lease_owner IN (SELECT value FROM json_each(?1)) AND status IN ('claimed','working','verifying','blocked') AND CAST(lease_expires_at_unix_ms AS INTEGER)>?2)", params![fractal_store::store::work_actor_spellings_json(&input.actor), now_ms() as i64], |row| row.get(0)).map_err(internal)?; if active { return Err(FractalError::new("agent-capacity", "finish or release independent claimed work before starting another run")); } let claim = append_claim_tx(tx, &self.origin, &run, "mission-run.created", Some(&input.actor), &json!({"fields": { "status": "running", "mission": mission.subject, "revision": mission.revision, "initial_revision": mission.revision, diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index d82078cf106..468f45cffcc 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -3,6 +3,7 @@ use super::*; pub use fractal_delivery::store::missions::*; use fractal_store::store::identities::resolve_reference_conn; +use fractal_store::store::{work_actor_matches, work_actor_spellings_json}; mod run_review; #[doc(hidden)] @@ -607,14 +608,14 @@ fn renew_nested_ancestor_leases_tx( WHERE generation_id=?3 AND substr(?4, 1, length(step_path) + 1)=step_path || '/' AND status IN ('claimed','working','verifying') - AND lease_owner=?5 + AND lease_owner IN (SELECT value FROM json_each(?5)) AND lease_incarnation IS ?6", params![ expiry.to_string(), now.to_string(), generation, step_path, - actor, + work_actor_spellings_json(actor), incarnation ], ) @@ -991,9 +992,9 @@ fn carried_claim_successor_tx( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, lease_owner, lease_incarnation, lease_expires_at_unix_ms, blocked_reason, not_before_unix_ms, created_at_unix_ms, updated_at_unix_ms, readiness_epoch, constraints FROM step_runs - WHERE run_id=?1 AND step_path=?2 AND lease_owner=?3 AND generation_id<>?4 + WHERE run_id=?1 AND step_path=?2 AND lease_owner IN (SELECT value FROM json_each(?3)) AND generation_id<>?4 AND generation_id=(SELECT current_generation_id FROM mission_runs WHERE id=?1)", - params![run_id, step.step, actor, generation_id_from_subject(&step.generation)], + params![run_id, step.step, work_actor_spellings_json(actor), generation_id_from_subject(&step.generation)], step_run_from_row, ) .optional() @@ -1011,7 +1012,7 @@ fn mission_output_authority( lease_incarnation: Option<&str>, expiry: Option| { matches!(status, "claimed" | "working") - && owner == Some(actor) + && work_actor_matches(owner, actor) && expiry.is_some_and(|expiry| expiry > now) && lease_incarnation.is_none_or(|lease| Some(lease) == incarnation) }; @@ -5130,25 +5131,26 @@ impl MissionsStore for Store { } fn work_for_renewal(&self, actor: &str) -> Result> { - let actor = normalize_actor(actor, "agent"); let snapshot = now_ms(); let connection = self.readers.get(); - let actor = physical_actor(&connection, &actor)?; + let actor = physical_actor(&connection, actor)?; let views = connection .prepare_cached( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, lease_owner, lease_incarnation, lease_expires_at_unix_ms, blocked_reason, not_before_unix_ms, created_at_unix_ms, updated_at_unix_ms, readiness_epoch, constraints FROM step_runs INDEXED BY step_runs_lease_index - WHERE lease_owner=?1 AND agentless=0 + WHERE lease_owner IN (SELECT value FROM json_each(?1)) AND agentless=0 AND generation_id=(SELECT current_generation_id FROM mission_runs WHERE id=step_runs.run_id) ORDER BY subject", )? - .query_map([&actor], step_run_from_row)? + .query_map([work_actor_spellings_json(&actor)], step_run_from_row)? .collect::>>()?; let mut held = Vec::with_capacity(views.len()); for mut view in views { apply_effective_step_state(&connection, &mut view, snapshot)?; - if view.work_claim_is_held_by(&actor) { + if matches!(view.status.as_str(), "claimed" | "working" | "verifying" | "blocked") + && work_actor_matches(view.claimant.as_deref(), &actor) + { held.push(view); } } @@ -5361,6 +5363,7 @@ impl MissionsStore for Store { ) -> Result> { let connection = self.readers.get(); let actor = actor.map(|value| physical_actor(&connection, value)).transpose()?; + let actor_spellings = actor.as_deref().map(work_actor_spellings_json); let views = match selected { None => { let query = if actor.is_some() { @@ -5370,7 +5373,7 @@ impl MissionsStore for Store { }; let mut statement = connection.prepare_cached(/* sqlite-large-value: allow-for-now lv-hc51ec5c7f089 owner=fractal_missions::store::missions TODO=trace-statement-and-value-representation */ &query)?; let rows = statement.query_map( - params![actor.as_deref(), include_terminal, include_agentless], + params![actor_spellings.as_deref(), include_terminal, include_agentless], step_run_from_row, )?; rows.collect::, _>>()? @@ -5381,7 +5384,7 @@ impl MissionsStore for Store { let mut statement = connection.prepare_cached(/* sqlite-large-value: allow-for-now lv-hfb225899064e owner=fractal_missions::store::missions TODO=trace-statement-and-value-representation */ &query)?; let rows = statement.query_map( params![ - actor.as_deref(), + actor_spellings.as_deref(), include_terminal, include_agentless, serde_json::to_string(selected)? @@ -5440,10 +5443,10 @@ impl MissionsStore for Store { continue; } let visible_to_actor = actor.as_ref().is_none_or(|actor| { - view.assigned_to.as_deref() == Some(actor.as_str()) - || view.claimant.as_deref() == Some(actor.as_str()) + work_actor_matches(view.assigned_to.as_deref(), actor) + || work_actor_matches(view.claimant.as_deref(), actor) || (matches!(view.status.as_str(), "ready" | "blocked") - && view.available_to.iter().any(|candidate| candidate == actor)) + && view.available_to.iter().any(|candidate| work_actor_matches(Some(candidate), actor))) }); if visible_to_actor && (include_terminal @@ -5496,8 +5499,9 @@ impl MissionsStore for Store { limit: usize, after: Option<&(u128, String)>, ) -> Result<(Vec, bool)> { - let actor = actor.map(|value| normalize_actor(value, "agent")); let connection = self.readers.get(); + let actor = actor.map(|value| physical_actor(&connection, value)).transpose()?; + let actor_spellings = actor.as_deref().map(work_actor_spellings_json); // Enrichment can clear a step's claimant, through its effective state alone, but // never changes its assignee, its candidates or its update time. So this finds every // step the actor can see, in the order the history shows them, and a step it matches @@ -5506,17 +5510,17 @@ impl MissionsStore for Store { let Some(actor) = actor.as_deref() else { return Ok(true); }; - if view.assigned_to.as_deref() == Some(actor) - || view.available_to.iter().any(|candidate| candidate == actor) + if work_actor_matches(view.assigned_to.as_deref(), actor) + || view.available_to.iter().any(|candidate| work_actor_matches(Some(candidate), actor)) { return Ok(true); } - if view.claimant.as_deref() != Some(actor) { + if !work_actor_matches(view.claimant.as_deref(), actor) { return Ok(false); } let mut effective = view.clone(); apply_effective_step_state(&connection, &mut effective, snapshot_unix_ms)?; - Ok(effective.claimant.as_deref() == Some(actor)) + Ok(work_actor_matches(effective.claimant.as_deref(), actor)) }; let mut statement = connection.prepare_cached( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, @@ -5526,13 +5530,13 @@ impl MissionsStore for Store { OR (length(updated_at_unix_ms)=length(?2) AND updated_at_unix_ms?3)) AND (agentless=0 OR ?1 IS NULL) - AND (?1 IS NULL OR assignee=?1 OR lease_owner=?1 - OR EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value=?1)) + AND (?1 IS NULL OR assignee IN (SELECT value FROM json_each(?1)) OR lease_owner IN (SELECT value FROM json_each(?1)) + OR EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value IN (SELECT value FROM json_each(?1)))) ORDER BY length(updated_at_unix_ms) DESC, updated_at_unix_ms DESC, subject", )?; let rows = statement.query_map( params![ - actor.as_deref(), + actor_spellings.as_deref(), after.map(|key| key.0.to_string()), after.map(|key| key.1.as_str()) ], @@ -5564,8 +5568,8 @@ impl MissionsStore for Store { actor: Option<&str>, snapshot_unix_ms: u128, ) -> Result> { - let actor = actor.map(|value| normalize_actor(value, "agent")); let connection = self.readers.get(); + let actor = actor.map(|value| physical_actor(&connection, value)).transpose()?; let view = connection .prepare_cached( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, @@ -5580,9 +5584,9 @@ impl MissionsStore for Store { }; enrich_step_queue_at(&connection, &mut view, snapshot_unix_ms)?; let visible_to_actor = actor.as_ref().is_none_or(|actor| { - view.assigned_to.as_deref() == Some(actor.as_str()) - || view.claimant.as_deref() == Some(actor.as_str()) - || view.available_to.iter().any(|candidate| candidate == actor) + work_actor_matches(view.assigned_to.as_deref(), actor) + || work_actor_matches(view.claimant.as_deref(), actor) + || view.available_to.iter().any(|candidate| work_actor_matches(Some(candidate), actor)) }); Ok(visible_to_actor.then_some(view)) } @@ -5593,8 +5597,8 @@ impl MissionsStore for Store { snapshot_unix_ms: u128, include_agentless: bool, ) -> Result> { - let actor = actor.map(|value| normalize_actor(value, "agent")); let connection = self.readers.get(); + let actor = actor.map(|value| physical_actor(&connection, value)).transpose()?; let mut statement = connection.prepare( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, lease_owner, lease_incarnation, lease_expires_at_unix_ms, blocked_reason, not_before_unix_ms, created_at_unix_ms, updated_at_unix_ms, readiness_epoch, constraints @@ -5612,9 +5616,9 @@ impl MissionsStore for Store { let mut view = row?; enrich_step_queue_cached(&connection, &mut view, snapshot_unix_ms, &mut definitions)?; let visible_to_actor = actor.as_ref().is_none_or(|actor| { - view.assigned_to.as_deref() == Some(actor.as_str()) - || view.claimant.as_deref() == Some(actor.as_str()) - || view.available_to.iter().any(|candidate| candidate == actor) + work_actor_matches(view.assigned_to.as_deref(), actor) + || work_actor_matches(view.claimant.as_deref(), actor) + || view.available_to.iter().any(|candidate| work_actor_matches(Some(candidate), actor)) }); if visible_to_actor { visible.push(view); @@ -5963,12 +5967,6 @@ impl MissionsStore for Store { Some(successor) => (successor.subject.clone(), successor), None => (subject, current), }; - let legacy_claimant = current.claimant.as_deref().is_some_and(|claimant| { - fractal_schema::canonical_agent_actor(claimant).as_deref() != Some(claimant) - }); - current.claimant = current.claimant.as_deref() - .and_then(fractal_schema::canonical_agent_actor) - .map(std::borrow::Cow::into_owned); if handoff.is_some() { enrich_step_summaries_at(transaction, &mut current, now).map_err(internal)?; } @@ -6047,11 +6045,8 @@ impl MissionsStore for Store { if action == "claim" && adhoc_work::pending_handoff(transaction, ¤t)? { return Err(FractalError::new("handoff-not-acknowledged", "read the handoff note and acknowledge its message before claiming this work")); } - let eligible = current.assigned_to.as_deref().is_some_and(|assignee| { - fractal_schema::canonical_agent_actor(assignee).as_deref() == Some(actor.as_str()) - }) || current.available_to.iter().any(|candidate| { - fractal_schema::canonical_agent_actor(candidate).as_deref() == Some(actor.as_str()) - }); + let eligible = work_actor_matches(current.assigned_to.as_deref(), &actor) + || current.available_to.iter().any(|candidate| work_actor_matches(Some(candidate), &actor)); if current.agentless || !eligible { return Err(FractalError::new( "work-not-available", @@ -6062,7 +6057,7 @@ impl MissionsStore for Store { let mut statement = transaction .prepare( "SELECT subject, generation_id, step_path FROM step_runs - WHERE lease_owner=?1 + WHERE lease_owner IN (SELECT value FROM json_each(?1)) AND subject<>?2 AND status IN ('claimed','working','verifying','blocked') AND lease_expires_at_unix_ms IS NOT NULL @@ -6071,7 +6066,7 @@ impl MissionsStore for Store { ) .map_err(internal)?; let active = statement - .query_map(params![actor, subject, now.to_string()], |row| { + .query_map(params![work_actor_spellings_json(&actor), subject, now.to_string()], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, @@ -6140,14 +6135,14 @@ impl MissionsStore for Store { let lease_valid = current .claim_expires_at_unix_ms .is_some_and(|expiry| expiry > now); - if lease_valid && current.claimant.as_deref() != Some(actor.as_str()) { + if lease_valid && !work_actor_matches(current.claimant.as_deref(), &actor) { return Err(FractalError::new( "work-already-claimed", format!("step run `{subject}` has an active lease"), )); } if action != "claim" - && (!lease_valid || current.claimant.as_deref() != Some(actor.as_str())) + && (!lease_valid || !work_actor_matches(current.claimant.as_deref(), &actor)) { return Err(FractalError::new( "work-not-claimed", @@ -6155,7 +6150,7 @@ impl MissionsStore for Store { )); } if lease_valid - && current.claimant.as_deref() == Some(actor.as_str()) + && work_actor_matches(current.claimant.as_deref(), &actor) && current.claim_incarnation.as_deref() != Some(requested_incarnation) { return Err(FractalError::new( @@ -6189,6 +6184,21 @@ impl MissionsStore for Store { )); } } + let mut lease_actor = actor.as_str(); + // LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership — DELETE at contraction — https://github.com/compoundingtech/fractal/pull/5582 + // Old fleet members compare eligibility and ownership literally. Preserve a + // legacy assignment's spelling until every fleet member compares canonical + // identities; a later contraction PR migrates the rows and removes this arm. + if let Some(legacy) = current.assigned_to.as_deref().into_iter() + .chain(current.available_to.iter().map(String::as_str)) + .find(|candidate| *candidate != actor.as_str() + && work_actor_matches(Some(*candidate), &actor)) + { + lease_actor = legacy; + } + // LIVE-MIGRATION END arn:lmig:fractal:2026-11-07-agent-actor-ownership + let ownership_changed = current.claimant.as_deref() + .is_some_and(|claimant| claimant != lease_actor); let (status, worker_reported, claimant, claim_incarnation, claim_expiry) = match action { "claim" => { if current.status != "ready" && current.status != "claimed" { @@ -6200,7 +6210,7 @@ impl MissionsStore for Store { ( "claimed", current.worker_reported, - Some(actor.clone()), + Some(lease_actor.to_owned()), effective_incarnation.clone(), Some(now + 600_000), ) @@ -6208,21 +6218,21 @@ impl MissionsStore for Store { "renew" => ( current.status.as_str(), current.worker_reported, - Some(actor.clone()), + Some(lease_actor.to_owned()), effective_incarnation.clone(), Some(now + 600_000), ), "progress" => ( "working", current.worker_reported, - Some(actor.clone()), + Some(lease_actor.to_owned()), effective_incarnation, Some(now + 600_000), ), "extend" => ( current.status.as_str(), current.worker_reported, - Some(actor.clone()), + Some(lease_actor.to_owned()), effective_incarnation, Some(now + 600_000), ), @@ -6230,7 +6240,7 @@ impl MissionsStore for Store { "wait" | "resume" => ( "working", current.worker_reported, - Some(actor.clone()), + Some(lease_actor.to_owned()), effective_incarnation, Some(now + 600_000), ), @@ -6266,7 +6276,7 @@ impl MissionsStore for Store { } else { None }; - let publish = !quiet_renewal || legacy_claimant + let publish = !quiet_renewal || ownership_changed || last_replicated_expiry.is_none_or(|expiry| expiry <= now.saturating_add(300_000)); let readiness_epoch = current .readiness_epoch @@ -6375,7 +6385,7 @@ impl MissionsStore for Store { lease_owner=excluded.lease_owner, lease_incarnation=excluded.lease_incarnation, lease_expires_at_unix_ms=excluded.lease_expires_at_unix_ms, updated_at_unix_ms=excluded.updated_at_unix_ms", - params![subject, current.attempt, actor, requested_incarnation, + params![subject, current.attempt, lease_actor, requested_incarnation, claim_expiry.expect("a renewal has a lease expiry").to_string(), now.to_string()], ) .map_err(internal)?; diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index 5f8d20c0474..a8e61db34d2 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -6413,7 +6413,8 @@ fn an_active_nested_lease_can_publish_its_parent_mission_output() { } #[test] -fn nested_work_renews_the_same_workers_ancestor_lease() { +fn nested_work_renews_the_same_workers_legacy_ancestor_lease() { + let holder = "node/worker"; let store = Store::open_memory("node").unwrap(); let source = r#"version 2 @@ -6422,7 +6423,7 @@ agent "worker" { workspace "/tmp"; command "true" } mission "nested-work" state="ready" { goal "Complete nested work." step "outer" { - assigned-to "agent/node.worker" + assigned-to "agent/node/worker" mission "work" { goal "Complete the child work." step "first" { } @@ -6464,7 +6465,7 @@ mission "nested-work" state="ready" { .unwrap(); let request = |key: &str| WorkRequest { wait_ms: None, - actor: Some("agent/node.worker".into()), + actor: Some("agent/node/worker".into()), incarnation: Some("current".into()), summary: None, reason: None, @@ -6489,8 +6490,8 @@ mission "nested-work" state="ready" { .lock() .unwrap() .execute( - /* sqlite-large-value: allow-for-now lv-h96d7cb75d46b owner=fractal_missions::store::tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_expires_at_unix_ms='0' WHERE subject=?1", - [&parent.subject], + /* sqlite-large-value: allow-for-now lv-h96d7cb75d46b owner=fractal_missions::store::tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_expires_at_unix_ms='0', lease_owner=?2 WHERE subject=?1", + params![parent.subject, holder], ) .unwrap(); @@ -6500,7 +6501,7 @@ mission "nested-work" state="ready" { let parent = store.step_run(&parent.subject).unwrap().unwrap(); assert_eq!(parent.status, "working"); - assert_eq!(parent.claimant.as_deref(), Some("agent/node.worker")); + assert_eq!(parent.claimant.as_deref(), Some(holder)); assert_eq!(parent.claim_incarnation.as_deref(), Some("current")); assert!(parent.claim_expires_at_unix_ms.unwrap() > now_ms()); } @@ -9558,6 +9559,44 @@ fn a_revision_keeps_the_claim_on_a_carried_step() { assert_eq!(submitted.status, "verifying"); } +#[test] +fn carried_claim_successor_matches_legacy_and_canonical_holders() { + for actor in ["node/worker", "agent/node/worker"] { + for holder in ["node/worker", "agent/node/worker"] { + let store = Store::open_memory("node").unwrap(); + publish_carry(&store, CARRY_SOURCE, "alias-carry-one"); + let run = store.create_mission_run(&MissionRunRequest { + mission: "carry".into(), revision: None, workspace: "/tmp".into(), + requester: Some("person/requester".into()), mode: Some("run".into()), + inputs: BTreeMap::new(), idempotency_key: "alias-carry-run".into(), + }).unwrap(); + let old = &run.steps.iter().find(|step| step.step == "work").unwrap().subject; + store.set_step_state(old, "ready", None).unwrap(); + store.work_action(old, "claim", &carry_request("one", "alias-carry-claim")).unwrap(); + let revised = publish_carry( + &store, + &CARRY_SOURCE.replace("Use the first goal.", "Use the second goal."), + "alias-carry-two", + ); + let adopted = store.adopt_mission_revision( + &run.id, &revised, "person/requester", "revise other work", "alias-carry-revision", + ).unwrap(); + let carried = &adopted.steps.iter().find(|step| step.step == "work").unwrap().subject; + assert_ne!(carried, old); + store.connection.write().execute( + "UPDATE step_runs SET assignee='agent/node/worker', lease_owner=?2 WHERE subject=?1", + params![carried, holder], + ).unwrap(); + let mut renew = carry_request("one", "alias-carry-renew"); + renew.actor = Some(actor.into()); + let renewed = store.work_action(old, "renew", &renew).unwrap(); + assert_eq!(&renewed.subject, carried); + assert_eq!(renewed.claimant.as_deref(), Some("agent/node/worker")); + assert!(renewed.claim_expires_at_unix_ms.unwrap() > now_ms()); + } + } +} + #[test] fn all_affected_human_reviewers_approve_the_exact_revision_preview() { let store = Store::open_memory("node").unwrap(); diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index d41179ca460..afb16f43790 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -1,5 +1,6 @@ #![allow(unused_imports)] use super::*; +use super::adhoc_work::AdhocWorkStore as _; use fractal_delivery::store::shared_missions::*; use fractal_delivery::store::shared_missions::{RUN_TREE_REBUILDS, STEP_TIMING_FOLD_CALLS, STEP_TIMING_ROWS_PARSED}; use fractal_store::store::work_renew_tests_shared_missions::*; @@ -509,10 +510,12 @@ fn ready_step_with_mail(mail: usize) -> Renewal { } #[test] -fn work_claim_canonicalizes_legacy_actor_and_stored_assignee() { +fn work_claim_preserves_assignment_spelling_for_mixed_version_ownership() { for (actor, assignee) in [ ("node/worker", "agent/node/worker"), ("agent/node/worker", "node/worker"), + ("node/worker", "node/worker"), + ("agent/node/worker", "agent/node/worker"), ] { let step = ready_step_with_mail(0); // Model both current rows and persisted rows predating actor canonicalization. @@ -527,13 +530,247 @@ fn work_claim_canonicalizes_legacy_actor_and_stored_assignee() { let mut claim_request = request("canonical-actor-claim", None); claim_request.actor = Some(actor.into()); let claimed = step.store.work_action(&step.subject, "claim", &claim_request).unwrap(); - assert_eq!(claimed.claimant.as_deref(), Some("agent/node/worker")); + assert_eq!(claimed.claimant.as_deref(), Some(assignee)); + assert_eq!(claimed.assigned_to.as_deref(), claimed.claimant.as_deref()); let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); - assert_eq!(persisted.claimant.as_deref(), Some("agent/node/worker")); + assert_eq!(persisted.claimant.as_deref(), Some(assignee)); + assert_eq!(persisted.assigned_to.as_deref(), Some(assignee)); let claims = step.store.claims_for(&step.subject, Some("work.claimed")).unwrap(); assert_eq!(claims.len(), 1); assert_eq!(claims[0].actor.as_deref(), Some("agent/node/worker")); - assert_eq!(claims[0].body["fields"]["claimant"], json!("agent/node/worker")); + assert_eq!(claims[0].body["fields"]["claimant"], json!(assignee)); + } +} + +#[test] +fn work_claim_preserves_available_to_spelling_for_mixed_version_ownership() { + for actor in ["node/worker", "agent/node/worker"] { + for candidate in ["node/worker", "agent/node/worker"] { + let step = ready_step_with_mail(0); + step.store.connection.write().execute( + "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1", + params![step.subject, json!([candidate]).to_string()], + ).unwrap(); + let mut claim = request("candidate-claim", None); + claim.actor = Some(actor.into()); + let claimed = step.store.work_action(&step.subject, "claim", &claim).unwrap(); + assert_eq!(claimed.claimant.as_deref(), Some(candidate)); + // An older member's exact eligibility/ownership comparison must still succeed. + assert!(claimed.available_to.iter().any(|available| Some(available) == claimed.claimant.as_ref())); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.claimant.as_deref(), Some(candidate)); + assert!(persisted.assigned_to.is_none()); + assert_eq!(persisted.available_to, vec![candidate.to_owned()]); + } + } +} + +#[test] +fn work_actor_equivalence_preserves_recognized_kinds() { + use fractal_store::store::{work_actor_matches, work_actor_spellings_json}; + + assert!(work_actor_matches(Some("node/worker"), "agent/node/worker")); + assert!(work_actor_matches(Some("agent/node/worker"), "node/worker")); + for (candidate, actor) in [ + ("person/reviewer", "agent/person/reviewer"), + ("daemon/scheduler", "agent/daemon/scheduler"), + ("agent/node/worker", "agent/agent/node/worker"), + ("node/worker-other", "agent/node/worker"), + ] { + assert!(!work_actor_matches(Some(candidate), actor)); + let spellings: Vec = serde_json::from_str(&work_actor_spellings_json(actor)).unwrap(); + assert!(!spellings.iter().any(|spelling| spelling == candidate)); + } + for invalid in ["", "worker", "node/worker name"] { + assert!(!work_actor_matches(Some(invalid), invalid)); + assert_eq!(work_actor_spellings_json(invalid), "[]"); + } +} + +#[test] +fn work_reads_match_legacy_and_canonical_actor_spellings() { + for actor in ["node/worker", "agent/node/worker"] { + for stored in ["node/worker", "agent/node/worker"] { + for field in ["assignee", "available_to", "lease_owner"] { + let step = ready_step_with_mail(0); + let snapshot = now_ms(); + let assignee = if field == "assignee" { stored } else { "agent/node/other" }; + let available = if field == "available_to" { json!([stored]) } else { json!([]) }; + let owner = (field == "lease_owner").then_some(stored); + let status = if owner.is_some() { "claimed" } else { "ready" }; + step.store.connection.write().execute( + "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, + status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1", + params![step.subject, assignee, available.to_string(), owner, status, + request("read", None).incarnation, (snapshot + 600_000).to_string()], + ).unwrap(); + let selected = BTreeSet::from([step.subject.clone()]); + let lists = [ + step.store.work_at_snapshot(Some(actor), false, snapshot).unwrap(), + step.store.client_work_at_snapshot(Some(actor), false, snapshot).unwrap(), + step.store.work_at_snapshot_selected( + Some(actor), false, snapshot, true, false, false, Some(&selected), + ).unwrap(), + step.store.work_history_at_snapshot(Some(actor), snapshot).unwrap(), + step.store.client_work_history_at_snapshot(Some(actor), snapshot).unwrap(), + step.store.client_work_history_page_at_snapshot( + Some(actor), snapshot, 0, 10, + ).unwrap().0, + ]; + for list in lists { + assert_eq!(list.len(), 1, "{actor} / {stored} / {field}"); + assert_eq!(list[0].subject, step.subject); + } + assert_eq!( + step.store.client_work_item_at_snapshot(&step.subject, Some(actor), snapshot) + .unwrap().unwrap().subject, + step.subject, + ); + assert!(step.store.work_at_snapshot( + Some("agent/node/unrelated"), false, snapshot, + ).unwrap().is_empty()); + assert!(step.store.client_work_item_at_snapshot( + &step.subject, Some("agent/node/unrelated"), snapshot, + ).unwrap().is_none()); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.assigned_to.as_deref(), Some(assignee)); + assert_eq!(persisted.claimant.as_deref(), owner); + assert_eq!(persisted.available_to, if field == "available_to" { + vec![stored.to_owned()] + } else { Vec::::new() }); + } + } + } +} + +#[test] +fn work_renewal_matches_legacy_and_canonical_holders() { + for actor in ["node/worker", "agent/node/worker"] { + for holder in ["node/worker", "agent/node/worker"] { + let step = ready_step_with_mail(0); + step.store.connection.write().execute( + "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", + [&step.subject], + ).unwrap(); + let mut claim = request("holder-claim", None); + claim.actor = Some(actor.into()); + step.store.work_action(&step.subject, "claim", &claim).unwrap(); + step.store.connection.write().execute( + "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1", + params![step.subject, holder], + ).unwrap(); + for status in ["claimed", "working", "verifying", "blocked"] { + step.store.connection.write().execute( + "UPDATE step_runs SET status=?2 WHERE subject=?1", + params![step.subject, status], + ).unwrap(); + let held = step.store.work_for_renewal(actor).unwrap(); + assert_eq!(held.len(), 1, "{actor} / {holder} / {status}"); + assert_eq!(held[0].subject, step.subject); + } + let mut renew = claim.clone(); + renew.idempotency_key = "holder-renew".into(); + let renewed = step.store.work_action(&step.subject, "renew", &renew).unwrap(); + assert_eq!(renewed.claimant.as_deref(), Some("agent/node/worker")); + assert!(renewed.claim_expires_at_unix_ms.unwrap() > now_ms()); + } + } +} + +#[test] +fn work_renew_release_and_completion_match_legacy_and_canonical_holders() { + for actor in ["node/worker", "agent/node/worker"] { + for holder in ["node/worker", "agent/node/worker"] { + for (action, expected_status) in [("renew", "claimed"), ("release", "ready"), ("complete", "verifying")] { + let step = ready_step_with_mail(0); + step.store.connection.write().execute( + "UPDATE step_runs SET assignee=?2 WHERE subject=?1", + params![step.subject, holder], + ).unwrap(); + let mut claim = request("holder-action-claim", None); + claim.actor = Some(actor.into()); + step.store.work_action(&step.subject, "claim", &claim).unwrap(); + step.store.connection.write().execute( + "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1", + params![step.subject, holder], + ).unwrap(); + let mut finish = claim.clone(); + finish.idempotency_key = format!("holder-{action}"); + let finished = step.store.work_action(&step.subject, action, &finish).unwrap(); + assert_eq!(finished.status, expected_status, "{actor} / {holder} / {action}"); + let expected_holder = (action == "renew").then_some(holder); + assert_eq!(finished.claimant.as_deref(), expected_holder); + if action == "renew" { + assert_eq!(finished.claim_incarnation, claim.incarnation); + assert!(finished.claim_expires_at_unix_ms.unwrap() > now_ms()); + let local_holder: String = step.store.connection.write().query_row( + "SELECT lease_owner FROM local_work_lease_renewals WHERE subject=?1", + [&step.subject], |row| row.get(0), + ).unwrap(); + assert_eq!(local_holder, holder); + } else { + assert!(finished.claim_incarnation.is_none()); + assert!(finished.claim_expires_at_unix_ms.is_none()); + } + assert_eq!(finished.worker_reported, action == "complete"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, expected_status); + assert_eq!(persisted.claimant.as_deref(), expected_holder); + assert_eq!(persisted.assigned_to.as_deref(), Some(holder)); + } + } + } +} + +#[test] +fn independent_work_capacity_refuses_second_admission_for_actor_aliases() { + for actor in ["node/worker", "agent/node/worker"] { + for holder in ["node/worker", "agent/node/worker"] { + for status in ["claimed", "working", "verifying", "blocked"] { + let step = ready_step_with_mail(0); + step.store.connection.write().execute( + "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", + [&step.subject], + ).unwrap(); + let mut claim = request("capacity-first", None); + claim.actor = Some(actor.into()); + step.store.work_action(&step.subject, "claim", &claim).unwrap(); + step.store.connection.write().execute( + "UPDATE step_runs SET lease_owner=?2, status=?3 WHERE subject=?1", + params![step.subject, holder, status], + ).unwrap(); + let declaration = "version 2\nagent \"node/worker\" { workspace \"/tmp\"; command \"true\"; restart \"never\"; }\nmission \"capacity-second\" state=\"ready\" { goal \"Another task.\"; step \"work\" { assigned-to \"agent/node/worker\"; } }"; + let intent = crate::graph::parse_test_intent(declaration, "node").unwrap(); + let plan = step.store.mission(&intent, IntentInput { + kdl: declaration.into(), source_name: None, + }).unwrap(); + step.store.apply(&intent, &plan.subject_tokens, "capacity-agent").unwrap(); + let error = step.store.start_work(&crate::model::WorkStartRequest { + actor: "agent/node/worker".into(), + title: "A second independent task".into(), + idempotency_key: "capacity-spontaneous-start".into(), + }).unwrap_err(); + assert_eq!(error.code, "agent-capacity", "{actor} / {holder} / {status}"); + let second = step.store.create_mission_run(&MissionRunRequest { + mission: "capacity-second".into(), revision: None, workspace: "/tmp".into(), + requester: Some("person/test".into()), mode: Some("run".into()), + inputs: BTreeMap::new(), idempotency_key: "capacity-second-run".into(), + }).unwrap(); + let second = &second.steps[0].subject; + step.store.set_step_state(second, "ready", None).unwrap(); + step.store.connection.write().execute( + "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", + [second], + ).unwrap(); + claim.idempotency_key = "capacity-second-claim".into(); + let error = step.store.work_action(second, "claim", &claim).unwrap_err(); + assert_eq!(error.code, "agent-capacity", "{actor} / {holder} / {status}"); + let refused = step.store.step_run(second).unwrap().unwrap(); + assert_eq!(refused.status, "ready"); + assert!(refused.claimant.is_none()); + assert!(step.store.claims_for(second, Some("work.claimed")).unwrap().is_empty()); + } + } } } diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index dffa563b960..2edf6bf2e0e 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -18791,8 +18791,32 @@ pub const SEAT_QUEUE_NAMED_RUN_QUERY: &str = FROM step_runs JOIN mission_runs ON mission_runs.id=step_runs.run_id WHERE +step_runs.assignee=?1 AND step_runs.run_id=?2 AND step_runs.agentless=0"; -/// [`work_at_snapshot_query`] for one named actor `?1`. The reconciler reads each running seat's -/// steps on every pass; the actor's steps are found through the assignee and lease indexes, and +/// Compare work actors without changing persisted rows or conflating recognized actor kinds. +pub fn work_actor_matches(candidate: Option<&str>, actor: &str) -> bool { + let Some(candidate) = candidate.and_then(fractal_schema::canonical_agent_actor) else { + return false; + }; + fractal_schema::canonical_agent_actor(actor).as_deref() == Some(candidate.as_ref()) +} + +/// Exact persisted spellings of one logical work actor, as a JSON array for indexed SQL reads. +/// Only a prefixless name that canonicalizes back to the same actor is an alias. +pub fn work_actor_spellings_json(actor: &str) -> String { + let Some(canonical) = fractal_schema::canonical_agent_actor(actor) else { + return "[]".into(); + }; + let legacy = canonical.strip_prefix("agent/").filter(|legacy| { + fractal_schema::canonical_agent_actor(legacy).as_deref() == Some(canonical.as_ref()) + }); + match legacy { + Some(legacy) => json!([canonical, legacy]).to_string(), + None => json!([canonical]).to_string(), + } +} + +/// [`work_at_snapshot_query`] for one actor's JSON spelling array `?1`. +/// The reconciler reads each running seat's steps on every pass; the actor's steps are found +/// through the assignee and lease indexes, and /// the few steps offered to several seats through their own index, instead of scanning every /// step the store ever ran. pub fn seat_work_at_snapshot_query(open_only: bool) -> String { @@ -18806,10 +18830,10 @@ pub fn seat_work_at_snapshot_query(open_only: bool) -> String { lease_owner, lease_incarnation, lease_expires_at_unix_ms, blocked_reason, not_before_unix_ms, created_at_unix_ms, updated_at_unix_ms, readiness_epoch, constraints FROM step_runs WHERE rowid IN ( - SELECT rowid FROM step_runs WHERE assignee=?1 - UNION SELECT rowid FROM step_runs WHERE lease_owner=?1 + SELECT rowid FROM step_runs WHERE assignee IN (SELECT value FROM json_each(?1)) + UNION SELECT rowid FROM step_runs WHERE lease_owner IN (SELECT value FROM json_each(?1)) UNION SELECT rowid FROM step_runs WHERE available_to!='[]' - AND EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value=?1) + AND EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value IN (SELECT value FROM json_each(?1))) ) AND (agentless=0 OR (?3 AND ?1 IS NULL)) AND generation_id=(SELECT current_generation_id FROM mission_runs WHERE id=step_runs.run_id) diff --git a/crates/fractal-store/src/store/reconcile_work_context.rs b/crates/fractal-store/src/store/reconcile_work_context.rs index 6f53a866966..6f39fd75595 100644 --- a/crates/fractal-store/src/store/reconcile_work_context.rs +++ b/crates/fractal-store/src/store/reconcile_work_context.rs @@ -175,13 +175,13 @@ pub fn read(store: &Store, actor: &str, at: u128) -> Result> { .get(view.run.strip_prefix("mission-run/").unwrap_or(&view.run)) .is_some_and(|phase| phase == "revision-draining"); (!draining || matches!(view.status.as_str(), "claimed" | "working" | "verifying")) - && (view.assigned_to.as_deref() == Some(actor.as_str()) - || view.claimant.as_deref() == Some(actor.as_str()) + && (work_actor_matches(view.assigned_to.as_deref(), &actor) + || work_actor_matches(view.claimant.as_deref(), &actor) || (matches!(view.status.as_str(), "ready" | "blocked") && view .available_to .iter() - .any(|candidate| candidate == &actor))) + .any(|candidate| work_actor_matches(Some(candidate), &actor)))) }); Ok(inputs.views) } @@ -190,7 +190,7 @@ pub fn inputs(store: &Store, actor: &str, at: u128) -> Result { let connection = store.readers.get(); let mut views = connection .prepare_cached(/* sqlite-large-value: allow-for-now lv-ha3063a1f5575 owner=fractal_store::store::reconcile_work_context TODO=trace-statement-and-value-representation */ &seat_work_at_snapshot_query(false))? - .query_map(params![actor, true, false], step_run_from_row)? + .query_map(params![work_actor_spellings_json(actor), true, false], step_run_from_row)? .collect::>>()?; let carried = carried_claimants_tx( &connection, diff --git a/crates/fractal-store/src/store/work_list.rs b/crates/fractal-store/src/store/work_list.rs index 92cab2b908c..03300a9be8c 100644 --- a/crates/fractal-store/src/store/work_list.rs +++ b/crates/fractal-store/src/store/work_list.rs @@ -248,10 +248,10 @@ pub fn viewer_seat(viewer: &str) -> String { /// Whether `viewer` sees `view` in its own work list, as the direct read's actor filter does. fn visible_to(view: &StepRunView, viewer: &str) -> bool { !view.agentless - && (view.assigned_to.as_deref() == Some(viewer) - || view.claimant.as_deref() == Some(viewer) + && (super::work_actor_matches(view.assigned_to.as_deref(), viewer) + || super::work_actor_matches(view.claimant.as_deref(), viewer) || (matches!(view.status.as_str(), "ready" | "blocked") - && view.available_to.iter().any(|seat| seat == viewer))) + && view.available_to.iter().any(|seat| super::work_actor_matches(Some(seat), viewer)))) } /// How the work list ranks a step's state: ready work first, ended work last. @@ -380,7 +380,19 @@ impl WorkRows { let queue = self.seat_orders.get(&viewer_seat(viewer)).map_or(&[][..], |order| order.as_slice()); let steps = shown .iter() - .map(|(_, view)| crate::seat_queue::SeatStep::from(&***view)) + .map(|(_, view)| { + let mut step = crate::seat_queue::SeatStep::from(&***view); + if super::work_actor_matches(step.assignee, viewer) { + step.assignee = Some(viewer); + } + if super::work_actor_matches(step.claimant, viewer) { + step.claimant = Some(viewer); + } + if super::work_actor_matches(step.carried_claimant, viewer) { + step.carried_claimant = Some(viewer); + } + step + }) .collect::>(); let ready = crate::seat_queue::select(viewer, &steps, queue) .ready diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json index 40d466f15de..e13befa5a66 100644 --- a/release-notes/mission-agentid-canonical.json +++ b/release-notes/mission-agentid-canonical.json @@ -1,10 +1,10 @@ { "version": 1, "summary": "Emit canonical agent identities in mission presentation and admit canonical mission work actors", - "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical assignee normalization for malformed values rather than rejecting them."}, + "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical host-relative qualification and malformed assignee normalization rather than rejecting them."}, "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, - "rules": {"status": "changed", "detail": "Mission assignees, run-start actors and work actions canonicalize slash-qualified agent identities. Work eligibility compares canonical identities and malformed work actors are rejected."}, - "compatibility": {"status": "changed", "detail": "Legacy unprefixed assignees and claimants resolve canonical roster seats and emit agent-prefixed presentation identities, so strict clients retain mission rows."}, + "rules": {"status": "changed", "detail": "Mission assignees, run-start actors and work actions canonicalize slash-qualified agent identities. Work eligibility, reads, renewal, successor lookup and capacity accounting compare canonical identities; malformed work actors are rejected."}, + "compatibility": {"status": "changed", "detail": "Mixed-version compatible expansion: legacy assignees and candidates keep their stored spelling for new lease ownership, allowing older members to renew, release and complete work; canonical assignments write canonical owners. Reads and capacity compare canonical identities. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership contracts only after every fleet member runs canonical comparisons, when a later change migrates stored rows and removes the exception."}, "downtime": {"status": "none", "detail": "No data migration or coordinated client rollout is required."}, "manual": {"status": "none", "detail": "No operator action is required."}, "recovery": {"status": "none", "detail": "Existing transaction rollback and lease fencing remain unchanged."} From 9726599711cd3c3010f93fc464c74b464157b783 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:51:37 +0100 Subject: [PATCH 008/132] fix(missions): admit contextual legacy ownership during mixed-version rollout agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../src/api/published_lists_tests.rs | 2 +- crates/fractal-missions/src/store/missions.rs | 18 ++- crates/fractal-missions/src/store/tests.rs | 4 +- .../src/store/work_renew_tests.rs | 77 ++++++++++-- crates/fractal-store/src/store.rs | 119 +++++++++++++++++- docs/st3/large-value-sites.json | 22 +++- release-notes/mission-agentid-canonical.json | 2 +- 7 files changed, 218 insertions(+), 26 deletions(-) diff --git a/crates/fractal-api-core/src/api/published_lists_tests.rs b/crates/fractal-api-core/src/api/published_lists_tests.rs index a58e26aed12..922356976e3 100644 --- a/crates/fractal-api-core/src/api/published_lists_tests.rs +++ b/crates/fractal-api-core/src/api/published_lists_tests.rs @@ -317,7 +317,7 @@ fn published_work_keeps_persisted_legacy_assignments_and_available_seats_visible let assignee = (!available).then_some("garden/ash"); let candidates = if available { r#"["garden/ash"]"# } else { "[]" }; connection.execute( - "UPDATE step_runs SET assignee=?2, available_to=?3 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-h16928b5eb896 owner=fractal_api_core::api::published_lists_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2, available_to=?3 WHERE subject=?1", rusqlite::params![subject, assignee, candidates], ).unwrap(); drop(connection); diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 468f45cffcc..13de145c8a3 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -86,6 +86,12 @@ fn physical_actor(connection: &Connection, actor: &str) -> Result Result { + physical_actor(connection, &normalize_actor(actor, "agent")) +} + fn canonical_work_actor(actor: &str) -> Result { fractal_schema::canonical_agent_actor(actor) .map(std::borrow::Cow::into_owned) @@ -603,7 +609,7 @@ fn renew_nested_ancestor_leases_tx( let expiry = now.saturating_add(600_000); transaction .execute( - /* sqlite-large-value: allow-for-now lv-hdaafb8e69f1c owner=fractal_missions::store::missions TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs + /* sqlite-large-value: allow-for-now lv-heca706b36312 owner=fractal_missions::store::missions TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_expires_at_unix_ms=?1, updated_at_unix_ms=?2 WHERE generation_id=?3 AND substr(?4, 1, length(step_path) + 1)=step_path || '/' @@ -5133,7 +5139,7 @@ impl MissionsStore for Store { fn work_for_renewal(&self, actor: &str) -> Result> { let snapshot = now_ms(); let connection = self.readers.get(); - let actor = physical_actor(&connection, actor)?; + let actor = physical_read_actor(&connection, actor)?; let views = connection .prepare_cached( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, @@ -5362,7 +5368,7 @@ impl MissionsStore for Store { selected: Option<&BTreeSet>, ) -> Result> { let connection = self.readers.get(); - let actor = actor.map(|value| physical_actor(&connection, value)).transpose()?; + let actor = actor.map(|value| physical_read_actor(&connection, value)).transpose()?; let actor_spellings = actor.as_deref().map(work_actor_spellings_json); let views = match selected { None => { @@ -5500,7 +5506,7 @@ impl MissionsStore for Store { after: Option<&(u128, String)>, ) -> Result<(Vec, bool)> { let connection = self.readers.get(); - let actor = actor.map(|value| physical_actor(&connection, value)).transpose()?; + let actor = actor.map(|value| physical_read_actor(&connection, value)).transpose()?; let actor_spellings = actor.as_deref().map(work_actor_spellings_json); // Enrichment can clear a step's claimant, through its effective state alone, but // never changes its assignee, its candidates or its update time. So this finds every @@ -5569,7 +5575,7 @@ impl MissionsStore for Store { snapshot_unix_ms: u128, ) -> Result> { let connection = self.readers.get(); - let actor = actor.map(|value| physical_actor(&connection, value)).transpose()?; + let actor = actor.map(|value| physical_read_actor(&connection, value)).transpose()?; let view = connection .prepare_cached( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, @@ -5598,7 +5604,7 @@ impl MissionsStore for Store { include_agentless: bool, ) -> Result> { let connection = self.readers.get(); - let actor = actor.map(|value| physical_actor(&connection, value)).transpose()?; + let actor = actor.map(|value| physical_read_actor(&connection, value)).transpose()?; let mut statement = connection.prepare( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, lease_owner, lease_incarnation, lease_expires_at_unix_ms, blocked_reason, not_before_unix_ms, created_at_unix_ms, updated_at_unix_ms, readiness_epoch, constraints diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index a8e61db34d2..b0d93160ee2 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -6490,7 +6490,7 @@ mission "nested-work" state="ready" { .lock() .unwrap() .execute( - /* sqlite-large-value: allow-for-now lv-h96d7cb75d46b owner=fractal_missions::store::tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_expires_at_unix_ms='0', lease_owner=?2 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-h414f4baa2072 owner=fractal_missions::store::tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_expires_at_unix_ms='0', lease_owner=?2 WHERE subject=?1", params![parent.subject, holder], ) .unwrap(); @@ -9584,7 +9584,7 @@ fn carried_claim_successor_matches_legacy_and_canonical_holders() { let carried = &adopted.steps.iter().find(|step| step.step == "work").unwrap().subject; assert_ne!(carried, old); store.connection.write().execute( - "UPDATE step_runs SET assignee='agent/node/worker', lease_owner=?2 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-h76038fa53ef8 owner=fractal_missions::store::tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='agent/node/worker', lease_owner=?2 WHERE subject=?1", params![carried, holder], ).unwrap(); let mut renew = carry_request("one", "alias-carry-renew"); diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index afb16f43790..f7341d06a82 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -542,13 +542,70 @@ fn work_claim_preserves_assignment_spelling_for_mixed_version_ownership() { } } +#[test] +fn replicated_legacy_claimant_waits_for_row_and_checks_projected_eligibility() { + use fractal_store::store::{classify_replicated_claim_with_registry, + project_claim_isolated_tx, project_mission_run_update}; + + let source = Store::open_memory("node").unwrap(); + let kdl = "version 2\nmission \"legacy-replica\" state=\"ready\" { goal \"Work.\"; step \"work\" { assigned-to \"agent/node/worker\"; } }"; + let intent = crate::graph::parse_test_intent(kdl, "node").unwrap(); + let plan = source.mission(&intent, IntentInput { kdl: kdl.into(), source_name: None }).unwrap(); + source.apply(&intent, &plan.subject_tokens, "legacy-replica-definition").unwrap(); + let run = source.create_mission_run(&MissionRunRequest { + mission: "legacy-replica".into(), revision: None, workspace: "/tmp".into(), + requester: Some("person/test".into()), mode: Some("run".into()), + inputs: BTreeMap::new(), idempotency_key: "legacy-replica-run".into(), + }).unwrap(); + let subject = &run.steps[0].subject; + source.set_step_state(subject, "ready", None).unwrap(); + source.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h2bd292359425 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='node/worker' WHERE subject=?1", [subject], + ).unwrap(); + let mut claim_request = request("legacy-replica-claim", None); + claim_request.actor = Some("agent/node/worker".into()); + source.work_action(subject, "claim", &claim_request).unwrap(); + let claim = source.claims_for(subject, Some("work.claimed")).unwrap().pop().unwrap(); + assert_eq!(claim.body["fields"]["claimant"], json!("node/worker")); + assert!(classify_replicated_claim_with_registry(&claim, fractal_schema::registry()).is_ok()); + let replica = Store::open_memory("replica").unwrap(); + assert!(replica.step_run(subject).unwrap().is_none()); + replica.import_replication("node", &source.export_replication(0).unwrap()).unwrap(); + let projected = replica.step_run(subject).unwrap().unwrap(); + assert_eq!(projected.claimant.as_deref(), Some("node/worker")); + assert_eq!(replica.claims_for(subject, Some("work.claimed")).unwrap()[0].body, claim.body); + replica.rebuild_claim_projections().unwrap(); + assert_eq!(replica.step_run(subject).unwrap().unwrap().claimant.as_deref(), Some("node/worker")); + assert_eq!(replica.claims_for(subject, Some("work.claimed")).unwrap()[0].body, claim.body); + + let mut connection = replica.connection.write(); + let transaction = connection.transaction().unwrap(); + transaction.execute( + /* sqlite-large-value: allow-for-now lv-hac330e1ef0a3 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='agent/node/other', available_to='[]', + status='ready', lease_owner=NULL, lease_incarnation=NULL, + lease_expires_at_unix_ms=NULL WHERE subject=?1", [subject], + ).unwrap(); + project_claim_isolated_tx(&transaction, "legacy-ownership-test", &claim, || { + project_mission_run_update(&transaction, &claim) + }).unwrap(); + let owner: Option = transaction.query_row( + "SELECT lease_owner FROM step_runs WHERE subject=?1", [subject], |row| row.get(0), + ).unwrap(); + assert!(owner.is_none()); + let defect: String = transaction.query_row( + "SELECT error_code FROM projection_health WHERE aggregate=?1", + [format!("legacy-ownership-test:{}", claim.id)], |row| row.get(0), + ).unwrap(); + assert_eq!(defect, "invalid-work-claimant"); +} + #[test] fn work_claim_preserves_available_to_spelling_for_mixed_version_ownership() { for actor in ["node/worker", "agent/node/worker"] { for candidate in ["node/worker", "agent/node/worker"] { let step = ready_step_with_mail(0); step.store.connection.write().execute( - "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-hbbb772f82848 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1", params![step.subject, json!([candidate]).to_string()], ).unwrap(); let mut claim = request("candidate-claim", None); @@ -599,7 +656,7 @@ fn work_reads_match_legacy_and_canonical_actor_spellings() { let owner = (field == "lease_owner").then_some(stored); let status = if owner.is_some() { "claimed" } else { "ready" }; step.store.connection.write().execute( - "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, + /* sqlite-large-value: allow-for-now lv-hd60f12cbb0cd owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1", params![step.subject, assignee, available.to_string(), owner, status, request("read", None).incarnation, (snapshot + 600_000).to_string()], @@ -649,19 +706,19 @@ fn work_renewal_matches_legacy_and_canonical_holders() { for holder in ["node/worker", "agent/node/worker"] { let step = ready_step_with_mail(0); step.store.connection.write().execute( - "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-hd38e77dd6432 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", [&step.subject], ).unwrap(); let mut claim = request("holder-claim", None); claim.actor = Some(actor.into()); step.store.work_action(&step.subject, "claim", &claim).unwrap(); step.store.connection.write().execute( - "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-h2102123ed04c owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1", params![step.subject, holder], ).unwrap(); for status in ["claimed", "working", "verifying", "blocked"] { step.store.connection.write().execute( - "UPDATE step_runs SET status=?2 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-h28928374ca14 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET status=?2 WHERE subject=?1", params![step.subject, status], ).unwrap(); let held = step.store.work_for_renewal(actor).unwrap(); @@ -684,14 +741,14 @@ fn work_renew_release_and_completion_match_legacy_and_canonical_holders() { for (action, expected_status) in [("renew", "claimed"), ("release", "ready"), ("complete", "verifying")] { let step = ready_step_with_mail(0); step.store.connection.write().execute( - "UPDATE step_runs SET assignee=?2 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-hd700e0550a21 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2 WHERE subject=?1", params![step.subject, holder], ).unwrap(); let mut claim = request("holder-action-claim", None); claim.actor = Some(actor.into()); step.store.work_action(&step.subject, "claim", &claim).unwrap(); step.store.connection.write().execute( - "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-ha0cd354483f3 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1", params![step.subject, holder], ).unwrap(); let mut finish = claim.clone(); @@ -729,14 +786,14 @@ fn independent_work_capacity_refuses_second_admission_for_actor_aliases() { for status in ["claimed", "working", "verifying", "blocked"] { let step = ready_step_with_mail(0); step.store.connection.write().execute( - "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-h48c2f2d4b497 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", [&step.subject], ).unwrap(); let mut claim = request("capacity-first", None); claim.actor = Some(actor.into()); step.store.work_action(&step.subject, "claim", &claim).unwrap(); step.store.connection.write().execute( - "UPDATE step_runs SET lease_owner=?2, status=?3 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-h7d7fcc2328ee owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_owner=?2, status=?3 WHERE subject=?1", params![step.subject, holder, status], ).unwrap(); let declaration = "version 2\nagent \"node/worker\" { workspace \"/tmp\"; command \"true\"; restart \"never\"; }\nmission \"capacity-second\" state=\"ready\" { goal \"Another task.\"; step \"work\" { assigned-to \"agent/node/worker\"; } }"; @@ -759,7 +816,7 @@ fn independent_work_capacity_refuses_second_admission_for_actor_aliases() { let second = &second.steps[0].subject; step.store.set_step_state(second, "ready", None).unwrap(); step.store.connection.write().execute( - "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-h78e96a5b9d99 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", [second], ).unwrap(); claim.idempotency_key = "capacity-second-claim".into(); diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 2edf6bf2e0e..b6971e04f2a 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -11126,6 +11126,97 @@ pub fn append_claim_tx_with_assistant_answer( ) } +// LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership — DELETE at contraction — https://github.com/compoundingtech/fractal/pull/5582 +fn legacy_work_claimant<'a>(kind: &str, fields: &'a BTreeMap) -> Option<&'a str> { + if !matches!(kind, "work.claimed" | "work.renewed" | "work.progress" | "work.extended") { + return None; + } + fields.get("claimant").and_then(Value::as_str).filter(|claimant| { + fractal_schema::canonical_agent_actor(claimant).is_some_and(|canonical| { + canonical.starts_with("agent/") && canonical.as_ref() != *claimant + }) + }) +} + +/// Validate only a canonical copy; authenticated and persisted claim bytes remain unchanged. +fn work_claim_validation_fields<'a>( + connection: &Connection, + subject: &str, + kind: &str, + fields: &'a BTreeMap, +) -> rusqlite::Result>> { + let Some(claimant) = legacy_work_claimant(kind, fields) else { + return Ok(std::borrow::Cow::Borrowed(fields)); + }; + let eligible: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM step_runs WHERE subject=?1 AND agentless=0 + AND (assignee=?2 OR EXISTS(SELECT 1 FROM json_each(step_runs.available_to) WHERE value=?2)))", + params![subject, claimant], |row| row.get(0), + )?; + if !eligible { + return Ok(std::borrow::Cow::Borrowed(fields)); + } + let mut validation = fields.clone(); + validation.insert("claimant".into(), json!(fractal_schema::canonical_agent_actor(claimant))); + Ok(std::borrow::Cow::Owned(validation)) +} + +fn replicated_work_validation_fields<'a>( + kind: &str, + fields: &'a BTreeMap, +) -> std::borrow::Cow<'a, BTreeMap> { + let Some(claimant) = legacy_work_claimant(kind, fields) else { + return std::borrow::Cow::Borrowed(fields); + }; + let mut validation = fields.clone(); + validation.insert("claimant".into(), json!(fractal_schema::canonical_agent_actor(claimant))); + std::borrow::Cow::Owned(validation) +} +#[test] +fn legacy_work_claimant_validation_requires_exact_target_eligibility() { + let connection = Connection::open_in_memory().unwrap(); + connection.execute_batch( + /* sqlite-large-value: allow-for-now lv-ha92e9efed614 owner=fractal_store::store TODO=chunk-or-reference-variable-columns */ "CREATE TABLE step_runs(subject TEXT, agentless INTEGER, assignee TEXT, available_to TEXT); + INSERT INTO step_runs VALUES('step-run/test/work',0,'node/worker','[]'); + INSERT INTO step_runs VALUES('step-run/test/offered',0,NULL,'[\"node/worker\"]'); + INSERT INTO step_runs VALUES('step-run/test/other',0,'agent/node/worker','[]');", + ).unwrap(); + let fields = BTreeMap::from([ + ("attempt".into(), json!(1)), + ("status".into(), json!("claimed")), + ("claimant".into(), json!("node/worker")), + ]); + assert!(fractal_schema::registry().validate_claim( + "step-run/test/work", "work.claimed", &fields, + ).is_err()); + for subject in ["step-run/test/work", "step-run/test/offered"] { + let validation = work_claim_validation_fields(&connection, subject, "work.claimed", &fields).unwrap(); + assert_eq!(validation["claimant"], json!("agent/node/worker")); + fractal_schema::registry().validate_claim(subject, "work.claimed", &validation).unwrap(); + assert_eq!(fields["claimant"], json!("node/worker")); + } + for (subject, claimant, kind) in [ + ("step-run/test/other", "node/worker", "work.claimed"), + ("step-run/test/missing", "node/worker", "work.claimed"), + ("step-run/test/work", "node/other", "work.claimed"), + ("step-run/test/work", "node/worker name", "work.claimed"), + ("step-run/test/work", "worker", "work.claimed"), + ("step-run/test/work", "node/worker", "step-run.state"), + ] { + let mut invalid = fields.clone(); + invalid.insert("claimant".into(), json!(claimant)); + let validation = work_claim_validation_fields(&connection, subject, kind, &invalid).unwrap(); + assert_eq!(validation.as_ref(), &invalid); + assert!(fractal_schema::registry().validate_claim(subject, kind, &validation).is_err()); + if claimant == "worker" || claimant.contains(' ') || kind == "step-run.state" { + let replicated = replicated_work_validation_fields(kind, &invalid); + assert!(fractal_schema::registry().validate_replicated_claim(subject, kind, &replicated).is_err()); + } + } +} + +// LIVE-MIGRATION END arn:lmig:fractal:2026-11-07-agent-actor-ownership + #[allow(clippy::too_many_arguments)] /* sqlite-large-value-flow: allow-for-now flow-h1f241b496964 owner=fractal_store::store TODO=prepare-body-and-predecessor-values */ fn append_admitted_claim_tx( transaction: &Transaction<'_>, @@ -11167,8 +11258,9 @@ pub fn append_claim_tx_with_assistant_answer( delegation::validate_write(transaction, subject, kind, actor, &fields, &evidence) .map_err(anyhow::Error::new)?; } + let validation_fields = work_claim_validation_fields(transaction, subject, kind, &fields)?; let claim_spec = fractal_schema::registry() - .validate_claim(subject, kind, &fields) + .validate_claim(subject, kind, &validation_fields) .map_err(|error| anyhow::anyhow!("{}: {}", error.code, error.message))?; if kind == fractal_schema::directive_notes::KIND { directive_notes::validate_publication(transaction, origin).map_err(anyhow::Error::new)?; @@ -15620,6 +15712,9 @@ pub fn classify_replicated_claim_with_registry( claim: &ClaimRecord, registry: &fractal_schema::Registry, ) -> Result { + // LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership — DELETE at contraction — https://github.com/compoundingtech/fractal/pull/5582 + // Signed legacy ownership can arrive before its row. Projection checks eligibility. + // LIVE-MIGRATION END arn:lmig:fractal:2026-11-07-agent-actor-ownership if matches!(claim.kind.as_str(), "agent.launcher-bound" | "agent.helper-launched" | "client.action-result") { return Err(FractalError::new("invalid-replicated-claim", "helper launcher authority, launch history, and client action receipts cannot be admitted from peers")); @@ -15637,7 +15732,8 @@ pub fn classify_replicated_claim_with_registry( ), ) })?; - if let Err(error) = registry.validate_replicated_claim(&claim.subject, &claim.kind, &fields) { + let validation_fields = replicated_work_validation_fields(&claim.kind, &fields); + if let Err(error) = registry.validate_replicated_claim(&claim.subject, &claim.kind, &validation_fields) { // A peer may already publish a field introduced by a newer schema. Keep // that authenticated record retryable so an upgrade can admit the // original claim without replacing or rewriting it. @@ -18088,6 +18184,22 @@ pub fn project_mission_run_update( if current_attempt != Some(claim_attempt) { return Ok(()); } + // LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership — DELETE at contraction — https://github.com/compoundingtech/fractal/pull/5582 + let validation_fields = schema_fields_for_body(&claim.kind, &claim.body).map_err(internal)?; + if let Some(claimant) = legacy_work_claimant(&claim.kind, &validation_fields) { + let (assignee, available, agentless): (Option, String, bool) = transaction.query_row( + "SELECT assignee, available_to, agentless FROM step_runs WHERE subject=?1", + [&claim.subject], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ).map_err(internal)?; + let available: Vec = serde_json::from_str(&available).map_err(internal)?; + if agentless || !(work_actor_matches(assignee.as_deref(), claimant) + || available.iter().any(|candidate| work_actor_matches(Some(candidate), claimant))) + { + return Err(FractalError::new("invalid-work-claimant", + "legacy work claimant is not eligible for the target step")); + } + } + // LIVE-MIGRATION END arn:lmig:fractal:2026-11-07-agent-actor-ownership if claim.kind == "work.handoff-returned" { transaction.execute( /* sqlite-large-value: allow-for-now lv-h26512c176d95 owner=fractal_store::store TODO=bound-or-reference-values-and-reclamation */ @@ -22211,8 +22323,9 @@ pub fn verify_replica_batch(batch: &ReplicaBatch) -> Result<(), FractalError> { ), ) })?; + let validation_fields = replicated_work_validation_fields(&claim.kind, &fields); fractal_schema::registry() - .validate_claim(&claim.subject, &claim.kind, &fields) + .validate_claim(&claim.subject, &claim.kind, &validation_fields) .map_err(|error| { FractalError::new( "invalid-replicated-claim", diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index d20152b6a04..da592f701a2 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -26,6 +26,7 @@ ], "policy": "Source-site transitional debt only; no numeric threshold, value-size proof, logical cap or historical rejection. See large-values.md.", "declarations": [ + {"columns": [{"declaration": "TEXT", "declared_type": "TEXT", "name": "subject", "value_size_proven": false}, {"declaration": "INTEGER", "declared_type": "INTEGER", "name": "agentless", "value_size_proven": false}, {"declaration": "TEXT", "declared_type": "TEXT", "name": "assignee", "value_size_proven": false}, {"declaration": "TEXT", "declared_type": "TEXT", "name": "available_to", "value_size_proven": false}], "path": "crates/fractal-store/src/store.rs", "site": "lv-ha92e9efed614", "table_pattern": "step_runs", "unresolved_template": false}, {"site": "lv-h670107940355", "path": "crates/fractal-api-core/src/api/tests.rs", "table_pattern": "legacy_fixture_events", "columns": [{"name": "store_index", "declared_type": "INTEGER", "declaration": "INTEGER PRIMARY KEY", "value_size_proven": false}, {"name": "kind", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}, {"name": "subject", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}, {"name": "body", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}], "unresolved_template": false}, {"site": "lv-h4eb038021bdb", "path": "crates/fractal-api-core/src/api/tests.rs", "table_pattern": "legacy_fixture_events", "columns": [{"name": "store_index", "declared_type": "INTEGER", "declaration": "INTEGER PRIMARY KEY", "value_size_proven": false}, {"name": "kind", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}, {"name": "subject", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}, {"name": "body", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}], "unresolved_template": false}, {"site": "lv-h6a20397a303e", "path": "crates/fractal-daemon/src/store_tests/client_message_reminder_deletion.rs", "table_pattern": "reminder_delete_context", "columns": [{"name": "subject", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}], "unresolved_template": false}, @@ -772,7 +773,7 @@ ], "flows": [ {"id": "flow-h0f24faa60a9f", "path": "crates/fractal-store/src/backup.rs", "function": "restore", "owner": "fractal_store::backup", "fingerprint": "367616d58ec64e13d447f49aa64db2731708beadc4a192eae7860c648e752808", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Offline backup restore is a historical producer/open path; write-preserving recovery and opener quiescence require a separate plan."}, - {"id": "flow-h1f241b496964", "path": "crates/fractal-store/src/store.rs", "function": "append_admitted_claim_tx", "owner": "fractal_store::store", "fingerprint": "74d62ac08779f032124fef084a97e0e488c25f952db01990895036b5b67b8620", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Application admission passes actor/body/dependency values to Core and projection; guards cover raw legacy paths too."}, + {"id": "flow-h1f241b496964", "path": "crates/fractal-store/src/store.rs", "function": "append_admitted_claim_tx", "owner": "fractal_store::store", "fingerprint": "985b468aec18448fad69ad702021a459f23a4ff2b3c7c1daf825667ba97a9f0e", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Application admission passes actor/body/dependency values to Core and projection; guards cover raw legacy paths too."}, {"id": "flow-hae49bfcdc951", "path": "crates/fractal-store/src/store.rs", "function": "insert_event", "owner": "fractal_store::store", "fingerprint": "9cdeca113f2b75bcf56424758796dc68d0158e63ba3c7520de898551700bbf4e", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "description": "Event positions/publication must retain canonical pointer/body and ordering semantics."}, {"id": "flow-hcc1764594fd2", "path": "crates/fractal-store/src/store/backup.rs", "function": "receive_backup_page", "owner": "fractal_store::store::backup", "fingerprint": "35b1ec2cf1835ef24f9f29107f60813ca41eb4197e8a2be9a98b5771cbccaed7", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Backup import permits valid historical records under its own record/page limits, not client extractor limits."}, {"id": "flow-he7b2f39a2da2", "path": "crates/smallclaims/src/store.rs", "function": "append_claim_record_tx", "owner": "smallclaims::store", "fingerprint": "fd48ee537bf54b5b208b4e3da218640c9239c63fd3b76ab3e836cf6ef81b97b8", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Local append serializes the logical body and predecessor identities; no route-limit inference."}, @@ -891,6 +892,7 @@ {"id": "lv-hb967428e5ded", "path": "crates/fractal-api-core/src/api/mailbox_tests_shared.rs", "kind": "sql-template", "fingerprint": "a466c1bb6842b49c1d34a00a24fdcb27190d6711850fee20c555c3c6c8bfc672", "owner": "fractal_api_core::api::mailbox_tests_shared", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 36, "excerpt": "UPDATE claims SET accepted_at_unix_ms=?1 WHERE id=?2"}, {"id": "lv-h17ed23f52569", "path": "crates/fractal-api-core/src/api/message_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "bbfac8f07b9a24bc278443aa0d496ef7bbd0019ed35cee7698f44b358102850e", "owner": "fractal_api_core::api::message_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 154, "excerpt": "[ ] , | row | row . get ( 0 )"}, {"id": "lv-he830e5bf3588", "path": "crates/fractal-api-core/src/api/message_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "4e49a931b39a60214de90775142d3b4e3ed67d6a15c71239636d86d2dd64c082", "owner": "fractal_api_core::api::message_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 150, "excerpt": "& format ! ( \"SELECT EXISTS(SELECT 1 FROM claims WHERE kind='daemon.started' AND json_extract(body,'$.fields.features.{MESSAGE_OFFSETS_FEATURE}')=1)\" )"}, + {"id": "lv-h16928b5eb896", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "c3a0532ab17a84e1ad0287749b26ea41efe293bb61b1c62a42a4093e94414017", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 320, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3 WHERE subject=?1"}, {"id": "lv-h336cf3012340", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "4daaf494993c5039a2c395c770fa18050981d44160ac7a9e2455155a50eb0fd3", "owner": "fractal_api_core::api::published_lists_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1678, "excerpt": "UPDATE claims SET accepted_at_unix_ms=CAST(CAST(accepted_at_unix_ms AS INTEGER)+400 AS TEXT) WHERE kind='step-run.state' AND json_extract(body,'$.fields.status')='completed'"}, {"id": "lv-h5326fa890870", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "0830a4e014a2f92f3cde33a3af36104c47cdd6cbbcaa1f79922de83c6bdf9fa6", "owner": "fractal_api_core::api::published_lists_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1651, "excerpt": "UPDATE claims SET accepted_at_unix_ms=CASE json_extract(body,'$.fields.status') WHEN 'working' THEN '1000' WHEN 'completed' THEN CAST(1000+?2 AS TEXT) ELSE accepted_at_unix_ms END WHERE subject=?1 AND"}, {"id": "lv-h5a9504380e4b", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "0a57095c5bf44350a4d3b0001f7f811cc665a4e720e998dfc88e00300ff68c38", "owner": "fractal_api_core::api::published_lists_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1715, "excerpt": "UPDATE claims SET accepted_at_unix_ms=CASE json_extract(body,'$.fields.status') WHEN 'working' THEN '1000' WHEN 'completed' THEN CAST(1000+?2 AS TEXT) ELSE accepted_at_unix_ms END WHERE subject=?1 AND"}, @@ -1367,7 +1369,7 @@ {"id": "lv-hd25122d3a8e3", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "4d05fbde9b6c96ddb1359bdc85eaefc3bc4722b0cbe7a8be94bc83d5ddcbe77f", "owner": "fractal_missions::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6587, "excerpt": "TERMINAL_OWNED_RUNTIME_SUBJECTS"}, {"id": "lv-hd3c47451793e", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "f693948f475717b76f002971be13b759fa28f414a0cac94114534848af02dea7", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 4352, "excerpt": "INSERT INTO step_runs(subject, run_id, generation_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, blocked_reason, not_before_unix_ms,"}, {"id": "lv-hd664a42b8778", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "3697661830d53f3e1a91050af1aa97bd2d155632d2f329cece60aea2b2515c9b", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 3289, "excerpt": "UPDATE revision_proposals SET approvals=?2, status=?3, updated_at_unix_ms=?4 WHERE id=?1"}, - {"id": "lv-hdaafb8e69f1c", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "32341188f85d60d9096f994a222ebfb32b9d57bf459b421111239cc1a0ee05fc", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 473, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?1, updated_at_unix_ms=?2 WHERE generation_id=?3 AND substr(?4, 1, length(step_path) + 1)=step_path || '/' AND status IN ('claimed','working','verifying')"}, + {"id": "lv-heca706b36312", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "e6f237c06db774da6bdad7b00930d02dc570d8e73dd88639ec4bc50faa21701e", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 612, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?1, updated_at_unix_ms=?2 WHERE generation_id=?3 AND substr(?4, 1, length(step_path) + 1)=step_path || '/' AND status IN ('claimed','working','verifying')"}, {"id": "lv-hdd12cc78c9f2", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "464ad1ad68d82b99fbd4c3ba5d668e93c24f9820f3e6a97a730875066684f0df", "owner": "fractal_missions::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 7725, "excerpt": "[ claim ] , | row | row . get : : < _ , u64 > ( 0 )"}, {"id": "lv-hdedd742978e7", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "c32b884bb11aa9d33ad9fa5b6cbd7b18be6b5eec40e5397aa0319a5e4069ab0d", "owner": "fractal_missions::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 4576, "excerpt": "& sql"}, {"id": "lv-hdf6560483fe1", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "93f196172a3d88c05739f52fb29db0e6585d32eeafaec73208a6ec26ecc94824", "owner": "fractal_missions::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 7706, "excerpt": "Self : : SCHEDULE_ACTIVE_STARTED_RUN_QUERY , [ subject ] , | row | row . get ( 0 ) ,"}, @@ -1477,6 +1479,7 @@ {"id": "lv-h72d6e3ed93eb", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "5a4e2ef7b21a990501bea000d92dead38be8083c4ac3584c2caaf4cec8627b34", "owner": "fractal_missions::store::tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 4754, "excerpt": "UPDATE claims SET accepted_at_unix_ms=CAST(1000 + (store_index * 37) % 97 AS TEXT) WHERE subject=?1"}, {"id": "lv-h734b678e7a1c", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "e4028c3dc59d813bc4515d1183567d96f2431fb254746d291d206bb85e106b21", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6703, "excerpt": "DROP INDEX claims_incarnation_accepted_index"}, {"id": "lv-h75b2000582ae", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "fd27a7c43de3b713d97f0b9b3e90aaadba6a838684bab417568b650e78add6ed", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 4681, "excerpt": "UPDATE meta SET value='old-registry' WHERE key='projection_digest_registry'"}, + {"id": "lv-h76038fa53ef8", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "e21446038f569a6eec8b9b9a7e8201f454cd571dd47d6c8c72dedd6f6cbfbb2b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 9587, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker', lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h774c911bd0f7", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "d7884c2e571cd3e6bd0aca90f04cc3f630e00c81e473bc432744ad252550abfe", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 151, "excerpt": "INSERT INTO operations(id, request_digest, canonical_claim_id, state) VALUES ('op/kept', 'digest', 'claim/kept', 'active')"}, {"id": "lv-h776a38ab964c", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "b264fff65d4e828306ecb878244ab64249f5f7ab8876f2060656c9133f5532b2", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 748, "excerpt": "UPDATE mission_runs SET created_at_unix_ms='50' WHERE id=?1"}, {"id": "lv-h7d84468a5931", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "dd2b5f9f07e0760d8d29d36a0234e46940679161a2bccaeec3851ef4253997eb", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6922, "excerpt": "UPDATE idempotency SET expires_at_unix_ms=0"}, @@ -1494,7 +1497,7 @@ {"id": "lv-h917b0134e9be", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "48f1b7c24add202c1cf9e35ef1961992661db84a55e1219d962b59c8479c51cd", "owner": "fractal_missions::store::tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 8876, "excerpt": "UPDATE claims SET accepted_at_unix_ms=?2 WHERE id=?1"}, {"id": "lv-h9188e4d7bebf", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "670380e6c1568143746eefd5717d30685a73d61436c5663c11ede8fb9d9aec6f", "owner": "fractal_missions::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6624, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {query}\" )"}, {"id": "lv-h938e543b607c", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "bc3a19d4b04ac6b7f25ca1f53e1ad449235053e7b54017e059497a64f988c393", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 283, "excerpt": "UPDATE step_runs SET status='working', lease_owner='agent/tree/worker', lease_incarnation='expired', lease_expires_at_unix_ms='0' WHERE subject=?1"}, - {"id": "lv-h96d7cb75d46b", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "93efaf2218bc4b565e8983f0bf5d5283aab054949fc8a85b2c8b6f8bd476244b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6281, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms='0' WHERE subject=?1"}, + {"id": "lv-h414f4baa2072", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "109ff8f6d5e636d774131ecfa96a2ed314788773df553c3a050fbff05a493496", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6493, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms='0', lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h9d1a4529329e", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "6d61bea5a65b90a987f743b4c9f0213339c5f2cbd9bacc5fe59f068d39962839", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 5643, "excerpt": "UPDATE mission_runs SET status='running', phase='normal' WHERE id='{}'; UPDATE run_generations SET status='running' WHERE id='{}'; UPDATE step_runs SET status='ready', blocked_reason=NULL WHERE subjec"}, {"id": "lv-ha39be727e750", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "1ba4fdc8a1dd62bd2c17727a28457739c7e5ece1cdea20fcb2f65af8bd592bf3", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 2792, "excerpt": "DELETE FROM operations WHERE id='op/unrelated-upgrade'"}, {"id": "lv-ha816dc01de1b", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "312c623079b440c8ed74533c88d167d2bfa28e6abf746367894219a8345bd762", "owner": "fractal_missions::store::tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 4585, "excerpt": "UPDATE replica_records SET state='repaired' WHERE claim_id=?1"}, @@ -1527,16 +1530,28 @@ {"id": "lv-h8fe8d15d1c36", "path": "crates/fractal-missions/src/store/work_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "78c31fdd20e68f2bfc8f505c81c122979627b8f993537622ba3195ae8d1aed55", "owner": "fractal_missions::store::work_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 290, "excerpt": "REPAIRED_SINCE"}, {"id": "lv-hb16ae5c869ea", "path": "crates/fractal-missions/src/store/work_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "43e32be2fa928193cbeb175d2a9abb1d55f1a8848755a28b58c8ed362456aa58", "owner": "fractal_missions::store::work_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 229, "excerpt": "params ! [ subject , time . min ( i64 : : MAX as u128 ) as i64 ] , | row | row . get : : < _ , Option < i64 > > ( 0 )"}, {"id": "lv-hd9f7e7a219ce", "path": "crates/fractal-missions/src/store/work_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "be824f83b40fb6e1bfa945556ae9be449d492a6e6edd8c5fec6f70c7ce2522da", "owner": "fractal_missions::store::work_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 104, "excerpt": "published_list : : CLAIMS_SINCE"}, + {"id": "lv-h2102123ed04c", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 659, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, + {"id": "lv-h28928374ca14", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1d84c831a4aa0ece376bf0e40064fb0bd36cd58e7f04211c859df103c1e40e8c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 664, "excerpt": "UPDATE step_runs SET status=?2 WHERE subject=?1"}, + {"id": "lv-h2bd292359425", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "0e79e608b8e2a3edcfdb97783f9b822002419792a6c9e69c075b28468577594c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 563, "excerpt": "UPDATE step_runs SET assignee='node/worker' WHERE subject=?1"}, + {"id": "lv-h48c2f2d4b497", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 732, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-h4d5ea12d429d", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "93efaf2218bc4b565e8983f0bf5d5283aab054949fc8a85b2c8b6f8bd476244b", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 113, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms='0' WHERE subject=?1"}, {"id": "lv-h574254113134", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1ab5df7968f742b4058aafe38de481b8bac0ae283ff5d18ac035eb8f6db6352b", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 130, "excerpt": "UPDATE step_runs SET status=status,worker_reported=worker_reported, lease_owner=lease_owner,lease_incarnation=lease_incarnation, blocked_reason=blocked_reason,readiness_epoch=readiness_epoch, lease_ex"}, {"id": "lv-h5c4c48063d7f", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "5b561fcc182b1026f663954fd63a044a0fbbfb912c11dfc59ba6402df925f799", "owner": "fractal_missions::store::work_renew_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 287, "excerpt": "query , [ subject ] , | row | row . get ( 0 )"}, {"id": "lv-h70d2d054ccd8", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d564ed6ae137d4958c7f0751a4a1e5eac72c732ba498342d49bb3f7f9e06849c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 350, "excerpt": "UPDATE step_runs SET status=?2,lease_expires_at_unix_ms=?3 WHERE subject=?1"}, {"id": "lv-h72871ed13568", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1d84c831a4aa0ece376bf0e40064fb0bd36cd58e7f04211c859df103c1e40e8c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 367, "excerpt": "UPDATE step_runs SET status=?2 WHERE subject=?1"}, {"id": "lv-h755979e290e9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 520, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, + {"id": "lv-h78e96a5b9d99", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 762, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, + {"id": "lv-h7d7fcc2328ee", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d35271b78634a314d43a3538f1037107387d271a4fa449cfd3f69afad27e28ac", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 739, "excerpt": "UPDATE step_runs SET lease_owner=?2, status=?3 WHERE subject=?1"}, + {"id": "lv-ha0cd354483f3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 694, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, {"id": "lv-haa4a50f8ccd9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "5a9caadd18a47cc8540d557e3d5eead5e9f21cf15b57a415bfb07cfeddbc56b6", "owner": "fractal_missions::store::work_renew_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 322, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {}\" , last_replicated_lease_query ( ) )"}, + {"id": "lv-hac330e1ef0a3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "23d384584d619edc1b8135734fe4536e80b585db990943fa67e8dc9b10b08fa9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 584, "excerpt": "UPDATE step_runs SET assignee='agent/node/other', available_to='[]', status='ready', lease_owner=NULL, lease_incarnation=NULL, lease_expires_at_unix_ms=NULL WHERE subject=?1"}, {"id": "lv-haebfd1e4985b", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "004b63826474bc335db8f27b5d87f4a6dff9a32f4303f80ce1d7e3f10e5bee0d", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 138, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?2, updated_at_unix_ms=?3 WHERE subject=?1"}, + {"id": "lv-hbbb772f82848", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "f5a08f92b6484e142bfc0fac291d42996b27093a57f64739f414596a86da5406", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 551, "excerpt": "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1"}, {"id": "lv-hc363ee66ca25", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "cb91d38d30c6a413535ed4934c5d9f94b13f9574b4b2de6d6f1ba51b8f0156f9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 121, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?2 WHERE subject=?1"}, {"id": "lv-hca69050f62ee", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "9e2feeb2aa93b65606d10e7f78b269d681c421de488d083225519f9c3391dcf6", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 92, "excerpt": "CREATE TEMP TRIGGER quiet_renewal_state_guard AFTER UPDATE OF status,worker_reported,lease_owner,lease_incarnation, blocked_reason,readiness_epoch ON main.step_runs BEGIN SELECT RAISE(ABORT,'quiet ren"}, + {"id": "lv-hd38e77dd6432", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 652, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, + {"id": "lv-hd60f12cbb0cd", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "2c428368447719337e62bf6baa77578a1f95a5cda24f8fab6b5471192613c58e", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 602, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1"}, + {"id": "lv-hd700e0550a21", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 687, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, {"id": "lv-hece51d8edfe6", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "814ab2a3cd084839306d0a64185834c1925e530ec0a6b011591a7ee49f119288", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 125, "excerpt": "UPDATE mission_runs SET status='cancelled',phase='terminal' WHERE id=( SELECT run_id FROM step_runs WHERE subject=?1)"}, {"id": "lv-h3e591421c1da", "path": "crates/fractal-missions/src/store/work_summaries_ivm_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "c32b884bb11aa9d33ad9fa5b6cbd7b18be6b5eec40e5397aa0319a5e4069ab0d", "owner": "fractal_missions::store::work_summaries_ivm_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 266, "excerpt": "& sql"}, {"id": "lv-h468284965bf5", "path": "crates/fractal-missions/src/store/work_summaries_ivm_tests.rs", "kind": "sql-template", "fingerprint": "3466bf5d4a1eb7ff20ea582d75c74ba8f224a1d7139e34c76ec821707145b2f2", "owner": "fractal_missions::store::work_summaries_ivm_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 177, "excerpt": "CREATE TEMP TABLE summary_writes(key TEXT); CREATE TEMP TRIGGER summary_insert AFTER INSERT ON local_work_summaries BEGIN INSERT INTO summary_writes VALUES(NEW.key); END; CREATE TEMP TRIGGER summary_u"}, @@ -2040,6 +2055,7 @@ {"id": "lv-ha579e98adb61", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "760d4f618831df45f267995bcc9fefdbea03ca4c67729f3df415f219b942bc13", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 18856, "excerpt": "& canonical_sql ( & format ! ( \"SELECT claims.kind, claims.body, claims.accepted_at_unix_ms FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind IN ({STEP_TIM"}, {"id": "lv-ha72ce7d5f839", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "595a28365d672025c7aa2cd823bae247873c8d385e057ca0c3ecc268d37c7c0b", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 16433, "excerpt": "INSERT OR IGNORE INTO mission_runs(id, mission_id, initial_revision, current_generation_id, root_revision, root_run_id, parent_step_run, workspace, requester, inputs, mode, status, phase, created_at_u"}, {"id": "lv-ha8b271924da3", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "f67407213e838e5f2e13a1f1d64ffd1860b7693cd608733f031068e6a8f93109", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 10876, "excerpt": "params ! [ fence . subject , i64 : : MAX ] , | row | row . get ( 0 )"}, + {"id": "lv-ha92e9efed614", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "78a5f84f3bbdabc885b58025115065b783b171aaca054e2c48d7e151a6baa9c0", "owner": "fractal_store::store", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 11167, "excerpt": "CREATE TABLE step_runs(subject TEXT, agentless INTEGER, assignee TEXT, available_to TEXT); INSERT INTO step_runs VALUES('step-run/test/work',0,'node/worker','[]'); INSERT INTO step_runs VALUES('step-r"}, {"id": "lv-ha96ba11ea383", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "d857c61275e1f0c7fb49cffdbe76fa3a76c33e900003e132c71651bbbd0c83a8", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12706, "excerpt": "& canonical_sql ( \"SELECT id, subject, body, accepted_at_unix_ms FROM claims WHERE kind='harness.diagnostic' AND json_extract(body, '$.fields.code')='work-wake-exhausted' AND accepted_at_unix_ms<=?1 O"}, {"id": "lv-ha99b8c434d96", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "5fed957da5046e18647b25569ba1a9112ec51129e66cc7e267c8d58e7c862c57", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 15050, "excerpt": "& query ( \"claims.subject>=?1 AND claims.subject Date: Sat, 7 Nov 2026 19:29:49 +0100 Subject: [PATCH 009/132] chore(missions): retain generated SQL catalog ordering agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- docs/st3/large-value-sites.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index da592f701a2..97b2fdf5061 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -26,7 +26,6 @@ ], "policy": "Source-site transitional debt only; no numeric threshold, value-size proof, logical cap or historical rejection. See large-values.md.", "declarations": [ - {"columns": [{"declaration": "TEXT", "declared_type": "TEXT", "name": "subject", "value_size_proven": false}, {"declaration": "INTEGER", "declared_type": "INTEGER", "name": "agentless", "value_size_proven": false}, {"declaration": "TEXT", "declared_type": "TEXT", "name": "assignee", "value_size_proven": false}, {"declaration": "TEXT", "declared_type": "TEXT", "name": "available_to", "value_size_proven": false}], "path": "crates/fractal-store/src/store.rs", "site": "lv-ha92e9efed614", "table_pattern": "step_runs", "unresolved_template": false}, {"site": "lv-h670107940355", "path": "crates/fractal-api-core/src/api/tests.rs", "table_pattern": "legacy_fixture_events", "columns": [{"name": "store_index", "declared_type": "INTEGER", "declaration": "INTEGER PRIMARY KEY", "value_size_proven": false}, {"name": "kind", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}, {"name": "subject", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}, {"name": "body", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}], "unresolved_template": false}, {"site": "lv-h4eb038021bdb", "path": "crates/fractal-api-core/src/api/tests.rs", "table_pattern": "legacy_fixture_events", "columns": [{"name": "store_index", "declared_type": "INTEGER", "declaration": "INTEGER PRIMARY KEY", "value_size_proven": false}, {"name": "kind", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}, {"name": "subject", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}, {"name": "body", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}], "unresolved_template": false}, {"site": "lv-h6a20397a303e", "path": "crates/fractal-daemon/src/store_tests/client_message_reminder_deletion.rs", "table_pattern": "reminder_delete_context", "columns": [{"name": "subject", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}], "unresolved_template": false}, @@ -526,6 +525,7 @@ {"site": "lv-h6c68f9b18a32", "path": "crates/fractal-store/src/store.rs", "table_pattern": "batches", "columns": [{"name": "id", "declared_type": "TEXT", "declaration": "TEXT PRIMARY KEY", "value_size_proven": false}, {"name": "origin", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}, {"name": "replica_sequence", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL", "value_size_proven": false}, {"name": "previous_hash", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}, {"name": "hash", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}, {"name": "accepted_at_unix_ms", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}], "unresolved_template": false}, {"site": "lv-h608dfaa2b410", "path": "crates/fractal-store/src/store.rs", "table_pattern": "planning_sessions", "operation": "add-column", "columns": [{"name": "planner_spec_json", "declared_type": "TEXT", "declaration": "TEXT NOT NULL DEFAULT '{\"provider\":\"codex\"}'", "value_size_proven": false}], "unresolved_template": false}, {"site": "lv-he6605c6a6184", "path": "crates/fractal-store/src/store.rs", "table_pattern": "mission_run_deadlines", "columns": [{"name": "run_id", "declared_type": "TEXT", "declaration": "TEXT PRIMARY KEY REFERENCES mission_runs(id)", "value_size_proven": false}, {"name": "timeout_ms", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL", "value_size_proven": false}, {"name": "deadline_at_unix_ms", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}], "unresolved_template": false}, + {"site": "lv-ha92e9efed614", "path": "crates/fractal-store/src/store.rs", "table_pattern": "step_runs", "columns": [{"name": "subject", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}, {"name": "agentless", "declared_type": "INTEGER", "declaration": "INTEGER", "value_size_proven": false}, {"name": "assignee", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}, {"name": "available_to", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}], "unresolved_template": false}, {"site": "lv-hcf6cbfeb4bd3", "path": "crates/fractal-store/src/store.rs", "table_pattern": "digest_rows", "columns": [{"name": "value", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}], "unresolved_template": false}, {"site": "lv-h05bfc7540344", "path": "crates/fractal-store/src/store.rs", "table_pattern": "heal_replay_base_order", "columns": [{"name": "ordinal", "declared_type": "INTEGER", "declaration": "INTEGER PRIMARY KEY", "value_size_proven": false}, {"name": "claim_id", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}], "unresolved_template": false}, {"site": "lv-h76acec99c00b", "path": "crates/smallclaims/src/ivm/asynchronous.rs", "table_pattern": "ivm_async_state", "columns": [{"name": "singleton", "declared_type": "INTEGER", "declaration": "INTEGER PRIMARY KEY CHECK(singleton=1)", "value_size_proven": false}, {"name": "format", "declared_type": "TEXT", "declaration": "TEXT NOT NULL", "value_size_proven": false}, {"name": "queue_rows", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL", "value_size_proven": false}, {"name": "queue_bytes", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL", "value_size_proven": false}, {"name": "page_rows", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL", "value_size_proven": false}, {"name": "page_bytes", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL", "value_size_proven": false}, {"name": "legacy_rank_rows", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL", "value_size_proven": false}, {"name": "retained_rows", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL DEFAULT 0", "value_size_proven": false}, {"name": "retained_bytes", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL DEFAULT 0", "value_size_proven": false}, {"name": "available", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL DEFAULT 1", "value_size_proven": false}, {"name": "error", "declared_type": "TEXT", "declaration": "TEXT", "value_size_proven": false}, {"name": "capturing", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL DEFAULT 0", "value_size_proven": false}, {"name": "draining", "declared_type": "INTEGER", "declaration": "INTEGER NOT NULL DEFAULT 0", "value_size_proven": false}], "unresolved_template": false}, From c5b54dfd45d4fe7ba8ff6665e4a097700e3d128b Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:57:35 +0100 Subject: [PATCH 010/132] fix(missions): gate legacy ownership and unify queue actor equivalence agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-core/src/api.rs | 4 +- .../src/api/published_lists_tests.rs | 42 ++++++++++++ crates/fractal-kernel/src/seat_queue.rs | 63 ++++++++++++++++-- crates/fractal-missions/src/store/missions.rs | 20 ++++-- .../src/store/work_renew_tests.rs | 64 +++++++++++++++++++ crates/fractal-reconcile/src/reconcile.rs | 28 ++++++-- crates/fractal-store/src/store.rs | 64 +++++++++++-------- .../fractal-store/src/store/marks_switch.rs | 2 +- crates/fractal-store/src/store/missions.rs | 14 ++-- .../src/store/seat_queue_plan_tests.rs | 2 +- crates/fractal-store/src/store/tests.rs | 3 +- crates/fractal-store/src/store/work_list.rs | 17 +---- crates/fractal/src/main.rs | 2 + docs/st3/large-value-sites.json | 4 +- release-notes/mission-agentid-canonical.json | 4 +- 15 files changed, 261 insertions(+), 72 deletions(-) diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 75b10c202a8..0747414c6bd 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -2730,8 +2730,8 @@ pub fn client_work_resources( // that chooses its next work and wake. let seat_ready = match actor { Some(actor) => { - // Match the canonical actor used by the store's work filter before selecting - // ready steps; a legacy slash-qualified ID is not itself a seat actor. + // Queue lookup uses the same canonical actor as the shared seat selector, + // including when persisted steps still use a legacy spelling. let seat = fractal_schema::canonical_agent_actor(actor) .unwrap_or(std::borrow::Cow::Borrowed(actor)); let order = store.seat_run_order(&seat)?; diff --git a/crates/fractal-api-core/src/api/published_lists_tests.rs b/crates/fractal-api-core/src/api/published_lists_tests.rs index 922356976e3..4c5efaab397 100644 --- a/crates/fractal-api-core/src/api/published_lists_tests.rs +++ b/crates/fractal-api-core/src/api/published_lists_tests.rs @@ -335,6 +335,48 @@ fn published_work_keeps_persisted_legacy_assignments_and_available_seats_visible } } +#[test] +fn mixed_spelling_ready_rows_keep_queue_order_in_direct_and_published_work() { + let _clock = Clock::at(start_time()); + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("graph.db"); + let store = Store::open(&path, "cedar").unwrap(); + mission(&store, "garden/alpha"); + let first = start(&store, "garden/alpha", "mixed-first"); + let second = start(&store, "garden/alpha", "mixed-second"); + let (first_subject, _) = step(&first, "plant"); + let (second_subject, _) = step(&second, "plant"); + for subject in [&first_subject, &second_subject] { + store.set_step_state(subject, "ready", None).unwrap(); + } + let connection = rusqlite::Connection::open(&path).unwrap(); + smallclaims::store::projection_digest::register(&connection).unwrap(); + for (subject, assignee, created) in [ + (&first_subject, "node/worker", "10"), + (&second_subject, "agent/node/worker", "20"), + ] { + connection.execute( + /* sqlite-large-value: allow-for-now lv-hb7cf1dc01667 owner=fractal_api_core::api::published_lists_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2, created_at_unix_ms=?3 WHERE subject=?1", + rusqlite::params![subject, assignee, created], + ).unwrap(); + } + drop(connection); + let (publication, _) = fold_work_checked(&store); + for actor in ["node/worker", "agent/node/worker"] { + let order = store.seat_run_order(actor).unwrap(); + let work = store.client_work_at_snapshot(Some(actor), false, publication.rows.time_unix_ms).unwrap(); + let steps = work.iter().map(crate::seat_queue::SeatStep::from).collect::>(); + let selected = crate::seat_queue::select(actor, &steps, &order); + assert_eq!(selected.ready, [&*first_subject, &*second_subject], + "both spellings have finite ready ranks, with the earlier run first"); + let direct = work_oracle(&store, publication.rows.time_unix_ms, Some(actor), usize::MAX).0; + let (indexes, _) = publication.rows.actor_rows(actor); + assert_eq!(indexes.iter().map(|&index| publication.rows.order[index as usize].as_str()) + .collect::>(), [&*first_subject, &*second_subject]); + assert_eq!(publication.rows.page_of(Some(&indexes), 0, usize::MAX).0, direct); + } +} + #[test] fn published_work_matches_the_direct_read_for_every_actor_as_time_passes() { let started = start_time(); diff --git a/crates/fractal-kernel/src/seat_queue.rs b/crates/fractal-kernel/src/seat_queue.rs index 32a4534c3b2..1647269cb26 100644 --- a/crates/fractal-kernel/src/seat_queue.rs +++ b/crates/fractal-kernel/src/seat_queue.rs @@ -13,6 +13,22 @@ use serde::{Deserialize, Serialize}; pub const MOVED_CLAIM: &str = "agent.queue.moved"; +/// Compare work actors without changing persisted rows or conflating recognized actor kinds. +pub fn work_actor_matches(candidate: Option<&str>, actor: &str) -> bool { + let Some(candidate) = candidate.and_then(fractal_schema::canonical_agent_actor) else { + return false; + }; + fractal_schema::canonical_agent_actor(actor).as_deref() == Some(candidate.as_ref()) +} + +fn same_actor(left: Option<&str>, right: Option<&str>) -> bool { + match (left, right) { + (Some(left), Some(right)) => work_actor_matches(Some(left), right), + (None, None) => true, + _ => false, + } +} + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum Placement { @@ -222,7 +238,7 @@ pub fn select<'a>(agent: &str, steps: &[SeatStep<'a>], run_order: &[String]) -> .iter() .filter(|step| { matches!(step.status, "claimed" | "working" | "verifying") - && (step.claimant == Some(agent) || step.assignee == Some(agent)) + && (work_actor_matches(step.claimant, agent) || work_actor_matches(step.assignee, agent)) && !submitted_with_ready_nested_work(step, steps) }) .map(|step| step.subject) @@ -237,11 +253,11 @@ pub fn select<'a>(agent: &str, steps: &[SeatStep<'a>], run_order: &[String]) -> .iter() .filter(|step| { step.status == "ready" - && step.assignee == Some(agent) + && work_actor_matches(step.assignee, agent) && !reached_through_parent(step, steps) }) .collect::>(); - let waits_its_turn = |step: &SeatStep<'_>| step.carried_claimant != Some(agent); + let waits_its_turn = |step: &SeatStep<'_>| !work_actor_matches(step.carried_claimant, agent); ready.sort_by(|left, right| { waits_its_turn(left) .cmp(&waits_its_turn(right)) @@ -268,8 +284,11 @@ pub fn reached_through_parent(step: &SeatStep<'_>, steps: &[SeatStep<'_>]) -> bo /// both have the same selector. pub fn nests_under(ancestor: &SeatStep<'_>, step: &SeatStep<'_>) -> bool { ancestor.run == step.run - && ancestor.assignee == step.assignee - && ancestor.available_to == step.available_to + && same_actor(ancestor.assignee, step.assignee) + && ancestor.available_to.len() == step.available_to.len() + && ancestor.available_to.iter().zip(step.available_to).all(|(left, right)| { + work_actor_matches(Some(left), right) + }) && ancestor.step.len() < step.step.len() && step.step.starts_with(ancestor.step) && step.step.as_bytes().get(ancestor.step.len()) == Some(&b'/') @@ -431,6 +450,40 @@ mod tests { } } + #[test] + fn mixed_actor_spellings_share_ready_held_carried_and_nested_selection() { + let available = vec!["node/worker".to_owned()]; + let canonical_available = vec!["agent/node/worker".to_owned()]; + let first = SeatStep { + subject: "step-run/first/build", run: "mission-run/first", step: "build", + status: "ready", assignee: Some("node/worker"), claimant: None, + available_to: &available, created_at_unix_ms: 10, carried_claimant: None, + }; + let second = SeatStep { + subject: "step-run/second/build", run: "mission-run/second", + assignee: Some("agent/node/worker"), created_at_unix_ms: 20, ..first + }; + let order = vec![first.run.to_owned(), second.run.to_owned()]; + for actor in ["node/worker", "agent/node/worker"] { + assert_eq!(select(actor, &[second, first], &order).ready, [first.subject, second.subject]); + let carried = SeatStep { carried_claimant: Some("node/worker"), ..second }; + assert_eq!(select(actor, &[first, carried], &order).next(), Some(second.subject)); + let held = SeatStep { status: "working", claimant: Some("node/worker"), ..second }; + assert_eq!(select(actor, &[first, held], &order).held, [second.subject]); + } + let nested = SeatStep { + subject: "step-run/first/build/check", step: "build/check", + assignee: Some("agent/node/worker"), available_to: &canonical_available, ..first + }; + assert!(reached_through_parent(&nested, &[first, nested])); + let submitted = SeatStep { status: "verifying", ..first }; + assert!(submitted_with_ready_nested_work(&submitted, &[submitted, nested])); + for other in ["daemon/node/worker", "person/node/worker", "host/node/worker"] { + assert!(select(other, &[first, second], &order).ready.is_empty()); + assert!(!nests_under(&SeatStep { assignee: Some(other), ..first }, &nested)); + } + } + #[test] fn nested_steps_are_reached_through_their_listed_parent_only() { let none = Vec::new(); diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 13de145c8a3..373d82edad6 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -913,15 +913,17 @@ fn seat_order_conflict_tx( agent: &str, subject: &str, ) -> Result> { - let order = seat_run_orders_tx(connection, Some(agent))? - .remove(agent) + let agent = fractal_schema::canonical_agent_actor(agent) + .unwrap_or(std::borrow::Cow::Borrowed(agent)); + let order = seat_run_orders_tx(connection, Some(&agent))? + .remove(agent.as_ref()) .unwrap_or_default(); - let rows = seat_step_rows_tx(connection, Some(agent))?; + let rows = seat_step_rows_tx(connection, Some(&agent))?; let steps = rows .iter() .map(RosterStepRow::seat_step) .collect::>(); - let selection = seat_queue::select(agent, &steps, &order); + let selection = seat_queue::select(&agent, &steps, &order); let Some(next) = selection.next() else { return Ok(None); }; @@ -6192,13 +6194,17 @@ impl MissionsStore for Store { } let mut lease_actor = actor.as_str(); // LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership — DELETE at contraction — https://github.com/compoundingtech/fractal/pull/5582 - // Old fleet members compare eligibility and ownership literally. Preserve a - // legacy assignment's spelling until every fleet member compares canonical - // identities; a later contraction PR migrates the rows and removes this arm. + // Old validators reject raw claimants. Preserve legacy eligibility spelling only + // after every current member advertises the internal admission bridge; otherwise + // retain main's canonical claimant. A later contraction PR migrates the rows and + // removes this arm once every fleet member compares canonical identities. if let Some(legacy) = current.assigned_to.as_deref().into_iter() .chain(current.available_to.iter().map(String::as_str)) .find(|candidate| *candidate != actor.as_str() && work_actor_matches(Some(*candidate), &actor)) + && fractal_store::store::marks_switch::feature_blockers( + transaction, &self.origin, &[], "work-claimant-legacy-admission", + ).map_err(internal)?.is_empty() { lease_actor = legacy; } diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index f7341d06a82..ceff05030f7 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -509,6 +509,70 @@ fn ready_step_with_mail(mail: usize) -> Renewal { Renewal { _directory: directory, store, subject } } +#[test] +fn legacy_claimant_writes_require_every_current_members_admission_capability() { + use fractal_store::store::directive_notes::tests_shared_delivery::{ + FLEET, claim, enable, sync, + }; + const FEATURE: &str = "work-claimant-legacy-admission"; + for pool in [false, true] { + for legacy in ["node/worker", "agent/node/worker"] { + let step = ready_step_with_mail(0); + let store = &step.store; + let anchor = enable(store); + let peer_key = Arc::new(smallclaims::fleet::MemberKey::generate().unwrap().0); + claim(store, "host/peer", "fleet.member-admitted", None, + json!({"fleet_id": FLEET, "member_key": peer_key.public(), "via": "invite", + "sponsor": "host/node", "mode": "listening"})); + store.replication_snapshot().unwrap(); + let peer = Store::open_memory("peer").unwrap(); + peer.bind_fleet(FLEET).unwrap(); + peer.pin_fleet_anchor(anchor.public()).unwrap(); + peer.set_member_key(Some(peer_key)).unwrap(); + sync(store, &peer); + store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-hb967f9dc4fa2 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ /* sqlite-large-value: allow-for-now lv-h755979e290e9 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2,available_to=?3 WHERE subject=?1", + params![step.subject, (!pool).then_some(legacy), + if pool { json!([legacy]).to_string() } else { "[]".into() }], + ).unwrap(); + for (index, advertised) in [None, Some(1), Some(0), Some(1)].into_iter().enumerate() { + // A missing feature, or a restarted old build, must retain main's spelling. + let features = advertised.map_or_else(|| json!({}), |version| json!({FEATURE: version})); + claim(&peer, "daemon/peer", "daemon.started", None, + json!({"status": "running", "features": features})); + sync(&peer, store); + let mut request = request(&format!("capability-claim-{index}"), Some("publish")); + request.actor = Some(legacy.into()); + let action = if index == 0 { "claim" } else { "renew" }; + let view = store.work_action(&step.subject, action, &request).unwrap(); + let expected = if advertised == Some(1) { legacy } else { "agent/node/worker" }; + assert_eq!(view.claimant.as_deref(), Some(expected)); + let kind = if index == 0 { "work.claimed" } else { "work.renewed" }; + let fact = store.claims_for(&step.subject, Some(kind)).unwrap().pop().unwrap(); + assert_eq!(fact.body["fields"]["claimant"], json!(expected)); + let fields: BTreeMap = + serde_json::from_value(fact.body["fields"].clone()).unwrap(); + let strict = fractal_schema::registry().validate_claim(&fact.subject, &fact.kind, &fields); + if advertised != Some(1) { + assert!(strict.is_ok(), "old validators must accept the gated fact: {strict:?}"); + // This is the old capacity guard's literal ownership predicate, not the + // new canonical read bridge. Main normalizes both inputs to this actor. + let held: bool = store.readers.get().query_row( + "SELECT EXISTS(SELECT 1 FROM step_runs WHERE subject=?1 AND lease_owner=?2)", + params![step.subject, "agent/node/worker"], |row| row.get(0), + ).unwrap(); + assert!(held, "an old capacity guard must still see this held lease"); + let mut raw = fields; + raw.insert("claimant".into(), json!("node/worker")); + assert!(fractal_schema::registry() + .validate_claim(&fact.subject, &fact.kind, &raw).is_err(), + "the pre-bridge validator rejects raw claimants"); + } + } + } + } +} + #[test] fn work_claim_preserves_assignment_spelling_for_mixed_version_ownership() { for (actor, assignee) in [ diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index fb760cf4d23..3b5c61dcbfb 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -20097,11 +20097,6 @@ fn work_wake_deadline( }) .collect::>(); work.iter() - .filter(|step| { - step.assigned_to - .as_ref() - .is_some_and(|assignee| local_agents.contains(assignee)) - }) .filter(|step| wakes.contains(step.subject.as_str())) .filter_map(|step| { let wake = step.wake.as_ref()?; @@ -49110,6 +49105,17 @@ mission "ios-proof-blocked" state="ready" { updated_at_unix_ms: 1, person_answers: Vec::new(), }; + let legacy = step("step-run/run-1/legacy", "legacy", "node/worker"); + assert_eq!( + next_work_wake_for_agent("agent/node/worker", std::slice::from_ref(&legacy), &[]), + Some(legacy.subject.as_str()), + "a legacy-only ready row must wake its canonical seat" + ); + assert_eq!( + next_work_wake_for_agent("daemon/node/worker", std::slice::from_ref(&legacy), &[]), + None, + "recognized non-agent kinds remain distinct" + ); let parent = step("step-run/run-1/build", "build", "agent/builder"); let inherited = step( "step-run/run-1/build/work/inspect", @@ -49275,6 +49281,18 @@ mission "ios-proof-blocked" state="ready" { updated_at_unix_ms: 10, person_answers: Vec::new(), }; + let mut legacy = first.clone(); + legacy.assigned_to = Some("node/worker".into()); + assert_eq!( + work_wake_deadline( + &[legacy], + &BTreeSet::from(["agent/node/worker".into()]), + &BTreeMap::new(), + 2_000, + ), + Some(1_000 + WORK_WAKE_RETRY_MS), + "a legacy-only ready row keeps its canonical seat's wake deadline" + ); let mut second = first.clone(); second.subject = "step-run/newer/work".into(); second.run = "mission-run/newer".into(); diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index b6971e04f2a..24b567bd740 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -18813,7 +18813,7 @@ impl RosterStepRow { } } -/// The held and ready agent steps of seat `?1`, or of every seat when null. The unfinished-step +/// The held and ready agent steps of seat spellings `?1`, or of every seat when null. The unfinished-step /// predicate, word for word that of `step_runs_open_index`, reads the fleet's open steps rather /// than every step the store has run. const SEAT_STEP_ROWS: &str = "SELECT subject, run_id, step_path, status, assignee, lease_owner, @@ -18822,7 +18822,8 @@ const SEAT_STEP_ROWS: &str = "SELECT subject, run_id, step_path, status, assigne WHERE agentless=0 AND status IN ('ready', 'claimed', 'working', 'verifying') AND status NOT IN ('completed','failed','cancelled') - AND (?1 IS NULL OR assignee=?1 OR lease_owner=?1) + AND (?1 IS NULL OR assignee IN (SELECT value FROM json_each(?1)) + OR lease_owner IN (SELECT value FROM json_each(?1))) AND generation_id=(SELECT current_generation_id FROM mission_runs WHERE id=step_runs.run_id) AND NOT ((status='ready' OR (status IN ('claimed','working','verifying') AND lease_expires_at_unix_ms IS NOT NULL @@ -18860,15 +18861,15 @@ impl SeatQueueInputs { } } -/// When each seat `?1` (every seat when null) joined each open run it has a step in, and the +/// When each seat spelling in `?1` (every seat when null) joined each open run it has a step in, and the /// run's status. It reads from the open runs to their steps, in that order: from the steps, /// SQLite reads every step any seat was ever assigned before it finds the open runs among them. -/// Every seat's queue moves (`?2` null), or one seat's, that no repair replaced, in graph order. +/// Every seat's queue moves (`?2` null), or one seat's spelling array, that no repair replaced, in graph order. pub fn seat_queue_moves_query() -> String { canonical_sql( "SELECT claims.id, claims.subject, claims.actor, claims.body, claims.accepted_at_unix_ms FROM claims JOIN batches ON batches.id=claims.batch_id - WHERE claims.kind=?1 AND (?2 IS NULL OR claims.subject=?2) + WHERE claims.kind=?1 AND (?2 IS NULL OR claims.subject IN (SELECT value FROM json_each(?2))) AND NOT EXISTS ( SELECT 1 FROM replica_records WHERE replica_records.claim_id=claims.id @@ -18881,35 +18882,30 @@ pub fn seat_queue_moves_query() -> String { pub fn seat_queue_joins_query() -> String { format!( "SELECT step_runs.assignee, step_runs.run_id, - MIN(CAST(step_runs.created_at_unix_ms AS INTEGER)), mission_runs.status + MIN(CAST(step_runs.created_at_unix_ms AS INTEGER)), mission_runs.status, + MAX(step_runs.generation_id=mission_runs.current_generation_id) FROM mission_runs CROSS JOIN step_runs ON step_runs.run_id=mission_runs.id WHERE {OPEN_MISSION_RUN} AND mission_runs.phase<>'terminal' AND step_runs.agentless=0 AND step_runs.assignee IS NOT NULL - AND (?1 IS NULL OR step_runs.assignee=?1) + AND (?1 IS NULL OR step_runs.assignee IN (SELECT value FROM json_each(?1))) GROUP BY step_runs.assignee, step_runs.run_id - HAVING MAX(step_runs.generation_id=mission_runs.current_generation_id)=1 ORDER BY step_runs.assignee, step_runs.run_id" ) } -/// When seat `?1` joined run `?2`, a run its queue moves name that is no longer queued, and the +/// When a seat spelling in `?1` joined run `?2`, a run its queue moves name that is no longer queued, and the /// run's status. `+step_runs.assignee` keeps SQLite on the run's own steps (`step_runs_run_index`): /// through the assignee index it read every step the seat was ever assigned, once per named run, /// and a seat that has moved its queue often names a hundred runs on every claim. pub const SEAT_QUEUE_NAMED_RUN_QUERY: &str = "SELECT MIN(CAST(step_runs.created_at_unix_ms AS INTEGER)), mission_runs.status FROM step_runs JOIN mission_runs ON mission_runs.id=step_runs.run_id - WHERE +step_runs.assignee=?1 AND step_runs.run_id=?2 AND step_runs.agentless=0"; + WHERE +step_runs.assignee IN (SELECT value FROM json_each(?1)) + AND step_runs.run_id=?2 AND step_runs.agentless=0"; -/// Compare work actors without changing persisted rows or conflating recognized actor kinds. -pub fn work_actor_matches(candidate: Option<&str>, actor: &str) -> bool { - let Some(candidate) = candidate.and_then(fractal_schema::canonical_agent_actor) else { - return false; - }; - fractal_schema::canonical_agent_actor(actor).as_deref() == Some(candidate.as_ref()) -} +pub use crate::seat_queue::work_actor_matches; /// Exact persisted spellings of one logical work actor, as a JSON array for indexed SQL reads. /// Only a prefixless name that canonicalizes back to the same actor is an alias. @@ -18987,9 +18983,10 @@ pub fn seat_queue_inputs_of_tx( agent: Option<&str>, only: Option<&BTreeSet>, ) -> Result> { + let spellings = agent.map(work_actor_spellings_json); let mut seats = BTreeMap::::new(); let mut statement = connection.prepare(/* sqlite-large-value: allow-for-now lv-hd2995294a599 owner=fractal_store::store TODO=trace-statement-and-value-representation */ &seat_queue_moves_query())?; - let rows = statement.query_map(params![seat_queue::MOVED_CLAIM, agent], |row| { + let rows = statement.query_map(params![seat_queue::MOVED_CLAIM, spellings], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, @@ -19000,6 +18997,8 @@ pub fn seat_queue_inputs_of_tx( })?; for row in rows { let (claim_id, subject, actor, body, accepted_at) = row?; + let subject = fractal_schema::canonical_agent_actor(&subject) + .map(std::borrow::Cow::into_owned).unwrap_or(subject); let body = serde_json::from_str::(&body).unwrap_or(Value::Null); let fields = body.get("fields").unwrap_or(&body); let text = |name: &str| fields.get(name).and_then(Value::as_str).map(str::to_owned); @@ -19036,24 +19035,27 @@ pub fn seat_queue_inputs_of_tx( drop(statement); let mut statement = connection.prepare(/* sqlite-large-value: allow-for-now lv-h9270afe17ad4 owner=fractal_store::store TODO=trace-statement-and-value-representation */ &seat_queue_joins_query())?; - let rows = statement.query_map(params![agent], |row| { + let rows = statement.query_map(params![spellings], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, row.get::<_, String>(3)?, + row.get::<_, bool>(4)?, )) })?; for row in rows { - let (seat, run_id, joined_at, status) = row?; + let (seat, run_id, joined_at, status, current) = row?; + let seat = fractal_schema::canonical_agent_actor(&seat) + .map(std::borrow::Cow::into_owned).unwrap_or(seat); let run = format!("mission-run/{run_id}"); let inputs = seats.entry(seat).or_default(); - inputs.joins.push(QueueJoin { - run: run.clone(), - at_unix_ms: u128::try_from(joined_at).unwrap_or_default(), - }); + let at_unix_ms = u128::try_from(joined_at).unwrap_or_default(); + inputs.joins.push(QueueJoin { run: run.clone(), at_unix_ms }); inputs.statuses.insert(run.clone(), status); - inputs.live.insert(run); + if current { + inputs.live.insert(run); + } } drop(statement); @@ -19061,7 +19063,17 @@ pub fn seat_queue_inputs_of_tx( if let Some(only) = only { seats.retain(|seat, _| only.contains(seat)); } + seats.retain(|_, inputs| !inputs.live.is_empty() || !inputs.moves.is_empty()); for (seat, inputs) in &mut seats { + inputs.joins.retain(|join| inputs.live.contains(&join.run)); + // Mixed spellings can contribute different generations of the same run. + // Keep their earliest join, but only current-generation membership makes it live. + inputs.joins.sort_unstable_by(|left, right| { + left.run.cmp(&right.run).then(left.at_unix_ms.cmp(&right.at_unix_ms)) + }); + inputs.joins.dedup_by(|left, right| left.run == right.run); + inputs.statuses.retain(|run, _| inputs.live.contains(run)); + let spellings = work_actor_spellings_json(seat); let moves = inputs .moves .iter() @@ -19074,7 +19086,7 @@ pub fn seat_queue_inputs_of_tx( #[cfg(any(test, feature = "test-support"))] NAMED_RUN_PROBES.with(|probes| probes.set(probes.get() + 1)); let joined = statement - .query_row(/* sqlite-large-value: allow-for-now lv-h79c5fdb57d22 owner=fractal_store::store TODO=trace-statement-and-value-representation */ params![seat, run_id], |row| { + .query_row(/* sqlite-large-value: allow-for-now lv-hf01b6d5cecf5 owner=fractal_store::store TODO=trace-statement-and-value-representation */ params![spellings, run_id], |row| { Ok(( row.get::<_, Option>(0)?, row.get::<_, Option>(1)?, diff --git a/crates/fractal-store/src/store/marks_switch.rs b/crates/fractal-store/src/store/marks_switch.rs index 1eb732363ac..f0992a1e23e 100644 --- a/crates/fractal-store/src/store/marks_switch.rs +++ b/crates/fractal-store/src/store/marks_switch.rs @@ -67,7 +67,7 @@ pub fn staged_blockers(connection: &Connection, origin: &str, peers: &[String]) } /// The barrier for one `daemon.started` feature, read the same strict way for each. -fn feature_blockers(connection: &Connection, origin: &str, peers: &[String], feature: &str) -> Result> { +pub fn feature_blockers(connection: &Connection, origin: &str, peers: &[String], feature: &str) -> Result> { let membership = fleet_membership_tx(connection)?; let mut blockers = Vec::new(); let mut members = BTreeSet::new(); diff --git a/crates/fractal-store/src/store/missions.rs b/crates/fractal-store/src/store/missions.rs index e18eb21f43a..9dfa4fa222d 100644 --- a/crates/fractal-store/src/store/missions.rs +++ b/crates/fractal-store/src/store/missions.rs @@ -644,15 +644,16 @@ impl Store { let connection = self.readers.get(); let orders = seat_run_orders_tx(&connection, selected_agent)?; let rows = seat_step_rows_tx(&connection, selected_agent)?; - let mut seats = BTreeMap::<&str, Vec>>::new(); + let mut seats = BTreeMap::, Vec>>::new(); for row in &rows { let step = row.seat_step(); for agent in [row.assignee.as_deref(), row.claimant.as_deref()] .into_iter() .flatten() + .filter_map(fractal_schema::canonical_agent_actor) .collect::>() { - if selected_agent.is_some_and(|selected| selected != agent) { + if selected_agent.is_some_and(|selected| !work_actor_matches(Some(&agent), selected)) { continue; } seats.entry(agent).or_default().push(step); @@ -660,13 +661,13 @@ impl Store { } let mut queues = BTreeMap::::new(); for (agent, steps) in seats { - let order = orders.get(agent).map(Vec::as_slice).unwrap_or_default(); - let selection = seat_queue::select(agent, &steps, order); + let order = orders.get(agent.as_ref()).map(Vec::as_slice).unwrap_or_default(); + let selection = seat_queue::select(&agent, &steps, order); if selection.held.is_empty() && selection.ready.is_empty() { continue; } queues.insert( - agent.to_owned(), + agent.into_owned(), AgentWorkQueue { current_work_ids: selection .held @@ -1780,9 +1781,10 @@ pub fn note_run_view_reads(view: &MissionRunView) { /// Current held and ready agent steps, for every seat or for one seat. pub fn seat_step_rows_tx(connection: &Connection, agent: Option<&str>) -> Result> { let snapshot_unix_ms = now_ms(); + let spellings = agent.map(work_actor_spellings_json); let mut statement = connection.prepare_cached(/* sqlite-large-value: allow-for-now lv-h0096cec23ed6 owner=fractal_store::store::missions TODO=trace-statement-and-value-representation */ SEAT_STEP_ROWS)?; let mut rows = statement - .query_map(params![agent, snapshot_unix_ms.to_string()], |row| { + .query_map(params![spellings, snapshot_unix_ms.to_string()], |row| { let mut status: String = row.get(3)?; let mut claimant = row.get(5)?; let expires: Option = row.get(8)?; diff --git a/crates/fractal-store/src/store/seat_queue_plan_tests.rs b/crates/fractal-store/src/store/seat_queue_plan_tests.rs index c2b9edbe9af..687cc1e5708 100644 --- a/crates/fractal-store/src/store/seat_queue_plan_tests.rs +++ b/crates/fractal-store/src/store/seat_queue_plan_tests.rs @@ -7,7 +7,7 @@ fn a_named_run_probe_reads_the_runs_steps_not_every_step_of_the_seat() { let plan = writer .prepare(/* sqlite-large-value: allow-for-now lv-hf932ad4cefc8 owner=fractal_store::store::seat_queue_plan_tests TODO=trace-statement-and-value-representation */ &format!("EXPLAIN QUERY PLAN {SEAT_QUEUE_NAMED_RUN_QUERY}")) .unwrap() - .query_map(params!["agent/garden/seat", "garden/tend"], |row| row.get::<_, String>(3)) + .query_map(params![work_actor_spellings_json("agent/garden/seat"), "garden/tend"], |row| row.get::<_, String>(3)) .unwrap() .collect::>>() .unwrap(); diff --git a/crates/fractal-store/src/store/tests.rs b/crates/fractal-store/src/store/tests.rs index 70d4728a87f..86aed09b9b8 100644 --- a/crates/fractal-store/src/store/tests.rs +++ b/crates/fractal-store/src/store/tests.rs @@ -3363,7 +3363,8 @@ fn a_prefixs_subjects_are_read_one_seek_per_subject() { fn seat_steps_are_read_from_the_open_steps() { let store = Store::open_memory("node").unwrap(); for agent in [None, Some("agent/seat")] { - let steps = query_plan(&store, SEAT_STEP_ROWS, params![agent, now_ms().to_string()]); + let spellings = agent.map(work_actor_spellings_json); + let steps = query_plan(&store, SEAT_STEP_ROWS, params![spellings, now_ms().to_string()]); assert!( steps .iter() diff --git a/crates/fractal-store/src/store/work_list.rs b/crates/fractal-store/src/store/work_list.rs index 03300a9be8c..f9923e18248 100644 --- a/crates/fractal-store/src/store/work_list.rs +++ b/crates/fractal-store/src/store/work_list.rs @@ -375,24 +375,11 @@ impl WorkRows { .map(|(index, step)| (index as u32, &self.rows[step].view)) .filter(|(_, view)| visible_to(view, viewer)) .collect::>(); - // The direct read canonicalizes legacy agent IDs before selecting ready work and - // looks the queue up by the same normalized seat. + // Queue lookup and the shared selector use the same canonical seat identity. let queue = self.seat_orders.get(&viewer_seat(viewer)).map_or(&[][..], |order| order.as_slice()); let steps = shown .iter() - .map(|(_, view)| { - let mut step = crate::seat_queue::SeatStep::from(&***view); - if super::work_actor_matches(step.assignee, viewer) { - step.assignee = Some(viewer); - } - if super::work_actor_matches(step.claimant, viewer) { - step.claimant = Some(viewer); - } - if super::work_actor_matches(step.carried_claimant, viewer) { - step.carried_claimant = Some(viewer); - } - step - }) + .map(|(_, view)| crate::seat_queue::SeatStep::from(&***view)) .collect::>(); let ready = crate::seat_queue::select(viewer, &steps, queue) .ready diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 29c6760e75e..e0e7265b1fd 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -7379,6 +7379,8 @@ async fn run_up(args: UpArgs) -> Result<()> { ( "features".into(), serde_json::json!({"owned_sets":1,"seat_rollout":1,"seat_rollout_manual":1,"person_directive_note":1,"schedule_standby":1,"github_plugin":2,"seat_freshness":1,"seat_freshness_reads":1,"message_offsets":1,"message_view_reads":1,"message_staged_reads":1,"mission_capacity":1, + // Admits legacy work claimants internally without weakening the public schema. + "work-claimant-legacy-admission": 1, "harness_access_auto": u8::from(config.active_access_mode() == fractal_drivers::access::AccessMode::Auto), "messages_stream": u8::from(messages_shard.is_ok()), // Counts its stream mail in its idle, unread, held and usage indexes. It diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 97b2fdf5061..b252e32a45a 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -897,6 +897,7 @@ {"id": "lv-h5326fa890870", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "0830a4e014a2f92f3cde33a3af36104c47cdd6cbbcaa1f79922de83c6bdf9fa6", "owner": "fractal_api_core::api::published_lists_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1651, "excerpt": "UPDATE claims SET accepted_at_unix_ms=CASE json_extract(body,'$.fields.status') WHEN 'working' THEN '1000' WHEN 'completed' THEN CAST(1000+?2 AS TEXT) ELSE accepted_at_unix_ms END WHERE subject=?1 AND"}, {"id": "lv-h5a9504380e4b", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "0a57095c5bf44350a4d3b0001f7f811cc665a4e720e998dfc88e00300ff68c38", "owner": "fractal_api_core::api::published_lists_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1715, "excerpt": "UPDATE claims SET accepted_at_unix_ms=CASE json_extract(body,'$.fields.status') WHEN 'working' THEN '1000' WHEN 'completed' THEN CAST(1000+?2 AS TEXT) ELSE accepted_at_unix_ms END WHERE subject=?1 AND"}, {"id": "lv-h832547b61b1f", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "ee5bc82da6f49b62f613cf8d7a8666426a9b294c94b59fd68c6fe53ad0bbdc82", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1700, "excerpt": "DROP TRIGGER run_eta_run_update; CREATE TRIGGER run_eta_run_update AFTER UPDATE ON mission_runs WHEN NEW.phase='terminal' OR OLD.phase='terminal' BEGIN INSERT OR IGNORE INTO local_run_eta_dirty SELECT"}, + {"id": "lv-hb7cf1dc01667", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "39c0ce46943adc6d3fa133a261008f77c00d77aa927d346dd8295ee44b6ef104", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 359, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms=?3 WHERE subject=?1"}, {"id": "lv-h18084a82300f", "path": "crates/fractal-api-core/src/api/replication_guard_tests.rs", "kind": "sql-template", "fingerprint": "c10e0cf4fe0b73347cfcb6da085067f1381609cc06e696d05b35addb31040074", "owner": "fractal_api_core::api::replication_guard_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 275, "excerpt": "DROP TABLE replica_envelope_holds"}, {"id": "lv-h85ba6985cde2", "path": "crates/fractal-api-core/src/api/replication_guard_tests.rs", "kind": "sql-template", "fingerprint": "fb53f45f34c425e53a75b2047afe076b5d4aedb8092b2c1db6e21644723c63f7", "owner": "fractal_api_core::api::replication_guard_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 187, "excerpt": "INSERT INTO replica_envelopes(writer,sequence,envelope_hash,accepted_at_unix_ms,payload,relay,receipt_state,received_at_unix_ms) VALUES('fixture',1,?1,'0',X'','fixture','pending','0')"}, {"id": "lv-ha2066549b853", "path": "crates/fractal-api-core/src/api/replication_guard_tests.rs", "kind": "sql-template", "fingerprint": "b708aad00db6c660b8c6f80f82ae68463637e72d7e50d6504ed42598315f087b", "owner": "fractal_api_core::api::replication_guard_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 63, "excerpt": "DROP TABLE replica_envelopes"}, @@ -1546,6 +1547,7 @@ {"id": "lv-haa4a50f8ccd9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "5a9caadd18a47cc8540d557e3d5eead5e9f21cf15b57a415bfb07cfeddbc56b6", "owner": "fractal_missions::store::work_renew_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 322, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {}\" , last_replicated_lease_query ( ) )"}, {"id": "lv-hac330e1ef0a3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "23d384584d619edc1b8135734fe4536e80b585db990943fa67e8dc9b10b08fa9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 584, "excerpt": "UPDATE step_runs SET assignee='agent/node/other', available_to='[]', status='ready', lease_owner=NULL, lease_incarnation=NULL, lease_expires_at_unix_ms=NULL WHERE subject=?1"}, {"id": "lv-haebfd1e4985b", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "004b63826474bc335db8f27b5d87f4a6dff9a32f4303f80ce1d7e3f10e5bee0d", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 138, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?2, updated_at_unix_ms=?3 WHERE subject=?1"}, + {"id": "lv-hb967f9dc4fa2", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "c44dad4630c100b2d853c063eb0b42e8aa292037ff4fced92a7713bb1ce79e11", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 534, "excerpt": "UPDATE step_runs SET assignee=?2,available_to=?3 WHERE subject=?1"}, {"id": "lv-hbbb772f82848", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "f5a08f92b6484e142bfc0fac291d42996b27093a57f64739f414596a86da5406", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 551, "excerpt": "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1"}, {"id": "lv-hc363ee66ca25", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "cb91d38d30c6a413535ed4934c5d9f94b13f9574b4b2de6d6f1ba51b8f0156f9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 121, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?2 WHERE subject=?1"}, {"id": "lv-hca69050f62ee", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "9e2feeb2aa93b65606d10e7f78b269d681c421de488d083225519f9c3391dcf6", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 92, "excerpt": "CREATE TEMP TRIGGER quiet_renewal_state_guard AFTER UPDATE OF status,worker_reported,lease_owner,lease_incarnation, blocked_reason,readiness_epoch ON main.step_runs BEGIN SELECT RAISE(ABORT,'quiet ren"}, @@ -2009,7 +2011,7 @@ {"id": "lv-h7792d6e9101c", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "2a49e323a00aca0a97892bbbfe9620a12305f49b66a9b10c77d491ec9909b30e", "owner": "fractal_store::store", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 1435, "excerpt": "CREATE INDEX IF NOT EXISTS claims_runtime_id_subject_index ON claims({RUNTIME_ID_FIELD}, subject, store_index) WHERE {ACTUAL_STATE_CLAIM} AND typeof({RUNTIME_ID_FIELD})='text'; CREATE INDEX IF NOT EXI"}, {"id": "lv-h78f223f76c65", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "11962b6d1a71b779748eeb516ee88832780f3b025fe316441be02891568d703f", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6144, "excerpt": "params ! [ subject , i64 : : MAX , incarnation ] , | row | row . get ( 1 )"}, {"id": "lv-h78f4c9ab8fb0", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "990cbb165a23077232f26d13723754e956c38eced662ebabe920afb0d68c90fd", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 18764, "excerpt": "& canonical_sql ( \"SELECT body FROM claims WHERE subject=?1 AND kind='harness.observed' AND accepted_at_unix_ms<=?2 ORDER BY CANONICAL_DESC(claims)\" , )"}, - {"id": "lv-h79c5fdb57d22", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "1802408fd849dd080ad146563b6a720fb7cf929641750256f35812292a594306", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 17547, "excerpt": "params ! [ seat , run_id ] , | row | { Ok ( ( row . get : : < _ , Option < i64 > > ( 0 ) ? , row . get : : < _ , Option < String > > ( 1 ) ? , ) ) }"}, + {"id": "lv-hf01b6d5cecf5", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "95923a5d49ab1c3c1f6b3254e6cf3ccafb8ca33833d9566fa5e90626612d746e", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 19089, "excerpt": "params ! [ spellings , run_id ] , | row | { Ok ( ( row . get : : < _ , Option < i64 > > ( 0 ) ? , row . get : : < _ , Option < String > > ( 1 ) ? , ) ) }"}, {"id": "lv-h7a51a72fe942", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "5542c3e7a032e3b8b126e4443bd59273c5a4cfc2a55567386e029f3f699f88ec", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 15905, "excerpt": "DELETE FROM {table}"}, {"id": "lv-h7a7c49285515", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "61ef3ba33fe51d857a26e15573438b3e3ed572382f15b716727c8a0972baa3f9", "owner": "fractal_store::store", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 2220, "excerpt": "CREATE TABLE IF NOT EXISTS mission_run_deadlines ( run_id TEXT PRIMARY KEY REFERENCES mission_runs(id), timeout_ms INTEGER NOT NULL, deadline_at_unix_ms TEXT NOT NULL ); PRAGMA user_version = 11;"}, {"id": "lv-h7ab5e25ccad4", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "0cafdab7267f9a1864fff125cdd4e115b680ea889c9d6dd31ffe1a9bb48b97c1", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 7640, "excerpt": "& canonical_sql ( & format ! ( \"SELECT id, body, predecessors FROM claims WHERE subject=?1 AND kind='intent.desired' AND store_index<=?2 AND {UNREPAIRED_CLAIM} AND NOT EXISTS ( SELECT 1 FROM replica_r"}, diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json index 5e01072448e..3bed8c52426 100644 --- a/release-notes/mission-agentid-canonical.json +++ b/release-notes/mission-agentid-canonical.json @@ -4,8 +4,8 @@ "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical host-relative qualification and malformed assignee normalization rather than rejecting them."}, "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, "rules": {"status": "changed", "detail": "Mission assignees, run-start actors and work actions canonicalize slash-qualified agent identities. Work eligibility, reads, renewal, successor lookup and capacity accounting compare canonical identities; malformed work actors are rejected."}, - "compatibility": {"status": "changed", "detail": "Mixed-version compatible expansion: legacy assignees and candidates keep their stored spelling for new lease ownership, allowing older members to renew, release and complete work; canonical assignments write canonical owners. Reads and capacity compare canonical identities. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership contracts only after every fleet member runs canonical comparisons, when a later change migrates stored rows and removes the exception."}, - "downtime": {"status": "none", "detail": "No data migration or coordinated client rollout is required."}, + "compatibility": {"status": "changed", "detail": "Capability-gated expansion: all current fleet members must advertise work-claimant-legacy-admission before writers preserve legacy assignee/candidate spelling in lease ownership. Until then ownership writes retain the prior behavior, avoiding facts rejected by older validators. Readers, queue selection and capacity compare canonical identities. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership contracts after fleet-wide canonical comparison/admission support, when a later change migrates stored rows and removes the exception."}, + "downtime": {"status": "none", "detail": "No downtime or stored-row migration is required for expansion; legacy ownership writes stay gated until fleet-wide admission support is observed."}, "manual": {"status": "none", "detail": "No operator action is required."}, "recovery": {"status": "none", "detail": "Lease fencing remains unchanged. Signed legacy work ownership can arrive before its target row during cold import; projection checks canonical eligibility and records a defect without taking the lease if the target selector does not match."} } From 76f79f2081ba3b5f106c3b7b06445b58ab857cfe Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:24:27 +0100 Subject: [PATCH 011/132] test(missions): account for bounded actor spelling SQL inputs agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions/src/store/work_renew_tests.rs | 2 +- crates/fractal-store/src/store/work_list.rs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index ceff05030f7..60491020b75 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -531,7 +531,7 @@ fn legacy_claimant_writes_require_every_current_members_admission_capability() { peer.set_member_key(Some(peer_key)).unwrap(); sync(store, &peer); store.connection.write().execute( - /* sqlite-large-value: allow-for-now lv-hb967f9dc4fa2 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ /* sqlite-large-value: allow-for-now lv-h755979e290e9 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2,available_to=?3 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-hb967f9dc4fa2 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2,available_to=?3 WHERE subject=?1", params![step.subject, (!pool).then_some(legacy), if pool { json!([legacy]).to_string() } else { "[]".into() }], ).unwrap(); diff --git a/crates/fractal-store/src/store/work_list.rs b/crates/fractal-store/src/store/work_list.rs index f9923e18248..501db7f66c8 100644 --- a/crates/fractal-store/src/store/work_list.rs +++ b/crates/fractal-store/src/store/work_list.rs @@ -703,8 +703,13 @@ mod tests { .unwrap() .collect::>>() .unwrap(); + // json_each walks only the bound actor spelling array (at most canonical + legacy), + // not a stored table. Keep rejecting scans of the mission and step projections. for line in &plan { - if line.starts_with("SCAN ") && !line.starts_with("SCAN CONSTANT") { + if line.starts_with("SCAN ") + && !line.starts_with("SCAN CONSTANT") + && !line.starts_with("SCAN json_each VIRTUAL TABLE INDEX ") + { assert!(line.contains("USING "), "a bare table scan:\n{}", plan.join("\n")); } } From 5f565e3f552e00f031bdd15c975721070046354e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:51:56 +0100 Subject: [PATCH 012/132] test(missions): use registered synthetic person identity agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-kernel/src/seat_queue.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fractal-kernel/src/seat_queue.rs b/crates/fractal-kernel/src/seat_queue.rs index 1647269cb26..6489c7ffc5a 100644 --- a/crates/fractal-kernel/src/seat_queue.rs +++ b/crates/fractal-kernel/src/seat_queue.rs @@ -478,7 +478,7 @@ mod tests { assert!(reached_through_parent(&nested, &[first, nested])); let submitted = SeatStep { status: "verifying", ..first }; assert!(submitted_with_ready_nested_work(&submitted, &[submitted, nested])); - for other in ["daemon/node/worker", "person/node/worker", "host/node/worker"] { + for other in ["daemon/node/worker", "person/test", "host/node/worker"] { assert!(select(other, &[first, second], &order).ready.is_empty()); assert!(!nests_under(&SeatStep { assignee: Some(other), ..first }, &nested)); } From 2aa34aa7eb51e85c006c46b5bb31c72b60dadd61 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:32:20 +0100 Subject: [PATCH 013/132] feat(fractal-api-core): add service read routes and central refusal gate agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-core/src/api.rs | 3 + .../src/api/client_documents.rs | 8 +- .../fractal-api-core/src/api/client_push.rs | 5 + crates/fractal-api-core/src/api/client_v0.rs | 6 +- .../src/api/client_v0/external_grants.rs | 91 +++++- .../external_grants_service_tests.rs | 259 ++++++++++++++++++ crates/fractal-api-core/src/api/records.rs | 50 +++- crates/fractal-api-core/src/api/work.rs | 16 +- docs/st3/principals-and-grants.md | 31 ++- release-notes/service-read-gate.json | 1 + 10 files changed, 451 insertions(+), 19 deletions(-) create mode 100644 crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs create mode 100644 release-notes/service-read-gate.json diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index e8991a938c8..a6c40714077 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -642,6 +642,9 @@ pub fn router_for_transport(state: AppState, transport: ClientTransportBoundary, .layer(DefaultBodyLimit::max(client_observations::MAX_BODY_BYTES))) .route("/v1/client/documents/content", get(client_documents::document_get)) .route("/v1/client/documents", get(client_documents::documents_list)) + .route("/v1/client/claims", get(records::list_claims)) + .route("/v1/client/claims/by-id/{id}", get(records::get_claim)) + .route("/v1/client/work-items/{*subject}", get(work::get_work)) .route("/v1/client/document-references", get(client_documents::document_references)) .route("/v1/client/push-registration", post(client_push::register) .delete(client_push::unregister) diff --git a/crates/fractal-api-core/src/api/client_documents.rs b/crates/fractal-api-core/src/api/client_documents.rs index 77accac53c2..cb4f89d019d 100644 --- a/crates/fractal-api-core/src/api/client_documents.rs +++ b/crates/fractal-api-core/src/api/client_documents.rs @@ -28,7 +28,9 @@ pub(super) async fn document_get( Extension(session): Extension, Query(query): Query, ) -> Result, ApiError> { - require_scope(&session, "read.projections")?; + require_scope(&session, if client_v0::external_grants::is_service(&session) { + "read.documents" + } else { "read.projections" })?; let (name, hash) = match query.name.rsplit_once('@') { Some((name, hash)) if !name.is_empty() && !hash.is_empty() => { (name.to_owned(), Some(hash.to_owned())) @@ -86,7 +88,9 @@ pub(super) async fn documents_list( Extension(session): Extension, query: Query, ) -> Result, ApiError> { - require_scope(&session, "read.projections")?; + require_scope(&session, if client_v0::external_grants::is_service(&session) { + "read.documents" + } else { "read.projections" })?; let Json(page) = list_documents(State(state), query).await?; Ok(Json(json!({ "items": page.items.iter().map(document_item).collect::>(), diff --git a/crates/fractal-api-core/src/api/client_push.rs b/crates/fractal-api-core/src/api/client_push.rs index aaab9136eb5..2ee698b3b37 100644 --- a/crates/fractal-api-core/src/api/client_push.rs +++ b/crates/fractal-api-core/src/api/client_push.rs @@ -50,6 +50,11 @@ pub fn authenticate( .filter(|value| !value.is_empty()) .ok_or_else(denied)?; let digest = hex::encode(Sha256::digest(credential.as_bytes())); + if let Some(session) = super::client_v0::external_grants::authenticate(state, &digest, transport)? + && super::client_v0::external_grants::is_service(&session) { + super::client_v0::external_grants::authorize_route(&session, request)?; + return Ok(Authentication::Session(session)); + } let pairings = state.store .claims_for_kind_at("custom.client.pairing-completed", None, true, 10_000) .map_err(|_| unavailable())?; diff --git a/crates/fractal-api-core/src/api/client_v0.rs b/crates/fractal-api-core/src/api/client_v0.rs index 7fc1faccad4..872c259df98 100644 --- a/crates/fractal-api-core/src/api/client_v0.rs +++ b/crates/fractal-api-core/src/api/client_v0.rs @@ -758,7 +758,8 @@ pub fn authenticate( .split(',') .any(|feature| feature.trim() == "messages-paging.v2") }); - if let Some(web) = request.extensions().get::() { + if let Some(web) = request.extensions().get::() + && !request.headers().contains_key(AUTHORIZATION) { let mut session = ClientSession::local(Some(&web.person))?; session.actor = format!("client/local-web/{}", web.id); session.transport = "local-web"; @@ -809,6 +810,9 @@ pub fn authenticate( let digest = credential_digest(credential); if let Some(session) = assistant::authenticate(state, &digest, transport)? { assistant::authorize_route(&session, request)?; + if external_grants::is_service(&session) { + external_grants::audit_service_read(state, &session)?; + } return Ok(session); } let pairings = state diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants.rs b/crates/fractal-api-core/src/api/client_v0/external_grants.rs index 1b0378cd726..76fe3e1e6c7 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants.rs @@ -18,7 +18,7 @@ pub const PRINCIPAL: &str = "assistant/chatgpt"; pub const MCP_PRINCIPAL: &str = "assistant/mcp"; /// Closed authority profiles: callers cannot register a profile or mix its rights. -/// Service is reserved and cannot issue credentials or authorize any request. +/// Service authentication is supported, but production issuance remains disabled. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Profile { Assistant, @@ -95,6 +95,15 @@ fn admit_profile(state: &AppState, source: &Value) -> Result<(), ApiError> { } pub fn principal(source: &Value) -> Result<&str, ApiError> { + if Profile::stored(source)? == Profile::Service { + return source["principal"].as_str().filter(|principal| { + let mut parts = principal.split('/'); + let component = |part: Option<&str>| part.is_some_and(|part| !part.is_empty() + && part.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))); + parts.next() == Some("plugin") && component(parts.next()) + && component(parts.next()) && parts.next().is_none() + }).ok_or_else(|| forbidden("invalid service principal")); + } crate::store::assistant_write::principal(source) .ok_or_else(|| forbidden("invalid assistant principal")) } @@ -225,8 +234,12 @@ pub fn session(state: &AppState, granted: &ClaimRecord, transport: &'static str) let source = fields(&policy)?; let principal = principal(source)?; let granted_fields = fields(granted)?; - assistant_profile(granted_fields)?; - assistant_profile(source)?; + let profile = Profile::stored(granted_fields)?; + profile.compatible(Profile::stored(source)?)?; + if profile == Profile::Service { + digest_only(granted_fields)?; + digest_only(source)?; + } if self::principal(granted_fields)? != principal || ["grant_id", "session_actor", "issuer"].iter().any(|key| source[*key] != granted_fields[*key]) || source["session_actor"].as_str().and_then(|actor| actor.strip_prefix(principal)) @@ -240,11 +253,14 @@ pub fn session(state: &AppState, granted: &ClaimRecord, transport: &'static str) if latest(state, &granted.subject, REVOKED)?.is_some() || source["expires_at_unix_ms"].as_u64().map(u128::from).unwrap_or(0) <= client_now_ms() || source["issuer"] != owning_person(state)? - || !matches!(source["minimal_read_mode"].as_str(), Some("approval-once" | "caller-bound-allow")) || source["disclosure_consent"] != true { + || (profile == Profile::Assistant + && (!matches!(source["minimal_read_mode"].as_str(), Some("approval-once" | "caller-bound-allow")) + || source["disclosure_consent"] != true)) { return Err(forbidden("assistant grant was revoked, expired, or has invalid policy")); } - let scopes = source["scopes"].as_array().ok_or_else(|| forbidden("assistant scopes absent"))? - .iter().map(|value| value.as_str().filter(|scope| SCOPES.contains(scope)).map(str::to_owned)) + let allowed = if profile == Profile::Service { SERVICE_SCOPES } else { SCOPES }; + let scopes = source["scopes"].as_array().ok_or_else(|| forbidden("external grant scopes absent"))? + .iter().map(|value| value.as_str().filter(|scope| allowed.contains(scope)).map(str::to_owned)) .collect::>>().ok_or_else(|| forbidden("assistant scopes invalid"))?; Ok(ClientSession { actor: source["session_actor"].as_str() .ok_or_else(|| forbidden("assistant actor absent"))?.into(), authority_actor: principal.into(), @@ -257,6 +273,9 @@ pub fn session(state: &AppState, granted: &ClaimRecord, transport: &'static str) } pub fn authorize_route(session: &ClientSession, request: &Request) -> Result<(), ApiError> { + if is_service(session) { + return authorize_service(session, request); + } if !matches!(session.authority_actor.as_str(), PRINCIPAL | MCP_PRINCIPAL) { return Err(forbidden("external grant profile has no allowed routes")); } @@ -273,6 +292,62 @@ pub fn authorize_route(session: &ClientSession, request: &Request) -> Resu Ok(()) } +pub const SERVICE_SCOPES: &[&str] = &["read.claims", "read.work", "read.documents"]; + +pub fn is_service(session: &ClientSession) -> bool { + session.authority_actor.starts_with("plugin/") +} + +/// This closed registry is also used for native aliases. HEAD and upgrades are not reads. +fn authorize_service(session: &ClientSession, request: &Request) -> Result<(), ApiError> { + let path = request.uri().path(); + let scope = match path { + "/v1/client/claims" | "/v1/claims" => Some("read.claims"), + "/v1/client/documents" | "/v1/client/documents/content" + | "/v1/documents" | "/v1/documents/content" => Some("read.documents"), + _ if ["/v1/client/claims/by-id/", "/v1/claims/by-id/"].iter() + .any(|prefix| path.strip_prefix(prefix).is_some_and(|id| !id.is_empty() && !id.contains('/'))) => + Some("read.claims"), + _ if ["/v1/client/work-items/", "/v1/work-items/"].iter() + .any(|prefix| path.strip_prefix(prefix).is_some_and(|subject| !subject.is_empty())) => + Some("read.work"), + _ => None, + }; + if request.method() != axum::http::Method::GET + || request.headers().contains_key(axum::http::header::UPGRADE) + || (session.transport == "fabric-loopback" && !path.starts_with("/v1/client/")) + || !scope.is_some_and(|scope| session.allows(scope)) { + return Err(forbidden("service requires a registered pure GET and its exact read scope")); + } + Ok(()) +} + +/// The only graph mutation admitted on behalf of a service read. The daemon signs as itself. +pub const SERVICE_AUDIT_KINDS: &[&str] = &["custom.client.service.v1.read-window"]; +const AUDIT_WINDOW_MS: u128 = 60_000; +static SERVICE_AUDIT: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +pub fn audit_service_read(state: &AppState, session: &ClientSession) -> Result<(), ApiError> { + let _guard = SERVICE_AUDIT.lock().map_err(ApiError::internal)?; + let grant = session.pairing_grant.as_deref().ok_or_else(|| forbidden("service grant absent"))?; + let window = client_now_ms() / AUDIT_WINDOW_MS; + let subject = format!("custom/client/service-read-{}-{window}", credential_digest(grant)); + let kind = SERVICE_AUDIT_KINDS[0]; + if latest(state, &subject, kind)?.is_some() { + return Ok(()); + } + state.store.append_claim(&ClaimInput { + subject, kind: kind.into(), actor: Some(format!("daemon/{}", state.store.origin())), + fields: serde_json::from_value(json!({ + "version":1, "grant":grant, "principal":session.authority_actor, + "window_start_unix_ms":window * AUDIT_WINDOW_MS, + "window_ms":AUDIT_WINDOW_MS + })).map_err(ApiError::internal)?, + evidence: Vec::new(), expected_subject: Some(None), idempotency_key: None, + }).map_err(ApiError::bad)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -346,3 +421,7 @@ mod tests { } } } + +#[cfg(test)] +#[path = "external_grants_service_tests.rs"] +mod service_tests; diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs new file mode 100644 index 00000000000..439bb9cab9a --- /dev/null +++ b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs @@ -0,0 +1,259 @@ +use super::*; +use axum::body::to_bytes; + +const TOKEN: &str = "service-test-private-bearer-never-in-a-claim"; +const ID: &str = "121212121212121212121212"; + +// This module is compiled only by rustc --test. No feature flag, route, or production +// caller can reach this fixture; production append still rejects Profile::Service. +fn fixture(root: &std::path::Path, scopes: &[&str]) -> (AppState, ClaimRecord) { + let mut state = crate::api::tests_shared::state(root); + state.owning_person = Some("person/owner".into()); + let grant = state.store.append_claim(&ClaimInput { + subject: subject(ID).unwrap(), kind: GRANTED.into(), actor: Some("person/owner".into()), + fields: serde_json::from_value(json!({ + "profile":"service", "principal":"plugin/test/reader", "grant_id":ID, + "session_actor":format!("plugin/test/reader/session/{ID}"), + "issuer":"person/owner", "expires_at_unix_ms":u64::MAX, + "credential_hash":credential_digest(TOKEN), "scopes":scopes + })).unwrap(), evidence: vec![], expected_subject: Some(None), idempotency_key: None, + }).unwrap(); + (state, grant) +} + +async fn call(app: &Router, method: &str, path: &str, body: Value, upgrade: bool) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(path) + .header("authorization", format!("Bearer {TOKEN}")) + .header("x-st3-person", "person/owner") + .header("x-st3-actor", "agent/operator") + .header("content-type", "application/json"); + if upgrade { builder = builder.header("upgrade", "websocket").header("connection", "upgrade"); } + let response = app.clone().oneshot(builder.body(Body::from(body.to_string())).unwrap()).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let envelope: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + let value = if status.is_success() { envelope.get("value").cloned().unwrap_or(envelope) } else { envelope }; + (status, value) +} + +#[test] +fn service_fixture_does_not_enable_production_issuance_or_free_mode() { + let root = tempfile::tempdir().unwrap(); + let (state, grant) = fixture(root.path(), SERVICE_SCOPES); + let service = session(&state, &grant, "unix").unwrap(); + assert!(!acting_party(&service)); + assert!(!service.local_peer); + assert!(!service.allows("read.declarations")); + assert!(append(&state, subject("343434343434343434343434").unwrap(), GRANTED, + "person/owner", fields(&grant).unwrap().clone(), None).is_err()); + assert!(assistant_profile(fields(&grant).unwrap()).is_err()); + assert!(state.store.append_client_claim(&ClaimInput { + subject:subject("343434343434343434343434").unwrap(), kind:GRANTED.into(), + actor:Some("person/owner".into()), + fields:serde_json::from_value(fields(&grant).unwrap().clone()).unwrap(), + evidence:vec![], expected_subject:None, idempotency_key:None, + }).is_err()); +} + +#[tokio::test] +async fn service_r29_claim_parity_paging_bounds_redaction_and_credential_secrecy() { + let root = tempfile::tempdir().unwrap(); + let (state, grant) = fixture(root.path(), SERVICE_SCOPES); + let mut expected = Vec::new(); + for n in 0..7 { + expected.push(state.store.append_claim(&ClaimInput { + subject:"custom/service/paging".into(), kind:"custom.service-test.v1.item".into(), + actor:Some("person/owner".into()), fields:serde_json::from_value(json!({"number":n, + "desired":{"environment":{"TOKEN":"environment-secret"}}, "declaration":"declaration-secret"})).unwrap(), + evidence:vec![], expected_subject:None, idempotency_key:None, + }).unwrap().id); + } + for gateway in [false, true] { + let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; + let mut ids = Vec::new(); + let mut after = 0; + loop { + let (status, page) = call(&app, "GET", &format!("/v1/client/claims?subject=custom/service/paging&limit=2&after={after}"), Value::Null, false).await; + assert_eq!(status, StatusCode::OK, "{page}"); + let serialized = page.to_string(); + assert!(!serialized.contains("environment-secret")); + assert!(!serialized.contains("declaration-secret")); + ids.extend(page["claims"].as_array().unwrap().iter().map(|claim| claim["id"].as_str().unwrap().to_owned())); + let Some(next) = page["next_cursor"].as_u64() else { break }; + assert!(next > after); + after = next; + } + assert_eq!(ids, expected); + for query in ["limit=0", "limit=501", "limit=wrong", "after=invalid", "before=invalid"] { + let (status, _) = call(&app, "GET", &format!("/v1/client/claims?{query}"), Value::Null, false).await; + assert!(status.is_client_error(), "{query}: {status}"); + } + for limit in [1, 500] { + assert_eq!(call(&app,"GET",&format!("/v1/client/claims?limit={limit}"),Value::Null,false).await.0, StatusCode::OK); + } + let (status, value) = call(&app,"GET",&format!("/v1/client/claims/by-id/{}",grant.id),Value::Null,false).await; + assert_eq!(status, StatusCode::OK, "{value}"); + assert_eq!(value["body"]["fields"]["credential_hash"], credential_digest(TOKEN)); + assert!(!value.to_string().contains(TOKEN)); + assert!(!value.to_string().contains("\"credential\":")); + if !gateway { + let (status, raw) = call(&app,"GET",&format!("/v1/claims/by-id/{}",grant.id),Value::Null,false).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(raw, value); + } + } +} + +#[tokio::test] +async fn service_missing_scopes_are_denied_on_both_routers_without_audit() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(), &[]); + let before = state.store.index().unwrap(); + for app in [crate::api::router(state.clone()), crate::api::fabric_router(state.clone())] { + for path in ["/v1/client/claims", "/v1/client/claims/by-id/absent", "/v1/client/documents", + "/v1/client/documents/content?name=doc/test", "/v1/client/work-items/absent", + "/v1/claims", "/v1/documents", "/v1/work-items/absent"] { + assert_eq!(call(&app,"GET",path,Value::Null,false).await.0,StatusCode::FORBIDDEN,"{path}"); + } + } + assert_eq!(state.store.index().unwrap(), before); +} + +fn files(root: &std::path::Path) -> BTreeMap> { + fn visit(root: &std::path::Path, path: &std::path::Path, result: &mut BTreeMap>) { + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { visit(root, &path, result); } + else { + let name = path.file_name().unwrap().to_string_lossy(); + // SQLite files are checked logically by the complete claims/document state. + if !name.contains("sqlite") && !name.ends_with("-wal") && !name.ends_with("-shm") && !name.ends_with(".db") { + result.insert(path.strip_prefix(root).unwrap().to_owned(), std::fs::read(path).unwrap()); + } + } + } + } + let mut result = BTreeMap::new(); + visit(root, root, &mut result); + result +} + +#[tokio::test] +async fn service_r30_both_router_refusal_matrix_preserves_graph_documents_processes_files() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(), SERVICE_SCOPES); + std::fs::write(root.path().join("must-not-change"), "sentinel").unwrap(); + let documents = state.store.list_documents_page(None,None,true,None,500).unwrap(); + let claims = state.store.claims_page(None,None,0,None,false,500).unwrap(); + let before_files = files(root.path()); + let processes = std::fs::read_to_string("/proc/self/task".to_owned() + &format!("/{}/children",std::process::id())).unwrap(); + let routes = [ + "/v1/claims", "/v1/documents", "/v1/client/claims", "/v1/client/documents", + "/v1/client/assistant/propose", "/v1/client/actions", "/v1/client/actions/messages.send", + "/v1/client/pairings", "/v1/client/pairings/test/complete", "/v1/client/assistant/grants/test", + "/v1/client/push-registration", "/v1/work/start", "/v1/work/done", "/v1/work/cancel-ask", + "/v1/work/wake/step-run/test", "/v1/work/retry/step-run/test", "/v1/work/claim/step-run/test", + "/v1/messages", "/v1/intent/apply", "/v1/intent/preview", "/v1/notes", + "/v1/sessions/input/agent/test", "/v1/sessions/agent/test/signal", "/v1/repair/apply", + "/v1/internal/fleet/redeem", "/v1/internal/replication/receive", "/v1/backup", "/v1/future-route", + ]; + // Bodies carry valid write fields as well as spoofed person and actor headers. Admission + // runs before extraction, so no endpoint-specific validation is the refusal mechanism. + let body = json!({"subject":"custom/service/mutation", "kind":"custom.service-test.v1.write", + "actor":"person/owner", "fields":{"value":"mutated"}, "evidence":[], + "name":"doc/service-mutation", "bytes":[109,117,116,97,116,101,100], "idempotency_key":"service-write", + "from":"person/owner", "to":"person/owner", "body":"mutated", "kdl":"agent \"changed\" {}"}); + for app in [crate::api::router(state.clone()), crate::api::fabric_router(state.clone())] { + for route in routes { + for method in ["POST", "PUT", "PATCH", "DELETE"] { + let (status, response) = call(&app,method,route,body.clone(),false).await; + assert_eq!(status,StatusCode::FORBIDDEN,"{method} {route}: {response}"); + } + } + for action in ACTIONS { + let path = format!("/v1/client/actions/{action}"); + assert_eq!(call(&app,"POST",&path,body.clone(),false).await.0,StatusCode::FORBIDDEN,"action {action}"); + } + for route in ["/v1/backup", "/v1/sessions/terminal/agent/test", "/v1/client/assistant/summary", + "/v1/client/devices", "/v1/internal/fleet/status", "/v1/future-route", "/v1/client/future-route", + "/v1/client/agents/test/declaration", "/v1/desired/agent/test", "/v1/health"] { + assert_eq!(call(&app,"GET",route,Value::Null,false).await.0,StatusCode::FORBIDDEN,"GET {route}"); + } + for route in ["/v1/client/claims", "/v1/client/documents", "/v1/client/work-items/test", "/v1/claims"] { + assert_eq!(call(&app,"GET",route,Value::Null,true).await.0,StatusCode::FORBIDDEN,"upgrade {route}"); + assert_eq!(call(&app,"HEAD",route,Value::Null,false).await.0,StatusCode::FORBIDDEN,"HEAD {route}"); + } + } + assert_eq!(serde_json::to_value(state.store.claims_page(None,None,0,None,false,500).unwrap()).unwrap(),serde_json::to_value(claims).unwrap()); + assert_eq!(serde_json::to_value(state.store.list_documents_page(None,None,true,None,500).unwrap()).unwrap(),serde_json::to_value(documents).unwrap()); + assert_eq!(files(root.path()),before_files); + assert_eq!(std::fs::read_to_string("/proc/self/task".to_owned() + &format!("/{}/children",std::process::id())).unwrap(),processes); +} + +#[tokio::test] +async fn service_audit_is_daemon_owned_closed_and_bounded_per_grant_window() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(), SERVICE_SCOPES); + let before = state.store.index().unwrap(); + let app = crate::api::fabric_router(state.clone()); + for _ in 0..20 { + assert_eq!(call(&app,"GET","/v1/client/documents",Value::Null,false).await.0,StatusCode::OK); + } + let changed = state.store.claims_page(None,None,before,None,false,500).unwrap().claims; + // A run crossing a minute boundary may produce two windows, never twenty records. + assert!(!changed.is_empty() && changed.len() <= 2); + for claim in changed { + assert!(SERVICE_AUDIT_KINDS.contains(&claim.kind.as_str())); + assert_eq!(claim.actor,Some(format!("daemon/{}",state.store.origin()))); + assert_eq!(claim.body["fields"]["principal"],"plugin/test/reader"); + assert!(!serde_json::to_string(&claim).unwrap().contains(TOKEN)); + } +} + +#[tokio::test] +async fn service_r29_work_and_document_projection_parity() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(), SERVICE_SCOPES); + let source = r#"version 2 +agent "reader" { command "true"; name "Reader" } +mission "service-work" state="ready" { + goal "Read without acknowledging work." + step "work" { assigned-to "agent/node.reader" } +} +"#; + let intent = crate::graph::parse_test_intent(source, "node").unwrap(); + let plan = state.store.mission(&intent, crate::model::IntentInput { + kdl:source.into(), source_name:None, + }).unwrap(); + state.store.apply(&intent, &plan.subject_tokens, "service-parity").unwrap(); + let run = state.store.create_mission_run(&MissionRunRequest { + mission:"service-work".into(), revision:None, workspace:root.path().to_string_lossy().into_owned(), + requester:Some("person/owner".into()), mode:Some("run".into()), inputs:BTreeMap::new(), + idempotency_key:"service-run".into(), + }).unwrap(); + let subject = &run.steps[0].subject; + let expected = serde_json::to_value(state.store.step_run(subject).unwrap().unwrap()).unwrap(); + let document = state.store.put_document_as("doc/service-parity", b"service document", &None, "service-doc", None).unwrap(); + for gateway in [false, true] { + let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; + let (status, work) = call(&app,"GET",&format!("/v1/client/work-items/{subject}"),Value::Null,false).await; + assert_eq!(status,StatusCode::OK,"{work}"); + assert_eq!(work,expected); + let (status, list) = call(&app,"GET","/v1/client/documents?prefix=doc/service-parity",Value::Null,false).await; + assert_eq!(status,StatusCode::OK,"{list}"); + assert_eq!(list["items"].as_array().unwrap().len(),1); + assert_eq!(list["items"][0]["version"],document.hash); + let (status, content) = call(&app,"GET","/v1/client/documents/content?name=doc/service-parity",Value::Null,false).await; + assert_eq!(status,StatusCode::OK,"{content}"); + assert_eq!(content["bytes"],json!(b"service document".to_vec())); + if !gateway { + let (status, raw) = call(&app,"GET",&format!("/v1/work-items/{subject}"),Value::Null,false).await; + assert_eq!(status,StatusCode::OK); + assert_eq!(raw,work); + assert_eq!(call(&app,"GET","/v1/documents",Value::Null,false).await.0,StatusCode::OK); + let (status, raw) = call(&app,"GET",&format!("/v1/documents/content?reference=doc/service-parity@{}",document.hash),Value::Null,false).await; + assert_eq!(status,StatusCode::OK); + assert_eq!(raw["bytes"],content["bytes"]); + } + } +} diff --git a/crates/fractal-api-core/src/api/records.rs b/crates/fractal-api-core/src/api/records.rs index c741f06cde1..bf32e8b29b7 100644 --- a/crates/fractal-api-core/src/api/records.rs +++ b/crates/fractal-api-core/src/api/records.rs @@ -4,6 +4,7 @@ use crate::LanesStore; use crate::PersonWorkStore; use crate::EvidenceStore; use super::*; +use super::client_v0::{ClientSession, require_scope}; pub(super) fn generic_claim_visible(claim: &ClaimRecord) -> bool { !fractal_schema::notice::is_reserved_subject(&claim.subject) @@ -286,14 +287,25 @@ pub async fn post_claim( pub async fn get_claim( State(state): State, AxumPath(id): AxumPath, + session: Option>, ) -> Result, ApiError> { + if let Some(session) = &session { + require_scope(&session.0, if client_v0::external_grants::is_service(&session.0) { + "read.claims" + } else { "read.projections" })?; + } let store = state.store.clone(); let id_for_read = id.clone(); blocking_store(move || store.claim_by_id(&id_for_read)) .await? .filter(|claim| !claim.subject.starts_with("glass/")) .filter(generic_claim_visible) - .map(Json) + .map(|mut claim| { + if session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)) { + redact_service_claim(&mut claim.body); + } + Json(claim) + }) .ok_or_else(|| ApiError::not_found(format!("claim `{id}` does not exist"))) } @@ -322,15 +334,22 @@ enum ClaimsOrder { pub async fn list_claims( State(state): State, Query(query): Query, + session: Option>, ) -> Result, ApiError> { + if let Some(session) = &session { + require_scope(&session.0, if client_v0::external_grants::is_service(&session.0) { + "read.claims" + } else { "read.projections" })?; + } if query.limit == 0 || query.limit > 500 { return Err(ApiError::bad(FractalError::new( "invalid-page-limit", "a claim page limit must be between 1 and 500", ))); } + let service = session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)); let store = state.store.clone(); - blocking_store(move || { + let mut page = blocking_store(move || { let subject = query.subject.as_deref().map(|subject| { crate::store::identities::resolve_reference_conn(&store.readers.get(), subject) }).transpose()?; @@ -344,8 +363,31 @@ pub async fn list_claims( query.limit, ) }) - .await - .map(Json) + .await?; + if service { + for claim in &mut page.claims { + redact_service_claim(&mut claim.body); + } + } + Ok(Json(page)) +} + +/// Generic records must not become a declaration or environment-value disclosure route. +fn redact_service_claim(value: &mut Value) { + match value { + Value::Object(fields) => { + for (key, value) in fields { + if matches!(key.as_str(), "desired" | "declaration" | "kdl" | "tree" + | "env" | "environment" | "env_values" | "credential" | "code") { + *value = json!(""); + } else { + redact_service_claim(value); + } + } + } + Value::Array(values) => values.iter_mut().for_each(redact_service_claim), + _ => {} + } } #[derive(Default, Deserialize)] diff --git a/crates/fractal-api-core/src/api/work.rs b/crates/fractal-api-core/src/api/work.rs index 75a2e357a28..47d894d737b 100644 --- a/crates/fractal-api-core/src/api/work.rs +++ b/crates/fractal-api-core/src/api/work.rs @@ -4,6 +4,7 @@ use crate::MissionsStoreCore; use crate::PersonWorkStore; use crate::SeatsStoreCore; use super::*; +use super::client_v0::{ClientSession, require_scope}; /// Only a locally bound harness may recover a missing work fence from its own runtime. /// Explicit fences are retained so the action's validation can refuse stale callers. @@ -180,7 +181,12 @@ pub async fn list_work( pub async fn get_work( State(state): State, AxumPath(subject): AxumPath, + session: Option>, ) -> Result, ApiError> { + let service = session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)); + if let Some(session) = &session { + require_scope(&session.0, if service { "read.work" } else { "read.projections" })?; + } let subject = if subject.starts_with("step-run/") { subject } else { @@ -191,10 +197,12 @@ pub async fn get_work( .step_run(&subject) .map_err(ApiError::internal)? .ok_or_else(|| ApiError::not_found(format!("step run `{subject}` does not exist")))?; - // A seat whose host keeps its receipts acknowledges a wake on that host. - super::message_reads::read_work_wakes(&state, std::iter::once(&mut step)) - .await - .map_err(ApiError::internal)?; + // Services observe work without acknowledging wakes or changing delivery state. + if !service { + super::message_reads::read_work_wakes(&state, std::iter::once(&mut step)) + .await + .map_err(ApiError::internal)?; + } Ok(Json(step)) } diff --git a/docs/st3/principals-and-grants.md b/docs/st3/principals-and-grants.md index d5c4d70f00f..22d72995826 100644 --- a/docs/st3/principals-and-grants.md +++ b/docs/st3/principals-and-grants.md @@ -119,12 +119,39 @@ and absent-principal legacy ChatGPT grants retain their existing meaning. Claims persist credential/code digests, never bearer tokens or pairing codes; grant projections expose neither credential material nor its digests. +Service read admission uses a closed GET/scope registry on both the credential +gateway and bearer-authenticated native router. `read.claims` admits +`/v1/client/claims` and `/v1/client/claims/by-id/{id}` with the native readers' +1–500 bounds and numeric `after`/`before` cursors. `read.work` admits exact +`/v1/client/work-items/{subject}` reads without acknowledging wakes. +`read.documents` admits the existing client document list/content projections. +Native `/v1/claims`, `/v1/claims/by-id/{id}`, `/v1/work-items/{subject}` and +`/v1/documents[/content]` aliases use the same service admission decision; the +gateway continues to refuse raw routes. Claim bodies redact declaration trees, +environment values and credential/code material; digests remain visible. +No declaration, assistant, action, upgrade, HEAD or unknown route is admitted. + +The daemon writes only `custom.client.service.v1.read-window` as +`daemon/{origin}`, at most once per grant per 60-second wall-clock window. +Admission-refused requests write no audit claim. Service identity is never an acting party, +and a bearer takes precedence over local person headers or browser identity. +The only service-grant fixture is inside a `#[cfg(test)]` module; production +grant append still rejects service issuance. This is not an OS sandbox: +same-UID callers dropping the credential on an unrestricted socket remain +outside credential enforcement (R18). + +Production issuance remains disabled pending owner CLI/rotation, admission +atomicity with concurrent-admission proof, bounded/indexed profile admission, +fleet-wide mixed-profile exclusion, mixed-version/downgrade safety, and auth-path +p95 under concurrent completions. No claim is made that M1 lifecycle or the +real merge consumer acceptance is complete. + | ID | Criterion (observable and testable) | Milestone | Current state and evidence | Verification method | | --- | --- | --- | --- | --- | | R27 | A host user service outside a harness authenticates as a stable non-person subject provisioned by a person or a node, not as its invoking agent. | M1 | NOT STARTED: #5019/#3268; plugin/assistant primitives do not prove this consumer contract. | Restart unattended service and invoke from two agents; every read retains the same service subject. | | R28 | Its credential file rotates and revokes without changing the stable subject; retired/revoked credentials cease to authenticate. | M1 | NOT STARTED: #5019; general device pairing is insufficient. | Rotate, use old/new credentials, revoke, retry after daemon restart; audit subject continuity. | -| R29 | Scoped native routes allow claims by id and paged claims, work-item reads and document reads at admission, queue proof and final merge. | M1 | NOT STARTED: #5019; client-v0/scoped raw route acceptance UNKNOWN. | Real merge consumer reads all four families, traverses pages without gaps and validates exact receipts at all three stages. | -| R30 | Every write is refused under the service credential, including free mode, raw routes, client actions, claims, documents and runtime/admin operations. | M1 | NOT STARTED: #5019; unauthenticated local reads do not prove denial. | Enumerate mutation routes/aliases; negative-write suite asserts structured refusal and zero store/runtime effects. | +| R29 | Scoped native routes allow claims by id and paged claims, work-item reads and document reads at admission, queue proof and final merge. | M1 | PARTIAL: closed service read surface and router fixtures implemented; issuance remains disabled and real merge-consumer acceptance is UNKNOWN (#5019). | Real merge consumer reads all four families, traverses pages without gaps and validates exact receipts at all three stages. | +| R30 | Every write is refused under the service credential, including free mode, raw routes, client actions, claims, documents and runtime/admin operations. | M1 | PARTIAL: central GET/scope admission and both-router refusal fixtures implemented; production lifecycle acceptance remains UNKNOWN (#5019). | Enumerate mutation routes/aliases; negative-write suite asserts structured refusal and zero store/runtime effects. | ## Shared box and messaging diff --git a/release-notes/service-read-gate.json b/release-notes/service-read-gate.json new file mode 100644 index 00000000000..d0620722809 --- /dev/null +++ b/release-notes/service-read-gate.json @@ -0,0 +1 @@ +{"version":1,"summary":"Add closed read-only service admission for claims, exact work items and documents while keeping production service issuance disabled","replay":{"status":"none","detail":"No migration; grant storage and digest authentication retain their existing meanings."},"schema":{"status":"none","detail":"No fractal-schema or device pairing changes."},"rules":{"status":"changed","detail":"Service bearers on both routers require an exact registered GET/read-scope pair. Unknown routes, upgrades and every mutation are refused before dispatch. Work reads do not acknowledge wakes; daemon-owned read-window audits are bounded per grant."},"compatibility":{"status":"none","detail":"Assistant scopes, disclosure consent and tests are unchanged. Service issuance remains disabled; owner lifecycle and rollout safety remain prerequisites."}} From c22dcf50f282831f31ff58b2cba3cb0b097ee7c1 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:49:26 +0100 Subject: [PATCH 014/132] fix(missions): retain bare actor work read qualification agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-core/src/api.rs | 5 ++-- .../src/api/published_lists_tests.rs | 28 ++++++++++++++----- crates/fractal-store/src/store/work_list.rs | 5 ++-- release-notes/mission-agentid-canonical.json | 2 +- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 0747414c6bd..2f5e170ab8d 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -2732,8 +2732,9 @@ pub fn client_work_resources( Some(actor) => { // Queue lookup uses the same canonical actor as the shared seat selector, // including when persisted steps still use a legacy spelling. - let seat = fractal_schema::canonical_agent_actor(actor) - .unwrap_or(std::borrow::Cow::Borrowed(actor)); + let actor = smallclaims::store::normalize_actor(actor, "agent"); + let seat = fractal_schema::canonical_agent_actor(&actor) + .unwrap_or(std::borrow::Cow::Borrowed(&actor)); let order = store.seat_run_order(&seat)?; let steps = work .iter() diff --git a/crates/fractal-api-core/src/api/published_lists_tests.rs b/crates/fractal-api-core/src/api/published_lists_tests.rs index 4c5efaab397..4dbd18529ab 100644 --- a/crates/fractal-api-core/src/api/published_lists_tests.rs +++ b/crates/fractal-api-core/src/api/published_lists_tests.rs @@ -337,6 +337,10 @@ fn published_work_keeps_persisted_legacy_assignments_and_available_seats_visible #[test] fn mixed_spelling_ready_rows_keep_queue_order_in_direct_and_published_work() { + for (legacy, canonical) in [ + ("node/worker", "agent/node/worker"), + ("node.builder", "agent/node.builder"), + ] { let _clock = Clock::at(start_time()); let root = tempfile::tempdir().unwrap(); let path = root.path().join("graph.db"); @@ -352,8 +356,8 @@ fn mixed_spelling_ready_rows_keep_queue_order_in_direct_and_published_work() { let connection = rusqlite::Connection::open(&path).unwrap(); smallclaims::store::projection_digest::register(&connection).unwrap(); for (subject, assignee, created) in [ - (&first_subject, "node/worker", "10"), - (&second_subject, "agent/node/worker", "20"), + (&first_subject, if legacy.contains('/') { legacy } else { canonical }, "10"), + (&second_subject, canonical, "20"), ] { connection.execute( /* sqlite-large-value: allow-for-now lv-hb7cf1dc01667 owner=fractal_api_core::api::published_lists_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2, created_at_unix_ms=?3 WHERE subject=?1", @@ -361,20 +365,30 @@ fn mixed_spelling_ready_rows_keep_queue_order_in_direct_and_published_work() { ).unwrap(); } drop(connection); + store.move_seat_queue_run(&crate::model::SeatQueueMoveRequest { + agent: canonical.into(), + run: second.id.clone(), + placement: "top".into(), + anchor: None, + reason: Some("reverse readiness order".into()), + actor: "person/operator".into(), + idempotency_key: "reverse-ready-order".into(), + }).unwrap(); let (publication, _) = fold_work_checked(&store); - for actor in ["node/worker", "agent/node/worker"] { + for actor in [legacy, canonical] { let order = store.seat_run_order(actor).unwrap(); let work = store.client_work_at_snapshot(Some(actor), false, publication.rows.time_unix_ms).unwrap(); let steps = work.iter().map(crate::seat_queue::SeatStep::from).collect::>(); - let selected = crate::seat_queue::select(actor, &steps, &order); - assert_eq!(selected.ready, [&*first_subject, &*second_subject], - "both spellings have finite ready ranks, with the earlier run first"); + let selected = crate::seat_queue::select(canonical, &steps, &order); + assert_eq!(selected.ready, [&*second_subject, &*first_subject], + "both spellings honor the explicit queue order, opposite readiness order"); let direct = work_oracle(&store, publication.rows.time_unix_ms, Some(actor), usize::MAX).0; let (indexes, _) = publication.rows.actor_rows(actor); assert_eq!(indexes.iter().map(|&index| publication.rows.order[index as usize].as_str()) - .collect::>(), [&*first_subject, &*second_subject]); + .collect::>(), [&*second_subject, &*first_subject]); assert_eq!(publication.rows.page_of(Some(&indexes), 0, usize::MAX).0, direct); } + } } #[test] diff --git a/crates/fractal-store/src/store/work_list.rs b/crates/fractal-store/src/store/work_list.rs index 501db7f66c8..e9cf0a7333b 100644 --- a/crates/fractal-store/src/store/work_list.rs +++ b/crates/fractal-store/src/store/work_list.rs @@ -360,8 +360,9 @@ impl WorkRows { /// its ready work by its seat queue at this publication's own cut. Legacy slash-qualified /// agent IDs share the canonical actor's memo. Says whether this read built it. pub fn actor_rows(&self, actor: &str) -> (Arc>, bool) { - let viewer = fractal_schema::canonical_agent_actor(actor) - .unwrap_or(std::borrow::Cow::Borrowed(actor)); + let actor = smallclaims::store::normalize_actor(actor, "agent"); + let viewer = fractal_schema::canonical_agent_actor(&actor) + .unwrap_or(std::borrow::Cow::Borrowed(&actor)); self.actors.get_or_build(&viewer, || self.actor_order(&viewer)) } diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json index 3bed8c52426..a8f3cb65339 100644 --- a/release-notes/mission-agentid-canonical.json +++ b/release-notes/mission-agentid-canonical.json @@ -3,7 +3,7 @@ "summary": "Emit canonical agent identities in mission presentation and admit canonical mission work actors", "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical host-relative qualification and malformed assignee normalization rather than rejecting them."}, "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, - "rules": {"status": "changed", "detail": "Mission assignees, run-start actors and work actions canonicalize slash-qualified agent identities. Work eligibility, reads, renewal, successor lookup and capacity accounting compare canonical identities; malformed work actors are rejected."}, + "rules": {"status": "changed", "detail": "Mission assignees, run-start actors and work actions canonicalize slash-qualified agent identities. Work eligibility, reads, renewal, successor lookup and capacity accounting compare canonical identities; malformed work actors are rejected. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering."}, "compatibility": {"status": "changed", "detail": "Capability-gated expansion: all current fleet members must advertise work-claimant-legacy-admission before writers preserve legacy assignee/candidate spelling in lease ownership. Until then ownership writes retain the prior behavior, avoiding facts rejected by older validators. Readers, queue selection and capacity compare canonical identities. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership contracts after fleet-wide canonical comparison/admission support, when a later change migrates stored rows and removes the exception."}, "downtime": {"status": "none", "detail": "No downtime or stored-row migration is required for expansion; legacy ownership writes stay gated until fleet-wide admission support is observed."}, "manual": {"status": "none", "detail": "No operator action is required."}, From 59212ea0429462bb59feaff98887c0e7e575d943 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:57:37 +0100 Subject: [PATCH 015/132] fix(fractal-api-core): deny service disclosure outside explicit receipt schemas agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-core/src/api.rs | 6 +- .../src/api/client_documents.rs | 14 +- .../src/api/client_v0/external_grants.rs | 8 + .../external_grants_service_tests.rs | 52 ++++-- crates/fractal-api-core/src/api/records.rs | 108 +++++++----- crates/fractal-api-core/src/api/work.rs | 16 +- crates/fractal-daemon/src/api.rs | 3 + .../src/api/service_read_tests.rs | 154 ++++++++++++++++++ crates/fractal-daemon/src/web.rs | 40 ++++- docs/st3/principals-and-grants.md | 36 +++- release-notes/service-read-gate.json | 2 +- 11 files changed, 362 insertions(+), 77 deletions(-) create mode 100644 crates/fractal-daemon/src/api/service_read_tests.rs diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index a6c40714077..1266d1fb669 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -642,9 +642,9 @@ pub fn router_for_transport(state: AppState, transport: ClientTransportBoundary, .layer(DefaultBodyLimit::max(client_observations::MAX_BODY_BYTES))) .route("/v1/client/documents/content", get(client_documents::document_get)) .route("/v1/client/documents", get(client_documents::documents_list)) - .route("/v1/client/claims", get(records::list_claims)) - .route("/v1/client/claims/by-id/{id}", get(records::get_claim)) - .route("/v1/client/work-items/{*subject}", get(work::get_work)) + .route("/v1/client/claims", get(records::client_list_claims)) + .route("/v1/client/claims/by-id/{id}", get(records::client_get_claim)) + .route("/v1/client/work-items/{*subject}", get(work::client_get_work)) .route("/v1/client/document-references", get(client_documents::document_references)) .route("/v1/client/push-registration", post(client_push::register) .delete(client_push::unregister) diff --git a/crates/fractal-api-core/src/api/client_documents.rs b/crates/fractal-api-core/src/api/client_documents.rs index cb4f89d019d..c083d869beb 100644 --- a/crates/fractal-api-core/src/api/client_documents.rs +++ b/crates/fractal-api-core/src/api/client_documents.rs @@ -28,9 +28,10 @@ pub(super) async fn document_get( Extension(session): Extension, Query(query): Query, ) -> Result, ApiError> { - require_scope(&session, if client_v0::external_grants::is_service(&session) { - "read.documents" - } else { "read.projections" })?; + if client_v0::external_grants::is_service(&session) { + return Err(client_v0::external_grants::service_documents_forbidden()); + } + require_scope(&session, "read.projections")?; let (name, hash) = match query.name.rsplit_once('@') { Some((name, hash)) if !name.is_empty() && !hash.is_empty() => { (name.to_owned(), Some(hash.to_owned())) @@ -88,9 +89,10 @@ pub(super) async fn documents_list( Extension(session): Extension, query: Query, ) -> Result, ApiError> { - require_scope(&session, if client_v0::external_grants::is_service(&session) { - "read.documents" - } else { "read.projections" })?; + if client_v0::external_grants::is_service(&session) { + return Err(client_v0::external_grants::service_documents_forbidden()); + } + require_scope(&session, "read.projections")?; let Json(page) = list_documents(State(state), query).await?; Ok(Json(json!({ "items": page.items.iter().map(document_item).collect::>(), diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants.rs b/crates/fractal-api-core/src/api/client_v0/external_grants.rs index 76fe3e1e6c7..4c101f96fc6 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants.rs @@ -319,9 +319,17 @@ fn authorize_service(session: &ClientSession, request: &Request) -> Result || !scope.is_some_and(|scope| session.allows(scope)) { return Err(forbidden("service requires a registered pure GET and its exact read scope")); } + if scope == Some("read.documents") { + return Err(service_documents_forbidden()); + } Ok(()) } +/// No consumer-owned document namespace is declared: the allowlist is deliberately empty. +pub fn service_documents_forbidden() -> ApiError { + forbidden("service document namespace allowlist is empty") +} + /// The only graph mutation admitted on behalf of a service read. The daemon signs as itself. pub const SERVICE_AUDIT_KINDS: &[&str] = &["custom.client.service.v1.read-window"]; const AUDIT_WINDOW_MS: u128 = 60_000; diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs index 439bb9cab9a..333d364842a 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs @@ -93,7 +93,9 @@ async fn service_r29_claim_parity_paging_bounds_redaction_and_credential_secrecy } let (status, value) = call(&app,"GET",&format!("/v1/client/claims/by-id/{}",grant.id),Value::Null,false).await; assert_eq!(status, StatusCode::OK, "{value}"); - assert_eq!(value["body"]["fields"]["credential_hash"], credential_digest(TOKEN)); + assert!(value.get("body").is_none()); + assert_eq!(value["body_digest"].as_str().unwrap().len(),64); + assert!(!value.to_string().contains(&credential_digest(TOKEN))); assert!(!value.to_string().contains(TOKEN)); assert!(!value.to_string().contains("\"credential\":")); if !gateway { @@ -146,6 +148,7 @@ async fn service_r30_both_router_refusal_matrix_preserves_graph_documents_proces let documents = state.store.list_documents_page(None,None,true,None,500).unwrap(); let claims = state.store.claims_page(None,None,0,None,false,500).unwrap(); let before_files = files(root.path()); + #[cfg(target_os = "linux")] let processes = std::fs::read_to_string("/proc/self/task".to_owned() + &format!("/{}/children",std::process::id())).unwrap(); let routes = [ "/v1/claims", "/v1/documents", "/v1/client/claims", "/v1/client/documents", @@ -187,6 +190,7 @@ async fn service_r30_both_router_refusal_matrix_preserves_graph_documents_proces assert_eq!(serde_json::to_value(state.store.claims_page(None,None,0,None,false,500).unwrap()).unwrap(),serde_json::to_value(claims).unwrap()); assert_eq!(serde_json::to_value(state.store.list_documents_page(None,None,true,None,500).unwrap()).unwrap(),serde_json::to_value(documents).unwrap()); assert_eq!(files(root.path()),before_files); + #[cfg(target_os = "linux")] assert_eq!(std::fs::read_to_string("/proc/self/task".to_owned() + &format!("/{}/children",std::process::id())).unwrap(),processes); } @@ -197,7 +201,7 @@ async fn service_audit_is_daemon_owned_closed_and_bounded_per_grant_window() { let before = state.store.index().unwrap(); let app = crate::api::fabric_router(state.clone()); for _ in 0..20 { - assert_eq!(call(&app,"GET","/v1/client/documents",Value::Null,false).await.0,StatusCode::OK); + assert_eq!(call(&app,"GET","/v1/client/claims",Value::Null,false).await.0,StatusCode::OK); } let changed = state.store.claims_page(None,None,before,None,false,500).unwrap().claims; // A run crossing a minute boundary may produce two windows, never twenty records. @@ -211,7 +215,7 @@ async fn service_audit_is_daemon_owned_closed_and_bounded_per_grant_window() { } #[tokio::test] -async fn service_r29_work_and_document_projection_parity() { +async fn service_r29_work_parity_and_empty_document_allowlist() { let root = tempfile::tempdir().unwrap(); let (state, _) = fixture(root.path(), SERVICE_SCOPES); let source = r#"version 2 @@ -239,21 +243,41 @@ mission "service-work" state="ready" { let (status, work) = call(&app,"GET",&format!("/v1/client/work-items/{subject}"),Value::Null,false).await; assert_eq!(status,StatusCode::OK,"{work}"); assert_eq!(work,expected); - let (status, list) = call(&app,"GET","/v1/client/documents?prefix=doc/service-parity",Value::Null,false).await; - assert_eq!(status,StatusCode::OK,"{list}"); - assert_eq!(list["items"].as_array().unwrap().len(),1); - assert_eq!(list["items"][0]["version"],document.hash); - let (status, content) = call(&app,"GET","/v1/client/documents/content?name=doc/service-parity",Value::Null,false).await; - assert_eq!(status,StatusCode::OK,"{content}"); - assert_eq!(content["bytes"],json!(b"service document".to_vec())); + for path in [ + "/v1/client/documents?prefix=doc/service-parity", + "/v1/client/documents?history=true", + "/v1/client/documents/content?name=doc/service-parity", + ] { + assert_eq!(call(&app,"GET",path,Value::Null,false).await.0,StatusCode::FORBIDDEN); + } if !gateway { let (status, raw) = call(&app,"GET",&format!("/v1/work-items/{subject}"),Value::Null,false).await; assert_eq!(status,StatusCode::OK); assert_eq!(raw,work); - assert_eq!(call(&app,"GET","/v1/documents",Value::Null,false).await.0,StatusCode::OK); - let (status, raw) = call(&app,"GET",&format!("/v1/documents/content?reference=doc/service-parity@{}",document.hash),Value::Null,false).await; - assert_eq!(status,StatusCode::OK); - assert_eq!(raw["bytes"],content["bytes"]); + assert_eq!(call(&app,"GET","/v1/documents?history=true",Value::Null,false).await.0,StatusCode::FORBIDDEN); + assert_eq!(call(&app,"GET",&format!("/v1/documents/content?reference=doc/service-parity@{}",document.hash),Value::Null,false).await.0,StatusCode::FORBIDDEN); } } } + +#[tokio::test] +async fn service_receipt_bodies_use_only_the_closed_field_schema() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(), SERVICE_SCOPES); + let receipt = state.store.append_claim(&ClaimInput { + subject:"gate-operation/service/work/review".into(),kind:"gate.result".into(), + actor:Some("person/owner".into()), + fields:serde_json::from_value(json!({"verdict":"pass","gate":"review","baseline":true, + "value":{"declarations_kdl":"receipt-secret-sentinel","code_hash":"other-verifier"}, + "reason":"private-free-text"})).unwrap(), + evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); + for app in [crate::api::router(state.clone()),crate::api::fabric_router(state.clone())] { + let (status,value) = call(&app,"GET",&format!("/v1/client/claims/by-id/{}",receipt.id),Value::Null,false).await; + assert_eq!(status,StatusCode::OK,"{value}"); + assert_eq!(value["body"],json!({"fields":{"verdict":"pass","gate":"review","baseline":true}})); + assert!(!value.to_string().contains("receipt-secret-sentinel")); + assert!(!value.to_string().contains("other-verifier")); + assert!(!value.to_string().contains("private-free-text")); + } +} diff --git a/crates/fractal-api-core/src/api/records.rs b/crates/fractal-api-core/src/api/records.rs index bf32e8b29b7..6e4b6c06018 100644 --- a/crates/fractal-api-core/src/api/records.rs +++ b/crates/fractal-api-core/src/api/records.rs @@ -288,11 +288,10 @@ pub async fn get_claim( State(state): State, AxumPath(id): AxumPath, session: Option>, -) -> Result, ApiError> { - if let Some(session) = &session { - require_scope(&session.0, if client_v0::external_grants::is_service(&session.0) { - "read.claims" - } else { "read.projections" })?; +) -> Result, ApiError> { + if let Some(session) = &session + && client_v0::external_grants::is_service(&session.0) { + require_scope(&session.0, "read.claims")?; } let store = state.store.clone(); let id_for_read = id.clone(); @@ -300,15 +299,30 @@ pub async fn get_claim( .await? .filter(|claim| !claim.subject.starts_with("glass/")) .filter(generic_claim_visible) - .map(|mut claim| { - if session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)) { - redact_service_claim(&mut claim.body); - } - Json(claim) - }) + .map(|claim| Json(if session.as_ref().is_some_and(|session| + client_v0::external_grants::is_service(&session.0)) { + service_claim(&claim) + } else { json!(claim) })) .ok_or_else(|| ApiError::not_found(format!("claim `{id}` does not exist"))) } +/// These aliases are a service surface, not a new projection capability for devices. +pub async fn client_get_claim( + state: State, + id: AxumPath, + session: Extension, +) -> Result, ApiError> { + require_service_claims(&session.0)?; + get_claim(state, id, Some(session)).await +} + +fn require_service_claims(session: &ClientSession) -> Result<(), ApiError> { + if !client_v0::external_grants::is_service(session) { + return Err(client_v0::forbidden("client claim reads require a service principal")); + } + require_scope(session, "read.claims") +} + #[derive(Deserialize)] pub struct ClaimsQuery { subject: Option, @@ -335,11 +349,10 @@ pub async fn list_claims( State(state): State, Query(query): Query, session: Option>, -) -> Result, ApiError> { - if let Some(session) = &session { - require_scope(&session.0, if client_v0::external_grants::is_service(&session.0) { - "read.claims" - } else { "read.projections" })?; +) -> Result, ApiError> { + if let Some(session) = &session + && client_v0::external_grants::is_service(&session.0) { + require_scope(&session.0, "read.claims")?; } if query.limit == 0 || query.limit > 500 { return Err(ApiError::bad(FractalError::new( @@ -349,7 +362,7 @@ pub async fn list_claims( } let service = session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)); let store = state.store.clone(); - let mut page = blocking_store(move || { + let page = blocking_store(move || { let subject = query.subject.as_deref().map(|subject| { crate::store::identities::resolve_reference_conn(&store.readers.get(), subject) }).transpose()?; @@ -364,30 +377,47 @@ pub async fn list_claims( ) }) .await?; - if service { - for claim in &mut page.claims { - redact_service_claim(&mut claim.body); - } - } - Ok(Json(page)) + Ok(Json(if service { + json!({"claims":page.claims.iter().map(service_claim).collect::>(), + "next_cursor":page.next_cursor}) + } else { json!(page) })) } -/// Generic records must not become a declaration or environment-value disclosure route. -fn redact_service_claim(value: &mut Value) { - match value { - Value::Object(fields) => { - for (key, value) in fields { - if matches!(key.as_str(), "desired" | "declaration" | "kdl" | "tree" - | "env" | "environment" | "env_values" | "credential" | "code") { - *value = json!(""); - } else { - redact_service_claim(value); - } - } - } - Value::Array(values) => values.iter_mut().for_each(redact_service_claim), - _ => {} - } +pub async fn client_list_claims( + state: State, + query: Query, + session: Extension, +) -> Result, ApiError> { + require_service_claims(&session.0)?; + list_claims(state, query, Some(session)).await +} + +/// Closed R29 receipt/state schema. No unknown kind or field acquires disclosure rights. +/// In particular, declarations, challenges and grant verifiers are metadata-only. +fn service_claim(claim: &ClaimRecord) -> Value { + let mut value = json!({ + "id":claim.id, "kind":claim.kind, "subject":claim.subject, "origin":claim.origin, + "store_index":claim.store_index, "accepted_at_unix_ms":claim.accepted_at_unix_ms, + "body_digest":client_v0::credential_digest(&claim.body.to_string()), + }); + let fields: &[&str] = match claim.kind.as_str() { + "gate.result" => &["verdict", "operation", "request", "gate", "baseline", "token_usage", "stage"], + "work.claimed" | "work.renewed" | "work.progress" | "work.submitted" | "work.failed" + | "work.released" | "work.extended" => &[ + "attempt", "status", "worker_reported", "claimant", "claim_incarnation", + "claim_expires_at_unix_ms", "readiness_epoch", "extend_ms", "clock", + "wait_until_unix_ms", "submitted_incarnation", + ], + _ => return value, + }; + value["actor"] = json!(claim.actor); + let source = claim.body.get("fields").unwrap_or(&claim.body); + let projected = fields.iter().filter_map(|field| { + source.get(*field).filter(|value| value.is_string() || value.is_number() || value.is_boolean()) + .map(|value| ((*field).to_owned(), value.clone())) + }).collect::>(); + value["body"] = json!({"fields":projected}); + value } #[derive(Default, Deserialize)] diff --git a/crates/fractal-api-core/src/api/work.rs b/crates/fractal-api-core/src/api/work.rs index 47d894d737b..fc00a2dce44 100644 --- a/crates/fractal-api-core/src/api/work.rs +++ b/crates/fractal-api-core/src/api/work.rs @@ -184,8 +184,9 @@ pub async fn get_work( session: Option>, ) -> Result, ApiError> { let service = session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)); - if let Some(session) = &session { - require_scope(&session.0, if service { "read.work" } else { "read.projections" })?; + if let Some(session) = &session + && service { + require_scope(&session.0, "read.work")?; } let subject = if subject.starts_with("step-run/") { subject @@ -206,6 +207,17 @@ pub async fn get_work( Ok(Json(step)) } +pub async fn client_get_work( + state: State, + subject: AxumPath, + session: Extension, +) -> Result, ApiError> { + if !client_v0::external_grants::is_service(&session.0) { + return Err(client_v0::forbidden("client work reads require a service principal")); + } + get_work(state, subject, Some(session)).await +} + pub async fn wake_work( State(state): State, AxumPath(subject): AxumPath, diff --git a/crates/fractal-daemon/src/api.rs b/crates/fractal-daemon/src/api.rs index 72fe8f65b00..1c6a5574d41 100644 --- a/crates/fractal-daemon/src/api.rs +++ b/crates/fractal-daemon/src/api.rs @@ -29,3 +29,6 @@ pub fn router(state: AppState) -> Router { pub fn fabric_router(state: AppState) -> Router { fabric_router_with(state, ROUTE_FAMILIES) } + +#[cfg(test)] +mod service_read_tests; diff --git a/crates/fractal-daemon/src/api/service_read_tests.rs b/crates/fractal-daemon/src/api/service_read_tests.rs new file mode 100644 index 00000000000..456e9d8637e --- /dev/null +++ b/crates/fractal-daemon/src/api/service_read_tests.rs @@ -0,0 +1,154 @@ +//! Real producers above api-core prove that service projections fail closed. +use super::*; +use axum::{body::{Body, to_bytes}, http::{Request, StatusCode}}; +use serde_json::{Value, json}; +use tower::ServiceExt as _; +use fractal_api_core::api::client_v0::{credential_digest, external_grants}; +use crate::model::ClaimInput; + +const TOKEN: &str = "private-service-read-fixture"; +const OWNER: &str = "person/owner"; + +async fn request(app: &Router, method: &str, path: &str, token: Option<&str>, body: Value) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(path) + .header("x-st3-person",OWNER).header("content-type","application/json"); + if let Some(token) = token { builder = builder.header("authorization",format!("Bearer {token}")); } + let response = app.clone().oneshot(builder.body(Body::from(body.to_string())).unwrap()).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(),usize::MAX).await.unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status,if status.is_success() { value.get("value").cloned().unwrap_or(value) } else { value }) +} + +// cfg(test) module only: no production route or feature enables service issuance. +fn service_fixture(state: &AppState) { + state.store.append_claim(&ClaimInput { + subject:external_grants::subject("565656565656565656565656").unwrap(), + kind:external_grants::GRANTED.into(),actor:Some(OWNER.into()), + fields:serde_json::from_value(json!({"profile":"service","principal":"plugin/test/reader", + "grant_id":"565656565656565656565656", "session_actor":"plugin/test/reader/session/565656565656565656565656", + "issuer":OWNER,"expires_at_unix_ms":u64::MAX,"credential_hash":credential_digest(TOKEN), + "scopes":external_grants::SERVICE_SCOPES})).unwrap(), + evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); +} + +#[tokio::test] +async fn real_mission_planning_and_pending_pairings_are_metadata_only_or_refused() { + let root = tempfile::tempdir().unwrap(); + let mut state = tests_shared::state(root.path()); + state.owning_person = Some(OWNER.into()); + let source = r#"version 2 +mission "service-sensitive" state="ready" { + goal "Protect embedded declarations." + agent "builder" { command "true"; env { TOKEN "mission-env-secret-sentinel" } } + step "work" { + assigned-to "agent/${ST_MISSION_RUN}/builder" + agent "checker" { command "true"; env { TOKEN "step-env-secret-sentinel" } } + } +} +"#; + let intent = crate::graph::parse_test_intent(source,"node").unwrap(); + let plan = state.store.mission(&intent,crate::model::IntentInput { kdl:source.into(),source_name:None }).unwrap(); + state.store.apply(&intent,&plan.subject_tokens,"service-real-mission").unwrap(); + let native = router(state.clone()); + let (status,launch) = request(&native,"POST","/v1/launches",None,json!({ + "mission":"service-planned","request":b"Plan a harmless mission.".to_vec(), + "workspace":root.path().to_string_lossy(),"requester":OWNER,"idempotency_key":"service-real-planning" + })).await; + assert_eq!(status,StatusCode::OK,"{launch}"); + let candidate = br#"version 2 +mission "service-planned" state="ready" { + goal "Protect a real planning candidate." + agent "builder" { command "true"; env { TOKEN "planning-env-secret-sentinel" } } + step "work" { assigned-to "agent/${ST_MISSION_RUN}/builder" } +} +"#; + let id = launch["id"].as_str().unwrap(); + let (status,submitted) = request(&native,"POST",&format!("/v1/launches/{}/variants/secret/submit",urlencoding::encode(id)),None,json!({ + "actor":launch["planner"],"markdown":b"# Planning candidate".to_vec(), + "kdl":candidate.to_vec(),"idempotency_key":"service-real-variant" + })).await; + assert_eq!(status,StatusCode::OK,"{submitted}"); + for body in [ + json!({"api_version":"st3.client.v0","device_name":"Pending device","person_id":OWNER,"scopes":["read.projections"]}), + json!({"api_version":"st3.client.v0","device_name":"Pending assistant","assistant_id":"assistant/chatgpt", + "scopes":["read.assistant.summary"],"disclosure_consent":true,"consent_evidence":"Recorded owner consent"}), + ] { + let (status,challenge) = request(&native,"POST","/v1/client/pairings",None,body).await; + assert_eq!(status,StatusCode::OK,"{challenge}"); + assert!(challenge["code"].as_str().is_some()); + } + service_fixture(&state); + let claims = state.store.claims_page(None,None,0,None,false,500).unwrap().claims; + let mission = claims.iter().find(|claim| claim.kind == "mission.published").unwrap(); + assert!(mission.body.to_string().contains("mission-env-secret-sentinel")); + assert!(mission.body.to_string().contains("step-env-secret-sentinel")); + let challenges: Vec<_> = claims.iter().filter(|claim| matches!(claim.kind.as_str(), + "custom.client.pairing-begun" | external_grants::BEGUN)).collect(); + assert_eq!(challenges.len(),2); + let verifiers: Vec<_> = challenges.iter().map(|claim| claim.body["fields"]["code_hash"].as_str().unwrap()).collect(); + let documents = state.store.list_documents_page(None,Some("doc/planning/"),true,None,500).unwrap(); + assert!(!documents.is_empty()); + assert!(documents.iter().any(|document| state.store.get_document(&document.name,&document.hash).unwrap() + .is_some_and(|bytes| String::from_utf8_lossy(&bytes).contains("planning-env-secret-sentinel")))); + for app in [router(state.clone()),fabric_router(state.clone())] { + let mut after = 0; + let mut seen = Vec::new(); + loop { + let (status,page) = request(&app,"GET",&format!("/v1/client/claims?limit=2&after={after}"),Some(TOKEN),Value::Null).await; + assert_eq!(status,StatusCode::OK,"{page}"); + let wire = page.to_string(); + for sentinel in ["mission-env-secret-sentinel","step-env-secret-sentinel","planning-env-secret-sentinel"] { + assert!(!wire.contains(sentinel)); + } + for verifier in &verifiers { assert!(!wire.contains(verifier)); } + assert!(!wire.contains("code_hash") && !wire.contains("credential_hash")); + seen.extend(page["claims"].as_array().unwrap().iter().map(|claim|claim["id"].as_str().unwrap().to_owned())); + let Some(next) = page["next_cursor"].as_u64() else { break }; + assert!(next > after); after = next; + } + for claim in claims.iter().filter(|claim| matches!(claim.kind.as_str(),"mission.published" | "intent.desired" + | "custom.client.pairing-begun" | external_grants::BEGUN | external_grants::GRANTED)) { + assert!(seen.contains(&claim.id)); + for prefix in ["/v1/client/claims/by-id/","/v1/claims/by-id/"] { + let (status,value) = request(&app,"GET",&format!("{prefix}{}",claim.id),Some(TOKEN),Value::Null).await; + if status == StatusCode::OK { + assert!(value.get("body").is_none(),"{value}"); + assert_eq!(value["body_digest"].as_str().unwrap().len(),64); + } else { assert_eq!(status,StatusCode::FORBIDDEN); } + } + } + for prefix in ["/v1/client/documents","/v1/documents"] { + for query in ["","?history=true","?prefix=doc/planning/","?prefix=doc/planning/&history=true"] { + assert_eq!(request(&app,"GET",&format!("{prefix}{query}"),Some(TOKEN),Value::Null).await.0,StatusCode::FORBIDDEN); + } + } + for document in &documents { + for path in [format!("/v1/client/documents/content?name={}@{}",document.name,document.hash), + format!("/v1/client/documents/content?name={}",document.name), + format!("/v1/documents/content?reference={}@{}",document.name,document.hash)] { + assert_eq!(request(&app,"GET",&path,Some(TOKEN),Value::Null).await.0,StatusCode::FORBIDDEN); + } + } + } +} + +#[tokio::test] +async fn paired_devices_and_browser_sessions_cannot_use_service_claim_routes() { + let root = tempfile::tempdir().unwrap(); + let state = tests_shared::state(root.path()); + state.store.append_claim(&ClaimInput { + subject:"custom/client/service-route-device".into(),kind:"custom.client.pairing-completed".into(), + actor:Some(OWNER.into()),fields:serde_json::from_value(json!({ + "credential_hash":credential_digest("device-only"),"session_actor":"person/owner/session/device", + "person_id":OWNER,"device_id":"device/phone","scopes":["read.projections"],"expires_at_unix_ms":u64::MAX + })).unwrap(),evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); + for app in [router(state.clone()),fabric_router(state.clone())] { + for path in ["/v1/client/claims","/v1/client/claims/by-id/absent"] { + assert_eq!(request(&app,"GET",path,Some("device-only"),Value::Null).await.0,StatusCode::FORBIDDEN); + assert_eq!(request(&app,"GET",path,None,Value::Null).await.0,StatusCode::FORBIDDEN); + } + } +} diff --git a/crates/fractal-daemon/src/web.rs b/crates/fractal-daemon/src/web.rs index 4e2454d7222..7a2fc1798a7 100644 --- a/crates/fractal-daemon/src/web.rs +++ b/crates/fractal-daemon/src/web.rs @@ -162,8 +162,10 @@ async fn admit(web: &Web, mut request: Request, next: Next) -> Response { Some(StatusCode::UNAUTHORIZED.into_response()) } }; - if let Some(response) = result { response } else { - request.headers_mut().remove(header::AUTHORIZATION); + if let Some(response) = result { response } else if request.headers().contains_key(header::AUTHORIZATION) { + // Browser authority and bearer authority must never be silently combined or downgraded. + StatusCode::FORBIDDEN.into_response() + } else { request.headers_mut().remove("x-fractal-person"); request.headers_mut().remove("x-st3-person"); request.extensions_mut().insert(crate::api::client_v0::LocalWebSession { @@ -243,7 +245,7 @@ mod tests { }).layer(middleware::from_fn_with_state(web.clone(), admission)); let call = |uri: &str, method: &str, host: &str, cookie: Option<&str>, origin: Option<&str>, upgrade: bool| { let mut request = Request::builder().uri(uri).method(method).header(header::HOST, host) - .header("x-st3-person", "person/other").header(header::AUTHORIZATION, "Bearer forged"); + .header("x-st3-person", "person/other"); if let Some(cookie) = cookie { request = request.header(header::COOKIE, cookie); } if let Some(origin) = origin { request = request.header(header::ORIGIN, origin); } if upgrade { request = request.header(header::UPGRADE, "websocket"); } @@ -265,4 +267,36 @@ mod tests { let bytes = axum::body::to_bytes(accepted.into_body(), 1024).await.unwrap(); assert_eq!(&bytes[..], b"person/robin"); } + + #[tokio::test] + async fn admitted_browser_cookie_and_bearer_are_refused_before_dispatch() { + let root = tempfile::tempdir().unwrap(); + let web = Web { + port:7410, person:"person/robin".into(), dist:root.path().into(), + policy:policy("",7410), credential_path:root.path().join("web-launch-url"), + credentials:Arc::new(Mutex::new(Credentials { + token:"once".into(), sessions:VecDeque::from([("admitted".into(),Instant::now())]), + })), + }; + let dispatched = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = dispatched.clone(); + let app = Router::new().fallback(move || { + let observed = observed.clone(); + async move { + observed.fetch_add(1,std::sync::atomic::Ordering::SeqCst); + StatusCode::OK + } + }).layer(middleware::from_fn_with_state(web,admission)); + for method in ["GET","POST","PUT","PATCH","DELETE"] { + let response = app.clone().oneshot(Request::builder() + .method(method).uri("/v1/client/actions") + .header(header::HOST,"127.0.0.1:7410") + .header(header::ORIGIN,"http://127.0.0.1:7410") + .header(header::COOKIE,"fractal_local=admitted") + .header(header::AUTHORIZATION,"Bearer service-credential") + .body(Body::from("{}")).unwrap()).await.unwrap(); + assert_eq!(response.status(),StatusCode::FORBIDDEN,"{method}"); + } + assert_eq!(dispatched.load(std::sync::atomic::Ordering::SeqCst),0); + } } diff --git a/docs/st3/principals-and-grants.md b/docs/st3/principals-and-grants.md index 22d72995826..888c64df022 100644 --- a/docs/st3/principals-and-grants.md +++ b/docs/st3/principals-and-grants.md @@ -124,18 +124,36 @@ gateway and bearer-authenticated native router. `read.claims` admits `/v1/client/claims` and `/v1/client/claims/by-id/{id}` with the native readers' 1–500 bounds and numeric `after`/`before` cursors. `read.work` admits exact `/v1/client/work-items/{subject}` reads without acknowledging wakes. -`read.documents` admits the existing client document list/content projections. -Native `/v1/claims`, `/v1/claims/by-id/{id}`, `/v1/work-items/{subject}` and -`/v1/documents[/content]` aliases use the same service admission decision; the -gateway continues to refuse raw routes. Claim bodies redact declaration trees, -environment values and credential/code material; digests remain visible. -No declaration, assistant, action, upgrade, HEAD or unknown route is admitted. +`read.documents` remains a closed scope and its routes remain registered, but its +document namespace allowlist is empty: every service list/content/history request +is refused until a consumer namespace is declared. Native aliases use the same +service decision; the gateway continues to refuse raw routes. + +Service claim bodies are deny-by-default, not field-name redaction. Only +`gate.result` and `work.claimed`, `work.renewed`, `work.progress`, +`work.submitted`, `work.failed`, `work.released`, `work.extended` disclose +schema-listed scalar receipt/state fields. Gate bodies expose `verdict`, +`operation`, `request`, `gate`, `baseline`, `token_usage`, `stage`; work bodies +expose `attempt`, `status`, `worker_reported`, `claimant`, `claim_incarnation`, +`claim_expires_at_unix_ms`, `readiness_epoch`, `extend_ms`, `clock`, +`wait_until_unix_ms`, `submitted_incarnation`, with the receipt/state claim's actor. +These support independent gate receipt and work-state validation without arbitrary +payload, summary, reason or handoff objects. +Every other kind returns only id, kind, subject, origin, store index, acceptance +time and a SHA-256 digest of its JSON body. This includes `mission.published`, +`intent.desired`, grant records and pending device/assistant pairing challenges: +neither declaration bodies nor another grant's verifiers are disclosed. +Metadata-only items retain their page positions, so pagination has no gaps. +The new client claim/work aliases admit only services; device, assistant and +browser clients gain no new routes. Existing native routes retain non-service +semantics. No declaration, assistant, action, upgrade, HEAD or unknown route is admitted. The daemon writes only `custom.client.service.v1.read-window` as `daemon/{origin}`, at most once per grant per 60-second wall-clock window. Admission-refused requests write no audit claim. Service identity is never an acting party, -and a bearer takes precedence over local person headers or browser identity. -The only service-grant fixture is inside a `#[cfg(test)]` module; production +and browser ingress refuses an admitted cookie combined with Authorization rather +than discarding the bearer and exercising person authority. Service-grant fixtures +are inside `#[cfg(test)]` modules; production grant append still rejects service issuance. This is not an OS sandbox: same-UID callers dropping the credential on an unrestricted socket remain outside credential enforcement (R18). @@ -150,7 +168,7 @@ real merge consumer acceptance is complete. | --- | --- | --- | --- | --- | | R27 | A host user service outside a harness authenticates as a stable non-person subject provisioned by a person or a node, not as its invoking agent. | M1 | NOT STARTED: #5019/#3268; plugin/assistant primitives do not prove this consumer contract. | Restart unattended service and invoke from two agents; every read retains the same service subject. | | R28 | Its credential file rotates and revokes without changing the stable subject; retired/revoked credentials cease to authenticate. | M1 | NOT STARTED: #5019; general device pairing is insufficient. | Rotate, use old/new credentials, revoke, retry after daemon restart; audit subject continuity. | -| R29 | Scoped native routes allow claims by id and paged claims, work-item reads and document reads at admission, queue proof and final merge. | M1 | PARTIAL: closed service read surface and router fixtures implemented; issuance remains disabled and real merge-consumer acceptance is UNKNOWN (#5019). | Real merge consumer reads all four families, traverses pages without gaps and validates exact receipts at all three stages. | +| R29 | Scoped native routes allow claims by id and paged claims, work-item reads and document reads at admission, queue proof and final merge. | M1 | PARTIAL: closed receipt/state claim bodies and exact work reads implemented; document reads: allowlist empty until a consumer namespace is declared. Issuance remains disabled and real merge-consumer acceptance is UNKNOWN (#5019). | Real merge consumer reads all four families, traverses pages without gaps and validates exact receipts at all three stages. | | R30 | Every write is refused under the service credential, including free mode, raw routes, client actions, claims, documents and runtime/admin operations. | M1 | PARTIAL: central GET/scope admission and both-router refusal fixtures implemented; production lifecycle acceptance remains UNKNOWN (#5019). | Enumerate mutation routes/aliases; negative-write suite asserts structured refusal and zero store/runtime effects. | ## Shared box and messaging diff --git a/release-notes/service-read-gate.json b/release-notes/service-read-gate.json index d0620722809..272351b60ed 100644 --- a/release-notes/service-read-gate.json +++ b/release-notes/service-read-gate.json @@ -1 +1 @@ -{"version":1,"summary":"Add closed read-only service admission for claims, exact work items and documents while keeping production service issuance disabled","replay":{"status":"none","detail":"No migration; grant storage and digest authentication retain their existing meanings."},"schema":{"status":"none","detail":"No fractal-schema or device pairing changes."},"rules":{"status":"changed","detail":"Service bearers on both routers require an exact registered GET/read-scope pair. Unknown routes, upgrades and every mutation are refused before dispatch. Work reads do not acknowledge wakes; daemon-owned read-window audits are bounded per grant."},"compatibility":{"status":"none","detail":"Assistant scopes, disclosure consent and tests are unchanged. Service issuance remains disabled; owner lifecycle and rollout safety remain prerequisites."}} +{"version":1,"summary":"Add closed read-only service admission for receipt/state claims and exact work items; service document namespaces and production issuance remain disabled","replay":{"status":"none","detail":"No migration; grant storage and digest authentication retain their existing meanings."},"schema":{"status":"none","detail":"No fractal-schema or device pairing changes."},"rules":{"status":"changed","detail":"Service bearers on both routers require a registered GET/read-scope pair. Claim bodies use a closed gate-result/work-state field schema; all other kinds are metadata-only. Document scope and routes remain registered but every service document read is refused because the namespace allowlist is empty. Unknown routes, upgrades and mutations are refused before dispatch. Work reads do not acknowledge wakes; daemon-owned read-window audits are bounded per grant."},"compatibility":{"status":"changed","detail":"New client claim/work aliases are service-only; existing non-service native reads retain their semantics. Browser ingress refuses mixed admitted-cookie/Authorization requests instead of discarding the bearer. Assistant scopes and disclosure consent are unchanged. Service issuance remains disabled; owner lifecycle and rollout safety remain prerequisites."}} From 29335f1d2076d4dd34f9f92194fe5d18edc09d58 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:00:25 +0100 Subject: [PATCH 016/132] fix(fractal-daemon): refuse mixed browser authority before token redemption agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-daemon/src/web.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/fractal-daemon/src/web.rs b/crates/fractal-daemon/src/web.rs index 7a2fc1798a7..caff27d7a69 100644 --- a/crates/fractal-daemon/src/web.rs +++ b/crates/fractal-daemon/src/web.rs @@ -131,6 +131,10 @@ async fn admit(web: &Web, mut request: Request, next: Next) -> Response { let cookie = request.headers().get(header::COOKIE).and_then(|value| value.to_str().ok()) .and_then(|value| value.split(';').find_map(|part| part.trim().strip_prefix("fractal_local="))) .map(str::to_owned); + if cookie.is_some() && request.headers().contains_key(header::AUTHORIZATION) { + // Refuse before token redemption too: mixed authority must not mint a browser session. + return StatusCode::FORBIDDEN.into_response(); + } let mut expires_at = None; let result = { let mut credentials = web.credentials.lock().expect("web credentials poisoned"); @@ -162,10 +166,7 @@ async fn admit(web: &Web, mut request: Request, next: Next) -> Response { Some(StatusCode::UNAUTHORIZED.into_response()) } }; - if let Some(response) = result { response } else if request.headers().contains_key(header::AUTHORIZATION) { - // Browser authority and bearer authority must never be silently combined or downgraded. - StatusCode::FORBIDDEN.into_response() - } else { + if let Some(response) = result { response } else { request.headers_mut().remove("x-fractal-person"); request.headers_mut().remove("x-st3-person"); request.extensions_mut().insert(crate::api::client_v0::LocalWebSession { @@ -286,7 +287,7 @@ mod tests { observed.fetch_add(1,std::sync::atomic::Ordering::SeqCst); StatusCode::OK } - }).layer(middleware::from_fn_with_state(web,admission)); + }).layer(middleware::from_fn_with_state(web.clone(),admission)); for method in ["GET","POST","PUT","PATCH","DELETE"] { let response = app.clone().oneshot(Request::builder() .method(method).uri("/v1/client/actions") @@ -297,6 +298,14 @@ mod tests { .body(Body::from("{}")).unwrap()).await.unwrap(); assert_eq!(response.status(),StatusCode::FORBIDDEN,"{method}"); } + let login = app.oneshot(Request::builder().uri("/?token=once") + .header(header::HOST,"127.0.0.1:7410") + .header(header::COOKIE,"fractal_local=admitted") + .header(header::AUTHORIZATION,"Bearer service-credential") + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(login.status(),StatusCode::FORBIDDEN); + assert_eq!(web.credentials.lock().unwrap().token,"once"); + assert!(!web.credential_path.exists()); assert_eq!(dispatched.load(std::sync::atomic::Ordering::SeqCst),0); } } From 98600d8fe130018b7888b08cfb04a8736fa548c5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:01:33 +0100 Subject: [PATCH 017/132] test(fractal-daemon): refuse browser sessions on service claim aliases agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-daemon/src/api/service_read_tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/fractal-daemon/src/api/service_read_tests.rs b/crates/fractal-daemon/src/api/service_read_tests.rs index 456e9d8637e..59d0c456e5f 100644 --- a/crates/fractal-daemon/src/api/service_read_tests.rs +++ b/crates/fractal-daemon/src/api/service_read_tests.rs @@ -149,6 +149,12 @@ async fn paired_devices_and_browser_sessions_cannot_use_service_claim_routes() { for path in ["/v1/client/claims","/v1/client/claims/by-id/absent"] { assert_eq!(request(&app,"GET",path,Some("device-only"),Value::Null).await.0,StatusCode::FORBIDDEN); assert_eq!(request(&app,"GET",path,None,Value::Null).await.0,StatusCode::FORBIDDEN); + let browser = Request::builder().uri(path) + .extension(fractal_api_core::api::client_v0::LocalWebSession { + person:OWNER.into(),id:credential_digest("browser-session"), + expires_at:u128::from(u64::MAX), + }).body(Body::empty()).unwrap(); + assert_eq!(app.clone().oneshot(browser).await.unwrap().status(),StatusCode::FORBIDDEN); } } } From 7b3838a9a15d98c14044bbf7fb550c0fe26efea3 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:15:02 +0100 Subject: [PATCH 018/132] fix(service): withhold verifier metadata and rich work text agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../external_grants_service_tests.rs | 65 ++++++++++++++--- crates/fractal-api-core/src/api/records.rs | 32 +++++---- crates/fractal-api-core/src/api/work.rs | 66 ++++++++++++++++- .../src/api/service_read_tests.rs | 72 +++++++++++++++++-- docs/st3/principals-and-grants.md | 21 ++++-- release-notes/service-read-gate.json | 2 +- 6 files changed, 219 insertions(+), 39 deletions(-) diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs index 333d364842a..58c3cc54e8b 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs @@ -1,5 +1,6 @@ use super::*; use axum::body::to_bytes; +use crate::PersonWorkStore; const TOKEN: &str = "service-test-private-bearer-never-in-a-claim"; const ID: &str = "121212121212121212121212"; @@ -66,7 +67,7 @@ async fn service_r29_claim_parity_paging_bounds_redaction_and_credential_secrecy actor:Some("person/owner".into()), fields:serde_json::from_value(json!({"number":n, "desired":{"environment":{"TOKEN":"environment-secret"}}, "declaration":"declaration-secret"})).unwrap(), evidence:vec![], expected_subject:None, idempotency_key:None, - }).unwrap().id); + }).unwrap().store_index); } for gateway in [false, true] { let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; @@ -78,7 +79,11 @@ async fn service_r29_claim_parity_paging_bounds_redaction_and_credential_secrecy let serialized = page.to_string(); assert!(!serialized.contains("environment-secret")); assert!(!serialized.contains("declaration-secret")); - ids.extend(page["claims"].as_array().unwrap().iter().map(|claim| claim["id"].as_str().unwrap().to_owned())); + ids.extend(page["claims"].as_array().unwrap().iter().map(|claim| { + assert_eq!(claim.as_object().unwrap().len(), 2); + assert_eq!(claim["withheld"], true); + claim["store_index"].as_u64().unwrap() + })); let Some(next) = page["next_cursor"].as_u64() else { break }; assert!(next > after); after = next; @@ -92,15 +97,15 @@ async fn service_r29_claim_parity_paging_bounds_redaction_and_credential_secrecy assert_eq!(call(&app,"GET",&format!("/v1/client/claims?limit={limit}"),Value::Null,false).await.0, StatusCode::OK); } let (status, value) = call(&app,"GET",&format!("/v1/client/claims/by-id/{}",grant.id),Value::Null,false).await; - assert_eq!(status, StatusCode::OK, "{value}"); - assert!(value.get("body").is_none()); - assert_eq!(value["body_digest"].as_str().unwrap().len(),64); + assert_eq!(status, StatusCode::NOT_FOUND, "{value}"); + let (missing_status, _) = call(&app,"GET","/v1/client/claims/by-id/nonexistent",Value::Null,false).await; + assert_eq!(status, missing_status); assert!(!value.to_string().contains(&credential_digest(TOKEN))); assert!(!value.to_string().contains(TOKEN)); assert!(!value.to_string().contains("\"credential\":")); if !gateway { let (status, raw) = call(&app,"GET",&format!("/v1/claims/by-id/{}",grant.id),Value::Null,false).await; - assert_eq!(status, StatusCode::OK); + assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(raw, value); } } @@ -236,13 +241,54 @@ mission "service-work" state="ready" { idempotency_key:"service-run".into(), }).unwrap(); let subject = &run.steps[0].subject; + state.store.set_step_state(subject,"ready",None).unwrap(); + let action = |name: &str, summary: &str| state.store.work_action(subject,name,&crate::model::WorkRequest { + wait_ms:None,actor:Some("agent/node.reader".into()),incarnation:Some("service-runtime".into()), + summary:Some(summary.into()),reason:None,evidence:vec![],idempotency_key:format!("service-{name}"), + }).unwrap(); + action("claim","claim"); + action("progress","worker-progress-secret-sentinel"); + let progress = state.store.step_run(subject).unwrap().unwrap(); + assert_eq!(progress.progress_summary.as_deref(),Some("worker-progress-secret-sentinel")); + for app in [crate::api::router(state.clone()),crate::api::fabric_router(state.clone())] { + let (status,view) = call(&app,"GET",&format!("/v1/client/work-items/{subject}"),Value::Null,false).await; + assert_eq!(status,StatusCode::OK); + assert!(!view.to_string().contains("worker-progress-secret-sentinel")); + } + let ask = state.store.ask_person(&crate::model::PersonAskRequest { + legacy_request:None,person:"person/owner".into(),title:"Private question".into(), + reason:"Private reason".into(),actor:"agent/node.reader".into(),step:Some(subject.clone()), + new_run:None,incarnation:Some("service-runtime".into()),request:None, + deadline_ms:None,default_answer:None,idempotency_key:"service-person-ask".into(), + }).unwrap(); + state.store.finish_person_step(&crate::model::PersonStepResponse { + delegation:None,subject:ask.subject,actor:"person/owner".into(), + summary:"person-answer-secret-sentinel".into(),evidence:vec![],episode:None, + answer:None,idempotency_key:"service-person-answer".into(), + },false).unwrap(); + action("complete","worker-completion-secret-sentinel"); + let completed = state.store.step_run(subject).unwrap().unwrap(); + assert_eq!(completed.completion_summary.as_deref(),Some("worker-completion-secret-sentinel")); + assert!(serde_json::to_string(&completed.person_answers).unwrap().contains("person-answer-secret-sentinel")); let expected = serde_json::to_value(state.store.step_run(subject).unwrap().unwrap()).unwrap(); let document = state.store.put_document_as("doc/service-parity", b"service document", &None, "service-doc", None).unwrap(); for gateway in [false, true] { let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; let (status, work) = call(&app,"GET",&format!("/v1/client/work-items/{subject}"),Value::Null,false).await; assert_eq!(status,StatusCode::OK,"{work}"); - assert_eq!(work,expected); + for sentinel in ["worker-progress-secret-sentinel","worker-completion-secret-sentinel","person-answer-secret-sentinel"] { + assert!(!work.to_string().contains(sentinel)); + } + for field in ["subject","run","generation","step","attempt","status","claimant", + "claim_incarnation","submitted_incarnation","claim_expires_at_unix_ms", + "readiness_epoch","worker_reported","created_at_unix_ms","updated_at_unix_ms"] { + assert_eq!(work[field],expected[field],"{field}"); + } + assert_eq!(work.as_object().unwrap().len(),15); + for field in ["goals","constraints","title","person_answers","progress_summary", + "completion_summary","blocked_reason","under"] { + assert!(work.get(field).is_none(),"{field}"); + } for path in [ "/v1/client/documents?prefix=doc/service-parity", "/v1/client/documents?history=true", @@ -267,7 +313,7 @@ async fn service_receipt_bodies_use_only_the_closed_field_schema() { let receipt = state.store.append_claim(&ClaimInput { subject:"gate-operation/service/work/review".into(),kind:"gate.result".into(), actor:Some("person/owner".into()), - fields:serde_json::from_value(json!({"verdict":"pass","gate":"review","baseline":true, + fields:serde_json::from_value(json!({"verdict":"pass","gate":"gate-name-secret-sentinel","baseline":true, "value":{"declarations_kdl":"receipt-secret-sentinel","code_hash":"other-verifier"}, "reason":"private-free-text"})).unwrap(), evidence:vec![],expected_subject:None,idempotency_key:None, @@ -275,7 +321,8 @@ async fn service_receipt_bodies_use_only_the_closed_field_schema() { for app in [crate::api::router(state.clone()),crate::api::fabric_router(state.clone())] { let (status,value) = call(&app,"GET",&format!("/v1/client/claims/by-id/{}",receipt.id),Value::Null,false).await; assert_eq!(status,StatusCode::OK,"{value}"); - assert_eq!(value["body"],json!({"fields":{"verdict":"pass","gate":"review","baseline":true}})); + assert_eq!(value["body"],json!({"fields":{"verdict":"pass","baseline":true}})); + assert!(!value.to_string().contains("gate-name-secret-sentinel")); assert!(!value.to_string().contains("receipt-secret-sentinel")); assert!(!value.to_string().contains("other-verifier")); assert!(!value.to_string().contains("private-free-text")); diff --git a/crates/fractal-api-core/src/api/records.rs b/crates/fractal-api-core/src/api/records.rs index 6e4b6c06018..a85a5200946 100644 --- a/crates/fractal-api-core/src/api/records.rs +++ b/crates/fractal-api-core/src/api/records.rs @@ -299,6 +299,9 @@ pub async fn get_claim( .await? .filter(|claim| !claim.subject.starts_with("glass/")) .filter(generic_claim_visible) + .filter(|claim| !session.as_ref().is_some_and(|session| + client_v0::external_grants::is_service(&session.0)) + || service_claim_fields(&claim.kind).is_some()) .map(|claim| Json(if session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)) { service_claim(&claim) @@ -392,24 +395,16 @@ pub async fn client_list_claims( list_claims(state, query, Some(session)).await } -/// Closed R29 receipt/state schema. No unknown kind or field acquires disclosure rights. -/// In particular, declarations, challenges and grant verifiers are metadata-only. +/// Closed R29 receipt/state schema. Unknown rows reveal only a paging position. fn service_claim(claim: &ClaimRecord) -> Value { + let Some(fields) = service_claim_fields(&claim.kind) else { + return json!({"store_index":claim.store_index, "withheld":true}); + }; let mut value = json!({ "id":claim.id, "kind":claim.kind, "subject":claim.subject, "origin":claim.origin, "store_index":claim.store_index, "accepted_at_unix_ms":claim.accepted_at_unix_ms, "body_digest":client_v0::credential_digest(&claim.body.to_string()), }); - let fields: &[&str] = match claim.kind.as_str() { - "gate.result" => &["verdict", "operation", "request", "gate", "baseline", "token_usage", "stage"], - "work.claimed" | "work.renewed" | "work.progress" | "work.submitted" | "work.failed" - | "work.released" | "work.extended" => &[ - "attempt", "status", "worker_reported", "claimant", "claim_incarnation", - "claim_expires_at_unix_ms", "readiness_epoch", "extend_ms", "clock", - "wait_until_unix_ms", "submitted_incarnation", - ], - _ => return value, - }; value["actor"] = json!(claim.actor); let source = claim.body.get("fields").unwrap_or(&claim.body); let projected = fields.iter().filter_map(|field| { @@ -420,6 +415,19 @@ fn service_claim(claim: &ClaimRecord) -> Value { value } +fn service_claim_fields(kind: &str) -> Option<&'static [&'static str]> { + Some(match kind { + "gate.result" => &["verdict", "operation", "request", "baseline", "token_usage", "stage"], + "work.claimed" | "work.renewed" | "work.progress" | "work.submitted" | "work.failed" + | "work.released" | "work.extended" => &[ + "attempt", "status", "worker_reported", "claimant", "claim_incarnation", + "claim_expires_at_unix_ms", "readiness_epoch", "extend_ms", "clock", + "wait_until_unix_ms", "submitted_incarnation", + ], + _ => return None, + }) +} + #[derive(Default, Deserialize)] pub struct ReviewsQuery { reviewer: Option, diff --git a/crates/fractal-api-core/src/api/work.rs b/crates/fractal-api-core/src/api/work.rs index fc00a2dce44..3839e4d3ef8 100644 --- a/crates/fractal-api-core/src/api/work.rs +++ b/crates/fractal-api-core/src/api/work.rs @@ -178,11 +178,67 @@ pub async fn list_work( Ok(Json(work)) } +/// Deliberately independent of the rich native work view: adding a native field +/// cannot grant service disclosure rights. +#[derive(serde::Serialize)] +struct ServiceWork<'a> { + subject: &'a str, + run: &'a str, + generation: &'a str, + step: &'a str, + attempt: u32, + status: &'a str, + claimant: &'a Option, + claim_incarnation: &'a Option, + submitted_incarnation: &'a Option, + claim_expires_at_unix_ms: Option, + readiness_epoch: u32, + worker_reported: bool, + created_at_unix_ms: u128, + updated_at_unix_ms: u128, + clock: ServiceWorkClock, +} + +#[derive(serde::Serialize)] +struct ServiceWorkClock { + started_at_unix_ms: Option, + paused_until_unix_ms: Option, + waited_ms: u128, + elapsed_ms: u128, +} + +impl<'a> From<&'a StepRunView> for ServiceWork<'a> { + fn from(step: &'a StepRunView) -> Self { + Self { + subject: &step.subject, + run: &step.run, + generation: &step.generation, + step: &step.step, + attempt: step.attempt, + status: &step.status, + claimant: &step.claimant, + claim_incarnation: &step.claim_incarnation, + submitted_incarnation: &step.submitted_incarnation, + claim_expires_at_unix_ms: step.claim_expires_at_unix_ms, + readiness_epoch: step.readiness_epoch, + worker_reported: step.worker_reported, + created_at_unix_ms: step.created_at_unix_ms, + updated_at_unix_ms: step.updated_at_unix_ms, + clock: ServiceWorkClock { + started_at_unix_ms: step.execution_started_at_unix_ms, + paused_until_unix_ms: step.execution_paused_until_unix_ms, + waited_ms: step.execution_waited_ms, + elapsed_ms: step.execution_elapsed_ms, + }, + } + } +} + pub async fn get_work( State(state): State, AxumPath(subject): AxumPath, session: Option>, -) -> Result, ApiError> { +) -> Result { let service = session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)); if let Some(session) = &session && service { @@ -204,14 +260,18 @@ pub async fn get_work( .await .map_err(ApiError::internal)?; } - Ok(Json(step)) + Ok(if service { + Json(ServiceWork::from(&step)).into_response() + } else { + Json(step).into_response() + }) } pub async fn client_get_work( state: State, subject: AxumPath, session: Extension, -) -> Result, ApiError> { +) -> Result { if !client_v0::external_grants::is_service(&session.0) { return Err(client_v0::forbidden("client work reads require a service principal")); } diff --git a/crates/fractal-daemon/src/api/service_read_tests.rs b/crates/fractal-daemon/src/api/service_read_tests.rs index 59d0c456e5f..12047b0d3d7 100644 --- a/crates/fractal-daemon/src/api/service_read_tests.rs +++ b/crates/fractal-daemon/src/api/service_read_tests.rs @@ -34,7 +34,7 @@ fn service_fixture(state: &AppState) { } #[tokio::test] -async fn real_mission_planning_and_pending_pairings_are_metadata_only_or_refused() { +async fn real_mission_planning_and_pending_pairings_are_withheld_or_refused() { let root = tempfile::tempdir().unwrap(); let mut state = tests_shared::state(root.path()); state.owning_person = Some(OWNER.into()); @@ -103,20 +103,31 @@ mission "service-planned" state="ready" { assert!(!wire.contains(sentinel)); } for verifier in &verifiers { assert!(!wire.contains(verifier)); } + for challenge in &challenges { + assert!(!wire.contains(&challenge.subject)); + assert!(!wire.contains(&challenge.id)); + assert!(!wire.contains(&credential_digest(&challenge.body.to_string()))); + let pairing_id = challenge.subject.rsplit('/').next().unwrap(); + assert!(!wire.contains(pairing_id)); + } assert!(!wire.contains("code_hash") && !wire.contains("credential_hash")); - seen.extend(page["claims"].as_array().unwrap().iter().map(|claim|claim["id"].as_str().unwrap().to_owned())); + seen.extend(page["claims"].as_array().unwrap().iter().map(|claim| { + if claim["withheld"] == true { + assert_eq!(claim,&json!({"store_index":claim["store_index"],"withheld":true})); + } + claim["store_index"].as_u64().unwrap() + })); let Some(next) = page["next_cursor"].as_u64() else { break }; assert!(next > after); after = next; } for claim in claims.iter().filter(|claim| matches!(claim.kind.as_str(),"mission.published" | "intent.desired" | "custom.client.pairing-begun" | external_grants::BEGUN | external_grants::GRANTED)) { - assert!(seen.contains(&claim.id)); + assert!(seen.contains(&claim.store_index)); for prefix in ["/v1/client/claims/by-id/","/v1/claims/by-id/"] { let (status,value) = request(&app,"GET",&format!("{prefix}{}",claim.id),Some(TOKEN),Value::Null).await; - if status == StatusCode::OK { - assert!(value.get("body").is_none(),"{value}"); - assert_eq!(value["body_digest"].as_str().unwrap().len(),64); - } else { assert_eq!(status,StatusCode::FORBIDDEN); } + assert!(matches!(status,StatusCode::NOT_FOUND | StatusCode::FORBIDDEN),"{value}"); + let (missing_status,_) = request(&app,"GET",&format!("{prefix}nonexistent"),Some(TOKEN),Value::Null).await; + assert_eq!(status,missing_status); } } for prefix in ["/v1/client/documents","/v1/documents"] { @@ -158,3 +169,50 @@ async fn paired_devices_and_browser_sessions_cannot_use_service_claim_routes() { } } } + +#[tokio::test] +async fn real_predicate_gate_receipt_withholds_gate_name() { + use fractal_reconcile::reconcile::{Reconciler, tests::FakeRuntime}; + use fractal_api_core::{MissionsStore, MissionsStoreCore}; + let root = tempfile::tempdir().unwrap(); + let mut state = tests_shared::state(root.path()); + state.owning_person = Some(OWNER.into()); + let source = r#"version 2 +mission "service-gate" state="ready" { + goal "Protect literal gate names." + step "check" { + agentless + gate "gate-name-secret-sentinel" { exists "doc/service-gate-input" } + } +} +"#; + let intent = crate::graph::parse_test_intent(source,"node").unwrap(); + let plan = state.store.mission(&intent,crate::model::IntentInput { kdl:source.into(),source_name:None }).unwrap(); + state.store.apply(&intent,&plan.subject_tokens,"service-gate-publish").unwrap(); + state.store.put_document_as("doc/service-gate-input",b"present",&None,"service-gate-input",None).unwrap(); + state.store.create_mission_run(&crate::model::MissionRunRequest { + mission:"service-gate".into(),revision:None,workspace:root.path().display().to_string(), + requester:Some(OWNER.into()),mode:Some("run".into()),inputs:std::collections::BTreeMap::new(), + idempotency_key:"service-gate-run".into(), + }).unwrap(); + let reconciler = Reconciler::new(state.store.clone(),std::sync::Arc::new(FakeRuntime::default()), + "node".into(),std::sync::Arc::new(tokio::sync::Notify::new())); + reconciler.reconcile_once().unwrap(); + let receipt = state.store.claims_page(None,None,0,None,false,500).unwrap().claims.into_iter() + .find(|claim| claim.kind == "gate.result" && claim.body["fields"]["gate"] == "gate-name-secret-sentinel") + .expect("the real reconciler writes the literal gate name"); + service_fixture(&state); + for (gateway,app) in [(false,router(state.clone())),(true,fabric_router(state.clone()))] { + for prefix in ["/v1/client/claims/by-id/","/v1/claims/by-id/"] { + let (status,value) = request(&app,"GET",&format!("{prefix}{}",receipt.id),Some(TOKEN),Value::Null).await; + if gateway && prefix == "/v1/claims/by-id/" { + assert_eq!(status,StatusCode::FORBIDDEN); + } else { + assert_eq!(status,StatusCode::OK,"{value}"); + assert_eq!(value["body"]["fields"]["verdict"],"pass"); + assert!(value["body"]["fields"].get("gate").is_none()); + assert!(!value.to_string().contains("gate-name-secret-sentinel")); + } + } + } +} diff --git a/docs/st3/principals-and-grants.md b/docs/st3/principals-and-grants.md index 888c64df022..de496d4b5e5 100644 --- a/docs/st3/principals-and-grants.md +++ b/docs/st3/principals-and-grants.md @@ -123,7 +123,12 @@ Service read admission uses a closed GET/scope registry on both the credential gateway and bearer-authenticated native router. `read.claims` admits `/v1/client/claims` and `/v1/client/claims/by-id/{id}` with the native readers' 1–500 bounds and numeric `after`/`before` cursors. `read.work` admits exact -`/v1/client/work-items/{subject}` reads without acknowledging wakes. +`/v1/client/work-items/{subject}` reads without acknowledging wakes. Service work +responses are a separate typed DTO: subject, run/generation/step identifiers, +attempt, status, claimant, claim/submission incarnations, claim expiry, readiness +epoch, worker-reported flag, creation/update timestamps and typed execution clock +timestamps/counters only. Goals, constraints, titles, person answers, summaries, +blocked reasons and other native free-text subviews are excluded on both aliases. `read.documents` remains a closed scope and its routes remain registered, but its document namespace allowlist is empty: every service list/content/history request is refused until a consumer namespace is declared. Native aliases use the same @@ -133,17 +138,19 @@ Service claim bodies are deny-by-default, not field-name redaction. Only `gate.result` and `work.claimed`, `work.renewed`, `work.progress`, `work.submitted`, `work.failed`, `work.released`, `work.extended` disclose schema-listed scalar receipt/state fields. Gate bodies expose `verdict`, -`operation`, `request`, `gate`, `baseline`, `token_usage`, `stage`; work bodies +`operation`, `request`, `baseline`, `token_usage`, `stage`; the literal gate name +is excluded. Work bodies expose `attempt`, `status`, `worker_reported`, `claimant`, `claim_incarnation`, `claim_expires_at_unix_ms`, `readiness_epoch`, `extend_ms`, `clock`, `wait_until_unix_ms`, `submitted_incarnation`, with the receipt/state claim's actor. These support independent gate receipt and work-state validation without arbitrary payload, summary, reason or handoff objects. -Every other kind returns only id, kind, subject, origin, store index, acceptance -time and a SHA-256 digest of its JSON body. This includes `mission.published`, -`intent.desired`, grant records and pending device/assistant pairing challenges: -neither declaration bodies nor another grant's verifiers are disclosed. -Metadata-only items retain their page positions, so pagination has no gaps. +Every other kind returns only `store_index` and the fixed `withheld: true` +marker in pages, with no subject, id, actor, origin, kind, timestamp or body digest. +This includes declarations, grants and pending device/assistant pairings: even +the deterministic pairing subject can be a verifier and is not disclosed. +Nonallowlisted by-id requests return the same not-found response as nonexistent +ids, with no existence oracle. Withheld rows retain page positions without gaps. The new client claim/work aliases admit only services; device, assistant and browser clients gain no new routes. Existing native routes retain non-service semantics. No declaration, assistant, action, upgrade, HEAD or unknown route is admitted. diff --git a/release-notes/service-read-gate.json b/release-notes/service-read-gate.json index 272351b60ed..033ae74d47f 100644 --- a/release-notes/service-read-gate.json +++ b/release-notes/service-read-gate.json @@ -1 +1 @@ -{"version":1,"summary":"Add closed read-only service admission for receipt/state claims and exact work items; service document namespaces and production issuance remain disabled","replay":{"status":"none","detail":"No migration; grant storage and digest authentication retain their existing meanings."},"schema":{"status":"none","detail":"No fractal-schema or device pairing changes."},"rules":{"status":"changed","detail":"Service bearers on both routers require a registered GET/read-scope pair. Claim bodies use a closed gate-result/work-state field schema; all other kinds are metadata-only. Document scope and routes remain registered but every service document read is refused because the namespace allowlist is empty. Unknown routes, upgrades and mutations are refused before dispatch. Work reads do not acknowledge wakes; daemon-owned read-window audits are bounded per grant."},"compatibility":{"status":"changed","detail":"New client claim/work aliases are service-only; existing non-service native reads retain their semantics. Browser ingress refuses mixed admitted-cookie/Authorization requests instead of discarding the bearer. Assistant scopes and disclosure consent are unchanged. Service issuance remains disabled; owner lifecycle and rollout safety remain prerequisites."}} +{"version":1,"summary":"Add closed read-only service admission for receipt/state claims and typed work items; service document namespaces and production issuance remain disabled","replay":{"status":"none","detail":"No migration; grant storage and digest authentication retain their existing meanings."},"schema":{"status":"none","detail":"No fractal-schema or device pairing changes."},"rules":{"status":"changed","detail":"Service bearers on both routers require a registered GET/read-scope pair. Claim bodies use a closed gate-result/work-state field schema excluding literal gate names. All other kinds expose only store_index and a fixed withheld marker in pages and return not-found by id, withholding pairing subjects and all digests. Work reads use a separate typed identifier/state/clock DTO excluding person answers, summaries and all other free-text subviews, without acknowledging wakes. Document scope and routes remain registered but every service document read is refused because the namespace allowlist is empty. Unknown routes, upgrades and mutations are refused before dispatch. Daemon-owned read-window audits are bounded per grant."},"compatibility":{"status":"changed","detail":"New client claim/work aliases are service-only; existing non-service native reads retain their semantics. Browser ingress refuses mixed admitted-cookie/Authorization requests instead of discarding the bearer. Assistant scopes and disclosure consent are unchanged. Service issuance remains disabled; owner lifecycle and rollout safety remain prerequisites."}} From f595ecc4393d8bfbded7d0a35d40a7e4b768ea10 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:22:14 +0100 Subject: [PATCH 019/132] fix(service): reject hidden claim ids before body loading agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../external_grants_service_tests.rs | 6 ++++- crates/fractal-api-core/src/api/records.rs | 24 ++++++++++++++----- docs/st3/principals-and-grants.md | 4 +++- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs index 58c3cc54e8b..31b0b98230a 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs @@ -98,8 +98,9 @@ async fn service_r29_claim_parity_paging_bounds_redaction_and_credential_secrecy } let (status, value) = call(&app,"GET",&format!("/v1/client/claims/by-id/{}",grant.id),Value::Null,false).await; assert_eq!(status, StatusCode::NOT_FOUND, "{value}"); - let (missing_status, _) = call(&app,"GET","/v1/client/claims/by-id/nonexistent",Value::Null,false).await; + let (missing_status, missing) = call(&app,"GET","/v1/client/claims/by-id/nonexistent",Value::Null,false).await; assert_eq!(status, missing_status); + assert_eq!(value.to_string().replace(&grant.id,"nonexistent"),missing.to_string()); assert!(!value.to_string().contains(&credential_digest(TOKEN))); assert!(!value.to_string().contains(TOKEN)); assert!(!value.to_string().contains("\"credential\":")); @@ -107,6 +108,9 @@ async fn service_r29_claim_parity_paging_bounds_redaction_and_credential_secrecy let (status, raw) = call(&app,"GET",&format!("/v1/claims/by-id/{}",grant.id),Value::Null,false).await; assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(raw, value); + let (absent_status, absent) = call(&app,"GET","/v1/claims/by-id/nonexistent",Value::Null,false).await; + assert_eq!(status,absent_status); + assert_eq!(raw.to_string().replace(&grant.id,"nonexistent"),absent.to_string()); } } } diff --git a/crates/fractal-api-core/src/api/records.rs b/crates/fractal-api-core/src/api/records.rs index a85a5200946..70892f5385f 100644 --- a/crates/fractal-api-core/src/api/records.rs +++ b/crates/fractal-api-core/src/api/records.rs @@ -5,6 +5,7 @@ use crate::PersonWorkStore; use crate::EvidenceStore; use super::*; use super::client_v0::{ClientSession, require_scope}; +use rusqlite::OptionalExtension as _; pub(super) fn generic_claim_visible(claim: &ClaimRecord) -> bool { !fractal_schema::notice::is_reserved_subject(&claim.subject) @@ -289,21 +290,32 @@ pub async fn get_claim( AxumPath(id): AxumPath, session: Option>, ) -> Result, ApiError> { + let service = session.as_ref().is_some_and(|session| + client_v0::external_grants::is_service(&session.0)); if let Some(session) = &session && client_v0::external_grants::is_service(&session.0) { require_scope(&session.0, "read.claims")?; } let store = state.store.clone(); let id_for_read = id.clone(); - blocking_store(move || store.claim_by_id(&id_for_read)) + blocking_store(move || { + if service { + // The id index resolves only kind. Hidden and absent rows take the + // same path without loading or parsing any potentially large body. + let kind: Option = store.readers.get() + .query_row("SELECT kind FROM claims WHERE id=?1", [&id_for_read], + |row| row.get(0)) + .optional()?; + if !kind.as_deref().is_some_and(|kind| service_claim_fields(kind).is_some()) { + return Ok(None); + } + } + store.claim_by_id(&id_for_read) + }) .await? .filter(|claim| !claim.subject.starts_with("glass/")) .filter(generic_claim_visible) - .filter(|claim| !session.as_ref().is_some_and(|session| - client_v0::external_grants::is_service(&session.0)) - || service_claim_fields(&claim.kind).is_some()) - .map(|claim| Json(if session.as_ref().is_some_and(|session| - client_v0::external_grants::is_service(&session.0)) { + .map(|claim| Json(if service { service_claim(&claim) } else { json!(claim) })) .ok_or_else(|| ApiError::not_found(format!("claim `{id}` does not exist"))) diff --git a/docs/st3/principals-and-grants.md b/docs/st3/principals-and-grants.md index de496d4b5e5..14ded349fc1 100644 --- a/docs/st3/principals-and-grants.md +++ b/docs/st3/principals-and-grants.md @@ -150,7 +150,9 @@ marker in pages, with no subject, id, actor, origin, kind, timestamp or body dig This includes declarations, grants and pending device/assistant pairings: even the deterministic pairing subject can be a verifier and is not disclosed. Nonallowlisted by-id requests return the same not-found response as nonexistent -ids, with no existence oracle. Withheld rows retain page positions without gaps. +ids. Service by-id admission first reads only the kind through the id index; +hidden and absent rows return before loading or parsing a body, avoiding a +body-size/JSON-parse existence oracle. Withheld rows retain page positions without gaps. The new client claim/work aliases admit only services; device, assistant and browser clients gain no new routes. Existing native routes retain non-service semantics. No declaration, assistant, action, upgrade, HEAD or unknown route is admitted. From 1edca03d6f3fac857234c0eeb61698f458fe1240 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:28:30 +0100 Subject: [PATCH 020/132] test(service): account for request ids and resumed work leases agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../client_v0/external_grants_service_tests.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs index 31b0b98230a..1cbcad52886 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs @@ -100,17 +100,25 @@ async fn service_r29_claim_parity_paging_bounds_redaction_and_credential_secrecy assert_eq!(status, StatusCode::NOT_FOUND, "{value}"); let (missing_status, missing) = call(&app,"GET","/v1/client/claims/by-id/nonexistent",Value::Null,false).await; assert_eq!(status, missing_status); - assert_eq!(value.to_string().replace(&grant.id,"nonexistent"),missing.to_string()); + // Correlation ids are fresh per HTTP request, independent of claim existence. + let mut normalized = value.clone(); + if let Some(request_id) = normalized.get_mut("request_id") { + *request_id = missing["request_id"].clone(); + } + assert_eq!(normalized.to_string().replace(&grant.id,"nonexistent"),missing.to_string()); assert!(!value.to_string().contains(&credential_digest(TOKEN))); assert!(!value.to_string().contains(TOKEN)); assert!(!value.to_string().contains("\"credential\":")); if !gateway { let (status, raw) = call(&app,"GET",&format!("/v1/claims/by-id/{}",grant.id),Value::Null,false).await; assert_eq!(status, StatusCode::NOT_FOUND); - assert_eq!(raw, value); + assert_eq!(raw["code"], value["code"]); + assert_eq!(raw["message"], value["message"]); let (absent_status, absent) = call(&app,"GET","/v1/claims/by-id/nonexistent",Value::Null,false).await; assert_eq!(status,absent_status); - assert_eq!(raw.to_string().replace(&grant.id,"nonexistent"),absent.to_string()); + let mut normalized = raw; + normalized["request_id"] = absent["request_id"].clone(); + assert_eq!(normalized.to_string().replace(&grant.id,"nonexistent"),absent.to_string()); } } } @@ -248,7 +256,7 @@ mission "service-work" state="ready" { state.store.set_step_state(subject,"ready",None).unwrap(); let action = |name: &str, summary: &str| state.store.work_action(subject,name,&crate::model::WorkRequest { wait_ms:None,actor:Some("agent/node.reader".into()),incarnation:Some("service-runtime".into()), - summary:Some(summary.into()),reason:None,evidence:vec![],idempotency_key:format!("service-{name}"), + summary:Some(summary.into()),reason:None,evidence:vec![],idempotency_key:format!("service-{name}-{summary}"), }).unwrap(); action("claim","claim"); action("progress","worker-progress-secret-sentinel"); @@ -270,6 +278,8 @@ mission "service-work" state="ready" { summary:"person-answer-secret-sentinel".into(),evidence:vec![],episode:None, answer:None,idempotency_key:"service-person-answer".into(), },false).unwrap(); + // Answering resumes the origin as ready, with its old lease released. + action("claim","resume-after-answer"); action("complete","worker-completion-secret-sentinel"); let completed = state.store.step_run(subject).unwrap().unwrap(); assert_eq!(completed.completion_summary.as_deref(),Some("worker-completion-secret-sentinel")); From bd9aca3c802828fcc91a04bbc585dcbb20d5d84d Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:35:01 +0100 Subject: [PATCH 021/132] test(service): use valid assistant scopes and pinned gate document agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-daemon/src/api/service_read_tests.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/fractal-daemon/src/api/service_read_tests.rs b/crates/fractal-daemon/src/api/service_read_tests.rs index 12047b0d3d7..a30b1f4b5bb 100644 --- a/crates/fractal-daemon/src/api/service_read_tests.rs +++ b/crates/fractal-daemon/src/api/service_read_tests.rs @@ -73,7 +73,7 @@ mission "service-planned" state="ready" { for body in [ json!({"api_version":"st3.client.v0","device_name":"Pending device","person_id":OWNER,"scopes":["read.projections"]}), json!({"api_version":"st3.client.v0","device_name":"Pending assistant","assistant_id":"assistant/chatgpt", - "scopes":["read.assistant.summary"],"disclosure_consent":true,"consent_evidence":"Recorded owner consent"}), + "scopes":&external_grants::SCOPES[..4],"disclosure_consent":true,"consent_evidence":"Recorded owner consent"}), ] { let (status,challenge) = request(&native,"POST","/v1/client/pairings",None,body).await; assert_eq!(status,StatusCode::OK,"{challenge}"); @@ -173,10 +173,11 @@ async fn paired_devices_and_browser_sessions_cannot_use_service_claim_routes() { #[tokio::test] async fn real_predicate_gate_receipt_withholds_gate_name() { use fractal_reconcile::reconcile::{Reconciler, tests::FakeRuntime}; - use fractal_api_core::{MissionsStore, MissionsStoreCore}; + use fractal_api_core::MissionsStore; let root = tempfile::tempdir().unwrap(); let mut state = tests_shared::state(root.path()); state.owning_person = Some(OWNER.into()); + let document = state.store.put_document_as("doc/service-gate-input",b"present",&None,"service-gate-input",None).unwrap(); let source = r#"version 2 mission "service-gate" state="ready" { goal "Protect literal gate names." @@ -185,11 +186,10 @@ mission "service-gate" state="ready" { gate "gate-name-secret-sentinel" { exists "doc/service-gate-input" } } } -"#; - let intent = crate::graph::parse_test_intent(source,"node").unwrap(); - let plan = state.store.mission(&intent,crate::model::IntentInput { kdl:source.into(),source_name:None }).unwrap(); +"#.replace("doc/service-gate-input",&format!("doc/service-gate-input@{}",document.hash)); + let intent = crate::graph::parse_test_intent(&source,"node").unwrap(); + let plan = state.store.mission(&intent,crate::model::IntentInput { kdl:source,source_name:None }).unwrap(); state.store.apply(&intent,&plan.subject_tokens,"service-gate-publish").unwrap(); - state.store.put_document_as("doc/service-gate-input",b"present",&None,"service-gate-input",None).unwrap(); state.store.create_mission_run(&crate::model::MissionRunRequest { mission:"service-gate".into(),revision:None,workspace:root.path().display().to_string(), requester:Some(OWNER.into()),mode:Some("run".into()),inputs:std::collections::BTreeMap::new(), From 0858ced7876485ce341cf9b0f0c08241fd31a900 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:35:58 +0100 Subject: [PATCH 022/132] test(service): advance agentless gate through readiness agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-daemon/src/api/service_read_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/fractal-daemon/src/api/service_read_tests.rs b/crates/fractal-daemon/src/api/service_read_tests.rs index a30b1f4b5bb..0bb5cfafd86 100644 --- a/crates/fractal-daemon/src/api/service_read_tests.rs +++ b/crates/fractal-daemon/src/api/service_read_tests.rs @@ -198,6 +198,8 @@ mission "service-gate" state="ready" { let reconciler = Reconciler::new(state.store.clone(),std::sync::Arc::new(FakeRuntime::default()), "node".into(),std::sync::Arc::new(tokio::sync::Notify::new())); reconciler.reconcile_once().unwrap(); + // Pending work first becomes ready; the next pass admits agentless work and evaluates its gate. + reconciler.reconcile_once().unwrap(); let receipt = state.store.claims_page(None,None,0,None,false,500).unwrap().claims.into_iter() .find(|claim| claim.kind == "gate.result" && claim.body["fields"]["gate"] == "gate-name-secret-sentinel") .expect("the real reconciler writes the literal gate name"); From b0d2ae1149e5e9aec857f0889a64d7ac7bb2e78f Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:00:59 +0100 Subject: [PATCH 023/132] fix(missions): retain initial run selector writer spelling agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-store/src/store/missions.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/crates/fractal-store/src/store/missions.rs b/crates/fractal-store/src/store/missions.rs index 9dfa4fa222d..85ae2777e82 100644 --- a/crates/fractal-store/src/store/missions.rs +++ b/crates/fractal-store/src/store/missions.rs @@ -451,15 +451,7 @@ impl Store { for (step, selector, constraints) in flat { let (assignee, available_to, agentless) = interpolate_selector(&selector, &variables)?; let storage_party = |party: String| { - let canonical = |id: &str| { - fractal_schema::canonical_agent_actor(id) - .map(std::borrow::Cow::into_owned) - .ok_or_else(|| FractalError::new( - "invalid-work-actor", format!("invalid agent actor `{id}`"), - )) - }; - let party = canonical(&party)?; - canonical(&identities::resolve_reference_conn(&transaction, &party)?) + identities::resolve_reference_conn(&transaction, &party) }; let assignee = assignee.map(storage_party).transpose()?; let available_to = available_to.into_iter().map(storage_party) From e0c1c93e076405155c884f2873d8e9164c47720e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:21:15 +0100 Subject: [PATCH 024/132] fix(release): classify service gate downtime and recovery agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- release-notes/service-read-gate.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/service-read-gate.json b/release-notes/service-read-gate.json index 033ae74d47f..0fbdc3fff93 100644 --- a/release-notes/service-read-gate.json +++ b/release-notes/service-read-gate.json @@ -1 +1 @@ -{"version":1,"summary":"Add closed read-only service admission for receipt/state claims and typed work items; service document namespaces and production issuance remain disabled","replay":{"status":"none","detail":"No migration; grant storage and digest authentication retain their existing meanings."},"schema":{"status":"none","detail":"No fractal-schema or device pairing changes."},"rules":{"status":"changed","detail":"Service bearers on both routers require a registered GET/read-scope pair. Claim bodies use a closed gate-result/work-state field schema excluding literal gate names. All other kinds expose only store_index and a fixed withheld marker in pages and return not-found by id, withholding pairing subjects and all digests. Work reads use a separate typed identifier/state/clock DTO excluding person answers, summaries and all other free-text subviews, without acknowledging wakes. Document scope and routes remain registered but every service document read is refused because the namespace allowlist is empty. Unknown routes, upgrades and mutations are refused before dispatch. Daemon-owned read-window audits are bounded per grant."},"compatibility":{"status":"changed","detail":"New client claim/work aliases are service-only; existing non-service native reads retain their semantics. Browser ingress refuses mixed admitted-cookie/Authorization requests instead of discarding the bearer. Assistant scopes and disclosure consent are unchanged. Service issuance remains disabled; owner lifecycle and rollout safety remain prerequisites."}} +{"version":1,"summary":"Add closed read-only service admission for receipt/state claims and typed work items; service document namespaces and production issuance remain disabled","replay":{"status":"none","detail":"No migration; grant storage and digest authentication retain their existing meanings."},"schema":{"status":"none","detail":"No fractal-schema or device pairing changes."},"rules":{"status":"changed","detail":"Service bearers on both routers require a registered GET/read-scope pair. Claim bodies use a closed gate-result/work-state field schema excluding literal gate names. All other kinds expose only store_index and a fixed withheld marker in pages and return not-found by id, withholding pairing subjects and all digests. Work reads use a separate typed identifier/state/clock DTO excluding person answers, summaries and all other free-text subviews, without acknowledging wakes. Document scope and routes remain registered but every service document read is refused because the namespace allowlist is empty. Unknown routes, upgrades and mutations are refused before dispatch. Daemon-owned read-window audits are bounded per grant."},"compatibility":{"status":"changed","detail":"New client claim/work aliases are service-only; existing non-service native reads retain their semantics. Browser ingress refuses mixed admitted-cookie/Authorization requests instead of discarding the bearer. Assistant scopes and disclosure consent are unchanged. Service issuance remains disabled; owner lifecycle and rollout safety remain prerequisites."},"downtime":{"status":"none","detail":"No downtime is required for this change."},"manual":{"status":"none","detail":"No manual step is required; production service issuance remains disabled."},"recovery":{"status":"none","detail":"No recovery action is needed; this change introduces no migration or persisted-state conversion."}} From 2f5d0bd387966e927c668300dc1108142ae7f64f Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:56:47 +0100 Subject: [PATCH 025/132] test(missions): assert expansion-only renewal spelling agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions/src/store/tests.rs | 11 ++++++++++- crates/fractal-missions/src/store/work_renew_tests.rs | 7 +++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index b0d93160ee2..96de9ecc7de 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -9589,9 +9589,18 @@ fn carried_claim_successor_matches_legacy_and_canonical_holders() { ).unwrap(); let mut renew = carry_request("one", "alias-carry-renew"); renew.actor = Some(actor.into()); + if !actor.starts_with("agent/") { + let error = store.work_action(old, "renew", &renew).unwrap_err(); + assert_eq!(error.code, "invalid-work-actor"); + assert_eq!( + store.step_run(carried).unwrap().unwrap().claimant.as_deref(), + Some(holder), + ); + continue; + } let renewed = store.work_action(old, "renew", &renew).unwrap(); assert_eq!(&renewed.subject, carried); - assert_eq!(renewed.claimant.as_deref(), Some("agent/node/worker")); + assert_eq!(renewed.claimant.as_deref(), Some(holder)); assert!(renewed.claim_expires_at_unix_ms.unwrap() > now_ms()); } } diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index 04af93f0aa6..06e996cb450 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -783,7 +783,9 @@ fn work_renewal_matches_legacy_and_canonical_holders() { continue; } let renewed = step.store.work_action(&step.subject, "renew", &renew).unwrap(); - assert_eq!(renewed.claimant.as_deref(), Some(actor)); + // Main's quiet renewal writes only the operational lease, not a new claim + // or a replacement claimant spelling in the projected step. + assert_eq!(renewed.claimant.as_deref(), Some(holder)); assert!(renewed.claim_expires_at_unix_ms.unwrap() > now_ms()); } } @@ -819,7 +821,8 @@ fn work_renew_release_and_completion_match_legacy_and_canonical_holders() { } let finished = step.store.work_action(&step.subject, action, &finish).unwrap(); assert_eq!(finished.status, expected_status, "{actor} / {holder} / {action}"); - let expected_holder = (action == "renew").then_some(actor); + // A quiet renewal retains the projected claimant; release/complete clear it. + let expected_holder = (action == "renew").then_some(holder); assert_eq!(finished.claimant.as_deref(), expected_holder); if action == "renew" { assert_eq!(finished.claim_incarnation, claim.incarnation); From f1ba8ce9b9abc522e087dd288d726889e95ae961 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:28:44 +0100 Subject: [PATCH 026/132] fix(service): restrict selected claim pages to disclosed kinds agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../fractal-api-core/src/api/client_reads.rs | 2 +- .../external_grants_service_tests.rs | 85 ++++++++++++++++++- crates/fractal-api-core/src/api/custom.rs | 2 +- crates/fractal-api-core/src/api/records.rs | 38 ++++++--- crates/fractal-store/src/store.rs | 32 +++++-- crates/fractal-store/src/store/tests.rs | 2 +- crates/smallclaims/src/store.rs | 5 +- docs/st3/principals-and-grants.md | 5 ++ release-notes/service-read-gate.json | 2 +- 9 files changed, 149 insertions(+), 24 deletions(-) diff --git a/crates/fractal-api-core/src/api/client_reads.rs b/crates/fractal-api-core/src/api/client_reads.rs index 5988c4485c8..eface52345d 100644 --- a/crates/fractal-api-core/src/api/client_reads.rs +++ b/crates/fractal-api-core/src/api/client_reads.rs @@ -1533,7 +1533,7 @@ pub async fn client_history( }; let store = state.store.clone(); let page = - blocking_store(move || records::generic_claims_page(&store, None, None, 0, Some(before_index), true, limit)) + blocking_store(move || records::generic_claims_page(&store, None, None, 0, Some(before_index), true, limit, None)) .await?; let has_more = page.next_cursor.is_some(); let items = page diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs index 1cbcad52886..b7c291eb7a1 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs @@ -72,9 +72,9 @@ async fn service_r29_claim_parity_paging_bounds_redaction_and_credential_secrecy for gateway in [false, true] { let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; let mut ids = Vec::new(); - let mut after = 0; + let mut after = grant.store_index; loop { - let (status, page) = call(&app, "GET", &format!("/v1/client/claims?subject=custom/service/paging&limit=2&after={after}"), Value::Null, false).await; + let (status, page) = call(&app, "GET", &format!("/v1/client/claims?limit=2&after={after}&before={}",expected.last().unwrap()+1), Value::Null, false).await; assert_eq!(status, StatusCode::OK, "{page}"); let serialized = page.to_string(); assert!(!serialized.contains("environment-secret")); @@ -342,3 +342,84 @@ async fn service_receipt_bodies_use_only_the_closed_field_schema() { assert!(!value.to_string().contains("private-free-text")); } } + +async fn page_wire(app: &Router, path: &str) -> (StatusCode, String) { + let response = app.clone().oneshot(Request::builder().uri(path) + .header("authorization",format!("Bearer {TOKEN}")) + .body(Body::empty()).unwrap()).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(),usize::MAX).await.unwrap(); + let mut wire = String::from_utf8(bytes.to_vec()).unwrap(); + let envelope: Value = serde_json::from_str(&wire).unwrap(); + if let Some(correlation) = envelope["request_id"].as_str() { + wire = wire.replace(correlation,"request/test"); + } + (status,wire) +} + +#[tokio::test] +async fn service_selectors_match_only_disclosed_claim_kinds() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(),SERVICE_SCOPES); + let mut withheld_indexes = Vec::new(); + let hidden_subject = "gate-operation/service/selected"; + let mixed_subject = "gate-operation/service/mixed"; + let hidden_owner = "mission-run/service-selected"; + let mixed_owner = "mission-run/service-mixed"; + for (subject,owner) in [(hidden_subject,hidden_owner),(mixed_subject,mixed_owner)] { + for n in 0..7 { + let claim = state.store.append_claim(&ClaimInput { + subject:subject.into(),kind:"custom.service-test.v1.item".into(), + actor:None,fields:serde_json::from_value(json!({"number":n})).unwrap(), + evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); + withheld_indexes.push(claim.store_index); + if n == 0 { + state.store.connection.lock().unwrap().execute( + "INSERT INTO desired(subject,kind,revision,claim_id,body,owner_run) VALUES (?1,'gate-operation',?2,?2,'{}',?3)", + rusqlite::params![subject,claim.id,owner], + ).unwrap(); + } + } + } + assert_eq!(state.store.claims_page(None,Some(hidden_owner),0,None,false,500).unwrap().claims.len(),7); + let receipt = state.store.append_claim(&ClaimInput { + subject:mixed_subject.into(),kind:"gate.result".into(),actor:None, + fields:serde_json::from_value(json!({"verdict":"pass"})).unwrap(), + evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); + for gateway in [false,true] { + let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; + call(&app,"GET","/v1/client/claims?limit=1",Value::Null,false).await; + for prefix in if gateway { vec!["/v1/client/claims"] } else { vec!["/v1/client/claims","/v1/claims"] } { + for suffix in ["&limit=1","&limit=500&order=desc","&limit=2&after=1&before=9223372036854775807"] { + for (selected,absent) in [ + (format!("subject={hidden_subject}"),"subject=gate-operation/service/unused".to_owned()), + (format!("owner_run={hidden_owner}"),"owner_run=mission-run/service-unused".to_owned()), + (format!("subject={hidden_subject}&owner_run={hidden_owner}"),"subject=gate-operation/service/unused&owner_run=mission-run/service-unused".to_owned()), + ] { + let actual = page_wire(&app,&format!("{prefix}?{selected}{suffix}")).await; + let missing = page_wire(&app,&format!("{prefix}?{absent}{suffix}")).await; + assert_eq!(actual,missing,"filtered empty pages are byte-identical except fresh correlation ids"); + assert_eq!(actual.0,StatusCode::OK); + let envelope: Value = serde_json::from_str(&actual.1).unwrap(); + let page = envelope.get("value").unwrap_or(&envelope); + assert_eq!(page,&json!({"claims":[],"next_cursor":null})); + } + for selector in [format!("subject={mixed_subject}"),format!("owner_run={mixed_owner}"), + format!("subject={mixed_subject}&owner_run={mixed_owner}")] { + let (status,page) = call(&app,"GET",&format!("{prefix}?{selector}{suffix}"),Value::Null,false).await; + assert_eq!(status,StatusCode::OK); + assert!(page["next_cursor"].is_null()); + let claims = page["claims"].as_array().unwrap(); + assert_eq!(claims.len(),1); + assert_eq!(claims[0]["id"],receipt.id); + for claim in claims { + assert!(claim.get("withheld").is_none()); + assert!(!withheld_indexes.contains(&claim["store_index"].as_u64().unwrap())); + } + } + } + } + } +} diff --git a/crates/fractal-api-core/src/api/custom.rs b/crates/fractal-api-core/src/api/custom.rs index f4c2e29283d..e7eaf89811e 100644 --- a/crates/fractal-api-core/src/api/custom.rs +++ b/crates/fractal-api-core/src/api/custom.rs @@ -392,7 +392,7 @@ mod tests { assert!(!second.page.has_more); assert!(second.page.next_cursor.is_none()); assert!(second.page.cursor_expires_at.is_none()); - let tail = records::generic_claims_page(&state.store, None, None, 0, Some(oldest.store_index + 1), true, 1).unwrap(); + let tail = records::generic_claims_page(&state.store, None, None, 0, Some(oldest.store_index + 1), true, 1, None).unwrap(); assert_eq!(tail.claims[0].id, oldest.id); assert!(tail.next_cursor.is_none(), "hidden tail must not create another page"); } diff --git a/crates/fractal-api-core/src/api/records.rs b/crates/fractal-api-core/src/api/records.rs index 70892f5385f..afb5224f6e0 100644 --- a/crates/fractal-api-core/src/api/records.rs +++ b/crates/fractal-api-core/src/api/records.rs @@ -13,6 +13,7 @@ pub(super) fn generic_claim_visible(claim: &ClaimRecord) -> bool { } /// Fill a serving page with visible claims, leaving raw replication/projector reads intact. +#[allow(clippy::too_many_arguments)] pub(super) fn generic_claims_page( store: &Store, subject: Option<&str>, @@ -21,10 +22,11 @@ pub(super) fn generic_claims_page( mut before: Option, descending: bool, limit: usize, + kinds: Option<&[&str]>, ) -> anyhow::Result { let mut claims = Vec::with_capacity(limit.saturating_add(1)); loop { - let page = store.claims_page(subject, owner_run, after, before, descending, limit.saturating_add(1))?; + let page = store.claims_page_with_kinds(subject, owner_run, after, before, descending, limit.saturating_add(1), kinds)?; let next = page.next_cursor; for claim in page.claims.into_iter().filter(generic_claim_visible) { claims.push(claim); @@ -352,6 +354,12 @@ pub struct ClaimsQuery { limit: usize, } +impl ClaimsQuery { + fn has_selector(&self) -> bool { + self.subject.is_some() || self.owner_run.is_some() + } +} + #[derive(Clone, Copy, Default, Deserialize)] #[serde(rename_all = "lowercase")] enum ClaimsOrder { @@ -378,6 +386,7 @@ pub async fn list_claims( let service = session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)); let store = state.store.clone(); let page = blocking_store(move || { + let kinds = (service && query.has_selector()).then_some(SERVICE_CLAIM_KINDS); let subject = query.subject.as_deref().map(|subject| { crate::store::identities::resolve_reference_conn(&store.readers.get(), subject) }).transpose()?; @@ -389,6 +398,7 @@ pub async fn list_claims( query.before_index, matches!(query.order, ClaimsOrder::Desc), query.limit, + kinds, ) }) .await?; @@ -427,17 +437,23 @@ fn service_claim(claim: &ClaimRecord) -> Value { value } +const SERVICE_CLAIM_KINDS: &[&str] = &[ + "gate.result", "work.claimed", "work.renewed", "work.progress", "work.submitted", + "work.failed", "work.released", "work.extended", +]; + fn service_claim_fields(kind: &str) -> Option<&'static [&'static str]> { - Some(match kind { - "gate.result" => &["verdict", "operation", "request", "baseline", "token_usage", "stage"], - "work.claimed" | "work.renewed" | "work.progress" | "work.submitted" | "work.failed" - | "work.released" | "work.extended" => &[ - "attempt", "status", "worker_reported", "claimant", "claim_incarnation", - "claim_expires_at_unix_ms", "readiness_epoch", "extend_ms", "clock", - "wait_until_unix_ms", "submitted_incarnation", - ], - _ => return None, - }) + if kind == "gate.result" { + Some(&["verdict", "operation", "request", "baseline", "token_usage", "stage"]) + } else if SERVICE_CLAIM_KINDS.contains(&kind) { + Some(&[ + "attempt", "status", "worker_reported", "claimant", "claim_incarnation", + "claim_expires_at_unix_ms", "readiness_epoch", "extend_ms", "clock", + "wait_until_unix_ms", "submitted_incarnation", + ]) + } else { + None + } } #[derive(Default, Deserialize)] diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 17b56ee1577..9b66ae04e61 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -4230,9 +4230,24 @@ impl Store { before_index: Option, descending: bool, limit: usize, + ) -> Result { + self.claims_page_with_kinds(subject, owner_run, after_index, before_index, descending, limit, None) + } + + /// Restrict kinds in SQL before selecting or decoding page rows. + #[allow(clippy::too_many_arguments)] + pub fn claims_page_with_kinds( + &self, + subject: Option<&str>, + owner_run: Option<&str>, + after_index: u64, + before_index: Option, + descending: bool, + limit: usize, + kinds: Option<&[&str]>, ) -> Result { let connection = self.readers.get(); - let query = claims_page_query(subject.is_some(), descending); + let query = claims_page_query(subject.is_some(), descending, kinds.is_some()); let mut statement = connection.prepare(/* sqlite-large-value: allow-for-now lv-h2080eb040134 owner=fractal_store::store TODO=trace-statement-and-value-representation */ &query)?; // The owner filter is evaluated per subject below. Other pages can stop // in SQLite at one row past the requested bound, before decoding bodies. @@ -4241,10 +4256,17 @@ impl Store { } else { i64::try_from(limit.saturating_add(1)).unwrap_or(i64::MAX) }; - let rows = statement.query_map( - params![after_index, before_index, subject, scan_limit], - claim_from_row, - )?; + let rows = if let Some(kinds) = kinds { + statement.query_map( + params![after_index, before_index, subject, scan_limit, serde_json::to_string(kinds)?], + claim_from_row, + )? + } else { + statement.query_map( + params![after_index, before_index, subject, scan_limit], + claim_from_row, + )? + }; let mut claims = Vec::new(); for row in rows { let claim = row?; diff --git a/crates/fractal-store/src/store/tests.rs b/crates/fractal-store/src/store/tests.rs index 70d4728a87f..77893c94e5a 100644 --- a/crates/fractal-store/src/store/tests.rs +++ b/crates/fractal-store/src/store/tests.rs @@ -442,7 +442,7 @@ fn subject_claim_pages_seek_the_subject_index() { .unwrap(); let connection = store.readers.get(); for descending in [false, true] { - let query = format!("EXPLAIN QUERY PLAN {}", claims_page_query(true, descending)); + let query = format!("EXPLAIN QUERY PLAN {}", claims_page_query(true, descending, false)); let plan = connection .prepare(/* sqlite-large-value: allow-for-now lv-h8911c1538a9c owner=fractal_store::store::tests TODO=trace-statement-and-value-representation */ &query) .unwrap() diff --git a/crates/smallclaims/src/store.rs b/crates/smallclaims/src/store.rs index 0d5ecbf05fd..cfd73126c09 100644 --- a/crates/smallclaims/src/store.rs +++ b/crates/smallclaims/src/store.rs @@ -2114,12 +2114,13 @@ pub fn reject_old_schema(connection: &Connection) -> Result<()> { Ok(()) } -pub fn claims_page_query(subject: bool, descending: bool) -> String { +pub fn claims_page_query(subject: bool, descending: bool, kinds: bool) -> String { let subject_filter = if subject { "subject=?3 AND " } else { "" }; + let kind_filter = if kinds { "kind IN (SELECT value FROM json_each(?5)) AND " } else { "" }; let order = if descending { "DESC" } else { "ASC" }; format!( "SELECT id, store_index, batch_id, subject, kind, origin, actor, body, predecessors, accepted_at_unix_ms - FROM claims WHERE subject NOT LIKE 'glass/%' AND {subject_filter}store_index>?1 AND (?2 IS NULL OR store_index?1 AND (?2 IS NULL OR store_index Date: Sun, 8 Nov 2026 01:42:23 +0100 Subject: [PATCH 027/132] test(missions): compare carried renewal anchor with main writer rule agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions/src/store/tests.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index 96de9ecc7de..1594d0d2bcd 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -9598,9 +9598,21 @@ fn carried_claim_successor_matches_legacy_and_canonical_holders() { ); continue; } + // Literal c72ede13 physical_actor rule, independent of the implementation + // under test: normalize_actor(actor, "agent"), then resolve_reference_conn. + let main_actor = fractal_store::store::identities::resolve_reference_conn( + &store.readers.get(), + &smallclaims::store::normalize_actor(actor, "agent"), + ).unwrap(); let renewed = store.work_action(old, "renew", &renew).unwrap(); assert_eq!(&renewed.subject, carried); - assert_eq!(renewed.claimant.as_deref(), Some(holder)); + // The carried subject has no replicated lease anchor. Main publishes this + // renewal and writes the request actor, unlike a quiet in-generation renewal. + assert_eq!(renewed.claimant.as_deref(), Some(main_actor.as_str())); + let anchors = store.claims_for(carried, Some("work.renewed")).unwrap(); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].actor.as_deref(), Some(main_actor.as_str())); + assert_eq!(anchors[0].body["fields"]["claimant"], json!(main_actor)); assert!(renewed.claim_expires_at_unix_ms.unwrap() > now_ms()); } } From 8e23a8ae4ab73ab1bec2a201178baa922d894e75 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:01:35 +0100 Subject: [PATCH 028/132] fix(service): cover read routes and require single browser authority agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-daemon/src/web.rs | 24 ++++++----- crates/fractal/tests/daemon_cost.rs | 66 +++++++++++++++++++++++++++++ docs/st3/principals-and-grants.md | 4 +- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/crates/fractal-daemon/src/web.rs b/crates/fractal-daemon/src/web.rs index caff27d7a69..a3f845972b2 100644 --- a/crates/fractal-daemon/src/web.rs +++ b/crates/fractal-daemon/src/web.rs @@ -131,8 +131,8 @@ async fn admit(web: &Web, mut request: Request, next: Next) -> Response { let cookie = request.headers().get(header::COOKIE).and_then(|value| value.to_str().ok()) .and_then(|value| value.split(';').find_map(|part| part.trim().strip_prefix("fractal_local="))) .map(str::to_owned); - if cookie.is_some() && request.headers().contains_key(header::AUTHORIZATION) { - // Refuse before token redemption too: mixed authority must not mint a browser session. + if (cookie.is_some() || token.is_some()) && request.headers().contains_key(header::AUTHORIZATION) { + // Browser admission requires a single credential source. return StatusCode::FORBIDDEN.into_response(); } let mut expires_at = None; @@ -298,14 +298,18 @@ mod tests { .body(Body::from("{}")).unwrap()).await.unwrap(); assert_eq!(response.status(),StatusCode::FORBIDDEN,"{method}"); } - let login = app.oneshot(Request::builder().uri("/?token=once") - .header(header::HOST,"127.0.0.1:7410") - .header(header::COOKIE,"fractal_local=admitted") - .header(header::AUTHORIZATION,"Bearer service-credential") - .body(Body::empty()).unwrap()).await.unwrap(); - assert_eq!(login.status(),StatusCode::FORBIDDEN); - assert_eq!(web.credentials.lock().unwrap().token,"once"); - assert!(!web.credential_path.exists()); + for cookie in [Some("fractal_local=admitted"),None] { + let mut login = Request::builder().uri("/?token=once") + .header(header::HOST,"127.0.0.1:7410") + .header(header::AUTHORIZATION,"Bearer service-credential"); + if let Some(cookie) = cookie { login = login.header(header::COOKIE,cookie); } + let response = app.clone().oneshot(login.body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(),StatusCode::FORBIDDEN); + let credentials = web.credentials.lock().unwrap(); + assert_eq!(credentials.token,"once"); + assert_eq!(credentials.sessions.len(),1); + assert!(!web.credential_path.exists()); + } assert_eq!(dispatched.load(std::sync::atomic::Ordering::SeqCst),0); } } diff --git a/crates/fractal/tests/daemon_cost.rs b/crates/fractal/tests/daemon_cost.rs index 84715be0dd1..a807b63cc5c 100644 --- a/crates/fractal/tests/daemon_cost.rs +++ b/crates/fractal/tests/daemon_cost.rs @@ -603,6 +603,9 @@ const fn direct(route: &'static str, call: Direct) -> Probe { } const PROBES: &[Probe] = &[ + get("GET /v1/client/claims", "/v1/client/claims?limit=100"), + get("GET /v1/client/claims/by-id/{id}", "/v1/client/claims/by-id/{service_claim}"), + get("GET /v1/client/work-items/{*subject}", "/v1/client/work-items/{step}"), post("POST /v1/client/push-registration", "/v1/client/push-registration", |_, _| push_body()), Probe { route: "DELETE /v1/client/push-registration", @@ -2625,6 +2628,61 @@ fn seed_git_change_reads(store: &Store, scale: f64) { } } +#[cfg(test)] +const SERVICE_PROBE_TOKEN: &str = "invented-cost-service-reader"; + +// This integration-test target alone constructs the disabled Service profile. +#[cfg(test)] +fn seed_service_probe(store: &Store) -> String { + use fractal_daemon::api::client_v0::{credential_digest, external_grants}; + const ID: &str = "787878787878787878787878"; + store.append_claim(&ClaimInput { + subject:external_grants::subject(ID).unwrap(),kind:external_grants::GRANTED.into(), + actor:Some("person/bench-operator".into()), + fields:serde_json::from_value(json!({ + "profile":"service","principal":"plugin/cost/reader","grant_id":ID, + "session_actor":format!("plugin/cost/reader/session/{ID}"), + "issuer":"person/bench-operator","expires_at_unix_ms":u64::MAX, + "credential_hash":credential_digest(SERVICE_PROBE_TOKEN), + "scopes":["read.claims","read.work"], + })).unwrap(),evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); + store.append_claim(&ClaimInput { + subject:"gate-operation/cost/service-read".into(),kind:"gate.result".into(),actor:None, + fields:serde_json::from_value(json!({"verdict":"pass"})).unwrap(), + evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap().id +} + +#[cfg(test)] +async fn service_probe(app: &Router, path: &str) -> Result { + use axum::{body::Body,http::Request}; + use tower::ServiceExt as _; + let response = app.clone().oneshot(Request::builder().uri(path) + .header("authorization",format!("Bearer {SERVICE_PROBE_TOKEN}")) + .body(Body::empty()).map_err(|error| error.to_string())?) + .await.map_err(|error| error.to_string())?; + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(),usize::MAX).await.map_err(|error| error.to_string())?; + if !status.is_success() { + return Err(format!("service read {path} failed: {status}")); + } + let envelope: Value = serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + let answer = envelope["value"].clone(); + if path.starts_with("/v1/client/claims/by-id/") { + if answer["body"]["fields"]["verdict"] != "pass" { + return Err("service receipt probe must read its populated receipt".into()); + } + } else if path.starts_with("/v1/client/claims?") { + if !answer["claims"].as_array().is_some_and(|claims| !claims.is_empty()) { + return Err("service page probe must read populated claims".into()); + } + } else if !answer["subject"].as_str().is_some_and(|subject| subject.starts_with("step-run/")) { + return Err("service work probe must read a populated work item".into()); + } + Ok(answer) +} + fn push_body() -> Value { json!({ "token":"0123456789abcdef0123456789abcdef", @@ -2804,6 +2862,10 @@ async fn measure(scale: f64, source: &Path, peer_source: &Path, usage_window: (u let mut state = state; state.harness_question_sink = Some(harness_sink.clone()); let paired_router = fractal_daemon::api::fabric_router(state.clone()); + let service_claim = seed_service_probe(&store); + let mut service_state = state.clone(); + service_state.owning_person = Some("person/bench-operator".into()); + let service_router = fractal_daemon::api::fabric_router(service_state); let seeded_cut = store.index().unwrap(); fractal_daemon::api::start_published_lists(&state); tokio::time::timeout(Duration::from_secs(120), @@ -2871,6 +2933,7 @@ async fn measure(scale: f64, source: &Path, peer_source: &Path, usage_window: (u store.append_claim(&running).unwrap(); } let mut fixture = fixture(&person, &client, subjects).await; + fixture.items.insert("service_claim",service_claim); fixture.items.insert( "harness_question", urlencoding::encode(&harness_question.id).into_owned(), @@ -3087,6 +3150,9 @@ async fn measure(scale: f64, source: &Path, peer_source: &Path, usage_window: (u let method = probe.route.split_once(' ').unwrap().0; let body = probe.body.map(|body| body(&fixture, attempt)); counted(|| paired_push(&paired_router, push_credential, method, body.as_ref())).await + } else if probe.path.starts_with("/v1/client/claims") || probe.path.starts_with("/v1/client/work-items/") { + let path = fixture.fill(probe.path,attempt); + counted(|| service_probe(&service_router,&path)).await } else if let Some(call) = probe.direct { let (store, fixture) = (store.clone(), &fixture); counted(|| async move { diff --git a/docs/st3/principals-and-grants.md b/docs/st3/principals-and-grants.md index 717e3d7605d..bf1885a2cda 100644 --- a/docs/st3/principals-and-grants.md +++ b/docs/st3/principals-and-grants.md @@ -165,8 +165,8 @@ semantics. No declaration, assistant, action, upgrade, HEAD or unknown route is The daemon writes only `custom.client.service.v1.read-window` as `daemon/{origin}`, at most once per grant per 60-second wall-clock window. Admission-refused requests write no audit claim. Service identity is never an acting party, -and browser ingress refuses an admitted cookie combined with Authorization rather -than discarding the bearer and exercising person authority. Service-grant fixtures +and browser ingress refuses a browser cookie or launch token combined with +Authorization. Browser admission requires a single credential source. Service-grant fixtures are inside `#[cfg(test)]` modules; production grant append still rejects service issuance. This is not an OS sandbox: same-UID callers dropping the credential on an unrestricted socket remain From 861e19ed5741433def2ebf70d38a194f79f40d8b Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:02:05 +0100 Subject: [PATCH 029/132] fix(service): make selector field admission exhaustive agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-core/src/api/records.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/fractal-api-core/src/api/records.rs b/crates/fractal-api-core/src/api/records.rs index afb5224f6e0..5b955ac249e 100644 --- a/crates/fractal-api-core/src/api/records.rs +++ b/crates/fractal-api-core/src/api/records.rs @@ -356,7 +356,9 @@ pub struct ClaimsQuery { impl ClaimsQuery { fn has_selector(&self) -> bool { - self.subject.is_some() || self.owner_run.is_some() + // Every new query field requires an explicit selector-admission decision. + let Self { subject, owner_run, after_index: _, before_index: _, order: _, limit: _ } = self; + subject.is_some() || owner_run.is_some() } } From 9d294af3b77d74b1713a2f20f83f8da98d43e909 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:13:07 +0100 Subject: [PATCH 030/132] test(service): use registered selected claim fixtures agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../src/api/client_v0/external_grants_service_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs index b7c291eb7a1..85db60fb37d 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs @@ -369,8 +369,8 @@ async fn service_selectors_match_only_disclosed_claim_kinds() { for (subject,owner) in [(hidden_subject,hidden_owner),(mixed_subject,mixed_owner)] { for n in 0..7 { let claim = state.store.append_claim(&ClaimInput { - subject:subject.into(),kind:"custom.service-test.v1.item".into(), - actor:None,fields:serde_json::from_value(json!({"number":n})).unwrap(), + subject:subject.into(),kind:"gate.reviewer-reassigned".into(), + actor:None,fields:serde_json::from_value(json!({"request":format!("request-{n}")})).unwrap(), evidence:vec![],expected_subject:None,idempotency_key:None, }).unwrap(); withheld_indexes.push(claim.store_index); From 0c70272a00d14386bd172e5d8e4e9acbba4a2de5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:32:58 +0100 Subject: [PATCH 031/132] fix(service): match canonical claim selectors literally agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../external_grants_service_tests.rs | 67 +++++++++++++++++++ crates/fractal-api-core/src/api/records.rs | 11 ++- docs/st3/principals-and-grants.md | 4 ++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs index 85db60fb37d..b90c8118fa6 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs @@ -423,3 +423,70 @@ async fn service_selectors_match_only_disclosed_claim_kinds() { } } } + +#[tokio::test] +async fn service_subject_selectors_are_literal_canonical_names() { + use crate::store::identities::{IdentityKind, IdentityStore, RenameRequest}; + let root = tempfile::tempdir().unwrap(); + let (state,_) = fixture(root.path(),SERVICE_SCOPES); + let source = r#"version 2 +agent "literal-reader" { command "true" } +mission "literal-service" state="ready" { + goal "Exercise canonical selectors." + step "work" { assigned-to "agent/node.literal-reader" } +} +"#; + let intent = crate::graph::parse_test_intent(source,"node").unwrap(); + let plan = state.store.mission(&intent,crate::model::IntentInput { kdl:source.into(),source_name:None }).unwrap(); + state.store.apply(&intent,&plan.subject_tokens,"literal-selectors").unwrap(); + let identities = [ + (IdentityKind::Agent,"agent/node.literal-reader","agent/node.literal-reader-alias","agent.renamed"), + (IdentityKind::Mission,"mission/literal-service","mission/literal-service-alias","mission.renamed"), + ]; + for (kind,subject,alias,_) in identities { + state.store.rename_identity(kind,&RenameRequest { + from:subject.into(),to:alias.into(),actor:"person/owner".into(), + reason:"Exercise canonical selectors.".into(),expected_naming_revision:None, + expected_declaration:None,idempotency_key:format!("literal-{subject}"), + },&[]).unwrap(); + assert_eq!(crate::store::identities::resolve_reference_conn(&state.store.readers.get(),alias).unwrap(),subject); + } + for lifecycle in ["unknown","invalid"] { + for (_,subject,_,kind) in identities { + state.store.connection.lock().unwrap().execute( + "INSERT INTO replica_records(record_ref,writer,sequence,envelope_hash,position,raw,state,subject_hint,kind_hint,updated_at_unix_ms) + VALUES(?1,'test-peer',1,?1,0,'{}',?2,?3,?4,'1')", + rusqlite::params![format!("record/{lifecycle}/{subject}"),lifecycle,subject,kind], + ).unwrap(); + } + for (_,subject,alias,_) in identities { + for name in [subject,alias] { + let error = crate::store::identities::resolve_reference_conn(&state.store.readers.get(),name).unwrap_err(); + assert_eq!(error.code,"lifecycle-unavailable"); + assert_eq!(error.details["reason"],if lifecycle == "invalid" { "conflict" } else { "pending-replication" }); + } + } + for gateway in [false,true] { + let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; + call(&app,"GET","/v1/client/claims?limit=1",Value::Null,false).await; + for prefix in if gateway { vec!["/v1/client/claims"] } else { vec!["/v1/client/claims","/v1/claims"] } { + for (_,subject,alias,_) in identities { + for name in [subject,alias] { + for owner in ["","&owner_run=mission-run/literal-unused"] { + for bounds in ["&limit=1","&limit=500&order=desc","&after=1&before=9223372036854775807"] { + let selected = page_wire(&app,&format!("{prefix}?subject={name}{owner}{bounds}")).await; + let absent = page_wire(&app,&format!("{prefix}?subject=agent/node.literal-unused{owner}{bounds}")).await; + assert_eq!(selected,absent); + assert_eq!(selected.0,StatusCode::OK); + } + } + } + } + } + } + let native = crate::api::router(state.clone()); + let response = native.oneshot(Request::builder().uri("/v1/claims?subject=agent/node.literal-reader") + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(),StatusCode::CONFLICT); + } +} diff --git a/crates/fractal-api-core/src/api/records.rs b/crates/fractal-api-core/src/api/records.rs index 5b955ac249e..19aee33ab36 100644 --- a/crates/fractal-api-core/src/api/records.rs +++ b/crates/fractal-api-core/src/api/records.rs @@ -389,9 +389,14 @@ pub async fn list_claims( let store = state.store.clone(); let page = blocking_store(move || { let kinds = (service && query.has_selector()).then_some(SERVICE_CLAIM_KINDS); - let subject = query.subject.as_deref().map(|subject| { - crate::store::identities::resolve_reference_conn(&store.readers.get(), subject) - }).transpose()?; + let subject = if service { + // Services select literal canonical subjects, without alias resolution. + query.subject + } else { + query.subject.as_deref().map(|subject| { + crate::store::identities::resolve_reference_conn(&store.readers.get(), subject) + }).transpose()? + }; generic_claims_page( &store, subject.as_deref(), diff --git a/docs/st3/principals-and-grants.md b/docs/st3/principals-and-grants.md index bf1885a2cda..f69db017428 100644 --- a/docs/st3/principals-and-grants.md +++ b/docs/st3/principals-and-grants.md @@ -158,6 +158,10 @@ never match withheld rows: filtered store queries restrict kinds to the disclosu allowlist in SQL before paging. Filtered pages contain only allowlisted claims, never withheld markers or indexes; empty filtered pages retain the ordinary empty page/cursor shape. Unfiltered paging still includes withheld positions. +Service `subject` selectors match literal canonical subjects only; services must +supply the stored canonical subject, not an alias. No identity-reference or +lifecycle resolution runs for service selectors. `owner_run` remains a literal +ownership comparison. Non-service reference resolution is unchanged. The new client claim/work aliases admit only services; device, assistant and browser clients gain no new routes. Existing native routes retain non-service semantics. No declaration, assistant, action, upgrade, HEAD or unknown route is admitted. From 1c5a6c050a708170091fb1935ec9e71b8067a10e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:48:46 +0100 Subject: [PATCH 032/132] fix(missions): remove redundant work actor borrow agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions/src/store/missions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index a734ebe8a70..66f4a23ef6c 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -5941,7 +5941,7 @@ impl MissionsStore for Store { let committed = self .connection .batched(|transaction| -> Result<(StepRunView, Option), FractalError> { - let actor = physical_actor(transaction, &actor)?; + let actor = physical_actor(transaction, actor)?; validate_work_write_actor(&actor)?; #[cfg(any(test, feature = "test-support"))] let _writer_work = WorkActionWriterProbe::start(&request.idempotency_key); From e3a077345df9b1fab2eb34452d2133f93b9138d5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:20:23 +0100 Subject: [PATCH 033/132] test(service): require successful seeded cost probes agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal/tests/daemon_cost.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/fractal/tests/daemon_cost.rs b/crates/fractal/tests/daemon_cost.rs index a807b63cc5c..806f7fb7b73 100644 --- a/crates/fractal/tests/daemon_cost.rs +++ b/crates/fractal/tests/daemon_cost.rs @@ -2683,6 +2683,19 @@ async fn service_probe(app: &Router, path: &str) -> Result { Ok(answer) } +async fn required_service_probe(app: &Router, path: &str) -> Value { + service_probe(app,path).await.unwrap_or_else(|error| { + panic!("seeded service probe must succeed before cost aggregation: {error}") + }) +} + +#[tokio::test] +#[should_panic(expected = "seeded service probe must succeed before cost aggregation")] +async fn missing_service_probe_is_fatal_before_cost_aggregation() { + let app = Router::new().fallback(|| async { axum::http::StatusCode::NOT_FOUND }); + required_service_probe(&app,"/v1/client/claims/by-id/unseeded").await; +} + fn push_body() -> Value { json!({ "token":"0123456789abcdef0123456789abcdef", @@ -3152,7 +3165,7 @@ async fn measure(scale: f64, source: &Path, peer_source: &Path, usage_window: (u counted(|| paired_push(&paired_router, push_credential, method, body.as_ref())).await } else if probe.path.starts_with("/v1/client/claims") || probe.path.starts_with("/v1/client/work-items/") { let path = fixture.fill(probe.path,attempt); - counted(|| service_probe(&service_router,&path)).await + counted(|| async { Ok(required_service_probe(&service_router,&path).await) }).await } else if let Some(call) = probe.direct { let (store, fixture) = (store.clone(), &fixture); counted(|| async move { From 717c0ec6b06c7f097c6208402fb2e19b77abb413 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:10:10 +0100 Subject: [PATCH 034/132] test(service): register SQLite fixture value sites agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../src/api/client_v0/external_grants_service_tests.rs | 2 ++ docs/st3/large-value-sites.json | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs index b90c8118fa6..fe5b689a015 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs @@ -376,6 +376,7 @@ async fn service_selectors_match_only_disclosed_claim_kinds() { withheld_indexes.push(claim.store_index); if n == 0 { state.store.connection.lock().unwrap().execute( + /* sqlite-large-value: allow-for-now lv-h16df3901f435 owner=fractal_api_core::api::client_v0::external_grants_service_tests TODO=bound-or-reference-values-and-reclamation */ "INSERT INTO desired(subject,kind,revision,claim_id,body,owner_run) VALUES (?1,'gate-operation',?2,?2,'{}',?3)", rusqlite::params![subject,claim.id,owner], ).unwrap(); @@ -454,6 +455,7 @@ mission "literal-service" state="ready" { for lifecycle in ["unknown","invalid"] { for (_,subject,_,kind) in identities { state.store.connection.lock().unwrap().execute( + /* sqlite-large-value: allow-for-now lv-h6825b80ba287 owner=fractal_api_core::api::client_v0::external_grants_service_tests TODO=chunk-or-reference-exact-wire-values */ "INSERT INTO replica_records(record_ref,writer,sequence,envelope_hash,position,raw,state,subject_hint,kind_hint,updated_at_unix_ms) VALUES(?1,'test-peer',1,?1,0,'{}',?2,?3,?4,'1')", rusqlite::params![format!("record/{lifecycle}/{subject}"),lifecycle,subject,kind], diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 416ebc6695d..0b277e9ddd6 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -797,6 +797,8 @@ {"id": "flow-h39d714b2bc13", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "function": "trim_checkpoint_from", "owner": "smallclaims::store::checkpoint_trim", "fingerprint": "344fc686f5b0778d9f534c322d46e20e2a21066b09d5acfc1c01c0cf88ad607a", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "description": "Exact-adoption/trim publication and durable cleanup must be separated without bypassing certification."} ], "sites": [ + {"id": "lv-h16df3901f435", "path": "crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs", "kind": "sql-template", "fingerprint": "b296d61cf0e851433215fceb31a07fe318f71043a61dbd3a4fe076940dfaffa3", "owner": "fractal_api_core::api::client_v0::external_grants_service_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 380, "excerpt": "INSERT INTO desired(subject,kind,revision,claim_id,body,owner_run) VALUES (?1,'gate-operation',?2,?2,'{}',?3)"}, + {"id": "lv-h6825b80ba287", "path": "crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs", "kind": "sql-template", "fingerprint": "ab01873ecaaa220d63b37aec6d7213c13548a25d0bc552b5189ae49cbe7580b6", "owner": "fractal_api_core::api::client_v0::external_grants_service_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 459, "excerpt": "INSERT INTO replica_records(record_ref,writer,sequence,envelope_hash,position,raw,state,subject_hint,kind_hint,updated_at_unix_ms) VALUES(?1,'test-peer',1,?1,0,'{}',?2,?3,?4,'1')"}, {"id": "lv-h2d6ca7ff29ef", "path": "crates/fractal-store/src/store/notice_authority.rs", "kind": "sql-template", "fingerprint": "bffb6dc7a37c0f4dc2b8f7d9c9f4330331b47cda2278c2dc99116326e0060fa7", "owner": "fractal_store::store::notice_authority", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 86, "excerpt": "INSERT OR IGNORE INTO custom_dependencies VALUES(?1,?2,'[]','')"}, {"id": "lv-h6b344b76fae0", "path": "crates/fractal-store/src/store/notice_authority.rs", "kind": "sql-template", "fingerprint": "d413e887f4063a27a07b744cd1decd88fd76b81bcf87ca8bf4bd66434e18ef3b", "owner": "fractal_store::store::notice_authority", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 61, "excerpt": "CREATE TRIGGER {name} BEFORE {action} ON {table} WHEN ({relevant}) AND ({changed}) BEGIN INSERT INTO local_custom_dirty SELECT subject FROM custom_sources WHERE subject LIKE 'custom/attention/%' ON CO"}, {"id": "lv-h80b0a7485e7e", "path": "crates/fractal-store/src/store/notice_authority.rs", "kind": "sql-template", "fingerprint": "8dc3b720253389a4c76f2c322c03a8f44a29cd7331786ca96608bdcbc12eb19d", "owner": "fractal_store::store::notice_authority", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 135, "excerpt": "INSERT INTO local_custom_dirty SELECT subject FROM custom_sources WHERE subject LIKE 'custom/attention/%' ON CONFLICT(subject) DO NOTHING"}, From 9a070d05b0cc2427642df4ecc15292e00cecc612 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:55:25 +0100 Subject: [PATCH 035/132] fix(missions): retain literal lease ownership for renewal writes agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions/src/store/missions.rs | 13 ++++-- crates/fractal-missions/src/store/tests.rs | 9 ++++ .../src/store/work_renew_tests.rs | 41 ++++++++++++++++++- release-notes/mission-agentid-canonical.json | 2 +- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 66f4a23ef6c..4de80f87c3a 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -6155,14 +6155,21 @@ impl MissionsStore for Store { let lease_valid = current .claim_expires_at_unix_ms .is_some_and(|expiry| expiry > now); - if lease_valid && !work_actor_matches(current.claimant.as_deref(), &actor) { + // Expansion keeps main's literal ownership fence for renewal writes: + // replay reapplies operational extensions only to the exact stored owner. + let owns_lease = if action == "renew" { + current.claimant.as_deref() == Some(actor.as_str()) + } else { + work_actor_matches(current.claimant.as_deref(), &actor) + }; + if lease_valid && !owns_lease { return Err(FractalError::new( "work-already-claimed", format!("step run `{subject}` has an active lease"), )); } if action != "claim" - && (!lease_valid || !work_actor_matches(current.claimant.as_deref(), &actor)) + && (!lease_valid || !owns_lease) { return Err(FractalError::new( "work-not-claimed", @@ -6170,7 +6177,7 @@ impl MissionsStore for Store { )); } if lease_valid - && work_actor_matches(current.claimant.as_deref(), &actor) + && owns_lease && current.claim_incarnation.as_deref() != Some(requested_incarnation) { return Err(FractalError::new( diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index 1594d0d2bcd..10b2abd1db8 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -9604,6 +9604,15 @@ fn carried_claim_successor_matches_legacy_and_canonical_holders() { &store.readers.get(), &smallclaims::store::normalize_actor(actor, "agent"), ).unwrap(); + if main_actor != holder { + let error = store.work_action(old, "renew", &renew).unwrap_err(); + assert_eq!(error.code, "work-already-claimed"); + assert_eq!( + store.step_run(carried).unwrap().unwrap().claimant.as_deref(), + Some(holder), + ); + continue; + } let renewed = store.work_action(old, "renew", &renew).unwrap(); assert_eq!(&renewed.subject, carried); // The carried subject has no replicated lease anchor. Main publishes this diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index 06e996cb450..04c5a093cfa 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -570,7 +570,7 @@ fn work_claim_rejects_raw_actor_and_retains_main_spelling_independent_of_assignm } #[test] -fn replicated_legacy_claimant_waits_for_row_and_checks_projected_eligibility() { +fn replicated_legacy_claimant_refuses_alias_renewal_without_replay_loss() { use fractal_store::store::{classify_replicated_claim_with_registry, project_claim_isolated_tx, project_mission_run_update}; @@ -597,6 +597,8 @@ fn replicated_legacy_claimant_waits_for_row_and_checks_projected_eligibility() { let historical = replication.batches.iter_mut().flat_map(|batch| &mut batch.claims) .find(|claim| claim.subject == *subject && claim.kind == "work.claimed").unwrap(); historical.body["fields"]["claimant"] = json!("node/worker"); + let anchored_expiry = now_ms() + 540_000; + historical.body["fields"]["claim_expires_at_unix_ms"] = json!(anchored_expiry); historical.id = claim_hash( &historical.batch_id, &historical.subject, &historical.kind, &historical.origin, historical.actor.as_deref(), &historical.body, &historical.predecessors, @@ -610,8 +612,35 @@ fn replicated_legacy_claimant_waits_for_row_and_checks_projected_eligibility() { let projected = replica.step_run(subject).unwrap().unwrap(); assert_eq!(projected.claimant.as_deref(), Some("node/worker")); assert_eq!(replica.claims_for(subject, Some("work.claimed")).unwrap()[0].body, claim.body); + assert_eq!(projected.claim_incarnation, claim_request.incarnation); + assert_eq!(projected.claim_expires_at_unix_ms, Some(anchored_expiry)); + assert!(anchored_expiry > now_ms() + 300_000, "the anchor would allow a quiet renewal"); + let before_index = replica.index().unwrap(); + let mut renew = claim_request.clone(); + renew.idempotency_key = "legacy-owner-alias-renew".into(); + let error = replica.work_action(subject, "renew", &renew).unwrap_err(); + assert_eq!(error.code, "work-already-claimed", "main's literal ownership fence"); + assert_eq!(replica.index().unwrap(), before_index); + let after = replica.step_run(subject).unwrap().unwrap(); + assert_eq!(after.claimant, projected.claimant); + assert_eq!(after.claim_incarnation, projected.claim_incarnation); + assert_eq!(after.claim_expires_at_unix_ms, projected.claim_expires_at_unix_ms); + let local_renewals: u64 = replica.readers.get().query_row( + "SELECT COUNT(*) FROM local_work_lease_renewals WHERE subject=?1", + [subject], |row| row.get(0), + ).unwrap(); + assert_eq!(local_renewals, 0); replica.rebuild_claim_projections().unwrap(); assert_eq!(replica.step_run(subject).unwrap().unwrap().claimant.as_deref(), Some("node/worker")); + let replayed = replica.step_run(subject).unwrap().unwrap(); + assert_eq!(replayed.status, projected.status); + assert_eq!(replayed.claim_incarnation, projected.claim_incarnation); + assert_eq!(replayed.claim_expires_at_unix_ms, projected.claim_expires_at_unix_ms); + let replayed_local_renewals: u64 = replica.readers.get().query_row( + "SELECT COUNT(*) FROM local_work_lease_renewals WHERE subject=?1", + [subject], |row| row.get(0), + ).unwrap(); + assert_eq!(replayed_local_renewals, 0); assert_eq!(replica.claims_for(subject, Some("work.claimed")).unwrap()[0].body, claim.body); let mut connection = replica.connection.write(); @@ -782,6 +811,11 @@ fn work_renewal_matches_legacy_and_canonical_holders() { assert_eq!(step.store.claims_for(&step.subject, None).unwrap().len(), before); continue; } + if actor != holder { + let error = step.store.work_action(&step.subject, "renew", &renew).unwrap_err(); + assert_eq!(error.code, "work-already-claimed"); + continue; + } let renewed = step.store.work_action(&step.subject, "renew", &renew).unwrap(); // Main's quiet renewal writes only the operational lease, not a new claim // or a replacement claimant spelling in the projected step. @@ -819,6 +853,11 @@ fn work_renew_release_and_completion_match_legacy_and_canonical_holders() { assert_eq!(step.store.step_run(&step.subject).unwrap().unwrap().claimant.as_deref(), Some(holder)); continue; } + if action == "renew" && actor != holder { + let error = step.store.work_action(&step.subject, action, &finish).unwrap_err(); + assert_eq!(error.code, "work-already-claimed"); + continue; + } let finished = step.store.work_action(&step.subject, action, &finish).unwrap(); assert_eq!(finished.status, expected_status, "{actor} / {holder} / {action}"); // A quiet renewal retains the projected claimant; release/complete clear it. diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json index a478f40e6fe..3bb0a6d6add 100644 --- a/release-notes/mission-agentid-canonical.json +++ b/release-notes/mission-agentid-canonical.json @@ -3,7 +3,7 @@ "summary": "Emit canonical agent identities in mission presentation while preserving historical writer spelling", "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical host-relative qualification and malformed assignee normalization rather than rejecting them."}, "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, - "rules": {"status": "changed", "detail": "Work eligibility, reads, renewal, successor lookup and capacity accounting compare canonical identities. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, + "rules": {"status": "changed", "detail": "Work eligibility, reads, renewal polling, successor lookup and capacity accounting compare canonical identities. Renewal mutations retain main's literal owner fence: an unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed, without an operational extension or spelling-changing publication. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, "compatibility": {"status": "changed", "detail": "Expand phase only: read both actor spellings and preserve the previous writer spelling, with no feature gate or capability advertisement. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership: a later spelling switch and stored-row migration require a durable one-way fleet fence. Once raw legacy-signed ownership facts exist, the historical reader floor must prohibit old readers that cannot admit those facts; a reversible capability check cannot establish that floor."}, "downtime": {"status": "none", "detail": "No downtime or stored-row migration is required for this read-compatible expansion."}, "manual": {"status": "none", "detail": "No operator action is required."}, From 9dcc3ead65940fbb6be45075cc3120a4d055d5bc Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:06:52 +0100 Subject: [PATCH 036/132] fix(missions): preserve literal admission for all claim mutations Refuse alias-owner release and completion just as main does, preserving claim state across replay. Keep canonical identity resolution on read paths only. Co-authored-by: OpenAI Codex agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions/src/store/missions.rs | 14 ++---- .../src/store/work_renew_tests.rs | 46 +++++++++++++------ release-notes/mission-agentid-canonical.json | 2 +- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 4de80f87c3a..7e5e45a70e4 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -6155,21 +6155,15 @@ impl MissionsStore for Store { let lease_valid = current .claim_expires_at_unix_ms .is_some_and(|expiry| expiry > now); - // Expansion keeps main's literal ownership fence for renewal writes: - // replay reapplies operational extensions only to the exact stored owner. - let owns_lease = if action == "renew" { - current.claimant.as_deref() == Some(actor.as_str()) - } else { - work_actor_matches(current.claimant.as_deref(), &actor) - }; - if lease_valid && !owns_lease { + // Expansion preserves main's literal ownership admission for every mutation. + if lease_valid && current.claimant.as_deref() != Some(actor.as_str()) { return Err(FractalError::new( "work-already-claimed", format!("step run `{subject}` has an active lease"), )); } if action != "claim" - && (!lease_valid || !owns_lease) + && (!lease_valid || current.claimant.as_deref() != Some(actor.as_str())) { return Err(FractalError::new( "work-not-claimed", @@ -6177,7 +6171,7 @@ impl MissionsStore for Store { )); } if lease_valid - && owns_lease + && current.claimant.as_deref() == Some(actor.as_str()) && current.claim_incarnation.as_deref() != Some(requested_incarnation) { return Err(FractalError::new( diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index 04c5a093cfa..37d2b7653a7 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -570,7 +570,7 @@ fn work_claim_rejects_raw_actor_and_retains_main_spelling_independent_of_assignm } #[test] -fn replicated_legacy_claimant_refuses_alias_renewal_without_replay_loss() { +fn replicated_legacy_claimant_refuses_alias_mutations_without_replay_loss() { use fractal_store::store::{classify_replicated_claim_with_registry, project_claim_isolated_tx, project_mission_run_update}; @@ -616,15 +616,26 @@ fn replicated_legacy_claimant_refuses_alias_renewal_without_replay_loss() { assert_eq!(projected.claim_expires_at_unix_ms, Some(anchored_expiry)); assert!(anchored_expiry > now_ms() + 300_000, "the anchor would allow a quiet renewal"); let before_index = replica.index().unwrap(); - let mut renew = claim_request.clone(); - renew.idempotency_key = "legacy-owner-alias-renew".into(); - let error = replica.work_action(subject, "renew", &renew).unwrap_err(); - assert_eq!(error.code, "work-already-claimed", "main's literal ownership fence"); - assert_eq!(replica.index().unwrap(), before_index); - let after = replica.step_run(subject).unwrap().unwrap(); - assert_eq!(after.claimant, projected.claimant); - assert_eq!(after.claim_incarnation, projected.claim_incarnation); - assert_eq!(after.claim_expires_at_unix_ms, projected.claim_expires_at_unix_ms); + for action in ["renew", "release", "complete"] { + let mut mutation = claim_request.clone(); + mutation.idempotency_key = format!("legacy-owner-alias-{action}"); + let error = replica.work_action(subject, action, &mutation).unwrap_err(); + assert_eq!(error.code, "work-already-claimed", "main's literal ownership fence: {action}"); + assert_eq!(replica.index().unwrap(), before_index); + let after = replica.step_run(subject).unwrap().unwrap(); + assert_eq!(after.status, projected.status); + assert_eq!(after.claimant, projected.claimant); + assert_eq!(after.claim_incarnation, projected.claim_incarnation); + assert_eq!(after.claim_expires_at_unix_ms, projected.claim_expires_at_unix_ms); + // In particular, a refused release must not disappear or clear the lease on replay. + replica.rebuild_claim_projections().unwrap(); + let replayed = replica.step_run(subject).unwrap().unwrap(); + assert_eq!(replayed.status, projected.status, "{action}"); + assert_eq!(replayed.claimant, projected.claimant, "{action}"); + assert_eq!(replayed.claim_incarnation, projected.claim_incarnation, "{action}"); + assert_eq!(replayed.claim_expires_at_unix_ms, projected.claim_expires_at_unix_ms, "{action}"); + assert_eq!(replica.index().unwrap(), before_index); + } let local_renewals: u64 = replica.readers.get().query_row( "SELECT COUNT(*) FROM local_work_lease_renewals WHERE subject=?1", [subject], |row| row.get(0), @@ -826,7 +837,7 @@ fn work_renewal_matches_legacy_and_canonical_holders() { } #[test] -fn work_renew_release_and_completion_match_legacy_and_canonical_holders() { +fn work_mutations_require_literal_legacy_or_canonical_holders() { for actor in ["node/worker", "agent/node/worker"] { for holder in ["node/worker", "agent/node/worker"] { for (action, expected_status) in [("renew", "claimed"), ("release", "ready"), ("complete", "verifying")] { @@ -853,9 +864,18 @@ fn work_renew_release_and_completion_match_legacy_and_canonical_holders() { assert_eq!(step.store.step_run(&step.subject).unwrap().unwrap().claimant.as_deref(), Some(holder)); continue; } - if action == "renew" && actor != holder { + if actor != holder { + let before_index = step.store.index().unwrap(); + let before = step.store.step_run(&step.subject).unwrap().unwrap(); let error = step.store.work_action(&step.subject, action, &finish).unwrap_err(); - assert_eq!(error.code, "work-already-claimed"); + assert_eq!(error.code, "work-already-claimed", "{action}"); + assert_eq!(step.store.index().unwrap(), before_index); + let after = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(after.status, before.status); + assert_eq!(after.claimant, before.claimant); + assert_eq!(after.claim_incarnation, before.claim_incarnation); + assert_eq!(after.claim_expires_at_unix_ms, before.claim_expires_at_unix_ms); + assert_eq!(after.worker_reported, before.worker_reported); continue; } let finished = step.store.work_action(&step.subject, action, &finish).unwrap(); diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json index 3bb0a6d6add..696466b432b 100644 --- a/release-notes/mission-agentid-canonical.json +++ b/release-notes/mission-agentid-canonical.json @@ -3,7 +3,7 @@ "summary": "Emit canonical agent identities in mission presentation while preserving historical writer spelling", "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical host-relative qualification and malformed assignee normalization rather than rejecting them."}, "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, - "rules": {"status": "changed", "detail": "Work eligibility, reads, renewal polling, successor lookup and capacity accounting compare canonical identities. Renewal mutations retain main's literal owner fence: an unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed, without an operational extension or spelling-changing publication. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, + "rules": {"status": "changed", "detail": "Work eligibility, reads, renewal polling, successor lookup and capacity accounting compare canonical identities. All work mutations retain main's literal owner admission: an unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed, without changing claim state, publishing a mutation or extending the operational lease. This includes renewal, release and completion. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, "compatibility": {"status": "changed", "detail": "Expand phase only: read both actor spellings and preserve the previous writer spelling, with no feature gate or capability advertisement. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership: a later spelling switch and stored-row migration require a durable one-way fleet fence. Once raw legacy-signed ownership facts exist, the historical reader floor must prohibit old readers that cannot admit those facts; a reversible capability check cannot establish that floor."}, "downtime": {"status": "none", "detail": "No downtime or stored-row migration is required for this read-compatible expansion."}, "manual": {"status": "none", "detail": "No operator action is required."}, From ca6b58cd4d3f1ad67992efbee168fbb795843eec Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:13:45 +0100 Subject: [PATCH 037/132] fix(missions): keep alias resolution out of mutation admission Restore main literal predicates for ancestor renewal, carried claims, output authority, eligibility, capacity and queue admission. Cover alias-owned ancestors and literal admission behavior while preserving canonical read filters. Co-authored-by: OpenAI Codex agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../fractal-missions/src/store/adhoc_work.rs | 2 +- crates/fractal-missions/src/store/missions.rs | 28 ++++----- crates/fractal-missions/src/store/tests.rs | 16 +++-- .../src/store/work_renew_tests.rs | 59 +++++++++++++------ release-notes/mission-agentid-canonical.json | 2 +- 5 files changed, 68 insertions(+), 39 deletions(-) diff --git a/crates/fractal-missions/src/store/adhoc_work.rs b/crates/fractal-missions/src/store/adhoc_work.rs index ddb2e18578d..2f7baa825a1 100644 --- a/crates/fractal-missions/src/store/adhoc_work.rs +++ b/crates/fractal-missions/src/store/adhoc_work.rs @@ -347,7 +347,7 @@ impl AdhocWorkStore for Store { } return Err(FractalError::new("missing-work-actor", "the calling seat needs a live declaration")); } - let active: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM step_runs WHERE lease_owner IN (SELECT value FROM json_each(?1)) AND status IN ('claimed','working','verifying','blocked') AND CAST(lease_expires_at_unix_ms AS INTEGER)>?2)", params![fractal_store::store::work_actor_spellings_json(&input.actor), now_ms() as i64], |row| row.get(0)).map_err(internal)?; + let active: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM step_runs WHERE lease_owner=?1 AND status IN ('claimed','working','verifying','blocked') AND CAST(lease_expires_at_unix_ms AS INTEGER)>?2)", params![input.actor, now_ms() as i64], |row| row.get(0)).map_err(internal)?; if active { return Err(FractalError::new("agent-capacity", "finish or release independent claimed work before starting another run")); } let claim = append_claim_tx(tx, &self.origin, &run, "mission-run.created", Some(&input.actor), &json!({"fields": { "status": "running", "mission": mission.subject, "revision": mission.revision, "initial_revision": mission.revision, diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 7e5e45a70e4..d6cab2bd267 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -626,14 +626,14 @@ fn renew_nested_ancestor_leases_tx( WHERE generation_id=?3 AND substr(?4, 1, length(step_path) + 1)=step_path || '/' AND status IN ('claimed','working','verifying') - AND lease_owner IN (SELECT value FROM json_each(?5)) + AND lease_owner=?5 AND lease_incarnation IS ?6", params![ expiry.to_string(), now.to_string(), generation, step_path, - work_actor_spellings_json(actor), + actor, incarnation ], ) @@ -925,17 +925,15 @@ fn seat_order_conflict_tx( agent: &str, subject: &str, ) -> Result> { - let agent = fractal_schema::canonical_agent_actor(agent) - .unwrap_or(std::borrow::Cow::Borrowed(agent)); - let order = seat_run_orders_tx(connection, Some(&agent))? - .remove(agent.as_ref()) + let order = seat_run_orders_tx(connection, Some(agent))? + .remove(agent) .unwrap_or_default(); - let rows = seat_step_rows_tx(connection, Some(&agent))?; + let rows = seat_step_rows_tx(connection, Some(agent))?; let steps = rows .iter() .map(RosterStepRow::seat_step) .collect::>(); - let selection = seat_queue::select(&agent, &steps, &order); + let selection = seat_queue::select(agent, &steps, &order); let Some(next) = selection.next() else { return Ok(None); }; @@ -1012,9 +1010,9 @@ fn carried_claim_successor_tx( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, lease_owner, lease_incarnation, lease_expires_at_unix_ms, blocked_reason, not_before_unix_ms, created_at_unix_ms, updated_at_unix_ms, readiness_epoch, constraints FROM step_runs - WHERE run_id=?1 AND step_path=?2 AND lease_owner IN (SELECT value FROM json_each(?3)) AND generation_id<>?4 + WHERE run_id=?1 AND step_path=?2 AND lease_owner=?3 AND generation_id<>?4 AND generation_id=(SELECT current_generation_id FROM mission_runs WHERE id=?1)", - params![run_id, step.step, work_actor_spellings_json(actor), generation_id_from_subject(&step.generation)], + params![run_id, step.step, actor, generation_id_from_subject(&step.generation)], step_run_from_row, ) .optional() @@ -1032,7 +1030,7 @@ fn mission_output_authority( lease_incarnation: Option<&str>, expiry: Option| { matches!(status, "claimed" | "working") - && work_actor_matches(owner, actor) + && owner == Some(actor) && expiry.is_some_and(|expiry| expiry > now) && lease_incarnation.is_none_or(|lease| Some(lease) == incarnation) }; @@ -6065,8 +6063,8 @@ impl MissionsStore for Store { if action == "claim" && adhoc_work::pending_handoff(transaction, ¤t)? { return Err(FractalError::new("handoff-not-acknowledged", "read the handoff note and acknowledge its message before claiming this work")); } - let eligible = work_actor_matches(current.assigned_to.as_deref(), &actor) - || current.available_to.iter().any(|candidate| work_actor_matches(Some(candidate), &actor)); + let eligible = current.assigned_to.as_deref() == Some(actor.as_str()) + || current.available_to.iter().any(|candidate| candidate == &actor); if current.agentless || !eligible { return Err(FractalError::new( "work-not-available", @@ -6077,7 +6075,7 @@ impl MissionsStore for Store { let mut statement = transaction .prepare( "SELECT subject, generation_id, step_path FROM step_runs - WHERE lease_owner IN (SELECT value FROM json_each(?1)) + WHERE lease_owner=?1 AND subject<>?2 AND status IN ('claimed','working','verifying','blocked') AND lease_expires_at_unix_ms IS NOT NULL @@ -6086,7 +6084,7 @@ impl MissionsStore for Store { ) .map_err(internal)?; let active = statement - .query_map(params![work_actor_spellings_json(&actor), subject, now.to_string()], |row| { + .query_map(params![actor, subject, now.to_string()], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index 10b2abd1db8..a370a278a21 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -6412,9 +6412,7 @@ fn an_active_nested_lease_can_publish_its_parent_mission_output() { assert_eq!(output.revision, mission.revision); } -#[test] -fn nested_work_renews_the_same_workers_legacy_ancestor_lease() { - let holder = "node/worker"; +fn nested_work_ancestor_expiry_after_child_completion(holder: &str) -> u128 { let store = Store::open_memory("node").unwrap(); let source = r#"version 2 @@ -6503,7 +6501,17 @@ mission "nested-work" state="ready" { assert_eq!(parent.status, "working"); assert_eq!(parent.claimant.as_deref(), Some(holder)); assert_eq!(parent.claim_incarnation.as_deref(), Some("current")); - assert!(parent.claim_expires_at_unix_ms.unwrap() > now_ms()); + parent.claim_expires_at_unix_ms.unwrap() +} + +#[test] +fn nested_work_renews_the_literal_owners_ancestor_lease() { + assert!(nested_work_ancestor_expiry_after_child_completion("agent/node/worker") > now_ms()); +} + +#[test] +fn nested_work_does_not_renew_an_alias_owned_ancestor_lease() { + assert_eq!(nested_work_ancestor_expiry_after_child_completion("node/worker"), 0); } #[test] diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index 37d2b7653a7..aa32dfd70ce 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -510,7 +510,7 @@ fn ready_step_with_mail(mail: usize) -> Renewal { } #[test] -fn work_claim_rejects_raw_actor_and_retains_main_spelling_independent_of_assignment() { +fn work_claim_requires_literal_assignment_and_main_actor_spelling() { for (actor, assignee) in [ ("node/worker", "agent/node/worker"), ("agent/node/worker", "node/worker"), @@ -539,6 +539,15 @@ fn work_claim_rejects_raw_actor_and_retains_main_spelling_independent_of_assignm assert!(step.store.claims_for(&step.subject, Some("work.claimed")).unwrap().is_empty()); continue; } + if actor != assignee { + let error = step.store.work_action(&step.subject, "claim", &claim_request).unwrap_err(); + assert_eq!(error.code, "work-not-available"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, "ready"); + assert!(persisted.claimant.is_none()); + assert!(step.store.claims_for(&step.subject, Some("work.claimed")).unwrap().is_empty()); + continue; + } let claimed = step.store.work_action(&step.subject, "claim", &claim_request).unwrap(); assert_eq!(claimed.claimant.as_deref(), Some(actor)); assert_eq!(claimed.assigned_to.as_deref(), Some(assignee)); @@ -586,9 +595,6 @@ fn replicated_legacy_claimant_refuses_alias_mutations_without_replay_loss() { }).unwrap(); let subject = &run.steps[0].subject; source.set_step_state(subject, "ready", None).unwrap(); - source.connection.write().execute( - /* sqlite-large-value: allow-for-now lv-h2bd292359425 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='node/worker' WHERE subject=?1", [subject], - ).unwrap(); let mut claim_request = request("legacy-replica-claim", None); claim_request.actor = Some("agent/node/worker".into()); source.work_action(subject, "claim", &claim_request).unwrap(); @@ -676,7 +682,7 @@ fn replicated_legacy_claimant_refuses_alias_mutations_without_replay_loss() { } #[test] -fn work_claim_rejects_raw_actor_and_retains_main_spelling_independent_of_available_to() { +fn work_claim_requires_literal_candidate_and_main_actor_spelling() { for actor in ["node/worker", "agent/node/worker"] { for candidate in ["node/worker", "agent/node/worker"] { let step = ready_step_with_mail(0); @@ -695,6 +701,15 @@ fn work_claim_rejects_raw_actor_and_retains_main_spelling_independent_of_availab assert!(step.store.claims_for(&step.subject, Some("work.claimed")).unwrap().is_empty()); continue; } + if actor != candidate { + let error = step.store.work_action(&step.subject, "claim", &claim).unwrap_err(); + assert_eq!(error.code, "work-not-available"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, "ready"); + assert!(persisted.claimant.is_none()); + assert!(step.store.claims_for(&step.subject, Some("work.claimed")).unwrap().is_empty()); + continue; + } let claimed = step.store.work_action(&step.subject, "claim", &claim).unwrap(); assert_eq!(claimed.claimant.as_deref(), Some(actor)); let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); @@ -844,7 +859,7 @@ fn work_mutations_require_literal_legacy_or_canonical_holders() { let step = ready_step_with_mail(0); step.store.connection.write().execute( /* sqlite-large-value: allow-for-now lv-hd700e0550a21 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2 WHERE subject=?1", - params![step.subject, holder], + params![step.subject, "agent/node/worker"], ).unwrap(); let mut claim = request("holder-action-claim", None); claim.actor = Some("agent/node/worker".into()); @@ -899,14 +914,14 @@ fn work_mutations_require_literal_legacy_or_canonical_holders() { let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); assert_eq!(persisted.status, expected_status); assert_eq!(persisted.claimant.as_deref(), expected_holder); - assert_eq!(persisted.assigned_to.as_deref(), Some(holder)); + assert_eq!(persisted.assigned_to.as_deref(), Some("agent/node/worker")); } } } } #[test] -fn independent_work_capacity_refuses_second_admission_for_actor_aliases() { +fn independent_work_capacity_uses_literal_ownership_for_admission() { for actor in ["node/worker", "agent/node/worker"] { for holder in ["node/worker", "agent/node/worker"] { for status in ["claimed", "working", "verifying", "blocked"] { @@ -928,12 +943,16 @@ fn independent_work_capacity_refuses_second_admission_for_actor_aliases() { kdl: declaration.into(), source_name: None, }).unwrap(); step.store.apply(&intent, &plan.subject_tokens, "capacity-agent").unwrap(); - let error = step.store.start_work(&crate::model::WorkStartRequest { + let started = step.store.start_work(&crate::model::WorkStartRequest { actor: "agent/node/worker".into(), title: "A second independent task".into(), idempotency_key: "capacity-spontaneous-start".into(), - }).unwrap_err(); - assert_eq!(error.code, "agent-capacity", "{actor} / {holder} / {status}"); + }); + if holder == "agent/node/worker" { + assert_eq!(started.unwrap_err().code, "agent-capacity", "{actor} / {holder} / {status}"); + } else { + assert!(started.unwrap().claimant.is_none()); + } let second = step.store.create_mission_run(&MissionRunRequest { mission: "capacity-second".into(), revision: None, workspace: "/tmp".into(), requester: Some("person/test".into()), mode: Some("run".into()), @@ -947,13 +966,17 @@ fn independent_work_capacity_refuses_second_admission_for_actor_aliases() { ).unwrap(); claim.idempotency_key = "capacity-second-claim".into(); claim.actor = Some(actor.into()); - let error = step.store.work_action(second, "claim", &claim).unwrap_err(); - let expected = if actor.starts_with("agent/") { "agent-capacity" } else { "invalid-work-actor" }; - assert_eq!(error.code, expected, "{actor} / {holder} / {status}"); - let refused = step.store.step_run(second).unwrap().unwrap(); - assert_eq!(refused.status, "ready"); - assert!(refused.claimant.is_none()); - assert!(step.store.claims_for(second, Some("work.claimed")).unwrap().is_empty()); + let result = step.store.work_action(second, "claim", &claim); + if !actor.starts_with("agent/") || actor == holder { + let expected = if actor.starts_with("agent/") { "agent-capacity" } else { "invalid-work-actor" }; + assert_eq!(result.unwrap_err().code, expected, "{actor} / {holder} / {status}"); + let refused = step.store.step_run(second).unwrap().unwrap(); + assert_eq!(refused.status, "ready"); + assert!(refused.claimant.is_none()); + assert!(step.store.claims_for(second, Some("work.claimed")).unwrap().is_empty()); + } else { + assert_eq!(result.unwrap().claimant.as_deref(), Some(actor)); + } } } } diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json index 696466b432b..08ff2cc6dba 100644 --- a/release-notes/mission-agentid-canonical.json +++ b/release-notes/mission-agentid-canonical.json @@ -3,7 +3,7 @@ "summary": "Emit canonical agent identities in mission presentation while preserving historical writer spelling", "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical host-relative qualification and malformed assignee normalization rather than rejecting them."}, "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, - "rules": {"status": "changed", "detail": "Work eligibility, reads, renewal polling, successor lookup and capacity accounting compare canonical identities. All work mutations retain main's literal owner admission: an unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed, without changing claim state, publishing a mutation or extending the operational lease. This includes renewal, release and completion. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, + "rules": {"status": "changed", "detail": "Work reads, renewal polling and presentation compare canonical identities. All mutations preserve main's literal actor matching, including ownership admission, eligibility, capacity, queue admission, carried-claim successor selection, output authority and ancestor lease extension. An unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed when the literal eligibility selector admits that actor, without changing claim state, publishing a mutation or extending the operational lease. Renewal, release and completion retain the same fence; successful child mutations extend only literally owned ancestor leases. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, "compatibility": {"status": "changed", "detail": "Expand phase only: read both actor spellings and preserve the previous writer spelling, with no feature gate or capability advertisement. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership: a later spelling switch and stored-row migration require a durable one-way fleet fence. Once raw legacy-signed ownership facts exist, the historical reader floor must prohibit old readers that cannot admit those facts; a reversible capability check cannot establish that floor."}, "downtime": {"status": "none", "detail": "No downtime or stored-row migration is required for this read-compatible expansion."}, "manual": {"status": "none", "detail": "No operator action is required."}, From 6c0c72902925ced0e1fd036904cb06a98924fb5e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:18:34 +0100 Subject: [PATCH 038/132] fix(missions): normalize bare writers before admission validation Preserve main acceptance of bare work actors for claim, renew, release and complete. Assert stale-generation refusal for alias-owned carried successors and retain literal-owner success coverage. Co-authored-by: OpenAI Codex agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions/src/store/missions.rs | 1 - crates/fractal-missions/src/store/tests.rs | 4 +- .../src/store/work_renew_tests.rs | 44 ++++++++++++++++++- release-notes/mission-agentid-canonical.json | 2 +- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index d6cab2bd267..5fc22a7c6c9 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -5923,7 +5923,6 @@ impl MissionsStore for Store { let actor = request.actor.as_deref().ok_or_else(|| { FractalError::new("missing-work-actor", "a work action needs an actor") })?; - validate_work_write_actor(actor)?; let requested_incarnation = request .incarnation .as_deref() diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index a370a278a21..e6005ebb413 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -9568,7 +9568,7 @@ fn a_revision_keeps_the_claim_on_a_carried_step() { } #[test] -fn carried_claim_successor_matches_legacy_and_canonical_holders() { +fn carried_claim_successor_requires_literal_ownership() { for actor in ["node/worker", "agent/node/worker"] { for holder in ["node/worker", "agent/node/worker"] { let store = Store::open_memory("node").unwrap(); @@ -9614,7 +9614,7 @@ fn carried_claim_successor_matches_legacy_and_canonical_holders() { ).unwrap(); if main_actor != holder { let error = store.work_action(old, "renew", &renew).unwrap_err(); - assert_eq!(error.code, "work-already-claimed"); + assert_eq!(error.code, "stale-run-generation"); assert_eq!( store.step_run(carried).unwrap().unwrap().claimant.as_deref(), Some(holder), diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index aa32dfd70ce..aa563043ef5 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -982,10 +982,52 @@ fn independent_work_capacity_uses_literal_ownership_for_admission() { } } +#[test] +fn bare_work_actors_claim_renew_release_and_complete_with_main_normalization() { + for bare_actor in ["node.worker", "worker"] { + for (action, expected_status) in [("release", "ready"), ("complete", "verifying")] { + let step = ready_step_with_mail(0); + let main_actor = smallclaims::store::normalize_actor(bare_actor, "agent"); + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h755979e290e9 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2 WHERE subject=?1", + params![step.subject, main_actor], + ).unwrap(); + let mut mutation = request("bare-claim", None); + mutation.actor = Some(bare_actor.into()); + let claimed = step.store.work_action(&step.subject, "claim", &mutation).unwrap(); + assert_eq!(claimed.status, "claimed"); + assert_eq!(claimed.claimant.as_deref(), Some(main_actor.as_str())); + assert_eq!(claimed.claim_incarnation, mutation.incarnation); + let claims = step.store.claims_for(&step.subject, Some("work.claimed")).unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].actor.as_deref(), Some(main_actor.as_str())); + assert_eq!(claims[0].body["fields"]["claimant"], json!(main_actor)); + + mutation.idempotency_key = "bare-renew".into(); + let renewed = step.store.work_action(&step.subject, "renew", &mutation).unwrap(); + assert_eq!(renewed.status, "claimed"); + assert_eq!(renewed.claimant.as_deref(), Some(main_actor.as_str())); + assert_eq!(renewed.claim_incarnation, mutation.incarnation); + assert!(renewed.claim_expires_at_unix_ms.unwrap() > now_ms()); + + mutation.idempotency_key = format!("bare-{action}"); + let finished = step.store.work_action(&step.subject, action, &mutation).unwrap(); + assert_eq!(finished.status, expected_status); + assert!(finished.claimant.is_none()); + assert!(finished.claim_incarnation.is_none()); + assert!(finished.claim_expires_at_unix_ms.is_none()); + assert_eq!(finished.worker_reported, action == "complete"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, expected_status); + assert!(persisted.claimant.is_none()); + } + } +} + #[test] fn work_claim_rejects_malformed_actor_without_claiming_work() { let step = ready_step_with_mail(0); - for (index, actor) in ["", "worker", "agent/", "/worker", "node/worker name"].iter().enumerate() { + for (index, actor) in ["", "agent/", "/worker", "node/worker name"].iter().enumerate() { let mut claim_request = request(&format!("malformed-actor-claim-{index}"), None); claim_request.actor = Some((*actor).into()); let error = step.store.work_action(&step.subject, "claim", &claim_request).unwrap_err(); diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json index 08ff2cc6dba..38ec8b9d108 100644 --- a/release-notes/mission-agentid-canonical.json +++ b/release-notes/mission-agentid-canonical.json @@ -3,7 +3,7 @@ "summary": "Emit canonical agent identities in mission presentation while preserving historical writer spelling", "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical host-relative qualification and malformed assignee normalization rather than rejecting them."}, "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, - "rules": {"status": "changed", "detail": "Work reads, renewal polling and presentation compare canonical identities. All mutations preserve main's literal actor matching, including ownership admission, eligibility, capacity, queue admission, carried-claim successor selection, output authority and ancestor lease extension. An unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed when the literal eligibility selector admits that actor, without changing claim state, publishing a mutation or extending the operational lease. Renewal, release and completion retain the same fence; successful child mutations extend only literally owned ancestor leases. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, + "rules": {"status": "changed", "detail": "Work reads, renewal polling and presentation compare canonical identities. All mutations preserve main's actor normalization and literal matching, including ownership admission, eligibility, capacity, queue admission, carried-claim successor selection, output authority and ancestor lease extension. Bare work actors such as node.worker normalize to agent/node.worker before validation and can claim, renew, release and complete literally assigned work. An unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed when the literal eligibility selector admits that actor, without changing claim state, publishing a mutation or extending the operational lease. Alias-owned carried claims do not redirect a mutation from a stale generation; literal owners still reach their carried successor. Successful child mutations extend only literally owned ancestor leases. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed slash-qualified claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, "compatibility": {"status": "changed", "detail": "Expand phase only: read both actor spellings and preserve the previous writer spelling, with no feature gate or capability advertisement. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership: a later spelling switch and stored-row migration require a durable one-way fleet fence. Once raw legacy-signed ownership facts exist, the historical reader floor must prohibit old readers that cannot admit those facts; a reversible capability check cannot establish that floor."}, "downtime": {"status": "none", "detail": "No downtime or stored-row migration is required for this read-compatible expansion."}, "manual": {"status": "none", "detail": "No operator action is required."}, From adcf3ce30567465ab742d9f4eb3a0b497cf283a3 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:51:11 +0100 Subject: [PATCH 039/132] test(missions): isolate literal lease and capacity parity fixtures Anchor ancestor leases before expiry and remove cancelled spontaneous fixture work from queue ordering. Refresh reviewed SQLite inventory through catalog APIs, retaining main coverage and adding or updating only logical source sites. Co-authored-by: OpenAI Codex agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions/src/store/missions.rs | 2 +- crates/fractal-missions/src/store/tests.rs | 15 +- .../src/store/work_renew_tests.rs | 6 +- docs/st3/large-value-sites.json | 306 ++++++++---------- 4 files changed, 143 insertions(+), 186 deletions(-) diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 5fc22a7c6c9..509acdc5ba5 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -621,7 +621,7 @@ fn renew_nested_ancestor_leases_tx( let expiry = now.saturating_add(600_000); transaction .execute( - /* sqlite-large-value: allow-for-now lv-heca706b36312 owner=fractal_missions::store::missions TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs + /* sqlite-large-value: allow-for-now lv-hdaafb8e69f1c owner=fractal_missions::store::missions TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_expires_at_unix_ms=?1, updated_at_unix_ms=?2 WHERE generation_id=?3 AND substr(?4, 1, length(step_path) + 1)=step_path || '/' diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index e6005ebb413..bf09fca6428 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -6412,7 +6412,7 @@ fn an_active_nested_lease_can_publish_its_parent_mission_output() { assert_eq!(output.revision, mission.revision); } -fn nested_work_ancestor_expiry_after_child_completion(holder: &str) -> u128 { +fn nested_work_ancestor_expiry_after_child_completion(holder: &str) -> (u128, u128) { let store = Store::open_memory("node").unwrap(); let source = r#"version 2 @@ -6483,13 +6483,14 @@ mission "nested-work" state="ready" { store .work_action(&child.subject, "claim", &request("claim-child")) .unwrap(); + let anchored_expiry = now_ms() + 60_000; store .connection .lock() .unwrap() .execute( - /* sqlite-large-value: allow-for-now lv-h414f4baa2072 owner=fractal_missions::store::tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_expires_at_unix_ms='0', lease_owner=?2 WHERE subject=?1", - params![parent.subject, holder], + /* sqlite-large-value: allow-for-now lv-h1c3e44d7086a owner=fractal_missions::store::tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_expires_at_unix_ms=?3, lease_owner=?2 WHERE subject=?1", + params![parent.subject, holder, anchored_expiry.to_string()], ) .unwrap(); @@ -6501,17 +6502,19 @@ mission "nested-work" state="ready" { assert_eq!(parent.status, "working"); assert_eq!(parent.claimant.as_deref(), Some(holder)); assert_eq!(parent.claim_incarnation.as_deref(), Some("current")); - parent.claim_expires_at_unix_ms.unwrap() + (anchored_expiry, parent.claim_expires_at_unix_ms.unwrap()) } #[test] fn nested_work_renews_the_literal_owners_ancestor_lease() { - assert!(nested_work_ancestor_expiry_after_child_completion("agent/node/worker") > now_ms()); + let (before, after) = nested_work_ancestor_expiry_after_child_completion("agent/node/worker"); + assert!(after > before); } #[test] fn nested_work_does_not_renew_an_alias_owned_ancestor_lease() { - assert_eq!(nested_work_ancestor_expiry_after_child_completion("node/worker"), 0); + let (before, after) = nested_work_ancestor_expiry_after_child_completion("node/worker"); + assert_eq!(after, before); } #[test] diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index aa563043ef5..deb381fa6dd 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -951,7 +951,9 @@ fn independent_work_capacity_uses_literal_ownership_for_admission() { if holder == "agent/node/worker" { assert_eq!(started.unwrap_err().code, "agent-capacity", "{actor} / {holder} / {status}"); } else { - assert!(started.unwrap().claimant.is_none()); + let started = started.unwrap(); + assert!(started.claimant.is_none()); + step.store.set_step_state(&started.subject, "cancelled", None).unwrap(); } let second = step.store.create_mission_run(&MissionRunRequest { mission: "capacity-second".into(), revision: None, workspace: "/tmp".into(), @@ -989,7 +991,7 @@ fn bare_work_actors_claim_renew_release_and_complete_with_main_normalization() { let step = ready_step_with_mail(0); let main_actor = smallclaims::store::normalize_actor(bare_actor, "agent"); step.store.connection.write().execute( - /* sqlite-large-value: allow-for-now lv-h755979e290e9 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2 WHERE subject=?1", + /* sqlite-large-value: allow-for-now lv-h34894d4454a8 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2 WHERE subject=?1", params![step.subject, main_actor], ).unwrap(); let mut mutation = request("bare-claim", None); diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 35495265431..736f9337a75 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -911,7 +911,6 @@ {"id": "lv-hb0dab8208622", "path": "crates/fractal-api-client-v0/src/api/client_v0/summary/tests.rs", "kind": "sql-template", "fingerprint": "017ac7c0df346114a4b1269321691194ab9c3e70f7eb3d133b74f85bf9121694", "owner": "fractal_api_client_v0::api::client_v0::summary::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 412, "excerpt": "INSERT INTO mission_definitions(mission_id,revision,state,claim_id) SELECT ?1,revision,state,claim_id FROM mission_definitions WHERE mission_id='summary-base'"}, {"id": "lv-h2154055540e0", "path": "crates/fractal-api-core/src/api.rs", "kind": "unresolved-sql-sink", "fingerprint": "66caa0a1e5551f3bffe59e941b868791720f2b2e6ef39c59a4518c528b2ab31c", "owner": "fractal_api_core::api", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 5455, "excerpt": "crate : : store : : RANGE_SUBJECTS"}, {"id": "lv-h2e85f6b23389", "path": "crates/fractal-api-core/src/api.rs", "kind": "unresolved-sql-sink", "fingerprint": "66caa0a1e5551f3bffe59e941b868791720f2b2e6ef39c59a4518c528b2ab31c", "owner": "fractal_api_core::api", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 2944, "excerpt": "crate : : store : : RANGE_SUBJECTS"}, - {"id": "lv-h1ceb045a5bba", "path": "crates/fractal-api-core/src/api/agents_publication_tests.rs", "kind": "sql-template", "fingerprint": "e846c20eb3e898f19682ca10d28077acad1a8755eac4c271436a5d7ec8ad731d", "owner": "fractal_api_core::api::agents_publication_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 29, "excerpt": "UPDATE claims SET kind='custom.attention.notice-created' WHERE id=?1"}, {"id": "lv-h2e12f8d6c4f9", "path": "crates/fractal-api-core/src/api/client_messages_cursor_tests.rs", "kind": "sql-template", "fingerprint": "e4e779b71d265dd0a0ea852e09808efa82d60d77c3e72484e7e8947db12dfef1", "owner": "fractal_api_core::api::client_messages_cursor_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 172, "excerpt": "DELETE FROM claims WHERE subject='message/cursor-binding-1'"}, {"id": "lv-he673279c56b5", "path": "crates/fractal-api-core/src/api/client_messages_cursor_tests.rs", "kind": "sql-template", "fingerprint": "ec3e4c158b44699f8b27ad0b0de0c1d9d5fef0bc9402a6be114e9449e9304c65", "owner": "fractal_api_core::api::client_messages_cursor_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 200, "excerpt": "DELETE FROM desired WHERE subject='message/cursor-legacy-1'"}, {"id": "lv-h09246809d018", "path": "crates/fractal-api-core/src/api/client_messages_selector_tests.rs", "kind": "sql-template", "fingerprint": "84b60e99be03935341e9e5576b1cfbd2c2166be2da92516602664318b2e3055d", "owner": "fractal_api_core::api::client_messages_selector_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 745, "excerpt": "DELETE FROM desired WHERE subject=?1"}, @@ -947,12 +946,10 @@ {"id": "lv-hb967428e5ded", "path": "crates/fractal-api-core/src/api/mailbox_tests_shared.rs", "kind": "sql-template", "fingerprint": "a466c1bb6842b49c1d34a00a24fdcb27190d6711850fee20c555c3c6c8bfc672", "owner": "fractal_api_core::api::mailbox_tests_shared", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 36, "excerpt": "UPDATE claims SET accepted_at_unix_ms=?1 WHERE id=?2"}, {"id": "lv-h17ed23f52569", "path": "crates/fractal-api-core/src/api/message_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "bbfac8f07b9a24bc278443aa0d496ef7bbd0019ed35cee7698f44b358102850e", "owner": "fractal_api_core::api::message_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 154, "excerpt": "[ ] , | row | row . get ( 0 )"}, {"id": "lv-he830e5bf3588", "path": "crates/fractal-api-core/src/api/message_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "4e49a931b39a60214de90775142d3b4e3ed67d6a15c71239636d86d2dd64c082", "owner": "fractal_api_core::api::message_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 150, "excerpt": "& format ! ( \"SELECT EXISTS(SELECT 1 FROM claims WHERE kind='daemon.started' AND json_extract(body,'$.fields.features.{MESSAGE_OFFSETS_FEATURE}')=1)\" )"}, - {"id": "lv-h16928b5eb896", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "c3a0532ab17a84e1ad0287749b26ea41efe293bb61b1c62a42a4093e94414017", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 320, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3 WHERE subject=?1"}, {"id": "lv-h336cf3012340", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "4daaf494993c5039a2c395c770fa18050981d44160ac7a9e2455155a50eb0fd3", "owner": "fractal_api_core::api::published_lists_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1678, "excerpt": "UPDATE claims SET accepted_at_unix_ms=CAST(CAST(accepted_at_unix_ms AS INTEGER)+400 AS TEXT) WHERE kind='step-run.state' AND json_extract(body,'$.fields.status')='completed'"}, {"id": "lv-h5326fa890870", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "0830a4e014a2f92f3cde33a3af36104c47cdd6cbbcaa1f79922de83c6bdf9fa6", "owner": "fractal_api_core::api::published_lists_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1651, "excerpt": "UPDATE claims SET accepted_at_unix_ms=CASE json_extract(body,'$.fields.status') WHEN 'working' THEN '1000' WHEN 'completed' THEN CAST(1000+?2 AS TEXT) ELSE accepted_at_unix_ms END WHERE subject=?1 AND"}, {"id": "lv-h5a9504380e4b", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "0a57095c5bf44350a4d3b0001f7f811cc665a4e720e998dfc88e00300ff68c38", "owner": "fractal_api_core::api::published_lists_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1715, "excerpt": "UPDATE claims SET accepted_at_unix_ms=CASE json_extract(body,'$.fields.status') WHEN 'working' THEN '1000' WHEN 'completed' THEN CAST(1000+?2 AS TEXT) ELSE accepted_at_unix_ms END WHERE subject=?1 AND"}, {"id": "lv-h832547b61b1f", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "ee5bc82da6f49b62f613cf8d7a8666426a9b294c94b59fd68c6fe53ad0bbdc82", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1700, "excerpt": "DROP TRIGGER run_eta_run_update; CREATE TRIGGER run_eta_run_update AFTER UPDATE ON mission_runs WHEN NEW.phase='terminal' OR OLD.phase='terminal' BEGIN INSERT OR IGNORE INTO local_run_eta_dirty SELECT"}, - {"id": "lv-hb7cf1dc01667", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "39c0ce46943adc6d3fa133a261008f77c00d77aa927d346dd8295ee44b6ef104", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 363, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms=?3 WHERE subject=?1"}, {"id": "lv-h18084a82300f", "path": "crates/fractal-api-core/src/api/replication_guard_tests.rs", "kind": "sql-template", "fingerprint": "c10e0cf4fe0b73347cfcb6da085067f1381609cc06e696d05b35addb31040074", "owner": "fractal_api_core::api::replication_guard_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 275, "excerpt": "DROP TABLE replica_envelope_holds"}, {"id": "lv-h85ba6985cde2", "path": "crates/fractal-api-core/src/api/replication_guard_tests.rs", "kind": "sql-template", "fingerprint": "fb53f45f34c425e53a75b2047afe076b5d4aedb8092b2c1db6e21644723c63f7", "owner": "fractal_api_core::api::replication_guard_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 187, "excerpt": "INSERT INTO replica_envelopes(writer,sequence,envelope_hash,accepted_at_unix_ms,payload,relay,receipt_state,received_at_unix_ms) VALUES('fixture',1,?1,'0',X'','fixture','pending','0')"}, {"id": "lv-ha2066549b853", "path": "crates/fractal-api-core/src/api/replication_guard_tests.rs", "kind": "sql-template", "fingerprint": "b708aad00db6c660b8c6f80f82ae68463637e72d7e50d6504ed42598315f087b", "owner": "fractal_api_core::api::replication_guard_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 63, "excerpt": "DROP TABLE replica_envelopes"}, @@ -961,7 +958,6 @@ {"id": "lv-h934301fb4a5c", "path": "crates/fractal-api-core/src/api/storage_contention_response_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "b2cbbe76784ce7e1e5c37479f1d38c960c8035dd74974ae04e0aad4649d0dc87", "owner": "fractal_api_core::api::storage_contention_response_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 40, "excerpt": "\"BEGIN IMMEDIATE\""}, {"id": "lv-h03a0c43d19d5", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "ce33aa5a176a18845d88f753dfc7e003040b9fd3982a677bc16a905e00a4a64d", "owner": "fractal_api_core::api::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 14060, "excerpt": "UPDATE operations SET state='conflict'"}, {"id": "lv-h04686002cc84", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "aa1c5e61e444bf29e0ae01706c12fca288de4f9341f71b2c80899e1c44f7a978", "owner": "fractal_api_core::api::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 13765, "excerpt": "UPDATE mission_runs SET status='cancelled' WHERE id=?1"}, - {"id": "lv-h18ab046ede43", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "d916eeef4f4b8829f02d4fc853091aba2e3090cfb6db676adf9f8ba0b214c6fa", "owner": "fractal_api_core::api::tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 13476, "excerpt": "INSERT INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES ('summary-notice','node',9000,'fixture','1700000000000'); INSERT INTO claims(id,batch_id,subject,kind,origin,body,predec"}, {"id": "lv-h1d40fe723c4f", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "2df63921cfb3b8303927c2ec8a88ff1e4e1a56b2c3f24fbbf6759713842fbbb8", "owner": "fractal_api_core::api::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 3791, "excerpt": "DROP TRIGGER retention_quick_failure"}, {"id": "lv-h26f127af3754", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "735b1cf1efde84eb56a6b51556d919c1ba918d036c0d85076ad9010bee50b1e1", "owner": "fractal_api_core::api::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 4904, "excerpt": "DROP TABLE stream_arrivals"}, {"id": "lv-h308873de51d3", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "4be7dd9858dd93888a5d96d42bbeb0eebf16b01cb2701822642914d042f740bc", "owner": "fractal_api_core::api::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 4167, "excerpt": "INSERT INTO meta(key,value) VALUES('event_resume_floor',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value"}, @@ -970,7 +966,6 @@ {"id": "lv-h57f7065baf33", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "2518538cec97d58c75b44207e845723ca0ce101b4a8ee4a02a853dadcf160618", "owner": "fractal_api_core::api::tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 9679, "excerpt": "UPDATE claims SET origin='zz-card-tie' WHERE batch_id=?1"}, {"id": "lv-h670107940355", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "f6465e0fa5099a3afc417d76901d68a88082111b078f2ebf1a82abf1dc11d34c", "owner": "fractal_api_core::api::tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 4277, "excerpt": "BEGIN; CREATE TABLE legacy_fixture_events(store_index INTEGER PRIMARY KEY,kind TEXT,subject TEXT,body TEXT); INSERT INTO legacy_fixture_events SELECT * FROM events; DROP VIEW events; ALTER TABLE legac"}, {"id": "lv-h6d012e85d16c", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "59e3e1882232d309144d537c0a9dd0256fca005a0157c6232fd373377eb76bc5", "owner": "fractal_api_core::api::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 2050, "excerpt": "crate : : store : : unread_mail : : OVERDUE_BY_RECIPIENT"}, - {"id": "lv-h7dc166900806", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "64fd5e02b89ad6757e2772d8bfa381a3f3a4bc414cc707362d0d3724ef8d5530", "owner": "fractal_api_core::api::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 13510, "excerpt": "INSERT INTO projection_digest_repaired_claims(id) VALUES (?1)"}, {"id": "lv-haddf27814762", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "6a56b27e0f7ce7793c3c0e2366fe6d0451725b855ddd0028476c27855e365277", "owner": "fractal_api_core::api::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1357, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?1 WHERE subject=?2"}, {"id": "lv-hc25e2957d9ce", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "0018a83873287d654b49832039f469dfc9f12e27355ceb5f02644f706f060b32", "owner": "fractal_api_core::api::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 3861, "excerpt": "DELETE FROM idempotency WHERE operation_id=?1"}, {"id": "lv-hc3f2f9c1c967", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "3185f60a78de16cf8a811e2bc94ba995def203dc795ee783b11417e21e0ca0f9", "owner": "fractal_api_core::api::tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 9676, "excerpt": "UPDATE claims SET accepted_at_unix_ms='1000' WHERE subject='agent/undeclared'"}, @@ -1076,7 +1071,6 @@ {"id": "lv-h7ddc6a187ef8", "path": "crates/fractal-delivery/src/store/conversation_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "164c42ef7ce746f494d87a239ebdca7a01b5bce8921d8fa88e3958dcd0676f04", "owner": "fractal_delivery::store::conversation_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 386, "excerpt": "[ before ] , | row | row . get : : < _ , u64 > ( 0 )"}, {"id": "lv-h803c3550faa3", "path": "crates/fractal-delivery/src/store/conversation_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "81c9e33d6cc2a1ae1ee602b0df0dd57bc40be64ad0879c29a20681f67a4145db", "owner": "fractal_delivery::store::conversation_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 530, "excerpt": "& format ! ( \"SELECT accepted_at_unix_ms FROM claims INDEXED BY claims_subject_kind_accepted_index WHERE subject=?1 AND kind='runtime.observed' AND json_type(body,'$.fields.incarnation_id')='text' AND"}, {"id": "lv-hbd706c4f7611", "path": "crates/fractal-delivery/src/store/conversation_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "690717ab3d1d69b8c59560000496d16865bb6a8400f9261166de314dfe6635a7", "owner": "fractal_delivery::store::conversation_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 451, "excerpt": "& format ! ( \"{LOCAL_OBSERVATION_COLUMNS} INDEXED BY local_observations_subject_kind_index WHERE subject=?1 AND kind='harness.diagnostic' AND after_store_index > ( 1 0 ) ? , row . get : : < _ , u64 > ( 1 1 "}, - {"id": "lv-h16daeb7e7a0e", "path": "crates/fractal-evidence/src/store/evidence.rs", "kind": "unresolved-sql-sink", "fingerprint": "859af7c67269885a5dc98951007e55b4e941c8e7bc612fa15872caa7e5749bcc", "owner": "fractal_evidence::store::evidence", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 435, "excerpt": "& format ! ( \"SELECT DISTINCT subject, origin, owner FROM {source}\" )"}, {"id": "lv-h6706f1613b0b", "path": "crates/fractal-evidence/src/store/evidence.rs", "kind": "unresolved-sql-sink", "fingerprint": "c78ea1be1f18e9de24532faffd7555fbe47ef2ec7ba9c683028007fc6d146f37", "owner": "fractal_evidence::store::evidence", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 351, "excerpt": "& canonical_sql ( & format ! ( \"WITH current_request AS MATERIALIZED ( SELECT request.subject, request.batch_id, request.store_index, request_batch.replica_sequence AS sequence, {request_position} AS "}, - {"id": "lv-hb6ab7e0bb7a5", "path": "crates/fractal-evidence/src/store/evidence.rs", "kind": "unresolved-sql-sink", "fingerprint": "c30998f21fc2bd0bdf8c58659108c602b8ce2d68128c7548a456a108b846e349", "owner": "fractal_evidence::store::evidence", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 514, "excerpt": "& canonical_sql ( \"SELECT id, store_index, batch_id, subject, kind, origin, actor, body, predecessors, accepted_at_unix_ms FROM claims WHERE kind='gate.requested' AND json_extract(body, '$.fields.owne"}, {"id": "lv-hc89d14431aec", "path": "crates/fractal-evidence/src/store/evidence.rs", "kind": "unresolved-sql-sink", "fingerprint": "bca7989750cd723990b2fae9f67281bb815b22e9a749ddeab27d6a03ec3297d9", "owner": "fractal_evidence::store::evidence", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 527, "excerpt": "& canonical_sql ( & format ! ( \"SELECT request.id, json_extract(request.body, '$.fields.owner'), {} FROM claims request INDEXED BY claims_human_gate_request_index WHERE request.kind='gate.requested' A"}, + {"id": "lv-hb6ab7e0bb7a5", "path": "crates/fractal-evidence/src/store/evidence.rs", "kind": "unresolved-sql-sink", "fingerprint": "c30998f21fc2bd0bdf8c58659108c602b8ce2d68128c7548a456a108b846e349", "owner": "fractal_evidence::store::evidence", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 514, "excerpt": "& canonical_sql ( \"SELECT id, store_index, batch_id, subject, kind, origin, actor, body, predecessors, accepted_at_unix_ms FROM claims WHERE kind='gate.requested' AND json_extract(body, '$.fields.owne"}, {"id": "lv-he1c4046f31f1", "path": "crates/fractal-evidence/src/store/evidence.rs", "kind": "unresolved-sql-sink", "fingerprint": "d8d6e97dd09e58071a4a5ce2236219d0418dbf377b3d48af03a53adc58540ca0", "owner": "fractal_evidence::store::evidence", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 549, "excerpt": "& canonical_sql ( \"SELECT body FROM claims INDEXED BY claims_harness_auth_incarnation_index WHERE subject=?1 AND kind='harness.observed' AND json_extract(body, '$.fields.incarnation_id')=?2 AND json_t"}, {"id": "lv-h99958058d413", "path": "crates/fractal-evidence/src/store/owned_sets.rs", "kind": "unresolved-sql-sink", "fingerprint": "a9709a4943b57fe0c2ca4e0c133e2443f1c14c00364b7fdc6b010f89f6cec7c1", "owner": "fractal_evidence::store::owned_sets", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 160, "excerpt": "STAGED_SUBJECTS_QUERY"}, {"id": "lv-h05344f98e859", "path": "crates/fractal-evidence/src/store/owned_sets_tests.rs", "kind": "sql-template", "fingerprint": "213cec2e91e4c3178d12187013806b91a5557b7cc08e46780ec767bbcb655dd2", "owner": "fractal_evidence::store::owned_sets_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 341, "excerpt": "UPDATE replica_records SET state='invalid' WHERE record_ref=?1"}, @@ -1430,6 +1423,7 @@ {"id": "lv-hd25122d3a8e3", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "4d05fbde9b6c96ddb1359bdc85eaefc3bc4722b0cbe7a8be94bc83d5ddcbe77f", "owner": "fractal_missions::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6587, "excerpt": "TERMINAL_OWNED_RUNTIME_SUBJECTS"}, {"id": "lv-hd3c47451793e", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "f693948f475717b76f002971be13b759fa28f414a0cac94114534848af02dea7", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 4352, "excerpt": "INSERT INTO step_runs(subject, run_id, generation_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, blocked_reason, not_before_unix_ms,"}, {"id": "lv-hd664a42b8778", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "3697661830d53f3e1a91050af1aa97bd2d155632d2f329cece60aea2b2515c9b", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 3289, "excerpt": "UPDATE revision_proposals SET approvals=?2, status=?3, updated_at_unix_ms=?4 WHERE id=?1"}, + {"id": "lv-hdaafb8e69f1c", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "32341188f85d60d9096f994a222ebfb32b9d57bf459b421111239cc1a0ee05fc", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 473, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?1, updated_at_unix_ms=?2 WHERE generation_id=?3 AND substr(?4, 1, length(step_path) + 1)=step_path || '/' AND status IN ('claimed','working','verifying')"}, {"id": "lv-hdd12cc78c9f2", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "464ad1ad68d82b99fbd4c3ba5d668e93c24f9820f3e6a97a730875066684f0df", "owner": "fractal_missions::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 7725, "excerpt": "[ claim ] , | row | row . get : : < _ , u64 > ( 0 )"}, {"id": "lv-hdedd742978e7", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "c32b884bb11aa9d33ad9fa5b6cbd7b18be6b5eec40e5397aa0319a5e4069ab0d", "owner": "fractal_missions::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 4576, "excerpt": "& sql"}, {"id": "lv-hdf6560483fe1", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "93f196172a3d88c05739f52fb29db0e6585d32eeafaec73208a6ec26ecc94824", "owner": "fractal_missions::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 7706, "excerpt": "Self : : SCHEDULE_ACTIVE_STARTED_RUN_QUERY , [ subject ] , | row | row . get ( 0 ) ,"}, @@ -1437,7 +1431,6 @@ {"id": "lv-hdfd4341fc0fd", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "4f28a46cee6c796b42c87f208cec878d6c74b1c8a24a7b0896391188c5480ea4", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 4474, "excerpt": "UPDATE revision_proposals SET status='applied', successor_generation_id=?2, updated_at_unix_ms=?3 WHERE id=?1 AND source_generation_id=?4 AND status IN ('pending-approval','draining')"}, {"id": "lv-he916625b0ffb", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "4bfe213ff4d47a4c18cc7e89048bf8c092e3dbe240f02c8af07b4fb23e5289e5", "owner": "fractal_missions::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 4618, "excerpt": "& format ! ( \"SELECT claims.subject, claims.accepted_at_unix_ms, claims.actor, json_extract(claims.body, '$.fields.status'), json_extract(claims.body, '$.fields.phase'), json_extract(claims.body, '$.f"}, {"id": "lv-he9a920ead8a9", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "ad698369cc3b79c3e7633a3b21b63ac7fb277e328a1207caf2dc4c4f8a963291", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 3859, "excerpt": "UPDATE mission_runs SET status=?2, updated_at_unix_ms=?3 WHERE id=?1"}, - {"id": "lv-heca706b36312", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "e6f237c06db774da6bdad7b00930d02dc570d8e73dd88639ec4bc50faa21701e", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 624, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?1, updated_at_unix_ms=?2 WHERE generation_id=?3 AND substr(?4, 1, length(step_path) + 1)=step_path || '/' AND status IN ('claimed','working','verifying')"}, {"id": "lv-hed370910e4dc", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "a235ba9c8851455e4b23a32ca7ab6126af600f6fd3ff3cb7e4e5c1fe22c0fa3f", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1991, "excerpt": "UPDATE step_runs SET status=?2, blocked_reason=?3, lease_owner=CASE WHEN ?2 IN ('ready','completed','failed','cancelled') THEN NULL ELSE lease_owner END, lease_incarnation=CASE WHEN ?2 IN ('ready','co"}, {"id": "lv-hed588df2c739", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "0f91e4c4f25341b89b03f7d3dbfb061ca1eebc0d71499f4f8800ad4fcde40e0a", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 2487, "excerpt": "DELETE FROM planning_previews WHERE session_id=?1 AND variant=?2"}, {"id": "lv-hf213e6d27f03", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "5d3baa4cb1a6ebdc4527226258f80f2f07dfd1bd8aa4ff43204d68c70a101ee7", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 3384, "excerpt": "INSERT INTO idempotency(operation_id, response) VALUES (?1, ?2)"}, @@ -1521,7 +1514,6 @@ {"id": "lv-h304e314668e9", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "included-source", "fingerprint": "46e2dcc63cca57df9b9c4533f344589ce44e55f46b3a7e8cabef39febb1e62b4", "owner": "fractal_missions::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 10818, "excerpt": "include ! ( \"step_timing_purity_tests.rs\" )"}, {"id": "lv-h30c50ff23c8b", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "6f91a3b453029ae6a500cfc8de88f46e6cd25f8d1b18959c2b6938bad5a283d2", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 636, "excerpt": "UPDATE mission_runs SET created_at_unix_ms='1' WHERE id=?1"}, {"id": "lv-h32bc64f4521e", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "included-source", "fingerprint": "698c3fe0fd96fd53f04a1b7b67a61f29699904a70d72b3653c27522728148df8", "owner": "fractal_missions::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 10852, "excerpt": "include ! ( \"run_capacity_tests.rs\" )"}, - {"id": "lv-h414f4baa2072", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "109ff8f6d5e636d774131ecfa96a2ed314788773df553c3a050fbff05a493496", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6493, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms='0', lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h43d796c2dd69", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "0b82350e36f636c2b118f45ba1e68d29f5c8cc5e78a58325614326eaca56f9a4", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 742, "excerpt": "DELETE FROM step_runs WHERE run_id=?1"}, {"id": "lv-h44b0b8d8add9", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "c28134b757fe9961386a5a12b320aacd6b2e59d25b6e17b2fcb19ad5a79fc677", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6960, "excerpt": "CREATE TRIGGER retention_receipt_failure BEFORE INSERT ON idempotency BEGIN SELECT RAISE(ABORT,'injected lost receipt'); END;"}, {"id": "lv-h48c97b07300c", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "c657f8e081eea0e5dab78e468952abad110b99276c6078eaf1bab639d117932b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 511, "excerpt": "INSERT INTO mission_runs SELECT 'page-other',mission_id,initial_revision,current_generation_id, root_revision,'another-root',parent_step_run,workspace,requester,inputs, mode,status,phase,created_at_un"}, @@ -1541,7 +1533,6 @@ {"id": "lv-h72d6e3ed93eb", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "5a4e2ef7b21a990501bea000d92dead38be8083c4ac3584c2caaf4cec8627b34", "owner": "fractal_missions::store::tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 4754, "excerpt": "UPDATE claims SET accepted_at_unix_ms=CAST(1000 + (store_index * 37) % 97 AS TEXT) WHERE subject=?1"}, {"id": "lv-h734b678e7a1c", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "e4028c3dc59d813bc4515d1183567d96f2431fb254746d291d206bb85e106b21", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6703, "excerpt": "DROP INDEX claims_incarnation_accepted_index"}, {"id": "lv-h75b2000582ae", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "fd27a7c43de3b713d97f0b9b3e90aaadba6a838684bab417568b650e78add6ed", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 4681, "excerpt": "UPDATE meta SET value='old-registry' WHERE key='projection_digest_registry'"}, - {"id": "lv-h76038fa53ef8", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "e21446038f569a6eec8b9b9a7e8201f454cd571dd47d6c8c72dedd6f6cbfbb2b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 9587, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker', lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h774c911bd0f7", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "d7884c2e571cd3e6bd0aca90f04cc3f630e00c81e473bc432744ad252550abfe", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 151, "excerpt": "INSERT INTO operations(id, request_digest, canonical_claim_id, state) VALUES ('op/kept', 'digest', 'claim/kept', 'active')"}, {"id": "lv-h776a38ab964c", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "b264fff65d4e828306ecb878244ab64249f5f7ab8876f2060656c9133f5532b2", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 748, "excerpt": "UPDATE mission_runs SET created_at_unix_ms='50' WHERE id=?1"}, {"id": "lv-h7d84468a5931", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "dd2b5f9f07e0760d8d29d36a0234e46940679161a2bccaeec3851ef4253997eb", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6922, "excerpt": "UPDATE idempotency SET expires_at_unix_ms=0"}, @@ -1559,6 +1550,7 @@ {"id": "lv-h917b0134e9be", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "48f1b7c24add202c1cf9e35ef1961992661db84a55e1219d962b59c8479c51cd", "owner": "fractal_missions::store::tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 8876, "excerpt": "UPDATE claims SET accepted_at_unix_ms=?2 WHERE id=?1"}, {"id": "lv-h9188e4d7bebf", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "670380e6c1568143746eefd5717d30685a73d61436c5663c11ede8fb9d9aec6f", "owner": "fractal_missions::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6624, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {query}\" )"}, {"id": "lv-h938e543b607c", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "bc3a19d4b04ac6b7f25ca1f53e1ad449235053e7b54017e059497a64f988c393", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 283, "excerpt": "UPDATE step_runs SET status='working', lease_owner='agent/tree/worker', lease_incarnation='expired', lease_expires_at_unix_ms='0' WHERE subject=?1"}, + {"id": "lv-h1c3e44d7086a", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "51cc0656fb3569edd52d628ebfaf71a0d8cc9b159da6fb3069aaaf55b3cdc1a5", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6492, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?3, lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h9d1a4529329e", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "6d61bea5a65b90a987f743b4c9f0213339c5f2cbd9bacc5fe59f068d39962839", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 5643, "excerpt": "UPDATE mission_runs SET status='running', phase='normal' WHERE id='{}'; UPDATE run_generations SET status='running' WHERE id='{}'; UPDATE step_runs SET status='ready', blocked_reason=NULL WHERE subjec"}, {"id": "lv-ha39be727e750", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "1ba4fdc8a1dd62bd2c17727a28457739c7e5ece1cdea20fcb2f65af8bd592bf3", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 2792, "excerpt": "DELETE FROM operations WHERE id='op/unrelated-upgrade'"}, {"id": "lv-ha816dc01de1b", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "312c623079b440c8ed74533c88d167d2bfa28e6abf746367894219a8345bd762", "owner": "fractal_missions::store::tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 4585, "excerpt": "UPDATE replica_records SET state='repaired' WHERE claim_id=?1"}, @@ -1591,28 +1583,15 @@ {"id": "lv-h8fe8d15d1c36", "path": "crates/fractal-missions/src/store/work_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "78c31fdd20e68f2bfc8f505c81c122979627b8f993537622ba3195ae8d1aed55", "owner": "fractal_missions::store::work_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 290, "excerpt": "REPAIRED_SINCE"}, {"id": "lv-hb16ae5c869ea", "path": "crates/fractal-missions/src/store/work_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "43e32be2fa928193cbeb175d2a9abb1d55f1a8848755a28b58c8ed362456aa58", "owner": "fractal_missions::store::work_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 229, "excerpt": "params ! [ subject , time . min ( i64 : : MAX as u128 ) as i64 ] , | row | row . get : : < _ , Option < i64 > > ( 0 )"}, {"id": "lv-hd9f7e7a219ce", "path": "crates/fractal-missions/src/store/work_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "be824f83b40fb6e1bfa945556ae9be449d492a6e6edd8c5fec6f70c7ce2522da", "owner": "fractal_missions::store::work_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 104, "excerpt": "published_list : : CLAIMS_SINCE"}, - {"id": "lv-h2102123ed04c", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 763, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, - {"id": "lv-h28928374ca14", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1d84c831a4aa0ece376bf0e40064fb0bd36cd58e7f04211c859df103c1e40e8c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 768, "excerpt": "UPDATE step_runs SET status=?2 WHERE subject=?1"}, - {"id": "lv-h2bd292359425", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "0e79e608b8e2a3edcfdb97783f9b822002419792a6c9e69c075b28468577594c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 590, "excerpt": "UPDATE step_runs SET assignee='node/worker' WHERE subject=?1"}, - {"id": "lv-h48c2f2d4b497", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 853, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-h4d5ea12d429d", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "93efaf2218bc4b565e8983f0bf5d5283aab054949fc8a85b2c8b6f8bd476244b", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 113, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms='0' WHERE subject=?1"}, {"id": "lv-h574254113134", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1ab5df7968f742b4058aafe38de481b8bac0ae283ff5d18ac035eb8f6db6352b", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 130, "excerpt": "UPDATE step_runs SET status=status,worker_reported=worker_reported, lease_owner=lease_owner,lease_incarnation=lease_incarnation, blocked_reason=blocked_reason,readiness_epoch=readiness_epoch, lease_ex"}, {"id": "lv-h5c4c48063d7f", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "5b561fcc182b1026f663954fd63a044a0fbbfb912c11dfc59ba6402df925f799", "owner": "fractal_missions::store::work_renew_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 287, "excerpt": "query , [ subject ] , | row | row . get ( 0 )"}, {"id": "lv-h70d2d054ccd8", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d564ed6ae137d4958c7f0751a4a1e5eac72c732ba498342d49bb3f7f9e06849c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 350, "excerpt": "UPDATE step_runs SET status=?2,lease_expires_at_unix_ms=?3 WHERE subject=?1"}, {"id": "lv-h72871ed13568", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1d84c831a4aa0ece376bf0e40064fb0bd36cd58e7f04211c859df103c1e40e8c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 367, "excerpt": "UPDATE step_runs SET status=?2 WHERE subject=?1"}, - {"id": "lv-h755979e290e9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 523, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, - {"id": "lv-h78e96a5b9d99", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 883, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, - {"id": "lv-h7d7fcc2328ee", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d35271b78634a314d43a3538f1037107387d271a4fa449cfd3f69afad27e28ac", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 860, "excerpt": "UPDATE step_runs SET lease_owner=?2, status=?3 WHERE subject=?1"}, - {"id": "lv-ha0cd354483f3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 806, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, {"id": "lv-haa4a50f8ccd9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "5a9caadd18a47cc8540d557e3d5eead5e9f21cf15b57a415bfb07cfeddbc56b6", "owner": "fractal_missions::store::work_renew_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 322, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {}\" , last_replicated_lease_query ( ) )"}, - {"id": "lv-hac330e1ef0a3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "23d384584d619edc1b8135734fe4536e80b585db990943fa67e8dc9b10b08fa9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 620, "excerpt": "UPDATE step_runs SET assignee='agent/node/other', available_to='[]', status='ready', lease_owner=NULL, lease_incarnation=NULL, lease_expires_at_unix_ms=NULL WHERE subject=?1"}, {"id": "lv-haebfd1e4985b", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "004b63826474bc335db8f27b5d87f4a6dff9a32f4303f80ce1d7e3f10e5bee0d", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 138, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?2, updated_at_unix_ms=?3 WHERE subject=?1"}, - {"id": "lv-hbbb772f82848", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "f5a08f92b6484e142bfc0fac291d42996b27093a57f64739f414596a86da5406", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 644, "excerpt": "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1"}, {"id": "lv-hc363ee66ca25", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "cb91d38d30c6a413535ed4934c5d9f94b13f9574b4b2de6d6f1ba51b8f0156f9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 121, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?2 WHERE subject=?1"}, {"id": "lv-hca69050f62ee", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "9e2feeb2aa93b65606d10e7f78b269d681c421de488d083225519f9c3391dcf6", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 92, "excerpt": "CREATE TEMP TRIGGER quiet_renewal_state_guard AFTER UPDATE OF status,worker_reported,lease_owner,lease_incarnation, blocked_reason,readiness_epoch ON main.step_runs BEGIN SELECT RAISE(ABORT,'quiet ren"}, - {"id": "lv-hd38e77dd6432", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 756, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, - {"id": "lv-hd60f12cbb0cd", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "2c428368447719337e62bf6baa77578a1f95a5cda24f8fab6b5471192613c58e", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 706, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1"}, - {"id": "lv-hd700e0550a21", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 799, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, {"id": "lv-hece51d8edfe6", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "814ab2a3cd084839306d0a64185834c1925e530ec0a6b011591a7ee49f119288", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 125, "excerpt": "UPDATE mission_runs SET status='cancelled',phase='terminal' WHERE id=( SELECT run_id FROM step_runs WHERE subject=?1)"}, {"id": "lv-h3e591421c1da", "path": "crates/fractal-missions/src/store/work_summaries_ivm_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "c32b884bb11aa9d33ad9fa5b6cbd7b18be6b5eec40e5397aa0319a5e4069ab0d", "owner": "fractal_missions::store::work_summaries_ivm_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 266, "excerpt": "& sql"}, {"id": "lv-h468284965bf5", "path": "crates/fractal-missions/src/store/work_summaries_ivm_tests.rs", "kind": "sql-template", "fingerprint": "3466bf5d4a1eb7ff20ea582d75c74ba8f224a1d7139e34c76ec821707145b2f2", "owner": "fractal_missions::store::work_summaries_ivm_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 177, "excerpt": "CREATE TEMP TABLE summary_writes(key TEXT); CREATE TEMP TRIGGER summary_insert AFTER INSERT ON local_work_summaries BEGIN INSERT INTO summary_writes VALUES(NEW.key); END; CREATE TEMP TRIGGER summary_u"}, @@ -1883,7 +1862,6 @@ {"id": "lv-h7c82318fe7b4", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "sql-template", "fingerprint": "93efaf2218bc4b565e8983f0bf5d5283aab054949fc8a85b2c8b6f8bd476244b", "owner": "fractal_seats::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1740, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms='0' WHERE subject=?1"}, {"id": "lv-h8b98cc1bafed", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "4fcb25dbe329c93aeeddf9db8221029cc4d3964107298dd3ca3951a13a96e8f9", "owner": "fractal_seats::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 224, "excerpt": "params ! [ later , \"agent/node.amber\" , \"harness.observed\" ]"}, {"id": "lv-h95fd456b11a2", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "35b3b55dc0d41a833a72f4009dfed3434aa87154d6b9b83da24b2d728d42ad32", "owner": "fractal_seats::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1433, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {sql}\" )"}, - {"id": "lv-h9e9e37dcf663", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "sql-template", "fingerprint": "e846c20eb3e898f19682ca10d28077acad1a8755eac4c271436a5d7ec8ad731d", "owner": "fractal_seats::store::tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1194, "excerpt": "UPDATE claims SET kind='custom.attention.notice-created' WHERE id=?1"}, {"id": "lv-ha43c88827ede", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "sql-template", "fingerprint": "8592ccd245fbc41cfded1314364b25028eac374d1a08ab4cc1317e45b4352519", "owner": "fractal_seats::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 2213, "excerpt": "INSERT OR REPLACE INTO desired(subject, kind, revision, claim_id, body, owner_run, owner_generation, owner_step) VALUES (?1, 'agent', 'revision', ?2, '{}', 'mission-run/example', 'run-generation/examp"}, {"id": "lv-hac292d5d4ef7", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "3edada985fa7f0ed998965d239aab79abb45b29ef59bbe850c29f4bb5ebee977", "owner": "fractal_seats::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 722, "excerpt": "& format ! ( \"SELECT COUNT(*) FROM {table}\" ) , [ ] , | row | row . get : : < _ , u64 > ( 0 )"}, {"id": "lv-hc34c5aa42a3f", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "sql-template", "fingerprint": "abc4ab26de87f589be9a3eb9674cb20096fe16e79eb4431c5e4d8734d0d4f0f5", "owner": "fractal_seats::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 217, "excerpt": "INSERT INTO local_observations(after_store_index, subject, kind, body, observed_at_unix_ms) VALUES (?1, ?2, ?3, '{}', 1)"}, @@ -1975,7 +1953,7 @@ {"id": "lv-h2080eb040134", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "8edb630b01a1f78d4abc5ee08b714ce7a8578b35d2269aec45684bf19ac1e4fc", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 3861, "excerpt": "& query"}, {"id": "lv-h20950ed4b5dc", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "5d869aba23919ebabbf18885e6d7deebf69a7298969b72ec41ca59a8ecc6015c", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 20254, "excerpt": "& work_wake_history_query ( complete )"}, {"id": "lv-h20ede67dacaa", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "6d5b306c813337790dcc59d3ea0244058138d994c293b1d1b62b4d21f61d94ca", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1608, "excerpt": "params ! [ subject , store_index ] , | row | row . get : : < _ , u64 > ( 0 )"}, - {"id": "lv-h21c970c2c1f9", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "1b637695de7f89e7a97de09938a02b7c142cd482bbdda072e453c4d426a91acb", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12127, "excerpt": "[ index ] , | row | { let kind : String = row . get ( 0 ) ? ; if exclude_reserved & & fractal_schema : : notice : : is_reserved_kind ( & kind ) { return Ok ( None ) ; } Ok ( Some ( ( kind , row . get "}, + {"id": "lv-h518e0bfb7ccb", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "cb1e349434ed46ec91aff46ff49e260a10ee9290d17983beff6d16108dcda3c4", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 11867, "excerpt": "& format ! ( \"SELECT claims.id, claims.origin, claims.kind FROM claims INDEXED BY claims_actual_accepted_index JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND +claims.store_inde"}, {"id": "lv-h22c8c670811c", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "9630d07c20f6a6d1e638ea3b963e3b7d598498adadaf17e78ad3b0a2df790033", "owner": "fractal_store::store", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 754, "excerpt": " -- An unmanaged member's staging check needs tagged claims, not its runtime history. CREATE INDEX IF NOT EXISTS claims_owned_set_subject_index ON claims(subject) WHERE json_extract(body,'$.owned_set'"}, {"id": "lv-h26214094cec7", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "a07b2af24ca5ab021fe4b21e8353c58beb343552ea83e0a6b54c50510d16194e", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 10422, "excerpt": "UPDATE step_runs SET updated_at_unix_ms=?2, activated_at_unix_ms=CASE WHEN ?3='ready' THEN ?2 ELSE activated_at_unix_ms END WHERE subject=?1"}, {"id": "lv-h26512c176d95", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "c40c825e3f560b060d1f8d9c67e790832cd2ec75806dde3bb03626c07da15aee", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 17987, "excerpt": "UPDATE step_runs SET assignee=?2, available_to='[]', status='ready', lease_owner=NULL, lease_incarnation=NULL, lease_expires_at_unix_ms=NULL, readiness_epoch=?3, updated_at_unix_ms=?4 WHERE subject=?1"}, @@ -2031,7 +2009,6 @@ {"id": "lv-h50306fbf8508", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "6c51917a0509a1edc88aca17296d8e754b40f9766cd2895fff36fd4680e263d6", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 4512, "excerpt": "RANGE_SUBJECTS"}, {"id": "lv-h5099779308a4", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "e40f770d841a03febb76d845324deb479325d26a8c367a22b814de369fa02251", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 5783, "excerpt": "\"WITH operations AS ( SELECT json_extract(body, '$.fields.entry_id') AS entry_id, store_index, 0 AS local_id FROM claims WHERE subject=?1 AND kind='harness.timeline' AND json_extract(body, '$.fields.i"}, {"id": "lv-h5131cd171242", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "3a892e114b8aea39d929eeda4e156626825d4b6ebcad03a13b221d1433939c0d", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 9479, "excerpt": "DELETE FROM planning_previews"}, - {"id": "lv-h518e0bfb7ccb", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "cb1e349434ed46ec91aff46ff49e260a10ee9290d17983beff6d16108dcda3c4", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 11867, "excerpt": "& format ! ( \"SELECT claims.id, claims.origin, claims.kind FROM claims INDEXED BY claims_actual_accepted_index JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND +claims.store_inde"}, {"id": "lv-h51b2d53c0d8e", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "3efb54465bc9e7eccb1a33822e3a8eaba947f911671590be6d7df0d79b29c913", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 10135, "excerpt": "UPDATE revision_proposals SET created_at_unix_ms=?2,updated_at_unix_ms=?2 WHERE id=?1"}, {"id": "lv-h556b74d6044a", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "9579ec05a5dc2b199e9dbe717739274cad8dad7c65bb2a0360fad919917a01ac", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 7119, "excerpt": "INSERT INTO planning_sessions(id, mission_id, request_ref, workspace, requester, planner, planner_spec_json, status, target_run_id, source_generation_id, created_at_unix_ms, updated_at_unix_ms) VALUES"}, {"id": "lv-h577a313aef90", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "fcd340f019e01b1084c66128d855aac6f8c297b51328505b86da79b9526ea125", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 11242, "excerpt": "params ! [ subject , at_index , incarnation_id , runtime_accepted_at ] , | row | { row . get ( 0 ) }"}, @@ -2067,12 +2044,12 @@ {"id": "lv-h736179f9e933", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "765302eb1653067e32e1b2e9e1e1d0d92fc31adac3e655b9ce4f6e9a8b27c8fa", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 9483, "excerpt": "& canonical_sql ( \"SELECT id FROM claims WHERE kind >= 'planning-session.' AND kind < 'planning-session/' ORDER BY CANONICAL_ASC(claims)\" ) ,"}, {"id": "lv-h74692d77666a", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "8624082b5e0f09aa2298a5b1fdc505ec159dafabe14ec8c40a9c1bd3e1edcb5f", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12370, "excerpt": "message_canonical_keys_query ( )"}, {"id": "lv-h74be3b2acae0", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "5b3852d461286654dc6240e77ed7e1f4464ab067c301f26b4c6fc63846cc88b4", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 14450, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?5, updated_at_unix_ms=?6 WHERE subject=?1 AND attempt=?2 AND lease_owner=?3 AND lease_incarnation=?4 AND status NOT IN ('completed','failed','cancelled')"}, - {"id": "lv-h7554008ab79c", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "692b891eaf4f608d7adcbc24e4f06d36a27ed830efcde23ff111aa095833860d", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12016, "excerpt": "& format ! ( \"SELECT claims.store_index FROM claims INDEXED BY claims_subject_kind_index JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND {} AND claims.store_index<=?2 ORDER BY {"}, {"id": "lv-h75681bdb4431", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "f11929340270a16d3eaab3c7795e06e30950976c771ce9a1cac08e0a5ec0964e", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 17244, "excerpt": "INSERT INTO mission_definitions(mission_id, revision, state, claim_id) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(mission_id) DO UPDATE SET revision=excluded.revision, state=excluded.state, claim_id=excluded"}, {"id": "lv-h7699a71dcd39", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "69a19bd7c2c484a9c5c2b150e5ce2b93c16ff0e35862c1ec418962f5e34afb58", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 10329, "excerpt": "[ subject ] , | row | row . get : : < _ , bool > ( 0 )"}, {"id": "lv-h7792d6e9101c", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "2a49e323a00aca0a97892bbbfe9620a12305f49b66a9b10c77d491ec9909b30e", "owner": "fractal_store::store", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 1435, "excerpt": "CREATE INDEX IF NOT EXISTS claims_runtime_id_subject_index ON claims({RUNTIME_ID_FIELD}, subject, store_index) WHERE {ACTUAL_STATE_CLAIM} AND typeof({RUNTIME_ID_FIELD})='text'; CREATE INDEX IF NOT EXI"}, {"id": "lv-h78f223f76c65", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "11962b6d1a71b779748eeb516ee88832780f3b025fe316441be02891568d703f", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6144, "excerpt": "params ! [ subject , i64 : : MAX , incarnation ] , | row | row . get ( 1 )"}, {"id": "lv-h78f4c9ab8fb0", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "990cbb165a23077232f26d13723754e956c38eced662ebabe920afb0d68c90fd", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 18764, "excerpt": "& canonical_sql ( \"SELECT body FROM claims WHERE subject=?1 AND kind='harness.observed' AND accepted_at_unix_ms<=?2 ORDER BY CANONICAL_DESC(claims)\" , )"}, + {"id": "lv-hf01b6d5cecf5", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "95923a5d49ab1c3c1f6b3254e6cf3ccafb8ca33833d9566fa5e90626612d746e", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 19298, "excerpt": "params ! [ spellings , run_id ] , | row | { Ok ( ( row . get : : < _ , Option < i64 > > ( 0 ) ? , row . get : : < _ , Option < String > > ( 1 ) ? , ) ) }"}, {"id": "lv-h7a51a72fe942", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "5542c3e7a032e3b8b126e4443bd59273c5a4cfc2a55567386e029f3f699f88ec", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 15905, "excerpt": "DELETE FROM {table}"}, {"id": "lv-h7a7c49285515", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "61ef3ba33fe51d857a26e15573438b3e3ed572382f15b716727c8a0972baa3f9", "owner": "fractal_store::store", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 2220, "excerpt": "CREATE TABLE IF NOT EXISTS mission_run_deadlines ( run_id TEXT PRIMARY KEY REFERENCES mission_runs(id), timeout_ms INTEGER NOT NULL, deadline_at_unix_ms TEXT NOT NULL ); PRAGMA user_version = 11;"}, {"id": "lv-h7ab5e25ccad4", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "0cafdab7267f9a1864fff125cdd4e115b680ea889c9d6dd31ffe1a9bb48b97c1", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 7640, "excerpt": "& canonical_sql ( & format ! ( \"SELECT id, body, predecessors FROM claims WHERE subject=?1 AND kind='intent.desired' AND store_index<=?2 AND {UNREPAIRED_CLAIM} AND NOT EXISTS ( SELECT 1 FROM replica_r"}, @@ -2108,6 +2085,7 @@ {"id": "lv-h9ce147382461", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "c32b884bb11aa9d33ad9fa5b6cbd7b18be6b5eec40e5397aa0319a5e4069ab0d", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 11241, "excerpt": "& sql"}, {"id": "lv-h9d5cb60588b7", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "14e6882a5d5459e7642147c640a77a9038fdb7071e9dec262d4e818fe1f2fd11", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 19303, "excerpt": "& canonical_sql ( \"SELECT id,store_index,batch_id,subject,kind,origin,actor,body,predecessors,accepted_at_unix_ms FROM claims WHERE kind='work.person-asked' AND json_extract(body,'$.fields.origin_step"}, {"id": "lv-h9ed04aaf4540", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "a5e5da280c1f128be68d7596892f8c29f7da633d92871f0453ed33df5ae60007", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 2212, "excerpt": "ALTER TABLE planning_sessions ADD COLUMN planner_spec_json TEXT NOT NULL DEFAULT '{\"provider\":\"codex\"}';"}, + {"id": "lv-h21c970c2c1f9", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "1b637695de7f89e7a97de09938a02b7c142cd482bbdda072e453c4d426a91acb", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12127, "excerpt": "[ index ] , | row | { let kind : String = row . get ( 0 ) ? ; if exclude_reserved & & fractal_schema : : notice : : is_reserved_kind ( & kind ) { return Ok ( None ) ; } Ok ( Some ( ( kind , row . get "}, {"id": "lv-ha10368722a70", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "06fa715e6203dc10a217a1473e30bab722a8f0df05bc5eccd74b1a6d132ad95c", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 18696, "excerpt": "& canonical_sql ( \"SELECT body FROM claims WHERE subject=?1 AND kind='harness.diagnostic' AND accepted_at_unix_ms<=?2 AND json_extract(body, '$.fields.code')='work-wake-exhausted' AND json_extract(bod"}, {"id": "lv-ha20c4a9bdfe5", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "8019bb730f034032c09c31577e29b16e0a39faaeda944096c2d927c4c4eff97b", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 21122, "excerpt": "params ! [ subject , i64 : : MAX ] , | r | r . get ( 0 )"}, {"id": "lv-ha2f90f8d8012", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "3f9b687ddac1dded450514d8ce2c904b4022e7c90b41c96bb14204cfbe4dae8a", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 11277, "excerpt": "params ! [ subject , at_index ] , | row | { Ok ( ( row . get : : < _ , String > ( 0 ) ? , row . get : : < _ , String > ( 1 ) ? ) ) }"}, @@ -2149,7 +2127,6 @@ {"id": "lv-hc14c34ea4e1b", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "4e67e1fb1f507083301a5120953ee7c600a91a9b26610820cd6cfbaad8fe6cbb", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 8734, "excerpt": "params ! [ subject , incarnation ] , claim_from_row"}, {"id": "lv-hc1db65df6dce", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "f364d8ca6168ffa8cad7d686c9cbe491d919c3fcdda7ba967af6912aaffb62ae", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 5612, "excerpt": "& format ! ( \"{} LIMIT 1\" , newest_claims_of_kind_query ( \"claims.origin\" , \"runtime.observed\" ) )"}, {"id": "lv-hc251ec015da5", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "4a8bcefb5d30aceece8e5178176f5e8843ed063b2ecdfb5a04251352819f7d15", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 10687, "excerpt": "& canonical_sql ( \"SELECT id FROM claims WHERE subject=?1 AND kind >= 'planning-session.' AND kind < 'planning-session/' ORDER BY CANONICAL_ASC(claims)\" ) ,"}, - {"id": "lv-hc44243b75520", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "7ae4c4eedbe72dbad7b2d1f7d4ecac8b5d0fd8382ea9bb6b970fea1e13d03878", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12073, "excerpt": "& fields_sql"}, {"id": "lv-hc52177269f65", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "48d2f237b7bca0a231c164ac8e97566ceae364c4c375f01855322c73b08eb175", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 9382, "excerpt": "UPDATE local_latest_slots SET pending_local_id=NULL WHERE subject=?1 AND kind='harness.usage' AND json_extract(slot, '$[1]') NOT IN ('response_rollup', 'context_occupancy')"}, {"id": "lv-hc641f59096c7", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "2a42e7db6035364dbfcae839df56720e6db79e733f4fdac5661f296b7a342e85", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 13298, "excerpt": "UPDATE run_generations SET status=?2, updated_at_unix_ms=?3 WHERE id=?1"}, {"id": "lv-hc880524a7216", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "d032ee9d11ad5ad8ad2e3e5bb7f4ae48568fa565e859af6058af90717bcb07dc", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 9481, "excerpt": "DELETE FROM planning_sessions"}, @@ -2188,7 +2165,6 @@ {"id": "lv-hd925b5785834", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "49a38fe3d64b04fd70ffc23d73e15302912dc466a2598a132eb8872d6db08c08", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 16236, "excerpt": "& canonical_sql ( & format ! ( \"SELECT claims.id FROM claims JOIN batches ON batches.id=claims.batch_id WHERE {filter} ORDER BY CANONICAL_ASC(claims)\" ) )"}, {"id": "lv-hd9ad37ed7efe", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "a067a6492235dd51352180de416eaa60e26e59d1f3e1feed338871ce03f9510e", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 21073, "excerpt": "DELETE FROM expected_claim_signatures WHERE subject=?1 AND kind=?2 AND actor=?3"}, {"id": "lv-hda04e7d0a440", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "7a6638cbcaff33b61b1845d149b6b7b71015afda102b8114472ee8ae632e72b6", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 16096, "excerpt": "DELETE FROM projection_health WHERE aggregate LIKE ?1"}, - {"id": "lv-hdacaab2c061c", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "73e9726766615edb1d3ffd4ae267633878f2752cea488a98caa6a111454943be", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 5344, "excerpt": "status_query"}, {"id": "lv-hdb08eca764a7", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "38baa6ef434cbfcc49a47cbdfe50bdca7acbd2ce204542eaa5f1b598234a990f", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12090, "excerpt": "& canonical_sql ( \"SELECT json_extract(claims.body, '$.fields.items') FROM claims WHERE claims.subject=?1 AND claims.kind='loop.state' AND json_extract(claims.body, '$.fields.items') IS NOT NULL ORDER"}, {"id": "lv-hdddd1fc9e5b8", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "6516c5525e9c48c6f2e25e0fe1fdf557620492976076fd799a54b96b0b63d7c3", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 8516, "excerpt": "INSERT OR IGNORE INTO local_usage_responses(subject, source_id, observed_at_unix_ms) VALUES (?1, ?2, ?3)"}, {"id": "lv-hde78ac1818e2", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "8e70a33c08a6f2cd37055b8a1f6c8f4d15246eb05e18780cbb798f9fabf7b9d3", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 10685, "excerpt": "DELETE FROM planning_sessions WHERE id=?1"}, @@ -2213,7 +2189,6 @@ {"id": "lv-hec3b6de01146", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "a6ee9dd89cc36cb0ac010053a0bd0f8599bc144a2de3ecc58bada4e3bda1e340", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 10871, "excerpt": "& format ! ( \"{} LIMIT 1\" , newest_claims_of_kind_query ( \"claims.body\" , \"harness.observed\" ) )"}, {"id": "lv-hec8d3183c68e", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "f63e528e89a2ec7b274716f4f4a323c42aa1f361eea798317de9aeb1103fbe2e", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 16791, "excerpt": "INSERT OR IGNORE INTO revision_proposals( id, run_id, source_generation_id, candidate_revision, actor, reason, status, cutover, compatible_steps, reviewers, approvals, preview_hash, created_at_unix_ms"}, {"id": "lv-hefdad4fbcd1d", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "28006ab82d7ad8a2f45f76f406117c83e41a9746b9caabf07d97de228a415121", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 17581, "excerpt": "SEAT_QUEUE_NAMED_RUN_QUERY"}, - {"id": "lv-hf01b6d5cecf5", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "95923a5d49ab1c3c1f6b3254e6cf3ccafb8ca33833d9566fa5e90626612d746e", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 19286, "excerpt": "params ! [ spellings , run_id ] , | row | { Ok ( ( row . get : : < _ , Option < i64 > > ( 0 ) ? , row . get : : < _ , Option < String > > ( 1 ) ? , ) ) }"}, {"id": "lv-hf01d490c1378", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "1d725f730d91a1aabeefc8917346db4353d66cc952e2df205cebd12b6f4d9d39", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 16910, "excerpt": "UPDATE revision_proposals SET status=?2, successor_generation_id=?3, updated_at_unix_ms=?4 WHERE id=?1"}, {"id": "lv-hf0c8dec17096", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "04da194219fdf11d1582ecd207ce8407c1186c3555cb10ea8cfd7698be780b1e", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 20290, "excerpt": "& canonical_sql ( \"SELECT body FROM claims WHERE subject=?1 AND kind='loop.state' ORDER BY CANONICAL_DESC(claims) LIMIT 1\" ) , [ & subject ] , | row | row . get : : < _ , String > ( 0 ) ,"}, {"id": "lv-hf1e3cb16a1dc", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "b6a440e476d1addda9e0a21fad452d7a82d610f6449ca95718990bad9285846d", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 10977, "excerpt": "params ! [ subject , incarnation ] , | row | row . get ( 0 )"}, @@ -2327,8 +2302,6 @@ {"id": "lv-hd4075be32904", "path": "crates/fractal-store/src/store/attention_ivm.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "fractal_store::store::attention_ivm", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 86, "excerpt": "SCHEMA"}, {"id": "lv-hd4a43b41cce0", "path": "crates/fractal-store/src/store/attention_ivm.rs", "kind": "sql-template", "fingerprint": "119df891f6fc3268a0afe32444dbf7f14b001ed33c86711cbefbaf88689bf2ab", "owner": "fractal_store::store::attention_ivm", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 205, "excerpt": "INSERT OR IGNORE INTO local_attention_open_dependencies VALUES(?1,?2,?3)"}, {"id": "lv-hde84e82e0035", "path": "crates/fractal-store/src/store/attention_ivm.rs", "kind": "sql-template", "fingerprint": "bc88d392fc4eb5e859589166f0856773c33bf380ee646b6d8d33b5418b13cfc2", "owner": "fractal_store::store::attention_ivm", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 111, "excerpt": "INSERT INTO local_attention_open_dirty SELECT 'person',{which}.subject WHERE {which}.assignee>='person/' AND {which}.assignee<'person0' ON CONFLICT DO NOTHING;"}, - {"id": "lv-h01805b1bf25a", "path": "crates/fractal-store/src/store/attention_ivm/tests.rs", "kind": "sql-template", "fingerprint": "3daaf8646dbe849c172fb0f64b12ba46bbb3418e54979870a258dd4df55ae410", "owner": "fractal_store::store::attention_ivm::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 34, "excerpt": "INSERT INTO local_attention_open SELECT family,?1,'attention/legacy',person, priority,requested,eligible,episode,item,json_set(body,'$.id','attention/legacy') FROM local_attention_open WHERE source=?2"}, - {"id": "lv-h162cd91a047b", "path": "crates/fractal-store/src/store/attention_ivm/tests.rs", "kind": "sql-template", "fingerprint": "c739eb3e90efd3c19aaf24d11baee3787444edbcc5873a9ada5ea0c83dfb0cc5", "owner": "fractal_store::store::attention_ivm::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 24, "excerpt": "INSERT INTO custom_sources SELECT ?1,registration,kind,revision,state, json_set(body,'$.id',?1),person,episode,active,requested_at FROM custom_sources WHERE subject=?2"}, {"id": "lv-h277021197eb9", "path": "crates/fractal-store/src/store/attention_ivm/tests_shared_seats.rs", "kind": "sql-template", "fingerprint": "4260359f13ac6144da38a217805705f73f4651d0954e90d20cde5144211d379d", "owner": "fractal_store::store::attention_ivm::tests_shared_seats", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 34, "excerpt": "UPDATE ivm_views SET ready=1"}, {"id": "lv-h5650237ff33b", "path": "crates/fractal-store/src/store/attention_ivm/tests_shared_seats.rs", "kind": "included-source", "fingerprint": "5d604bf3342897bb94342ddbdbe8296d87ee778849bee124dd73f3c81b8f6a26", "owner": "fractal_store::store::attention_ivm::tests_shared_seats", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 51, "excerpt": "include_str ! ( \"../../../../../examples/fractal/custom-review.json\" )"}, {"id": "lv-h1eca2dcd8e6e", "path": "crates/fractal-store/src/store/attention_snapshot.rs", "kind": "unresolved-sql-sink", "fingerprint": "1a89efc152789a3f0ef56ff275fbdbf2285f5210f812c0990935267d14a5b9f9", "owner": "fractal_store::store::attention_snapshot", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1636, "excerpt": "params ! [ seat , i64 : : MAX ] , | row | row . get : : < _ , String > ( 0 )"}, @@ -2390,7 +2363,6 @@ {"id": "lv-h8762f783c17f", "path": "crates/fractal-store/src/store/card_fold.rs", "kind": "unresolved-sql-sink", "fingerprint": "902fa07a153e28de485e37acc42865e48c7e5bc8192b706cf285c3a1e2de102f", "owner": "fractal_store::store::card_fold", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 58, "excerpt": "& format ! ( \"SELECT claims.id, {} FROM claims WHERE claims.id IN (SELECT value FROM json_each(?1))\" , smallclaims : : store : : canonical : : position_sql ( \"claims\" ) )"}, {"id": "lv-hce05fa503292", "path": "crates/fractal-store/src/store/card_fold.rs", "kind": "unresolved-sql-sink", "fingerprint": "a76f52b8ca68cd342f78a2ce6315f6cb96c9f23c88ea0d744357675868064ba7", "owner": "fractal_store::store::card_fold", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 369, "excerpt": "& format ! ( \"SELECT subjects.value, (SELECT COALESCE(MAX(store_index), 0) FROM claims WHERE subject=subjects.value AND store_index<=?2), EXISTS(SELECT 1 FROM claims INDEXED BY claims_current_view_ind"}, {"id": "lv-hdccc004d481c", "path": "crates/fractal-store/src/store/card_fold.rs", "kind": "unresolved-sql-sink", "fingerprint": "5c13504eebc0da2729236d099bf0d4ca3b663b5007b9a12abd5f66bae6b2f784", "owner": "fractal_store::store::card_fold", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 153, "excerpt": "& format ! ( \"SELECT claims.subject, claims.id FROM json_each(?1) subjects CROSS JOIN claims INDEXED BY claims_subject_kind_index ON claims.subject=subjects.value AND claims.kind='agent.retired' JOIN "}, - {"id": "lv-hf9e2da198b6b", "path": "crates/fractal-store/src/store/card_fold.rs", "kind": "unresolved-sql-sink", "fingerprint": "fff86fae0178f832ea469c3fe6e601110edd5c247e14267e550b32085c7995b7", "owner": "fractal_store::store::card_fold", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 511, "excerpt": "& canonical_sql ( \"SELECT id,kind FROM claims INDEXED BY claims_subject_accepted_index WHERE subject=?1 AND store_index<=?2 ORDER BY CANONICAL_DESC(claims)\" , )"}, {"id": "lv-haacfae1ab071", "path": "crates/fractal-store/src/store/card_fold_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "70179d15541275772374e8041d0ecdc2bd05e747bfef0419cdb72204aa2073a5", "owner": "fractal_store::store::card_fold_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 43, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {}\" , card_fold : : newest_claims_sql ( ) )"}, {"id": "lv-hd14af6227748", "path": "crates/fractal-store/src/store/card_fold_tests_shared.rs", "kind": "unresolved-sql-sink", "fingerprint": "6c51917a0509a1edc88aca17296d8e754b40f9766cd2895fff36fd4680e263d6", "owner": "fractal_store::store::card_fold_tests_shared", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 7, "excerpt": "RANGE_SUBJECTS"}, {"id": "lv-h001f3a5e4b28", "path": "crates/fractal-store/src/store/checkpoint_agreement_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::checkpoint_agreement_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 649, "excerpt": "DELETE FROM claims WHERE id=?1"}, @@ -2610,20 +2582,15 @@ {"id": "lv-h3f6849b90a68", "path": "crates/fractal-store/src/store/custom.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "fractal_store::store::custom", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 161, "excerpt": "SCHEMA"}, {"id": "lv-h44ac934d417e", "path": "crates/fractal-store/src/store/custom.rs", "kind": "sql-template", "fingerprint": "bffb6dc7a37c0f4dc2b8f7d9c9f4330331b47cda2278c2dc99116326e0060fa7", "owner": "fractal_store::store::custom", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 664, "excerpt": "INSERT OR IGNORE INTO custom_dependencies VALUES(?1,?2,'[]','')"}, {"id": "lv-h44ecd869d0d2", "path": "crates/fractal-store/src/store/custom.rs", "kind": "sql-template", "fingerprint": "bffb6dc7a37c0f4dc2b8f7d9c9f4330331b47cda2278c2dc99116326e0060fa7", "owner": "fractal_store::store::custom", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 655, "excerpt": "INSERT OR IGNORE INTO custom_dependencies VALUES(?1,?2,'[]','')"}, - {"id": "lv-h45d8564d30b6", "path": "crates/fractal-store/src/store/custom.rs", "kind": "unresolved-sql-sink", "fingerprint": "a8b771920b8319e47251d1360f5e880bc18e8d329b0f0d003ea3c7e615558947", "owner": "fractal_store::store::custom", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 895, "excerpt": "query"}, - {"id": "lv-h4711b40b27f6", "path": "crates/fractal-store/src/store/custom.rs", "kind": "sql-template", "fingerprint": "9ba12afb3a52c845c258302b48b1df355ba51702259d0df43f1075805228b193", "owner": "fractal_store::store::custom", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 232, "excerpt": "CREATE TEMP TRIGGER IF NOT EXISTS {body}"}, {"id": "lv-h4718566161fa", "path": "crates/fractal-store/src/store/custom.rs", "kind": "unresolved-sql-sink", "fingerprint": "6ed2a4d6014fa75ac8a96a68a2fd4cc8db6ea6bbef6d129b672cc9dff0cbdf49", "owner": "fractal_store::store::custom", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 222, "excerpt": "\"ROLLBACK TO custom_source; RELEASE custom_source\""}, - {"id": "lv-h50b120964acf", "path": "crates/fractal-store/src/store/custom.rs", "kind": "sql-template", "fingerprint": "712a4c8d297136a90432f136b240c8c6644d547ab961a3e111cb85795e0d48db", "owner": "fractal_store::store::custom", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 559, "excerpt": "INSERT OR REPLACE INTO custom_sources VALUES(?1,'','','','unavailable',?2,NULL,NULL,0,'0')"}, {"id": "lv-h50daecb5c0e9", "path": "crates/fractal-store/src/store/custom.rs", "kind": "unresolved-sql-sink", "fingerprint": "7578a20027cf0b16ba65d57bb5b4719f7190c81a0e25b582a8c49b0ee02524d0", "owner": "fractal_store::store::custom", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 158, "excerpt": "& format ! ( \"DROP {kind} {name}\" )"}, {"id": "lv-h54522921c58d", "path": "crates/fractal-store/src/store/custom.rs", "kind": "unresolved-sql-sink", "fingerprint": "12ad020bc23a2da53bdcc6a3e7223d9b1fe90a2eca765091f1a0071f5a6237f2", "owner": "fractal_store::store::custom", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 225, "excerpt": "\"RELEASE custom_source\""}, {"id": "lv-h5f040aa46115", "path": "crates/fractal-store/src/store/custom.rs", "kind": "sql-template", "fingerprint": "d0ee88bfeb8e21ec1bbf53ee2f67902ebb578532cff4644c3031d9f1803d0a11", "owner": "fractal_store::store::custom", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 554, "excerpt": "DELETE FROM custom_dependencies WHERE source=?1"}, {"id": "lv-h75147b2df5b4", "path": "crates/fractal-store/src/store/custom.rs", "kind": "sql-template", "fingerprint": "17d83fc1a36988f2121ea5c2175c0c884b95eb76b2aa3586bc6e7344957d8bd4", "owner": "fractal_store::store::custom", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 212, "excerpt": "INSERT OR IGNORE INTO local_custom_dirty SELECT DISTINCT subject FROM claims WHERE subject>=?1 AND subject?1 AND request.store_index<=?2)\" )"}, - {"id": "lv-h37c63cda84b7", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "46ef611ef7d162d6233c0568aa0ea5d1fca1ef05a3561513fb9f1138cf4a631a", "owner": "fractal_store::store::gate_runners", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 85, "excerpt": " BEGIN DELETE FROM local_gate_runners WHERE subject=NEW.subject; END; CREATE TRIGGER IF NOT EXISTS gate_runners_claim_delete AFTER DELETE ON claims WHEN OLD.kind='gate.requested' OR "}, - {"id": "lv-h542f0da74953", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "defd92c8243108c83b75211feb275395f6c34f9832c4a7304fbf04884577bab8", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 80, "excerpt": " BEGIN INSERT OR IGNORE INTO local_gate_runners SELECT NEW.id, NEW.subject, NEW.origin, "}, - {"id": "lv-h65b57ea9cc03", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "cffb5ce87d2247da4f482e3486c15fe345ced4d8161ac9f978815b858e5fe68f", "owner": "fractal_store::store::gate_runners", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 82, "excerpt": "; END; CREATE TRIGGER IF NOT EXISTS gate_runners_stop_insert AFTER INSERT ON claims WHEN "}, - {"id": "lv-h7b6d9491384e", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "492c792b9519ba2d6c506167f3c48969c7b542c60f9d4efc1a30710c04a36aed", "owner": "fractal_store::store::gate_runners", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 95, "excerpt": "; END; CREATE TRIGGER IF NOT EXISTS gate_runners_claim_update AFTER UPDATE OF id, subject, kind, origin, body ON claims WHEN OLD.kind IN ('gate.requested','runtime.observed') OR NEW.kind IN ('gate.req"}, - {"id": "lv-h8673761c3666", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "808fad6da03d993b451a056dfca7e8a919733c5f2f799dae79fb9413f8edcd29", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 186, "excerpt": "UPDATE local_gate_runners_backfill SET cursor=?1, complete=?2 WHERE singleton=1 AND complete=0"}, - {"id": "lv-h88b1908ca9f0", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "unresolved-sql-sink", "fingerprint": "018842dbda78636acc5fcdc36fd5a67092599daa2359c2d5ad1b38011f3fb01d", "owner": "fractal_store::store::gate_runners", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 181, "excerpt": "[ id ]"}, - {"id": "lv-had5c98a5f302", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "7e12df6e3ed2fb9a1710a2b5d070afed6cd54cff54b2a9c4c239a577dcfb52a0", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 131, "excerpt": "DELETE FROM local_gate_runners"}, - {"id": "lv-hb85f5f8cc048", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "0de6ce18412045d61a0eaec711333a39046e68ca9308fc9e6cf658cf5fd7a997", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 101, "excerpt": "; INSERT OR IGNORE INTO local_gate_runners SELECT request.id, request.subject, request.origin, "}, - {"id": "lv-hd3c38661fdf1", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "847f8e19ab04cf92777df8292b4f9365378f96832c2a119526caf6e136ce5b31", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 178, "excerpt": "INSERT OR IGNORE INTO local_gate_runners {FROM_CLAIMS} AND request.id=?1"}, - {"id": "lv-hd42874953f0f", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "fractal_store::store::gate_runners", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 125, "excerpt": "SCHEMA"}, - {"id": "lv-hd4ba7299a574", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "93b8b240ca38baf6e0ccfa12dd287a7a4e6f4ddf5315419458a129892acfcdab", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 89, "excerpt": " BEGIN DELETE FROM local_gate_runners WHERE claim_id=OLD.id; INSERT OR IGNORE INTO local_gate_runners SELECT request.id, request.subject, request.origin, "}, - {"id": "lv-hf10b389f1752", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "335ed1171e10a6e9be2f362ae1ae11019cbd2b6aeaa91ef983bc0b9ce9511dba", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 132, "excerpt": "INSERT OR IGNORE INTO local_gate_runners {FROM_CLAIMS}"}, - {"id": "lv-hfa75362b6110", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "7a5fe3587a36e4a71c82f07f03c03c086c7e30663febef5a52c5d8e3e1efdfed", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 134, "excerpt": "UPDATE local_gate_runners_backfill SET cursor=ceiling, complete=1 WHERE singleton=1"}, - {"id": "lv-h1abbf8648a66", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "7e12df6e3ed2fb9a1710a2b5d070afed6cd54cff54b2a9c4c239a577dcfb52a0", "owner": "fractal_store::store::gate_runners_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 140, "excerpt": "DELETE FROM local_gate_runners"}, - {"id": "lv-h24c98c95d905", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "ad130234b57a4020d27768b59106562e1d1e5412a3aeb70595a58171d875e737", "owner": "fractal_store::store::gate_runners_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 90, "excerpt": "UPDATE claims SET body=json_set(body, '$.fields.runner', 'manual') WHERE kind='gate.requested' AND subject='gate-operation/step-run.b/metric'"}, - {"id": "lv-h66ea9662b56e", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "fcbe5eb889a08e4faf55e51dd36e0600a40fd81262b069d7036fea70df4fd1aa", "owner": "fractal_store::store::gate_runners_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 110, "excerpt": "DROP TRIGGER gate_runners_request_insert; DROP TRIGGER gate_runners_stop_insert; DROP TRIGGER gate_runners_claim_delete; DROP TRIGGER gate_runners_claim_update; DROP TABLE local_gate_runners; DROP TAB"}, - {"id": "lv-h9702b2285d87", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::gate_runners_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 79, "excerpt": "DELETE FROM claims WHERE id=?1"}, - {"id": "lv-h9f0db61bdab1", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "e6fcbe8e6418ed858cfed558287b77774cd5ec41680d08abc9d468597889e4b1", "owner": "fractal_store::store::gate_runners_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 188, "excerpt": "DROP INDEX claims_subject_kind_index; DROP INDEX claims_subject_kind_accepted_index;"}, - {"id": "lv-hae6010f8fa83", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "4ec7c53222c8a758c722e2111541035ce700d5ae7bd0898c5f1b1a743e6450fd", "owner": "fractal_store::store::gate_runners_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 41, "excerpt": "sql"}, - {"id": "lv-hc0ab63c1e6fe", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "35b3b55dc0d41a833a72f4009dfed3434aa87154d6b9b83da24b2d728d42ad32", "owner": "fractal_store::store::gate_runners_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 149, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {sql}\" )"}, - {"id": "lv-hdcff2425c62f", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::gate_runners_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 83, "excerpt": "DELETE FROM claims WHERE id=?1"}, {"id": "lv-h8bb33b1f6576", "path": "crates/fractal-store/src/store/git_change_tables.rs", "kind": "sql-template", "fingerprint": "ebfebce229de053c27b1cbb915f93e1da9d8f70a745b6113f24d0750be51bcd7", "owner": "fractal_store::store::git_change_tables", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 4, "excerpt": " CREATE TABLE IF NOT EXISTS local_git_change_records ( repository TEXT NOT NULL, object_id TEXT NOT NULL, change_id TEXT NOT NULL, record_json TEXT NOT NULL, indexed_at TEXT NOT NULL, PRIMARY KEY(repo"}, {"id": "lv-h14edaf0aaf6c", "path": "crates/fractal-store/src/store/glass_heads.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "fractal_store::store::glass_heads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 125, "excerpt": "SCHEMA"}, {"id": "lv-h1c54bb0d403b", "path": "crates/fractal-store/src/store/glass_heads.rs", "kind": "unresolved-sql-sink", "fingerprint": "a11ff17b351a8a31b9d385dd17f6f0c5b983c8147434a126bef9fd827da833f9", "owner": "fractal_store::store::glass_heads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 221, "excerpt": "& canonical_sql ( \"SELECT id FROM claims WHERE subject=?1 AND kind='glass.upserted' ORDER BY CANONICAL_ASC(claims) LIMIT 1\" , ) , [ subject ] , | row | row . get ( 0 ) ,"}, @@ -2797,12 +2736,9 @@ {"id": "lv-h3458778f2d90", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "sql-template", "fingerprint": "8faf044a2a9cc7ec4a2a8c3be41da9b789b4d6eee03d27c6f796564528beaf97", "owner": "fractal_store::store::local_trim_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 574, "excerpt": "INSERT OR IGNORE INTO local_trim_citations(cited, citer) VALUES (?1, ?2)"}, {"id": "lv-h3fec8e4cbeb7", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2834506b2572144a0d127370f1d744840307a3bfac1fc7bf6e91d936e8b4bc32", "owner": "fractal_store::store::local_trim_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 570, "excerpt": "\"RELEASE retire\""}, {"id": "lv-h4eb31bda17fb", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "sql-template", "fingerprint": "5103123bf474ffa596f21e74826457f07e5f15c58820fd999e88b85c09e74d5f", "owner": "fractal_store::store::local_trim_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 413, "excerpt": "INSERT INTO checkpoints(id, cut_unix_ms, state, updated_at_unix_ms) VALUES ('checkpoint/example-resource', 0, 'trimmed', 0) ON CONFLICT(id) DO NOTHING"}, - {"id": "lv-h5caea1023c5d", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "sql-template", "fingerprint": "61c7e4921e163637a5cf08315ec343cc3de259918284fe88926d336c6e0e7470", "owner": "fractal_store::store::local_trim_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 1127, "excerpt": "INSERT INTO checkpoints(id, cut_unix_ms, state, updated_at_unix_ms) VALUES ('checkpoint/example-step', 0, 'trimming', 0) ON CONFLICT(id) DO NOTHING"}, {"id": "lv-h5f1625904b9b", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "sql-template", "fingerprint": "36f098037f175ccf759ab1044c208bf7024cf699297b20c35a73558dd65ae8d0", "owner": "fractal_store::store::local_trim_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 656, "excerpt": "CREATE TRIGGER IF NOT EXISTS replica_envelopes_inventory_DELETE AFTER DELETE ON replica_envelopes BEGIN INSERT INTO meta(key,value) VALUES('replication_inventory_generation','1') ON CONFLICT(key) DO U"}, - {"id": "lv-h6cc0bf36e69e", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "6db7d803e74f1ffa7d8f5adc0bf95b3e15bf4c8373fffadf546227cc6c6742cb", "owner": "fractal_store::store::local_trim_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1222, "excerpt": "before"}, {"id": "lv-h79e3a98c6d5e", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "289d119346b7dcd4b3694a72f93eaab144184687770aad714bcea6c219a990ac", "owner": "fractal_store::store::local_trim_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 260, "excerpt": "[ id ] , | row | row . get : : < _ , bool > ( 0 )"}, {"id": "lv-h7ce7384320fa", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "9daa9134059e10ef757f0703602ea8fccfcae5a0abae10ba368734a0efd5d5d6", "owner": "fractal_store::store::local_trim_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 548, "excerpt": "\"SAVEPOINT retire\""}, - {"id": "lv-hb859fde7dd63", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "sql-template", "fingerprint": "609090e63ced832d370303d09de3bd545aa2a745423345066600d7902b1ad0fb", "owner": "fractal_store::store::local_trim_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1270, "excerpt": "INSERT OR IGNORE INTO local_resource_projection_pending SELECT DISTINCT subject FROM claims WHERE kind='resource.observed'"}, {"id": "lv-hcc86b5e9b027", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "c32b884bb11aa9d33ad9fa5b6cbd7b18be6b5eec40e5397aa0319a5e4069ab0d", "owner": "fractal_store::store::local_trim_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 529, "excerpt": "& sql"}, {"id": "lv-he41d7d662db4", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "fea7ac6e6dd950e2c82473fbb7db467f0ddd977c2e1366072121c53eb9194da7", "owner": "fractal_store::store::local_trim_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 240, "excerpt": "& sql , [ ] , | row | row . get ( 0 )"}, {"id": "lv-he5f64674d59f", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "sql-template", "fingerprint": "5ea657e504b107352af16ab0ce79f66cbf46669c3765b706b68ab9d228e2fd74", "owner": "fractal_store::store::local_trim_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 646, "excerpt": "UPDATE replica_envelopes SET rowid=(SELECT MAX(rowid)+1 FROM replica_envelopes) WHERE batch_id=(SELECT batch_id FROM claims WHERE kind='observer.observed' ORDER BY store_index DESC LIMIT 1 OFFSET 1)"}, @@ -2942,47 +2878,6 @@ {"id": "lv-h9d524e52ffa5", "path": "crates/fractal-store/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "9e134604e8a2f34df426fe916a3a403b03512f5d973022854260e2ba107bfd7d", "owner": "fractal_store::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 135, "excerpt": "\"WITH requested AS ( SELECT COALESCE((SELECT identity FROM identity_names WHERE name='mission/'||?1),'mission/'||?1) AS identity ) SELECT r.body, requested.identity, COALESCE(h.unavailable, (SELECT CA"}, {"id": "lv-ha48fc9d01a74", "path": "crates/fractal-store/src/store/missions.rs", "kind": "sql-template", "fingerprint": "5d3baa4cb1a6ebdc4527226258f80f2f07dfd1bd8aa4ff43204d68c70a101ee7", "owner": "fractal_store::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 530, "excerpt": "INSERT INTO idempotency(operation_id, response) VALUES (?1, ?2)"}, {"id": "lv-hd28862ef6c43", "path": "crates/fractal-store/src/store/missions.rs", "kind": "unresolved-sql-sink", "fingerprint": "4a565ade88a091828380a64e2e7283fc3e456e597600c188e54b7f645a65dafe", "owner": "fractal_store::store::missions", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 763, "excerpt": "& canonical_sql ( \"SELECT documents.hash FROM documents WHERE documents.name = ?1 AND documents.created_index<=?2 ORDER BY binding_key DESC LIMIT 1\" ) , params ! [ name , store_index ] , | row | row ."}, - {"id": "lv-h2d6ca7ff29ef", "path": "crates/fractal-store/src/store/notice_authority.rs", "kind": "sql-template", "fingerprint": "bffb6dc7a37c0f4dc2b8f7d9c9f4330331b47cda2278c2dc99116326e0060fa7", "owner": "fractal_store::store::notice_authority", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 86, "excerpt": "INSERT OR IGNORE INTO custom_dependencies VALUES(?1,?2,'[]','')"}, - {"id": "lv-h6b344b76fae0", "path": "crates/fractal-store/src/store/notice_authority.rs", "kind": "sql-template", "fingerprint": "d413e887f4063a27a07b744cd1decd88fd76b81bcf87ca8bf4bd66434e18ef3b", "owner": "fractal_store::store::notice_authority", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 61, "excerpt": "CREATE TRIGGER {name} BEFORE {action} ON {table} WHEN ({relevant}) AND ({changed}) BEGIN INSERT INTO local_custom_dirty SELECT subject FROM custom_sources WHERE subject LIKE 'custom/attention/%' ON CO"}, - {"id": "lv-h80b0a7485e7e", "path": "crates/fractal-store/src/store/notice_authority.rs", "kind": "sql-template", "fingerprint": "8dc3b720253389a4c76f2c322c03a8f44a29cd7331786ca96608bdcbc12eb19d", "owner": "fractal_store::store::notice_authority", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 135, "excerpt": "INSERT INTO local_custom_dirty SELECT subject FROM custom_sources WHERE subject LIKE 'custom/attention/%' ON CONFLICT(subject) DO NOTHING"}, - {"id": "lv-h9d1a487a758c", "path": "crates/fractal-store/src/store/notice_authority.rs", "kind": "sql-template", "fingerprint": "38988c4cc485927ca8dc00fe2ea0a84944a4dc488ddb899ab5277cf9657558a0", "owner": "fractal_store::store::notice_authority", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 137, "excerpt": "INSERT OR REPLACE INTO meta(key,value) VALUES('notice_runtime_binding',?1)"}, - {"id": "lv-ha3325e0e8f6d", "path": "crates/fractal-store/src/store/notice_authority.rs", "kind": "sql-template", "fingerprint": "b7e49652913dae4c424bc5dc2c74e1d1ef7dede55985d044f5bb2cc10fdc8783", "owner": "fractal_store::store::notice_authority", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 71, "excerpt": "DROP TRIGGER IF EXISTS {name}; {sql};"}, - {"id": "lv-hc61106882558", "path": "crates/fractal-store/src/store/notice_authority.rs", "kind": "unresolved-sql-sink", "fingerprint": "a6b8314e0448980bc506ccbcb2433413a2cd36c2d4545337e8b70ea07543ae93", "owner": "fractal_store::store::notice_authority", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 92, "excerpt": "params ! [ subject , dependency ]"}, - {"id": "lv-h18dd79b21618", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "1d306ca6f8d0ac404bd039bda5b4330f1befc3c5af2cbb683b0ea08b3b3a7a25", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 185, "excerpt": "DELETE FROM documents WHERE name=?1 AND hash=?2"}, - {"id": "lv-h1f35500980c5", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "a59972ee5132e49b8f5e57f9eda044f6e2854a248baf0a75fe55bc62067abeef", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 274, "excerpt": "UPDATE mission_runs SET status='completed' WHERE id='unrelated'"}, - {"id": "lv-h206fb305ff5c", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "8ea84554b8b78cb1439d3b23efa8712f2e85dcb9ba58822565db43155eda9cf3", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 323, "excerpt": "UPDATE identity_heads SET unavailable=NULL WHERE identity=?1"}, - {"id": "lv-h20a541ba3f90", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "312c623079b440c8ed74533c88d167d2bfa28e6abf746367894219a8345bd762", "owner": "fractal_store::store::notice_authority_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 187, "excerpt": "UPDATE replica_records SET state='repaired' WHERE claim_id=?1"}, - {"id": "lv-h21b3f50f7470", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "7f278b69cec646287b3aade63b955b9f70fb9971553ab08b7d204585f1d0ef51", "owner": "fractal_store::store::notice_authority_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 280, "excerpt": "sql , [ ]"}, - {"id": "lv-h3394ef4bbe97", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "7bd900971dd6cf84b992a9474a3ca4401844dbb3babec446c4440d279d02897e", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 236, "excerpt": "DELETE FROM run_generations WHERE id='notice-generation'"}, - {"id": "lv-h3a0326f24f3d", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "41efac05301b5f41ae2c90cc96f2f75e07f946b6b5a72c57e843d304a8ee8785", "owner": "fractal_store::store::notice_authority_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 324, "excerpt": "DELETE FROM replica_records WHERE record_ref='record/notice-fence'"}, - {"id": "lv-h4e5df88ef9e3", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "84b60e99be03935341e9e5576b1cfbd2c2166be2da92516602664318b2e3055d", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 99, "excerpt": "DELETE FROM desired WHERE subject=?1"}, - {"id": "lv-h5f87cb47cc14", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "773ed0f5f3e3802ebe41789dfde29c787490734e3a03ee9b7d9e8f9777b0bcdc", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 294, "excerpt": "INSERT INTO run_generations VALUES('ancestor','ancestor','revision',NULL, 'running','person/example','Fixture','0','0')"}, - {"id": "lv-h63e65e38bdf8", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "24e40308fc92f8dda28b96f6ba85757913dae6745eee2bdd4f99c35a4fc93c07", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 250, "excerpt": "INSERT INTO mission_runs VALUES(?1,?1,'revision',?1,'revision',?1, NULL,'.','person/example','{}','run','running','normal','0','0')"}, - {"id": "lv-h802ed557558f", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "b87ee962669d1e2d035b8e8c3dbd20a6605c919f865ea8c52947116a8490d35e", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 225, "excerpt": "INSERT INTO run_generations VALUES('notice-generation','notice-owner','revision', NULL,'running','person/example','Fixture','0','0')"}, - {"id": "lv-h8610ddeb875c", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "2f09062974e3af83b900069bf9d5f1a76653ccc3b734c67d2da9b54653fbdd42", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 260, "excerpt": "UPDATE desired SET owner_run='mission-run/owner', owner_generation='run-generation/owner' WHERE subject=?1"}, - {"id": "lv-h95a6ae5cb3b4", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "da98761ffadba4a92bb9c4f027b22851dea93fe4c6e2c799f505c5bb4812d763", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 258, "excerpt": "UPDATE mission_runs SET parent_step_run='step-run/ancestor', root_run_id='ancestor' WHERE id='owner'"}, - {"id": "lv-h99223601f75d", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "3313a4244e660e8bce68288552aecf468f2b8f10f81ac9376ca4e793f960b56a", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 406, "excerpt": "UPDATE meta SET value=value WHERE key='custom_projections_version'"}, - {"id": "lv-h99362c36c696", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "296836c48ad91d2355b62bce20eb92e7307bea132871680721ce65f07b88766c", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 252, "excerpt": "INSERT INTO run_generations VALUES(?1,?1,'revision',NULL,'running', 'person/example','Fixture','0','0')"}, - {"id": "lv-h99da4f16cb38", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "included-source", "fingerprint": "95a31f82b0f71a9ff221695a923d435abdd83140e106d53b60be481e59e6b390", "owner": "fractal_store::store::notice_authority_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 21, "excerpt": "include_str ! ( \"../../../../docs/st3/client-v0/fixtures/notice-conformance.json\" )"}, - {"id": "lv-h9e46780a0a9b", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "136ba5f1de6ecb1edacd29ef0239cbdc9db5b0aecba66ee35ddaf224bbbacaa7", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 286, "excerpt": "UPDATE step_runs SET status='completed' WHERE subject='step-run/ancestor'"}, - {"id": "lv-ha6074026d9d9", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "5ca704695117249b8113ec55abb6b65d8e99ff9bd4be5c88eb3da850bb4c61fa", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 235, "excerpt": "UPDATE mission_runs SET current_generation_id='missing' WHERE id='notice-owner'"}, - {"id": "lv-ha65e1b1067de", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "2153b63b7aab368f7f28840b3f77e7922203972b4d754635b88c01ca4f6c202e", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 288, "excerpt": "DELETE FROM run_generations WHERE id='ancestor'"}, - {"id": "lv-haa66dc1a89ad", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "efd987d8f8faccec79e4d64aa2fda4c4e2e209ba12db1d63e3913fb328f7914c", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 227, "excerpt": "UPDATE desired SET owner_run='mission-run/notice-owner', owner_generation='run-generation/notice-generation' WHERE subject=?1"}, - {"id": "lv-hb0fb45559620", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "7a5089e60ae9a32e486995a4efcd667a36f9e9e0a38a1d7d49ec785439b6b3b9", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 223, "excerpt": "INSERT INTO mission_runs VALUES('notice-owner','example/notice-owner','revision', 'notice-generation','revision','notice-owner',NULL,'.','person/example','{}','run','running','normal','0','0')"}, - {"id": "lv-hc25ca9a02029", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "ffde3440780012ddeb6ae2d9337ecd5760421037623858d9de2cfb444faf4c2a", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 308, "excerpt": "INSERT INTO identity_heads VALUES(?1,'agent',?1,NULL,'conflict') ON CONFLICT(identity) DO UPDATE SET unavailable='conflict'"}, - {"id": "lv-hc35f31df4335", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "6555751b12932d4307b6695236bf2a92268e81d4540910ae09386f146c9389b7", "owner": "fractal_store::store::notice_authority_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 434, "excerpt": "\"ROLLBACK TO notice_flush; RELEASE notice_flush\""}, - {"id": "lv-hc47322f44af2", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "0032d41e565d4a3986a904dc086312d82ec3319ceeabe6a2aa2a08c6b09f3ff2", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 310, "excerpt": "INSERT OR IGNORE INTO identity_names VALUES(?1,?1,NULL)"}, - {"id": "lv-hca8d43bc7c69", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "73daa9d54d2da9e852fe4a55aa6debab2d47f02bc78fdcf83b8b849f9678c956", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 278, "excerpt": "UPDATE desired SET body='{\"children\":[]}' WHERE subject='agent/example/unrelated'"}, - {"id": "lv-hcc57e4fcd6e3", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "a3b5c96adcc8cd381cedb087c47967414f232b753106ba8814f4eac6d7a53e62", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 287, "excerpt": "UPDATE mission_runs SET status='completed' WHERE id='ancestor'"}, - {"id": "lv-hcda036dc4a29", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "293ffe8784fd0e81f4152618d065d72b54dabfa09c9dacec3ddf7a56721c1070", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 277, "excerpt": "UPDATE run_generations SET id='unrelated' WHERE id='unrelated-next'"}, - {"id": "lv-hd355c60374f5", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "a2f391be823a4eafe796bd93333cd89211b452282a5154b2f8c6470e936601e3", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 254, "excerpt": "INSERT INTO step_runs(subject,run_id,generation_id,step_path,definition_hash, status,attempt,goals,created_at_unix_ms,updated_at_unix_ms) VALUES('step-run/'||?1,?1,?1,'build','hash','working',1,'[]','"}, - {"id": "lv-hd3906354fc71", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "e994daae338c29fa43d6e8fbac011091d151437326784277140db7af7ffbeb5b", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 276, "excerpt": "UPDATE run_generations SET id='unrelated-next' WHERE id='unrelated'"}, - {"id": "lv-he4c6451c597a", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "a12c40ac0c1a67f92003290fbea8bc03339215e6d797cc2fc6146d627e6d5d8d", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 429, "excerpt": "INSERT INTO local_custom_dirty VALUES(?1)"}, - {"id": "lv-he4d3e44fce0b", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "2b488fc9813acb94842bf608c1e81a55eb825f93f94a6628a3d755ab0310e919", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 262, "excerpt": "INSERT INTO desired(subject,kind,revision,claim_id,body) SELECT 'agent/example/unrelated',kind,revision,claim_id,'{}' FROM desired WHERE subject=?1"}, - {"id": "lv-he7c0b1746d3b", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "f17c812319ee408ccb9f084b1215c37cc659f4b187ca4f836a7b9d00d551ca9e", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 234, "excerpt": "UPDATE mission_runs SET status='completed' WHERE id='notice-owner'"}, - {"id": "lv-hea3a81fd2260", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "9e31258808a1bd406552d28dcff1cf6711f8a90d1e157b65d203b5b7f1729909", "owner": "fractal_store::store::notice_authority_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 312, "excerpt": "INSERT INTO replica_records(record_ref,writer,sequence,envelope_hash,position, raw,state,subject_hint,kind_hint,updated_at_unix_ms) VALUES('record/notice-fence','example-peer',1,'pending',0,'{}',?1,?2"}, - {"id": "lv-hfe8fa93ac1c9", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "646b21a77207d2b14f778e66bdcf0d2862f75e00168e81a6aa6fb218629fd654", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 275, "excerpt": "UPDATE step_runs SET status='completed' WHERE subject='step-run/unrelated'"}, - {"id": "lv-hfece32705544", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "ff4b66d6eb272876fcd1ad2de49577612ca688ec30ab306632bbce160bd3d06b", "owner": "fractal_store::store::notice_authority_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 432, "excerpt": "\"SAVEPOINT notice_flush\""}, {"id": "lv-h1e1e50547a90", "path": "crates/fractal-store/src/store/observation_trim_tests.rs", "kind": "sql-template", "fingerprint": "96459e6b70633f2ef33e13398876c6abfee63ce0414fcf900ed35793ff782e96", "owner": "fractal_store::store::observation_trim_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 17, "excerpt": "DELETE FROM local_observations WHERE id IN ( SELECT id FROM local_observations AS old WHERE observed_at_unix_ms < ?1 AND kind NOT IN ('agent.launcher-bound', 'agent.helper-launched') AND NOT EXISTS (S"}, {"id": "lv-h431811e49240", "path": "crates/fractal-store/src/store/observation_trim_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "4ec7c53222c8a758c722e2111541035ce700d5ae7bd0898c5f1b1a743e6450fd", "owner": "fractal_store::store::observation_trim_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 84, "excerpt": "sql"}, {"id": "lv-had3c6ca01d84", "path": "crates/fractal-store/src/store/observation_trim_tests.rs", "kind": "sql-template", "fingerprint": "08eb4ccc2ff958dd54a5f7efa5e33a8edcb5a2725998535a9fee7b633d12f946", "owner": "fractal_store::store::observation_trim_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 31, "excerpt": "INSERT INTO local_latest_slots(subject, kind, slot, published_at_unix_ms, published_fields, pending_local_id) SELECT value->>0, 'harness.usage', value->>1, 0, '{}', value->>2 FROM json_each(?1)"}, @@ -3195,12 +3090,9 @@ {"id": "lv-h410ffbe8c264", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "8da3858beae87759cf440652262b0ff61be2b32c82e8e938faf8a9bf47fcf88f", "owner": "fractal_store::store::resources", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 118, "excerpt": "INSERT OR REPLACE INTO meta(key,value) VALUES('resource_observations_version',?1)"}, {"id": "lv-h4c329b1274fe", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "08682c9e126b31ea43a88c00ae8af22eb54d924b317e1d5db17629d49e722c4f", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 224, "excerpt": "& format ! ( \"SELECT body FROM claims WHERE subject=?1 AND kind='resource.observed' AND (COALESCE(json_extract(body, '$.fields.facts.opened_by'), json_extract(body, '$.fields.opened_by')) IS NOT NULL "}, {"id": "lv-h5083d144d1c8", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "4cf3d8d56cf6584bad9ad8dd8c53e27677aadf2245ea6f1dadade03b8dce3ba8", "owner": "fractal_store::store::resources", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 106, "excerpt": "INSERT OR IGNORE INTO local_resource_projection_pending SELECT DISTINCT subject FROM claims WHERE kind='vcs.item.state'"}, - {"id": "lv-h60b7cd1314a2", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "d5e90e009630aa3b7cc98b8129c8209ca6f3736dce850af637ba17b9736b4c02", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 376, "excerpt": "[ subject ]"}, {"id": "lv-h618ad247dea0", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "9b563718cb23101dca2c6ee7d3d783e6356f26ff8df313b9d455eb5e7b9faa66", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 312, "excerpt": "& canonical_sql ( \"SELECT id, json_extract(body, '$.fields'), accepted_at_unix_ms FROM claims WHERE subject=?1 AND kind='vcs.item.state' AND NOT EXISTS (SELECT 1 FROM replica_records WHERE claim_id=cl"}, - {"id": "lv-h7c39eb662d6b", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "69a19bd7c2c484a9c5c2b150e5ce2b93c16ff0e35862c1ec418962f5e34afb58", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 359, "excerpt": "[ subject ] , | row | row . get : : < _ , bool > ( 0 )"}, {"id": "lv-h80d2f77f5ac1", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "cc367ec872200f579befb89aa1dcd2465f858e562b6341228f1b015d5087193a", "owner": "fractal_store::store::resources", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 82, "excerpt": "DROP TRIGGER IF EXISTS resource_observations_insert; DROP TRIGGER IF EXISTS resource_observations_delete; DROP TRIGGER IF EXISTS resource_observations_record_insert; DROP TRIGGER IF EXISTS resource_ob"}, {"id": "lv-h81c266ee908d", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "5a8e42304e5c290b452f77ccd2a4ee19882c9505b35cda59915bf0bfd7902cf5", "owner": "fractal_store::store::resources", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 38, "excerpt": " CREATE TRIGGER IF NOT EXISTS resource_observations_insert AFTER INSERT ON claims WHEN NEW.kind IN ('resource.observed','vcs.item.state') BEGIN INSERT INTO local_resource_projection_pending(subject) S"}, - {"id": "lv-h820a8d8b9bbc", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "1b182fb220654b715cfb048550bb344886eef024f125498cda707531421b57a5", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 340, "excerpt": "[ & claim . id ] , | row | row . get : : < _ , Option < bool > > ( 0 )"}, {"id": "lv-h8b50d51a894d", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "1e641fc36ef53b63397d504bac1c4137a4ea1c56e14c7f85cbd0471b92c3dc91", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 315, "excerpt": "& canonical_sql ( \"SELECT json_extract(body, '$.fields'), accepted_at_unix_ms FROM claims WHERE subject=?1 AND kind='resource.observed' AND NOT EXISTS (SELECT 1 FROM replica_records WHERE claim_id=cla"}, {"id": "lv-h90834f2dfb3a", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "e2e736632198694bb8f8593b1baf54164014b7198604bcd07eb6627c1aeb79e5", "owner": "fractal_store::store::resources", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 129, "excerpt": "DELETE FROM resource_observations"}, {"id": "lv-hbbb29f6a8f86", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 72, "excerpt": "SCHEMA"}, @@ -3209,13 +3101,10 @@ {"id": "lv-hcd95eed6317e", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "c8b598974e26d51ebbd5b5fb9e27ca04da06fd4d6245cbc68641cbf6f469ab04", "owner": "fractal_store::store::resources", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 104, "excerpt": "INSERT OR REPLACE INTO meta(key,value) VALUES('resource_pending_trigger_version',?1)"}, {"id": "lv-hdacd067611e1", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "00cd35dacaffbd752a0f9a51ace18fbf4a5ec79846017c4962c570d55e4a0cd7", "owner": "fractal_store::store::resources", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 19, "excerpt": " CREATE TABLE IF NOT EXISTS resource_observations ( subject TEXT PRIMARY KEY, claim_id TEXT NOT NULL, kind TEXT NOT NULL, facts TEXT NOT NULL, observed_at TEXT NOT NULL, opened_by TEXT, opened_by_run "}, {"id": "lv-hdb152501a3ff", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "5063127b5b6e20193af52e10520ef270649ff7b3f46d6e07ac6ed128210866ae", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 75, "excerpt": "PENDING_TRIGGERS"}, - {"id": "lv-hdbfe4e55565b", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "98d2370affba534c8f897f58f7dfdc16dcbdf1fa5d6acf32dc8bd40605d906ae", "owner": "fractal_store::store::resources", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 375, "excerpt": "DELETE FROM local_resource_projection_pending WHERE subject=?1"}, - {"id": "lv-hdc89a1f149f2", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "85313148be4be4d3c3fe7c96b055dd74e399b45247302afe5e4ad97e462d3c0d", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 364, "excerpt": "[ subject ] , | row | row . get : : < _ , String > ( 0 )"}, {"id": "lv-he4981c1cce60", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "8edb630b01a1f78d4abc5ee08b714ce7a8578b35d2269aec45684bf19ac1e4fc", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 384, "excerpt": "& query"}, {"id": "lv-he57286e02c07", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "98d2370affba534c8f897f58f7dfdc16dcbdf1fa5d6acf32dc8bd40605d906ae", "owner": "fractal_store::store::resources", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 268, "excerpt": "DELETE FROM local_resource_projection_pending WHERE subject=?1"}, {"id": "lv-he6304e096edd", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "97fd0c3105751810f95183a033ab89c13af83d94cfab91a3c1965ae0d86811d1", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 209, "excerpt": "& canonical_sql ( \"SELECT id, body, accepted_at_unix_ms FROM claims WHERE subject=?1 AND kind='resource.observed' AND NOT EXISTS (SELECT 1 FROM replica_records WHERE claim_id=claims.id AND state='repa"}, {"id": "lv-hf4694658e03c", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "8226d64198620022ae5414064e99eac34fcedb70649f5a8449cf50c8ab1ba8dc", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 343, "excerpt": "params ! [ observer , \"observer.observed\" ] , smallclaims : : store : : claim_from_row"}, - {"id": "lv-hff2c7d840c05", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "982eb36d7cae2acfcb79e884416a545c64b431d3d4f080fcd74460cb8494de97", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 339, "excerpt": "TRIMMED_LEAVES_PROJECTION"}, {"id": "lv-h00daff5e3ae1", "path": "crates/fractal-store/src/store/resources_preparation.rs", "kind": "sql-template", "fingerprint": "c15d5dfa41d5f1f9e010dbfe53cda9d624bcb2bdd618ebd31d53349f6a9004ba", "owner": "fractal_store::store::resources_preparation", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 309, "excerpt": "CREATE TRIGGER IF NOT EXISTS preparation_legacy_insert AFTER INSERT ON claims BEGIN INSERT INTO local_resource_preparation_dirty(subject) SELECT subject FROM claims WHERE batch_id=NEW.batch_id AND sto"}, {"id": "lv-h054022c6a729", "path": "crates/fractal-store/src/store/resources_preparation.rs", "kind": "sql-template", "fingerprint": "6b6cfe0900359f6fd853b388da06606454a9a018f7d87d9eb528bec593052305", "owner": "fractal_store::store::resources_preparation", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 439, "excerpt": "DELETE FROM local_resource_preparation_deferred WHERE subject=?1"}, {"id": "lv-h08a4e5c48cc2", "path": "crates/fractal-store/src/store/resources_preparation.rs", "kind": "sql-template", "fingerprint": "ee3ea69cbd1cff28a059d21df6bd5dd902ca689a9bdda606d7f364aaff5f5af0", "owner": "fractal_store::store::resources_preparation", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 411, "excerpt": "DELETE FROM local_resource_preparation_rebuild"}, @@ -3462,14 +3351,6 @@ {"id": "lv-hd665b01390a4", "path": "crates/fractal-store/src/store/slo_breaches.rs", "kind": "unresolved-sql-sink", "fingerprint": "838e400a3bd77b9515e1c97b0582974a822aebb5616583ac02d07d87e7f1e6e5", "owner": "fractal_store::store::slo_breaches", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 130, "excerpt": "& format ! ( \"SELECT id,store_index,batch_id,subject,kind,origin,actor,body,predecessors,accepted_at_unix_ms FROM claims WHERE kind='slo.breach-transition' AND {UNREPAIRED_CLAIM}\" )"}, {"id": "lv-hd90aa6430548", "path": "crates/fractal-store/src/store/slo_breaches.rs", "kind": "sql-template", "fingerprint": "70fe1777cd6c7e0e27218378e26f3923783edaa8c769e19186fd8903f1ddabc1", "owner": "fractal_store::store::slo_breaches", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 102, "excerpt": "UPDATE slo_episode_events SET conflict=1 WHERE episode=?1"}, {"id": "lv-hf0005c722374", "path": "crates/fractal-store/src/store/slo_breaches.rs", "kind": "sql-template", "fingerprint": "72c42082bb7327a69ecfb944a9b3da418cedbbb9ba6e56444020bf2d9764e900", "owner": "fractal_store::store::slo_breaches", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 160, "excerpt": "INSERT INTO slo_episode_heads(fleet,member,objective,episode,ordinal,conflict) VALUES(?1,?2,?3,?4,?5,?6) ON CONFLICT(fleet,member,objective) DO UPDATE SET episode=excluded.episode,ordinal=excluded.ord"}, - {"id": "lv-h06cff359fe46", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "ed224edd1628f31bdd5920a8c2358945dc6202f6a470940a4b4df6b1f0e525a2", "owner": "fractal_store::store::status_summary_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 245, "excerpt": "INSERT INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES ('kinds','node',9000,'fixture','1700000000000')"}, - {"id": "lv-h4b8a1cb72b7d", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "351948fde00a6d06954a50a2e6f1ae27cf8a007890ad7927bd0c8e816c494332", "owner": "fractal_store::store::status_summary_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 116, "excerpt": "UPDATE desired SET owner_run='mission-run/example-owner', owner_generation='run-generation/example-owner', owner_step='step-run/example-owner/work' WHERE subject=?1"}, - {"id": "lv-h6dbcaf0dfacb", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "9e21cde6396378fa37c214584b230172b04fc2834030ac7e8e0ec92d09d829b4", "owner": "fractal_store::store::status_summary_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 256, "excerpt": "INSERT INTO claims(id,batch_id,subject,kind,origin,body,predecessors,accepted_at_unix_ms) VALUES (?1,'kinds',?2,?3,'node','{}','[]','1700000000000')"}, - {"id": "lv-h6fb9eeec970c", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "64fd5e02b89ad6757e2772d8bfa381a3f3a4bc414cc707362d0d3724ef8d5530", "owner": "fractal_store::store::status_summary_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 172, "excerpt": "INSERT INTO projection_digest_repaired_claims(id) VALUES (?1)"}, - {"id": "lv-h7da5f6391087", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "c9a09b11ff6ab079fbc412af4f0d90b393bd1fcbe770fc4c3a7fcdbdb56c2bc2", "owner": "fractal_store::store::status_summary_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 157, "excerpt": "INSERT INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES (?1,?2,?3,'fixture',?4)"}, - {"id": "lv-h87bbf137f13b", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "1b8b1b6029e43a1336fdf9d9e5bae289cf1ee70997dbaf6985975c1ded0ffe51", "owner": "fractal_store::store::status_summary_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 102, "excerpt": "INSERT INTO claims(id,batch_id,subject,kind,origin,body,predecessors,accepted_at_unix_ms) VALUES (?1,?1,?2,?3,?4,?5,'[]',?6)"}, - {"id": "lv-h98e5236bc06a", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "943200faae9d766f1ed785b06b2d7ae3aa80f49f8cad7d2faef5e72696618c9e", "owner": "fractal_store::store::status_summary_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 167, "excerpt": "INSERT INTO claims(id,batch_id,subject,kind,origin,actor,body,predecessors,accepted_at_unix_ms) VALUES (?1,?2,?3,?4,?5,?3,?6,'[]',?7)"}, - {"id": "lv-hb4ac3d3059e1", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "c9a09b11ff6ab079fbc412af4f0d90b393bd1fcbe770fc4c3a7fcdbdb56c2bc2", "owner": "fractal_store::store::status_summary_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 96, "excerpt": "INSERT INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES (?1,?2,?3,'fixture',?4)"}, {"id": "lv-h4e3c47b4ff2b", "path": "crates/fractal-store/src/store/step_labels.rs", "kind": "unresolved-sql-sink", "fingerprint": "d80eef577245eb7c0dc5788e29a4834b05723e8bcf6734f1b172ce3e12e29acd", "owner": "fractal_store::store::step_labels", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 20, "excerpt": "QUERY"}, {"id": "lv-h9dfc4dc2c7bf", "path": "crates/fractal-store/src/store/stop_deferral.rs", "kind": "unresolved-sql-sink", "fingerprint": "003672a5440b0fdad09e50da495b2a2b85c3e83c095e8077ab8eb59118c5ce73", "owner": "fractal_store::store::stop_deferral", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 133, "excerpt": "& canonical_sql ( & format ! ( \"WITH seats AS MATERIALIZED ( SELECT desired.subject,desired.claim_id, (SELECT claims.id FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.id IN ({}) A"}, {"id": "lv-had3b7f5ab6ff", "path": "crates/fractal-store/src/store/stop_deferral.rs", "kind": "sql-template", "fingerprint": "4572f6a65ca718a7e4f3f22dfd10f10593c75a949eb73e6953fc8e193007743d", "owner": "fractal_store::store::stop_deferral", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 64, "excerpt": "SAVEPOINT stop_deferral_schema; DROP INDEX IF EXISTS claims_stop_deferral_diagnostic_index; DROP INDEX IF EXISTS claims_stop_deferral_failure_index; DROP INDEX IF EXISTS claims_stop_deferral_recovery_"}, @@ -4295,8 +4176,6 @@ {"id": "lv-h602112ce8703", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "unresolved-sql-sink", "fingerprint": "b483328b9c557f0e72710de93cd41c3f2ddf18097b08be732c22f42be133659f", "owner": "smallclaims::store::checkpoint_trim", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 118, "excerpt": "params ! [ envelope . writer , envelope . sequence , envelope . envelope_hash ]"}, {"id": "lv-h606a2ea2c17e", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "sql-template", "fingerprint": "0f1f0127b2d374c5972ec8fd2024dc3712927a18c2e69a6f61955de79f712de6", "owner": "smallclaims::store::checkpoint_trim", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 114, "excerpt": "INSERT OR IGNORE INTO temp.adopted_envelopes(writer, sequence, envelope_hash) VALUES (?1, ?2, ?3)"}, {"id": "lv-h70b39bb4733d", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "unresolved-sql-sink", "fingerprint": "dff350920774d128a122162a98daaa0bc63304213c2990c3483637c65ed3e2f9", "owner": "smallclaims::store::checkpoint_trim", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1124, "excerpt": "[ LOCAL_TRIM_DAY ] , | row | row . get ( 0 )"}, - {"id": "lv-h71bb2b8a8c50", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "unresolved-sql-sink", "fingerprint": "e0fa34ec049d5b6555c4acfa58ecbe54ea1bba8e20b5dfe301fc57a8c9077dd8", "owner": "smallclaims::store::checkpoint_trim", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1602, "excerpt": "[ & cursor . claim ] , | row | { Ok ( deletable_claim ( row . get ( 0 ) ? , row . get ( 1 ) ? , row . get ( 2 ) ? , row . get ( 3 ) ? ) ) }"}, - {"id": "lv-h74964d9b8b2a", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "unresolved-sql-sink", "fingerprint": "4e0ea4cf319f317f6c1e1a4907deb9f4be779be8f30105c5eea73f14881f32f5", "owner": "smallclaims::store::checkpoint_trim", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1652, "excerpt": "params ! [ walk . claim , walk . checkpoint ] , | row | { Ok ( ( row . get : : < _ , i64 > ( 0 ) ? , deletable_claim ( row . get ( 1 ) ? , row . get ( 2 ) ? , row . get ( 3 ) ? , row . get ( 4 ) ? ) )"}, {"id": "lv-h74f80d64ae6a", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "sql-template", "fingerprint": "9c248dde50bc3fec27a5e62ff2dd67810aeb748d8f39c780ab5ba4ac937cd112", "owner": "smallclaims::store::checkpoint_trim", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1004, "excerpt": "INSERT INTO meta(key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value"}, {"id": "lv-h75e67f417bdf", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "unresolved-sql-sink", "fingerprint": "f87e92b08082c1a5768f5a1d274ebbbb9d0fa9428c4d495b2f8c280477f139b4", "owner": "smallclaims::store::checkpoint_trim", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1048, "excerpt": "params ! [ cursor . envelope . 0 , cursor . envelope . 1 , cursor . envelope . 2 ] , | row | { Ok ( EnvelopeTombstone { writer : row . get ( 0 ) ? , sequence : row . get ( 1 ) ? , envelope_hash : row "}, {"id": "lv-h90aea7898bf3", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "sql-template", "fingerprint": "794f7bbfce8200793f335af7596ce61ef8992c039b9e6b5703f582601c7779c7", "owner": "smallclaims::store::checkpoint_trim", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1022, "excerpt": "INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)"}, @@ -4321,12 +4200,10 @@ {"id": "lv-h02af172fc00e", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "92db243bcd8cace75784416ead644724e9cf3cf5caed158ecd15c8b477b054c6", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 242, "excerpt": "INSERT INTO checkpoint_claims VALUES(printf('cut-%d', ?1), NULL, 'cut', 9, 'h', 'checkpoint/cut')"}, {"id": "lv-h05e8b31a243c", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "e8d01b184967a8fa78607ae25a88f1852ccfc14590e05f4558f3959b7d5ee0a9", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 239, "excerpt": "INSERT INTO checkpoint_envelopes VALUES('cut', ?1, 'h', 0, 'checkpoint/cut')"}, {"id": "lv-h0f82774c88b3", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "11cda2afe8f046d8ab9a034d7553f23a4ee6c312f735c09f8875d271f7a57f59", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 29, "excerpt": "INSERT INTO replica_envelopes VALUES('writer',10001,'hash')"}, - {"id": "lv-h175352b3edc3", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "45c22b9ad5d861cda111564b63c37f3e96f76a87b8ae5dadf42ace4d40c3a263", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 10, "excerpt": "CREATE TABLE checkpoint_envelopes(writer TEXT, sequence INTEGER, envelope_hash TEXT, accepted_at_unix_ms INTEGER, PRIMARY KEY(writer,sequence,envelope_hash)); CREATE TABLE replica_envelopes(writer TEX"}, {"id": "lv-h3ed673b5c629", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "6e7eb6d016491e684f3ab95c3769e8cfd82243b244632afffbe316fe2ff25aaf", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 231, "excerpt": "INSERT INTO checkpoint_envelopes VALUES('w', ?1, 'h', 0, 'checkpoint/old')"}, {"id": "lv-h55b982191075", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "2a8c02186f0770ea12146438c6cf90067771de2bfc588fc04164691922a69cae", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 233, "excerpt": "INSERT INTO checkpoint_claims VALUES(printf('old-%05d', ?1), NULL, 'w', ?1, 'h', 'checkpoint/old')"}, {"id": "lv-h6aaa2338f314", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "48989c0a6b3db803b368d7545ebee94fdaaecee80791ef2f89b96f38f008bea2", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 39, "excerpt": "INSERT INTO checkpoint_claims VALUES('z-held',NULL,'other',2,'other')"}, {"id": "lv-h6cab025f7761", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "1396318360ce20e37f51d8127290541839b786469c37b221c6cf661a99c2ca9e", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 157, "excerpt": "INSERT OR REPLACE INTO meta(key,value) VALUES('foreground-during-trim','ok')"}, - {"id": "lv-h7dfb6529d1bf", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "c4a19012c1619f5c2e33e7c4042c00f4d76fd740fda192886dfba4b83a4e1b41", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 219, "excerpt": "CREATE TABLE checkpoint_envelopes(writer TEXT, sequence INTEGER, envelope_hash TEXT, accepted_at_unix_ms INTEGER, checkpoint TEXT, PRIMARY KEY(writer,sequence,envelope_hash)); CREATE TABLE replica_env"}, {"id": "lv-h8c96c824c32a", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "0b7f910f5f8e6c4fceb8e43fd98fba6c159a654b7a5f2d059af56e6860a89721", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 34, "excerpt": "INSERT INTO checkpoint_claims VALUES('a-gone',NULL,'other',1,'other')"}, {"id": "lv-h8f0e71b32555", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "8537a8751df624c9a9ce909f22bbd396f3b707240227082568f5fcf9e6f4b221", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 43, "excerpt": "INSERT INTO claims VALUES('z-held')"}, {"id": "lv-h9c52ebff18d6", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "0cb749f188b2ae267d692d5147b6fa5a830396a20ef9e40e2f96f055b28f3696", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 23, "excerpt": "INSERT INTO checkpoint_envelopes VALUES('writer',?1,'hash',0)"}, @@ -4714,14 +4591,11 @@ {"id": "lv-ha64a86b53612", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "59d9781204682b2198bb3abd0539c544c5b22dfa7690688bdee18d0bed950ec9", "owner": "smallclaims::store::receive_writer_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 466, "excerpt": "& format ! ( \"SELECT COUNT(*) FROM {table}\" ) , [ ] , | row | row . get : : < _ , usize > ( 0 )"}, {"id": "lv-ha77ce21369d5", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "smallclaims::store::receive_writer_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 997, "excerpt": "DELETE FROM claims WHERE id=?1"}, {"id": "lv-ha806231badab", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "smallclaims::store::receive_writer_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 873, "excerpt": "DELETE FROM claims WHERE id=?1"}, - {"id": "lv-hbfab6aec8e39", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "42a84e0cb9cdc530c12641c23386340623a571bd146870ee43e67972ddea93b8", "owner": "smallclaims::store::receive_writer_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 712, "excerpt": "DELETE FROM meta WHERE key='batch_rowid_high_water'; UPDATE meta SET value='8' WHERE key='seeded_batch_rowid';"}, {"id": "lv-hc19c6b34c2e3", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "d44ebadf1b789634812eb7a6bb70855d78dec99de9b32f79f46b72ae2823d94b", "owner": "smallclaims::store::receive_writer_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 97, "excerpt": "INSERT INTO meta(key,value) VALUES('queued-write','committed')"}, {"id": "lv-hc280ea0222d1", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "91549033156defd4830c46f19e369adc3fd8e59f0e425835f407af46ffc11a01", "owner": "smallclaims::store::receive_writer_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 75, "excerpt": "CREATE TEMP TRIGGER admission_cost BEFORE INSERT ON claims BEGIN SELECT admission_cost(); END;"}, - {"id": "lv-hc37f5c2feb24", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "fb3af4185b5fc989bb50efc1c8034f642e8cfd986f9b5af120119cc7ddf7d93b", "owner": "smallclaims::store::receive_writer_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 734, "excerpt": "DROP TRIGGER temp.refuse_frontier;"}, {"id": "lv-hcc7d9c00ac1a", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "eaa3febaed206ae00cf3c6f5d2bdf1e3d1d53d1762ddbea3121235af1bf71b04", "owner": "smallclaims::store::receive_writer_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 1052, "excerpt": "CREATE TEMP TRIGGER signature_gate BEFORE INSERT ON replica_envelope_signatures BEGIN SELECT signature_gate(); END;"}, {"id": "lv-he5d7cc0aa7aa", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "95007928a1be3fd8e63d86f480109a23914d88a3bf20155c563fd8705931a768", "owner": "smallclaims::store::receive_writer_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 875, "excerpt": "DELETE FROM meta WHERE key='batch_rowid_high_water'"}, {"id": "lv-heb01acaa45bb", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "91362b93a79d2bf2e34951caef3957fd2c2dccd53c87cebd23fbd566cc422fcb", "owner": "smallclaims::store::receive_writer_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 909, "excerpt": "DELETE FROM meta WHERE key='seeded_batch_rowid'"}, - {"id": "lv-hf34b402ef773", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "90b7695d1f748b876dca6a75e6ac165c3450322fcd7f23c3dca15d6b3baa070b", "owner": "smallclaims::store::receive_writer_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 725, "excerpt": "CREATE TEMP TRIGGER refuse_frontier BEFORE INSERT ON main.meta WHEN NEW.key='seeded_batch_rowid' BEGIN SELECT RAISE(ABORT, 'invented failure'); END;"}, {"id": "lv-hf43488f16b99", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "27579faa3260cd55b8a974a9017d0de1fa3307902d7876de85599b139e50d72e", "owner": "smallclaims::store::receive_writer_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1011, "excerpt": "UPDATE batches SET rowid=1 WHERE id=?1"}, {"id": "lv-h01d5606ab461", "path": "crates/smallclaims/src/store/repair_ledger.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "smallclaims::store::repair_ledger", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 43, "excerpt": "SCHEMA"}, {"id": "lv-h14d4c312d129", "path": "crates/smallclaims/src/store/repair_ledger.rs", "kind": "unresolved-sql-sink", "fingerprint": "d709c4b53198c61961b802ca03f38a23581d22fd1bf987e8b7f03393be1b2d50", "owner": "smallclaims::store::repair_ledger", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 73, "excerpt": "[ claim_id ] , | row | row . get ( 0 )"}, @@ -4837,50 +4711,6 @@ {"id": "lv-hbcbe231993d3", "path": "crates/smallclaims/src/store/retired_facts.rs", "kind": "sql-template", "fingerprint": "3f0cf8b4c243047ec3d7514a06739a5cac9277bfa1db435a4d69b43f02b9f11e", "owner": "smallclaims::store::retired_facts", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 26, "excerpt": "INSERT OR IGNORE INTO retired_claim_ancestry(claim_id, subject, kind, predecessors) VALUES ({id}, {row}.subject, {row}.kind, {row}.predecessors); INSERT OR IGNORE INTO retired_operation_receipts(opera"}, {"id": "lv-hd048297b0941", "path": "crates/smallclaims/src/store/retired_facts.rs", "kind": "unresolved-sql-sink", "fingerprint": "cf06eba8d398bac51079c203fe338da5e1d7e75c54416e8aad7b24512174b63e", "owner": "smallclaims::store::retired_facts", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 142, "excerpt": "& RECEIPTS , params ! [ after , through ]"}, {"id": "lv-hd8cbfcc61861", "path": "crates/smallclaims/src/store/retired_facts.rs", "kind": "unresolved-sql-sink", "fingerprint": "eb24a368e0bff3e4c20b859601dfe82607288f05f0343ee6147b09f9fc6f2ab4", "owner": "smallclaims::store::retired_facts", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 141, "excerpt": "& ANCESTRY , params ! [ after , through ]"}, - {"id": "lv-h166835b7b8cb", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "unresolved-sql-sink", "fingerprint": "fca5d96f6c68c4cc7e0a68c2245ed850d4a3f62fc48485354ad7706c45b31ac4", "owner": "smallclaims::store::saved_inventory", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 73, "excerpt": "[ rowid ] , | row | { Ok ( ReplicaEnvelopeId { writer : row . get ( 0 ) ? , sequence : row . get ( 1 ) ? , hash : row . get ( 2 ) ? , } ) }"}, - {"id": "lv-h3ebd622ad4da", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "sql-template", "fingerprint": "38efab1a4a2f66e9a448c36d834753542c9c19f00e0e9136de70cd41defe9fa3", "owner": "smallclaims::store::saved_inventory", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 33, "excerpt": "INSERT INTO meta(key,value) VALUES('{CHANGES}','1') ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER)+1;"}, - {"id": "lv-h4b84af8077fd", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "sql-template", "fingerprint": "521403d1c72e595af43f03fd14937411113ed94d3145efa15e9ac742537c43c7", "owner": "smallclaims::store::saved_inventory", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 56, "excerpt": "CREATE TRIGGER IF NOT EXISTS {table}_saved_inventory_{event} AFTER {event} ON {table} {condition} BEGIN {count} END;"}, - {"id": "lv-h9adce74dc7c8", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "unresolved-sql-sink", "fingerprint": "613aa558f750d5e463dac1886e9efdd80aa316c31e405e95831abbdff1038c23", "owner": "smallclaims::store::saved_inventory", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 67, "excerpt": "[ CHANGES ] , | row | row . get ( 0 )"}, - {"id": "lv-ha6479e16f374", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "unresolved-sql-sink", "fingerprint": "e4cc6f68708be39c42f71efa03a4b2489d2136fb0df4dde2ad74901da72ddc78", "owner": "smallclaims::store::saved_inventory", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 87, "excerpt": "[ SAVED ] , | row | row . get ( 0 )"}, - {"id": "lv-hcc1be495eace", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "sql-template", "fingerprint": "7cab30c56eaaeaff633e8c60a3041db22052ac2db51a797932dc59128b04a4fe", "owner": "smallclaims::store::saved_inventory", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 164, "excerpt": "INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value"}, - {"id": "lv-h038b38395151", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "78ae61633f00f08f0c8d2bb0565aeb462cbc8f4016079182dc95cacf251bd941", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 652, "excerpt": "DELETE FROM replica_envelopes WHERE writer='invented-peer'"}, - {"id": "lv-h08ed1d887f98", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2f1cae44a44a5b5e699c4adeca1f6fac45ac03b6b0e3c4e5f611ea514988f0db", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 603, "excerpt": "\"DETACH saved;\""}, - {"id": "lv-h096b3ac921e0", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dbe139803841971698d39ea71941fe113fd3b3f72593dcdb1fa905a86702d0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 449, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, - {"id": "lv-h0b5217008635", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "28838a88a0177884828bc31eee05b5f57c2034fb63999a38b8457d185c4245a5", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 323, "excerpt": "UPDATE replica_envelopes SET envelope_hash=?2 WHERE rowid=?1"}, - {"id": "lv-h28bd1a5d15fb", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "d8c07a9a3bd4948049486819192015c314d0a617ea538ab66f285ebd7b75a291", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 678, "excerpt": "CREATE TEMP TABLE held AS SELECT {COLUMNS} FROM replica_envelopes ORDER BY rowid; DELETE FROM replica_envelopes; INSERT INTO replica_envelopes({COLUMNS}) SELECT {COLUMNS} FROM held;"}, - {"id": "lv-h3a37f31e4de5", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "a29611e7374e609ad6cd8f40b024bcdedf914f833c9c54f1d831110b229cff2f", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 281, "excerpt": "INSERT INTO replica_envelopes(rowid, {COLUMNS}) SELECT ?1, 'invented-peer', sequence+1000000+?1, ?2, previous_hash, accepted_at_unix_ms, payload, batch_id, relay, receipt_state, validation_error, rece"}, - {"id": "lv-h407916e8f5b5", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "7f803950b8fa2362a48e086aa1ce1a57897c8178474b36611a3a3cf3d711d21e", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 476, "excerpt": "INSERT INTO retired_envelopes(writer, sequence, envelope_hash) SELECT writer, sequence, {} FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid)+2 FROM replica_envelopes)"}, - {"id": "lv-h452c69f7277e", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "ff40d50ac0346a0b517aa10f940685412705736fcb72489f0d3852d04fbd1227", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 143, "excerpt": "INSERT INTO checkpoint_envelopes(writer, sequence, envelope_hash, accepted_at_unix_ms, checkpoint) SELECT writer, sequence, envelope_hash, 0, 'invented-checkpoint' FROM replica_envelopes WHERE rowid=("}, - {"id": "lv-h4e5b95e07354", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dc7ee7721a24a4ca72df1d12c1beb80223bebf763d06d85f6d06f457ca0a707a", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 317, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=?1"}, - {"id": "lv-h4f4bee3873b0", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2ed38e42f1e6e30cad8c6811faca3e1fd080d02f8156ecd1bf22a4b735c4336d", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 564, "excerpt": "\"VACUUM INTO ?1\" , [ backup . to_str ( ) . unwrap ( ) ]"}, - {"id": "lv-h547ee089c932", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "9f03b5ea7e22b57ebde2ad9f2705502e3c25c938f16a73e73614f4c051c70bfc", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 598, "excerpt": "INSERT INTO replica_envelopes(rowid, {COLUMNS}) SELECT rowid, {COLUMNS} FROM saved.replica_envelopes WHERE rowid=?1"}, - {"id": "lv-h5a19c2f3c52b", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dbe139803841971698d39ea71941fe113fd3b3f72593dcdb1fa905a86702d0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 120, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, - {"id": "lv-h61ed8d5bde9c", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "9bb555442b5537e23740d40edf8a162823f09cd82bf1e807c446dab79a30406a", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 217, "excerpt": "\"VACUUM INTO ?1\" , [ copy . to_str ( ) . unwrap ( ) ]"}, - {"id": "lv-h6c4fd8d7681d", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "9bb555442b5537e23740d40edf8a162823f09cd82bf1e807c446dab79a30406a", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 663, "excerpt": "\"VACUUM INTO ?1\" , [ copy . to_str ( ) . unwrap ( ) ]"}, - {"id": "lv-h6e2aa55bdef6", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "c0bb05e069d39f6c90ce0db307317df0cd80c18fe10ad85546610306260309ed", "owner": "smallclaims::store::saved_inventory_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 533, "excerpt": "DROP TRIGGER {trigger};"}, - {"id": "lv-h7adac784a2b7", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dbe139803841971698d39ea71941fe113fd3b3f72593dcdb1fa905a86702d0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 152, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, - {"id": "lv-h806c710cfd5d", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "629c6da4ae9f3a58fdfe822b769338b6e07ec4868d70c89e9fbe96ab702d0eac", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 269, "excerpt": "if below_the_tail { \"SELECT MIN(rowid)-1 FROM replica_envelopes\" } else { \"SELECT MAX(rowid)+1 FROM replica_envelopes\" } , [ ] , | row | row . get ( 0 ) ,"}, - {"id": "lv-h89145e643a90", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "6972dcbe1981439c50004941304392991f4b835e7c273105cb3287dede71d3a4", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 471, "excerpt": "UPDATE checkpoint_envelopes SET envelope_hash=?1 WHERE sequence>=2000000"}, - {"id": "lv-h8af984feac27", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "4f9649ae2e9be6d051460d4973e3931ebab743819f09f72a6b931b1dc217af39", "owner": "smallclaims::store::saved_inventory_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 365, "excerpt": "INSERT INTO meta(key,value) VALUES(?1, CASE WHEN EXISTS(SELECT 1 FROM meta WHERE key=?1 AND value='1') THEN '0' ELSE '1' END) ON CONFLICT(key) DO UPDATE SET value=excluded.value"}, - {"id": "lv-h8d0cb791bb5d", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "9d844808085be2a2fc5bf06713616c587f02e1ded35b223d8fe115457c0e203b", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 342, "excerpt": "DELETE FROM checkpoint_envelopes WHERE rowid=(SELECT rowid FROM checkpoint_envelopes ORDER BY rowid LIMIT 1)"}, - {"id": "lv-h8db85480d0de", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "8bbd85ba47be92c05d4e3c1e1b2390a1c90221e2ae5323b9da0392e28cc2a0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 485, "excerpt": "DELETE FROM retired_envelopes"}, - {"id": "lv-h94183eda703e", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "4ec7c53222c8a758c722e2111541035ce700d5ae7bd0898c5f1b1a743e6450fd", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 684, "excerpt": "sql"}, - {"id": "lv-h94ad7e165653", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "cadf048a7d331020e531b523809c985a86bc2e6a51036b71d5e7a2b0f20cccf2", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 617, "excerpt": "INSERT INTO meta(key,value) VALUES('replication_inventory_saved',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value"}, - {"id": "lv-h9bc0d44f925c", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "b3146e6453d128849b29448d71f9ae9c7fdd07692029bee76e7de8983a3056ac", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 488, "excerpt": "UPDATE retired_envelopes SET sequence=sequence+1000000"}, - {"id": "lv-h9e1cf8a7d6ec", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "4f2cbeb305171677fe84fda3c8a09458142b5d4dcdf8c6b0d26a68c36c3343b4", "owner": "smallclaims::store::saved_inventory_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 674, "excerpt": "DROP TRIGGER {name};"}, - {"id": "lv-ha3d4449c40cb", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "cacab32515e03b567c70397405b17c7a16e0a55e7c353cd8aa28c83e35587eba", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 445, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid)+1 FROM replica_envelopes)"}, - {"id": "lv-habb792328cd1", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dbe139803841971698d39ea71941fe113fd3b3f72593dcdb1fa905a86702d0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 718, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, - {"id": "lv-hafcb47196507", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "bd65c238666820a4ad5e3c87858807f0b6fe9cbf786e229d4ffdf811660fdf8f", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 379, "excerpt": "UPDATE checkpoint_envelopes SET accepted_at_unix_ms=accepted_at_unix_ms+1"}, - {"id": "lv-hbdb15363782d", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "32f17f45dc1b61171faa3a7663e5131d9f07ad59b4f6734e37351653c9c91782", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 461, "excerpt": "INSERT INTO checkpoint_envelopes(writer, sequence, envelope_hash, accepted_at_unix_ms, checkpoint) SELECT writer, sequence+1000000, ?1, 0, 'invented-checkpoint' FROM replica_envelopes LIMIT 1"}, - {"id": "lv-hc29e4e23e9a9", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "ccab45845332dabd90250cbe1e03bec484b59729f3c7f67c4cdc3afa1091374b", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 468, "excerpt": "DELETE FROM checkpoint_envelopes WHERE sequence>=2000000"}, - {"id": "lv-hc31b549dff4f", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "0e8b547739fe2aa1896c790dc7eac3f14c04c4cce3a10a97eaf96601b6bf57c5", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 594, "excerpt": "\"ATTACH ?1 AS saved\" , [ saved_state . to_str ( ) . unwrap ( ) ]"}, - {"id": "lv-hc55e64044a0a", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "c8017bfbcf445d5c97813842118171d1a36b65729c48b92a627784ae443c2866", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 454, "excerpt": "UPDATE replica_envelopes SET envelope_hash=?1 WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, - {"id": "lv-hc7bd248137ba", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "57c2ffd7954f820ad67310c908d441972afa00902422bbf414e72606c0815ab4", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 506, "excerpt": "INSERT INTO meta(key,value) VALUES('{}','1') ON CONFLICT(key) DO UPDATE SET value='1'; INSERT INTO checkpoint_envelopes(writer, sequence, envelope_hash, accepted_at_unix_ms, checkpoint) SELECT writer,"}, - {"id": "lv-hc8cf34d194ca", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "ce18196812ec0ab75683b21ca6fec4399412d8fe24255dd34a4df7fde5c0ce95", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 572, "excerpt": "\"VACUUM INTO ?1\" , [ saved_state . to_str ( ) . unwrap ( ) ]"}, - {"id": "lv-hd3c9344b8d41", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "6831fcf41f4944d0b466a24ea907179425d4fc26080803f1fffb72e4f60b954a", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 374, "excerpt": "UPDATE replica_envelopes SET payload=x'00' WHERE rowid=?1"}, - {"id": "lv-hd7d92523e785", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "c7ccec23366bfe885531ec8fdde089ce526c3d76ef218b6d0afc93adef29effd", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 332, "excerpt": "INSERT OR IGNORE INTO checkpoint_envelopes(writer, sequence, envelope_hash, accepted_at_unix_ms, checkpoint) SELECT writer, sequence, envelope_hash, 0, 'invented-checkpoint' FROM replica_envelopes WHE"}, - {"id": "lv-hf1f2db5bb2d7", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "54e858da9e54bacd67337a8f217f715559cb5198794ea5fc4f52879a41a122b3", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 353, "excerpt": "INSERT OR IGNORE INTO retired_envelopes(writer, sequence, envelope_hash) SELECT writer, sequence, {} FROM replica_envelopes WHERE rowid=?1"}, - {"id": "lv-hfe4cb2c2ce82", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dbe139803841971698d39ea71941fe113fd3b3f72593dcdb1fa905a86702d0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 94, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, {"id": "lv-h06c8c7732dec", "path": "crates/smallclaims/src/store/schema_install_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "smallclaims::store::schema_install_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 138, "excerpt": "SCHEMA"}, {"id": "lv-h228af6311daa", "path": "crates/smallclaims/src/store/schema_install_tests.rs", "kind": "sql-template", "fingerprint": "38231cc4bbe5890b83f211c980c6ba022fbc73804c22a61ec1055e682c4b5c7a", "owner": "smallclaims::store::schema_install_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 227, "excerpt": "CREATE TABLE SQLITEbirds(value TEXT);"}, {"id": "lv-h25d326f14966", "path": "crates/smallclaims/src/store/schema_install_tests.rs", "kind": "sql-template", "fingerprint": "b077f5f3b187130c621611107048b1c05d928013cb39bc5b0b3c979a7724c52e", "owner": "smallclaims::store::schema_install_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 261, "excerpt": "CREATE TABLE {reserved}(value TEXT);"}, @@ -4973,6 +4803,128 @@ {"id": "lv-h67d08b9ee5cc", "path": "crates/smallclaims/src/sync/worker_tests.rs", "kind": "sql-template", "fingerprint": "8cb1072302449a5baf37c7c3ca58a673ba8c8d3a0d1f626e613242bceecefc1d", "owner": "smallclaims::sync::worker_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 325, "excerpt": "INSERT INTO checkpoint_envelopes VALUES (?1, ?2, ?3, 0, ?4)"}, {"id": "lv-h87aeaa1b47d9", "path": "crates/smallclaims/src/sync/worker_tests.rs", "kind": "sql-template", "fingerprint": "029972fdd18dca28f25e0917621e60fdd3ada3a64a2120fbce23781e7f5a9d84", "owner": "smallclaims::sync::worker_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 6497, "excerpt": "INSERT INTO checkpoints(id, cut_unix_ms, state, updated_at_unix_ms) VALUES('checkpoint/stopped', 1, 'recording', 1)"}, {"id": "lv-hbdb013909404", "path": "crates/smallclaims/src/sync/worker_tests.rs", "kind": "sql-template", "fingerprint": "ccb281347982d4b79b82720bdab10771fceb80efa010cc3520a7c713352f012e", "owner": "smallclaims::sync::worker_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6300, "excerpt": "INSERT OR IGNORE INTO retired_claim_ids(id) VALUES ({})"}, - {"id": "lv-hd2b7ba01e655", "path": "crates/smallclaims/src/sync/worker_tests.rs", "kind": "sql-template", "fingerprint": "c566c22d610a0cf62343d352127afaa3c88110569558442bbb6bb71a766f41d5", "owner": "smallclaims::sync::worker_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 489, "excerpt": "INSERT INTO checkpoint_envelopes VALUES (?1, ?2, ?3, 0, 'checkpoint/example')"} + {"id": "lv-hd2b7ba01e655", "path": "crates/smallclaims/src/sync/worker_tests.rs", "kind": "sql-template", "fingerprint": "c566c22d610a0cf62343d352127afaa3c88110569558442bbb6bb71a766f41d5", "owner": "smallclaims::sync::worker_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 489, "excerpt": "INSERT INTO checkpoint_envelopes VALUES (?1, ?2, ?3, 0, 'checkpoint/example')"}, + {"id": "lv-h5caea1023c5d", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "sql-template", "fingerprint": "61c7e4921e163637a5cf08315ec343cc3de259918284fe88926d336c6e0e7470", "owner": "fractal_store::store::local_trim_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 1127, "excerpt": "INSERT INTO checkpoints(id, cut_unix_ms, state, updated_at_unix_ms) VALUES ('checkpoint/example-step', 0, 'trimming', 0) ON CONFLICT(id) DO NOTHING"}, + {"id": "lv-h6cc0bf36e69e", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "6db7d803e74f1ffa7d8f5adc0bf95b3e15bf4c8373fffadf546227cc6c6742cb", "owner": "fractal_store::store::local_trim_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1222, "excerpt": "before"}, + {"id": "lv-hb859fde7dd63", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "sql-template", "fingerprint": "609090e63ced832d370303d09de3bd545aa2a745423345066600d7902b1ad0fb", "owner": "fractal_store::store::local_trim_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1270, "excerpt": "INSERT OR IGNORE INTO local_resource_projection_pending SELECT DISTINCT subject FROM claims WHERE kind='resource.observed'"}, + {"id": "lv-h60b7cd1314a2", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "d5e90e009630aa3b7cc98b8129c8209ca6f3736dce850af637ba17b9736b4c02", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 376, "excerpt": "[ subject ]"}, + {"id": "lv-h7c39eb662d6b", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "69a19bd7c2c484a9c5c2b150e5ce2b93c16ff0e35862c1ec418962f5e34afb58", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 359, "excerpt": "[ subject ] , | row | row . get : : < _ , bool > ( 0 )"}, + {"id": "lv-h820a8d8b9bbc", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "1b182fb220654b715cfb048550bb344886eef024f125498cda707531421b57a5", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 340, "excerpt": "[ & claim . id ] , | row | row . get : : < _ , Option < bool > > ( 0 )"}, + {"id": "lv-hdbfe4e55565b", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "98d2370affba534c8f897f58f7dfdc16dcbdf1fa5d6acf32dc8bd40605d906ae", "owner": "fractal_store::store::resources", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 375, "excerpt": "DELETE FROM local_resource_projection_pending WHERE subject=?1"}, + {"id": "lv-hdc89a1f149f2", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "85313148be4be4d3c3fe7c96b055dd74e399b45247302afe5e4ad97e462d3c0d", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 364, "excerpt": "[ subject ] , | row | row . get : : < _ , String > ( 0 )"}, + {"id": "lv-hff2c7d840c05", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "982eb36d7cae2acfcb79e884416a545c64b431d3d4f080fcd74460cb8494de97", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 339, "excerpt": "TRIMMED_LEAVES_PROJECTION"}, + {"id": "lv-h71bb2b8a8c50", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "unresolved-sql-sink", "fingerprint": "e0fa34ec049d5b6555c4acfa58ecbe54ea1bba8e20b5dfe301fc57a8c9077dd8", "owner": "smallclaims::store::checkpoint_trim", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1602, "excerpt": "[ & cursor . claim ] , | row | { Ok ( deletable_claim ( row . get ( 0 ) ? , row . get ( 1 ) ? , row . get ( 2 ) ? , row . get ( 3 ) ? ) ) }"}, + {"id": "lv-h74964d9b8b2a", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "unresolved-sql-sink", "fingerprint": "4e0ea4cf319f317f6c1e1a4907deb9f4be779be8f30105c5eea73f14881f32f5", "owner": "smallclaims::store::checkpoint_trim", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1652, "excerpt": "params ! [ walk . claim , walk . checkpoint ] , | row | { Ok ( ( row . get : : < _ , i64 > ( 0 ) ? , deletable_claim ( row . get ( 1 ) ? , row . get ( 2 ) ? , row . get ( 3 ) ? , row . get ( 4 ) ? ) )"}, + {"id": "lv-h175352b3edc3", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "45c22b9ad5d861cda111564b63c37f3e96f76a87b8ae5dadf42ace4d40c3a263", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 10, "excerpt": "CREATE TABLE checkpoint_envelopes(writer TEXT, sequence INTEGER, envelope_hash TEXT, accepted_at_unix_ms INTEGER, PRIMARY KEY(writer,sequence,envelope_hash)); CREATE TABLE replica_envelopes(writer TEX"}, + {"id": "lv-h7dfb6529d1bf", "path": "crates/smallclaims/src/store/checkpoint_trim/pacing_tests.rs", "kind": "sql-template", "fingerprint": "c4a19012c1619f5c2e33e7c4042c00f4d76fd740fda192886dfba4b83a4e1b41", "owner": "smallclaims::store::checkpoint_trim::pacing_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 219, "excerpt": "CREATE TABLE checkpoint_envelopes(writer TEXT, sequence INTEGER, envelope_hash TEXT, accepted_at_unix_ms INTEGER, checkpoint TEXT, PRIMARY KEY(writer,sequence,envelope_hash)); CREATE TABLE replica_env"}, + {"id": "lv-h3ebd622ad4da", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "sql-template", "fingerprint": "38efab1a4a2f66e9a448c36d834753542c9c19f00e0e9136de70cd41defe9fa3", "owner": "smallclaims::store::saved_inventory", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 33, "excerpt": "INSERT INTO meta(key,value) VALUES('{CHANGES}','1') ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER)+1;"}, + {"id": "lv-h4b84af8077fd", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "sql-template", "fingerprint": "521403d1c72e595af43f03fd14937411113ed94d3145efa15e9ac742537c43c7", "owner": "smallclaims::store::saved_inventory", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 56, "excerpt": "CREATE TRIGGER IF NOT EXISTS {table}_saved_inventory_{event} AFTER {event} ON {table} {condition} BEGIN {count} END;"}, + {"id": "lv-h9adce74dc7c8", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "unresolved-sql-sink", "fingerprint": "613aa558f750d5e463dac1886e9efdd80aa316c31e405e95831abbdff1038c23", "owner": "smallclaims::store::saved_inventory", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 67, "excerpt": "[ CHANGES ] , | row | row . get ( 0 )"}, + {"id": "lv-h166835b7b8cb", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "unresolved-sql-sink", "fingerprint": "fca5d96f6c68c4cc7e0a68c2245ed850d4a3f62fc48485354ad7706c45b31ac4", "owner": "smallclaims::store::saved_inventory", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 73, "excerpt": "[ rowid ] , | row | { Ok ( ReplicaEnvelopeId { writer : row . get ( 0 ) ? , sequence : row . get ( 1 ) ? , hash : row . get ( 2 ) ? , } ) }"}, + {"id": "lv-ha6479e16f374", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "unresolved-sql-sink", "fingerprint": "e4cc6f68708be39c42f71efa03a4b2489d2136fb0df4dde2ad74901da72ddc78", "owner": "smallclaims::store::saved_inventory", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 87, "excerpt": "[ SAVED ] , | row | row . get ( 0 )"}, + {"id": "lv-hcc1be495eace", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "sql-template", "fingerprint": "7cab30c56eaaeaff633e8c60a3041db22052ac2db51a797932dc59128b04a4fe", "owner": "smallclaims::store::saved_inventory", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 164, "excerpt": "INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value"}, + {"id": "lv-hfe4cb2c2ce82", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dbe139803841971698d39ea71941fe113fd3b3f72593dcdb1fa905a86702d0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 94, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, + {"id": "lv-h5a19c2f3c52b", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dbe139803841971698d39ea71941fe113fd3b3f72593dcdb1fa905a86702d0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 120, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, + {"id": "lv-h452c69f7277e", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "ff40d50ac0346a0b517aa10f940685412705736fcb72489f0d3852d04fbd1227", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 143, "excerpt": "INSERT INTO checkpoint_envelopes(writer, sequence, envelope_hash, accepted_at_unix_ms, checkpoint) SELECT writer, sequence, envelope_hash, 0, 'invented-checkpoint' FROM replica_envelopes WHERE rowid=("}, + {"id": "lv-h7adac784a2b7", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dbe139803841971698d39ea71941fe113fd3b3f72593dcdb1fa905a86702d0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 152, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, + {"id": "lv-h61ed8d5bde9c", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "9bb555442b5537e23740d40edf8a162823f09cd82bf1e807c446dab79a30406a", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 217, "excerpt": "\"VACUUM INTO ?1\" , [ copy . to_str ( ) . unwrap ( ) ]"}, + {"id": "lv-h806c710cfd5d", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "629c6da4ae9f3a58fdfe822b769338b6e07ec4868d70c89e9fbe96ab702d0eac", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 269, "excerpt": "if below_the_tail { \"SELECT MIN(rowid)-1 FROM replica_envelopes\" } else { \"SELECT MAX(rowid)+1 FROM replica_envelopes\" } , [ ] , | row | row . get ( 0 ) ,"}, + {"id": "lv-h3a37f31e4de5", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "a29611e7374e609ad6cd8f40b024bcdedf914f833c9c54f1d831110b229cff2f", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 281, "excerpt": "INSERT INTO replica_envelopes(rowid, {COLUMNS}) SELECT ?1, 'invented-peer', sequence+1000000+?1, ?2, previous_hash, accepted_at_unix_ms, payload, batch_id, relay, receipt_state, validation_error, rece"}, + {"id": "lv-h4e5b95e07354", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dc7ee7721a24a4ca72df1d12c1beb80223bebf763d06d85f6d06f457ca0a707a", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 317, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=?1"}, + {"id": "lv-h0b5217008635", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "28838a88a0177884828bc31eee05b5f57c2034fb63999a38b8457d185c4245a5", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 323, "excerpt": "UPDATE replica_envelopes SET envelope_hash=?2 WHERE rowid=?1"}, + {"id": "lv-hd7d92523e785", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "c7ccec23366bfe885531ec8fdde089ce526c3d76ef218b6d0afc93adef29effd", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 332, "excerpt": "INSERT OR IGNORE INTO checkpoint_envelopes(writer, sequence, envelope_hash, accepted_at_unix_ms, checkpoint) SELECT writer, sequence, envelope_hash, 0, 'invented-checkpoint' FROM replica_envelopes WHE"}, + {"id": "lv-h8d0cb791bb5d", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "9d844808085be2a2fc5bf06713616c587f02e1ded35b223d8fe115457c0e203b", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 342, "excerpt": "DELETE FROM checkpoint_envelopes WHERE rowid=(SELECT rowid FROM checkpoint_envelopes ORDER BY rowid LIMIT 1)"}, + {"id": "lv-hf1f2db5bb2d7", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "54e858da9e54bacd67337a8f217f715559cb5198794ea5fc4f52879a41a122b3", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 353, "excerpt": "INSERT OR IGNORE INTO retired_envelopes(writer, sequence, envelope_hash) SELECT writer, sequence, {} FROM replica_envelopes WHERE rowid=?1"}, + {"id": "lv-h8af984feac27", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "4f9649ae2e9be6d051460d4973e3931ebab743819f09f72a6b931b1dc217af39", "owner": "smallclaims::store::saved_inventory_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 365, "excerpt": "INSERT INTO meta(key,value) VALUES(?1, CASE WHEN EXISTS(SELECT 1 FROM meta WHERE key=?1 AND value='1') THEN '0' ELSE '1' END) ON CONFLICT(key) DO UPDATE SET value=excluded.value"}, + {"id": "lv-hd3c9344b8d41", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "6831fcf41f4944d0b466a24ea907179425d4fc26080803f1fffb72e4f60b954a", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 374, "excerpt": "UPDATE replica_envelopes SET payload=x'00' WHERE rowid=?1"}, + {"id": "lv-hafcb47196507", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "bd65c238666820a4ad5e3c87858807f0b6fe9cbf786e229d4ffdf811660fdf8f", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 379, "excerpt": "UPDATE checkpoint_envelopes SET accepted_at_unix_ms=accepted_at_unix_ms+1"}, + {"id": "lv-ha3d4449c40cb", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "cacab32515e03b567c70397405b17c7a16e0a55e7c353cd8aa28c83e35587eba", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 445, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid)+1 FROM replica_envelopes)"}, + {"id": "lv-h096b3ac921e0", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dbe139803841971698d39ea71941fe113fd3b3f72593dcdb1fa905a86702d0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 449, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, + {"id": "lv-hc55e64044a0a", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "c8017bfbcf445d5c97813842118171d1a36b65729c48b92a627784ae443c2866", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 454, "excerpt": "UPDATE replica_envelopes SET envelope_hash=?1 WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, + {"id": "lv-hbdb15363782d", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "32f17f45dc1b61171faa3a7663e5131d9f07ad59b4f6734e37351653c9c91782", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 461, "excerpt": "INSERT INTO checkpoint_envelopes(writer, sequence, envelope_hash, accepted_at_unix_ms, checkpoint) SELECT writer, sequence+1000000, ?1, 0, 'invented-checkpoint' FROM replica_envelopes LIMIT 1"}, + {"id": "lv-hc29e4e23e9a9", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "ccab45845332dabd90250cbe1e03bec484b59729f3c7f67c4cdc3afa1091374b", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 468, "excerpt": "DELETE FROM checkpoint_envelopes WHERE sequence>=2000000"}, + {"id": "lv-h89145e643a90", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "6972dcbe1981439c50004941304392991f4b835e7c273105cb3287dede71d3a4", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 471, "excerpt": "UPDATE checkpoint_envelopes SET envelope_hash=?1 WHERE sequence>=2000000"}, + {"id": "lv-h407916e8f5b5", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "7f803950b8fa2362a48e086aa1ce1a57897c8178474b36611a3a3cf3d711d21e", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 476, "excerpt": "INSERT INTO retired_envelopes(writer, sequence, envelope_hash) SELECT writer, sequence, {} FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid)+2 FROM replica_envelopes)"}, + {"id": "lv-h8db85480d0de", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "8bbd85ba47be92c05d4e3c1e1b2390a1c90221e2ae5323b9da0392e28cc2a0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 485, "excerpt": "DELETE FROM retired_envelopes"}, + {"id": "lv-h9bc0d44f925c", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "b3146e6453d128849b29448d71f9ae9c7fdd07692029bee76e7de8983a3056ac", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 488, "excerpt": "UPDATE retired_envelopes SET sequence=sequence+1000000"}, + {"id": "lv-hc7bd248137ba", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "57c2ffd7954f820ad67310c908d441972afa00902422bbf414e72606c0815ab4", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 506, "excerpt": "INSERT INTO meta(key,value) VALUES('{}','1') ON CONFLICT(key) DO UPDATE SET value='1'; INSERT INTO checkpoint_envelopes(writer, sequence, envelope_hash, accepted_at_unix_ms, checkpoint) SELECT writer,"}, + {"id": "lv-h6e2aa55bdef6", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "c0bb05e069d39f6c90ce0db307317df0cd80c18fe10ad85546610306260309ed", "owner": "smallclaims::store::saved_inventory_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 533, "excerpt": "DROP TRIGGER {trigger};"}, + {"id": "lv-h4f4bee3873b0", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2ed38e42f1e6e30cad8c6811faca3e1fd080d02f8156ecd1bf22a4b735c4336d", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 564, "excerpt": "\"VACUUM INTO ?1\" , [ backup . to_str ( ) . unwrap ( ) ]"}, + {"id": "lv-hc8cf34d194ca", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "ce18196812ec0ab75683b21ca6fec4399412d8fe24255dd34a4df7fde5c0ce95", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 572, "excerpt": "\"VACUUM INTO ?1\" , [ saved_state . to_str ( ) . unwrap ( ) ]"}, + {"id": "lv-hc31b549dff4f", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "0e8b547739fe2aa1896c790dc7eac3f14c04c4cce3a10a97eaf96601b6bf57c5", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 594, "excerpt": "\"ATTACH ?1 AS saved\" , [ saved_state . to_str ( ) . unwrap ( ) ]"}, + {"id": "lv-h547ee089c932", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "9f03b5ea7e22b57ebde2ad9f2705502e3c25c938f16a73e73614f4c051c70bfc", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 598, "excerpt": "INSERT INTO replica_envelopes(rowid, {COLUMNS}) SELECT rowid, {COLUMNS} FROM saved.replica_envelopes WHERE rowid=?1"}, + {"id": "lv-h08ed1d887f98", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2f1cae44a44a5b5e699c4adeca1f6fac45ac03b6b0e3c4e5f611ea514988f0db", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 603, "excerpt": "\"DETACH saved;\""}, + {"id": "lv-h94ad7e165653", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "cadf048a7d331020e531b523809c985a86bc2e6a51036b71d5e7a2b0f20cccf2", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 617, "excerpt": "INSERT INTO meta(key,value) VALUES('replication_inventory_saved',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value"}, + {"id": "lv-h038b38395151", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "78ae61633f00f08f0c8d2bb0565aeb462cbc8f4016079182dc95cacf251bd941", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 652, "excerpt": "DELETE FROM replica_envelopes WHERE writer='invented-peer'"}, + {"id": "lv-h6c4fd8d7681d", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "9bb555442b5537e23740d40edf8a162823f09cd82bf1e807c446dab79a30406a", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 663, "excerpt": "\"VACUUM INTO ?1\" , [ copy . to_str ( ) . unwrap ( ) ]"}, + {"id": "lv-h9e1cf8a7d6ec", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "4f2cbeb305171677fe84fda3c8a09458142b5d4dcdf8c6b0d26a68c36c3343b4", "owner": "smallclaims::store::saved_inventory_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 674, "excerpt": "DROP TRIGGER {name};"}, + {"id": "lv-h28bd1a5d15fb", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "d8c07a9a3bd4948049486819192015c314d0a617ea538ab66f285ebd7b75a291", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 678, "excerpt": "CREATE TEMP TABLE held AS SELECT {COLUMNS} FROM replica_envelopes ORDER BY rowid; DELETE FROM replica_envelopes; INSERT INTO replica_envelopes({COLUMNS}) SELECT {COLUMNS} FROM held;"}, + {"id": "lv-h94183eda703e", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "4ec7c53222c8a758c722e2111541035ce700d5ae7bd0898c5f1b1a743e6450fd", "owner": "smallclaims::store::saved_inventory_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 684, "excerpt": "sql"}, + {"id": "lv-habb792328cd1", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "dbe139803841971698d39ea71941fe113fd3b3f72593dcdb1fa905a86702d0c7", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 718, "excerpt": "DELETE FROM replica_envelopes WHERE rowid=(SELECT MIN(rowid) FROM replica_envelopes)"}, + {"id": "lv-hbfab6aec8e39", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "42a84e0cb9cdc530c12641c23386340623a571bd146870ee43e67972ddea93b8", "owner": "smallclaims::store::receive_writer_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 712, "excerpt": "DELETE FROM meta WHERE key='batch_rowid_high_water'; UPDATE meta SET value='8' WHERE key='seeded_batch_rowid';"}, + {"id": "lv-hf34b402ef773", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "90b7695d1f748b876dca6a75e6ac165c3450322fcd7f23c3dca15d6b3baa070b", "owner": "smallclaims::store::receive_writer_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 725, "excerpt": "CREATE TEMP TRIGGER refuse_frontier BEFORE INSERT ON main.meta WHEN NEW.key='seeded_batch_rowid' BEGIN SELECT RAISE(ABORT, 'invented failure'); END;"}, + {"id": "lv-hc37f5c2feb24", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "fb3af4185b5fc989bb50efc1c8034f642e8cfd986f9b5af120119cc7ddf7d93b", "owner": "smallclaims::store::receive_writer_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 734, "excerpt": "DROP TRIGGER temp.refuse_frontier;"}, + {"id": "lv-h16daeb7e7a0e", "path": "crates/fractal-evidence/src/store/evidence.rs", "kind": "unresolved-sql-sink", "fingerprint": "859af7c67269885a5dc98951007e55b4e941c8e7bc612fa15872caa7e5749bcc", "owner": "fractal_evidence::store::evidence", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 435, "excerpt": "& format ! ( \"SELECT DISTINCT subject, origin, owner FROM {source}\" )"}, + {"id": "lv-h08f0f6be5a85", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "a0a89c4f77060917642384814b6cc33680c6a652ead2ac63c03453bf287246d4", "owner": "fractal_store::store::gate_runners", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 62, "excerpt": "CREATE TABLE IF NOT EXISTS local_gate_runners ( claim_id TEXT PRIMARY KEY, subject TEXT NOT NULL, origin TEXT NOT NULL, owner TEXT NOT NULL ) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS local_gate_runne"}, + {"id": "lv-h542f0da74953", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "defd92c8243108c83b75211feb275395f6c34f9832c4a7304fbf04884577bab8", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 80, "excerpt": " BEGIN INSERT OR IGNORE INTO local_gate_runners SELECT NEW.id, NEW.subject, NEW.origin, "}, + {"id": "lv-h65b57ea9cc03", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "cffb5ce87d2247da4f482e3486c15fe345ced4d8161ac9f978815b858e5fe68f", "owner": "fractal_store::store::gate_runners", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 82, "excerpt": "; END; CREATE TRIGGER IF NOT EXISTS gate_runners_stop_insert AFTER INSERT ON claims WHEN "}, + {"id": "lv-h37c63cda84b7", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "46ef611ef7d162d6233c0568aa0ea5d1fca1ef05a3561513fb9f1138cf4a631a", "owner": "fractal_store::store::gate_runners", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 85, "excerpt": " BEGIN DELETE FROM local_gate_runners WHERE subject=NEW.subject; END; CREATE TRIGGER IF NOT EXISTS gate_runners_claim_delete AFTER DELETE ON claims WHEN OLD.kind='gate.requested' OR "}, + {"id": "lv-hd4ba7299a574", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "93b8b240ca38baf6e0ccfa12dd287a7a4e6f4ddf5315419458a129892acfcdab", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 89, "excerpt": " BEGIN DELETE FROM local_gate_runners WHERE claim_id=OLD.id; INSERT OR IGNORE INTO local_gate_runners SELECT request.id, request.subject, request.origin, "}, + {"id": "lv-h7b6d9491384e", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "492c792b9519ba2d6c506167f3c48969c7b542c60f9d4efc1a30710c04a36aed", "owner": "fractal_store::store::gate_runners", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 95, "excerpt": "; END; CREATE TRIGGER IF NOT EXISTS gate_runners_claim_update AFTER UPDATE OF id, subject, kind, origin, body ON claims WHEN OLD.kind IN ('gate.requested','runtime.observed') OR NEW.kind IN ('gate.req"}, + {"id": "lv-hb85f5f8cc048", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "0de6ce18412045d61a0eaec711333a39046e68ca9308fc9e6cf658cf5fd7a997", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 101, "excerpt": "; INSERT OR IGNORE INTO local_gate_runners SELECT request.id, request.subject, request.origin, "}, + {"id": "lv-h1a3d7af4e5fb", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "unresolved-sql-sink", "fingerprint": "bbfac8f07b9a24bc278443aa0d496ef7bbd0019ed35cee7698f44b358102850e", "owner": "fractal_store::store::gate_runners", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 120, "excerpt": "[ ] , | row | row . get ( 0 )"}, + {"id": "lv-hd42874953f0f", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "fractal_store::store::gate_runners", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 125, "excerpt": "SCHEMA"}, + {"id": "lv-had5c98a5f302", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "7e12df6e3ed2fb9a1710a2b5d070afed6cd54cff54b2a9c4c239a577dcfb52a0", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 131, "excerpt": "DELETE FROM local_gate_runners"}, + {"id": "lv-hf10b389f1752", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "335ed1171e10a6e9be2f362ae1ae11019cbd2b6aeaa91ef983bc0b9ce9511dba", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 132, "excerpt": "INSERT OR IGNORE INTO local_gate_runners {FROM_CLAIMS}"}, + {"id": "lv-hfa75362b6110", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "7a5fe3587a36e4a71c82f07f03c03c086c7e30663febef5a52c5d8e3e1efdfed", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 134, "excerpt": "UPDATE local_gate_runners_backfill SET cursor=ceiling, complete=1 WHERE singleton=1"}, + {"id": "lv-h2df7510c47cd", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "unresolved-sql-sink", "fingerprint": "82c7f685a2bedf2548325d89525f888916dba0d7273a1111f5a7b8e98311a11c", "owner": "fractal_store::store::gate_runners", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 165, "excerpt": "& format ! ( \"SELECT claim_id FROM ({FROM_CLAIMS} AND request.store_index>?1 AND request.store_index<=?2)\" )"}, + {"id": "lv-hd3c38661fdf1", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "847f8e19ab04cf92777df8292b4f9365378f96832c2a119526caf6e136ce5b31", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 178, "excerpt": "INSERT OR IGNORE INTO local_gate_runners {FROM_CLAIMS} AND request.id=?1"}, + {"id": "lv-h88b1908ca9f0", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "unresolved-sql-sink", "fingerprint": "018842dbda78636acc5fcdc36fd5a67092599daa2359c2d5ad1b38011f3fb01d", "owner": "fractal_store::store::gate_runners", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 181, "excerpt": "[ id ]"}, + {"id": "lv-h8673761c3666", "path": "crates/fractal-store/src/store/gate_runners.rs", "kind": "sql-template", "fingerprint": "808fad6da03d993b451a056dfca7e8a919733c5f2f799dae79fb9413f8edcd29", "owner": "fractal_store::store::gate_runners", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 186, "excerpt": "UPDATE local_gate_runners_backfill SET cursor=?1, complete=?2 WHERE singleton=1 AND complete=0"}, + {"id": "lv-hae6010f8fa83", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "4ec7c53222c8a758c722e2111541035ce700d5ae7bd0898c5f1b1a743e6450fd", "owner": "fractal_store::store::gate_runners_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 41, "excerpt": "sql"}, + {"id": "lv-h9702b2285d87", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::gate_runners_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 79, "excerpt": "DELETE FROM claims WHERE id=?1"}, + {"id": "lv-hdcff2425c62f", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::gate_runners_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 83, "excerpt": "DELETE FROM claims WHERE id=?1"}, + {"id": "lv-h24c98c95d905", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "ad130234b57a4020d27768b59106562e1d1e5412a3aeb70595a58171d875e737", "owner": "fractal_store::store::gate_runners_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 90, "excerpt": "UPDATE claims SET body=json_set(body, '$.fields.runner', 'manual') WHERE kind='gate.requested' AND subject='gate-operation/step-run.b/metric'"}, + {"id": "lv-h66ea9662b56e", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "fcbe5eb889a08e4faf55e51dd36e0600a40fd81262b069d7036fea70df4fd1aa", "owner": "fractal_store::store::gate_runners_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 110, "excerpt": "DROP TRIGGER gate_runners_request_insert; DROP TRIGGER gate_runners_stop_insert; DROP TRIGGER gate_runners_claim_delete; DROP TRIGGER gate_runners_claim_update; DROP TABLE local_gate_runners; DROP TAB"}, + {"id": "lv-h1abbf8648a66", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "7e12df6e3ed2fb9a1710a2b5d070afed6cd54cff54b2a9c4c239a577dcfb52a0", "owner": "fractal_store::store::gate_runners_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 140, "excerpt": "DELETE FROM local_gate_runners"}, + {"id": "lv-hc0ab63c1e6fe", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "35b3b55dc0d41a833a72f4009dfed3434aa87154d6b9b83da24b2d728d42ad32", "owner": "fractal_store::store::gate_runners_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 149, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {sql}\" )"}, + {"id": "lv-hc38ba81818b8", "path": "crates/fractal-store/src/store/custom.rs", "kind": "sql-template", "fingerprint": "802e9b6c439ed31cbe17937214d40aab34f530e8a99f226e8a0816eb817e7038", "owner": "fractal_store::store::custom", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 219, "excerpt": "CREATE TRIGGER {name} AFTER {action} ON main.replica_records WHEN {relevant} BEGIN SELECT custom_work_pending(); END"}, + {"id": "lv-hb43fa7777c18", "path": "crates/fractal-store/src/store/custom.rs", "kind": "sql-template", "fingerprint": "1f514b378faf2489e920f369d4937c5fcaac36c47b1a81f88cfc15f2e1d2c40c", "owner": "fractal_store::store::custom", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 206, "excerpt": "CREATE TRIGGER {name} AFTER {action} ON main.{table} BEGIN SELECT custom_work_pending(); END"}, + {"id": "lv-h248251de0d26", "path": "crates/fractal-store/src/store/custom.rs", "kind": "sql-template", "fingerprint": "793bab6d66c0cce14b8837968e9f87de7e540b3ffca4d32f23940b41f890b279", "owner": "fractal_store::store::custom", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 231, "excerpt": "CREATE TRIGGER "}, + {"id": "lv-h4711b40b27f6", "path": "crates/fractal-store/src/store/custom.rs", "kind": "sql-template", "fingerprint": "9ba12afb3a52c845c258302b48b1df355ba51702259d0df43f1075805228b193", "owner": "fractal_store::store::custom", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 232, "excerpt": "CREATE TEMP TRIGGER IF NOT EXISTS {body}"}, + {"id": "lv-h99223601f75d", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "3313a4244e660e8bce68288552aecf468f2b8f10f81ac9376ca4e793f960b56a", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 406, "excerpt": "UPDATE meta SET value=value WHERE key='custom_projections_version'"}, + {"id": "lv-hc35f31df4335", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "6555751b12932d4307b6695236bf2a92268e81d4540910ae09386f146c9389b7", "owner": "fractal_store::store::notice_authority_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 434, "excerpt": "\"ROLLBACK TO notice_flush; RELEASE notice_flush\""}, + {"id": "lv-he4c6451c597a", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "a12c40ac0c1a67f92003290fbea8bc03339215e6d797cc2fc6146d627e6d5d8d", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 429, "excerpt": "INSERT INTO local_custom_dirty VALUES(?1)"}, + {"id": "lv-hfece32705544", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "ff4b66d6eb272876fcd1ad2de49577612ca688ec30ab306632bbce160bd3d06b", "owner": "fractal_store::store::notice_authority_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 432, "excerpt": "\"SAVEPOINT notice_flush\""}, + {"id": "lv-h9f0db61bdab1", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "e6fcbe8e6418ed858cfed558287b77774cd5ec41680d08abc9d468597889e4b1", "owner": "fractal_store::store::gate_runners_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 188, "excerpt": "DROP INDEX claims_subject_kind_index; DROP INDEX claims_subject_kind_accepted_index;"}, + {"id": "lv-h4b8a1cb72b7d", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "351948fde00a6d06954a50a2e6f1ae27cf8a007890ad7927bd0c8e816c494332", "owner": "fractal_store::store::status_summary_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 116, "excerpt": "UPDATE desired SET owner_run='mission-run/example-owner', owner_generation='run-generation/example-owner', owner_step='step-run/example-owner/work' WHERE subject=?1"}, + {"id": "lv-h7da5f6391087", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "c9a09b11ff6ab079fbc412af4f0d90b393bd1fcbe770fc4c3a7fcdbdb56c2bc2", "owner": "fractal_store::store::status_summary_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 157, "excerpt": "INSERT INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES (?1,?2,?3,'fixture',?4)"}, + {"id": "lv-h98e5236bc06a", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "943200faae9d766f1ed785b06b2d7ae3aa80f49f8cad7d2faef5e72696618c9e", "owner": "fractal_store::store::status_summary_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 167, "excerpt": "INSERT INTO claims(id,batch_id,subject,kind,origin,actor,body,predecessors,accepted_at_unix_ms) VALUES (?1,?2,?3,?4,?5,?3,?6,'[]',?7)"}, + {"id": "lv-h6fb9eeec970c", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "64fd5e02b89ad6757e2772d8bfa381a3f3a4bc414cc707362d0d3724ef8d5530", "owner": "fractal_store::store::status_summary_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 172, "excerpt": "INSERT INTO projection_digest_repaired_claims(id) VALUES (?1)"}, + {"id": "lv-h7554008ab79c", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "692b891eaf4f608d7adcbc24e4f06d36a27ed830efcde23ff111aa095833860d", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12016, "excerpt": "& format ! ( \"SELECT claims.store_index FROM claims INDEXED BY claims_subject_kind_index JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND {} AND claims.store_index<=?2 ORDER BY {"}, + {"id": "lv-hc44243b75520", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "7ae4c4eedbe72dbad7b2d1f7d4ecac8b5d0fd8382ea9bb6b970fea1e13d03878", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12073, "excerpt": "& fields_sql"}, + {"id": "lv-h18ab046ede43", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "d916eeef4f4b8829f02d4fc853091aba2e3090cfb6db676adf9f8ba0b214c6fa", "owner": "fractal_api_core::api::tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 13476, "excerpt": "INSERT INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES ('summary-notice','node',9000,'fixture','1700000000000'); INSERT INTO claims(id,batch_id,subject,kind,origin,body,predec"}, + {"id": "lv-h06cff359fe46", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "ed224edd1628f31bdd5920a8c2358945dc6202f6a470940a4b4df6b1f0e525a2", "owner": "fractal_store::store::status_summary_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 245, "excerpt": "INSERT INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES ('kinds','node',9000,'fixture','1700000000000')"}, + {"id": "lv-h87bbf137f13b", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "1b8b1b6029e43a1336fdf9d9e5bae289cf1ee70997dbaf6985975c1ded0ffe51", "owner": "fractal_store::store::status_summary_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 102, "excerpt": "INSERT INTO claims(id,batch_id,subject,kind,origin,body,predecessors,accepted_at_unix_ms) VALUES (?1,?1,?2,?3,?4,?5,'[]',?6)"}, + {"id": "lv-hb4ac3d3059e1", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "c9a09b11ff6ab079fbc412af4f0d90b393bd1fcbe770fc4c3a7fcdbdb56c2bc2", "owner": "fractal_store::store::status_summary_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 96, "excerpt": "INSERT INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES (?1,?2,?3,'fixture',?4)"}, + {"id": "lv-h7dc166900806", "path": "crates/fractal-api-core/src/api/tests.rs", "kind": "sql-template", "fingerprint": "64fd5e02b89ad6757e2772d8bfa381a3f3a4bc414cc707362d0d3724ef8d5530", "owner": "fractal_api_core::api::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 13510, "excerpt": "INSERT INTO projection_digest_repaired_claims(id) VALUES (?1)"}, + {"id": "lv-hce3fba8082e0", "path": "crates/fractal-delivery/src/store/conversation_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "6c51917a0509a1edc88aca17296d8e754b40f9766cd2895fff36fd4680e263d6", "owner": "fractal_delivery::store::conversation_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 126, "excerpt": "RANGE_SUBJECTS"}, + {"id": "lv-h6dbcaf0dfacb", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "9e21cde6396378fa37c214584b230172b04fc2834030ac7e8e0ec92d09d829b4", "owner": "fractal_store::store::status_summary_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 256, "excerpt": "INSERT INTO claims(id,batch_id,subject,kind,origin,body,predecessors,accepted_at_unix_ms) VALUES (?1,'kinds',?2,?3,'node','{}','[]','1700000000000')"}, + {"id": "lv-h16928b5eb896", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "c3a0532ab17a84e1ad0287749b26ea41efe293bb61b1c62a42a4093e94414017", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 320, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3 WHERE subject=?1"}, + {"id": "lv-hb7cf1dc01667", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "39c0ce46943adc6d3fa133a261008f77c00d77aa927d346dd8295ee44b6ef104", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 363, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms=?3 WHERE subject=?1"}, + {"id": "lv-h76038fa53ef8", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "e21446038f569a6eec8b9b9a7e8201f454cd571dd47d6c8c72dedd6f6cbfbb2b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 9598, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker', lease_owner=?2 WHERE subject=?1"}, + {"id": "lv-h2102123ed04c", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 818, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, + {"id": "lv-h28928374ca14", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1d84c831a4aa0ece376bf0e40064fb0bd36cd58e7f04211c859df103c1e40e8c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 823, "excerpt": "UPDATE step_runs SET status=?2 WHERE subject=?1"}, + {"id": "lv-h34894d4454a8", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 994, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, + {"id": "lv-h48c2f2d4b497", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 930, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, + {"id": "lv-h755979e290e9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 523, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, + {"id": "lv-h78e96a5b9d99", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 966, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, + {"id": "lv-h7d7fcc2328ee", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d35271b78634a314d43a3538f1037107387d271a4fa449cfd3f69afad27e28ac", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 937, "excerpt": "UPDATE step_runs SET lease_owner=?2, status=?3 WHERE subject=?1"}, + {"id": "lv-ha0cd354483f3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 868, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, + {"id": "lv-hac330e1ef0a3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "23d384584d619edc1b8135734fe4536e80b585db990943fa67e8dc9b10b08fa9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 666, "excerpt": "UPDATE step_runs SET assignee='agent/node/other', available_to='[]', status='ready', lease_owner=NULL, lease_incarnation=NULL, lease_expires_at_unix_ms=NULL WHERE subject=?1"}, + {"id": "lv-hbbb772f82848", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "f5a08f92b6484e142bfc0fac291d42996b27093a57f64739f414596a86da5406", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 690, "excerpt": "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1"}, + {"id": "lv-hd38e77dd6432", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 811, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, + {"id": "lv-hd60f12cbb0cd", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "2c428368447719337e62bf6baa77578a1f95a5cda24f8fab6b5471192613c58e", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 761, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1"}, + {"id": "lv-hd700e0550a21", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 861, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"} ] } From a0c31f3bf5acc8cf7799774419c9da9f9479956c Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:07:49 +0100 Subject: [PATCH 040/132] fix(missions): use literal selectors for mutation queues Share queue SQL and selection logic while separating literal claim admission and automatic wake decisions from alias-aware read views. Cover a legacy-ready run before canonical work, and literal nested selector handling. Co-authored-by: OpenAI Codex agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-kernel/src/seat_queue.rs | 83 ++++++++++++++++--- crates/fractal-missions/src/store/missions.rs | 9 +- .../src/store/work_renew_tests.rs | 34 ++++++++ crates/fractal-reconcile/src/reconcile.rs | 8 +- crates/fractal-store/src/store.rs | 42 ++++++++-- crates/fractal-store/src/store/missions.rs | 10 ++- docs/st3/large-value-sites.json | 3 +- release-notes/mission-agentid-canonical.json | 2 +- 8 files changed, 160 insertions(+), 31 deletions(-) diff --git a/crates/fractal-kernel/src/seat_queue.rs b/crates/fractal-kernel/src/seat_queue.rs index 6489c7ffc5a..aa3c21c7fa3 100644 --- a/crates/fractal-kernel/src/seat_queue.rs +++ b/crates/fractal-kernel/src/seat_queue.rs @@ -21,11 +21,26 @@ pub fn work_actor_matches(candidate: Option<&str>, actor: &str) -> bool { fractal_schema::canonical_agent_actor(actor).as_deref() == Some(candidate.as_ref()) } -fn same_actor(left: Option<&str>, right: Option<&str>) -> bool { - match (left, right) { - (Some(left), Some(right)) => work_actor_matches(Some(left), right), - (None, None) => true, - _ => false, +#[derive(Clone, Copy)] +enum ActorMatching { + Literal, + Aliases, +} + +impl ActorMatching { + fn matches(self, candidate: Option<&str>, actor: &str) -> bool { + match self { + Self::Literal => candidate == Some(actor), + Self::Aliases => work_actor_matches(candidate, actor), + } + } + + fn same(self, left: Option<&str>, right: Option<&str>) -> bool { + match (left, right) { + (Some(left), Some(right)) => self.matches(Some(left), right), + (None, None) => true, + _ => false, + } } } @@ -234,12 +249,26 @@ impl<'a> SeatSelection<'a> { /// place either: a ready step whose claim by this seat a revision dropped comes /// first until the seat claims it again. pub fn select<'a>(agent: &str, steps: &[SeatStep<'a>], run_order: &[String]) -> SeatSelection<'a> { + select_with_matching(agent, steps, run_order, ActorMatching::Aliases) +} + +/// Select mutation-admission work using the exact persisted actor spellings. +pub fn select_literal<'a>(agent: &str, steps: &[SeatStep<'a>], run_order: &[String]) -> SeatSelection<'a> { + select_with_matching(agent, steps, run_order, ActorMatching::Literal) +} + +fn select_with_matching<'a>( + agent: &str, + steps: &[SeatStep<'a>], + run_order: &[String], + matching: ActorMatching, +) -> SeatSelection<'a> { let held = steps .iter() .filter(|step| { matches!(step.status, "claimed" | "working" | "verifying") - && (work_actor_matches(step.claimant, agent) || work_actor_matches(step.assignee, agent)) - && !submitted_with_ready_nested_work(step, steps) + && (matching.matches(step.claimant, agent) || matching.matches(step.assignee, agent)) + && !submitted_with_ready_nested_work_matching(step, steps, matching) }) .map(|step| step.subject) .collect(); @@ -253,11 +282,13 @@ pub fn select<'a>(agent: &str, steps: &[SeatStep<'a>], run_order: &[String]) -> .iter() .filter(|step| { step.status == "ready" - && work_actor_matches(step.assignee, agent) - && !reached_through_parent(step, steps) + && matching.matches(step.assignee, agent) + && !steps.iter().any(|candidate| { + nests_under_matching(candidate, step, matching) && candidate.status != "verifying" + }) }) .collect::>(); - let waits_its_turn = |step: &SeatStep<'_>| !work_actor_matches(step.carried_claimant, agent); + let waits_its_turn = |step: &SeatStep<'_>| !matching.matches(step.carried_claimant, agent); ready.sort_by(|left, right| { waits_its_turn(left) .cmp(&waits_its_turn(right)) @@ -283,11 +314,20 @@ pub fn reached_through_parent(step: &SeatStep<'_>, steps: &[SeatStep<'_>]) -> bo /// True when `step` is nested under the listed `ancestor` in the same run and /// both have the same selector. pub fn nests_under(ancestor: &SeatStep<'_>, step: &SeatStep<'_>) -> bool { + nests_under_matching(ancestor, step, ActorMatching::Aliases) +} + +/// Compare nested selectors for mutation admission without merging actor aliases. +pub fn nests_under_literal(ancestor: &SeatStep<'_>, step: &SeatStep<'_>) -> bool { + nests_under_matching(ancestor, step, ActorMatching::Literal) +} + +fn nests_under_matching(ancestor: &SeatStep<'_>, step: &SeatStep<'_>, matching: ActorMatching) -> bool { ancestor.run == step.run - && same_actor(ancestor.assignee, step.assignee) + && matching.same(ancestor.assignee, step.assignee) && ancestor.available_to.len() == step.available_to.len() && ancestor.available_to.iter().zip(step.available_to).all(|(left, right)| { - work_actor_matches(Some(left), right) + matching.matches(Some(left), right) }) && ancestor.step.len() < step.step.len() && step.step.starts_with(ancestor.step) @@ -299,10 +339,18 @@ pub fn nests_under(ancestor: &SeatStep<'_>, step: &SeatStep<'_>) -> bool { /// inherited parent message was consumed, and the seat looks occupied. Such a /// parent frees the seat so the ready nested step gets its own wake. pub fn submitted_with_ready_nested_work(parent: &SeatStep<'_>, steps: &[SeatStep<'_>]) -> bool { + submitted_with_ready_nested_work_matching(parent, steps, ActorMatching::Aliases) +} + +fn submitted_with_ready_nested_work_matching( + parent: &SeatStep<'_>, + steps: &[SeatStep<'_>], + matching: ActorMatching, +) -> bool { parent.status == "verifying" && steps .iter() - .any(|step| step.status == "ready" && nests_under(parent, step)) + .any(|step| step.status == "ready" && nests_under_matching(parent, step, matching)) } #[cfg(test)] @@ -464,6 +512,10 @@ mod tests { assignee: Some("agent/node/worker"), created_at_unix_ms: 20, ..first }; let order = vec![first.run.to_owned(), second.run.to_owned()]; + assert_eq!( + select_literal("agent/node/worker", &[second, first], &order).ready, + [second.subject], + ); for actor in ["node/worker", "agent/node/worker"] { assert_eq!(select(actor, &[second, first], &order).ready, [first.subject, second.subject]); let carried = SeatStep { carried_claimant: Some("node/worker"), ..second }; @@ -476,6 +528,11 @@ mod tests { assignee: Some("agent/node/worker"), available_to: &canonical_available, ..first }; assert!(reached_through_parent(&nested, &[first, nested])); + assert_eq!( + select_literal("agent/node/worker", &[first, nested], &order).ready, + [nested.subject], + "an alias-assigned parent must not hide literally assigned nested work", + ); let submitted = SeatStep { status: "verifying", ..first }; assert!(submitted_with_ready_nested_work(&submitted, &[submitted, nested])); for other in ["daemon/node/worker", "person/test", "host/node/worker"] { diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 509acdc5ba5..e94dab7776f 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -3,6 +3,7 @@ use super::*; pub use fractal_delivery::store::missions::*; use fractal_store::store::identities::resolve_reference_conn; +use fractal_store::store::missions::seat_step_rows_literal_tx; use fractal_store::store::{work_actor_matches, work_actor_spellings_json}; mod run_review; @@ -925,15 +926,13 @@ fn seat_order_conflict_tx( agent: &str, subject: &str, ) -> Result> { - let order = seat_run_orders_tx(connection, Some(agent))? - .remove(agent) - .unwrap_or_default(); - let rows = seat_step_rows_tx(connection, Some(agent))?; + let order = fractal_store::store::seat_queue_inputs_literal_tx(connection, agent)?.live_order(); + let rows = seat_step_rows_literal_tx(connection, agent)?; let steps = rows .iter() .map(RosterStepRow::seat_step) .collect::>(); - let selection = seat_queue::select(agent, &steps, &order); + let selection = seat_queue::select_literal(agent, &steps, &order); let Some(next) = selection.next() else { return Ok(None); }; diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index deb381fa6dd..abcdeaf7828 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -984,6 +984,40 @@ fn independent_work_capacity_uses_literal_ownership_for_admission() { } } +#[test] +fn legacy_ready_run_does_not_block_literal_claim_but_remains_visible() { + let first = ready_step_with_mail(0); + first.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h934585192b73 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2, created_at_unix_ms='1' WHERE subject=?1", + params![first.subject, "node/worker"], + ).unwrap(); + let source = "version 2\nmission \"literal-queue\" state=\"ready\" { goal \"Literal queue admission.\"; step \"work\" { assigned-to \"agent/node/worker\"; } }"; + let intent = crate::graph::parse_test_intent(source, "node").unwrap(); + let plan = first.store.mission(&intent, IntentInput { + kdl: source.into(), source_name: None, + }).unwrap(); + first.store.apply(&intent, &plan.subject_tokens, "literal-queue-definition").unwrap(); + let second = first.store.create_mission_run(&MissionRunRequest { + mission: "literal-queue".into(), revision: None, workspace: "/tmp".into(), + requester: Some("person/test".into()), mode: Some("run".into()), + inputs: BTreeMap::new(), idempotency_key: "literal-queue-run".into(), + }).unwrap(); + let second = &second.steps[0].subject; + first.store.set_step_state(second, "ready", None).unwrap(); + let visible = first.store.work(Some("agent/node/worker"), false).unwrap(); + assert_eq!(visible.len(), 2); + assert!(visible.iter().any(|step| step.subject == first.subject)); + assert!(visible.iter().any(|step| step.subject == *second)); + let mut claim = request("literal-queue-claim", None); + claim.actor = Some("agent/node/worker".into()); + let claimed = first.store.work_action(second, "claim", &claim).unwrap(); + assert_eq!(claimed.subject, *second); + assert_eq!(claimed.claimant.as_deref(), Some("agent/node/worker")); + let unchanged = first.store.step_run(&first.subject).unwrap().unwrap(); + assert_eq!(unchanged.status, "ready"); + assert!(unchanged.claimant.is_none()); +} + #[test] fn bare_work_actors_claim_renew_release_and_complete_with_main_normalization() { for bare_actor in ["node.worker", "worker"] { diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index 521a3ef6012..d5eabd23bbe 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -6116,7 +6116,7 @@ impl Reconciler { // The last durable automatic wake records the head already announced; // using its subject in the key also permits returning to an earlier head. let steps = work.iter().map(crate::seat_queue::SeatStep::from).collect::>(); - let selection = crate::seat_queue::select(agent, &steps, &run_order); + let selection = crate::seat_queue::select_literal(agent, &steps, &run_order); let last_wake = messages.iter().filter(|message| { !crate::silent::message_tag_values( message.tags.iter().map(String::as_str), "wake-source:", @@ -20181,7 +20181,7 @@ fn seat_chooses_between_runs(agent: &str, work: &[StepRunView]) -> bool { .iter() .map(crate::seat_queue::SeatStep::from) .collect::>(); - let selection = crate::seat_queue::select(agent, &steps, &[]); + let selection = crate::seat_queue::select_literal(agent, &steps, &[]); if !selection.held.is_empty() { return false; } @@ -20204,7 +20204,7 @@ fn next_work_wake_for_agent<'a>( .iter() .map(crate::seat_queue::SeatStep::from) .collect::>(); - crate::seat_queue::select(agent, &steps, run_order).wake() + crate::seat_queue::select_literal(agent, &steps, run_order).wake() } /// Inherited work is woken only after an early parent submission. A turn that is still working can @@ -20220,7 +20220,7 @@ fn defers_inherited_work_wake( fn nested_under_work(step: &StepRunView, work: &[StepRunView]) -> bool { let nested = crate::seat_queue::SeatStep::from(step); work.iter().any(|ancestor| { - crate::seat_queue::nests_under(&crate::seat_queue::SeatStep::from(ancestor), &nested) + crate::seat_queue::nests_under_literal(&crate::seat_queue::SeatStep::from(ancestor), &nested) }) } diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index aee2f2c7d7d..8f34f7e2516 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -19173,6 +19173,16 @@ pub fn seat_queue_inputs_tx( seat_queue_inputs_of_tx(connection, agent, None) } +/// Queue inputs for mutation admission, without merging actor aliases. +pub fn seat_queue_inputs_literal_tx( + connection: &Connection, + agent: &str, +) -> Result { + Ok(seat_queue_inputs_matching_tx(connection, Some(agent), None, false)? + .remove(agent) + .unwrap_or_default()) +} + #[cfg(any(test, feature = "test-support"))] thread_local! { /// Named-run probes `seat_queue_inputs_of_tx` made on this thread, for tests. @@ -19192,7 +19202,19 @@ pub fn seat_queue_inputs_of_tx( agent: Option<&str>, only: Option<&BTreeSet>, ) -> Result> { - let spellings = agent.map(work_actor_spellings_json); + seat_queue_inputs_matching_tx(connection, agent, only, true) +} + +fn seat_queue_inputs_matching_tx( + connection: &Connection, + agent: Option<&str>, + only: Option<&BTreeSet>, + aliases: bool, +) -> Result> { + let actor_spellings = |actor: &str| { + if aliases { work_actor_spellings_json(actor) } else { json!([actor]).to_string() } + }; + let spellings = agent.map(actor_spellings); let mut seats = BTreeMap::::new(); let mut statement = connection.prepare(/* sqlite-large-value: allow-for-now lv-hd2995294a599 owner=fractal_store::store TODO=trace-statement-and-value-representation */ &seat_queue_moves_query())?; let rows = statement.query_map(params![seat_queue::MOVED_CLAIM, spellings], |row| { @@ -19206,8 +19228,12 @@ pub fn seat_queue_inputs_of_tx( })?; for row in rows { let (claim_id, subject, actor, body, accepted_at) = row?; - let subject = fractal_schema::canonical_agent_actor(&subject) - .map(std::borrow::Cow::into_owned).unwrap_or(subject); + let subject = if aliases { + fractal_schema::canonical_agent_actor(&subject) + .map(std::borrow::Cow::into_owned).unwrap_or(subject) + } else { + subject + }; let body = serde_json::from_str::(&body).unwrap_or(Value::Null); let fields = body.get("fields").unwrap_or(&body); let text = |name: &str| fields.get(name).and_then(Value::as_str).map(str::to_owned); @@ -19255,8 +19281,12 @@ pub fn seat_queue_inputs_of_tx( })?; for row in rows { let (seat, run_id, joined_at, status, current) = row?; - let seat = fractal_schema::canonical_agent_actor(&seat) - .map(std::borrow::Cow::into_owned).unwrap_or(seat); + let seat = if aliases { + fractal_schema::canonical_agent_actor(&seat) + .map(std::borrow::Cow::into_owned).unwrap_or(seat) + } else { + seat + }; let run = format!("mission-run/{run_id}"); let inputs = seats.entry(seat).or_default(); let at_unix_ms = u128::try_from(joined_at).unwrap_or_default(); @@ -19282,7 +19312,7 @@ pub fn seat_queue_inputs_of_tx( }); inputs.joins.dedup_by(|left, right| left.run == right.run); inputs.statuses.retain(|run, _| inputs.live.contains(run)); - let spellings = work_actor_spellings_json(seat); + let spellings = actor_spellings(seat); let moves = inputs .moves .iter() diff --git a/crates/fractal-store/src/store/missions.rs b/crates/fractal-store/src/store/missions.rs index 85ae2777e82..ba0d92dd522 100644 --- a/crates/fractal-store/src/store/missions.rs +++ b/crates/fractal-store/src/store/missions.rs @@ -1772,8 +1772,16 @@ pub fn note_run_view_reads(view: &MissionRunView) { /// Current held and ready agent steps, for every seat or for one seat. pub fn seat_step_rows_tx(connection: &Connection, agent: Option<&str>) -> Result> { + seat_step_rows_with_spellings_tx(connection, agent.map(work_actor_spellings_json)) +} + +/// Current held and ready steps eligible for literal mutation admission. +pub fn seat_step_rows_literal_tx(connection: &Connection, agent: &str) -> Result> { + seat_step_rows_with_spellings_tx(connection, Some(serde_json::to_string(&[agent])?)) +} + +fn seat_step_rows_with_spellings_tx(connection: &Connection, spellings: Option) -> Result> { let snapshot_unix_ms = now_ms(); - let spellings = agent.map(work_actor_spellings_json); let mut statement = connection.prepare_cached(/* sqlite-large-value: allow-for-now lv-h0096cec23ed6 owner=fractal_store::store::missions TODO=trace-statement-and-value-representation */ SEAT_STEP_ROWS)?; let mut rows = statement .query_map(params![spellings, snapshot_unix_ms.to_string()], |row| { diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 736f9337a75..d041171a262 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -4925,6 +4925,7 @@ {"id": "lv-hbbb772f82848", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "f5a08f92b6484e142bfc0fac291d42996b27093a57f64739f414596a86da5406", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 690, "excerpt": "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1"}, {"id": "lv-hd38e77dd6432", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 811, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-hd60f12cbb0cd", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "2c428368447719337e62bf6baa77578a1f95a5cda24f8fab6b5471192613c58e", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 761, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1"}, - {"id": "lv-hd700e0550a21", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 861, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"} + {"id": "lv-hd700e0550a21", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 861, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, + {"id": "lv-h934585192b73", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "386e7e1231a36489f2cceb29bad211b82fb2995a96133e6a5663bffbd895e0aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 991, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms='1' WHERE subject=?1"} ] } diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json index 38ec8b9d108..27e49925272 100644 --- a/release-notes/mission-agentid-canonical.json +++ b/release-notes/mission-agentid-canonical.json @@ -3,7 +3,7 @@ "summary": "Emit canonical agent identities in mission presentation while preserving historical writer spelling", "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical host-relative qualification and malformed assignee normalization rather than rejecting them."}, "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, - "rules": {"status": "changed", "detail": "Work reads, renewal polling and presentation compare canonical identities. All mutations preserve main's actor normalization and literal matching, including ownership admission, eligibility, capacity, queue admission, carried-claim successor selection, output authority and ancestor lease extension. Bare work actors such as node.worker normalize to agent/node.worker before validation and can claim, renew, release and complete literally assigned work. An unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed when the literal eligibility selector admits that actor, without changing claim state, publishing a mutation or extending the operational lease. Alias-owned carried claims do not redirect a mutation from a stale generation; literal owners still reach their carried successor. Successful child mutations extend only literally owned ancestor leases. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed slash-qualified claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, + "rules": {"status": "changed", "detail": "Work reads, renewal polling and presentation compare canonical identities. All mutations preserve main's actor normalization and literal matching, including ownership admission, eligibility, capacity, queue admission, carried-claim successor selection, output authority and ancestor lease extension. Claim-admission queues and automatic work-wake selection use literal selectors, so an earlier legacy-assigned ready step does not block a canonical seat's claim; read and presentation queues still show both spellings. Bare work actors such as node.worker normalize to agent/node.worker before validation and can claim, renew, release and complete literally assigned work. An unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed when the literal eligibility selector admits that actor, without changing claim state, publishing a mutation or extending the operational lease. Alias-owned carried claims do not redirect a mutation from a stale generation; literal owners still reach their carried successor. Successful child mutations extend only literally owned ancestor leases. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed slash-qualified claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, "compatibility": {"status": "changed", "detail": "Expand phase only: read both actor spellings and preserve the previous writer spelling, with no feature gate or capability advertisement. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership: a later spelling switch and stored-row migration require a durable one-way fleet fence. Once raw legacy-signed ownership facts exist, the historical reader floor must prohibit old readers that cannot admit those facts; a reversible capability check cannot establish that floor."}, "downtime": {"status": "none", "detail": "No downtime or stored-row migration is required for this read-compatible expansion."}, "manual": {"status": "none", "detail": "No operator action is required."}, From c13e6302da211678882cc4fb16eb80062a1cb7be Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:06:14 +0100 Subject: [PATCH 041/132] fix(missions): keep wake queue inputs literal Use literal queue joins and moves for wake order, matching claim admission. Preserve alias-aware presentation and cover mixed-identity run ordering. Co-authored-by: OpenAI Codex agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions/src/store/missions.rs | 27 +++++++- crates/fractal-reconcile/src/reconcile.rs | 66 +++++++++++++++++-- crates/fractal-store/src/store.rs | 8 +-- docs/st3/large-value-sites.json | 3 +- release-notes/mission-agentid-canonical.json | 2 +- 5 files changed, 91 insertions(+), 15 deletions(-) diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index e94dab7776f..eefd00dd25d 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -926,7 +926,10 @@ fn seat_order_conflict_tx( agent: &str, subject: &str, ) -> Result> { - let order = fractal_store::store::seat_queue_inputs_literal_tx(connection, agent)?.live_order(); + let order = fractal_store::store::seat_queue_inputs_literal_tx(connection, Some(agent))? + .remove(agent) + .unwrap_or_default() + .live_order(); let rows = seat_step_rows_literal_tx(connection, agent)?; let steps = rows .iter() @@ -1795,6 +1798,9 @@ pub trait MissionsStore { fn seat_run_orders(&self) -> Result>>; /// The live mission runs queued for one seat, in seat-queue order. fn seat_run_order(&self, agent: &str) -> Result>; + /// Literal queue order for wake decisions that must agree with claim admission. + fn seat_run_orders_for_mutation(&self) -> Result>>; + fn seat_run_order_for_mutation(&self, agent: &str) -> Result>; /// Record one move in a seat's queue. The move never touches a claim: a /// held step stays held, and the new order applies to the next selection. fn move_seat_queue_run( @@ -5243,6 +5249,25 @@ impl MissionsStore for Store { .unwrap_or_default()) } + fn seat_run_orders_for_mutation(&self) -> Result>> { + let connection = self.readers.get(); + Ok(fractal_store::store::seat_queue_inputs_literal_tx(&connection, None)? + .into_iter() + .map(|(agent, inputs)| (agent, inputs.live_order())) + .collect()) + } + + fn seat_run_order_for_mutation(&self, agent: &str) -> Result> { + smallclaims::touched::note_read(|| format!("kind:{}", seat_queue::MOVED_CLAIM)); + let agent = normalize_seat(agent); + let connection = self.readers.get(); + let agent = physical_actor(&connection, &agent)?; + Ok(fractal_store::store::seat_queue_inputs_literal_tx(&connection, Some(&agent))? + .remove(&agent) + .unwrap_or_default() + .live_order()) + } + fn move_seat_queue_run( &self, request: &SeatQueueMoveRequest, diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index d5eabd23bbe..3a9d5540e63 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -2882,7 +2882,7 @@ impl Reconciler { if choosing.is_empty() { return Ok(BTreeMap::new()); } - let mut orders = self.store.seat_run_orders()?; + let mut orders = self.store.seat_run_orders_for_mutation()?; orders.retain(|agent, _| choosing.contains(agent.as_str())); Ok(orders) } @@ -6108,7 +6108,7 @@ impl Reconciler { } let run_order = if seat_chooses_between_runs(agent, &work) { - self.store.seat_run_order(agent)? + self.store.seat_run_order_for_mutation(agent)? } else { Vec::new() }; @@ -49105,8 +49105,14 @@ mission "ios-proof-blocked" state="ready" { let legacy = step("step-run/run-1/legacy", "legacy", "node/worker"); assert_eq!( next_work_wake_for_agent("agent/node/worker", std::slice::from_ref(&legacy), &[]), - Some(legacy.subject.as_str()), - "a legacy-only ready row must wake its canonical seat" + None, + "main does not wake a canonical seat for a legacy-only assignment" + ); + let canonical = step("step-run/run-1/canonical", "canonical", "agent/node/worker"); + assert_eq!( + next_work_wake_for_agent("agent/node/worker", std::slice::from_ref(&canonical), &[]), + Some(canonical.subject.as_str()), + "a literal assignment still wakes its seat" ); assert_eq!( next_work_wake_for_agent("daemon/node/worker", std::slice::from_ref(&legacy), &[]), @@ -49287,8 +49293,8 @@ mission "ios-proof-blocked" state="ready" { &BTreeMap::new(), 2_000, ), - Some(1_000 + WORK_WAKE_RETRY_MS), - "a legacy-only ready row keeps its canonical seat's wake deadline" + None, + "main gives no canonical wake deadline to a legacy-only assignment" ); let mut second = first.clone(); second.subject = "step-run/newer/work".into(); @@ -50128,6 +50134,12 @@ mission "queued" state="ready" { goal "Give the durable seat one step in each run." step "work" { assigned-to "agent/node.worker" } } +mission "mixed" state="ready" { + concurrent-runs + goal "Keep literal and historical assignments in one run." + step "work" { assigned-to "agent/node.worker" } + step "legacy" { assigned-to "agent/node.worker" } +} mission "gated" state="ready" { concurrent-runs goal "Hold the durable seat's step until another seat finishes." @@ -50256,7 +50268,7 @@ mission "fresh" state="ready" { /// The step the reconciler wakes now, or none while the seat is held. fn wake(&self) -> Option { let work = self.store.work_for_reconcile(SEAT).unwrap(); - let order = self.store.seat_run_order(SEAT).unwrap(); + let order = self.store.seat_run_order_for_mutation(SEAT).unwrap(); next_work_wake_for_agent(SEAT, &work, &order).map(str::to_owned) } @@ -50311,6 +50323,46 @@ mission "fresh" state="ready" { } } + #[test] + fn wake_order_ignores_alias_join_times_and_agrees_with_claim_admission() { + let root = tempfile::tempdir().unwrap(); + let database = root.path().join("literal-wake-order.sqlite"); + let seat = SeatQueueFixture::with_store(Arc::new(Store::open(&database, "node").unwrap())); + let a = seat.start("mixed", "literal-wake-a"); + let b = seat.start("queued", "literal-wake-b"); + let a_work = SeatQueueFixture::step(&a, "work"); + let a_legacy = SeatQueueFixture::step(&a, "legacy"); + let b_work = SeatQueueFixture::step(&b, "work"); + let connection = rusqlite::Connection::open(&database).unwrap(); + crate::store::configure_projection_writer(&connection).unwrap(); + for (subject, assignee, joined) in [ + (a_work.as_str(), SEAT, 20), + (a_legacy.as_str(), "node.worker", 1), + (b_work.as_str(), SEAT, 10), + ] { + connection.execute( + /* sqlite-large-value: allow-for-now lv-h05a4bea897ac owner=fractal_reconcile::reconcile TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?1, created_at_unix_ms=?2 WHERE subject=?3", + rusqlite::params![assignee, joined, subject], + ).unwrap(); + } + drop(connection); + assert_eq!(seat.order(), SeatQueueFixture::runs(&[&a, &b]), + "presentation still merges the earlier alias join"); + let expected = SeatQueueFixture::runs(&[&b, &a]); + assert_eq!(seat.store.seat_run_order_for_mutation(SEAT).unwrap(), expected); + let work = seat.store.work_for_reconcile_all().unwrap(); + let orders = seat.reconciler.wake_run_orders( + &BTreeSet::from([SEAT.to_owned()]), &work, + ).unwrap(); + assert_eq!(orders.get(SEAT), Some(&expected), + "deadline order uses only literal joins too"); + assert_eq!(seat.wake(), Some(b_work.clone())); + seat.reconciler.reconcile_once().unwrap(); + assert_eq!(seat.woken().last(), Some(&b_work), + "the actual wake agrees with the literal admission order"); + seat.work(&b_work, "claim", "literal-wake-claim-b").unwrap(); + } + /// Step 2b: once every member can ask the seat's host, an unchanged working re-stamp stays in /// the host's local log. A wake into a turn that was already working is still taken up by the /// next working report after it was sent, which the host hears locally. diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 8f34f7e2516..55bafe8823c 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -19176,11 +19176,9 @@ pub fn seat_queue_inputs_tx( /// Queue inputs for mutation admission, without merging actor aliases. pub fn seat_queue_inputs_literal_tx( connection: &Connection, - agent: &str, -) -> Result { - Ok(seat_queue_inputs_matching_tx(connection, Some(agent), None, false)? - .remove(agent) - .unwrap_or_default()) + agent: Option<&str>, +) -> Result> { + seat_queue_inputs_matching_tx(connection, agent, None, false) } #[cfg(any(test, feature = "test-support"))] diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index d041171a262..af6404e69b6 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -4926,6 +4926,7 @@ {"id": "lv-hd38e77dd6432", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 811, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-hd60f12cbb0cd", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "2c428368447719337e62bf6baa77578a1f95a5cda24f8fab6b5471192613c58e", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 761, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1"}, {"id": "lv-hd700e0550a21", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 861, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, - {"id": "lv-h934585192b73", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "386e7e1231a36489f2cceb29bad211b82fb2995a96133e6a5663bffbd895e0aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 991, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms='1' WHERE subject=?1"} + {"id": "lv-h934585192b73", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "386e7e1231a36489f2cceb29bad211b82fb2995a96133e6a5663bffbd895e0aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 991, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms='1' WHERE subject=?1"}, + {"id": "lv-h05a4bea897ac", "path": "crates/fractal-reconcile/src/reconcile.rs", "kind": "sql-template", "fingerprint": "cb68e0f83f2cfb08c6d0aceab493d04a4eb80a8d90b845c74a62eaf3d1b58843", "owner": "fractal_reconcile::reconcile", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 50344, "excerpt": "UPDATE step_runs SET assignee=?1, created_at_unix_ms=?2 WHERE subject=?3"} ] } diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json index 27e49925272..eea71717831 100644 --- a/release-notes/mission-agentid-canonical.json +++ b/release-notes/mission-agentid-canonical.json @@ -3,7 +3,7 @@ "summary": "Emit canonical agent identities in mission presentation while preserving historical writer spelling", "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read. Re-parsing stored declarations preserves historical host-relative qualification and malformed assignee normalization rather than rejecting them."}, "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, - "rules": {"status": "changed", "detail": "Work reads, renewal polling and presentation compare canonical identities. All mutations preserve main's actor normalization and literal matching, including ownership admission, eligibility, capacity, queue admission, carried-claim successor selection, output authority and ancestor lease extension. Claim-admission queues and automatic work-wake selection use literal selectors, so an earlier legacy-assigned ready step does not block a canonical seat's claim; read and presentation queues still show both spellings. Bare work actors such as node.worker normalize to agent/node.worker before validation and can claim, renew, release and complete literally assigned work. An unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed when the literal eligibility selector admits that actor, without changing claim state, publishing a mutation or extending the operational lease. Alias-owned carried claims do not redirect a mutation from a stale generation; literal owners still reach their carried successor. Successful child mutations extend only literally owned ancestor leases. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed slash-qualified claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, + "rules": {"status": "changed", "detail": "Work reads, renewal polling and presentation compare canonical identities. All mutations preserve main's actor normalization and literal matching, including ownership admission, eligibility, capacity, queue admission, carried-claim successor selection, output authority and ancestor lease extension. Claim-admission queues and automatic work-wake selection use literal selectors and literal queue join and move inputs, so an earlier legacy-assigned ready step does not block a canonical seat's claim or reorder its wake; legacy-only assignments do not wake a canonical seat or create its wake deadline. Read and presentation queues still show both spellings. Bare work actors such as node.worker normalize to agent/node.worker before validation and can claim, renew, release and complete literally assigned work. An unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed when the literal eligibility selector admits that actor, without changing claim state, publishing a mutation or extending the operational lease. Alias-owned carried claims do not redirect a mutation from a stale generation; literal owners still reach their carried successor. Successful child mutations extend only literally owned ancestor leases. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed slash-qualified claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, "compatibility": {"status": "changed", "detail": "Expand phase only: read both actor spellings and preserve the previous writer spelling, with no feature gate or capability advertisement. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership: a later spelling switch and stored-row migration require a durable one-way fleet fence. Once raw legacy-signed ownership facts exist, the historical reader floor must prohibit old readers that cannot admit those facts; a reversible capability check cannot establish that floor."}, "downtime": {"status": "none", "detail": "No downtime or stored-row migration is required for this read-compatible expansion."}, "manual": {"status": "none", "detail": "No operator action is required."}, From 15c9fd7192955c828bada08d21f35c312bbe9371 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:10:19 +0100 Subject: [PATCH 042/132] test(missions): use recognized aliases in wake order regression Parameterize the seat queue fixture identity while preserving its default source. Exercise agent/node/worker and node/worker so presentation merges the alias join and literal wake admission does not. Co-authored-by: OpenAI Codex agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-reconcile/src/reconcile.rs | 45 +++++++++++++++-------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index 3a9d5540e63..3e1a54ebc45 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -50162,6 +50162,7 @@ mission "fresh" state="ready" { struct SeatQueueFixture { store: Arc, reconciler: Reconciler, + agent: &'static str, } impl SeatQueueFixture { @@ -50170,12 +50171,23 @@ mission "fresh" state="ready" { } fn with_store(store: Arc) -> Self { - apply_source(&store, SEAT_QUEUE_SOURCE, "seat-queue-missions"); + Self::with_store_for_agent(store, SEAT) + } + + fn with_store_for_agent(store: Arc, agent: &'static str) -> Self { + let source = if agent == SEAT { + SEAT_QUEUE_SOURCE.to_owned() + } else { + SEAT_QUEUE_SOURCE + .replace("agent \"worker\"", &format!("agent \"{}\"", agent.strip_prefix("agent/").unwrap())) + .replace(SEAT, agent) + }; + apply_source(&store, &source, "seat-queue-missions"); let desired = store .desired_subjects() .unwrap() .into_iter() - .find(|subject| subject.subject == SEAT) + .find(|subject| subject.subject == agent) .unwrap(); let runtime = Arc::new(FakeRuntime::default()); runtime.ptys.lock().unwrap().push(RuntimeObservation { @@ -50194,9 +50206,9 @@ mission "fresh" state="ready" { reconciler.reconcile_once().unwrap(); store .append_claim(&ClaimInput { - subject: SEAT.into(), + subject: agent.into(), kind: "harness.observed".into(), - actor: Some(SEAT.into()), + actor: Some(agent.into()), fields: BTreeMap::from([ ("state".into(), Value::String("ready".into())), ("driver".into(), Value::String("codex".into())), @@ -50207,7 +50219,7 @@ mission "fresh" state="ready" { idempotency_key: Some("seat-one-ready".into()), }) .unwrap(); - Self { store, reconciler } + Self { store, reconciler, agent } } /// Start a run a little later than the last so join times differ. @@ -50239,7 +50251,7 @@ mission "fresh" state="ready" { } fn queue(&self) -> crate::model::SeatQueueView { - self.store.seat_queue(SEAT).unwrap() + self.store.seat_queue(self.agent).unwrap() } fn order(&self) -> Vec { @@ -50259,7 +50271,7 @@ mission "fresh" state="ready" { .store .agent_work_queues() .unwrap() - .remove(SEAT) + .remove(self.agent) .and_then(|queue| queue.next_work_id); assert_eq!(queue.next_work_id, roster, "the roster disagrees"); queue.next_work_id @@ -50267,15 +50279,15 @@ mission "fresh" state="ready" { /// The step the reconciler wakes now, or none while the seat is held. fn wake(&self) -> Option { - let work = self.store.work_for_reconcile(SEAT).unwrap(); - let order = self.store.seat_run_order_for_mutation(SEAT).unwrap(); - next_work_wake_for_agent(SEAT, &work, &order).map(str::to_owned) + let work = self.store.work_for_reconcile(self.agent).unwrap(); + let order = self.store.seat_run_order_for_mutation(self.agent).unwrap(); + next_work_wake_for_agent(self.agent, &work, &order).map(str::to_owned) } /// Steps named by open wake messages, oldest first. fn woken(&self) -> Vec { self.store - .messages(Some(SEAT), false) + .messages(Some(self.agent), false) .unwrap() .into_iter() .filter_map(|message| { @@ -50294,7 +50306,7 @@ mission "fresh" state="ready" { ) -> Result { self.store .move_seat_queue_run(&crate::model::SeatQueueMoveRequest { - agent: SEAT.into(), + agent: self.agent.into(), run: run.into(), placement: placement.into(), anchor: anchor.map(str::to_owned), @@ -50311,7 +50323,7 @@ mission "fresh" state="ready" { action, &crate::model::WorkRequest { wait_ms: None, - actor: Some(SEAT.into()), + actor: Some(self.agent.into()), incarnation: Some("seat-one".into()), summary: None, reason: None, @@ -50325,9 +50337,12 @@ mission "fresh" state="ready" { #[test] fn wake_order_ignores_alias_join_times_and_agrees_with_claim_admission() { + const SEAT: &str = "agent/node/worker"; let root = tempfile::tempdir().unwrap(); let database = root.path().join("literal-wake-order.sqlite"); - let seat = SeatQueueFixture::with_store(Arc::new(Store::open(&database, "node").unwrap())); + let seat = SeatQueueFixture::with_store_for_agent( + Arc::new(Store::open(&database, "node").unwrap()), SEAT, + ); let a = seat.start("mixed", "literal-wake-a"); let b = seat.start("queued", "literal-wake-b"); let a_work = SeatQueueFixture::step(&a, "work"); @@ -50337,7 +50352,7 @@ mission "fresh" state="ready" { crate::store::configure_projection_writer(&connection).unwrap(); for (subject, assignee, joined) in [ (a_work.as_str(), SEAT, 20), - (a_legacy.as_str(), "node.worker", 1), + (a_legacy.as_str(), "node/worker", 1), (b_work.as_str(), SEAT, 10), ] { connection.execute( From b4d16721a56a9043b7b63faa0bd6c80afcd0ded6 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:17:37 +0100 Subject: [PATCH 043/132] fix(missions): avoid hiding literal row helper re-export Remove the private import that shadows the public delivery missions glob export; qualify the admission helper call instead. Preserve the warning ratchet and existing public export. Co-authored-by: OpenAI Codex agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-missions/src/store/missions.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index eefd00dd25d..0728cb3ad68 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -3,7 +3,6 @@ use super::*; pub use fractal_delivery::store::missions::*; use fractal_store::store::identities::resolve_reference_conn; -use fractal_store::store::missions::seat_step_rows_literal_tx; use fractal_store::store::{work_actor_matches, work_actor_spellings_json}; mod run_review; @@ -930,7 +929,7 @@ fn seat_order_conflict_tx( .remove(agent) .unwrap_or_default() .live_order(); - let rows = seat_step_rows_literal_tx(connection, agent)?; + let rows = fractal_store::store::missions::seat_step_rows_literal_tx(connection, agent)?; let steps = rows .iter() .map(RosterStepRow::seat_step) From 89e147f3a0bbc2d313a998604b72855f7d4996c1 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:51:32 +0100 Subject: [PATCH 044/132] test(missions): build wake ordering fixture from durable claims Replace projection-only SQL with declaration/run and revision adoption APIs, using existing simulation clocks for alias join1, literal B join10, and literal A join20. Preserve strict incremental checking and remove obsolete SQL inventory entry. Co-authored-by: OpenAI Codex agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-reconcile/src/reconcile.rs | 65 +++++++++++++---------- docs/st3/large-value-sites.json | 3 +- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index 3e1a54ebc45..12650eef9f7 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -50134,12 +50134,6 @@ mission "queued" state="ready" { goal "Give the durable seat one step in each run." step "work" { assigned-to "agent/node.worker" } } -mission "mixed" state="ready" { - concurrent-runs - goal "Keep literal and historical assignments in one run." - step "work" { assigned-to "agent/node.worker" } - step "legacy" { assigned-to "agent/node.worker" } -} mission "gated" state="ready" { concurrent-runs goal "Hold the durable seat's step until another seat finishes." @@ -50338,29 +50332,46 @@ mission "fresh" state="ready" { #[test] fn wake_order_ignores_alias_join_times_and_agrees_with_claim_admission() { const SEAT: &str = "agent/node/worker"; - let root = tempfile::tempdir().unwrap(); - let database = root.path().join("literal-wake-order.sqlite"); - let seat = SeatQueueFixture::with_store_for_agent( - Arc::new(Store::open(&database, "node").unwrap()), SEAT, - ); - let a = seat.start("mixed", "literal-wake-a"); + struct Clock; + impl Drop for Clock { + fn drop(&mut self) { + smallclaims::store::set_thread_clock(None); + } + } + smallclaims::store::set_thread_clock(Some(1)); + let _clock = Clock; + let store = Arc::new(Store::open_memory("node").unwrap()); + store.set_write_clock_at(1).unwrap(); + let seat = SeatQueueFixture::with_store_for_agent(store, SEAT); + let canonical_source = SEAT_QUEUE_SOURCE + .replace("agent \"worker\"", "agent \"node/worker\"") + .replace("agent/node.worker", SEAT); + let legacy_source = canonical_source.replace(SEAT, "node/worker"); + apply_source(&seat.store, &legacy_source, "literal-wake-legacy-definition"); + let a = seat.start("queued", "literal-wake-a"); + let legacy = a.steps.iter().find(|step| step.step == "work").unwrap(); + assert_eq!(legacy.assigned_to.as_deref(), Some("node/worker")); + assert_eq!(legacy.created_at_unix_ms, 1); + smallclaims::store::set_thread_clock(Some(10)); + seat.store.set_write_clock_at(10).unwrap(); + apply_source(&seat.store, &canonical_source, "literal-wake-canonical-definition"); let b = seat.start("queued", "literal-wake-b"); - let a_work = SeatQueueFixture::step(&a, "work"); - let a_legacy = SeatQueueFixture::step(&a, "legacy"); let b_work = SeatQueueFixture::step(&b, "work"); - let connection = rusqlite::Connection::open(&database).unwrap(); - crate::store::configure_projection_writer(&connection).unwrap(); - for (subject, assignee, joined) in [ - (a_work.as_str(), SEAT, 20), - (a_legacy.as_str(), "node/worker", 1), - (b_work.as_str(), SEAT, 10), - ] { - connection.execute( - /* sqlite-large-value: allow-for-now lv-h05a4bea897ac owner=fractal_reconcile::reconcile TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?1, created_at_unix_ms=?2 WHERE subject=?3", - rusqlite::params![assignee, joined, subject], - ).unwrap(); - } - drop(connection); + smallclaims::store::set_thread_clock(Some(20)); + seat.store.set_write_clock_at(20).unwrap(); + // A new generation joins the literal seat at 20, while its old + // generation keeps the alias join at 1 for presentation. + let revised_source = canonical_source.replace("step \"work\"", "step \"literal\""); + apply_source(&seat.store, &revised_source, "literal-wake-revision"); + let revision = seat.store.mission_spec("queued", None).unwrap().unwrap(); + let a = seat.store.adopt_mission_revision( + &a.id, &revision, "person/requester", "join the literal seat", + "literal-wake-adopt", + ).unwrap(); + seat.reconciler.reconcile_once().unwrap(); + let a_work = SeatQueueFixture::step(&a, "literal"); + assert_eq!(a.steps.iter().find(|step| step.subject == a_work).unwrap().created_at_unix_ms, 20); + assert_eq!(b.steps.iter().find(|step| step.subject == b_work).unwrap().created_at_unix_ms, 10); assert_eq!(seat.order(), SeatQueueFixture::runs(&[&a, &b]), "presentation still merges the earlier alias join"); let expected = SeatQueueFixture::runs(&[&b, &a]); diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 1cb586351f3..876b1b0409b 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -4930,7 +4930,6 @@ {"id": "lv-hd38e77dd6432", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 811, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-hd60f12cbb0cd", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "2c428368447719337e62bf6baa77578a1f95a5cda24f8fab6b5471192613c58e", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 761, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1"}, {"id": "lv-hd700e0550a21", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 861, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, - {"id": "lv-h934585192b73", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "386e7e1231a36489f2cceb29bad211b82fb2995a96133e6a5663bffbd895e0aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 991, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms='1' WHERE subject=?1"}, - {"id": "lv-h05a4bea897ac", "path": "crates/fractal-reconcile/src/reconcile.rs", "kind": "sql-template", "fingerprint": "cb68e0f83f2cfb08c6d0aceab493d04a4eb80a8d90b845c74a62eaf3d1b58843", "owner": "fractal_reconcile::reconcile", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 50344, "excerpt": "UPDATE step_runs SET assignee=?1, created_at_unix_ms=?2 WHERE subject=?3"} + {"id": "lv-h934585192b73", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "386e7e1231a36489f2cceb29bad211b82fb2995a96133e6a5663bffbd895e0aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 991, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms='1' WHERE subject=?1"} ] } From 7c3d8edf179fd7539ac6be45130a1e5725fb7107 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:27:56 +0100 Subject: [PATCH 045/132] fix: use managed wrappers for interactive harness launches agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-drivers/src/lib.rs | 1 + crates/fractal-drivers/src/managed_launch.rs | 162 ++++++++++++++++++ crates/fractal/src/driver_cli.rs | 1 + docs/site/reference/configuration.md | 33 +++- release-notes/interactive-managed-launch.json | 11 ++ 5 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 crates/fractal-drivers/src/managed_launch.rs create mode 100644 release-notes/interactive-managed-launch.json diff --git a/crates/fractal-drivers/src/lib.rs b/crates/fractal-drivers/src/lib.rs index 060de96fd8b..e0b0228a1df 100644 --- a/crates/fractal-drivers/src/lib.rs +++ b/crates/fractal-drivers/src/lib.rs @@ -61,6 +61,7 @@ pub mod hooks; pub mod host; pub mod host_lock; pub mod identity; +pub mod managed_launch; pub mod materialize; pub mod message; pub mod metrics; diff --git a/crates/fractal-drivers/src/managed_launch.rs b/crates/fractal-drivers/src/managed_launch.rs new file mode 100644 index 00000000000..ca07bd18138 --- /dev/null +++ b/crates/fractal-drivers/src/managed_launch.rs @@ -0,0 +1,162 @@ +//! Resolve managed harnesses before admission, resume helpers, or provider startup. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; + +// Runtime-profile contract owned by the wrapper publisher. +const WRAPPER_DIRECTORY_KEY: &str = "harnessBin"; + +/// Select the managed executable, including when argv names a raw provider by absolute path. +/// Other harnesses retain their own launch contracts. +pub fn resolve(harness: &str, argv: &mut [String]) -> Result<()> { + if !matches!(harness, "omp" | "claude" | "codex") { + return Ok(()); + } + let profile = profile_path()?; + let persona = std::env::var_os("AGENT_PERSONA") + .map(|persona| persona.into_string().map_err(|_| anyhow::anyhow!("AGENT_PERSONA is not UTF-8"))) + .transpose()? + .unwrap_or_else(|| "generalist".into()); + resolve_for_persona(harness, argv, &profile, &persona) +} + +fn profile_path() -> Result { + profile_path_from(&|name| std::env::var_os(name)) +} + +fn profile_path_from(env: &impl Fn(&str) -> Option) -> Result { + if let Some(profile) = env("AGENT_RUNTIME_PROFILE") { + ensure!(!profile.is_empty(), "AGENT_RUNTIME_PROFILE is empty; refusing raw provider"); + return Ok(PathBuf::from(profile)); + } + let home = env("HOME").context("HOME is required to discover the managed runtime profile")?; + let home = PathBuf::from(home); + ensure!(home.is_absolute(), "HOME must be absolute to discover the managed runtime profile"); + Ok(home.join(".local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json")) +} + +fn resolve_for_persona( + harness: &str, + argv: &mut [String], + profile: &Path, + persona: &str, +) -> Result<()> { + let program = argv.first_mut().context("managed provider argv is empty")?; + let manifest: serde_json::Value = serde_json::from_slice( + &std::fs::read(profile) + .with_context(|| format!("read managed runtime profile {}", profile.display()))?, + ).with_context(|| format!("parse managed runtime profile {}", profile.display()))?; + let bin = manifest.get("personas") + .and_then(|personas| personas.get("launch")) + .and_then(|launch| launch.get(persona)) + .and_then(|launch| launch.get(WRAPPER_DIRECTORY_KEY)) + .and_then(serde_json::Value::as_str) + .filter(|bin| !bin.is_empty()) + .map(Path::new) + .with_context(|| format!( + "managed runtime profile {} is missing personas.launch.{persona}.{WRAPPER_DIRECTORY_KEY}; refusing raw {harness}", + profile.display(), + ))?; + ensure!(bin.is_absolute(), "managed wrapper directory must be absolute: {}", bin.display()); + let wrapper = bin.join(harness); + let metadata = std::fs::metadata(&wrapper) + .with_context(|| format!("managed {harness} wrapper is missing: {}; refusing raw provider", wrapper.display()))?; + ensure!(metadata.is_file(), "managed {harness} wrapper is not a file: {}", wrapper.display()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + ensure!(metadata.permissions().mode() & 0o111 != 0, + "managed {harness} wrapper is not executable: {}", wrapper.display()); + } + // An absolute executable gives the same wrapper precedence as the kit's PATH without + // changing the driver's environment or letting an explicit raw argv bypass the wrapper. + *program = wrapper.to_str().context("managed wrapper path is not UTF-8")?.to_owned(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("managed/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let profile = root.path().join("profile.json"); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"generalist": {(WRAPPER_DIRECTORY_KEY): bin}}}, + })).unwrap()).unwrap(); + (root, bin, profile) + } + + #[test] + fn managed_wrapper_takes_precedence_over_login_path_and_raw_argv() { + let (_root, bin, profile) = fixture(); + for harness in ["omp", "claude", "codex"] { + let wrapper = bin.join(harness); + std::fs::write(&wrapper, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + for program in [harness.to_owned(), format!("/raw/bin/{harness}")] { + let mut argv = vec![program, "--model".into(), "example".into()]; + resolve_for_persona(harness, &mut argv, &profile, "generalist").unwrap(); + assert_eq!(Path::new(&argv[0]), wrapper); + assert_eq!(&argv[1..], ["--model", "example"]); + } + } + } + + #[test] + fn missing_wrapper_refuses_instead_of_searching_path() { + let (_root, bin, profile) = fixture(); + for program in ["omp", "/raw/bin/omp"] { + let mut argv = vec![program.to_owned()]; + let error = resolve_for_persona("omp", &mut argv, &profile, "generalist").unwrap_err(); + assert!(format!("{error:#}").contains(bin.join("omp").to_str().unwrap())); + assert_eq!(argv[0], program); + } + } + + #[test] + fn missing_wrapper_directory_refuses() { + let (_root, bin, profile) = fixture(); + std::fs::remove_dir(&bin).unwrap(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist").unwrap_err(); + assert!(format!("{error:#}").contains(bin.join("omp").to_str().unwrap())); + } + + #[test] + fn missing_profile_key_refuses_raw_provider() { + let (_root, _bin, profile) = fixture(); + std::fs::write(&profile, "{}").unwrap(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist").unwrap_err(); + assert!(format!("{error:#}").contains(WRAPPER_DIRECTORY_KEY)); + } + + #[test] + fn missing_persona_does_not_fall_back_to_generalist() { + let (_root, _bin, profile) = fixture(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "unknown").unwrap_err(); + assert!(format!("{error:#}").contains("personas.launch.unknown.harnessBin")); + } + + #[test] + fn profile_discovery_prefers_explicit_path_and_keeps_stable_link() { + let explicit = profile_path_from(&|name| match name { + "AGENT_RUNTIME_PROFILE" => Some("/custom/profile.json".into()), + "HOME" => Some("/home/example".into()), + _ => None, + }).unwrap(); + assert_eq!(explicit, Path::new("/custom/profile.json")); + let stable = profile_path_from(&|name| (name == "HOME").then(|| "/home/example".into())).unwrap(); + assert_eq!(stable, Path::new( + "/home/example/.local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json", + )); + assert!(profile_path_from(&|_| None).is_err()); + assert!(profile_path_from(&|name| (name == "AGENT_RUNTIME_PROFILE").then(|| "".into())).is_err()); + } +} diff --git a/crates/fractal/src/driver_cli.rs b/crates/fractal/src/driver_cli.rs index 9e597510d78..2c33d2e78cc 100644 --- a/crates/fractal/src/driver_cli.rs +++ b/crates/fractal/src/driver_cli.rs @@ -153,6 +153,7 @@ pub(super) async fn run_driver(client: &Client, args: DriverArgs, catalog: Optio if let Some(state) = fractal_drivers::reexec::resume_path(fractal_drivers::reexec::DRIVER_RESUME_ENV) { return resume_native_driver(client, subject, &args.driver, argv, &state).await; } + fractal_drivers::managed_launch::resolve(&args.driver, &mut argv)?; let incarnation = wait_for_agent_incarnation(client, subject).await?; let paths = NativePaths::prepare(subject, &args.driver)?; let sessions = paths.session_dir.join("provider-sessions"); diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index fc1e09d645a..9389189ce24 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -112,6 +112,20 @@ the PATH of the terminal running `fractal apply`, nor just launchd's or systemd' service environment. Agent `env` entries overlay that environment. Fractal also prepends its own executable directory so its wrapper can be found. +Managed `omp`, `claude`, and `codex` launches read `AGENT_RUNTIME_PROFILE`, or +`$HOME/.local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json` +when that variable is unset. Each launch reads the current profile through its +stable link and executes `.harnessBin>/` +directly. `AGENT_PERSONA` selects the persona, defaulting to `generalist` only +when unset. The profile publisher selects the directory; Fractal does not +duplicate persona-to-path rules. A missing persona entry, `harnessBin`, or +executable wrapper refuses launch, naming the missing key/path. There is no +fallback to a raw provider or to a different persona. + +Before upgrading a host to this launch contract, publish and activate a runtime +profile containing `personas.launch..harnessBin` for its managed seats. +Existing provider processes are not changed by the upgrade. + To change lookup for one agent, set its `PATH` (the literal `${PATH}` expands to the captured shell PATH): @@ -119,22 +133,23 @@ the captured shell PATH): env { PATH "/opt/codex/bin:${PATH}"; } ``` -To bypass PATH lookup and version-manager shims entirely for the native program, -set `executable` inside the harness: +For `pi` and `opencode`, bypass PATH lookup and version-manager shims by setting +`executable` inside the harness. For example: ```kdl -harness "codex" { - executable "/opt/codex/bin/codex" +harness "pi" { + executable "/opt/pi/bin/pi" effort "high" } ``` `executable` is one executable name or path, not a shell command. Spaces in a -path are preserved; put arguments in `args`, not in this string. An absolute -path is recommended for a pinned installation. A plain name uses the seat's -PATH. Fractal does not interpret `CODEX_BIN` or `CLAUDE_BIN` as executable -overrides, and there is no separate per-harness executable setting in local -daemon configuration. A native harness may still read its own configuration +path are preserved; put arguments in `args`, not in this string. For managed +`omp`, `claude`, and `codex`, Fractal replaces even an explicit raw executable +with the profile's managed wrapper, preserving the remaining provider arguments. +For other harnesses, an absolute path selects a pinned installation and a plain +name uses the seat's PATH. Fractal does not interpret `CODEX_BIN` or `CLAUDE_BIN` +as executable overrides. A native harness may still read its own configuration and environment variables after it starts. ## Preview and gate checks diff --git a/release-notes/interactive-managed-launch.json b/release-notes/interactive-managed-launch.json new file mode 100644 index 00000000000..12ed8355c83 --- /dev/null +++ b/release-notes/interactive-managed-launch.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Interactive OMP, Claude, and Codex agents use the managed launch wrapper selected by their runtime profile, including explicit provider executable overrides", + "replay": {"status": "none", "detail": "No graph replay; executable selection happens in the driver before launch."}, + "schema": {"status": "none", "detail": "No database schema change."}, + "rules": {"status": "none", "detail": "Checkpoint rules and digests are unchanged."}, + "compatibility": {"status": "changed", "detail": "Managed OMP, Claude, and Codex launches require personas.launch..harnessBin in the runtime profile. AGENT_PERSONA defaults to generalist only when unset. Explicit executable overrides no longer bypass the managed wrapper."}, + "downtime": {"status": "none", "detail": "Existing provider processes are unchanged."}, + "manual": {"status": "changed", "detail": "Rollout dependency: do not upgrade a host to a Fractal release containing this change until that host has activated a runtime profile with personas.launch..harnessBin for its managed seats. Read AGENT_RUNTIME_PROFILE when set, otherwise the stable HOME-relative coding-agent-runtime Nix profile; missing profile, persona key, or wrapper refuses new managed launches."}, + "recovery": {"status": "none", "detail": "Correct the runtime profile or install the named wrapper before retrying a refused launch."} +} From 05b8e2c4a29d860cf5153e8747d874dec4dc4744 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:01:41 +0100 Subject: [PATCH 046/132] fix: preserve managed provider PATH and declared persona agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../fractal-drivers/src/codex_app_server.rs | 6 +- .../src/codex_app_server/protocol.rs | 5 +- crates/fractal-drivers/src/managed_launch.rs | 117 ++++++++++++++++-- .../fractal-drivers/src/provider_session.rs | 1 + crates/fractal/src/driver_cli.rs | 7 +- docs/site/reference/configuration.md | 16 ++- release-notes/interactive-managed-launch.json | 4 +- 7 files changed, 134 insertions(+), 22 deletions(-) diff --git a/crates/fractal-drivers/src/codex_app_server.rs b/crates/fractal-drivers/src/codex_app_server.rs index 71e46ac2264..8b29e4af123 100644 --- a/crates/fractal-drivers/src/codex_app_server.rs +++ b/crates/fractal-drivers/src/codex_app_server.rs @@ -3262,7 +3262,7 @@ fn spawn_controlled_app_server( socket_path: &Path, log: &File, ) -> Result { - let mut command = Command::new(codex); + let mut command = crate::managed_launch::provider_command(codex); command .args(args) .stdin(Stdio::null()) @@ -3277,7 +3277,7 @@ fn spawn_controlled_app_server( const CONTROLLED_TUI_OVERRIDES: [&str; 2] = ["-c", "check_for_update_on_startup=false"]; fn controlled_tui_command(codex: &str, args: &[String]) -> Command { - let mut command = Command::new(codex); + let mut command = crate::managed_launch::provider_command(codex); command.args(CONTROLLED_TUI_OVERRIDES).args(args); command } @@ -4416,7 +4416,7 @@ fn preflight_hook_trust( diagnostics: &mut WrapperDiagnostics, ) -> Result> { diagnostics.record("hookTrustPreflightStarting", json!({}))?; - let mut server_command = Command::new(codex); + let mut server_command = crate::managed_launch::provider_command(codex); server_command .args(server_args) .stdin(Stdio::null()) diff --git a/crates/fractal-drivers/src/codex_app_server/protocol.rs b/crates/fractal-drivers/src/codex_app_server/protocol.rs index 60d89b71460..1617d5b7130 100644 --- a/crates/fractal-drivers/src/codex_app_server/protocol.rs +++ b/crates/fractal-drivers/src/codex_app_server/protocol.rs @@ -5,7 +5,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::process::Command; use std::thread; use std::time::Duration; @@ -32,7 +31,7 @@ pub(super) fn ensure_supported_protocol(codex: &str) -> Result { .prefix("st-codex-protocol-") .tempdir() .context("creating a temporary Codex protocol schema directory")?; - let output = Command::new(codex) + let output = crate::managed_launch::provider_command(codex) .args([ "app-server", "generate-json-schema", @@ -70,7 +69,7 @@ pub(super) fn ensure_supported_protocol(codex: &str) -> Result { fn codex_version(codex: &str) -> Result { let mut attempt_index = 0; let output = loop { - let attempt = Command::new(codex).arg("--version").output(); + let attempt = crate::managed_launch::provider_command(codex).arg("--version").output(); match attempt { Ok(output) => break output, Err(error) if error.raw_os_error() == Some(libc::ETXTBSY) && attempt_index + 1 < 5 => { diff --git a/crates/fractal-drivers/src/managed_launch.rs b/crates/fractal-drivers/src/managed_launch.rs index ca07bd18138..abf8d4bb685 100644 --- a/crates/fractal-drivers/src/managed_launch.rs +++ b/crates/fractal-drivers/src/managed_launch.rs @@ -7,18 +7,32 @@ use anyhow::{Context, Result, ensure}; // Runtime-profile contract owned by the wrapper publisher. const WRAPPER_DIRECTORY_KEY: &str = "harnessBin"; +// A driver owns one seat. Keep launch-only environment separate from the driver's environment. +static PROVIDER_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); + /// Select the managed executable, including when argv names a raw provider by absolute path. /// Other harnesses retain their own launch contracts. -pub fn resolve(harness: &str, argv: &mut [String]) -> Result<()> { +pub fn resolve(harness: &str, argv: &mut [String], spec: &serde_json::Value) -> Result<()> { if !matches!(harness, "omp" | "claude" | "codex") { return Ok(()); } let profile = profile_path()?; - let persona = std::env::var_os("AGENT_PERSONA") - .map(|persona| persona.into_string().map_err(|_| anyhow::anyhow!("AGENT_PERSONA is not UTF-8"))) - .transpose()? - .unwrap_or_else(|| "generalist".into()); - resolve_for_persona(harness, argv, &profile, &persona) + let environment_persona = std::env::var("AGENT_PERSONA") + .map(Some) + .or_else(|error| match error { + std::env::VarError::NotPresent => Ok(None), + error => Err(error), + }).context("read AGENT_PERSONA")?; + let persona = selected_persona(spec, environment_persona.as_deref())?; + resolve_for_persona(harness, argv, &profile, persona)?; + let bin = Path::new(&argv[0]).parent().context("managed wrapper has no parent")?; + let path = prefixed_path(bin, std::env::var_os("PATH").as_deref())?; + if let Some(existing) = PROVIDER_PATH.get() { + ensure!(existing == &path, "driver already selected a different managed provider PATH"); + } else { + PROVIDER_PATH.set(path).map_err(|_| anyhow::anyhow!("managed provider PATH already selected"))?; + } + Ok(()) } fn profile_path() -> Result { @@ -28,7 +42,9 @@ fn profile_path() -> Result { fn profile_path_from(env: &impl Fn(&str) -> Option) -> Result { if let Some(profile) = env("AGENT_RUNTIME_PROFILE") { ensure!(!profile.is_empty(), "AGENT_RUNTIME_PROFILE is empty; refusing raw provider"); - return Ok(PathBuf::from(profile)); + let profile = PathBuf::from(profile); + ensure!(profile.is_absolute(), "AGENT_RUNTIME_PROFILE must be absolute: {}", profile.display()); + return Ok(profile); } let home = env("HOME").context("HOME is required to discover the managed runtime profile")?; let home = PathBuf::from(home); @@ -75,6 +91,50 @@ fn resolve_for_persona( Ok(()) } +/// Apply the managed launch environment only to a provider child. +pub fn apply_provider_path(command: &mut std::process::Command) { + apply_path(command, PROVIDER_PATH.get().map(std::ffi::OsString::as_os_str)); +} + +fn apply_path(command: &mut std::process::Command, path: Option<&std::ffi::OsStr>) { + if let Some(path) = path { + command.env("PATH", path); + } +} + +pub fn provider_command(program: impl AsRef) -> std::process::Command { + let mut command = std::process::Command::new(program); + apply_provider_path(&mut command); + command +} + +fn prefixed_path(bin: &Path, inherited: Option<&std::ffi::OsStr>) -> Result { + let mut paths = vec![bin.to_path_buf()]; + if let Some(inherited) = inherited { + paths.extend(std::env::split_paths(inherited)); + } + std::env::join_paths(paths).context("prepend managed harness directory to provider PATH") +} + +fn selected_persona<'a>(spec: &'a serde_json::Value, environment: Option<&'a str>) -> Result<&'a str> { + let mut nodes = spec.get("children").and_then(serde_json::Value::as_array) + .into_iter().flatten().filter(|node| node["name"] == "persona"); + let declared = if let Some(node) = nodes.next() { + ensure!(nodes.next().is_none(), "Agent Spec declares multiple personas"); + let arguments = node["arguments"].as_array().context("Agent Spec persona needs one string")?; + ensure!(arguments.len() == 1, "Agent Spec persona needs one string"); + Some(arguments[0].as_str().filter(|value| !value.is_empty()) + .context("Agent Spec persona needs one nonempty string")?) + } else { + None + }; + if let (Some(declared), Some(environment)) = (declared, environment) { + ensure!(declared == environment, + "Agent Spec persona {declared} disagrees with AGENT_PERSONA {environment}"); + } + Ok(declared.or(environment).unwrap_or("generalist")) +} + #[cfg(test)] mod tests { use super::*; @@ -159,4 +219,47 @@ mod tests { assert!(profile_path_from(&|_| None).is_err()); assert!(profile_path_from(&|name| (name == "AGENT_RUNTIME_PROFILE").then(|| "".into())).is_err()); } + + #[test] + fn declared_steward_selects_its_wrapper_without_environment_persona() { + let (_root, bin, profile) = fixture(); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"steward": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); + let wrapper = bin.join("omp"); + std::fs::write(&wrapper, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let spec = serde_json::json!({"children": [{"name": "persona", "arguments": ["steward"]}]}); + let persona = selected_persona(&spec, None).unwrap(); + assert_eq!(persona, "steward"); + let mut argv = ["omp".into()]; + resolve_for_persona("omp", &mut argv, &profile, persona).unwrap(); + assert_eq!(Path::new(&argv[0]), wrapper); + assert!(selected_persona(&spec, Some("generalist")).is_err()); + assert_eq!(selected_persona(&serde_json::json!({}), None).unwrap(), "generalist"); + } + + #[test] + fn provider_path_starts_with_managed_bin_without_changing_driver_path() { + let before = std::env::var_os("PATH"); + let path = prefixed_path(Path::new("/managed/bin"), Some(std::ffi::OsStr::new("/native/bin:/usr/bin"))).unwrap(); + let mut command = std::process::Command::new("/managed/bin/omp"); + apply_path(&mut command, Some(&path)); + let provider_path = command.get_envs().find(|(name, _)| *name == std::ffi::OsStr::new("PATH")) + .and_then(|(_, value)| value).unwrap(); + assert_eq!(std::env::split_paths(provider_path).collect::>(), + [PathBuf::from("/managed/bin"), PathBuf::from("/native/bin"), PathBuf::from("/usr/bin")]); + assert_eq!(std::env::var_os("PATH"), before); + } + + #[test] + fn explicit_relative_profile_is_refused() { + let error = profile_path_from(&|name| (name == "AGENT_RUNTIME_PROFILE") + .then(|| "relative/profile.json".into())).unwrap_err(); + assert!(format!("{error:#}").contains("AGENT_RUNTIME_PROFILE must be absolute")); + } } diff --git a/crates/fractal-drivers/src/provider_session.rs b/crates/fractal-drivers/src/provider_session.rs index 42d84821b05..d822d79fdb1 100644 --- a/crates/fractal-drivers/src/provider_session.rs +++ b/crates/fractal-drivers/src/provider_session.rs @@ -590,6 +590,7 @@ pub(crate) fn run_provider_observed_with_env_removals( .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); apply_provider_environment(&mut command, env, removed_env); + crate::managed_launch::apply_provider_path(&mut command); unsafe { command.pre_exec(|| { libc::signal(libc::SIGINT, libc::SIG_DFL); diff --git a/crates/fractal/src/driver_cli.rs b/crates/fractal/src/driver_cli.rs index 2c33d2e78cc..e35f1837c27 100644 --- a/crates/fractal/src/driver_cli.rs +++ b/crates/fractal/src/driver_cli.rs @@ -153,8 +153,13 @@ pub(super) async fn run_driver(client: &Client, args: DriverArgs, catalog: Optio if let Some(state) = fractal_drivers::reexec::resume_path(fractal_drivers::reexec::DRIVER_RESUME_ENV) { return resume_native_driver(client, subject, &args.driver, argv, &state).await; } - fractal_drivers::managed_launch::resolve(&args.driver, &mut argv)?; let incarnation = wait_for_agent_incarnation(client, subject).await?; + if matches!(args.driver.as_str(), "omp" | "claude" | "codex") { + let declaration = retry_while_daemon_unreachable(subject, || async { + client.agent_declaration_get(subject, None, false).await.map_err(anyhow::Error::from) + }).await?; + fractal_drivers::managed_launch::resolve(&args.driver, &mut argv, &declaration.value.tree)?; + } let paths = NativePaths::prepare(subject, &args.driver)?; let sessions = paths.session_dir.join("provider-sessions"); if let Some(session) = fractal_daemon::native_seed::first_launch( diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index 9389189ce24..3c3a6aed940 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -112,15 +112,19 @@ the PATH of the terminal running `fractal apply`, nor just launchd's or systemd' service environment. Agent `env` entries overlay that environment. Fractal also prepends its own executable directory so its wrapper can be found. -Managed `omp`, `claude`, and `codex` launches read `AGENT_RUNTIME_PROFILE`, or +Managed `omp`, `claude`, and `codex` launches read the absolute path in +`AGENT_RUNTIME_PROFILE`, or `$HOME/.local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json` when that variable is unset. Each launch reads the current profile through its stable link and executes `.harnessBin>/` -directly. `AGENT_PERSONA` selects the persona, defaulting to `generalist` only -when unset. The profile publisher selects the directory; Fractal does not -duplicate persona-to-path rules. A missing persona entry, `harnessBin`, or -executable wrapper refuses launch, naming the missing key/path. There is no -fallback to a raw provider or to a different persona. +directly. The Agent Spec's `persona` selects the persona; `AGENT_PERSONA` is next, +and `generalist` is the last fallback. A declared persona that disagrees with +`AGENT_PERSONA` refuses launch. The selected `harnessBin` is prepended to the +provider child's PATH, so provider subprocesses also resolve managed wrappers; +the driver's own PATH is unchanged. The profile publisher selects the directory; +Fractal does not duplicate persona-to-path rules. A missing persona entry, +`harnessBin`, or executable wrapper refuses launch, naming the missing key/path. +There is no fallback to a raw provider or to a different persona. Before upgrading a host to this launch contract, publish and activate a runtime profile containing `personas.launch..harnessBin` for its managed seats. diff --git a/release-notes/interactive-managed-launch.json b/release-notes/interactive-managed-launch.json index 12ed8355c83..3533ee014a9 100644 --- a/release-notes/interactive-managed-launch.json +++ b/release-notes/interactive-managed-launch.json @@ -4,8 +4,8 @@ "replay": {"status": "none", "detail": "No graph replay; executable selection happens in the driver before launch."}, "schema": {"status": "none", "detail": "No database schema change."}, "rules": {"status": "none", "detail": "Checkpoint rules and digests are unchanged."}, - "compatibility": {"status": "changed", "detail": "Managed OMP, Claude, and Codex launches require personas.launch..harnessBin in the runtime profile. AGENT_PERSONA defaults to generalist only when unset. Explicit executable overrides no longer bypass the managed wrapper."}, + "compatibility": {"status": "changed", "detail": "Managed OMP, Claude, and Codex launches require personas.launch..harnessBin in the runtime profile. Select the Agent Spec persona, then AGENT_PERSONA, then generalist; disagreement between spec and environment refuses launch. Explicit executable overrides cannot bypass the wrapper. Its directory is prepended to the provider PATH, not the driver PATH."}, "downtime": {"status": "none", "detail": "Existing provider processes are unchanged."}, - "manual": {"status": "changed", "detail": "Rollout dependency: do not upgrade a host to a Fractal release containing this change until that host has activated a runtime profile with personas.launch..harnessBin for its managed seats. Read AGENT_RUNTIME_PROFILE when set, otherwise the stable HOME-relative coding-agent-runtime Nix profile; missing profile, persona key, or wrapper refuses new managed launches."}, + "manual": {"status": "changed", "detail": "Rollout dependency: do not upgrade a host to a Fractal release containing this change until that host has activated a runtime profile with personas.launch..harnessBin for its managed seats. Read an absolute AGENT_RUNTIME_PROFILE when set, otherwise the stable HOME-relative coding-agent-runtime Nix profile; missing profile, persona key, or wrapper refuses new managed launches."}, "recovery": {"status": "none", "detail": "Correct the runtime profile or install the named wrapper before retrying a refused launch."} } From 1a26c8af58dfb25d499fdc17ded1968cfb9f85a5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:17:57 +0100 Subject: [PATCH 047/132] fix: bind managed launch policy to daemon launch provenance agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-drivers/src/managed_launch.rs | 107 +++++++++--- crates/fractal-reconcile/src/contract.rs | 153 ++++++++++++++++-- crates/fractal-reconcile/src/reconcile.rs | 8 + crates/fractal/src/driver_cli.rs | 58 ++++++- crates/fractal/src/main.rs | 3 + .../fractal/src/native_seed_driver_tests.rs | 16 ++ docs/site/reference/configuration.md | 12 +- release-notes/interactive-managed-launch.json | 2 +- 8 files changed, 321 insertions(+), 38 deletions(-) diff --git a/crates/fractal-drivers/src/managed_launch.rs b/crates/fractal-drivers/src/managed_launch.rs index abf8d4bb685..85499bba804 100644 --- a/crates/fractal-drivers/src/managed_launch.rs +++ b/crates/fractal-drivers/src/managed_launch.rs @@ -13,20 +13,7 @@ static PROVIDER_PATH: std::sync::OnceLock = std::sync::OnceL /// Select the managed executable, including when argv names a raw provider by absolute path. /// Other harnesses retain their own launch contracts. pub fn resolve(harness: &str, argv: &mut [String], spec: &serde_json::Value) -> Result<()> { - if !matches!(harness, "omp" | "claude" | "codex") { - return Ok(()); - } - let profile = profile_path()?; - let environment_persona = std::env::var("AGENT_PERSONA") - .map(Some) - .or_else(|error| match error { - std::env::VarError::NotPresent => Ok(None), - error => Err(error), - }).context("read AGENT_PERSONA")?; - let persona = selected_persona(spec, environment_persona.as_deref())?; - resolve_for_persona(harness, argv, &profile, persona)?; - let bin = Path::new(&argv[0]).parent().context("managed wrapper has no parent")?; - let path = prefixed_path(bin, std::env::var_os("PATH").as_deref())?; + let path = resolve_from_source(harness, argv, spec, &|name| std::env::var_os(name))?; if let Some(existing) = PROVIDER_PATH.get() { ensure!(existing == &path, "driver already selected a different managed provider PATH"); } else { @@ -35,8 +22,45 @@ pub fn resolve(harness: &str, argv: &mut [String], spec: &serde_json::Value) -> Ok(()) } -fn profile_path() -> Result { - profile_path_from(&|name| std::env::var_os(name)) +/// Resolve a daemon seat using the effective captured launch environment, without global state. +pub fn resolve_with_environment( + harness: &str, + argv: &mut [String], + spec: &serde_json::Value, + environment: &std::collections::BTreeMap, +) -> Result { + resolve_from_source(harness, argv, spec, &|name| environment.get(name).map(std::ffi::OsString::from)) +} + +fn resolve_from_source( + harness: &str, + argv: &mut [String], + spec: &serde_json::Value, + env: &impl Fn(&str) -> Option, +) -> Result { + ensure!(matches!(harness, "omp" | "claude" | "codex"), "unsupported managed harness {harness}"); + let profile = profile_path_from(env)?; + let environment_persona = env("AGENT_PERSONA") + .map(|value| value.into_string().map_err(|_| anyhow::anyhow!("AGENT_PERSONA is not UTF-8"))) + .transpose()?; + let persona = selected_persona(spec, environment_persona.as_deref())?; + resolve_for_persona(harness, argv, &profile, persona)?; + let bin = Path::new(&argv[0]).parent().context("managed wrapper has no parent")?; + prefixed_path(bin, env("PATH").as_deref()) +} + +pub fn is_managed_harness(daemon_managed: bool, harness: &str) -> bool { + daemon_managed && matches!(harness, "omp" | "claude" | "codex") +} + +/// Mark only the daemon's typed-driver launch, never a person-owned terminal invocation. +pub fn mark_daemon_launch(argv: &mut Vec) -> Result<()> { + let separator = argv.iter().position(|arg| arg == "--") + .context("managed driver launch has no provider separator")?; + if !argv[..separator].iter().any(|arg| arg == "--managed-launch") { + argv.insert(separator, "--managed-launch".into()); + } + Ok(()) } fn profile_path_from(env: &impl Fn(&str) -> Option) -> Result { @@ -111,7 +135,7 @@ pub fn provider_command(program: impl AsRef) -> std::process::C fn prefixed_path(bin: &Path, inherited: Option<&std::ffi::OsStr>) -> Result { let mut paths = vec![bin.to_path_buf()]; if let Some(inherited) = inherited { - paths.extend(std::env::split_paths(inherited)); + paths.extend(std::env::split_paths(inherited).filter(|path| path.as_path() != bin)); } std::env::join_paths(paths).context("prepend managed harness directory to provider PATH") } @@ -246,7 +270,7 @@ mod tests { #[test] fn provider_path_starts_with_managed_bin_without_changing_driver_path() { let before = std::env::var_os("PATH"); - let path = prefixed_path(Path::new("/managed/bin"), Some(std::ffi::OsStr::new("/native/bin:/usr/bin"))).unwrap(); + let path = prefixed_path(Path::new("/managed/bin"), Some(std::ffi::OsStr::new("/native/bin:/managed/bin:/usr/bin:/managed/bin"))).unwrap(); let mut command = std::process::Command::new("/managed/bin/omp"); apply_path(&mut command, Some(&path)); let provider_path = command.get_envs().find(|(name, _)| *name == std::ffi::OsStr::new("PATH")) @@ -262,4 +286,51 @@ mod tests { .then(|| "relative/profile.json".into())).unwrap_err(); assert!(format!("{error:#}").contains("AGENT_RUNTIME_PROFILE must be absolute")); } + + #[test] + fn malformed_profile_and_relative_wrapper_directory_refuse() { + let (_root, _bin, profile) = fixture(); + std::fs::write(&profile, "{").unwrap(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist").unwrap_err(); + assert!(format!("{error:#}").contains("parse managed runtime profile")); + std::fs::write(&profile, r#"{"personas":{"launch":{"generalist":{"harnessBin":"relative/bin"}}}}"#).unwrap(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist").unwrap_err(); + assert!(format!("{error:#}").contains("managed wrapper directory must be absolute")); + } + + #[cfg(unix)] + #[test] + fn profile_link_is_read_again_for_each_launch_and_broken_link_refuses() { + use std::os::unix::fs::{PermissionsExt as _, symlink}; + let (root, first_bin, first_profile) = fixture(); + let second_bin = root.path().join("second/bin"); + std::fs::create_dir_all(&second_bin).unwrap(); + for bin in [&first_bin, &second_bin] { + let wrapper = bin.join("codex"); + std::fs::write(&wrapper, "#!/bin/sh\nprintf managed-helper\n").unwrap(); + std::fs::set_permissions(wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let second_profile = root.path().join("second-profile.json"); + std::fs::write(&second_profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"generalist": {"harnessBin": second_bin}}}, + })).unwrap()).unwrap(); + let link = root.path().join("current-profile.json"); + let environment = std::collections::BTreeMap::from([ + ("AGENT_RUNTIME_PROFILE".into(), link.display().to_string()), + ("PATH".into(), "/missing/native/bin".into()), + ]); + for (profile, bin) in [(&first_profile, &first_bin), (&second_profile, &second_bin)] { + symlink(profile, &link).unwrap(); + let mut argv = ["codex".into(), "--version".into()]; + let path = resolve_with_environment("codex", &mut argv, &serde_json::json!({}), &environment).unwrap(); + assert_eq!(Path::new(&argv[0]), bin.join("codex")); + assert_eq!(std::env::split_paths(&path).next().unwrap(), *bin); + let output = std::process::Command::new(&argv[0]).arg(&argv[1]).env("PATH", path).output().unwrap(); + assert_eq!(output.stdout, b"managed-helper"); + std::fs::remove_file(&link).unwrap(); + } + symlink(root.path().join("missing-profile.json"), &link).unwrap(); + let error = resolve_with_environment("codex", &mut ["codex".into()], &serde_json::json!({}), &environment).unwrap_err(); + assert!(format!("{error:#}").contains("read managed runtime profile")); + } } diff --git a/crates/fractal-reconcile/src/contract.rs b/crates/fractal-reconcile/src/contract.rs index 12161fa3364..2debcc0daf9 100644 --- a/crates/fractal-reconcile/src/contract.rs +++ b/crates/fractal-reconcile/src/contract.rs @@ -1,5 +1,5 @@ //! Host-local launch identity; publication never changes incarnation evidence. -use std::{collections::BTreeMap, path::Path, sync::OnceLock}; +use std::{borrow::Cow, collections::BTreeMap, path::Path, sync::OnceLock}; use anyhow::{Context as _, Result}; use crate::launch_contract::Contract; @@ -68,6 +68,29 @@ fn capture_with(store: &Store, desired: &DesiredSubject, member: &MemberSpec, ru *path = std::env::join_paths(std::env::split_paths(path).map(|entry| if entry.is_absolute() { entry } else { Path::new(&member.cwd).join(entry) }))?.to_string_lossy().into_owned(); } + let authored = desired.member.as_ref().context("launch contract needs a member")?; + let provider = match &authored.launch { + LaunchSpec::Shell(_) => "sh", + LaunchSpec::Argv(argv) => { + let position = if authored.driver.is_some() { + argv.iter().position(|arg| arg == "--").context("harness launch has no provider")? + 1 + } else { 0 }; + argv.get(position).context("launch has no program")? + } + }; + let mut provider = Cow::Borrowed(provider); + if let Some(harness @ ("omp" | "claude" | "codex")) = member.driver.as_deref() { + // Measure the provider child, not the raw executable in the authored expansion. + // Wrapper selection and its interpreter PATH must agree with driver startup without + // changing the daemon's environment or selecting process-global driver state. + let mut argv = [provider.into_owned()]; + let path = fractal_drivers::managed_launch::resolve_with_environment( + harness, &mut argv, &desired.desired, &environment, + )?; + environment.insert("PATH".into(), path.into_string().map_err(|_| anyhow::anyhow!("managed provider PATH is not UTF-8"))?); + let [program] = argv; + provider = Cow::Owned(program); + } let resolve = |program: &str| { let mut program = program.to_owned(); fractal_runtime::expand_path_placeholder(&mut program, &environment); @@ -81,18 +104,8 @@ fn capture_with(store: &Store, desired: &DesiredSubject, member: &MemberSpec, ru let path = fractal_runtime::resolve_executable(&program, &environment)?; Ok(if path.is_absolute() { path } else { Path::new(&member.cwd).join(path) }) }; - let authored = desired.member.as_ref().context("launch contract needs a member")?; - let provider = match &authored.launch { - LaunchSpec::Shell(_) => "sh", - LaunchSpec::Argv(argv) => { - let position = if authored.driver.is_some() { - argv.iter().position(|arg| arg == "--").context("harness launch has no provider")? + 1 - } else { 0 }; - argv.get(position).context("launch has no program")? - } - }; let mut binaries = BTreeMap::new(); - for (role, path) in [("launcher", executable), ("provider", resolve(provider)?)] { + for (role, path) in [("launcher", executable), ("provider", resolve(&provider)?)] { // Start every pending measurement before answering, so one later read finds them all. binaries.insert(role.to_owned(), (measures.identity)(&path, &resolve, input)?); } @@ -139,6 +152,122 @@ pub fn status(store: &Store, desired: &DesiredSubject, incarnation: Option<&str> mod tests { use super::*; use std::os::unix::fs::PermissionsExt as _; + fn isolated_capture(store: &Store, desired: &DesiredSubject, ready: bool, input: &dyn Fn(&Path) -> Result<()>) -> Result> { + let environment = |declared: &BTreeMap, _: &Path| Ok(Some(declared.clone())); + let identity = |path: &Path, resolve: &dyn Fn(&str) -> Result, input: &dyn Fn(&Path) -> Result<()>| { + if ready { + fractal_drivers::harness_admission::executable_identity_ready_observed(path, resolve, input) + } else { + fractal_drivers::harness_admission::executable_identity_with(path, resolve).map(Some) + } + }; + capture_with(store, desired, desired.member.as_ref().unwrap(), false, &Measures { + environment: &environment, + identity: &identity, + }, input) + } + + fn managed_fixture(harness: &str, stable_profile: bool) -> (tempfile::TempDir, DesiredSubject, std::path::PathBuf) { + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("managed/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let profile = if stable_profile { + root.path().join(".local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json") + } else { + root.path().join("profile.json") + }; + std::fs::create_dir_all(profile.parent().unwrap()).unwrap(); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": { + "generalist": {"harnessBin": bin}, + "steward": {"harnessBin": bin}, + }}, + })).unwrap()).unwrap(); + for (program, source) in [(harness, "#!/usr/bin/env node\nmanaged provider\n"), ("node", "#!/bin/sh\nmanaged interpreter\n")] { + let path = bin.join(program); + std::fs::write(&path, source).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let mut desired = crate::graph::parse_test_intent(&format!( + "version 2\nagent \"worker\" {{ workspace {:?}; harness {harness:?} {{ model \"model-a\"; }} }}", + root.path().to_str().unwrap(), + ), "node").unwrap().subjects.into_values().next().unwrap(); + let member = desired.member.as_mut().unwrap(); + member.environment = BTreeMap::from([ + ("HOME".into(), root.path().display().to_string()), + ("PATH".into(), root.path().join("raw/bin").display().to_string()), + ]); + if !stable_profile { + member.environment.insert("AGENT_RUNTIME_PROFILE".into(), profile.display().to_string()); + } + (root, desired, bin) + } + + #[test] + fn typed_managed_contract_measures_wrapper_without_raw_provider() { + for harness in ["omp", "claude", "codex"] { + for stable_profile in [false, true] { + let (root, mut desired, bin) = managed_fixture(harness, stable_profile); + let store = Store::open_memory("node").unwrap(); + let seen = std::cell::RefCell::new(Vec::new()); + let original = isolated_capture(&store, &desired, false, &|path| { + seen.borrow_mut().push(path.to_owned()); Ok(()) + }).unwrap().unwrap(); + assert!(seen.borrow().contains(&bin.join(harness))); + assert!(!seen.borrow().contains(&root.path().join("raw/bin").join(harness))); + let LaunchSpec::Argv(argv) = &mut desired.member.as_mut().unwrap().launch else { panic!("typed argv") }; + let position = argv.iter().position(|arg| arg == "--").unwrap() + 1; + argv[position] = root.path().join("unavailable").join(harness).display().to_string(); + let explicit = isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); + assert_eq!(original.binary, explicit.binary, "an unavailable explicit raw provider cannot bypass the wrapper"); + let ready_inputs = std::cell::RefCell::new(Vec::new()); + if let Some(ready) = isolated_capture(&store, &desired, true, &|path| { + ready_inputs.borrow_mut().push(path.to_owned()); Ok(()) + }).unwrap() { + assert_eq!(ready, explicit); + } + assert!(ready_inputs.borrow().contains(&bin.join(harness))); + assert!(ready_inputs.borrow().contains(&bin.join("node"))); + std::fs::write(bin.join(harness), "#!/usr/bin/env node\nchanged managed provider\n").unwrap(); + let changed = isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); + assert_ne!(explicit.binary, changed.binary, "wrapper content is the provider identity"); + std::fs::write(bin.join("node"), "#!/bin/sh\nchanged managed interpreter\n").unwrap(); + assert_ne!(changed.binary, isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap().binary, + "wrapper shebang uses the provider's prefixed PATH"); + } + } + } + + #[test] + fn managed_contract_uses_raw_spec_persona_and_refuses_mismatch() { + let (root, mut desired, bin) = managed_fixture("omp", false); + let store = Store::open_memory("node").unwrap(); + std::fs::write(root.path().join("profile.json"), serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"steward": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); + desired.desired["children"].as_array_mut().unwrap().push(serde_json::json!({ + "name": "persona", "arguments": ["steward"], "properties": {}, "children": [], + })); + isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); + desired.member.as_mut().unwrap().environment.insert("AGENT_PERSONA".into(), "generalist".into()); + assert!(format!("{:#}", isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap_err()) + .contains("disagrees with AGENT_PERSONA")); + } + + #[test] + fn untyped_provider_named_like_managed_harness_keeps_native_identity() { + let (root, mut desired, bin) = managed_fixture("omp", false); + let store = Store::open_memory("node").unwrap(); + let member = desired.member.as_mut().unwrap(); + member.driver = None; + member.launch = LaunchSpec::Argv(vec![bin.join("omp").display().to_string()]); + member.environment.remove("AGENT_RUNTIME_PROFILE"); + member.environment.insert("HOME".into(), root.path().join("no-profile").display().to_string()); + // Native env shebang resolution still needs its authored interpreter PATH. + member.environment.insert("PATH".into(), bin.display().to_string()); + isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); + } + #[test] fn relative_provider_and_runtime_path_use_the_prepared_member() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index b8fa0db4433..d056d5be6fd 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -7884,6 +7884,14 @@ impl Reconciler { } else { crate::environment::remove_aliases(&mut launch_member.environment, "ST_AGENT"); } + if fractal_drivers::managed_launch::is_managed_harness( + subject.kind == "agent", member.driver.as_deref().unwrap_or(""), + ) { + let crate::model::LaunchSpec::Argv(argv) = &mut launch_member.launch else { + anyhow::bail!("managed typed driver requires an argv launch"); + }; + fractal_drivers::managed_launch::mark_daemon_launch(argv)?; + } let executable = launch_executable()?; let st_binary = self.st_binary.as_ref().unwrap_or(&executable); crate::environment::set(&mut launch_member.environment, "ST3_BIN", st_binary.to_string_lossy().into_owned()); diff --git a/crates/fractal/src/driver_cli.rs b/crates/fractal/src/driver_cli.rs index e35f1837c27..dd8fe14aa2b 100644 --- a/crates/fractal/src/driver_cli.rs +++ b/crates/fractal/src/driver_cli.rs @@ -1,5 +1,50 @@ use super::*; +fn managed_declaration_result( + result: std::result::Result, +) -> Result { + match result { + Ok(tree) => Ok(tree), + Err(GeneratedClientError::Api(ClientErrorCode::NotFound, _, _)) => Ok(Value::Null), + Err(error) => Err(error.into()), + } +} + +#[cfg(test)] +mod managed_declaration_tests { + use super::*; + + fn api_error(code: ClientErrorCode) -> GeneratedClientError { + let envelope = fractal_client::ErrorEnvelope { + api_version: "st3.client.v0".into(), + error_version: "st3.client.error.v0".into(), + request_id: "test".into(), + code: code.clone(), + message: "declaration read failed".into(), + retryable: false, + retry_after_ms: None, + details: BTreeMap::new(), + }; + GeneratedClientError::Api(code, envelope.message.clone(), Box::new(envelope)) + } + + #[test] + fn confirmed_not_found_allows_persona_fallback() { + assert_eq!(managed_declaration_result(Err(api_error(ClientErrorCode::NotFound))).unwrap(), Value::Null); + let spec = json!({"children": [{"name": "persona", "arguments": ["steward"]}]}); + assert_eq!(managed_declaration_result(Ok(spec.clone())).unwrap(), spec); + } + + #[test] + fn declaration_failures_do_not_become_missing_specs() { + for code in [ClientErrorCode::Forbidden, ClientErrorCode::Internal] { + assert!(managed_declaration_result(Err(api_error(code))).is_err()); + } + assert!(managed_declaration_result(Err(GeneratedClientError::Transport("unavailable".into()))).is_err()); + assert!(managed_declaration_result(Err(GeneratedClientError::Protocol("invalid JSON".into()))).is_err()); + } +} + /// `st driver-hook NAME [ARGS]`: answer one harness hook with the payload on stdin. pub(super) fn run_driver_hook() -> ExitCode { fractal_client::set_client_name(format!( @@ -150,16 +195,17 @@ pub(super) async fn run_driver(client: &Client, args: DriverArgs, catalog: Optio if let Some(drivers) = std::env::var_os("ST3_DRIVER_STATE_DIR") { fractal_drivers::run::use_harness_state_root(PathBuf::from(drivers).join("sessions")); } + if fractal_drivers::managed_launch::is_managed_harness(args.managed_launch, &args.driver) { + let gateway = generated_client(&client.endpoint, None)?; + let declaration = managed_declaration_result( + gateway.agent_declaration_get(subject, None, false).await.map(|reply| reply.value.tree), + )?; + fractal_drivers::managed_launch::resolve(&args.driver, &mut argv, &declaration)?; + } if let Some(state) = fractal_drivers::reexec::resume_path(fractal_drivers::reexec::DRIVER_RESUME_ENV) { return resume_native_driver(client, subject, &args.driver, argv, &state).await; } let incarnation = wait_for_agent_incarnation(client, subject).await?; - if matches!(args.driver.as_str(), "omp" | "claude" | "codex") { - let declaration = retry_while_daemon_unreachable(subject, || async { - client.agent_declaration_get(subject, None, false).await.map_err(anyhow::Error::from) - }).await?; - fractal_drivers::managed_launch::resolve(&args.driver, &mut argv, &declaration.value.tree)?; - } let paths = NativePaths::prepare(subject, &args.driver)?; let sessions = paths.session_dir.join("provider-sessions"); if let Some(session) = fractal_daemon::native_seed::first_launch( diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 97142994277..3cc1cd8a376 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -4869,6 +4869,9 @@ struct FeedbackReviewArgs { struct DriverArgs { #[arg(value_parser = ["claude", "claude-mcp", "codex", "pi", "pi-channel", "omp", "omp-channel", "opencode", "exec"])] driver: String, + /// Use the daemon-managed seat's runtime-profile wrapper contract. + #[arg(long, hide = true)] + managed_launch: bool, #[arg(long, env = "ST_AGENT")] subject: Option, #[arg(long)] diff --git a/crates/fractal/src/native_seed_driver_tests.rs b/crates/fractal/src/native_seed_driver_tests.rs index 96424162a74..3c03721fe33 100644 --- a/crates/fractal/src/native_seed_driver_tests.rs +++ b/crates/fractal/src/native_seed_driver_tests.rs @@ -1,5 +1,20 @@ use super::*; +#[test] +fn only_daemon_launches_select_managed_harnesses() { + let person = vec!["fractal", "driver", "claude", "--subject", "agent/example", "--", "/native/claude"] + .into_iter().map(str::to_owned).collect::>(); + let mut daemon = person.clone(); + fractal_drivers::managed_launch::mark_daemon_launch(&mut daemon).unwrap(); + for (argv, managed) in [(person, false), (daemon, true)] { + let Command::Driver(args) = Cli::try_parse_from(argv).unwrap().command else { + panic!("driver command expected"); + }; + assert_eq!(fractal_drivers::managed_launch::is_managed_harness(args.managed_launch, &args.driver), managed); + assert_eq!(args.argv, ["/native/claude"]); + } +} + #[test] fn seed_recovery_uses_agent_control_cli_and_requires_reason() { let cli = Cli::try_parse_from([ @@ -24,6 +39,7 @@ fn rejects_effective_resume_environment_before_receipt() { let client = Client::new(Endpoint::Unix(root.join("no-daemon.sock"))); let args = DriverArgs { driver: "omp".into(), + managed_launch: false, subject: Some("agent/example".into()), identity: None, initial_message: None, diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index 3c3a6aed940..23f7f07d194 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -112,7 +112,7 @@ the PATH of the terminal running `fractal apply`, nor just launchd's or systemd' service environment. Agent `env` entries overlay that environment. Fractal also prepends its own executable directory so its wrapper can be found. -Managed `omp`, `claude`, and `codex` launches read the absolute path in +Daemon-managed `omp`, `claude`, and `codex` launches read the absolute path in `AGENT_RUNTIME_PROFILE`, or `$HOME/.local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json` when that variable is unset. Each launch reads the current profile through its @@ -125,6 +125,16 @@ the driver's own PATH is unchanged. The profile publisher selects the directory; Fractal does not duplicate persona-to-path rules. A missing persona entry, `harnessBin`, or executable wrapper refuses launch, naming the missing key/path. There is no fallback to a raw provider or to a different persona. +Only a confirmed missing Agent Spec permits the environment/default persona +fallback; authorization, transport, malformed-response, and server errors refuse +managed launch. Prelaunch contract capture measures the selected wrapper, not a +raw executable from login PATH. Driver re-exec uses the managed wrapper for helper +commands while adopting, without restarting the running provider. + +The daemon marks managed typed-driver invocations explicitly. Person-owned +terminal invocations such as +`fractal driver claude --subject agent/example -- /native/claude` +are not marked and retain their native executable and PATH. Before upgrading a host to this launch contract, publish and activate a runtime profile containing `personas.launch..harnessBin` for its managed seats. diff --git a/release-notes/interactive-managed-launch.json b/release-notes/interactive-managed-launch.json index 3533ee014a9..f0b3f9bddb4 100644 --- a/release-notes/interactive-managed-launch.json +++ b/release-notes/interactive-managed-launch.json @@ -4,7 +4,7 @@ "replay": {"status": "none", "detail": "No graph replay; executable selection happens in the driver before launch."}, "schema": {"status": "none", "detail": "No database schema change."}, "rules": {"status": "none", "detail": "Checkpoint rules and digests are unchanged."}, - "compatibility": {"status": "changed", "detail": "Managed OMP, Claude, and Codex launches require personas.launch..harnessBin in the runtime profile. Select the Agent Spec persona, then AGENT_PERSONA, then generalist; disagreement between spec and environment refuses launch. Explicit executable overrides cannot bypass the wrapper. Its directory is prepended to the provider PATH, not the driver PATH."}, + "compatibility": {"status": "changed", "detail": "Daemon-managed OMP, Claude, and Codex launches, including re-exec helper commands and prelaunch identity capture, use personas.launch..harnessBin. Select the Agent Spec persona, then AGENT_PERSONA, then generalist; only confirmed declaration not-found permits fallback. Spec/environment mismatch refuses launch. Explicit executable overrides cannot bypass the wrapper; provider PATH is prefixed, driver PATH unchanged. Person-owned terminal driver launches retain native executables and PATH."}, "downtime": {"status": "none", "detail": "Existing provider processes are unchanged."}, "manual": {"status": "changed", "detail": "Rollout dependency: do not upgrade a host to a Fractal release containing this change until that host has activated a runtime profile with personas.launch..harnessBin for its managed seats. Read an absolute AGENT_RUNTIME_PROFILE when set, otherwise the stable HOME-relative coding-agent-runtime Nix profile; missing profile, persona key, or wrapper refuses new managed launches."}, "recovery": {"status": "none", "detail": "Correct the runtime profile or install the named wrapper before retrying a refused launch."} From abdc613118e78d1a87277fc494daf964be2b823f Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:35:41 +0100 Subject: [PATCH 048/132] fix: derive managed launch policy from authoritative seat binding agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-drivers/src/managed_launch.rs | 13 --- crates/fractal-reconcile/src/contract.rs | 43 +++++++++- crates/fractal-reconcile/src/reconcile.rs | 8 -- crates/fractal/src/driver_cli.rs | 82 ++++++++++++++++++- crates/fractal/src/main.rs | 3 - .../fractal/src/native_seed_driver_tests.rs | 16 ---- docs/site/reference/configuration.md | 13 ++- release-notes/interactive-managed-launch.json | 2 +- 8 files changed, 133 insertions(+), 47 deletions(-) diff --git a/crates/fractal-drivers/src/managed_launch.rs b/crates/fractal-drivers/src/managed_launch.rs index 85499bba804..ddc700580c7 100644 --- a/crates/fractal-drivers/src/managed_launch.rs +++ b/crates/fractal-drivers/src/managed_launch.rs @@ -49,19 +49,6 @@ fn resolve_from_source( prefixed_path(bin, env("PATH").as_deref()) } -pub fn is_managed_harness(daemon_managed: bool, harness: &str) -> bool { - daemon_managed && matches!(harness, "omp" | "claude" | "codex") -} - -/// Mark only the daemon's typed-driver launch, never a person-owned terminal invocation. -pub fn mark_daemon_launch(argv: &mut Vec) -> Result<()> { - let separator = argv.iter().position(|arg| arg == "--") - .context("managed driver launch has no provider separator")?; - if !argv[..separator].iter().any(|arg| arg == "--managed-launch") { - argv.insert(separator, "--managed-launch".into()); - } - Ok(()) -} fn profile_path_from(env: &impl Fn(&str) -> Option) -> Result { if let Some(profile) = env("AGENT_RUNTIME_PROFILE") { diff --git a/crates/fractal-reconcile/src/contract.rs b/crates/fractal-reconcile/src/contract.rs index 2debcc0daf9..dd61e3d4c7b 100644 --- a/crates/fractal-reconcile/src/contract.rs +++ b/crates/fractal-reconcile/src/contract.rs @@ -79,7 +79,26 @@ fn capture_with(store: &Store, desired: &DesiredSubject, member: &MemberSpec, ru } }; let mut provider = Cow::Borrowed(provider); - if let Some(harness @ ("omp" | "claude" | "codex")) = member.driver.as_deref() { + let binding = crate::terminal_binding::from_desired(&desired.desired)?; + let person_bound = if let Some(binding) = &binding { + let owner = fractal_schema::owned_terminals::owner(&binding.subject) + .map_err(|error| anyhow::anyhow!(error.message))?; + anyhow::ensure!( + owner.is_some_and(|owner| owner.starts_with("person/")) + && member.terminal_binding.as_ref() == Some(binding) + && member.lifecycle == crate::model::MemberLifecycle::TerminalBound, + "native launch contract requires a validated person-terminal binding", + ); + true + } else { + anyhow::ensure!(member.terminal_binding.is_none() + && member.lifecycle != crate::model::MemberLifecycle::TerminalBound, + "native launch contract is missing its person-terminal binding"); + false + }; + if let Some(harness @ ("omp" | "claude" | "codex")) = member.driver.as_deref() + && !person_bound + { // Measure the provider child, not the raw executable in the authored expansion. // Wrapper selection and its interpreter PATH must agree with driver startup without // changing the daemon's environment or selecting process-global driver state. @@ -268,6 +287,28 @@ mod tests { isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); } + #[test] + fn person_bound_contract_keeps_native_provider_without_a_managed_profile() { + let (root, mut desired, bin) = managed_fixture("claude", false); + let store = Store::open_memory("node").unwrap(); + let binding = crate::model::TerminalBinding { + subject: "pty/person/avery/019a0000-0000-7000-8000-000000000001".into(), + incarnation: "42:created".into(), + id: "019a0000-0000-7000-8000-000000000002".into(), + }; + desired.desired["children"].as_array_mut().unwrap().push(serde_json::json!({ + "name": "bind-terminal", "arguments": [binding.subject], + "properties": {"incarnation": binding.incarnation, "id": binding.id}, "children": [], + })); + let member = desired.member.as_mut().unwrap(); + member.lifecycle = crate::model::MemberLifecycle::TerminalBound; + member.terminal_binding = Some(binding); + member.environment.remove("AGENT_RUNTIME_PROFILE"); + member.environment.insert("HOME".into(), root.path().join("no-profile").display().to_string()); + member.environment.insert("PATH".into(), bin.display().to_string()); + isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); + } + #[test] fn relative_provider_and_runtime_path_use_the_prepared_member() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index d056d5be6fd..b8fa0db4433 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -7884,14 +7884,6 @@ impl Reconciler { } else { crate::environment::remove_aliases(&mut launch_member.environment, "ST_AGENT"); } - if fractal_drivers::managed_launch::is_managed_harness( - subject.kind == "agent", member.driver.as_deref().unwrap_or(""), - ) { - let crate::model::LaunchSpec::Argv(argv) = &mut launch_member.launch else { - anyhow::bail!("managed typed driver requires an argv launch"); - }; - fractal_drivers::managed_launch::mark_daemon_launch(argv)?; - } let executable = launch_executable()?; let st_binary = self.st_binary.as_ref().unwrap_or(&executable); crate::environment::set(&mut launch_member.environment, "ST3_BIN", st_binary.to_string_lossy().into_owned()); diff --git a/crates/fractal/src/driver_cli.rs b/crates/fractal/src/driver_cli.rs index dd8fe14aa2b..660ac2c7d59 100644 --- a/crates/fractal/src/driver_cli.rs +++ b/crates/fractal/src/driver_cli.rs @@ -10,6 +10,25 @@ fn managed_declaration_result( } } +fn person_terminal_exemption( + binding: Option<&fractal_daemon::model::TerminalBinding>, + session_actor: Option<&str>, + current_incarnation: Option<&str>, +) -> Result { + let Some(binding) = binding else { return Ok(false) }; + let owner = fractal_schema::owned_terminals::owner(&binding.subject) + .map_err(|error| anyhow::anyhow!(error.message))? + .filter(|owner| owner.starts_with("person/")) + .context("native launch exemption requires a person-owned terminal binding")?; + let actor = session_actor.context("native launch exemption requires an authenticated person")?; + anyhow::ensure!(actor == owner || actor.strip_prefix(owner).is_some_and(|suffix| { + suffix.strip_prefix("/session/").is_some_and(|id| !id.is_empty()) + }), "native launch exemption requires the bound terminal's person owner"); + anyhow::ensure!(current_incarnation == Some(binding.agent_incarnation().as_str()), + "native launch exemption requires the current bound terminal incarnation"); + Ok(true) +} + #[cfg(test)] mod managed_declaration_tests { use super::*; @@ -43,6 +62,55 @@ mod managed_declaration_tests { assert!(managed_declaration_result(Err(GeneratedClientError::Transport("unavailable".into()))).is_err()); assert!(managed_declaration_result(Err(GeneratedClientError::Protocol("invalid JSON".into()))).is_err()); } + + #[test] + fn person_owned_native_exemption_requires_authoritative_owner_and_binding_fence() { + let binding = fractal_daemon::model::TerminalBinding { + subject: "pty/person/avery/019a0000-0000-7000-8000-000000000001".into(), + incarnation: "42:created".into(), + id: "019a0000-0000-7000-8000-000000000002".into(), + }; + let incarnation = binding.agent_incarnation(); + assert!(person_terminal_exemption(Some(&binding), Some("person/avery"), Some(&incarnation)).unwrap()); + assert!(person_terminal_exemption(Some(&binding), Some("person/avery/session/device"), Some(&incarnation)).unwrap()); + assert!(person_terminal_exemption(Some(&binding), Some("agent/example"), Some(&incarnation)).is_err()); + assert!(person_terminal_exemption(Some(&binding), Some("person/other"), Some(&incarnation)).is_err()); + assert!(person_terminal_exemption(Some(&binding), Some("person/avery"), Some("stale")).is_err()); + assert!(person_terminal_exemption(Some(&binding), None, Some(&incarnation)).is_err()); + assert!(!person_terminal_exemption(None, Some("person/avery"), None).unwrap()); + } + + #[cfg(unix)] + #[test] + fn legacy_resume_and_flagless_agent_calls_select_the_managed_wrapper() { + use std::os::unix::fs::PermissionsExt as _; + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("managed/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let wrapper = bin.join("codex"); + std::fs::write(&wrapper, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let profile = root.path().join("profile.json"); + std::fs::write(&profile, serde_json::to_vec(&json!({ + "personas": {"launch": {"generalist": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); + let environment = BTreeMap::from([ + ("AGENT_RUNTIME_PROFILE".into(), profile.display().to_string()), + ("PATH".into(), "/native/bin".into()), + ]); + for program in ["codex", "/native/bin/codex"] { + // Legacy re-exec preserves these flagless arguments from the previous image. + let Command::Driver(mut args) = Cli::try_parse_from([ + "fractal", "driver", "codex", "--subject", "agent/example", "--", program, + ]).unwrap().command else { panic!("driver expected") }; + let declaration = managed_declaration_result(Err(api_error(ClientErrorCode::NotFound))).unwrap(); + assert!(!person_terminal_exemption(None, Some("agent/example"), None).unwrap()); + fractal_drivers::managed_launch::resolve_with_environment( + &args.driver, &mut args.argv, &declaration, &environment, + ).unwrap(); + assert_eq!(Path::new(&args.argv[0]), wrapper); + } + } } /// `st driver-hook NAME [ARGS]`: answer one harness hook with the payload on stdin. @@ -195,12 +263,22 @@ pub(super) async fn run_driver(client: &Client, args: DriverArgs, catalog: Optio if let Some(drivers) = std::env::var_os("ST3_DRIVER_STATE_DIR") { fractal_drivers::run::use_harness_state_root(PathBuf::from(drivers).join("sessions")); } - if fractal_drivers::managed_launch::is_managed_harness(args.managed_launch, &args.driver) { + if matches!(args.driver.as_str(), "omp" | "claude" | "codex") { let gateway = generated_client(&client.endpoint, None)?; let declaration = managed_declaration_result( gateway.agent_declaration_get(subject, None, false).await.map(|reply| reply.value.tree), )?; - fractal_drivers::managed_launch::resolve(&args.driver, &mut argv, &declaration)?; + let binding = fractal_daemon::terminal_binding::from_desired(&declaration)?; + let native = if let Some(binding) = &binding { + let actor = gateway.capabilities().await?.value.session_actor; + let incarnation = current_agent_incarnation(client, subject).await?; + person_terminal_exemption(Some(binding), Some(&actor), incarnation.as_deref())? + } else { + false + }; + if !native { + fractal_drivers::managed_launch::resolve(&args.driver, &mut argv, &declaration)?; + } } if let Some(state) = fractal_drivers::reexec::resume_path(fractal_drivers::reexec::DRIVER_RESUME_ENV) { return resume_native_driver(client, subject, &args.driver, argv, &state).await; diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 3cc1cd8a376..97142994277 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -4869,9 +4869,6 @@ struct FeedbackReviewArgs { struct DriverArgs { #[arg(value_parser = ["claude", "claude-mcp", "codex", "pi", "pi-channel", "omp", "omp-channel", "opencode", "exec"])] driver: String, - /// Use the daemon-managed seat's runtime-profile wrapper contract. - #[arg(long, hide = true)] - managed_launch: bool, #[arg(long, env = "ST_AGENT")] subject: Option, #[arg(long)] diff --git a/crates/fractal/src/native_seed_driver_tests.rs b/crates/fractal/src/native_seed_driver_tests.rs index 3c03721fe33..96424162a74 100644 --- a/crates/fractal/src/native_seed_driver_tests.rs +++ b/crates/fractal/src/native_seed_driver_tests.rs @@ -1,20 +1,5 @@ use super::*; -#[test] -fn only_daemon_launches_select_managed_harnesses() { - let person = vec!["fractal", "driver", "claude", "--subject", "agent/example", "--", "/native/claude"] - .into_iter().map(str::to_owned).collect::>(); - let mut daemon = person.clone(); - fractal_drivers::managed_launch::mark_daemon_launch(&mut daemon).unwrap(); - for (argv, managed) in [(person, false), (daemon, true)] { - let Command::Driver(args) = Cli::try_parse_from(argv).unwrap().command else { - panic!("driver command expected"); - }; - assert_eq!(fractal_drivers::managed_launch::is_managed_harness(args.managed_launch, &args.driver), managed); - assert_eq!(args.argv, ["/native/claude"]); - } -} - #[test] fn seed_recovery_uses_agent_control_cli_and_requires_reason() { let cli = Cli::try_parse_from([ @@ -39,7 +24,6 @@ fn rejects_effective_resume_environment_before_receipt() { let client = Client::new(Endpoint::Unix(root.join("no-daemon.sock"))); let args = DriverArgs { driver: "omp".into(), - managed_launch: false, subject: Some("agent/example".into()), identity: None, initial_message: None, diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index 23f7f07d194..a15d6f3fd22 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -131,10 +131,17 @@ managed launch. Prelaunch contract capture measures the selected wrapper, not a raw executable from login PATH. Driver re-exec uses the managed wrapper for helper commands while adopting, without restarting the running provider. -The daemon marks managed typed-driver invocations explicitly. Person-owned -terminal invocations such as +Wrapper selection follows the authoritative seat declaration, not a launch flag +or an environment-based guess. Flagless invocations and legacy driver re-exec +are managed too. A native exemption requires a validated `bind-terminal` +declaration for a person-owned terminal, an authenticated session belonging to +that person, and the seat's current incarnation matching the binding exactly. +Missing or mismatched proof refuses the exemption. + +For a positively verified person-terminal binding, `fractal driver claude --subject agent/example -- /native/claude` -are not marked and retain their native executable and PATH. +retains its native executable and PATH. Merely naming an agent subject from a +person shell does not establish a native exemption. Before upgrading a host to this launch contract, publish and activate a runtime profile containing `personas.launch..harnessBin` for its managed seats. diff --git a/release-notes/interactive-managed-launch.json b/release-notes/interactive-managed-launch.json index f0b3f9bddb4..92658989243 100644 --- a/release-notes/interactive-managed-launch.json +++ b/release-notes/interactive-managed-launch.json @@ -4,7 +4,7 @@ "replay": {"status": "none", "detail": "No graph replay; executable selection happens in the driver before launch."}, "schema": {"status": "none", "detail": "No database schema change."}, "rules": {"status": "none", "detail": "Checkpoint rules and digests are unchanged."}, - "compatibility": {"status": "changed", "detail": "Daemon-managed OMP, Claude, and Codex launches, including re-exec helper commands and prelaunch identity capture, use personas.launch..harnessBin. Select the Agent Spec persona, then AGENT_PERSONA, then generalist; only confirmed declaration not-found permits fallback. Spec/environment mismatch refuses launch. Explicit executable overrides cannot bypass the wrapper; provider PATH is prefixed, driver PATH unchanged. Person-owned terminal driver launches retain native executables and PATH."}, + "compatibility": {"status": "changed", "detail": "OMP, Claude, and Codex agent-seat launches, including flagless legacy re-exec helpers and prelaunch identity capture, use personas.launch..harnessBin. Native exemption requires an authoritative person-terminal binding, matching authenticated owner, and current binding incarnation. Select the Agent Spec persona, then AGENT_PERSONA, then generalist; only confirmed declaration not-found permits fallback. Spec/environment mismatch refuses launch. Explicit raw executable overrides cannot bypass the wrapper; provider PATH is prefixed, driver PATH unchanged."}, "downtime": {"status": "none", "detail": "Existing provider processes are unchanged."}, "manual": {"status": "changed", "detail": "Rollout dependency: do not upgrade a host to a Fractal release containing this change until that host has activated a runtime profile with personas.launch..harnessBin for its managed seats. Read an absolute AGENT_RUNTIME_PROFILE when set, otherwise the stable HOME-relative coding-agent-runtime Nix profile; missing profile, persona key, or wrapper refuses new managed launches."}, "recovery": {"status": "none", "detail": "Correct the runtime profile or install the named wrapper before retrying a refused launch."} From 92dcc9b29595f97c3fc10b09fae98b23fc3ae5fb Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:12:07 +0100 Subject: [PATCH 049/132] Preserve native person bindings with authenticated launch selection agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-drivers/src/managed_launch.rs | 100 ++++++++++++++-- .../fractal-drivers/src/provider_session.rs | 2 +- crates/fractal-reconcile/src/contract.rs | 23 +--- crates/fractal/src/driver_cli.rs | 63 +++------- .../fractal/src/native_seed_driver_tests.rs | 110 ++++++++++++++++++ docs/site/reference/configuration.md | 28 ++--- docs/st3/terminal-binding.md | 2 + release-notes/interactive-managed-launch.json | 2 +- 8 files changed, 234 insertions(+), 96 deletions(-) diff --git a/crates/fractal-drivers/src/managed_launch.rs b/crates/fractal-drivers/src/managed_launch.rs index ddc700580c7..6863d97b311 100644 --- a/crates/fractal-drivers/src/managed_launch.rs +++ b/crates/fractal-drivers/src/managed_launch.rs @@ -8,16 +8,26 @@ use anyhow::{Context, Result, ensure}; const WRAPPER_DIRECTORY_KEY: &str = "harnessBin"; // A driver owns one seat. Keep launch-only environment separate from the driver's environment. -static PROVIDER_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); +struct ProviderEnvironment { + path: std::ffi::OsString, + subject: String, +} +static PROVIDER_ENVIRONMENT: std::sync::OnceLock = std::sync::OnceLock::new(); /// Select the managed executable, including when argv names a raw provider by absolute path. /// Other harnesses retain their own launch contracts. -pub fn resolve(harness: &str, argv: &mut [String], spec: &serde_json::Value) -> Result<()> { +pub fn resolve(harness: &str, argv: &mut [String], spec: &serde_json::Value, subject: &str) -> Result<()> { + if is_person_bound(spec)? { + return Ok(()); + } + ensure!(subject.starts_with("agent/"), "managed harness launch requires a fenced agent subject"); let path = resolve_from_source(harness, argv, spec, &|name| std::env::var_os(name))?; - if let Some(existing) = PROVIDER_PATH.get() { - ensure!(existing == &path, "driver already selected a different managed provider PATH"); + if let Some(existing) = PROVIDER_ENVIRONMENT.get() { + ensure!(existing.path == path && existing.subject == subject, + "driver already selected a different managed provider environment"); } else { - PROVIDER_PATH.set(path).map_err(|_| anyhow::anyhow!("managed provider PATH already selected"))?; + PROVIDER_ENVIRONMENT.set(ProviderEnvironment { path, subject: subject.into() }) + .map_err(|_| anyhow::anyhow!("managed provider environment already selected"))?; } Ok(()) } @@ -32,6 +42,21 @@ pub fn resolve_with_environment( resolve_from_source(harness, argv, spec, &|name| environment.get(name).map(std::ffi::OsString::from)) } +/// Person-owned terminal bindings retain their native harness launch contract. +/// Callers supply the declaration read through the driver's authenticated client. +pub fn is_person_bound(spec: &serde_json::Value) -> Result { + let Some(binding) = spec.get("children").and_then(serde_json::Value::as_array) + .and_then(|nodes| nodes.iter().find(|node| node["name"] == "bind-terminal")) else { + return Ok(false); + }; + let subject = binding.get("arguments").and_then(serde_json::Value::as_array) + .and_then(|arguments| arguments.first()).and_then(serde_json::Value::as_str) + .context("terminal binding is missing its subject")?; + let owner = fractal_schema::owned_terminals::owner(subject) + .map_err(|error| anyhow::anyhow!("{}", error.message))?; + Ok(owner.is_some_and(|owner| owner.starts_with("person/"))) +} + fn resolve_from_source( harness: &str, argv: &mut [String], @@ -39,6 +64,9 @@ fn resolve_from_source( env: &impl Fn(&str) -> Option, ) -> Result { ensure!(matches!(harness, "omp" | "claude" | "codex"), "unsupported managed harness {harness}"); + if is_person_bound(spec)? { + return Ok(env("PATH").unwrap_or_default()); + } let profile = profile_path_from(env)?; let environment_persona = env("AGENT_PERSONA") .map(|value| value.into_string().map_err(|_| anyhow::anyhow!("AGENT_PERSONA is not UTF-8"))) @@ -49,7 +77,6 @@ fn resolve_from_source( prefixed_path(bin, env("PATH").as_deref()) } - fn profile_path_from(env: &impl Fn(&str) -> Option) -> Result { if let Some(profile) = env("AGENT_RUNTIME_PROFILE") { ensure!(!profile.is_empty(), "AGENT_RUNTIME_PROFILE is empty; refusing raw provider"); @@ -103,8 +130,25 @@ fn resolve_for_persona( } /// Apply the managed launch environment only to a provider child. -pub fn apply_provider_path(command: &mut std::process::Command) { - apply_path(command, PROVIDER_PATH.get().map(std::ffi::OsString::as_os_str)); +pub fn apply_provider_environment(command: &mut std::process::Command) { + if let Some(environment) = PROVIDER_ENVIRONMENT.get() { + apply_path(command, Some(&environment.path)); + apply_agent_identity(command, &environment.subject, &|name| std::env::var_os(name)); + } +} + +fn apply_agent_identity( + command: &mut std::process::Command, + subject: &str, + inherited: &impl Fn(&str) -> Option, +) { + for name in ["ST_AGENT", "FRACTAL_AGENT"] { + let present = command.get_envs().find(|(key, _)| *key == std::ffi::OsStr::new(name)) + .map(|(_, value)| value.is_some()).unwrap_or_else(|| inherited(name).is_some()); + if !present { + command.env(name, subject); + } + } } fn apply_path(command: &mut std::process::Command, path: Option<&std::ffi::OsStr>) { @@ -115,7 +159,7 @@ fn apply_path(command: &mut std::process::Command, path: Option<&std::ffi::OsStr pub fn provider_command(program: impl AsRef) -> std::process::Command { let mut command = std::process::Command::new(program); - apply_provider_path(&mut command); + apply_provider_environment(&mut command); command } @@ -320,4 +364,42 @@ mod tests { let error = resolve_with_environment("codex", &mut ["codex".into()], &serde_json::json!({}), &environment).unwrap_err(); assert!(format!("{error:#}").contains("read managed runtime profile")); } + + #[test] + fn person_bound_driver_keeps_native_argv_without_a_managed_profile() { + let spec = serde_json::json!({"children": [{ + "name": "bind-terminal", + "arguments": ["pty/person/avery/019a0000-0000-7000-8000-000000000001"], + }]}); + let environment = std::collections::BTreeMap::from([("PATH".into(), "/native/bin".into())]); + for harness in ["omp", "claude", "codex"] { + let mut argv = [format!("/native/{harness}")]; + let path = resolve_with_environment(harness, &mut argv, &spec, &environment).unwrap(); + assert_eq!(path, std::ffi::OsStr::new("/native/bin")); + assert_eq!(argv[0], format!("/native/{harness}")); + } + let agent_binding = serde_json::json!({"children": [{ + "name": "bind-terminal", + "arguments": ["pty/agent/worker/019a0000-0000-7000-8000-000000000001"], + }]}); + assert!(!is_person_bound(&agent_binding).unwrap()); + assert!(resolve_with_environment("omp", &mut ["omp".into()], &agent_binding, &environment).is_err()); + } + + #[test] + fn provider_identity_fills_missing_aliases_and_preserves_existing_authority() { + let mut missing = std::process::Command::new("omp"); + apply_agent_identity(&mut missing, "agent/worker", &|_| None); + let values = missing.get_envs().collect::>(); + assert_eq!(values[std::ffi::OsStr::new("ST_AGENT")], Some(std::ffi::OsStr::new("agent/worker"))); + assert_eq!(values[std::ffi::OsStr::new("FRACTAL_AGENT")], Some(std::ffi::OsStr::new("agent/worker"))); + let mut existing = std::process::Command::new("omp"); + existing.env("ST_AGENT", "agent/explicit"); + apply_agent_identity(&mut existing, "agent/worker", &|name| { + (name == "FRACTAL_AGENT").then(|| "agent/inherited".into()) + }); + let values = existing.get_envs().collect::>(); + assert_eq!(values[std::ffi::OsStr::new("ST_AGENT")], Some(std::ffi::OsStr::new("agent/explicit"))); + assert!(!values.contains_key(std::ffi::OsStr::new("FRACTAL_AGENT"))); + } } diff --git a/crates/fractal-drivers/src/provider_session.rs b/crates/fractal-drivers/src/provider_session.rs index d822d79fdb1..296d0fb62d2 100644 --- a/crates/fractal-drivers/src/provider_session.rs +++ b/crates/fractal-drivers/src/provider_session.rs @@ -590,7 +590,7 @@ pub(crate) fn run_provider_observed_with_env_removals( .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); apply_provider_environment(&mut command, env, removed_env); - crate::managed_launch::apply_provider_path(&mut command); + crate::managed_launch::apply_provider_environment(&mut command); unsafe { command.pre_exec(|| { libc::signal(libc::SIGINT, libc::SIG_DFL); diff --git a/crates/fractal-reconcile/src/contract.rs b/crates/fractal-reconcile/src/contract.rs index dd61e3d4c7b..d1eec59f13a 100644 --- a/crates/fractal-reconcile/src/contract.rs +++ b/crates/fractal-reconcile/src/contract.rs @@ -79,26 +79,7 @@ fn capture_with(store: &Store, desired: &DesiredSubject, member: &MemberSpec, ru } }; let mut provider = Cow::Borrowed(provider); - let binding = crate::terminal_binding::from_desired(&desired.desired)?; - let person_bound = if let Some(binding) = &binding { - let owner = fractal_schema::owned_terminals::owner(&binding.subject) - .map_err(|error| anyhow::anyhow!(error.message))?; - anyhow::ensure!( - owner.is_some_and(|owner| owner.starts_with("person/")) - && member.terminal_binding.as_ref() == Some(binding) - && member.lifecycle == crate::model::MemberLifecycle::TerminalBound, - "native launch contract requires a validated person-terminal binding", - ); - true - } else { - anyhow::ensure!(member.terminal_binding.is_none() - && member.lifecycle != crate::model::MemberLifecycle::TerminalBound, - "native launch contract is missing its person-terminal binding"); - false - }; - if let Some(harness @ ("omp" | "claude" | "codex")) = member.driver.as_deref() - && !person_bound - { + if let Some(harness @ ("omp" | "claude" | "codex")) = member.driver.as_deref() { // Measure the provider child, not the raw executable in the authored expansion. // Wrapper selection and its interpreter PATH must agree with driver startup without // changing the daemon's environment or selecting process-global driver state. @@ -173,7 +154,7 @@ mod tests { use std::os::unix::fs::PermissionsExt as _; fn isolated_capture(store: &Store, desired: &DesiredSubject, ready: bool, input: &dyn Fn(&Path) -> Result<()>) -> Result> { let environment = |declared: &BTreeMap, _: &Path| Ok(Some(declared.clone())); - let identity = |path: &Path, resolve: &dyn Fn(&str) -> Result, input: &dyn Fn(&Path) -> Result<()>| { + let identity = move |path: &Path, resolve: &dyn Fn(&str) -> Result, input: &dyn Fn(&Path) -> Result<()>| { if ready { fractal_drivers::harness_admission::executable_identity_ready_observed(path, resolve, input) } else { diff --git a/crates/fractal/src/driver_cli.rs b/crates/fractal/src/driver_cli.rs index 660ac2c7d59..6cd60a56e5f 100644 --- a/crates/fractal/src/driver_cli.rs +++ b/crates/fractal/src/driver_cli.rs @@ -10,23 +10,18 @@ fn managed_declaration_result( } } -fn person_terminal_exemption( - binding: Option<&fractal_daemon::model::TerminalBinding>, - session_actor: Option<&str>, - current_incarnation: Option<&str>, -) -> Result { - let Some(binding) = binding else { return Ok(false) }; - let owner = fractal_schema::owned_terminals::owner(&binding.subject) - .map_err(|error| anyhow::anyhow!(error.message))? - .filter(|owner| owner.starts_with("person/")) - .context("native launch exemption requires a person-owned terminal binding")?; - let actor = session_actor.context("native launch exemption requires an authenticated person")?; - anyhow::ensure!(actor == owner || actor.strip_prefix(owner).is_some_and(|suffix| { - suffix.strip_prefix("/session/").is_some_and(|id| !id.is_empty()) - }), "native launch exemption requires the bound terminal's person owner"); - anyhow::ensure!(current_incarnation == Some(binding.agent_incarnation().as_str()), - "native launch exemption requires the current bound terminal incarnation"); - Ok(true) +pub(super) async fn prepare_managed_provider( + client: &Client, + subject: &str, + harness: &str, + argv: &mut [String], +) -> Result<()> { + let actor = client.person.as_deref().unwrap_or(subject); + let gateway = generated_client(&client.endpoint, Some(actor))?; + let declaration = managed_declaration_result( + gateway.agent_declaration_get(subject, None, false).await.map(|reply| reply.value.tree), + )?; + fractal_drivers::managed_launch::resolve(harness, argv, &declaration, subject) } #[cfg(test)] @@ -63,23 +58,6 @@ mod managed_declaration_tests { assert!(managed_declaration_result(Err(GeneratedClientError::Protocol("invalid JSON".into()))).is_err()); } - #[test] - fn person_owned_native_exemption_requires_authoritative_owner_and_binding_fence() { - let binding = fractal_daemon::model::TerminalBinding { - subject: "pty/person/avery/019a0000-0000-7000-8000-000000000001".into(), - incarnation: "42:created".into(), - id: "019a0000-0000-7000-8000-000000000002".into(), - }; - let incarnation = binding.agent_incarnation(); - assert!(person_terminal_exemption(Some(&binding), Some("person/avery"), Some(&incarnation)).unwrap()); - assert!(person_terminal_exemption(Some(&binding), Some("person/avery/session/device"), Some(&incarnation)).unwrap()); - assert!(person_terminal_exemption(Some(&binding), Some("agent/example"), Some(&incarnation)).is_err()); - assert!(person_terminal_exemption(Some(&binding), Some("person/other"), Some(&incarnation)).is_err()); - assert!(person_terminal_exemption(Some(&binding), Some("person/avery"), Some("stale")).is_err()); - assert!(person_terminal_exemption(Some(&binding), None, Some(&incarnation)).is_err()); - assert!(!person_terminal_exemption(None, Some("person/avery"), None).unwrap()); - } - #[cfg(unix)] #[test] fn legacy_resume_and_flagless_agent_calls_select_the_managed_wrapper() { @@ -104,7 +82,6 @@ mod managed_declaration_tests { "fractal", "driver", "codex", "--subject", "agent/example", "--", program, ]).unwrap().command else { panic!("driver expected") }; let declaration = managed_declaration_result(Err(api_error(ClientErrorCode::NotFound))).unwrap(); - assert!(!person_terminal_exemption(None, Some("agent/example"), None).unwrap()); fractal_drivers::managed_launch::resolve_with_environment( &args.driver, &mut args.argv, &declaration, &environment, ).unwrap(); @@ -264,21 +241,7 @@ pub(super) async fn run_driver(client: &Client, args: DriverArgs, catalog: Optio fractal_drivers::run::use_harness_state_root(PathBuf::from(drivers).join("sessions")); } if matches!(args.driver.as_str(), "omp" | "claude" | "codex") { - let gateway = generated_client(&client.endpoint, None)?; - let declaration = managed_declaration_result( - gateway.agent_declaration_get(subject, None, false).await.map(|reply| reply.value.tree), - )?; - let binding = fractal_daemon::terminal_binding::from_desired(&declaration)?; - let native = if let Some(binding) = &binding { - let actor = gateway.capabilities().await?.value.session_actor; - let incarnation = current_agent_incarnation(client, subject).await?; - person_terminal_exemption(Some(binding), Some(&actor), incarnation.as_deref())? - } else { - false - }; - if !native { - fractal_drivers::managed_launch::resolve(&args.driver, &mut argv, &declaration)?; - } + prepare_managed_provider(client, subject, &args.driver, &mut argv).await?; } if let Some(state) = fractal_drivers::reexec::resume_path(fractal_drivers::reexec::DRIVER_RESUME_ENV) { return resume_native_driver(client, subject, &args.driver, argv, &state).await; diff --git a/crates/fractal/src/native_seed_driver_tests.rs b/crates/fractal/src/native_seed_driver_tests.rs index 96424162a74..76b248275ba 100644 --- a/crates/fractal/src/native_seed_driver_tests.rs +++ b/crates/fractal/src/native_seed_driver_tests.rs @@ -71,3 +71,113 @@ fn rejects_effective_resume_environment_before_receipt() { } } } + +#[cfg(unix)] +#[test] +fn managed_startup_reads_its_declaration_over_authenticated_unix() { + use std::os::unix::fs::PermissionsExt as _; + + const CHILD: &str = "ST3_TEST_MANAGED_UNIX_ROOT"; + let Some(root) = std::env::var_os(CHILD) else { + // Both the profile environment and the resolver's process-wide provider PATH belong + // to one driver. Isolate them from other tests, just like a real driver process. + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("steward/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let wrapper = bin.join("codex"); + std::fs::write(&wrapper, "#!/bin/sh\nexit 99\n").unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let profile = root.path().join("profile.json"); + // Deliberately omit generalist: treating the declaration as missing cannot pass. + std::fs::write(&profile, serde_json::to_vec(&json!({ + "personas": {"launch": {"steward": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "native_seed_driver_tests::managed_startup_reads_its_declaration_over_authenticated_unix", + "--nocapture", + ]) + .env(CHILD, root.path()) + .env("AGENT_RUNTIME_PROFILE", &profile) + .env_remove("AGENT_PERSONA") + .env("HOME", root.path()) + .output().unwrap(); + assert!(output.status.success(), "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr)); + return; + }; + let root = PathBuf::from(root); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all().build().unwrap(); + runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(10), async { + const NODE: &str = "managed-startup"; + const FLEET: &str = "3c9a1f2e-8b7d-4e6c-a5f4-1d2e3c4b5a69"; + const SUBJECT: &str = "agent/example/managed"; + let store = Arc::new(Store::open_memory(NODE).unwrap()); + let key = Arc::new(fractal_daemon::fleet::MemberKey::generate().unwrap().0); + store.bind_fleet(FLEET).unwrap(); + store.pin_fleet_anchor(key.public()).unwrap(); + store.set_member_key(Some(key.clone())).unwrap(); + store.admit_fleet_anchor(FLEET, key.public(), "listening").unwrap(); + let source = format!( + "version 2\nagent \"example/managed\" {{ accountable \"person/operator\"; workspace {:?}; harness \"codex\" {{}}; persona \"steward\"; }}", + root, + ); + let intent = fractal_daemon::graph::parse_intent(&source, NODE).unwrap(); + store.apply_as( + &intent, + &BTreeMap::from([(SUBJECT.into(), Vec::new())]), + "managed-startup-declaration", + Some("person/operator"), + ).unwrap(); + let state = AppState { + seat_admission: Default::default(), + owning_person: Some("person/operator".into()), + store, + notify: Arc::new(Notify::new()), + event_notify: watch::channel(0_u64).0, + node: NODE.into(), + state_dir: root.clone(), + pty_root: root.join("pty"), + pty_binary: PathBuf::from("pty"), + fleet_id: Some(FLEET.into()), + configured_peers: vec![], + client_relay: None, + native_session_home: None, + planner_default: PlannerSpec::default(), + harness_question_sink: None, + assistant_receipts: Default::default(), + private_notes: Default::default(), + }; + let socket = root.join("managed.sock"); + let server_socket = socket.clone(); + let server = tokio::spawn(async move { + serve_unix(&server_socket, router(state)).await.unwrap(); + }); + while !socket.exists() { + tokio::task::yield_now().await; + } + let mut client = Client::new(Endpoint::Unix(socket)); + let anonymous = generated_client(&client.endpoint, None).unwrap() + .agent_declaration_get(SUBJECT, None, false).await.unwrap_err(); + assert!(matches!(anonymous, + GeneratedClientError::Api(ClientErrorCode::Forbidden, _, _) + ), "anonymous declaration read must be forbidden: {anonymous}"); + + // Declaration GET requires read.declarations, not a live runtime incarnation: + // it is needed before startup can launch or bind the provider. + for person in [Some("person/operator"), None] { + client.person = person.map(str::to_owned); + let mut argv = vec!["/raw/bin/codex".into(), "--model".into(), "example".into()]; + driver_cli::prepare_managed_provider(&client, SUBJECT, "codex", &mut argv) + .await.unwrap(); + assert_eq!(Path::new(&argv[0]), root.join("steward/bin/codex")); + assert_eq!(&argv[1..], ["--model", "example"]); + } + server.abort(); + }).await.expect("managed Unix startup must finish without launching a provider"); + }); +} diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index a15d6f3fd22..1999f15232e 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -112,7 +112,7 @@ the PATH of the terminal running `fractal apply`, nor just launchd's or systemd' service environment. Agent `env` entries overlay that environment. Fractal also prepends its own executable directory so its wrapper can be found. -Daemon-managed `omp`, `claude`, and `codex` launches read the absolute path in +Supported `omp`, `claude`, and `codex` agent-seat driver launches read the absolute path in `AGENT_RUNTIME_PROFILE`, or `$HOME/.local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json` when that variable is unset. Each launch reads the current profile through its @@ -121,8 +121,10 @@ directly. The Agent Spec's `persona` selects the persona; `AGENT_PERSONA` is nex and `generalist` is the last fallback. A declared persona that disagrees with `AGENT_PERSONA` refuses launch. The selected `harnessBin` is prepended to the provider child's PATH, so provider subprocesses also resolve managed wrappers; -the driver's own PATH is unchanged. The profile publisher selects the directory; -Fractal does not duplicate persona-to-path rules. A missing persona entry, +the driver's own PATH is unchanged. The provider child also receives `ST_AGENT` +and `FRACTAL_AGENT` from the fenced driver subject when those variables are absent; +existing identity values remain unchanged. The profile publisher selects the +directory; Fractal does not duplicate persona-to-path rules. A missing persona entry, `harnessBin`, or executable wrapper refuses launch, naming the missing key/path. There is no fallback to a raw provider or to a different persona. Only a confirmed missing Agent Spec permits the environment/default persona @@ -131,17 +133,15 @@ managed launch. Prelaunch contract capture measures the selected wrapper, not a raw executable from login PATH. Driver re-exec uses the managed wrapper for helper commands while adopting, without restarting the running provider. -Wrapper selection follows the authoritative seat declaration, not a launch flag -or an environment-based guess. Flagless invocations and legacy driver re-exec -are managed too. A native exemption requires a validated `bind-terminal` -declaration for a person-owned terminal, an authenticated session belonging to -that person, and the seat's current incarnation matching the binding exactly. -Missing or mismatched proof refuses the exemption. - -For a positively verified person-terminal binding, -`fractal driver claude --subject agent/example -- /native/claude` -retains its native executable and PATH. Merely naming an agent subject from a -person shell does not establish a native exemption. +Supported agent-seat driver invocations use this contract, including flagless +legacy driver re-exec. There is no caller-supplied opt-in flag. Declaration reads +use the driver's authenticated client context, not an anonymous projection client. + +Person-owned `bind-terminal` sessions retain their native harness and environment. +The authenticated declaration read determines this exemption; agent-owned +bindings and declarations without a binding use managed launch. Read failures +refuse rather than silently selecting native launch. Prelaunch contract capture +uses the same binding rule. Before upgrading a host to this launch contract, publish and activate a runtime profile containing `personas.launch..harnessBin` for its managed seats. diff --git a/docs/st3/terminal-binding.md b/docs/st3/terminal-binding.md index 85ffc08e328..2ac6aa36c72 100644 --- a/docs/st3/terminal-binding.md +++ b/docs/st3/terminal-binding.md @@ -17,6 +17,8 @@ The terminal must already be declared on the same host. Publish with the termina This declaration records a harness configuration but never launches it. The caller runs `fractal driver claude --subject agent/example/terminal-claude -- ` as a child of its shell with its chosen permission settings, driver environment, channel and hook registrations. The launcher owns argument merging and unmanaged fallback. Declaring a binding alone does not arrange those registrations. +Person-owned terminal bindings retain native harness execution; they do not select the managed agent-seat wrapper. The driver reads the declaration through its authenticated client before selecting this path. Agent-owned bindings and agents without a binding use the managed launch contract for OMP, Claude, and Codex. + The driver keeps the terminal's `st3.subject` tag. It reads the binding and matches that terminal's tag and exact PTY incarnation in the local registry. Its agent incarnation appends `:bound:` to the PTY incarnation. Mail and driver exit reports use that invocation fence, so a prior harness in the same shell cannot end the next one. Reconciliation adopts the running PTY without a start. Once the driver records an exit for the current invocation, the seat stays exited across daemon restarts while the terminal's shell remains running. A missing or replacement PTY ends the binding without a launch. Stopping a bound seat detaches its graph runtime; it does not stop the harness process or shell. The person can exit the harness from its terminal. Bound seats cannot declare checkouts, rendering, terminals or exec sidecars, rollout, fresh-context recovery or one-shot retirement and cannot be materialized inside mission runs. diff --git a/release-notes/interactive-managed-launch.json b/release-notes/interactive-managed-launch.json index 92658989243..1aa0a66b001 100644 --- a/release-notes/interactive-managed-launch.json +++ b/release-notes/interactive-managed-launch.json @@ -4,7 +4,7 @@ "replay": {"status": "none", "detail": "No graph replay; executable selection happens in the driver before launch."}, "schema": {"status": "none", "detail": "No database schema change."}, "rules": {"status": "none", "detail": "Checkpoint rules and digests are unchanged."}, - "compatibility": {"status": "changed", "detail": "OMP, Claude, and Codex agent-seat launches, including flagless legacy re-exec helpers and prelaunch identity capture, use personas.launch..harnessBin. Native exemption requires an authoritative person-terminal binding, matching authenticated owner, and current binding incarnation. Select the Agent Spec persona, then AGENT_PERSONA, then generalist; only confirmed declaration not-found permits fallback. Spec/environment mismatch refuses launch. Explicit raw executable overrides cannot bypass the wrapper; provider PATH is prefixed, driver PATH unchanged."}, + "compatibility": {"status": "changed", "detail": "OMP, Claude, and Codex agent-seat driver launches, including flagless legacy re-exec helpers, use personas.launch..harnessBin. Person-owned terminal bindings retain native execution; one authenticated declaration read selects this path, while agent-owned or absent bindings remain managed. Prelaunch capture follows the same rule. Select Agent Spec persona, then AGENT_PERSONA, then generalist; only confirmed declaration not-found permits fallback. Other read errors refuse. Provider PATH is prefixed and missing ST_AGENT/FRACTAL_AGENT aliases are filled from fenced driver subject; driver environment and existing identity values are unchanged."}, "downtime": {"status": "none", "detail": "Existing provider processes are unchanged."}, "manual": {"status": "changed", "detail": "Rollout dependency: do not upgrade a host to a Fractal release containing this change until that host has activated a runtime profile with personas.launch..harnessBin for its managed seats. Read an absolute AGENT_RUNTIME_PROFILE when set, otherwise the stable HOME-relative coding-agent-runtime Nix profile; missing profile, persona key, or wrapper refuses new managed launches."}, "recovery": {"status": "none", "detail": "Correct the runtime profile or install the named wrapper before retrying a refused launch."} From e103f3ed95df5cf433abd2b5686e3d26c1ec20af Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:17:45 +0100 Subject: [PATCH 050/132] Refuse managed children with mismatched fenced identity aliases agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../fractal-drivers/src/codex_app_server.rs | 12 ++-- .../src/codex_app_server/protocol.rs | 4 +- crates/fractal-drivers/src/managed_launch.rs | 68 ++++++++++++------- .../fractal-drivers/src/provider_session.rs | 2 +- docs/site/reference/configuration.md | 7 +- release-notes/interactive-managed-launch.json | 2 +- 6 files changed, 58 insertions(+), 37 deletions(-) diff --git a/crates/fractal-drivers/src/codex_app_server.rs b/crates/fractal-drivers/src/codex_app_server.rs index 8b29e4af123..6ceac29ef05 100644 --- a/crates/fractal-drivers/src/codex_app_server.rs +++ b/crates/fractal-drivers/src/codex_app_server.rs @@ -3262,7 +3262,7 @@ fn spawn_controlled_app_server( socket_path: &Path, log: &File, ) -> Result { - let mut command = crate::managed_launch::provider_command(codex); + let mut command = crate::managed_launch::provider_command(codex)?; command .args(args) .stdin(Stdio::null()) @@ -3276,14 +3276,14 @@ fn spawn_controlled_app_server( /// startup screen would hold it before it creates a thread; the update notice is one. const CONTROLLED_TUI_OVERRIDES: [&str; 2] = ["-c", "check_for_update_on_startup=false"]; -fn controlled_tui_command(codex: &str, args: &[String]) -> Command { - let mut command = crate::managed_launch::provider_command(codex); +fn controlled_tui_command(codex: &str, args: &[String]) -> std::io::Result { + let mut command = crate::managed_launch::provider_command(codex)?; command.args(CONTROLLED_TUI_OVERRIDES).args(args); - command + Ok(command) } fn spawn_controlled_tui(codex: &str, args: &[String]) -> std::io::Result { - controlled_tui_command(codex, args) + controlled_tui_command(codex, args)? .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -4416,7 +4416,7 @@ fn preflight_hook_trust( diagnostics: &mut WrapperDiagnostics, ) -> Result> { diagnostics.record("hookTrustPreflightStarting", json!({}))?; - let mut server_command = crate::managed_launch::provider_command(codex); + let mut server_command = crate::managed_launch::provider_command(codex)?; server_command .args(server_args) .stdin(Stdio::null()) diff --git a/crates/fractal-drivers/src/codex_app_server/protocol.rs b/crates/fractal-drivers/src/codex_app_server/protocol.rs index 1617d5b7130..8b408ace2fd 100644 --- a/crates/fractal-drivers/src/codex_app_server/protocol.rs +++ b/crates/fractal-drivers/src/codex_app_server/protocol.rs @@ -31,7 +31,7 @@ pub(super) fn ensure_supported_protocol(codex: &str) -> Result { .prefix("st-codex-protocol-") .tempdir() .context("creating a temporary Codex protocol schema directory")?; - let output = crate::managed_launch::provider_command(codex) + let output = crate::managed_launch::provider_command(codex)? .args([ "app-server", "generate-json-schema", @@ -69,7 +69,7 @@ pub(super) fn ensure_supported_protocol(codex: &str) -> Result { fn codex_version(codex: &str) -> Result { let mut attempt_index = 0; let output = loop { - let attempt = crate::managed_launch::provider_command(codex).arg("--version").output(); + let attempt = crate::managed_launch::provider_command(codex)?.arg("--version").output(); match attempt { Ok(output) => break output, Err(error) if error.raw_os_error() == Some(libc::ETXTBSY) && attempt_index + 1 < 5 => { diff --git a/crates/fractal-drivers/src/managed_launch.rs b/crates/fractal-drivers/src/managed_launch.rs index 6863d97b311..60304180b5d 100644 --- a/crates/fractal-drivers/src/managed_launch.rs +++ b/crates/fractal-drivers/src/managed_launch.rs @@ -130,25 +130,31 @@ fn resolve_for_persona( } /// Apply the managed launch environment only to a provider child. -pub fn apply_provider_environment(command: &mut std::process::Command) { +pub fn apply_provider_environment(command: &mut std::process::Command) -> std::io::Result<()> { if let Some(environment) = PROVIDER_ENVIRONMENT.get() { + apply_agent_identity(command, &environment.subject, &|name| std::env::var_os(name))?; apply_path(command, Some(&environment.path)); - apply_agent_identity(command, &environment.subject, &|name| std::env::var_os(name)); } + Ok(()) } fn apply_agent_identity( command: &mut std::process::Command, subject: &str, inherited: &impl Fn(&str) -> Option, -) { +) -> std::io::Result<()> { for name in ["ST_AGENT", "FRACTAL_AGENT"] { - let present = command.get_envs().find(|(key, _)| *key == std::ffi::OsStr::new(name)) - .map(|(_, value)| value.is_some()).unwrap_or_else(|| inherited(name).is_some()); - if !present { - command.env(name, subject); + let value = command.get_envs().find(|(key, _)| *key == std::ffi::OsStr::new(name)) + .map(|(_, value)| value.map(std::ffi::OsStr::to_owned)) + .unwrap_or_else(|| inherited(name)); + if let Some(value) = value + && value != std::ffi::OsStr::new(subject) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, + format!("managed provider {name}={value:?} does not match fenced subject {subject}; refusing launch"))); } } + command.env("ST_AGENT", subject).env("FRACTAL_AGENT", subject); + Ok(()) } fn apply_path(command: &mut std::process::Command, path: Option<&std::ffi::OsStr>) { @@ -157,10 +163,10 @@ fn apply_path(command: &mut std::process::Command, path: Option<&std::ffi::OsStr } } -pub fn provider_command(program: impl AsRef) -> std::process::Command { +pub fn provider_command(program: impl AsRef) -> std::io::Result { let mut command = std::process::Command::new(program); - apply_provider_environment(&mut command); - command + apply_provider_environment(&mut command)?; + Ok(command) } fn prefixed_path(bin: &Path, inherited: Option<&std::ffi::OsStr>) -> Result { @@ -387,19 +393,33 @@ mod tests { } #[test] - fn provider_identity_fills_missing_aliases_and_preserves_existing_authority() { - let mut missing = std::process::Command::new("omp"); - apply_agent_identity(&mut missing, "agent/worker", &|_| None); - let values = missing.get_envs().collect::>(); - assert_eq!(values[std::ffi::OsStr::new("ST_AGENT")], Some(std::ffi::OsStr::new("agent/worker"))); - assert_eq!(values[std::ffi::OsStr::new("FRACTAL_AGENT")], Some(std::ffi::OsStr::new("agent/worker"))); - let mut existing = std::process::Command::new("omp"); - existing.env("ST_AGENT", "agent/explicit"); - apply_agent_identity(&mut existing, "agent/worker", &|name| { - (name == "FRACTAL_AGENT").then(|| "agent/inherited".into()) - }); - let values = existing.get_envs().collect::>(); - assert_eq!(values[std::ffi::OsStr::new("ST_AGENT")], Some(std::ffi::OsStr::new("agent/explicit"))); - assert!(!values.contains_key(std::ffi::OsStr::new("FRACTAL_AGENT"))); + fn provider_identity_refuses_mismatches_and_sets_matching_aliases() { + let subject = "agent/worker"; + for (st, fractal) in [ + (Some("agent/wrong"), None), + (None, Some("agent/wrong")), + (Some("agent/wrong"), Some("agent/wrong")), + (Some(subject), Some("agent/other")), + (Some(""), None), + ] { + let mut command = std::process::Command::new("omp"); + let error = apply_agent_identity(&mut command, subject, &|name| { + match name { "ST_AGENT" => st, "FRACTAL_AGENT" => fractal, _ => None } + .map(std::ffi::OsString::from) + }).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("does not match fenced subject")); + } + for inherited in [None, Some(subject)] { + let mut command = std::process::Command::new("omp"); + apply_agent_identity(&mut command, subject, &|_| inherited.map(Into::into)).unwrap(); + let values = command.get_envs().collect::>(); + for name in ["ST_AGENT", "FRACTAL_AGENT"] { + assert_eq!(values[std::ffi::OsStr::new(name)], Some(std::ffi::OsStr::new(subject))); + } + } + let mut overridden = std::process::Command::new("omp"); + overridden.env("ST_AGENT", "agent/wrong"); + assert!(apply_agent_identity(&mut overridden, subject, &|_| Some(subject.into())).is_err()); } } diff --git a/crates/fractal-drivers/src/provider_session.rs b/crates/fractal-drivers/src/provider_session.rs index 296d0fb62d2..75f3b46460e 100644 --- a/crates/fractal-drivers/src/provider_session.rs +++ b/crates/fractal-drivers/src/provider_session.rs @@ -590,7 +590,7 @@ pub(crate) fn run_provider_observed_with_env_removals( .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); apply_provider_environment(&mut command, env, removed_env); - crate::managed_launch::apply_provider_environment(&mut command); + crate::managed_launch::apply_provider_environment(&mut command)?; unsafe { command.pre_exec(|| { libc::signal(libc::SIGINT, libc::SIG_DFL); diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index 1999f15232e..adae3c14077 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -121,9 +121,10 @@ directly. The Agent Spec's `persona` selects the persona; `AGENT_PERSONA` is nex and `generalist` is the last fallback. A declared persona that disagrees with `AGENT_PERSONA` refuses launch. The selected `harnessBin` is prepended to the provider child's PATH, so provider subprocesses also resolve managed wrappers; -the driver's own PATH is unchanged. The provider child also receives `ST_AGENT` -and `FRACTAL_AGENT` from the fenced driver subject when those variables are absent; -existing identity values remain unchanged. The profile publisher selects the +the driver's own PATH is unchanged. Before spawning a managed provider child, +any present `ST_AGENT` or `FRACTAL_AGENT` must equal the fenced driver subject; +empty or mismatched values refuse launch. Both child aliases are then set to +that subject. The profile publisher selects the directory; Fractal does not duplicate persona-to-path rules. A missing persona entry, `harnessBin`, or executable wrapper refuses launch, naming the missing key/path. There is no fallback to a raw provider or to a different persona. diff --git a/release-notes/interactive-managed-launch.json b/release-notes/interactive-managed-launch.json index 1aa0a66b001..3ca06bdc707 100644 --- a/release-notes/interactive-managed-launch.json +++ b/release-notes/interactive-managed-launch.json @@ -4,7 +4,7 @@ "replay": {"status": "none", "detail": "No graph replay; executable selection happens in the driver before launch."}, "schema": {"status": "none", "detail": "No database schema change."}, "rules": {"status": "none", "detail": "Checkpoint rules and digests are unchanged."}, - "compatibility": {"status": "changed", "detail": "OMP, Claude, and Codex agent-seat driver launches, including flagless legacy re-exec helpers, use personas.launch..harnessBin. Person-owned terminal bindings retain native execution; one authenticated declaration read selects this path, while agent-owned or absent bindings remain managed. Prelaunch capture follows the same rule. Select Agent Spec persona, then AGENT_PERSONA, then generalist; only confirmed declaration not-found permits fallback. Other read errors refuse. Provider PATH is prefixed and missing ST_AGENT/FRACTAL_AGENT aliases are filled from fenced driver subject; driver environment and existing identity values are unchanged."}, + "compatibility": {"status": "changed", "detail": "OMP, Claude, and Codex agent-seat driver launches, including flagless legacy re-exec helpers, use personas.launch..harnessBin. Person-owned terminal bindings retain native execution; one authenticated declaration read selects this path, while agent-owned or absent bindings remain managed. Prelaunch capture follows the same rule. Select Agent Spec persona, then AGENT_PERSONA, then generalist; only confirmed declaration not-found permits fallback. Other read errors refuse. Provider PATH is prefixed. Before spawn, any present ST_AGENT/FRACTAL_AGENT must equal the fenced subject (including rejecting empty values); both child aliases are then set to that subject. Driver environment and person-owned native sessions are unchanged."}, "downtime": {"status": "none", "detail": "Existing provider processes are unchanged."}, "manual": {"status": "changed", "detail": "Rollout dependency: do not upgrade a host to a Fractal release containing this change until that host has activated a runtime profile with personas.launch..harnessBin for its managed seats. Read an absolute AGENT_RUNTIME_PROFILE when set, otherwise the stable HOME-relative coding-agent-runtime Nix profile; missing profile, persona key, or wrapper refuses new managed launches."}, "recovery": {"status": "none", "detail": "Correct the runtime profile or install the named wrapper before retrying a refused launch."} From efa83a092cf940f0427afda3ca9165bc13bdafec Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:44:22 +0100 Subject: [PATCH 051/132] Unwrap fallible managed command construction in Codex tests agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-drivers/src/codex_app_server/tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/fractal-drivers/src/codex_app_server/tests.rs b/crates/fractal-drivers/src/codex_app_server/tests.rs index f0a1c2dce8e..3d165674af5 100644 --- a/crates/fractal-drivers/src/codex_app_server/tests.rs +++ b/crates/fractal-drivers/src/codex_app_server/tests.rs @@ -6904,7 +6904,7 @@ fn a_controlled_tui_starts_without_the_update_notice() { "resume".to_string(), "thread-prior".to_string(), ]; - let command = controlled_tui_command("codex", &args); + let command = controlled_tui_command("codex", &args).unwrap(); assert_eq!(command.get_program(), "codex"); assert_eq!( command.get_args().collect::>(), @@ -6927,7 +6927,7 @@ fn a_controlled_tui_starts_without_the_update_notice() { "gpt-test".into(), ], ] { - let command = controlled_tui_command("codex", &args); + let command = controlled_tui_command("codex", &args).unwrap(); assert_eq!( command.get_args().take(2).collect::>(), ["-c", "check_for_update_on_startup=false"] @@ -9356,6 +9356,7 @@ fn managed_launch_argv_carries_access_mode_flags() { prepare_controlled_launch_args("unix:///control.sock", &managed[1..], resume).unwrap(); let server = prepared.server_args; let tui: Vec = controlled_tui_command("codex", &prepared.tui_args) + .unwrap() .get_args() .map(|arg| arg.to_str().unwrap().to_owned()) .collect(); From d0cb48448e4df3d7b08be6857fa1a04284049f4d Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:44:44 +0100 Subject: [PATCH 052/132] Exercise managed fixture profiles and preserve startup through daemon outages agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-drivers/src/managed_launch.rs | 33 +++--------- crates/fractal-reconcile/src/contract.rs | 8 ++- crates/fractal/src/driver_cli.rs | 22 +++++--- crates/fractal/src/main.rs | 2 +- .../fractal/src/native_seed_driver_tests.rs | 6 +-- crates/fractal/tests/authored_resume.rs | 11 +++- crates/fractal/tests/codex_bootstrap.rs | 13 ++++- crates/fractal/tests/daemon_restart.rs | 53 ++++++++++++++++--- .../tests/daemon_restart/stop_escalation.rs | 8 ++- crates/fractal/tests/delivery_probe.rs | 2 +- crates/fractal/tests/idle_budget.rs | 21 +++++++- crates/fractal/tests/no_st2_seat.rs | 2 + crates/fractal/tests/subagents_seat.rs | 2 + docs/site/reference/configuration.md | 14 ++--- release-notes/interactive-managed-launch.json | 2 +- scripts/fractal-delivery-probe-test | 7 +++ scripts/fractal-no-st2-eval/run | 9 +++- scripts/fractal-subagents-eval/run | 9 +++- 18 files changed, 160 insertions(+), 64 deletions(-) diff --git a/crates/fractal-drivers/src/managed_launch.rs b/crates/fractal-drivers/src/managed_launch.rs index 60304180b5d..cc2bb53a4b5 100644 --- a/crates/fractal-drivers/src/managed_launch.rs +++ b/crates/fractal-drivers/src/managed_launch.rs @@ -71,7 +71,7 @@ fn resolve_from_source( let environment_persona = env("AGENT_PERSONA") .map(|value| value.into_string().map_err(|_| anyhow::anyhow!("AGENT_PERSONA is not UTF-8"))) .transpose()?; - let persona = selected_persona(spec, environment_persona.as_deref())?; + let persona = environment_persona.as_deref().unwrap_or("generalist"); resolve_for_persona(harness, argv, &profile, persona)?; let bin = Path::new(&argv[0]).parent().context("managed wrapper has no parent")?; prefixed_path(bin, env("PATH").as_deref()) @@ -177,24 +177,6 @@ fn prefixed_path(bin: &Path, inherited: Option<&std::ffi::OsStr>) -> Result(spec: &'a serde_json::Value, environment: Option<&'a str>) -> Result<&'a str> { - let mut nodes = spec.get("children").and_then(serde_json::Value::as_array) - .into_iter().flatten().filter(|node| node["name"] == "persona"); - let declared = if let Some(node) = nodes.next() { - ensure!(nodes.next().is_none(), "Agent Spec declares multiple personas"); - let arguments = node["arguments"].as_array().context("Agent Spec persona needs one string")?; - ensure!(arguments.len() == 1, "Agent Spec persona needs one string"); - Some(arguments[0].as_str().filter(|value| !value.is_empty()) - .context("Agent Spec persona needs one nonempty string")?) - } else { - None - }; - if let (Some(declared), Some(environment)) = (declared, environment) { - ensure!(declared == environment, - "Agent Spec persona {declared} disagrees with AGENT_PERSONA {environment}"); - } - Ok(declared.or(environment).unwrap_or("generalist")) -} #[cfg(test)] mod tests { @@ -282,7 +264,7 @@ mod tests { } #[test] - fn declared_steward_selects_its_wrapper_without_environment_persona() { + fn environment_steward_selects_its_wrapper_without_a_spec_persona() { let (_root, bin, profile) = fixture(); std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ "personas": {"launch": {"steward": {"harnessBin": bin}}}, @@ -294,14 +276,13 @@ mod tests { use std::os::unix::fs::PermissionsExt as _; std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); } - let spec = serde_json::json!({"children": [{"name": "persona", "arguments": ["steward"]}]}); - let persona = selected_persona(&spec, None).unwrap(); - assert_eq!(persona, "steward"); + let environment = std::collections::BTreeMap::from([ + ("AGENT_RUNTIME_PROFILE".into(), profile.display().to_string()), + ("AGENT_PERSONA".into(), "steward".into()), + ]); let mut argv = ["omp".into()]; - resolve_for_persona("omp", &mut argv, &profile, persona).unwrap(); + resolve_with_environment("omp", &mut argv, &serde_json::json!({}), &environment).unwrap(); assert_eq!(Path::new(&argv[0]), wrapper); - assert!(selected_persona(&spec, Some("generalist")).is_err()); - assert_eq!(selected_persona(&serde_json::json!({}), None).unwrap(), "generalist"); } #[test] diff --git a/crates/fractal-reconcile/src/contract.rs b/crates/fractal-reconcile/src/contract.rs index d1eec59f13a..632a4423d44 100644 --- a/crates/fractal-reconcile/src/contract.rs +++ b/crates/fractal-reconcile/src/contract.rs @@ -239,19 +239,17 @@ mod tests { } #[test] - fn managed_contract_uses_raw_spec_persona_and_refuses_mismatch() { + fn managed_contract_uses_environment_persona_and_refuses_unknown_persona() { let (root, mut desired, bin) = managed_fixture("omp", false); let store = Store::open_memory("node").unwrap(); std::fs::write(root.path().join("profile.json"), serde_json::to_vec(&serde_json::json!({ "personas": {"launch": {"steward": {"harnessBin": bin}}}, })).unwrap()).unwrap(); - desired.desired["children"].as_array_mut().unwrap().push(serde_json::json!({ - "name": "persona", "arguments": ["steward"], "properties": {}, "children": [], - })); + desired.member.as_mut().unwrap().environment.insert("AGENT_PERSONA".into(), "steward".into()); isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); desired.member.as_mut().unwrap().environment.insert("AGENT_PERSONA".into(), "generalist".into()); assert!(format!("{:#}", isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap_err()) - .contains("disagrees with AGENT_PERSONA")); + .contains("personas.launch.generalist.harnessBin")); } #[test] diff --git a/crates/fractal/src/driver_cli.rs b/crates/fractal/src/driver_cli.rs index 6cd60a56e5f..8854f9d6691 100644 --- a/crates/fractal/src/driver_cli.rs +++ b/crates/fractal/src/driver_cli.rs @@ -18,9 +18,11 @@ pub(super) async fn prepare_managed_provider( ) -> Result<()> { let actor = client.person.as_deref().unwrap_or(subject); let gateway = generated_client(&client.endpoint, Some(actor))?; - let declaration = managed_declaration_result( - gateway.agent_declaration_get(subject, None, false).await.map(|reply| reply.value.tree), - )?; + let declaration = retry_while_daemon_unreachable(subject, || async { + managed_declaration_result( + gateway.agent_declaration_get(subject, None, false).await.map(|reply| reply.value.tree), + ) + }).await?; fractal_drivers::managed_launch::resolve(harness, argv, &declaration, subject) } @@ -45,7 +47,7 @@ mod managed_declaration_tests { #[test] fn confirmed_not_found_allows_persona_fallback() { assert_eq!(managed_declaration_result(Err(api_error(ClientErrorCode::NotFound))).unwrap(), Value::Null); - let spec = json!({"children": [{"name": "persona", "arguments": ["steward"]}]}); + let spec = json!({"children": [{"name": "name", "arguments": ["Managed seat"]}]}); assert_eq!(managed_declaration_result(Ok(spec.clone())).unwrap(), spec); } @@ -240,13 +242,21 @@ pub(super) async fn run_driver(client: &Client, args: DriverArgs, catalog: Optio if let Some(drivers) = std::env::var_os("ST3_DRIVER_STATE_DIR") { fractal_drivers::run::use_harness_state_root(PathBuf::from(drivers).join("sessions")); } + let resume = fractal_drivers::reexec::resume_path(fractal_drivers::reexec::DRIVER_RESUME_ENV); + // Fresh drivers keep the existing runtime wait across daemon restarts. Adopting + // drivers cannot wait on a new incarnation, but their declaration read retries outages. + let incarnation = if resume.is_none() { + Some(wait_for_agent_incarnation(client, subject).await?) + } else { + None + }; if matches!(args.driver.as_str(), "omp" | "claude" | "codex") { prepare_managed_provider(client, subject, &args.driver, &mut argv).await?; } - if let Some(state) = fractal_drivers::reexec::resume_path(fractal_drivers::reexec::DRIVER_RESUME_ENV) { + if let Some(state) = resume { return resume_native_driver(client, subject, &args.driver, argv, &state).await; } - let incarnation = wait_for_agent_incarnation(client, subject).await?; + let incarnation = incarnation.context("fresh driver has no runtime incarnation")?; let paths = NativePaths::prepare(subject, &args.driver)?; let sessions = paths.session_dir.join("provider-sessions"); if let Some(session) = fractal_daemon::native_seed::first_launch( diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 97142994277..9d649fa83f0 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -18357,7 +18357,7 @@ fn tolerate_driver_api_outage( last_warning: &mut Option, ) -> Result<()> { let outage = fractal_daemon::client::daemon_unreachable(&error).map(|outage| outage.summary()); - let transient = outage.is_some() + let transient = daemon_is_unreachable(&error) || error.chain().any(|cause| { let message = cause.to_string(); message.contains("incomplete HTTP response") diff --git a/crates/fractal/src/native_seed_driver_tests.rs b/crates/fractal/src/native_seed_driver_tests.rs index 76b248275ba..5d042b1eb0d 100644 --- a/crates/fractal/src/native_seed_driver_tests.rs +++ b/crates/fractal/src/native_seed_driver_tests.rs @@ -88,7 +88,7 @@ fn managed_startup_reads_its_declaration_over_authenticated_unix() { std::fs::write(&wrapper, "#!/bin/sh\nexit 99\n").unwrap(); std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); let profile = root.path().join("profile.json"); - // Deliberately omit generalist: treating the declaration as missing cannot pass. + // Deliberately omit generalist: the environment must select the steward wrapper. std::fs::write(&profile, serde_json::to_vec(&json!({ "personas": {"launch": {"steward": {"harnessBin": bin}}}, })).unwrap()).unwrap(); @@ -100,7 +100,7 @@ fn managed_startup_reads_its_declaration_over_authenticated_unix() { ]) .env(CHILD, root.path()) .env("AGENT_RUNTIME_PROFILE", &profile) - .env_remove("AGENT_PERSONA") + .env("AGENT_PERSONA", "steward") .env("HOME", root.path()) .output().unwrap(); assert!(output.status.success(), "{}{}", @@ -123,7 +123,7 @@ fn managed_startup_reads_its_declaration_over_authenticated_unix() { store.set_member_key(Some(key.clone())).unwrap(); store.admit_fleet_anchor(FLEET, key.public(), "listening").unwrap(); let source = format!( - "version 2\nagent \"example/managed\" {{ accountable \"person/operator\"; workspace {:?}; harness \"codex\" {{}}; persona \"steward\"; }}", + "version 2\nagent \"example/managed\" {{ accountable \"person/operator\"; workspace {:?}; harness \"codex\" {{}}; }}", root, ); let intent = fractal_daemon::graph::parse_intent(&source, NODE).unwrap(); diff --git a/crates/fractal/tests/authored_resume.rs b/crates/fractal/tests/authored_resume.rs index ea22c7a21a1..908bd69acdd 100644 --- a/crates/fractal/tests/authored_resume.rs +++ b/crates/fractal/tests/authored_resume.rs @@ -34,7 +34,13 @@ async fn authored_omp_resume_publishes_binding_from_an_empty_managed_directory() .join(&hex::encode(Sha256::digest(subject.as_bytes()))[..24]) .join("sessions/omp/provider-sessions"); std::fs::create_dir_all(&managed).unwrap(); - let provider = root.path().join("omp"); + let managed_bin = root.path().join("managed-bin"); + std::fs::create_dir(&managed_bin).unwrap(); + let profile = root.path().join("runtime-profile.json"); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"generalist": {"harnessBin": managed_bin}}}, + })).unwrap()).unwrap(); + let provider = managed_bin.join("omp"); let fixture_node = std::env::split_paths(&std::ffi::OsString::from(env!("ST3_FIXTURE_PATH"))) .map(|directory| directory.join("node")) .find(|candidate| candidate.is_file()) @@ -152,6 +158,9 @@ agent "garden/worker" {{ .env("RESUME_TEST_STOP", &stop) .env("ST3_DRIVER_STATE_DIR", &drivers) .env("ST_AGENT", subject) + .env("FRACTAL_AGENT", subject) + .env("AGENT_PERSONA", "generalist") + .env("AGENT_RUNTIME_PROFILE", &profile) .env("ST3_ENDPOINT", &socket) .env("ST3_MAILBOX_TRANSPORT", "push") .env("ST_HOOKS", hooks) diff --git a/crates/fractal/tests/codex_bootstrap.rs b/crates/fractal/tests/codex_bootstrap.rs index 378e7913039..bb5c8465f14 100644 --- a/crates/fractal/tests/codex_bootstrap.rs +++ b/crates/fractal/tests/codex_bootstrap.rs @@ -340,7 +340,13 @@ async fn delivery_recovery_control( .unwrap(); }); until(|| socket.exists(), "the isolated API did not start").await; - let provider = root.join("provider"); + let managed_bin = root.join("managed-bin"); + std::fs::create_dir(&managed_bin).unwrap(); + let profile = root.join("runtime-profile.json"); + std::fs::write(&profile, serde_json::to_vec(&json!({ + "personas": {"launch": {"generalist": {"harnessBin": managed_bin}}}, + })).unwrap()).unwrap(); + let provider = managed_bin.join("codex"); let stub = PathBuf::from(test_env!("CARGO_MANIFEST_DIR")) .join("../../scripts/fractal-boot-canaries/stub-codex.py"); std::fs::write( @@ -370,6 +376,9 @@ async fn delivery_recovery_control( ("HOME", root.to_string_lossy().into_owned()), ("PATH", path.to_string_lossy().into_owned()), ("ST_AGENT", SUBJECT.to_owned()), + ("FRACTAL_AGENT", SUBJECT.to_owned()), + ("AGENT_PERSONA", "generalist".to_owned()), + ("AGENT_RUNTIME_PROFILE", profile.to_string_lossy().into_owned()), ( "ST3_SUBJECT", if bound_shell { TERMINAL } else { SUBJECT }.to_owned(), @@ -408,7 +417,7 @@ async fn delivery_recovery_control( } if bound_shell { command.args(["--", bash.to_str().unwrap(), "--noprofile", "--norc", "-c"]) - .arg(format!("while [ ! -e \"$HOME/launch-bound-codex\" ]; do sleep 0.02; done; \"$ST3_BIN\" driver codex --subject \"$ST_AGENT\" -- \"$HOME/provider\"; {BOUND_EXIT_PUBLICATION}; while :; do sleep 1; done")); + .arg(format!("while [ ! -e \"$HOME/launch-bound-codex\" ]; do sleep 0.02; done; \"$ST3_BIN\" driver codex --subject \"$ST_AGENT\" -- \"$HOME/managed-bin/codex\"; {BOUND_EXIT_PUBLICATION}; while :; do sleep 1; done")); } else { command.arg("--").arg(binary).args(["driver", "codex", "--subject", SUBJECT, "--"]).arg(provider); } diff --git a/crates/fractal/tests/daemon_restart.rs b/crates/fractal/tests/daemon_restart.rs index 480f75b775b..e8a3ed1f124 100644 --- a/crates/fractal/tests/daemon_restart.rs +++ b/crates/fractal/tests/daemon_restart.rs @@ -288,6 +288,47 @@ fn seat_command(root: &Path, socket: &Path) -> Command { command } +/// Keep provider supervision real while selecting the fake provider through the managed +/// runtime contract, rather than relying on the raw executable after `--`. +fn managed_seat_command( + root: &Path, + socket: &Path, + subject: &str, + harness: &str, + provider: &str, +) -> Command { + use std::os::unix::fs::PermissionsExt as _; + + let bin = root.join("managed-runtime/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let wrapper = bin.join(harness); + std::fs::write( + &wrapper, + format!( + "#!{}\nexec \"$FRACTAL_TEST_PROVIDER\" \"$@\"\n", + env!("ST3_FIXTURE_BASH"), + ), + ) + .unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o700)).unwrap(); + let profile = root.join("managed-runtime/profile.json"); + std::fs::write( + &profile, + serde_json::to_vec(&json!({ + "personas": {"launch": {"generalist": {"harnessBin": bin}}}, + })) + .unwrap(), + ) + .unwrap(); + let mut command = seat_command(root, socket); + command + .env("AGENT_RUNTIME_PROFILE", profile) + .env("ST_AGENT", subject) + .env("FRACTAL_AGENT", subject) + .env("FRACTAL_TEST_PROVIDER", provider); + command +} + fn driver_log(root: &Path) -> String { std::fs::read_to_string(root.join("state/st3/driver-api-warnings.log")).unwrap_or_default() } @@ -393,7 +434,7 @@ impl Drop for TestSeat { /// test's process group and temporary home; TestSeat also cleans up on a failed assertion. async fn native_exit_driver(root: &Path, daemon: &mut Daemon) -> TestSeat { let seat = "agent/grove/exit-orchid"; - let mut command = seat_command(root, &daemon.socket); + let mut command = managed_seat_command(root, &daemon.socket, seat, "claude", "python3"); declare_claude(daemon, seat); daemon.observe_running(seat, "exit-orchid:one"); daemon.start_admitted_protocol().await; @@ -460,7 +501,7 @@ async fn claude_channel_policy_banner_keeps_driver_alive_and_channel_unattached( daemon.start_admitted_protocol().await; let provider = "printf '%s\\n' '--dangerously-load-development-channels blocked by org policy (plugin:st-channel@st)' 'Inbound messages will be silently dropped' 'Have an administrator set channelsEnabled: true in managed settings to enable'; exec sleep 300"; let mut driver = TestSeat(Some( - seat_command(root, &daemon.socket) + managed_seat_command(root, &daemon.socket, seat, "claude", "sh") .env("ST_AGENT", seat) .env("ST3_MAILBOX_TRANSPORT", "push") .env("ST3_CLAUDE_ATTACH_WINDOW_MS", "50") @@ -709,7 +750,7 @@ async fn a_quiet_idle_seat_ages_stale_and_recovers_new_evidence_after_daemon_res let seat = "agent/grove/quiet-cedar"; let incarnation = "quiet-cedar:one"; let mut daemon = Daemon::new(root); - let mut command = seat_command(root, &daemon.socket); + let mut command = managed_seat_command(root, &daemon.socket, seat, "claude", "python3"); declare_claude(&daemon, seat); daemon.observe_running(seat, incarnation); daemon.start_admitted_protocol().await; @@ -885,7 +926,7 @@ async fn title_failure(pty_session: Option<&str>, expected_session: &str) { let root = root.path(); let seat = "agent/grove/title-cedar"; let mut daemon = Daemon::new(root); - let mut command = seat_command(root, &daemon.socket); + let mut command = managed_seat_command(root, &daemon.socket, seat, "claude", "sleep"); declare_claude(&daemon, seat); daemon.observe_running(seat, "title-cedar:one"); daemon.start_admitted_protocol().await; @@ -1034,7 +1075,7 @@ async fn a_claude_seat_starts_through_a_daemon_restart_and_then_keeps_its_mail() daemon.start().await; daemon.stop().await; - let mut driver = seat_command(root, &daemon.socket) + let mut driver = managed_seat_command(root, &daemon.socket, seat, "claude", "sleep") .args(["driver", "claude", "--subject", seat, "--", "sleep", "300"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -2578,7 +2619,7 @@ channel.stdin.flush() time.sleep(300) "#; let mut driver = TestSeat(Some( - seat_command(root, &daemon.socket) + managed_seat_command(root, &daemon.socket, seat, "claude", "python3") .env("ST_AGENT", seat) .env("ST3_MAILBOX_TRANSPORT", "push") .env("ST3_CLAUDE_READINESS_GRACE_MS", HOOKLESS_GRACE_MS.to_string()) diff --git a/crates/fractal/tests/daemon_restart/stop_escalation.rs b/crates/fractal/tests/daemon_restart/stop_escalation.rs index 3300ea85d75..bb14e083e30 100644 --- a/crates/fractal/tests/daemon_restart/stop_escalation.rs +++ b/crates/fractal/tests/daemon_restart/stop_escalation.rs @@ -6,7 +6,7 @@ //! Linux uses pidfds and macOS uses kqueue NOTE_EXIT; both watch exact owned processes. #![cfg(any(target_os = "linux", target_os = "macos"))] -use super::{Daemon, TestSeat, seat_command}; +use super::{Daemon, TestSeat, managed_seat_command, seat_command}; use sha2::{Digest as _, Sha256}; use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}; use std::os::unix::process::ExitStatusExt as _; @@ -167,7 +167,11 @@ async fn spawn_driver( ) -> (TestSeat, OwnedFd, OwnedFd, PathBuf) { let ready_path = root.join("ready.sock"); let ready = tokio::net::UnixDatagram::bind(&ready_path).unwrap(); - let mut command = seat_command(root, &daemon.socket); + let mut command = if driver == "claude" { + managed_seat_command(root, &daemon.socket, SUBJECT, driver, "python3") + } else { + seat_command(root, &daemon.socket) + }; declare(daemon, driver); daemon.observe_running(SUBJECT, "stop-orchid:one"); daemon.start_admitted_protocol().await; diff --git a/crates/fractal/tests/delivery_probe.rs b/crates/fractal/tests/delivery_probe.rs index f1c006fdc33..57d6decfd21 100644 --- a/crates/fractal/tests/delivery_probe.rs +++ b/crates/fractal/tests/delivery_probe.rs @@ -27,7 +27,7 @@ fn probe_with_temp(selectors: &[&str], temp: Option<&std::path::Path>) { command.env("TMPDIR", temp); } let result = command - .args(["-f", "env", "-u", "ST_AGENT", "python3"]) + .args(["-f", "env", "-u", "ST_AGENT", "-u", "FRACTAL_AGENT", "-u", "ST3_SUBJECT", "python3"]) .arg(repo.join("scripts/fractal-delivery-probe-test")) .arg("--binary") .arg(test_env!("CARGO_BIN_EXE_fractal-fixture")) diff --git a/crates/fractal/tests/idle_budget.rs b/crates/fractal/tests/idle_budget.rs index 8ad9e52f7b4..3002bf406b7 100644 --- a/crates/fractal/tests/idle_budget.rs +++ b/crates/fractal/tests/idle_budget.rs @@ -207,13 +207,25 @@ mission "idle/wait-{index}" state="ready" {{ } } -/// A seat driver with the environment the reconciler gives it, its directories in `root`. +/// A seat driver with a private managed profile and idle provider, its directories in `root`. fn seat_driver(root: &Path, socket: &Path, index: usize) -> Child { let root = root.join(format!("seat-{index}")); for directory in ["workspace", "home", "state", "config", "runtime", "drivers"] { std::fs::create_dir_all(root.join(directory)).unwrap(); } let (runtime_id, _) = seat(index); + let managed_bin = root.join("managed-bin"); + std::fs::create_dir(&managed_bin).unwrap(); + let provider = managed_bin.join("claude"); + std::fs::write(&provider, format!("#!{}\nexec sleep 600\n", env!("ST3_FIXTURE_BASH"))).unwrap(); + std::fs::set_permissions(&provider, { + use std::os::unix::fs::PermissionsExt as _; + std::fs::Permissions::from_mode(0o700) + }).unwrap(); + let profile = root.join("runtime-profile.json"); + std::fs::write(&profile, serde_json::to_vec(&json!({ + "personas": {"launch": {"generalist": {"harnessBin": managed_bin}}}, + })).unwrap()).unwrap(); fractal_daemon::test_support::command(test_bin!("fractal-fixture")) // Its own process group, so stopping the seat also stops the stand-in provider. .process_group(0) @@ -221,6 +233,10 @@ fn seat_driver(root: &Path, socket: &Path, index: usize) -> Child { .env_clear() .env("PATH", std::env::var_os("PATH").unwrap_or_default()) .env("HOME", root.join("home")) + .env("AGENT_RUNTIME_PROFILE", &profile) + .env("AGENT_PERSONA", "generalist") + .env("ST_AGENT", format!("agent/{runtime_id}")) + .env("FRACTAL_AGENT", format!("agent/{runtime_id}")) .env("XDG_STATE_HOME", root.join("state")) .env("XDG_CONFIG_HOME", root.join("config")) .env("XDG_RUNTIME_DIR", root.join("runtime")) @@ -230,7 +246,8 @@ fn seat_driver(root: &Path, socket: &Path, index: usize) -> Child { .arg(socket) .args(["driver", "claude", "--subject"]) .arg(format!("agent/{runtime_id}")) - .args(["--", "sleep", "600"]) + .arg("--") + .arg(provider) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) diff --git a/crates/fractal/tests/no_st2_seat.rs b/crates/fractal/tests/no_st2_seat.rs index 262c4c76a3d..8bfeb1254e4 100644 --- a/crates/fractal/tests/no_st2_seat.rs +++ b/crates/fractal/tests/no_st2_seat.rs @@ -34,6 +34,8 @@ fn a_claude_seat_runs_with_no_st2_on_the_machine() { "-u", "ST_AGENT", "-u", + "FRACTAL_AGENT", + "-u", "ST3_SUBJECT", "python3", ]) diff --git a/crates/fractal/tests/subagents_seat.rs b/crates/fractal/tests/subagents_seat.rs index 7bc3c6b2b21..85afc7977e7 100644 --- a/crates/fractal/tests/subagents_seat.rs +++ b/crates/fractal/tests/subagents_seat.rs @@ -33,6 +33,8 @@ fn a_seats_subagents_appear_renew_and_end() { "-u", "ST_AGENT", "-u", + "FRACTAL_AGENT", + "-u", "ST3_SUBJECT", "python3", ]) diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index adae3c14077..cac8b3be5e8 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -117,9 +117,9 @@ Supported `omp`, `claude`, and `codex` agent-seat driver launches read the absol `$HOME/.local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json` when that variable is unset. Each launch reads the current profile through its stable link and executes `.harnessBin>/` -directly. The Agent Spec's `persona` selects the persona; `AGENT_PERSONA` is next, -and `generalist` is the last fallback. A declared persona that disagrees with -`AGENT_PERSONA` refuses launch. The selected `harnessBin` is prepended to the +directly. `AGENT_PERSONA`, when set, selects the persona; otherwise `generalist` +is the internal lookup default. Fractal does not export that default as +`AGENT_PERSONA`. The selected `harnessBin` is prepended to the provider child's PATH, so provider subprocesses also resolve managed wrappers; the driver's own PATH is unchanged. Before spawning a managed provider child, any present `ST_AGENT` or `FRACTAL_AGENT` must equal the fenced driver subject; @@ -128,9 +128,11 @@ that subject. The profile publisher selects the directory; Fractal does not duplicate persona-to-path rules. A missing persona entry, `harnessBin`, or executable wrapper refuses launch, naming the missing key/path. There is no fallback to a raw provider or to a different persona. -Only a confirmed missing Agent Spec permits the environment/default persona -fallback; authorization, transport, malformed-response, and server errors refuse -managed launch. Prelaunch contract capture measures the selected wrapper, not a +Only a confirmed missing Agent Spec permits treating the declaration as absent. +Authorization, malformed-response, and server errors refuse managed launch. +During a classified daemon outage, startup waits without spawning a provider +and retries the authenticated read; it never falls back to native execution. +Prelaunch contract capture measures the selected wrapper, not a raw executable from login PATH. Driver re-exec uses the managed wrapper for helper commands while adopting, without restarting the running provider. diff --git a/release-notes/interactive-managed-launch.json b/release-notes/interactive-managed-launch.json index 3ca06bdc707..0f6a3a3abd9 100644 --- a/release-notes/interactive-managed-launch.json +++ b/release-notes/interactive-managed-launch.json @@ -4,7 +4,7 @@ "replay": {"status": "none", "detail": "No graph replay; executable selection happens in the driver before launch."}, "schema": {"status": "none", "detail": "No database schema change."}, "rules": {"status": "none", "detail": "Checkpoint rules and digests are unchanged."}, - "compatibility": {"status": "changed", "detail": "OMP, Claude, and Codex agent-seat driver launches, including flagless legacy re-exec helpers, use personas.launch..harnessBin. Person-owned terminal bindings retain native execution; one authenticated declaration read selects this path, while agent-owned or absent bindings remain managed. Prelaunch capture follows the same rule. Select Agent Spec persona, then AGENT_PERSONA, then generalist; only confirmed declaration not-found permits fallback. Other read errors refuse. Provider PATH is prefixed. Before spawn, any present ST_AGENT/FRACTAL_AGENT must equal the fenced subject (including rejecting empty values); both child aliases are then set to that subject. Driver environment and person-owned native sessions are unchanged."}, + "compatibility": {"status": "changed", "detail": "OMP, Claude, and Codex agent-seat driver launches, including flagless legacy re-exec helpers, use personas.launch..harnessBin. Person-owned terminal bindings retain native execution; one authenticated declaration read selects this path, while agent-owned or absent bindings remain managed. Prelaunch capture follows the same rule. Select AGENT_PERSONA when set, otherwise generalist as an internal default without exporting AGENT_PERSONA. Only confirmed declaration not-found permits an absent declaration; classified daemon outages wait without spawning, while other read errors refuse. Provider PATH is prefixed. Before spawn, any present ST_AGENT/FRACTAL_AGENT must equal the fenced subject (including rejecting empty values); both child aliases are then set to that subject. Driver environment and person-owned native sessions are unchanged."}, "downtime": {"status": "none", "detail": "Existing provider processes are unchanged."}, "manual": {"status": "changed", "detail": "Rollout dependency: do not upgrade a host to a Fractal release containing this change until that host has activated a runtime profile with personas.launch..harnessBin for its managed seats. Read an absolute AGENT_RUNTIME_PROFILE when set, otherwise the stable HOME-relative coding-agent-runtime Nix profile; missing profile, persona key, or wrapper refuses new managed launches."}, "recovery": {"status": "none", "detail": "Correct the runtime profile or install the named wrapper before retrying a refused launch."} diff --git a/scripts/fractal-delivery-probe-test b/scripts/fractal-delivery-probe-test index 55bbdf0506c..bcc8bff57b9 100644 --- a/scripts/fractal-delivery-probe-test +++ b/scripts/fractal-delivery-probe-test @@ -996,6 +996,13 @@ class NativeProbeTests(unittest.IsolatedAsyncioTestCase): env["DBUS_SESSION_BUS_ADDRESS"] = os.environ["DBUS_SESSION_BUS_ADDRESS"] # The fault eval's default phase bound for node startup and each CLI call. nodes = [faults.Node(directory, host, BINARY, env, binary_directory, 60) for host in ("amber", "cobalt")] + for node in nodes: + runtime_profile = node.root / "runtime-profile.json" + runtime_profile.write_text(json.dumps({ + "personas": {"launch": {"generalist": {"harnessBin": str(node.root / "bin")}}}, + })) + node.env["AGENT_RUNTIME_PROFILE"] = str(runtime_profile) + node.env["AGENT_PERSONA"] = "generalist" if publication_gap: # Hold the launcher's return after the real PTY is published. The # native channel can already see its local incarnation while the diff --git a/scripts/fractal-no-st2-eval/run b/scripts/fractal-no-st2-eval/run index 774c10a4c3f..c49911cc4ec 100755 --- a/scripts/fractal-no-st2-eval/run +++ b/scripts/fractal-no-st2-eval/run @@ -2,7 +2,8 @@ """A Claude seat on a machine with no st2: no `st2` on PATH and no st2 state directory. One isolated daemon (private HOME, XDG dirs, PATH and sockets) runs one Claude seat whose -`claude` is ./claude, a stand-in that drives the real hook, status-line and channel contracts. +`claude` is ./claude, a stand-in selected through a private managed runtime profile that drives +the real hook, status-line and channel contracts. The eval checks, from outside the seat, that st3 alone: - publishes harness.observed working and idle from the seat's hooks; - binds Claude's native session, so the client API timeline shows the native transcript; @@ -59,6 +60,10 @@ class Node: stub = root / "bin/claude" stub.write_text(f'#!/bin/sh\nexec "{python}" "{root / "bin/claude.py"}" "$@"\n') stub.chmod(0o755) + runtime_profile = root / "runtime-profile.json" + runtime_profile.write_text(json.dumps({ + "personas": {"launch": {"generalist": {"harnessBin": str(root / "bin")}}}, + })) self.path = isolated_path(root / "bin") shell = shutil.which("bash", path=self.path) if not shell: @@ -74,6 +79,8 @@ class Node: "XDG_DATA_HOME": str(root / "home/.local/share"), "XDG_RUNTIME_DIR": str(root / "run"), "ST3_ENDPOINT": str(self.endpoint), "ST3_DAEMON_WAIT": "0"} + self.env["AGENT_RUNTIME_PROFILE"] = str(runtime_profile) + self.env["AGENT_PERSONA"] = "generalist" # The daemon rebuilds PATH from a login shell; pin it to the isolated one. profile = f"export PATH={shlex.quote(self.path)}\n" for name in (".profile", ".bash_profile", ".bashrc", ".zprofile", ".zshenv"): diff --git a/scripts/fractal-subagents-eval/run b/scripts/fractal-subagents-eval/run index e5b2ad6257f..f0d2f779290 100755 --- a/scripts/fractal-subagents-eval/run +++ b/scripts/fractal-subagents-eval/run @@ -2,7 +2,8 @@ """Subagent claims on an isolated daemon, driven through a stand-in Claude with no model. One isolated daemon (private HOME, XDG dirs, PATH and sockets) runs three Claude seats whose -`claude` is ./claude, a stand-in that reports subagents through the real hooks. The eval checks, +`claude` is ./claude, a stand-in selected through a private managed runtime profile that reports +subagents through the real hooks. The eval checks, from outside the seats, that st records one `subagent.appeared` and one `subagent.ended` per subagent on its parent seat, and lease renewals only while one runs: - appear then end, with the subagent's type, one-line description, session and held step, and @@ -82,6 +83,10 @@ class Node: stub = root / "bin/claude" stub.write_text(f'#!/bin/sh\nexec "{python}" "{root / "bin/claude.py"}" "$@"\n') stub.chmod(0o755) + runtime_profile = root / "runtime-profile.json" + runtime_profile.write_text(json.dumps({ + "personas": {"launch": {"generalist": {"harnessBin": str(root / "bin")}}}, + })) self.path = isolated_path(root / "bin") shell = shutil.which("bash", path=self.path) if not shell: @@ -94,6 +99,8 @@ class Node: "XDG_DATA_HOME": str(root / "home/.local/share"), "XDG_RUNTIME_DIR": str(root / "run"), "ST3_ENDPOINT": str(self.endpoint), "ST3_DAEMON_WAIT": "0"} + self.env["AGENT_RUNTIME_PROFILE"] = str(runtime_profile) + self.env["AGENT_PERSONA"] = "generalist" # The daemon rebuilds PATH from a login shell; pin it to the isolated one. profile = f"export PATH={shlex.quote(self.path)}\n" for name in (".profile", ".bash_profile", ".bashrc", ".zprofile", ".zshenv"): From f831f9d3ca807ba3a7b2fa0c922296316fc20d38 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:50:27 +0100 Subject: [PATCH 053/132] Retry managed client construction during full replay startup agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal/src/driver_cli.rs | 2 +- crates/fractal/src/native_seed_driver_tests.rs | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/fractal/src/driver_cli.rs b/crates/fractal/src/driver_cli.rs index 8854f9d6691..3a48666c36e 100644 --- a/crates/fractal/src/driver_cli.rs +++ b/crates/fractal/src/driver_cli.rs @@ -17,8 +17,8 @@ pub(super) async fn prepare_managed_provider( argv: &mut [String], ) -> Result<()> { let actor = client.person.as_deref().unwrap_or(subject); - let gateway = generated_client(&client.endpoint, Some(actor))?; let declaration = retry_while_daemon_unreachable(subject, || async { + let gateway = generated_client(&client.endpoint, Some(actor))?; managed_declaration_result( gateway.agent_declaration_get(subject, None, false).await.map(|reply| reply.value.tree), ) diff --git a/crates/fractal/src/native_seed_driver_tests.rs b/crates/fractal/src/native_seed_driver_tests.rs index 5d042b1eb0d..7e70d0bffd5 100644 --- a/crates/fractal/src/native_seed_driver_tests.rs +++ b/crates/fractal/src/native_seed_driver_tests.rs @@ -153,14 +153,26 @@ fn managed_startup_reads_its_declaration_over_authenticated_unix() { private_notes: Default::default(), }; let socket = root.join("managed.sock"); + #[cfg(target_os = "linux")] + let _startup = { + let startup = fractal_daemon::startup::Startup::begin(&socket).unwrap(); + startup.phase("full-replay/base-claims"); + let error = generated_client(&Endpoint::Unix(socket.clone()), Some(SUBJECT)) + .err().expect("full-replay startup must refuse client construction before listening"); + assert!(daemon_is_unreachable(&error)); + startup + }; let server_socket = socket.clone(); let server = tokio::spawn(async move { + // Managed adoption may arrive before the restarted daemon accepts connections. + tokio::time::sleep(Duration::from_millis(50)).await; serve_unix(&server_socket, router(state)).await.unwrap(); }); - while !socket.exists() { - tokio::task::yield_now().await; - } let mut client = Client::new(Endpoint::Unix(socket)); + let mut argv = vec!["/raw/bin/codex".into()]; + driver_cli::prepare_managed_provider(&client, SUBJECT, "codex", &mut argv) + .await.expect("managed startup must retry client construction during full replay"); + assert_eq!(Path::new(&argv[0]), root.join("steward/bin/codex")); let anonymous = generated_client(&client.endpoint, None).unwrap() .agent_declaration_get(SUBJECT, None, false).await.unwrap_err(); assert!(matches!(anonymous, From bb132c0a3b6aecaa6e1be154bd60911fb25a0026 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:30:27 +0100 Subject: [PATCH 054/132] Complete managed profile setup for Codex admission wait fixtures agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal/src/native_seed_driver_tests.rs | 5 +++-- crates/fractal/tests/codex_bootstrap.rs | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/fractal/src/native_seed_driver_tests.rs b/crates/fractal/src/native_seed_driver_tests.rs index 7e70d0bffd5..4e6f73be22f 100644 --- a/crates/fractal/src/native_seed_driver_tests.rs +++ b/crates/fractal/src/native_seed_driver_tests.rs @@ -157,8 +157,9 @@ fn managed_startup_reads_its_declaration_over_authenticated_unix() { let _startup = { let startup = fractal_daemon::startup::Startup::begin(&socket).unwrap(); startup.phase("full-replay/base-claims"); - let error = generated_client(&Endpoint::Unix(socket.clone()), Some(SUBJECT)) - .err().expect("full-replay startup must refuse client construction before listening"); + let Err(error) = generated_client(&Endpoint::Unix(socket.clone()), Some(SUBJECT)) else { + panic!("full-replay startup must refuse client construction before listening"); + }; assert!(daemon_is_unreachable(&error)); startup }; diff --git a/crates/fractal/tests/codex_bootstrap.rs b/crates/fractal/tests/codex_bootstrap.rs index bb5c8465f14..deb4abd3e4d 100644 --- a/crates/fractal/tests/codex_bootstrap.rs +++ b/crates/fractal/tests/codex_bootstrap.rs @@ -1038,7 +1038,13 @@ async fn bootstrap_waits_for_reconciliation(status: &str, previous: Option<&str> .unwrap(); }); until(|| socket.exists(), "the isolated API did not start").await; - let provider = root.path().join("provider"); + let managed_bin = root.path().join("managed-bin"); + std::fs::create_dir(&managed_bin).unwrap(); + let profile = root.path().join("runtime-profile.json"); + std::fs::write(&profile, serde_json::to_vec(&json!({ + "personas": {"launch": {"generalist": {"harnessBin": managed_bin}}}, + })).unwrap()).unwrap(); + let provider = managed_bin.join("codex"); let marker = root.path().join("provider-invoked"); std::fs::write( &provider, @@ -1057,6 +1063,8 @@ async fn bootstrap_waits_for_reconciliation(status: &str, previous: Option<&str> ("HOME", root.path().to_string_lossy().into_owned()), ("PATH", path.to_string_lossy().into_owned()), ("ST_AGENT", SUBJECT.to_owned()), + ("FRACTAL_AGENT", SUBJECT.to_owned()), + ("AGENT_RUNTIME_PROFILE", profile.to_string_lossy().into_owned()), ("ST3_SUBJECT", SUBJECT.to_owned()), ("ST3_BIN", binary_path.clone()), ("ST3_ENDPOINT", socket.to_string_lossy().into_owned()), From ef75da46e8df2cecf443224311cddbd0db3f986e Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 9 Nov 2026 19:59:43 +0100 Subject: [PATCH 055/132] packages/fractal: Fractal.objective, and the shipped objectives rendered from TS Fractal.objective, Fractal.indicator.* and Fractal.response.fault author the objective kind (docs/st3/objectives.md) and check the native reader's rules (fractal_kernel::objective) where the declaration is written; objectiveNode lowers it to KDL. The tree loader declares objective/ from objective.ts or objective.kdl (interval objectives named NAMESPACE/NAME only), resolving onBreach agent references; defineObjective is the low-level form. slo/objectives.kdl is generated by slo/objectives.kdl.genie.ts from the 21 objectives Fractal ships. objective.ts and the KDL serializer, moved from mod.ts to kdl.ts, import nothing, so Genie bundles them without effect. --- packages/fractal/COVERAGE.md | 5 +- packages/fractal/README.md | 42 +- packages/fractal/src/define.ts | 7 + packages/fractal/src/fractal.ts | 23 +- packages/fractal/src/index.ts | 1 + packages/fractal/src/kdl.ts | 119 ++++ packages/fractal/src/mod.ts | 117 +--- packages/fractal/src/objective.ts | 651 ++++++++++++++++++++ packages/fractal/src/objective.unit.test.ts | 416 +++++++++++++ packages/fractal/src/subject-ref.ts | 2 +- packages/fractal/src/tree.ts | 106 +++- slo/objectives.kdl | 181 ++++++ slo/objectives.kdl.genie.ts | 243 ++++++++ 13 files changed, 1778 insertions(+), 135 deletions(-) create mode 100644 packages/fractal/src/kdl.ts create mode 100644 packages/fractal/src/objective.ts create mode 100644 packages/fractal/src/objective.unit.test.ts create mode 100644 slo/objectives.kdl create mode 100644 slo/objectives.kdl.genie.ts diff --git a/packages/fractal/COVERAGE.md b/packages/fractal/COVERAGE.md index b8184954420..a41d580e832 100644 --- a/packages/fractal/COVERAGE.md +++ b/packages/fractal/COVERAGE.md @@ -30,6 +30,7 @@ Authoritative grammar: `compoundingtech/smalltalk`, `crates/st3/src/graph.rs` an | `resource` | Partial | vcs.repository, filesystem.file, vcs.pull-request and vcs.ref. Additional native resource kinds are not modeled. | | `doc`, `observer`, `subscription` | Partial | Immutable document bindings and step pins, named/pinned document gates, github.ref observers selecting head/ancestors, and message subscriptions with field conditions. Mission/step/round declarations preserve native run scoping and alias rewriting. Other observer providers and subscription delivery modes are not modeled. | | mission `lane` | Covered | Unique run-owned names, optional slash-terminated `entries` prefixes and `person/` approvers. Lane blocks precede execution blocks and omit absent children. Root lane declarations are not modeled. | +| `objective` | Covered | `Fractal.objective`, `indicator.*` and `response.fault` check the native reader's rules (`fractal_kernel::objective`) and `objectiveNode` lowers every indicator, target, window and breach response. A subject tree declares interval objectives only; native telemetry objectives are rendered by `slo/objectives.kdl.genie.ts`. | | `account`, `pty`, `host`, `person`, `mission-run`, `planning-session`, `message`, `repair`, `stop` | Not covered | Additional root grammar nodes omitted. | | `version 2` | Covered | All emitted documents start with this directive. | @@ -37,10 +38,12 @@ Schedule work accepts either a pinned `mission@revision` or an unpinned mission ## Subject trees (`fractal/tree`, Node only) -`loadSubjectTree({ root, context })` discovers `agent.ts|kdl`, `mission.ts|kdl` and `schedule.ts|kdl` declarations recursively. IDs come from directories relative to the root. A seat and mission may share a directory; mission and schedule cannot. Symlink aliases load once under their canonical path, and declarations outside the tree are rejected. +`loadSubjectTree({ root, context })` discovers `agent.ts|kdl`, `mission.ts|kdl`, `schedule.ts|kdl` and `objective.ts|kdl` declarations recursively. IDs come from directories relative to the root. A seat and mission may share a directory; mission and schedule cannot. Symlink aliases load once under their canonical path, and declarations outside the tree are rejected. TS modules export an intent, a context factory, or `defineAgent|defineMission|defineSchedule({ meta: import.meta, intent })`. Typed agent refs are accepted for `under.target`, required mission `reportTo`, mission/step `assignedTo`, and loop/final workers; they lower to validated `{ id }` agent references, never legacy assignee strings. Typed mission refs lower to unpinned schedule work IDs. Plain imported declarations and attributed people retain the authoring schemas' contracts. +Objective modules export `Fractal.objective(...)`, its plain fields, a context factory, or `defineObjective({ meta: import.meta, intent })`. The subject is `objective/`; a `name` field, if present, must equal the ID. Typed agent refs in `onBreach` `owner`/`backup` lower to `agent/`, then `Fractal.objective` checks the result. Only an interval over claims named `NAMESPACE/NAME` loads, from TS or KDL; the native telemetry indicators are Fractal's own. + Mission modules pass through `missionWire` before strict schema decoding, preserving scoped step handles, input/product identities and text templates until assembly. Reference lowering leaves unchanged authoring objects intact. Extra properties and invalid current declarations remain errors. Named module exports are retained beside the decoded intent. KDL declarations are checked for version, root kind and path-derived ID; native st remains their grammar authority. Native conformance is opt-in: `ST_BIN` selects the production binary for the agent-attributed round-trip; `ST_FIXTURE_BIN` selects upstream's separately compiled, pinned `st3-fixture` test-support binary for synthetic-person gate semantics. Both use isolated private runtime directories. The fixture mode does not prove production person authentication; see [the acceptance boundary](./HUMAN_GATE_ACCEPTANCE.md). diff --git a/packages/fractal/README.md b/packages/fractal/README.md index 3eb70d36e85..fc7eb05cd32 100644 --- a/packages/fractal/README.md +++ b/packages/fractal/README.md @@ -1,16 +1,16 @@ # fractal -Typed authoring for Fractal declarations: agents, missions and schedules kept as TypeScript modules in a directory tree, validated with Effect schemas and rendered to KDL v2. This is the private workspace package `fractal`; it is not published to npm. +Typed authoring for Fractal declarations: agents, missions, schedules and objectives kept as TypeScript modules in a directory tree, validated with Effect schemas or the native readers' rules and rendered to KDL v2. This is the private workspace package `fractal`; it is not published to npm. | Entrypoint | Runtime | Contents | |---|---|---| -| `fractal` | isomorphic | The `Fractal` authoring namespace; declaration schemas and the KDL lowering (`agent`, `mission`, `schedule` nodes, `emit`, `fractalKdl`) as named exports | +| `fractal` | isomorphic | The `Fractal` authoring namespace; declaration schemas and the KDL lowering (`agent`, `mission`, `schedule`, `objectiveNode` nodes, `emit`, `fractalKdl`) as named exports | | `fractal/tree` | Node | `loadSubjectTree` plus the define forms and reference types | | `fractal/testing` | Node | `scratchDaemonLayer`, an isolated daemon for conformance tests | ## Authoring -Each declaration is the default export of `/agent.ts`, `/mission.ts` or `/schedule.ts` below a tree root. `meta: import.meta` ties the declaration to its module; the loader derives the ID from the module's directory, so declarations never repeat it. Imported declarations are typed references. +Each declaration is the default export of `/agent.ts`, `/mission.ts`, `/schedule.ts` or `/objective.ts` below a tree root. `meta: import.meta` ties the declaration to its module; the loader derives the ID from the module's directory, so declarations never repeat it. Imported declarations are typed references. ```ts // garden/worker/agent.ts @@ -39,18 +39,48 @@ export default Fractal.mission({ }) ``` -The namespace also holds `step`, `gate`, `person`, `input`, `product`, `t`, `doc`, `pr`, `childMission`, `runId`, `completed`/`failed`/`terminal`, `Timing`, `Launch`, `Review`, `Fragment`, `Mission`, `Round`, `omp` and `pinMission`. `Fractal.defineAgent`, `Fractal.defineMission` and `Fractal.defineSchedule` are the low-level forms: `{ meta, intent }`, where `intent` may be a function of the loader's `context`. +An objective (`docs/st3/objectives.md`) is a target over something Fractal measures. A fleet declares intervals over claims, named `NAMESPACE/NAME` by their directory; `objective.ts` needs no `meta` because nothing references an objective, and a `name`, if given, must equal the path ID. + +```ts +// ops/change-rollout/objective.ts +import { Fractal } from 'fractal' + +import releaseManager from '../release-manager/agent.ts' + +export default Fractal.objective({ + about: 'A change, from its author marking it ready to every member that serves it', + indicator: Fractal.indicator.interval({ + subjectPrefix: 'custom/acme/change/', + start: { kind: 'custom.acme.change-ready', at: 'ready_at' }, + expect: { kind: 'custom.acme.change-merged', set: 'hosts', at: 'merged_at' }, + stop: { kind: 'custom.acme.change-applied', member: 'host', at: 'applied_at' }, + cancel: 'custom.acme.change-abandoned', + labels: ['class'], + }), + window: { span: '7d', minSamples: 20 }, + targets: [ + { stat: 'p50', within: '60s' }, + { stat: 'p95', within: '5m' }, + ], + onBreach: Fractal.response.fault({ owner: releaseManager, condition: 'change-rollout-slo-missed' }), +}) +``` + +`Fractal.objective` checks the rules the native reader enforces and throws on the first broken one. The other indicators (`latency`, `share`, `statement`, `transaction`, `writerHold`, `cpu`, `database`, `divergence`, `ciDuration`, `ciCount`) are native telemetry: Fractal ships them in [`slo/objectives.kdl`](../../slo/objectives.kdl), rendered by [`slo/objectives.kdl.genie.ts`](../../slo/objectives.kdl.genie.ts), and the tree loader refuses them. `src/objective.ts` and `src/kdl.ts` import nothing outside the package, so Genie bundles that file without installing dependencies. + +The namespace also holds `step`, `gate`, `person`, `input`, `product`, `t`, `doc`, `pr`, `childMission`, `runId`, `completed`/`failed`/`terminal`, `Timing`, `Launch`, `Review`, `Fragment`, `Mission`, `Round`, `omp` and `pinMission`. `Fractal.defineAgent`, `Fractal.defineMission`, `Fractal.defineSchedule` and `Fractal.defineObjective` are the low-level forms: `{ meta, intent }`, where `intent` may be a function of the loader's `context`. `step()` and `gate.*()` validate eagerly, so a step handle's `assignedTo` must be an object with an `id`; a path-derived reference works in an inline step object, which the loader lowers. ## Loading and rendering -`loadSubjectTree({ root, context })` imports every declaration module below `root`, lowers typed references to IDs without copying authored step and gate handles, assembles missions with `missionWire`, and decodes the result with the strict schemas. Render a loaded subject with the named exports: +`loadSubjectTree({ root, context })` imports every declaration module below `root`, lowers typed references to IDs without copying authored step and gate handles, assembles missions with `missionWire`, decodes the result with the strict schemas, and checks objectives with `Fractal.objective`. Render a loaded subject with the named exports: ```ts -import { agent, emit, mission } from 'fractal' +import { agent, emit, mission, objectiveNode } from 'fractal' const kdl = emit([subject.kind === 'agent' ? agent(intent) : mission(intent)]) +const objectiveKdl = emit([objectiveNode({ ...intent, name: subject.id })]) ``` Validate the output with `fractal validate` before publishing it with `fractal apply`. diff --git a/packages/fractal/src/define.ts b/packages/fractal/src/define.ts index 5cb8ba8d1ce..4a3c5d39cf4 100644 --- a/packages/fractal/src/define.ts +++ b/packages/fractal/src/define.ts @@ -1,4 +1,5 @@ import type { AgentSchema, MissionInput, ScheduleSchema, WorkSchema } from './mod.ts' +import type { ObjectiveInput } from './objective.ts' import type { SubjectKind, SubjectDefinition, @@ -108,6 +109,12 @@ export const defineMission: Define<'mission', MissionIntent> = define('mission') /** Defines the scheduled mission declared by a `schedule.ts` module. */ export const defineSchedule: Define<'schedule', ScheduleIntent> = define('schedule') +/** Objective fields for an `objective.ts` module; the name, if given, must be its path ID. */ +export type ObjectiveIntent = ObjectiveInput + +/** Defines the objective declared by an `objective.ts` module; see `Fractal.objective`. */ +export const defineObjective: Define<'objective', ObjectiveIntent> = define('objective') + /** The defining module's `import.meta`; the loader derives the subject ID from the module's path. */ type Meta = { readonly meta: { readonly url: string } } diff --git a/packages/fractal/src/fractal.ts b/packages/fractal/src/fractal.ts index b4079c89425..d72674922c1 100644 --- a/packages/fractal/src/fractal.ts +++ b/packages/fractal/src/fractal.ts @@ -3,8 +3,10 @@ * * `agent`, `mission` and `schedule` declare the subject of an `agent.ts`, `mission.ts` or * `schedule.ts` module from flat fields plus `meta: import.meta`; the tree loader - * (`fractal/tree`) derives each ID from the module's path. `defineAgent`, `defineMission` and - * `defineSchedule` are the low-level forms whose `intent` may also be a context factory. + * (`fractal/tree`) derives each ID from the module's path. `objective` declares the objective of + * an `objective.ts` module, measured by an `indicator` and answered by a `response`. + * `defineAgent`, `defineMission`, `defineSchedule` and `defineObjective` are the low-level forms + * whose `intent` may also be a context factory. */ export { agent, @@ -13,13 +15,30 @@ export { defineAgent, defineMission, defineSchedule, + defineObjective, pinMission, type AgentIntent, type MissionIntent, type ScheduleIntent, + type ObjectiveIntent, type PinnedMissionRef, } from './define.ts' export type { AgentRef, MissionRef, SubjectRef, SubjectDefinition } from './subject-ref.ts' +export { + objective, + indicator, + response, + type AgentTarget, + type ClaimEvent, + type Duration, + type Fault, + type Indicator, + type Interval, + type Objective, + type ObjectiveInput, + type Target, + type Window, +} from './objective.ts' export { step, gate, diff --git a/packages/fractal/src/index.ts b/packages/fractal/src/index.ts index 2310b273382..a3fb70b17eb 100644 --- a/packages/fractal/src/index.ts +++ b/packages/fractal/src/index.ts @@ -4,3 +4,4 @@ */ export * as Fractal from './fractal.ts' export * from './mod.ts' +export { objectiveNode } from './objective.ts' diff --git a/packages/fractal/src/kdl.ts b/packages/fractal/src/kdl.ts new file mode 100644 index 00000000000..8772b0820fa --- /dev/null +++ b/packages/fractal/src/kdl.ts @@ -0,0 +1,119 @@ +/** + * KDL v2 nodes and their serialization. This module imports nothing, so Genie files that render + * declarations (`slo/objectives.kdl.genie.ts`) bundle it without the package's dependencies. + */ + +/** A KDL argument or property value. */ +export type Value = string | number | boolean + +/** A KDL node; `children: undefined` renders without a children block. */ +export type Node = { + readonly name: string + readonly args: readonly Value[] + readonly props: Readonly> + readonly children?: readonly Node[] +} + +const immutableNodes = new WeakSet() + +/** Builds a KDL node; omitted args and props are empty. */ +export const node = ({ + name, + args = [], + props = {}, + children, +}: { + readonly name: string + readonly args?: readonly Value[] + readonly props?: Readonly> + readonly children?: readonly Node[] +}): Node => { + const value = Object.freeze({ + name, + args: Object.freeze([...args]), + props: Object.freeze({ ...props }), + ...(children === undefined + ? {} + : { + children: Object.freeze( + children.map((child) => (immutableNodes.has(child) === true ? child : node(child))), + ), + }), + }) + immutableNodes.add(value) + return value +} + +const renderValue = (value: Value): string => { + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'boolean') return `#${value}` + if (Number.isFinite(value) === false) throw new RangeError('KDL numbers must be finite') + return String(value) +} + +const identifier = (text: string): string => + /^[A-Za-z_][\w-]*$/u.test(text) === true ? text : JSON.stringify(text) + +const render = ({ + node: current, + depth, +}: { + readonly node: Node + readonly depth: number +}): string => { + const pad = ' '.repeat(depth) + const args = current.args.map((arg) => ` ${renderValue(arg)}`).join('') + const props = Object.entries(current.props) + .toSorted(([a], [b]) => a.localeCompare(b, 'en')) + .map(([key, value]) => ` ${identifier(key)}=${renderValue(value)}`) + .join('') + const header = `${pad}${identifier(current.name)}${args}${props}` + if (current.children === undefined) return `${header}\n` + const body = current.children.map((child) => render({ node: child, depth: depth + 1 })).join('') + return `${header} {\n${body}${pad}}\n` +} + +/** Serializes nodes as a KDL v2 document. */ +export const emit = (nodes: readonly Node[]): string => + `version 2\n${nodes.map((current) => render({ node: current, depth: 0 })).join('')}` + +/** One validation finding, in the shape Genie's `GenieValidationIssue` expects. */ +export interface KdlValidationIssue { + readonly severity: 'error' + readonly packageName: string + readonly dependency: string + readonly rule: string + readonly message: string +} + +/** + * A generated KDL declaration file. The shape is Genie's `GenieOutput` contract, declared + * structurally so a Genie file can return it without this package depending on Genie. + */ +export interface KdlFileOutput { + readonly data: readonly Node[] + readonly stringify: () => string + readonly validate: () => KdlValidationIssue[] +} + +/** Genie output for a Fractal KDL declaration file. */ +export const fractalKdl = (nodes: readonly Node[]): KdlFileOutput => ({ + data: nodes, + stringify: () => emit(nodes), + validate: () => { + try { + emit(nodes) + return [] + } catch (cause) { + return [ + { + severity: 'error', + packageName: 'fractal', + dependency: '', + rule: 'fractal-kdl', + message: cause instanceof Error ? cause.message : String(cause), + }, + ] + } + }, +}) diff --git a/packages/fractal/src/mod.ts b/packages/fractal/src/mod.ts index 25e475a29d1..6e9dd7c0bf7 100644 --- a/packages/fractal/src/mod.ts +++ b/packages/fractal/src/mod.ts @@ -2,6 +2,7 @@ import { sha256 } from '@noble/hashes/sha2.js' import { bytesToHex } from '@noble/hashes/utils.js' import { Schema, SchemaGetter } from 'effect' +import { node, type Node, type Value } from './kdl.ts' import type { ChildMission, DocumentReference, @@ -91,79 +92,8 @@ export type { TextTemplate, } from './run-values.ts' -/** A KDL argument or property value. */ -export type Value = string | number | boolean - -/** A KDL node; `children: undefined` renders without a children block. */ -export type Node = { - readonly name: string - readonly args: readonly Value[] - readonly props: Readonly> - readonly children?: readonly Node[] -} - -const immutableNodes = new WeakSet() - -/** Builds a KDL node; omitted args and props are empty. */ -export const node = ({ - name, - args = [], - props = {}, - children, -}: { - readonly name: string - readonly args?: readonly Value[] - readonly props?: Readonly> - readonly children?: readonly Node[] -}): Node => { - const value = Object.freeze({ - name, - args: Object.freeze([...args]), - props: Object.freeze({ ...props }), - ...(children === undefined - ? {} - : { - children: Object.freeze( - children.map((child) => (immutableNodes.has(child) === true ? child : node(child))), - ), - }), - }) - immutableNodes.add(value) - return value -} - -const renderValue = (value: Value): string => { - if (typeof value === 'string') return JSON.stringify(value) - if (typeof value === 'boolean') return `#${value}` - if (Number.isFinite(value) === false) throw new RangeError('KDL numbers must be finite') - return String(value) -} - -const identifier = (text: string): string => - /^[A-Za-z_][\w-]*$/u.test(text) === true ? text : JSON.stringify(text) - -const render = ({ - node: current, - depth, -}: { - readonly node: Node - readonly depth: number -}): string => { - const pad = ' '.repeat(depth) - const args = current.args.map((arg) => ` ${renderValue(arg)}`).join('') - const props = Object.entries(current.props) - .toSorted(([a], [b]) => a.localeCompare(b, 'en')) - .map(([key, value]) => ` ${identifier(key)}=${renderValue(value)}`) - .join('') - const header = `${pad}${identifier(current.name)}${args}${props}` - if (current.children === undefined) return `${header}\n` - const body = current.children.map((child) => render({ node: child, depth: depth + 1 })).join('') - return `${header} {\n${body}${pad}}\n` -} - -/** Serializes nodes as a KDL v2 document. */ -export const emit = (nodes: readonly Node[]): string => - `version 2\n${nodes.map((current) => render({ node: current, depth: 0 })).join('')}` +export { emit, fractalKdl, node } from './kdl.ts' +export type { KdlFileOutput, KdlValidationIssue, Node, Value } from './kdl.ts' const child = ({ name, value }: { readonly name: string; readonly value: Value }): Node => node({ name, args: [value] }) @@ -2580,44 +2510,3 @@ export const agent = (input: typeof AgentSchema.Encoded): Node => { for (const task of a.exec ?? []) children.push(taskNode({ kind: 'exec', task })) return node({ name: 'agent', args: [a.id], children }) } - -/** One validation finding, in the shape Genie's `GenieValidationIssue` expects. */ -export interface KdlValidationIssue { - readonly severity: 'error' - readonly packageName: string - readonly dependency: string - readonly rule: string - readonly message: string -} - -/** - * A generated KDL declaration file. The shape is Genie's `GenieOutput` contract, declared - * structurally so a Genie file can return it without this package depending on Genie. - */ -export interface KdlFileOutput { - readonly data: readonly Node[] - readonly stringify: () => string - readonly validate: () => KdlValidationIssue[] -} - -/** Genie output for a Fractal KDL declaration file. */ -export const fractalKdl = (nodes: readonly Node[]): KdlFileOutput => ({ - data: nodes, - stringify: () => emit(nodes), - validate: () => { - try { - emit(nodes) - return [] - } catch (cause) { - return [ - { - severity: 'error', - packageName: 'fractal', - dependency: '', - rule: 'fractal-kdl', - message: cause instanceof Error ? cause.message : String(cause), - }, - ] - } - }, -}) diff --git a/packages/fractal/src/objective.ts b/packages/fractal/src/objective.ts new file mode 100644 index 00000000000..dce58f583a3 --- /dev/null +++ b/packages/fractal/src/objective.ts @@ -0,0 +1,651 @@ +/** + * Objectives (docs/st3/objectives.md): an indicator that measures samples, the targets that bound + * them, the window an interval is judged over and who a miss is delivered to. `objective` checks + * the rules the native reader (`fractal_kernel::objective`) enforces, so a declaration fails where + * it is written; `objectiveNode` lowers it to the `objective` KDL node. + * + * This module imports only `./kdl.ts`: `slo/objectives.kdl.genie.ts` renders the objectives + * Fractal ships with it, and Genie bundles it without the package's dependencies. + */ +import { node, type Node, type Value } from './kdl.ts' +import type { AgentRef } from './subject-ref.ts' + +/** A native duration: an integer with `ms`, `s`, `m`, `h` or `d` (`300ms`, `5m`, `7d`). */ +export type Duration = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}` + +/** A claim that marks a moment of a sample; `at` names the field holding its time. */ +export interface ClaimEvent { + readonly kind: string + /** A field with an RFC 3339 time or Unix milliseconds; without it, the claim's accepted time. */ + readonly at?: string +} + +/** Work that starts and stops in claims, one sample per subject under `subjectPrefix`. */ +export interface Interval { + readonly subjectPrefix: string + readonly start: ClaimEvent + /** The members a sample must stop on: a list of strings in field `set` of the newest claim. */ + readonly expect?: ClaimEvent & { readonly set: string } + /** One member stopping, named by field `member` (required with `expect`). */ + readonly stop: ClaimEvent & { readonly member?: string } + readonly cancel?: string + readonly pause?: { readonly kind: string; readonly resume: string } + /** Fields of the start or expect claim, or `member`, that split the rows. */ + readonly labels?: readonly string[] + /** Leave out a sample whose start or expect claim has one of `values` in `field`. */ + readonly exclude?: readonly { readonly field: string; readonly values: readonly string[] }[] +} + +/** How a sample is measured; `kind` is the KDL indicator name. */ +export type Indicator = + | { readonly kind: 'latency'; readonly paths: readonly string[] } + | { readonly kind: 'share'; readonly path: string } + | { readonly kind: 'statement' } + | { readonly kind: 'transaction' } + | { readonly kind: 'writer-hold' } + | { readonly kind: 'cpu'; readonly idleMinMinutes: number; readonly loadMaxCores: number } + | { readonly kind: 'database' } + | { readonly kind: 'divergence' } + | { readonly kind: 'ci-duration'; readonly series: string } + | { readonly kind: 'ci-count'; readonly series: string } + | ({ readonly kind: 'interval' } & Interval) + +type IndicatorOf = Extract + +/** One statistic and its bound; `scope: 'remote'` is a latency target for reads served remotely. */ +export type Target = { readonly stat: string; readonly scope?: 'remote' } & ( + | { readonly within: Duration; readonly max?: never; readonly minPercent?: never } + | { readonly max: number; readonly within?: never; readonly minPercent?: never } + | { readonly minPercent: number; readonly within?: never; readonly max?: never } +) + +/** The span an interval's budget is measured over, and the samples it needs to be met or missed. */ +export interface Window { + readonly span: Duration + readonly minSamples?: number +} + +/** An agent: a typed reference to an `agent.ts` module, or its `agent/ID` subject. */ +export type AgentTarget = AgentRef | `agent/${string}` + +/** A miss opens a fault for `owner`; `backup` is held, recorded and never the reviewer. */ +export interface Fault { + readonly kind: 'fault' + readonly owner: AgentTarget + readonly backup?: AgentTarget + readonly condition: string +} + +/** An objective's fields; `name` is absent in a subject tree, which derives it from the path. */ +export interface ObjectiveInput { + readonly name?: string + readonly about: string + readonly indicator: Indicator + readonly window?: Window + readonly targets: readonly Target[] + readonly onBreach?: Fault +} + +/** A checked objective: exactly the fields of its input, frozen. */ +export type Objective = ObjectiveInput + +const freeze = (value: T): T => Object.freeze(value) + +/** The indicators an objective measures with. */ +export const indicator = { + /** Work that starts and stops in claims (the one kind a fleet declares). */ + interval: (input: Interval): IndicatorOf<'interval'> => freeze({ kind: 'interval', ...input }), + /** Daemon routes, stream first snapshots, publications or phone reports, by `METHOD /route`. */ + latency: ({ paths }: { readonly paths: readonly string[] }): IndicatorOf<'latency'> => + freeze({ kind: 'latency', paths }), + /** A phone's live foreground time over its foreground time. */ + share: ({ path }: { readonly path: string }): IndicatorOf<'share'> => freeze({ kind: 'share', path }), + /** One SQL statement. */ + statement: (): IndicatorOf<'statement'> => freeze({ kind: 'statement' }), + /** Read and write transactions and writer holds. */ + transaction: (): IndicatorOf<'transaction'> => freeze({ kind: 'transaction' }), + /** Each writer hold and long read, by holder. */ + writerHold: (): IndicatorOf<'writer-hold'> => freeze({ kind: 'writer-hold' }), + /** The member's daemon and replication workers over closed UTC minutes. */ + cpu: ({ + idleMinMinutes, + loadMaxCores, + }: { + readonly idleMinMinutes: number + readonly loadMaxCores: number + }): IndicatorOf<'cpu'> => freeze({ kind: 'cpu', idleMinMinutes, loadMaxCores }), + /** The store's pages in use and their daily growth. */ + database: (): IndicatorOf<'database'> => freeze({ kind: 'database' }), + /** Open projection divergence faults. */ + divergence: (): IndicatorOf<'divergence'> => freeze({ kind: 'divergence' }), + /** A percentile of a CI series' durations, `ci/NAME`. */ + ciDuration: ({ series }: { readonly series: string }): IndicatorOf<'ci-duration'> => + freeze({ kind: 'ci-duration', series }), + /** Events in a CI series per hour. */ + ciCount: ({ series }: { readonly series: string }): IndicatorOf<'ci-count'> => + freeze({ kind: 'ci-count', series }), +} + +/** What a missed row delivers. */ +export const response = { + /** One `operational.failure` for `owner` per episode, with `condition`. */ + fault: (input: Omit): Fault => freeze({ kind: 'fault', ...input }), +} + +/** Lowercase words joined by `-`, as a name segment and a fault condition are. */ +export const validWord = (word: string): boolean => + /^[a-z][a-z0-9-]*$/u.test(word) && word.endsWith('-') === false && word.includes('--') === false + +/** One or more words joined by `/`: `cpu`, `ops/change-rollout`. */ +export const validName = (name: string): boolean => + name.length <= 128 && name.split('/').every(validWord) + +const units = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const + +/** A native duration in milliseconds; bare integers are seconds, as the native reader reads them. */ +const durationMs = ({ value, fail }: { readonly value: unknown; readonly fail: Fail }): number => { + const match = typeof value === 'string' ? /^([0-9]+)(ms|s|m|h|d)?$/u.exec(value) : null + if (match === null) return fail(`invalid duration \`${String(value)}\``) + const ms = Number(match[1]) * units[(match[2] ?? 's') as keyof typeof units] + if (Number.isSafeInteger(ms) === false) return fail('duration is too large') + if (ms === 0) return fail('a required duration must be positive') + return ms +} + +type Fail = (message: string) => never +type Fields = Readonly> + +const isRecord = (value: unknown): value is Fields => + typeof value === 'object' && value !== null && Array.isArray(value) === false + +const isField = (value: unknown): value is string => + typeof value === 'string' && /^[A-Za-z0-9_-]+$/u.test(value) + +const isAgentRef = (value: unknown): value is AgentRef => + isRecord(value) && value._tag === 'SubjectRef' && value.kind === 'agent' + +const isCiSeries = (series: string): boolean => + /^ci\/[a-z0-9-]+(\/[a-z0-9-]+)*$/u.test(series) + +/** Refuses fields other than `allowed`; an `undefined` field counts as absent. */ +const only = ({ + value, + allowed, + what, + fail, +}: { + readonly value: Fields + readonly allowed: readonly string[] + readonly what: string + readonly fail: Fail +}): void => { + const unknown = Object.keys(value).find( + (key) => value[key] !== undefined && allowed.includes(key) === false, + ) + if (unknown !== undefined) fail(`${what} has an unknown field \`${unknown}\``) +} + +const percentile = (stat: string): number | undefined => + /^p[1-9][0-9]?$/u.test(stat) ? Number(stat.slice(1)) : undefined + +const event = ({ + value, + name, + extra, + fail, +}: { + readonly value: unknown + readonly name: string + readonly extra: readonly string[] + readonly fail: Fail +}): Fields & ClaimEvent => { + if (isRecord(value) === false) return fail(`\`${name}\` needs a claim \`kind\``) + only({ value, allowed: ['kind', 'at', ...extra], what: `\`${name}\``, fail }) + if (typeof value.kind !== 'string' || value.kind === '') fail(`\`${name}\` names no claim kind`) + if (value.at !== undefined && isField(value.at) === false) fail('`at` is not a field name') + return value as Fields & ClaimEvent +} + +const parseInterval = ({ value, fail }: { readonly value: Fields; readonly fail: Fail }): IndicatorOf<'interval'> => { + only({ + value, + allowed: ['kind', 'subjectPrefix', 'start', 'expect', 'stop', 'cancel', 'pause', 'labels', 'exclude'], + what: 'indicator `interval`', + fail, + }) + const prefix = value.subjectPrefix + if (prefix === undefined) fail('an interval needs `subjectPrefix`') + if ( + typeof prefix !== 'string' || + prefix.endsWith('/') === false || + prefix.length < 2 || + prefix.startsWith('/') + ) { + fail(`subjectPrefix ${JSON.stringify(prefix)} must name subjects and end with \`/\``) + } + if (value.start === undefined) fail('an interval needs `start`') + const start = event({ value: value.start, name: 'start', extra: [], fail }) + const expect = + value.expect === undefined + ? undefined + : event({ value: value.expect, name: 'expect', extra: ['set'], fail }) + if (expect !== undefined && isField(expect.set) === false) fail('`expect` needs `set` naming a field') + if (value.stop === undefined) fail('an interval needs `stop`') + const stop = event({ value: value.stop, name: 'stop', extra: ['member'], fail }) + if (stop.member !== undefined && isField(stop.member) === false) fail('`member` is not a field name') + if (value.cancel !== undefined && typeof value.cancel !== 'string') fail('`cancel` needs one string') + const pause = value.pause + if (pause !== undefined) { + if (isRecord(pause) === false || typeof pause.kind !== 'string') return fail('`pause` needs one string') + only({ value: pause, allowed: ['kind', 'resume'], what: '`pause`', fail }) + if (typeof pause.resume !== 'string' || pause.resume === '') fail('`pause` needs `resume`') + } + const labels = value.labels ?? [] + if (Array.isArray(labels) === false) return fail('`labels` is a list of fields') + labels.forEach((label: unknown, index) => { + if (isField(label) === false || labels.indexOf(label) !== index) { + fail(`label ${JSON.stringify(label)} is not a field, or is repeated`) + } + }) + const excludes = value.exclude ?? [] + if (Array.isArray(excludes) === false) return fail('`exclude` is a list') + for (const exclude of excludes as unknown[]) { + if ( + isRecord(exclude) === false || + isField(exclude.field) === false || + Array.isArray(exclude.values) === false || + exclude.values.length === 0 || + exclude.values.some((v: unknown) => typeof v !== 'string' || v === '') + ) { + fail('`exclude` needs a field and at least one value') + } + only({ value: exclude as Fields, allowed: ['field', 'values'], what: '`exclude`', fail }) + } + if (expect !== undefined && stop.member === undefined) fail('with `expect`, `stop` needs `member`') + if (labels.includes('member') && stop.member === undefined) { + fail('`label "member"` needs `stop.member`') + } + // Every field was checked above. + const interval = value as unknown as IndicatorOf<'interval'> + const kinds = [ + interval.start.kind, + ...(interval.expect === undefined ? [] : [interval.expect.kind]), + interval.stop.kind, + ...(interval.cancel === undefined ? [] : [interval.cancel]), + ...(interval.pause === undefined ? [] : [interval.pause.kind, interval.pause.resume]), + ] + const repeated = kinds.find((kind, index) => kinds.indexOf(kind) !== index) + if (repeated !== undefined) fail(`claim kind ${JSON.stringify(repeated)} has two roles`) + const eventOf = (e: ClaimEvent) => ({ kind: e.kind, ...(e.at === undefined ? {} : { at: e.at }) }) + return freeze({ + kind: 'interval', + subjectPrefix: interval.subjectPrefix, + start: freeze(eventOf(interval.start)), + ...(interval.expect === undefined + ? {} + : { expect: freeze({ ...eventOf(interval.expect), set: interval.expect.set }) }), + stop: freeze({ + ...eventOf(interval.stop), + ...(interval.stop.member === undefined ? {} : { member: interval.stop.member }), + }), + ...(interval.cancel === undefined ? {} : { cancel: interval.cancel }), + ...(interval.pause === undefined + ? {} + : { pause: freeze({ kind: interval.pause.kind, resume: interval.pause.resume }) }), + ...(interval.labels === undefined ? {} : { labels: freeze([...interval.labels]) }), + ...(interval.exclude === undefined + ? {} + : { + exclude: freeze( + interval.exclude.map(({ field, values }) => freeze({ field, values: freeze([...values]) })), + ), + }), + }) +} + +const parseIndicator = ({ value, fail }: { readonly value: unknown; readonly fail: Fail }): Indicator => { + if (isRecord(value) === false || typeof value.kind !== 'string') return fail('`indicator` needs a `kind`') + const kind = value.kind + const allow = (fields: readonly string[]) => + only({ value, allowed: ['kind', ...fields], what: `indicator \`${kind}\``, fail }) + switch (kind) { + case 'latency': { + allow(['paths']) + const paths = value.paths + if (Array.isArray(paths) === false || paths.length === 0) return fail('a latency indicator needs a `path`') + if (paths.some((path: unknown) => typeof path !== 'string')) return fail('`path` needs one string') + return freeze({ kind, paths: freeze([...(paths as string[])]) }) + } + case 'share': + allow(['path']) + if (typeof value.path !== 'string') return fail('indicator `share` needs one `path`') + return freeze({ kind, path: value.path }) + case 'statement': + case 'transaction': + case 'writer-hold': + case 'database': + case 'divergence': + allow([]) + return freeze({ kind }) + case 'cpu': { + allow(['idleMinMinutes', 'loadMaxCores']) + const { idleMinMinutes, loadMaxCores } = value + if (typeof idleMinMinutes !== 'number' || Number.isSafeInteger(idleMinMinutes) === false || idleMinMinutes <= 0) { + return fail('idleMinMinutes is not a positive integer') + } + if (typeof loadMaxCores !== 'number' || Number.isFinite(loadMaxCores) === false) { + return fail('loadMaxCores is not a number') + } + if (loadMaxCores <= 0) return fail('loadMaxCores is not positive') + return freeze({ kind, idleMinMinutes, loadMaxCores }) + } + case 'ci-duration': + case 'ci-count': { + allow(['series']) + const series = value.series + if (typeof series !== 'string') return fail(`indicator \`${kind}\` needs one \`series\``) + if (isCiSeries(series) === false) return fail(`series ${JSON.stringify(series)} is not \`ci/\` and lowercase words`) + return freeze({ kind, series }) + } + case 'interval': + return parseInterval({ value, fail }) + default: + return fail(`has an unknown indicator \`${kind}\``) + } +} + +/** A target with its bound in milliseconds or as a number. */ +type CheckedTarget = { readonly target: Target; readonly ms?: number; readonly number?: number } + +const parseTarget = ({ value, fail }: { readonly value: unknown; readonly fail: Fail }): CheckedTarget => { + if (isRecord(value) === false || typeof value.stat !== 'string') return fail('a target needs a `stat`') + only({ value, allowed: ['stat', 'within', 'max', 'minPercent', 'scope'], what: '`target`', fail }) + const stat = value.stat + const bounds = (['within', 'max', 'minPercent'] as const).filter((name) => value[name] !== undefined) + if (bounds.length !== 1) return fail(`target \`${stat}\` needs one bound: \`within\`, \`max\` or \`minPercent\``) + if (value.scope !== undefined && value.scope !== 'remote') { + return fail(`target \`${stat}\` has an unknown scope ${JSON.stringify(value.scope)}`) + } + const scope = value.scope === undefined ? {} : { scope: 'remote' as const } + const bound = bounds[0]! + const raw = value[bound] + if (bound === 'within') { + if (typeof raw !== 'string') return fail(`target \`${stat}\`'s \`within\` is not a duration`) + const ms = durationMs({ value: raw, fail }) + return { target: freeze({ stat, within: raw as Duration, ...scope }), ms } + } + if (typeof raw !== 'number' || Number.isFinite(raw) === false) { + return fail(`target \`${stat}\`'s \`${bound}\` is not a number`) + } + if (bound === 'minPercent') { + if ((raw > 0 && raw <= 100) === false) return fail(`target \`${stat}\`'s \`minPercent\` is not between 0 and 100`) + return { target: freeze({ stat, minPercent: raw, ...scope }), number: raw } + } + return { target: freeze({ stat, max: raw, ...scope }), number: raw } +} + +const parseAgent = ({ value, field, fail }: { readonly value: unknown; readonly field: string; readonly fail: Fail }): AgentTarget => { + if (isAgentRef(value)) return value + if (typeof value === 'string' && value.startsWith('agent/') && value.length > 'agent/'.length) { + return value as AgentTarget + } + return fail(`\`onBreach\`'s \`${field}\` is not an agent: "agent/..." or an agent.ts reference`) +} + +const sameAgent = (a: AgentTarget, b: AgentTarget): boolean => + typeof a === 'string' || typeof b === 'string' ? a === b : a.url === b.url + +const parseBreach = ({ value, fail }: { readonly value: unknown; readonly fail: Fail }): Fault => { + if (isRecord(value) === false || value.kind !== 'fault') return fail('`onBreach` is `fault`, the only response') + only({ value, allowed: ['kind', 'owner', 'backup', 'condition'], what: '`onBreach`', fail }) + const owner = parseAgent({ value: value.owner, field: 'owner', fail }) + const backup = value.backup === undefined ? undefined : parseAgent({ value: value.backup, field: 'backup', fail }) + if (backup !== undefined && sameAgent(owner, backup)) { + return fail("`onBreach`'s `backup` is an agent other than the owner") + } + if (typeof value.condition !== 'string' || validWord(value.condition) === false) { + return fail('`onBreach` needs `condition`, lowercase words joined by `-`') + } + return freeze({ kind: 'fault', owner, ...(backup === undefined ? {} : { backup }), condition: value.condition }) +} + +/** Each stat an indicator reports, and the bound it takes. */ +const statBound = ({ indicator, checked }: { readonly indicator: Indicator; readonly checked: CheckedTarget }): boolean => { + const { target, ms, number } = checked + const within = ms !== undefined + const max = ({ integer, zero }: { readonly integer: boolean; readonly zero: boolean }) => + 'max' in target && + number !== undefined && + (number > 0 || (zero && number === 0)) && + (integer === false || Number.isInteger(number)) + switch (indicator.kind) { + case 'latency': + return target.stat === 'p99' && within + case 'share': + return target.stat === 'live-share' && 'minPercent' in target + case 'statement': + return (target.stat === 'p99' || target.stat === 'max') && within + case 'transaction': + case 'writer-hold': + return target.stat === 'max' && within + case 'cpu': + return (target.stat === 'cores' || target.stat === 'idle-cores') && max({ integer: false, zero: false }) + case 'database': + return (target.stat === 'live-gb' || target.stat === 'growth-mb-per-day') && max({ integer: false, zero: false }) + case 'divergence': + return target.stat === 'open' && max({ integer: true, zero: true }) + case 'ci-count': + return target.stat === 'per-hour' && max({ integer: true, zero: true }) + case 'ci-duration': + case 'interval': + return percentile(target.stat) !== undefined && within + } +} + +const requiredStats: Readonly> = { + latency: ['p99'], + share: ['live-share'], + statement: ['p99', 'max'], + transaction: ['max'], + 'writer-hold': ['max'], + cpu: ['cores', 'idle-cores'], + database: ['live-gb', 'growth-mb-per-day'], + divergence: ['open'], + 'ci-duration': [], + 'ci-count': ['per-hour'], + interval: [], +} + +const checkTargets = ({ + indicator, + targets, + hasWindow, + hasBreach, + fail, +}: { + readonly indicator: Indicator + readonly targets: readonly CheckedTarget[] + readonly hasWindow: boolean + readonly hasBreach: boolean + readonly fail: Fail +}): void => { + const local = (stat: string) => targets.find(({ target }) => target.stat === stat && target.scope === undefined) + targets.forEach(({ target }, index) => { + if (targets.findIndex((other) => other.target.stat === target.stat && other.target.scope === target.scope) !== index) { + fail(`repeats target \`${target.stat}\``) + } + if (target.scope === 'remote' && indicator.kind !== 'latency') fail('only a latency target has a remote scope') + }) + const wrong = targets.find((checked) => statBound({ indicator, checked }) === false) + if (wrong !== undefined) fail(`a \`${indicator.kind}\` indicator has no target \`${wrong.target.stat}\` with that bound`) + const missing = requiredStats[indicator.kind].find((stat) => local(stat) === undefined) + if (missing !== undefined) fail(`a \`${indicator.kind}\` indicator needs target \`${missing}\``) + if (targets.length === 0) fail('has no `target`') + const remote = targets.find(({ target }) => target.stat === 'p99' && target.scope === 'remote') + if (remote !== undefined && remote.ms! < (local('p99')?.ms ?? 0)) fail('its remote latency is below its own') + if (indicator.kind === 'statement' && local('max')!.ms! < local('p99')!.ms!) fail('its max is below its p99') + if (indicator.kind === 'cpu' && local('idle-cores')!.number! > local('cores')!.number!) { + fail("its idle CPU target is above the member's") + } + if (indicator.kind === 'cpu' && indicator.idleMinMinutes > 60) { + fail('its idle minimum is not between 1 and 60 minutes') + } + if (indicator.kind === 'interval' && hasWindow === false) fail('an interval needs a `window`') + if (indicator.kind !== 'interval' && hasWindow) { + fail('only an interval takes a `window`; native telemetry keeps its own') + } + if (indicator.kind !== 'interval' && hasBreach) { + fail('only an interval takes `onBreach`; native objectives respond as they are built') + } +} + +/** + * Checks an objective by the native reader's rules and returns it frozen. A missing, unknown, + * repeated or nonsensical field throws a `TypeError`, never a default. `name` may be absent only + * where the subject tree derives it from the module's path. + */ +export const objective = (input: I): Objective & Pick => { + if (isRecord(input) === false) throw new TypeError('an objective is an object of fields') + const name: unknown = input.name + if (name !== undefined && (typeof name !== 'string' || validName(name) === false)) { + throw new TypeError('an objective has one name: lowercase words joined by `-`, segments by `/`') + } + const fail: Fail = (message) => { + throw new TypeError(name === undefined ? `objective: ${message}` : `objective \`${name}\`: ${message}`) + } + only({ value: input, allowed: ['name', 'about', 'indicator', 'window', 'targets', 'onBreach'], what: 'an objective', fail }) + if (input.about === undefined) fail('has no `about`') + if (typeof input.about !== 'string') fail('`about` needs one string') + if (input.about.trim() === '') fail('`about` is empty') + if (input.indicator === undefined) fail('has no `indicator`') + const indicator = parseIndicator({ value: input.indicator, fail }) + const window: unknown = input.window + if (window !== undefined) { + if (isRecord(window) === false) fail('`window` needs a `span`') + only({ value: window as Fields, allowed: ['span', 'minSamples'], what: '`window`', fail }) + durationMs({ value: input.window!.span, fail }) + const minSamples: unknown = input.window!.minSamples + if (minSamples !== undefined && (typeof minSamples !== 'number' || Number.isSafeInteger(minSamples) === false || minSamples <= 0)) { + fail('minSamples is not a positive integer') + } + } + if (Array.isArray(input.targets) === false) fail('has no `target`') + const targets = input.targets.map((value) => parseTarget({ value, fail })) + const onBreach = input.onBreach === undefined ? undefined : parseBreach({ value: input.onBreach, fail }) + checkTargets({ indicator, targets, hasWindow: window !== undefined, hasBreach: onBreach !== undefined, fail }) + return freeze({ + ...(name === undefined ? {} : { name: name as string }), + about: input.about, + indicator, + ...(input.window === undefined + ? {} + : { + window: freeze({ + span: input.window.span, + ...(input.window.minSamples === undefined ? {} : { minSamples: input.window.minSamples }), + }), + }), + targets: freeze(targets.map(({ target }) => target)), + ...(onBreach === undefined ? {} : { onBreach }), + }) +} + +const agentSubject = ({ agent, name }: { readonly agent: AgentTarget; readonly name: string }): string => { + if (typeof agent === 'string') return agent + throw new TypeError( + `objective \`${name}\`: an agent reference lowers through loadSubjectTree, which resolves its path`, + ) +} + +const intervalChildren = (interval: Interval): Node[] => { + const at = (e: ClaimEvent): Record => (e.at === undefined ? {} : { at: e.at }) + return [ + node({ name: 'subject-prefix', args: [interval.subjectPrefix] }), + node({ name: 'start', args: [interval.start.kind], props: at(interval.start) }), + ...(interval.expect === undefined + ? [] + : [node({ name: 'expect', args: [interval.expect.kind], props: { ...at(interval.expect), set: interval.expect.set } })]), + node({ + name: 'stop', + args: [interval.stop.kind], + props: { ...at(interval.stop), ...(interval.stop.member === undefined ? {} : { member: interval.stop.member }) }, + }), + ...(interval.cancel === undefined ? [] : [node({ name: 'cancel', args: [interval.cancel] })]), + ...(interval.pause === undefined + ? [] + : [node({ name: 'pause', args: [interval.pause.kind], props: { resume: interval.pause.resume } })]), + ...(interval.labels ?? []).map((label) => node({ name: 'label', args: [label] })), + ...(interval.exclude ?? []).map(({ field, values }) => node({ name: 'exclude', args: [field, ...values] })), + ] +} + +const indicatorChildren = (indicator: Indicator): Node[] => { + switch (indicator.kind) { + case 'latency': + return indicator.paths.map((path) => node({ name: 'path', args: [path] })) + case 'share': + return [node({ name: 'path', args: [indicator.path] })] + case 'cpu': + return [ + node({ name: 'idle-min-minutes', args: [indicator.idleMinMinutes] }), + node({ name: 'load-max-cores', args: [indicator.loadMaxCores] }), + ] + case 'ci-duration': + case 'ci-count': + return [node({ name: 'series', args: [indicator.series] })] + case 'interval': + return intervalChildren(indicator) + case 'statement': + case 'transaction': + case 'writer-hold': + case 'database': + case 'divergence': + return [] + } +} + +const targetProps = (target: Target): Record => ({ + ...(target.within === undefined ? {} : { within: target.within }), + ...(target.max === undefined ? {} : { max: target.max }), + ...(target.minPercent === undefined ? {} : { 'min-percent': target.minPercent }), + ...(target.scope === undefined ? {} : { scope: target.scope }), +}) + +/** Checks a named objective and lowers it to its `objective` KDL node. */ +export const objectiveNode = (input: ObjectiveInput & { readonly name: string }): Node => { + const o = objective(input) + const name = o.name + if (name === undefined) throw new TypeError('objective: lowering needs a `name`') + const kids = indicatorChildren(o.indicator) + return node({ + name: 'objective', + args: [name], + children: [ + node({ name: 'about', args: [o.about] }), + node({ name: 'indicator', args: [o.indicator.kind], ...(kids.length === 0 ? {} : { children: kids }) }), + ...(o.window === undefined + ? [] + : [ + node({ + name: 'window', + args: [o.window.span], + props: o.window.minSamples === undefined ? {} : { 'min-samples': o.window.minSamples }, + }), + ]), + ...o.targets.map((target) => node({ name: 'target', args: [target.stat], props: targetProps(target) })), + ...(o.onBreach === undefined + ? [] + : [ + node({ + name: 'on-breach', + args: ['fault'], + props: { + owner: agentSubject({ agent: o.onBreach.owner, name }), + ...(o.onBreach.backup === undefined ? {} : { backup: agentSubject({ agent: o.onBreach.backup, name }) }), + condition: o.onBreach.condition, + }, + }), + ]), + ], + }) +} diff --git a/packages/fractal/src/objective.unit.test.ts b/packages/fractal/src/objective.unit.test.ts new file mode 100644 index 00000000000..4cc770ef54a --- /dev/null +++ b/packages/fractal/src/objective.unit.test.ts @@ -0,0 +1,416 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { NodeFileSystem } from '@effect/platform-node' +import { describe, expect, it } from '@effect/vitest' +import { Effect } from 'effect' + +import { Fractal } from './index.ts' +import { emit, type KdlFileOutput } from './kdl.ts' +import { objectiveNode, type Objective, type ObjectiveInput } from './objective.ts' +import { loadSubjectTree } from './tree.ts' + +/** + * The Genie source lies outside this package's ESM `tsconfig` scope, so it is imported at runtime; + * its exports are the `fractalKdl` output and the checked objectives it lowers. + */ +const genie = (await import(new URL('../../../slo/objectives.kdl.genie.ts', import.meta.url).href)) as { + readonly default: KdlFileOutput + readonly objectives: readonly (Objective & { readonly name: string })[] +} +const builtins = genie.default +const objectives = genie.objectives + +const render = (input: ObjectiveInput & { readonly name: string }) => emit([objectiveNode(input)]) + +const rollout = { + name: 'ops/change-rollout', + about: 'A change, from its author marking it ready to every member that serves it', + indicator: Fractal.indicator.interval({ + subjectPrefix: 'custom/acme/change/', + start: { kind: 'custom.acme.change-ready', at: 'ready_at' }, + expect: { kind: 'custom.acme.change-merged', set: 'hosts', at: 'merged_at' }, + stop: { kind: 'custom.acme.change-applied', member: 'host', at: 'applied_at' }, + cancel: 'custom.acme.change-abandoned', + pause: { kind: 'custom.acme.change-held', resume: 'custom.acme.change-released' }, + labels: ['class'], + exclude: [{ field: 'exclusion', values: ['deferred-launch', 'deferred-runtime'] }], + }), + window: { span: '7d', minSamples: 20 }, + targets: [ + { stat: 'p50', within: '60s' }, + { stat: 'p95', within: '5m' }, + ], + onBreach: Fractal.response.fault({ + owner: 'agent/ops/release-manager', + backup: 'agent/ops/observability', + condition: 'change-rollout-slo-missed', + }), +} satisfies ObjectiveInput + +describe('Fractal.objective lowering', () => { + it('lowers an interval over claims as docs/st3/objectives.md declares it', () => { + expect(render(rollout)).toBe(`version 2 +objective "ops/change-rollout" { + about "A change, from its author marking it ready to every member that serves it" + indicator "interval" { + subject-prefix "custom/acme/change/" + start "custom.acme.change-ready" at="ready_at" + expect "custom.acme.change-merged" at="merged_at" set="hosts" + stop "custom.acme.change-applied" at="applied_at" member="host" + cancel "custom.acme.change-abandoned" + pause "custom.acme.change-held" resume="custom.acme.change-released" + label "class" + exclude "exclusion" "deferred-launch" "deferred-runtime" + } + window "7d" min-samples=20 + target "p50" within="60s" + target "p95" within="5m" + on-breach "fault" backup="agent/ops/observability" condition="change-rollout-slo-missed" owner="agent/ops/release-manager" +} +`) + }) + + it('lowers a minimal interval: no expect, accepted times, no window minimum or response', () => { + expect( + render({ + name: 'ops/build', + about: 'A build', + indicator: Fractal.indicator.interval({ + subjectPrefix: 'custom/acme/build/', + start: { kind: 'custom.acme.build-started' }, + stop: { kind: 'custom.acme.build-finished' }, + }), + window: { span: '1d' }, + targets: [{ stat: 'p99', within: '30m' }], + }), + ).toBe(`version 2 +objective "ops/build" { + about "A build" + indicator "interval" { + subject-prefix "custom/acme/build/" + start "custom.acme.build-started" + stop "custom.acme.build-finished" + } + window "1d" + target "p99" within="30m" +} +`) + }) + + const native: ReadonlyArray<{ + readonly indicator: ObjectiveInput['indicator'] + readonly targets: ObjectiveInput['targets'] + readonly kdl: string + }> = [ + { + indicator: Fractal.indicator.latency({ paths: ['GET /v1/client/agents', 'stream agents'] }), + targets: [ + { stat: 'p99', within: '300ms' }, + { stat: 'p99', within: '500ms', scope: 'remote' }, + ], + kdl: ' indicator "latency" {\n path "GET /v1/client/agents"\n path "stream agents"\n }\n target "p99" within="300ms"\n target "p99" scope="remote" within="500ms"\n', + }, + { + indicator: Fractal.indicator.share({ path: 'client/ios/live-share' }), + targets: [{ stat: 'live-share', minPercent: 99.5 }], + kdl: ' indicator "share" {\n path "client/ios/live-share"\n }\n target "live-share" min-percent=99.5\n', + }, + { + indicator: Fractal.indicator.statement(), + targets: [ + { stat: 'p99', within: '10ms' }, + { stat: 'max', within: '100ms' }, + ], + kdl: ' indicator "statement"\n target "p99" within="10ms"\n target "max" within="100ms"\n', + }, + { + indicator: Fractal.indicator.transaction(), + targets: [{ stat: 'max', within: '300ms' }], + kdl: ' indicator "transaction"\n target "max" within="300ms"\n', + }, + { + indicator: Fractal.indicator.writerHold(), + targets: [{ stat: 'max', within: '300ms' }], + kdl: ' indicator "writer-hold"\n target "max" within="300ms"\n', + }, + { + indicator: Fractal.indicator.cpu({ idleMinMinutes: 10, loadMaxCores: 2.5 }), + targets: [ + { stat: 'cores', max: 1 }, + { stat: 'idle-cores', max: 0.5 }, + ], + kdl: ' indicator "cpu" {\n idle-min-minutes 10\n load-max-cores 2.5\n }\n target "cores" max=1\n target "idle-cores" max=0.5\n', + }, + { + indicator: Fractal.indicator.database(), + targets: [ + { stat: 'live-gb', max: 8 }, + { stat: 'growth-mb-per-day', max: 300 }, + ], + kdl: ' indicator "database"\n target "live-gb" max=8\n target "growth-mb-per-day" max=300\n', + }, + { + indicator: Fractal.indicator.divergence(), + targets: [{ stat: 'open', max: 0 }], + kdl: ' indicator "divergence"\n target "open" max=0\n', + }, + { + indicator: Fractal.indicator.ciDuration({ series: 'ci/merge-queue' }), + targets: [{ stat: 'p50', within: '10m' }], + kdl: ' indicator "ci-duration" {\n series "ci/merge-queue"\n }\n target "p50" within="10m"\n', + }, + { + indicator: Fractal.indicator.ciCount({ series: 'ci/flakes' }), + targets: [{ stat: 'per-hour', max: 0 }], + kdl: ' indicator "ci-count" {\n series "ci/flakes"\n }\n target "per-hour" max=0\n', + }, + ] + it.each(native)('lowers indicator $indicator.kind', ({ indicator, targets, kdl }) => { + expect(render({ name: 'shipped', about: 'Measured', indicator, targets })).toBe( + `version 2\nobjective "shipped" {\n about "Measured"\n${kdl}}\n`, + ) + }) + + it('returns frozen, checked values and keeps an unnamed objective for the subject tree', () => { + const { name: _, ...unnamed } = rollout + const value = Fractal.objective(unnamed) + expect(Object.isFrozen(value)).toBe(true) + expect(value.name).toBeUndefined() + expect(() => objectiveNode(value as ObjectiveInput & { name: string })).toThrow(/needs a `name`/) + }) +}) + +describe('Fractal.objective validation', () => { + const base = rollout as ObjectiveInput + const interval = rollout.indicator + const latency = Fractal.indicator.latency({ paths: ['GET /v1/x'] }) + const cases: ReadonlyArray<{ readonly name: string; readonly input: unknown; readonly message: RegExp }> = [ + { name: 'a malformed name', input: { ...base, name: 'Ops/x' }, message: /lowercase words joined by `-`/ }, + { name: 'a name ending in a hyphen', input: { ...base, name: 'ops/x-' }, message: /lowercase words/ }, + { name: 'an unknown field', input: { ...base, owner: 'x' }, message: /unknown field `owner`/ }, + { name: 'no about', input: { ...base, about: undefined }, message: /has no `about`/ }, + { name: 'an empty about', input: { ...base, about: ' ' }, message: /`about` is empty/ }, + { name: 'an unknown indicator', input: { ...base, indicator: { kind: 'ratio' } }, message: /unknown indicator `ratio`/ }, + { name: 'an empty latency', input: { name: 'x', about: 'a', indicator: { kind: 'latency', paths: [] }, targets: [{ stat: 'p99', within: '1s' }] }, message: /needs a `path`/ }, + { name: 'an extra indicator field', input: { name: 'x', about: 'a', indicator: { kind: 'statement', path: 'p' }, targets: [] }, message: /indicator `statement` has an unknown field `path`/ }, + { name: 'a fractional idle minimum', input: { name: 'cpu', about: 'a', indicator: { kind: 'cpu', idleMinMinutes: 1.5, loadMaxCores: 1 }, targets: [] }, message: /idleMinMinutes is not a positive integer/ }, + { name: 'an idle minimum over an hour', input: { name: 'cpu', about: 'a', indicator: Fractal.indicator.cpu({ idleMinMinutes: 61, loadMaxCores: 1 }), targets: [{ stat: 'cores', max: 1 }, { stat: 'idle-cores', max: 0.5 }] }, message: /idle minimum is not between 1 and 60/ }, + { name: 'idle cores above cores', input: { name: 'cpu', about: 'a', indicator: Fractal.indicator.cpu({ idleMinMinutes: 10, loadMaxCores: 1 }), targets: [{ stat: 'cores', max: 1 }, { stat: 'idle-cores', max: 2 }] }, message: /idle CPU target is above/ }, + { name: 'a non-CI series', input: { name: 'x', about: 'a', indicator: { kind: 'ci-count', series: 'merge-queue' }, targets: [] }, message: /is not `ci\/` and lowercase words/ }, + { name: 'a relative subject prefix', input: { ...base, indicator: { ...interval, subjectPrefix: '/custom/' } }, message: /must name subjects and end with `\/`/ }, + { name: 'a prefix without a slash', input: { ...base, indicator: { ...interval, subjectPrefix: 'custom' } }, message: /must name subjects/ }, + { name: 'no stop', input: { ...base, indicator: { ...interval, stop: undefined } }, message: /an interval needs `stop`/ }, + { name: 'expect without stop member', input: { ...base, indicator: { ...interval, stop: { kind: 'custom.acme.change-applied' } } }, message: /with `expect`, `stop` needs `member`/ }, + { name: 'a label "member" without stop member', input: { ...base, indicator: { ...interval, expect: undefined, stop: { kind: 'k' }, labels: ['member'] } }, message: /`label "member"` needs `stop.member`/ }, + { name: 'a time field that is not a field', input: { ...base, indicator: { ...interval, start: { kind: 'k', at: 'ready at' } } }, message: /`at` is not a field name/ }, + { name: 'a repeated label', input: { ...base, indicator: { ...interval, labels: ['class', 'class'] } }, message: /label "class" is not a field, or is repeated/ }, + { name: 'an exclude without values', input: { ...base, indicator: { ...interval, exclude: [{ field: 'exclusion', values: [] }] } }, message: /needs a field and at least one value/ }, + { name: 'a pause without resume', input: { ...base, indicator: { ...interval, pause: { kind: 'held', resume: '' } } }, message: /`pause` needs `resume`/ }, + { name: 'a claim kind in two roles', input: { ...base, indicator: { ...interval, cancel: 'custom.acme.change-ready' } }, message: /claim kind "custom.acme.change-ready" has two roles/ }, + { name: 'an interval without a window', input: { ...base, window: undefined }, message: /an interval needs a `window`/ }, + { name: 'a window on native telemetry', input: { name: 'x', about: 'a', indicator: latency, window: { span: '1d' }, targets: [{ stat: 'p99', within: '1s' }] }, message: /only an interval takes a `window`/ }, + { name: 'a zero window', input: { ...base, window: { span: '0d' } }, message: /must be positive/ }, + { name: 'a malformed duration', input: { ...base, targets: [{ stat: 'p95', within: '5 minutes' }] }, message: /invalid duration `5 minutes`/ }, + { name: 'a zero minimum sample count', input: { ...base, window: { span: '1d', minSamples: 0 } }, message: /minSamples is not a positive integer/ }, + { name: 'two bounds', input: { ...base, targets: [{ stat: 'p95', within: '5m', max: 1 }] }, message: /needs one bound/ }, + { name: 'no target', input: { ...base, targets: [] }, message: /has no `target`/ }, + { name: 'a repeated target', input: { ...base, targets: [{ stat: 'p95', within: '5m' }, { stat: 'p95', within: '6m' }] }, message: /repeats target `p95`/ }, + { name: 'a non-percentile interval stat', input: { ...base, targets: [{ stat: 'p100', within: '5m' }] }, message: /no target `p100` with that bound/ }, + { name: 'a remote interval target', input: { ...base, targets: [{ stat: 'p95', within: '5m', scope: 'remote' }] }, message: /only a latency target has a remote scope/ }, + { name: 'an unknown scope', input: { ...base, targets: [{ stat: 'p95', within: '5m', scope: 'local' }] }, message: /unknown scope "local"/ }, + { name: 'a missing required stat', input: { name: 'sql', about: 'a', indicator: Fractal.indicator.statement(), targets: [{ stat: 'p99', within: '10ms' }] }, message: /needs target `max`/ }, + { name: 'a statement max below p99', input: { name: 'sql', about: 'a', indicator: Fractal.indicator.statement(), targets: [{ stat: 'p99', within: '10ms' }, { stat: 'max', within: '5ms' }] }, message: /max is below its p99/ }, + { name: 'a remote latency below local', input: { name: 'x', about: 'a', indicator: latency, targets: [{ stat: 'p99', within: '1s' }, { stat: 'p99', within: '500ms', scope: 'remote' }] }, message: /remote latency is below its own/ }, + { name: 'a fractional divergence max', input: { name: 'x', about: 'a', indicator: Fractal.indicator.divergence(), targets: [{ stat: 'open', max: 0.5 }] }, message: /no target `open` with that bound/ }, + { name: 'a min-percent over 100', input: { name: 'x', about: 'a', indicator: Fractal.indicator.share({ path: 'p' }), targets: [{ stat: 'live-share', minPercent: 101 }] }, message: /is not between 0 and 100/ }, + { name: 'a response on native telemetry', input: { name: 'x', about: 'a', indicator: latency, targets: [{ stat: 'p99', within: '1s' }], onBreach: rollout.onBreach }, message: /only an interval takes `onBreach`/ }, + { name: 'an owner that is not an agent', input: { ...base, onBreach: { ...rollout.onBreach, owner: 'person/ada' } }, message: /`owner` is not an agent/ }, + { name: 'a backup equal to the owner', input: { ...base, onBreach: { ...rollout.onBreach, backup: rollout.onBreach.owner } }, message: /`backup` is an agent other than the owner/ }, + { name: 'a malformed condition', input: { ...base, onBreach: { ...rollout.onBreach, condition: 'Missed' } }, message: /needs `condition`/ }, + { name: 'a response other than fault', input: { ...base, onBreach: { ...rollout.onBreach, kind: 'page' } }, message: /`onBreach` is `fault`/ }, + ] + it.each(cases)('refuses $name', ({ input, message }) => { + expect(() => Fractal.objective(input as ObjectiveInput)).toThrow(message) + }) + + it('names the objective in its errors', () => { + expect(() => Fractal.objective({ ...base, targets: [] })).toThrow( + 'objective `ops/change-rollout`: has no `target`', + ) + }) +}) + +describe('the objectives Fractal ships', () => { + it('render slo/objectives.kdl from slo/objectives.kdl.genie.ts', () => { + const committed = readFileSync(new URL('../../../slo/objectives.kdl', import.meta.url), 'utf8') + expect(committed).toBe( + `// Generated file - DO NOT EDIT\n// Source: objectives.kdl.genie.ts\n${builtins.stringify()}`, + ) + expect(builtins.validate()).toEqual([]) + }) + + it('are the 21 native objectives, one segment each, so none is declarable', () => { + expect(objectives.map(({ name }) => name)).toEqual([ + 'person-read', + 'person-read-fresh', + 'projection-fresh', + 'write-ack', + 'terminal-attach', + 'sql-statement', + 'transaction', + 'writer-hold', + 'cpu', + 'ios-open-to-live', + 'ios-connect', + 'ios-message-ack', + 'ios-conversation-open', + 'ios-terminal-open', + 'ios-recover', + 'ios-live-share', + 'projection-divergence', + 'database', + 'merge-latency', + 'ci-critical-path', + 'flakes', + ]) + expect(objectives.some(({ indicator, name }) => indicator.kind === 'interval' || name.includes('/'))).toBe( + false, + ) + }) +}) + +const treeModule = new URL('./tree.ts', import.meta.url).href +const indexModule = new URL('./index.ts', import.meta.url).href + +const fixture = (files: Readonly>): string => { + const root = mkdtempSync(join(tmpdir(), 'fractal-objective-')) + for (const [path, text] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), text) + } + return root +} + +const load = (root: string) => + loadSubjectTree({ root, context: { prefix: 'custom/acme/change/' } }).pipe(Effect.provide(NodeFileSystem.layer)) + +const declared = `{ + about: 'A change, from ready to applied everywhere', + indicator: Fractal.indicator.interval({ + subjectPrefix: 'custom/acme/change/', + start: { kind: 'custom.acme.change-ready' }, + stop: { kind: 'custom.acme.change-applied' }, + }), + window: { span: '7d' }, + targets: [{ stat: 'p95', within: '5m' }], +}` + +const header = `import { Fractal } from '${indexModule}'\nimport { defineAgent, defineObjective } from '${treeModule}'\n` + +describe('objective subject trees', () => { + it.effect('declare objective/ from objective.ts and objective.kdl', () => + Effect.gen(function* () { + const kdl = render({ ...rollout, name: 'ops/kdl' }) + const root = fixture({ + 'ops/release-manager/agent.ts': `${header}export default defineAgent({ meta: import.meta, intent: { workspace: '/srv/ops' } })`, + 'ops/change-rollout/objective.ts': `${header}import owner from '../release-manager/agent.ts' +export default Fractal.objective({ ...${declared}, onBreach: Fractal.response.fault({ owner, backup: 'agent/ops/observability', condition: 'change-rollout-slo-missed' }) })`, + 'ops/factory/objective.ts': `${header}export default defineObjective({ meta: import.meta, intent: (ctx: { prefix: string }) => ({ ...${declared}, name: 'ops/factory', indicator: Fractal.indicator.interval({ subjectPrefix: ctx.prefix, start: { kind: 'a' }, stop: { kind: 'b' } }) }) })`, + 'ops/kdl/objective.kdl': kdl, + }) + const subjects = (yield* load(root)).filter(({ kind }) => kind === 'objective') + expect(subjects.map(({ id, path, declaration }) => ({ id, path, tag: declaration._tag }))).toEqual([ + { id: 'ops/change-rollout', path: 'ops/change-rollout/objective.ts', tag: 'Module' }, + { id: 'ops/factory', path: 'ops/factory/objective.ts', tag: 'Module' }, + { id: 'ops/kdl', path: 'ops/kdl/objective.kdl', tag: 'Kdl' }, + ]) + const [rolloutSubject, factory, kdlSubject] = subjects + if (rolloutSubject?.kind !== 'objective' || rolloutSubject.declaration._tag !== 'Module') { + return yield* Effect.die('objective.ts did not load') + } + expect(rolloutSubject.declaration.intent.onBreach).toEqual({ + kind: 'fault', + owner: 'agent/ops/release-manager', + backup: 'agent/ops/observability', + condition: 'change-rollout-slo-missed', + }) + expect(emit([objectiveNode({ ...rolloutSubject.declaration.intent, name: rolloutSubject.id })])).toContain( + 'on-breach "fault" backup="agent/ops/observability" condition="change-rollout-slo-missed" owner="agent/ops/release-manager"', + ) + expect(factory?.declaration).toMatchObject({ + _tag: 'Module', + intent: { name: 'ops/factory', indicator: { subjectPrefix: 'custom/acme/change/' } }, + }) + expect(kdlSubject?.declaration).toEqual({ _tag: 'Kdl', text: kdl }) + }), + ) + + const rejections: ReadonlyArray<{ + readonly name: string + readonly files: Readonly> + readonly message: string + }> = [ + { + name: 'a name other than the path', + files: { 'ops/x/objective.ts': `${header}export default Fractal.objective({ ...${declared}, name: 'ops/y' })` }, + message: 'Declared name ops/y must equal the subject directory ops/x', + }, + { + name: 'native telemetry', + files: { + 'ops/x/objective.ts': `${header}export default Fractal.objective({ about: 'a', indicator: Fractal.indicator.statement(), targets: [{ stat: 'p99', within: '10ms' }, { stat: 'max', within: '100ms' }] })`, + }, + message: 'A declared objective is an interval over claims named NAMESPACE/NAME; objective/ops/x is not', + }, + { + name: 'a one-segment name', + files: { 'x/objective.ts': `${header}export default ${declared}` }, + message: 'objective/x is not', + }, + { + name: 'a path that is not an objective name', + files: { 'ops/Upper/objective.ts': `${header}export default ${declared}` }, + message: 'Invalid objective declaration', + }, + { + name: 'an invalid plain intent', + files: { 'ops/x/objective.ts': `${header}export default { ...${declared}, window: undefined }` }, + message: 'Invalid objective declaration', + }, + { + name: 'an owner reference to a mission', + files: { + 'ops/x/objective.ts': `${header}export default Fractal.objective({ ...${declared}, onBreach: { kind: 'fault', owner: { _tag: 'SubjectRef', kind: 'mission', url: new URL('../m/mission.ts', import.meta.url).href }, condition: 'missed' } })`, + }, + message: 'is not an agent', + }, + { + name: 'a KDL objective over native telemetry', + files: { + 'ops/x/objective.kdl': 'version 2\nobjective "ops/x" {\n about "a"\n indicator "transaction"\n target "max" within="1s"\n}\n', + }, + message: 'objective/ops/x is not', + }, + { + name: 'a KDL objective under another name', + files: { 'ops/x/objective.kdl': 'version 2\nobjective "ops/y" {\n}\n' }, + message: 'exactly one `objective "ops/x"` node', + }, + { + name: 'a KDL objective under a path that is not an objective name', + files: { + 'ops/Upper/objective.kdl': 'version 2\nobjective "ops/Upper" {\n indicator "interval"\n}\n', + }, + message: 'objective/ops/Upper is not', + }, + ] + it.effect.each(rejections)('rejects $name', ({ files, message }) => + Effect.gen(function* () { + const error = yield* load(fixture(files)).pipe(Effect.flip) + const cause = error.cause instanceof Error ? ` ${error.cause.message}` : '' + expect(`${error.message}${cause}`).toContain(message) + }), + ) +}) diff --git a/packages/fractal/src/subject-ref.ts b/packages/fractal/src/subject-ref.ts index 833363c141d..6a9bcb74c4b 100644 --- a/packages/fractal/src/subject-ref.ts +++ b/packages/fractal/src/subject-ref.ts @@ -1,5 +1,5 @@ /** The declaration kind; a schedule is a mission carrying native timing. */ -export type SubjectKind = 'agent' | 'mission' | 'schedule' +export type SubjectKind = 'agent' | 'mission' | 'schedule' | 'objective' /** An imported source module identity, resolved against the subject tree at publication. */ export interface SubjectRef { diff --git a/packages/fractal/src/tree.ts b/packages/fractal/src/tree.ts index 840ec041708..ea3f73b871f 100644 --- a/packages/fractal/src/tree.ts +++ b/packages/fractal/src/tree.ts @@ -7,16 +7,19 @@ import { parse } from '@overeng/kdl' import { AgentSchema, MissionSchema, missionWire, type MissionInput, SubjectId } from './mod.ts' import type { PinnedMissionRef } from './define.ts' +import { objective, validName, type Objective, type ObjectiveInput } from './objective.ts' import type { SubjectKind, SubjectRef } from './subject-ref.ts' export { defineAgent, defineMission, defineSchedule, + defineObjective, pinMission, type AgentIntent, type MissionIntent, type PinnedMissionRef, type ScheduleIntent, + type ObjectiveIntent, } from './define.ts' export type { SubjectKind, @@ -55,14 +58,27 @@ export type Subject = readonly path: string readonly declaration: Declaration } + | { + readonly kind: 'objective' + readonly id: SubjectId + readonly path: string + readonly declaration: Declaration + } + +const declarationFile = /^(agent|mission|schedule|objective)\.(ts|kdl)$/u -const declarationFile = /^(agent|mission|schedule)\.(ts|kdl)$/u +/** The subject family of a kind: missions and schedules share `mission/`. */ +const family = (kind: SubjectKind) => (kind === 'schedule' ? 'mission' : kind) -const scheduleMismatch: Record, string> = { +const scheduleMismatch: Record<'mission' | 'schedule', string> = { mission: 'A mission with a schedule must be declared in schedule.ts or schedule.kdl', schedule: 'A schedule file must declare a mission with a schedule', } +/** A fleet declares only interval objectives named `NAMESPACE/NAME`; Fractal ships the rest. */ +const declaredObjective = (id: string) => + `A declared objective is an interval over claims named NAMESPACE/NAME; objective/${id} is not` + const isRef = (value: unknown): value is SubjectRef => typeof value === 'object' && value !== null && '_tag' in value && value._tag === 'SubjectRef' @@ -302,6 +318,7 @@ const loadModule = Effect.fnUntraced(function* ({ try: (): unknown => (typeof source === 'function' ? source(context) : source), catch: (cause) => new SubjectTreeError({ path, message: 'Subject factory failed', cause }), }) + if (kind === 'objective') return yield* loadObjective({ root, path, id, intent, exports }) // Definitions take their ID from the path; an explicit `id` in the intent must still agree. const identified = definition !== undefined && typeof intent === 'object' && intent !== null @@ -354,6 +371,64 @@ const loadModule = Effect.fnUntraced(function* ({ return subject }) +/** + * Checks an objective module's intent: its `name`, if given, is the path ID; `onBreach` agent + * references resolve to `agent/`; and it is an interval over claims named `NAMESPACE/NAME`, + * the only objective a fleet declares (the native telemetry ones ship with Fractal). + */ +const loadObjective = Effect.fnUntraced(function* ({ + root, + path, + id, + intent, + exports, +}: { + readonly root: string + readonly path: string + readonly id: SubjectId + readonly intent: unknown + readonly exports: Readonly> +}) { + if (typeof intent !== 'object' || intent === null) { + return yield* new SubjectTreeError({ path, message: 'An objective module exports an objective' }) + } + if ('name' in intent && intent.name !== undefined && intent.name !== id) { + return yield* new SubjectTreeError({ + path, + message: `Declared name ${String(intent.name)} must equal the subject directory ${id}`, + }) + } + const agent = (value: unknown) => + isRef(value) === true + ? resolveRef({ root, path, ref: value, expected: 'agent' }).pipe(Effect.map((agentId) => `agent/${agentId}`)) + : Effect.succeed(value) + const breach = 'onBreach' in intent ? intent.onBreach : undefined + const onBreach = + typeof breach === 'object' && breach !== null + ? { + ...breach, + ...('owner' in breach ? { owner: yield* agent(breach.owner) } : {}), + ...('backup' in breach && breach.backup !== undefined ? { backup: yield* agent(breach.backup) } : {}), + } + : breach + const named: unknown = { ...intent, ...(onBreach === undefined ? {} : { onBreach }), name: id } + const value = yield* Effect.try({ + // The intent is untrusted module data; `objective` checks every field, unknown ones included. + try: () => objective(named as ObjectiveInput), + catch: (cause) => new SubjectTreeError({ path, message: 'Invalid objective declaration', cause }), + }) + if (value.indicator.kind !== 'interval' || id.includes('/') === false) { + return yield* new SubjectTreeError({ path, message: declaredObjective(id) }) + } + const subject: Subject = { + kind: 'objective', + id, + path, + declaration: { _tag: 'Module', intent: value, exports }, + } + return subject +}) + const loadKdl = Effect.fnUntraced(function* ({ fs, file, @@ -378,7 +453,7 @@ const loadKdl = Effect.fnUntraced(function* ({ try: () => parse(text).nodes, catch: (cause) => new SubjectTreeError({ path, message: 'Invalid KDL declaration', cause }), }) - const name = kind === 'agent' ? 'agent' : 'mission' + const name = family(kind) const [version, declaration] = nodes if ( nodes.length !== 2 || @@ -393,23 +468,32 @@ const loadKdl = Effect.fnUntraced(function* ({ }) } const scheduled = declaration.children?.findNodeByName('schedule') !== undefined - if (kind !== 'agent' && scheduled !== (kind === 'schedule')) { + if ((kind === 'mission' || kind === 'schedule') && scheduled !== (kind === 'schedule')) { return yield* new SubjectTreeError({ path, message: scheduleMismatch[kind] }) } + if ( + kind === 'objective' && + (declaration.children?.findNodeByName('indicator')?.getArgument(0) !== 'interval' || + id.includes('/') === false || + validName(id) === false) + ) { + return yield* new SubjectTreeError({ path, message: declaredObjective(id) }) + } const subject: Subject = { kind, id, path, declaration: { _tag: 'Kdl', text } } return subject }) /** - * Loads `//{agent,mission,schedule}.{ts,kdl}` declarations in path order. + * Loads `//{agent,mission,schedule,objective}.{ts,kdl}` declarations in path order. * * The ID is the POSIX directory path of the file's realpath below `root`'s realpath, so symlinked * aliases load once and links leaving the tree are rejected. Seats use the `agent/` namespace; missions - * and schedules share `mission/`, so one directory declares at most one of them. KDL files must - * hold `version 2` and one node whose kind and ID match the path. A TS module's default export is a - * `define*` definition for its own path and kind, a plain intent, or a factory; factories receive - * `context`. Typed references are lowered, then intents are decoded with `AgentSchema` or - * `MissionSchema`. Subject modules are trusted code and are imported. + * and schedules share `mission/`, so one directory declares at most one of them; objectives use + * `objective/`. KDL files must hold `version 2` and one node whose kind and ID match the path. A + * TS module's default export is a `define*` definition for its own path and kind, a plain intent, or + * a factory; factories receive `context`. Typed references are lowered, then intents are decoded + * with `AgentSchema` or `MissionSchema`, or checked by `Fractal.objective`. A declared objective is an + * interval over claims. Subject modules are trusted code and are imported. */ export const loadSubjectTree = Effect.fn('fractal.loadSubjectTree')(function* ({ root, @@ -452,7 +536,7 @@ export const loadSubjectTree = Effect.fn('fractal.loadSubjectTree')(function* Date: Mon, 9 Nov 2026 19:26:31 +0100 Subject: [PATCH 056/132] Objectives: one strict reader for the objective kind, and the shipped objectives as KDL fractal_kernel::objective reads an `objective` declaration (indicator, targets, window, on-breach) from its canonical JSON or a KDL document, refusing anything unknown or nonsensical. slo/objectives.kdl states the 21 objectives slo/targets.toml declares. canonical_node, json_value and parse_duration move to fractal_kernel::declaration_kdl so the kernel can read KDL; their callers re-export them. docs/st3/objectives.md is the design. --- Cargo.lock | 1 + crates/fractal-kernel/Cargo.toml | 1 + crates/fractal-kernel/src/declaration_kdl.rs | 109 +++ crates/fractal-kernel/src/lib.rs | 4 + crates/fractal-kernel/src/objective.rs | 732 ++++++++++++++++++ crates/fractal-kernel/src/objective_tests.rs | 94 +++ .../src/declaration_syntax.rs | 51 +- crates/fractal-store/src/graph.rs | 51 +- docs/st3/objectives.md | 164 ++++ slo/objectives.kdl | 235 ++++++ 10 files changed, 1343 insertions(+), 99 deletions(-) create mode 100644 crates/fractal-kernel/src/declaration_kdl.rs create mode 100644 crates/fractal-kernel/src/objective.rs create mode 100644 crates/fractal-kernel/src/objective_tests.rs create mode 100644 docs/st3/objectives.md create mode 100644 slo/objectives.kdl diff --git a/Cargo.lock b/Cargo.lock index d8927e4bd48..fc3d79e12a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2153,6 +2153,7 @@ dependencies = [ "fractal-terminal-direct", "getrandom 0.3.4", "hex", + "kdl", "libc", "reqwest", "rusqlite", diff --git a/crates/fractal-kernel/Cargo.toml b/crates/fractal-kernel/Cargo.toml index cd590be037f..a6b9d0c94bf 100644 --- a/crates/fractal-kernel/Cargo.toml +++ b/crates/fractal-kernel/Cargo.toml @@ -12,6 +12,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std" clap = { version = "4", features = ["derive", "env"] } getrandom = "0.3" hex = "0.4" +kdl = "6" libc = "0.2" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } rusqlite = { version = "0.32", features = ["bundled", "trace", "functions"] } diff --git a/crates/fractal-kernel/src/declaration_kdl.rs b/crates/fractal-kernel/src/declaration_kdl.rs new file mode 100644 index 00000000000..f2746f86175 --- /dev/null +++ b/crates/fractal-kernel/src/declaration_kdl.rs @@ -0,0 +1,109 @@ +//! The values of declaration KDL every declaration kind shares: a node as the canonical JSON +//! each declaration is stored, hashed and read as (`{name, arguments?, properties?, children?}`, +//! properties sorted by name), and durations (`300ms`, `5m`, `7d`). + +use std::collections::BTreeMap; + +use kdl::{KdlNode, KdlValue}; +use serde_json::{Map, Value}; + +use crate::model::FractalError; + +pub fn json_value(value: &KdlValue) -> Result { + Ok(match value { + KdlValue::String(value) => Value::String(value.clone()), + KdlValue::Integer(value) => { + let integer = i64::try_from(*value).map_err(|_| { + FractalError::new( + "integer-range", + "a KDL integer must fit a signed 64-bit value", + ) + })?; + Value::Number(integer.into()) + } + KdlValue::Float(value) => Value::Number( + serde_json::Number::from_f64(*value) + .ok_or_else(|| FractalError::new("invalid-number", "a float must be finite"))?, + ), + KdlValue::Bool(value) => Value::Bool(*value), + KdlValue::Null => Value::Null, + }) +} + +pub fn canonical_node(node: &KdlNode) -> Result { + let mut properties = BTreeMap::::new(); + let mut arguments = Vec::new(); + for entry in node.entries() { + let value = json_value(entry.value())?; + if let Some(name) = entry.name() { + if properties.insert(name.value().into(), value).is_some() { + return Err(FractalError::new( + "duplicate-property", + format!( + "node `{}` repeats property `{}`", + node.name().value(), + name.value() + ), + )); + } + } else { + arguments.push(value); + } + } + let children = node + .children() + .map(|document| { + document + .nodes() + .iter() + .map(canonical_node) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default(); + let mut output = Map::new(); + output.insert("name".into(), Value::String(node.name().value().into())); + if !arguments.is_empty() { + output.insert("arguments".into(), Value::Array(arguments)); + } + if !properties.is_empty() { + output.insert( + "properties".into(), + serde_json::to_value(properties).expect("BTreeMap serializes"), + ); + } + if !children.is_empty() { + output.insert("children".into(), Value::Array(children)); + } + Ok(Value::Object(output)) +} + +/// A duration in milliseconds: an integer with `ms`, `s`, `m`, `h` or `d`, seconds when bare. +pub fn parse_duration(value: &str, positive: bool) -> Result { + let (number, multiplier) = if let Some(value) = value.strip_suffix("ms") { + (value, 1) + } else if let Some(value) = value.strip_suffix('s') { + (value, 1_000) + } else if let Some(value) = value.strip_suffix('m') { + (value, 60_000) + } else if let Some(value) = value.strip_suffix('h') { + (value, 3_600_000) + } else if let Some(value) = value.strip_suffix('d') { + (value, 86_400_000) + } else { + (value, 1_000) + }; + let number = number + .parse::() + .map_err(|_| FractalError::new("invalid-duration", format!("invalid duration `{value}`")))?; + let duration = number + .checked_mul(multiplier) + .ok_or_else(|| FractalError::new("duration-range", "duration is too large"))?; + if positive && duration == 0 { + return Err(FractalError::new( + "invalid-duration", + "a required duration must be positive", + )); + } + Ok(duration) +} diff --git a/crates/fractal-kernel/src/lib.rs b/crates/fractal-kernel/src/lib.rs index eaf1517928a..0673fba7e1c 100644 --- a/crates/fractal-kernel/src/lib.rs +++ b/crates/fractal-kernel/src/lib.rs @@ -3,6 +3,10 @@ pub mod cli_output; pub mod accounts; +/// The values every declaration kind's KDL shares: canonical nodes and durations. +pub mod declaration_kdl; +/// An `objective` declaration: an indicator, its targets and the response to a miss. +pub mod objective; pub mod archive; pub mod boot; pub mod config; diff --git a/crates/fractal-kernel/src/objective.rs b/crates/fractal-kernel/src/objective.rs new file mode 100644 index 00000000000..63890e42b33 --- /dev/null +++ b/crates/fractal-kernel/src/objective.rs @@ -0,0 +1,732 @@ +//! What an `objective` declaration says (docs/st3/objectives.md): an indicator that measures +//! samples, the targets that bound them, the window an interval is judged over, and who a miss +//! is delivered to. One strict reader serves the objectives Fractal ships (`slo/objectives.kdl`) +//! and the ones a fleet declares: a missing, unknown, repeated or nonsensical child is an error, +//! never a default. + +use std::collections::BTreeSet; + +use kdl::KdlDocument; +use serde_json::Value; + +use crate::declaration_kdl::{canonical_node, parse_duration}; +use crate::model::FractalError; + +/// The declaration kind and subject family. +pub const KIND: &str = "objective"; + +#[derive(Clone, Debug, PartialEq)] +pub struct Objective { + /// The objective's id, the name after `objective/`. + pub name: String, + pub about: String, + pub indicator: Indicator, + /// The span an interval's budget is measured over; native telemetry keeps its own windows. + pub window: Option, + pub targets: Vec, + pub on_breach: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Indicator { + /// Request, stream, publication or paired-phone latency over the native 1m/5m/1h windows. + Latency { paths: Vec }, + /// A phone's live foreground time over its foreground time. + Share { path: String }, + /// One SQL statement. + Statement, + /// Read and write transactions and writer holds. + Transaction, + /// Each writer hold and long read, by holder. + WriterHold, + /// The member's daemon and replication workers over closed UTC minutes. + Cpu { idle_min_minutes: u64, load_max_cores: f64 }, + /// The store's pages in use and their daily growth. + Database, + /// Open projection divergence faults. + Divergence, + /// A percentile of a CI series' durations. + CiDuration { series: String }, + /// Events in a CI series per hour. + CiCount { series: String }, + /// Work that starts and stops in claims, one sample per subject. + Interval(Interval), +} + +impl Indicator { + pub fn kind(&self) -> &'static str { + match self { + Self::Latency { .. } => "latency", + Self::Share { .. } => "share", + Self::Statement => "statement", + Self::Transaction => "transaction", + Self::WriterHold => "writer-hold", + Self::Cpu { .. } => "cpu", + Self::Database => "database", + Self::Divergence => "divergence", + Self::CiDuration { .. } => "ci-duration", + Self::CiCount { .. } => "ci-count", + Self::Interval(_) => "interval", + } + } +} + +/// A claim that marks a moment of a sample, and the field its time is read from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Event { + pub kind: String, + /// A field holding an RFC 3339 time or Unix milliseconds; without one, the claim's accepted time. + pub at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Interval { + pub subject_prefix: String, + pub start: Event, + /// The members a sample must stop on, a list of strings in field `set` of the newest claim. + pub expect: Option<(Event, String)>, + /// One member stopping, named by field `member` (required with `expect`). + pub stop: (Event, Option), + pub cancel: Option, + /// The pause kind and the kind that resumes it. + pub pause: Option<(String, String)>, + /// Fields of the start or expect claim, or `member`, that split the rows. + pub labels: Vec, + pub excludes: Vec, +} + +/// Leave out a sample whose start or expect claim has one of `values` in `field`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Exclude { + pub field: String, + pub values: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Window { + pub ms: u64, + /// The samples the window needs before it is met or missed. + pub min_samples: u64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Target { + /// What the indicator reports: `p99`, `max`, `cores`, `live-gb`, ... + pub stat: String, + pub bound: Bound, + /// A latency target for reads served through this member from another that owns them. + pub remote: bool, +} + +impl Target { + /// The percentile a `pNN` target bounds. + pub fn percentile(&self) -> Option { + percentile(&self.stat) + } + + pub fn within_ms(&self) -> Option { + match self.bound { + Bound::Within(ms) => Some(ms), + _ => None, + } + } + + pub fn max(&self) -> Option { + match self.bound { + Bound::Max(max) => Some(max), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Bound { + Within(u64), + Max(f64), + MinPercent(f64), +} + +/// A miss opens a fault for `owner`; `backup` is held, recorded and never the reviewer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Breach { + pub owner: String, + pub backup: Option, + pub condition: String, +} + +impl Objective { + pub fn subject(&self) -> String { + format!("{KIND}/{}", self.name) + } + + /// The target with `stat` (and `remote`). + pub fn target(&self, stat: &str, remote: bool) -> Option<&Target> { + self.targets.iter().find(|target| target.stat == stat && target.remote == remote) + } + + /// Whether a fleet may declare it: an interval over claims, named `NAMESPACE/NAME` or deeper so + /// it never takes the name of one Fractal ships, which are one word each. Native telemetry + /// counts each path, series and reading toward the one objective the daemon was built with. + pub fn declarable(&self) -> bool { + matches!(self.indicator, Indicator::Interval(_)) && self.name.contains('/') + } +} + +/// The row of percentile `p` of objective `name`: `merge-latency/p95`. +pub fn percentile_row(name: &str, p: u64) -> String { + format!("{name}/p{p}") +} + +fn percentile(stat: &str) -> Option { + let digits = stat.strip_prefix('p')?; + (matches!(digits.len(), 1 | 2) && !digits.starts_with('0') && digits.bytes().all(|b| b.is_ascii_digit())) + .then(|| digits.parse().ok()) + .flatten() + .filter(|p| (1..=99).contains(p)) +} + +/// Lowercase words joined by `-`, as a name segment and a fault condition are. +pub fn valid_word(word: &str) -> bool { + word.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) + && word.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !word.ends_with('-') + && !word.contains("--") +} + +/// One or more words joined by `/`: `cpu`, `ops/change-rollout`. +pub fn valid_name(name: &str) -> bool { + name.len() <= 128 && name.split('/').all(valid_word) +} + +fn error(name: &str, message: impl std::fmt::Display) -> FractalError { + FractalError::new("invalid-objective", format!("objective `{name}`: {message}")) +} + +/// The objectives in a KDL document of `objective` nodes, after an optional `version 2`. +pub fn parse_document(text: &str) -> Result, FractalError> { + let document: KdlDocument = text + .parse() + .map_err(|error| FractalError::new("invalid-kdl", format!("{error}")))?; + let mut names = BTreeSet::new(); + let mut objectives = Vec::new(); + for (index, node) in document.nodes().iter().enumerate() { + let canonical = canonical_node(node)?; + if index == 0 && node.name().value() == "version" { + if canonical.get("arguments") != Some(&serde_json::json!([2])) || canonical.get("children").is_some() { + return Err(FractalError::new("invalid-version", "objectives are `version 2` KDL")); + } + continue; + } + let objective = parse(&canonical)?; + if !names.insert(objective.name.clone()) { + return Err(error(&objective.name, "is declared twice")); + } + objectives.push(objective); + } + Ok(objectives) +} + +/// An `objective` node, in its canonical JSON. +pub fn parse(node: &Value) -> Result { + if node["name"] != KIND { + return Err(FractalError::new("invalid-objective", format!("`{}` is not an objective", node["name"]))); + } + let name = match arguments(node) { + [Value::String(name)] if valid_name(name) => name.clone(), + _ => return Err(FractalError::new( + "invalid-objective", + "an objective has one name: lowercase words joined by `-`, segments by `/`", + )), + }; + let fail = |message: String| error(&name, message); + if properties(node).next().is_some() { + return Err(fail("takes no properties".into())); + } + let mut about = None; + let mut indicator = None; + let mut window = None; + let mut targets = Vec::new(); + let mut on_breach = None; + for child in children(node) { + let child_name = child["name"].as_str().unwrap_or_default(); + let once = |slot: bool| if slot { Err(fail(format!("repeats `{child_name}`"))) } else { Ok(()) }; + match child_name { + "about" => { + once(about.is_some())?; + no_children(child, &fail)?; + only_properties(child, &[], &fail)?; + about = Some(one_string(child, &fail)?.to_owned()).filter(|about| !about.trim().is_empty()); + if about.is_none() { + return Err(fail("`about` is empty".into())); + } + } + "indicator" => { + once(indicator.is_some())?; + indicator = Some(parse_indicator(child, &fail)?); + } + "window" => { + once(window.is_some())?; + no_children(child, &fail)?; + only_properties(child, &["min-samples"], &fail)?; + let ms = duration(one_string(child, &fail)?, &fail)?; + let min_samples = property(child, "min-samples") + .map(|value| positive_integer(value, "min-samples", &fail)) + .transpose()? + .unwrap_or(1); + window = Some(Window { ms, min_samples }); + } + "target" => { + no_children(child, &fail)?; + targets.push(parse_target(child, &fail)?); + } + "on-breach" => { + once(on_breach.is_some())?; + on_breach = Some(parse_breach(child, &fail)?); + } + other => return Err(fail(format!("has an unknown child `{other}`"))), + } + } + let objective = Objective { + about: about.ok_or_else(|| fail("has no `about`".into()))?, + indicator: indicator.ok_or_else(|| fail("has no `indicator`".into()))?, + name: name.clone(), + window, + targets, + on_breach, + }; + check_targets(&objective, &fail)?; + Ok(objective) +} + +fn parse_indicator(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result { + only_properties(node, &[], fail)?; + let kind = one_string(node, fail)?; + let kids = children(node).collect::>(); + let allow = |names: &[&str]| -> Result<(), FractalError> { + match kids.iter().find(|kid| !names.contains(&kid["name"].as_str().unwrap_or_default())) { + Some(kid) => Err(fail(format!("indicator `{kind}` has an unknown child `{}`", kid["name"].as_str().unwrap_or_default()))), + None => Ok(()), + } + }; + let strings = |child: &str| -> Result, FractalError> { + kids.iter() + .filter(|kid| kid["name"] == child) + .map(|kid| { + no_children(kid, fail)?; + only_properties(kid, &[], fail)?; + Ok(one_string(kid, fail)?.to_owned()) + }) + .collect() + }; + let single = |child: &str| -> Result { + match strings(child)?.as_slice() { + [value] => Ok(value.clone()), + _ => Err(fail(format!("indicator `{kind}` needs one `{child}`"))), + } + }; + Ok(match kind { + "latency" => { + allow(&["path"])?; + let paths = strings("path")?; + if paths.is_empty() { + return Err(fail("a latency indicator needs a `path`".into())); + } + Indicator::Latency { paths } + } + "share" => { + allow(&["path"])?; + Indicator::Share { path: single("path")? } + } + "statement" | "transaction" | "writer-hold" | "database" | "divergence" => { + allow(&[])?; + match kind { + "statement" => Indicator::Statement, + "transaction" => Indicator::Transaction, + "writer-hold" => Indicator::WriterHold, + "database" => Indicator::Database, + _ => Indicator::Divergence, + } + } + "cpu" => { + allow(&["idle-min-minutes", "load-max-cores"])?; + let number = |child: &str| -> Result<&Value, FractalError> { + let mut found = kids.iter().filter(|kid| kid["name"] == child); + match (found.next(), found.next()) { + (Some(kid), None) => { + no_children(kid, fail)?; + only_properties(kid, &[], fail)?; + match arguments(kid) { + [value] => Ok(value), + _ => Err(fail(format!("`{child}` needs one number"))), + } + } + _ => Err(fail(format!("a cpu indicator needs one `{child}`"))), + } + }; + Indicator::Cpu { + idle_min_minutes: positive_integer(number("idle-min-minutes")?, "idle-min-minutes", fail)?, + load_max_cores: positive_number(number("load-max-cores")?, "load-max-cores", fail)?, + } + } + "ci-duration" | "ci-count" => { + allow(&["series"])?; + let series = single("series")?; + if !is_ci_series(&series) { + return Err(fail(format!("series {series:?} is not `ci/` and lowercase words"))); + } + if kind == "ci-duration" { Indicator::CiDuration { series } } else { Indicator::CiCount { series } } + } + "interval" => Indicator::Interval(parse_interval(&kids, fail)?), + other => return Err(fail(format!("has an unknown indicator `{other}`"))), + }) +} + +fn parse_interval(kids: &[&Value], fail: &dyn Fn(String) -> FractalError) -> Result { + let mut prefix = None; + let mut start = None; + let mut expect = None; + let mut stop = None; + let mut cancel = None; + let mut pause = None; + let mut labels = Vec::new(); + let mut excludes = Vec::new(); + for kid in kids { + let name = kid["name"].as_str().unwrap_or_default(); + no_children(kid, fail)?; + let repeated = || fail(format!("the interval repeats `{name}`")); + let event = |extra: &[&str]| -> Result { + let mut allowed = vec!["at"]; + allowed.extend_from_slice(extra); + only_properties(kid, &allowed, fail)?; + let kind = one_string(kid, fail)?; + if kind.is_empty() { + return Err(fail(format!("`{name}` names no claim kind"))); + } + Ok(Event { kind: kind.to_owned(), at: field_property(kid, "at", fail)? }) + }; + match name { + "subject-prefix" => { + only_properties(kid, &[], fail)?; + let value = one_string(kid, fail)?; + if !value.ends_with('/') || value.len() < 2 || value.starts_with('/') { + return Err(fail(format!("subject-prefix {value:?} must name subjects and end with `/`"))); + } + if prefix.replace(value.to_owned()).is_some() { + return Err(repeated()); + } + } + "start" => { + if start.replace(event(&[])?).is_some() { + return Err(repeated()); + } + } + "expect" => { + let event = event(&["set"])?; + let set = field_property(kid, "set", fail)?.ok_or_else(|| fail("`expect` needs `set=FIELD`".into()))?; + if expect.replace((event, set)).is_some() { + return Err(repeated()); + } + } + "stop" => { + let event = event(&["member"])?; + if stop.replace((event, field_property(kid, "member", fail)?)).is_some() { + return Err(repeated()); + } + } + "cancel" => { + only_properties(kid, &[], fail)?; + if cancel.replace(one_string(kid, fail)?.to_owned()).is_some() { + return Err(repeated()); + } + } + "pause" => { + only_properties(kid, &["resume"], fail)?; + let resume = property(kid, "resume") + .and_then(Value::as_str) + .filter(|resume| !resume.is_empty()) + .ok_or_else(|| fail("`pause` needs `resume=KIND`".into()))?; + if pause.replace((one_string(kid, fail)?.to_owned(), resume.to_owned())).is_some() { + return Err(repeated()); + } + } + "label" => { + only_properties(kid, &[], fail)?; + let label = one_string(kid, fail)?; + if !is_field(label) || labels.contains(&label.to_owned()) { + return Err(fail(format!("label {label:?} is not a field, or is repeated"))); + } + labels.push(label.to_owned()); + } + "exclude" => { + only_properties(kid, &[], fail)?; + let values = arguments(kid) + .iter() + .map(|value| value.as_str().filter(|value| !value.is_empty()).map(str::to_owned)) + .collect::>>() + .filter(|values| values.len() >= 2 && is_field(&values[0])) + .ok_or_else(|| fail("`exclude` needs a field and at least one value".into()))?; + excludes.push(Exclude { field: values[0].clone(), values: values[1..].to_vec() }); + } + other => return Err(fail(format!("indicator `interval` has an unknown child `{other}`"))), + } + } + let stop = stop.ok_or_else(|| fail("an interval needs `stop`".into()))?; + if expect.is_some() && stop.1.is_none() { + return Err(fail("with `expect`, `stop` needs `member=FIELD`".into())); + } + if labels.iter().any(|label| label == "member") && stop.1.is_none() { + return Err(fail("`label \"member\"` needs `stop member=FIELD`".into())); + } + let interval = Interval { + subject_prefix: prefix.ok_or_else(|| fail("an interval needs `subject-prefix`".into()))?, + start: start.ok_or_else(|| fail("an interval needs `start`".into()))?, + expect, + stop, + cancel, + pause, + labels, + excludes, + }; + let mut kinds = BTreeSet::new(); + for kind in interval.kinds() { + if !kinds.insert(kind) { + return Err(fail(format!("claim kind {kind:?} has two roles"))); + } + } + Ok(interval) +} + +impl Interval { + /// Every claim kind the interval reads, each with one role. + pub fn kinds(&self) -> Vec<&str> { + let mut kinds = vec![self.start.kind.as_str()]; + kinds.extend(self.expect.as_ref().map(|(event, _)| event.kind.as_str())); + kinds.push(self.stop.0.kind.as_str()); + kinds.extend(self.cancel.as_deref()); + if let Some((pause, resume)) = &self.pause { + kinds.extend([pause.as_str(), resume.as_str()]); + } + kinds + } +} + +fn parse_target(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result { + only_properties(node, &["within", "max", "min-percent", "scope"], fail)?; + let stat = one_string(node, fail)?.to_owned(); + let bounds = ["within", "max", "min-percent"] + .into_iter() + .filter_map(|name| property(node, name).map(|value| (name, value))) + .collect::>(); + let bound = match bounds.as_slice() { + [("within", value)] => Bound::Within(duration( + value.as_str().ok_or_else(|| fail(format!("target `{stat}`'s `within` is not a duration")))?, + fail, + )?), + [("max", value)] => Bound::Max(number(value, &format!("target `{stat}`'s `max`"), fail)?), + [("min-percent", value)] => { + let min = number(value, &format!("target `{stat}`'s `min-percent`"), fail)?; + if !(min > 0.0 && min <= 100.0) { + return Err(fail(format!("target `{stat}`'s `min-percent` is not between 0 and 100"))); + } + Bound::MinPercent(min) + } + _ => return Err(fail(format!("target `{stat}` needs one bound: `within`, `max` or `min-percent`"))), + }; + let remote = match property(node, "scope") { + None => false, + Some(Value::String(scope)) if scope == "remote" => true, + Some(other) => return Err(fail(format!("target `{stat}` has an unknown scope {other}"))), + }; + Ok(Target { stat, bound, remote }) +} + +fn parse_breach(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result { + no_children(node, fail)?; + only_properties(node, &["owner", "backup", "condition"], fail)?; + if one_string(node, fail)? != "fault" { + return Err(fail("`on-breach` is `fault`, the only response".into())); + } + let string = |name: &str| property(node, name).map(|value| { + value.as_str().map(str::to_owned).ok_or_else(|| fail(format!("`on-breach`'s `{name}` is not a string"))) + }).transpose(); + let agent = |value: &str| value.strip_prefix("agent/").is_some_and(|id| !id.is_empty()); + let owner = string("owner")?.filter(|owner| agent(owner)).ok_or_else(|| fail("`on-breach` needs `owner=\"agent/...\"`".into()))?; + let backup = string("backup")?; + if backup.as_deref().is_some_and(|backup| !agent(backup) || backup == owner) { + return Err(fail("`on-breach`'s `backup` is an agent other than the owner".into())); + } + let condition = string("condition")? + .filter(|condition| valid_word(condition)) + .ok_or_else(|| fail("`on-breach` needs `condition=NAME`, lowercase words joined by `-`".into()))?; + Ok(Breach { owner, backup, condition }) +} + +/// Each indicator's targets: which stats it reports, which bounds they take and how many. +fn check_targets(objective: &Objective, fail: &dyn Fn(String) -> FractalError) -> Result<(), FractalError> { + let mut seen = BTreeSet::new(); + for target in &objective.targets { + if !seen.insert((target.stat.as_str(), target.remote)) { + return Err(fail(format!("repeats target `{}`", target.stat))); + } + if target.remote && !matches!(objective.indicator, Indicator::Latency { .. }) { + return Err(fail("only a latency target has a remote scope".into())); + } + } + let kind = objective.indicator.kind(); + let stat_bound = |target: &Target| -> bool { + let within = matches!(target.bound, Bound::Within(ms) if ms > 0); + let max = |integer: bool, zero: bool| match target.bound { + Bound::Max(max) => max.is_finite() && (max > 0.0 || (zero && max == 0.0)) && (!integer || max.fract() == 0.0), + _ => false, + }; + match (&objective.indicator, target.stat.as_str()) { + (Indicator::Latency { .. }, "p99") => within, + (Indicator::Share { .. }, "live-share") => matches!(target.bound, Bound::MinPercent(_)), + (Indicator::Statement, "p99" | "max") => within, + (Indicator::Transaction | Indicator::WriterHold, "max") => within, + (Indicator::Cpu { .. }, "cores" | "idle-cores") => max(false, false), + (Indicator::Database, "live-gb" | "growth-mb-per-day") => max(false, false), + (Indicator::Divergence, "open") => max(true, true), + (Indicator::CiCount { .. }, "per-hour") => max(true, true), + (Indicator::CiDuration { .. } | Indicator::Interval(_), stat) => percentile(stat).is_some() && within, + _ => false, + } + }; + if let Some(target) = objective.targets.iter().find(|target| !stat_bound(target)) { + return Err(fail(format!("a `{kind}` indicator has no target `{}` with that bound", target.stat))); + } + let required: &[&str] = match objective.indicator { + Indicator::Latency { .. } => &["p99"], + Indicator::Statement => &["p99", "max"], + Indicator::Share { .. } => &["live-share"], + Indicator::Transaction | Indicator::WriterHold => &["max"], + Indicator::Cpu { .. } => &["cores", "idle-cores"], + Indicator::Database => &["live-gb", "growth-mb-per-day"], + Indicator::Divergence => &["open"], + Indicator::CiCount { .. } => &["per-hour"], + _ => &[], + }; + if let Some(missing) = required.iter().find(|stat| objective.target(stat, false).is_none()) { + return Err(fail(format!("a `{kind}` indicator needs target `{missing}`"))); + } + if objective.targets.is_empty() { + return Err(fail("has no `target`".into())); + } + if let (Some(local), Some(remote)) = (objective.target("p99", false), objective.target("p99", true)) + && remote.within_ms() < local.within_ms() + { + return Err(fail("its remote latency is below its own".into())); + } + if let (Some(p99), Some(max)) = (objective.target("p99", false), objective.target("max", false)) + && matches!(objective.indicator, Indicator::Statement) + && max.within_ms() < p99.within_ms() + { + return Err(fail("its max is below its p99".into())); + } + if let (Some(cores), Some(idle)) = (objective.target("cores", false), objective.target("idle-cores", false)) + && idle.max() > cores.max() + { + return Err(fail("its idle CPU target is above the member's".into())); + } + if let Indicator::Cpu { idle_min_minutes, .. } = objective.indicator + && idle_min_minutes > 60 + { + return Err(fail("its idle minimum is not between 1 and 60 minutes".into())); + } + match (&objective.indicator, objective.window) { + (Indicator::Interval(_), None) => return Err(fail("an interval needs a `window`".into())), + (Indicator::Interval(_), Some(_)) => {} + (_, Some(_)) => return Err(fail("only an interval takes a `window`; native telemetry keeps its own".into())), + (_, None) => {} + } + if objective.on_breach.is_some() && !matches!(objective.indicator, Indicator::Interval(_)) { + return Err(fail("only an interval takes `on-breach`; native objectives respond as they are built".into())); + } + Ok(()) +} + +fn is_ci_series(path: &str) -> bool { + path.strip_prefix("ci/").is_some_and(|rest| { + rest.split('/').all(|part| { + !part.is_empty() && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }) + }) +} + +fn is_field(name: &str) -> bool { + !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +fn arguments(node: &Value) -> &[Value] { + node.get("arguments").and_then(Value::as_array).map_or(&[], Vec::as_slice) +} + +fn properties(node: &Value) -> impl Iterator { + node.get("properties").and_then(Value::as_object).into_iter().flatten() +} + +fn property<'a>(node: &'a Value, name: &str) -> Option<&'a Value> { + node.get("properties")?.get(name) +} + +fn children(node: &Value) -> impl Iterator { + node.get("children").and_then(Value::as_array).into_iter().flatten() +} + +fn no_children(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result<(), FractalError> { + match node.get("children") { + None => Ok(()), + Some(_) => Err(fail(format!("`{}` takes no children", node["name"].as_str().unwrap_or_default()))), + } +} + +fn only_properties(node: &Value, allowed: &[&str], fail: &dyn Fn(String) -> FractalError) -> Result<(), FractalError> { + match properties(node).find(|(name, _)| !allowed.contains(&name.as_str())) { + Some((name, _)) => Err(fail(format!( + "`{}` has an unknown property `{name}`", + node["name"].as_str().unwrap_or_default() + ))), + None => Ok(()), + } +} + +fn one_string<'a>(node: &'a Value, fail: &dyn Fn(String) -> FractalError) -> Result<&'a str, FractalError> { + match arguments(node) { + [Value::String(value)] => Ok(value), + _ => Err(fail(format!("`{}` needs one string", node["name"].as_str().unwrap_or_default()))), + } +} + +fn field_property(node: &Value, name: &str, fail: &dyn Fn(String) -> FractalError) -> Result, FractalError> { + property(node, name) + .map(|value| { + value + .as_str() + .filter(|field| is_field(field)) + .map(str::to_owned) + .ok_or_else(|| fail(format!("`{name}` is not a field name"))) + }) + .transpose() +} + +fn duration(value: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + parse_duration(value, true).map_err(|error| fail(error.message)) +} + +fn number(value: &Value, what: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + value.as_f64().filter(|number| number.is_finite()).ok_or_else(|| fail(format!("{what} is not a number"))) +} + +fn positive_number(value: &Value, what: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + number(value, what, fail).and_then(|number| { + if number > 0.0 { Ok(number) } else { Err(fail(format!("{what} is not positive"))) } + }) +} + +fn positive_integer(value: &Value, what: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + value.as_u64().filter(|n| *n > 0).ok_or_else(|| fail(format!("{what} is not a positive integer"))) +} + +#[cfg(test)] +#[path = "objective_tests.rs"] +mod tests; diff --git a/crates/fractal-kernel/src/objective_tests.rs b/crates/fractal-kernel/src/objective_tests.rs new file mode 100644 index 00000000000..ed73de5b076 --- /dev/null +++ b/crates/fractal-kernel/src/objective_tests.rs @@ -0,0 +1,94 @@ +use super::*; + +const SHIPPED: &str = include_str!("../../../slo/objectives.kdl"); + +fn one(text: &str) -> Result { + parse_document(&format!("version 2\n{text}")).map(|mut objectives| objectives.remove(0)) +} + +const INTERVAL: &str = r#"objective "ops/change-rollout" { + about "A change, from ready to served by every member" + indicator "interval" { + subject-prefix "custom/acme/change/" + start "custom.acme.change-ready" at="ready_at" + expect "custom.acme.change-merged" set="hosts" at="merged_at" + stop "custom.acme.change-applied" member="host" at="applied_at" + cancel "custom.acme.change-abandoned" + pause "custom.acme.change-paused" resume="custom.acme.change-resumed" + label "class" + label "member" + exclude "exclusion" "deferred-launch" "deferred-runtime" + } + window "7d" min-samples=20 + target "p50" within="60s" + target "p95" within="5m" + on-breach "fault" owner="agent/ops/release-manager" backup="agent/ops/observability" condition="change-rollout-slo-missed" +}"#; + +#[test] +fn the_shipped_objectives_parse_and_none_is_declarable() { + let objectives = parse_document(SHIPPED).unwrap(); + assert_eq!(objectives.len(), 21); + assert!(objectives.iter().all(|objective| !objective.declarable())); + let merge = objectives.iter().find(|objective| objective.name == "merge-latency").unwrap(); + assert_eq!(merge.indicator, Indicator::CiDuration { series: "ci/merge-queue".into() }); + assert_eq!( + merge.targets.iter().map(|target| (target.percentile(), target.within_ms())).collect::>(), + [(Some(50), Some(600_000)), (Some(95), Some(600_000))], + ); + let read = objectives.iter().find(|objective| objective.name == "person-read").unwrap(); + assert_eq!(read.target("p99", true).and_then(Target::within_ms), Some(300)); +} + +#[test] +fn an_interval_reads_every_role() { + let objective = one(INTERVAL).unwrap(); + assert!(objective.declarable()); + assert_eq!(objective.window, Some(Window { ms: 7 * 86_400_000, min_samples: 20 })); + let Indicator::Interval(interval) = &objective.indicator else { panic!() }; + assert_eq!(interval.subject_prefix, "custom/acme/change/"); + assert_eq!(interval.start, Event { kind: "custom.acme.change-ready".into(), at: Some("ready_at".into()) }); + assert_eq!(interval.expect.as_ref().map(|(_, set)| set.as_str()), Some("hosts")); + assert_eq!(interval.stop.1.as_deref(), Some("host")); + assert_eq!(interval.labels, ["class", "member"]); + assert_eq!(interval.excludes, [Exclude { field: "exclusion".into(), values: vec!["deferred-launch".into(), "deferred-runtime".into()] }]); + assert_eq!(interval.kinds().len(), 6); + assert_eq!( + objective.on_breach, + Some(Breach { + owner: "agent/ops/release-manager".into(), + backup: Some("agent/ops/observability".into()), + condition: "change-rollout-slo-missed".into(), + }) + ); + assert_eq!(percentile_row(&objective.name, 95), "ops/change-rollout/p95"); +} + +#[test] +fn a_nonsensical_objective_is_refused_never_defaulted() { + let refused = |text: &str| one(text).unwrap_err().message; + let interval = |replace: &str, with: &str| INTERVAL.replace(replace, with); + for (text, says) in [ + (interval(" window \"7d\" min-samples=20\n", ""), "needs a `window`"), + (interval("target \"p95\" within=\"5m\"", "target \"p95\" within=\"5m\"\n target \"p95\" within=\"9m\""), "repeats target"), + (interval("target \"p95\"", "target \"p100\""), "no target `p100`"), + (interval("target \"p95\"", "target \"p05\""), "no target `p05`"), + (interval(" member=\"host\"", ""), "needs `member=FIELD`"), + (interval("cancel \"custom.acme.change-abandoned\"", "cancel \"custom.acme.change-ready\""), "two roles"), + (interval("backup=\"agent/ops/observability\"", "backup=\"agent/ops/release-manager\""), "other than the owner"), + (interval("owner=\"agent/ops/release-manager\"", "owner=\"person/ada\""), "needs `owner="), + (interval("subject-prefix \"custom/acme/change/\"", "subject-prefix \"custom/acme/change\""), "end with `/`"), + (interval(" label \"class\"\n", " label \"class\"\n color \"red\"\n"), "unknown child `color`"), + (interval("about \"A change, from ready to served by every member\"", "about \" \""), "`about` is empty"), + ("objective \"cpu\" { about \"x\"; indicator \"cpu\" { idle-min-minutes 10; load-max-cores 2.0 }; target \"cores\" max=1.0 }".into(), "needs target `idle-cores`"), + ("objective \"db\" { about \"x\"; indicator \"database\"; target \"live-gb\" max=1.0; target \"growth-mb-per-day\" max=1; window \"1d\" }".into(), "only an interval takes a `window`"), + ("objective \"r\" { about \"x\"; indicator \"latency\" { path \"GET /v1/x\" }; target \"p99\" within=\"300ms\"; target \"p99\" within=\"200ms\" scope=\"remote\" }".into(), "remote latency is below"), + ("objective \"Bad\" { about \"x\" }".into(), "lowercase words"), + ("objective \"ops//x\" { about \"x\" }".into(), "lowercase words"), + ] { + let message = refused(&text); + assert!(message.contains(says), "{says:?} not in {message:?}"); + } + assert!(parse_document(&format!("version 2\n{INTERVAL}\n{INTERVAL}")).unwrap_err().message.contains("declared twice")); + assert!(!one(&INTERVAL.replace("ops/change-rollout", "change-rollout")).unwrap().declarable()); +} diff --git a/crates/fractal-missions-base/src/declaration_syntax.rs b/crates/fractal-missions-base/src/declaration_syntax.rs index a8043fa11bd..495a14c2019 100644 --- a/crates/fractal-missions-base/src/declaration_syntax.rs +++ b/crates/fractal-missions-base/src/declaration_syntax.rs @@ -4,7 +4,6 @@ use std::collections::{BTreeMap, HashSet}; use kdl::{KdlDocument, KdlNode, KdlValue}; -use serde_json::Value; use sha2::{Digest as _, Sha256}; use crate::model::FractalError; @@ -134,26 +133,7 @@ pub fn first_string(node: &KdlNode) -> Result { .and_then(|value| value_string(value)) } -pub fn json_value(value: &KdlValue) -> Result { - Ok(match value { - KdlValue::String(value) => Value::String(value.clone()), - KdlValue::Integer(value) => { - let integer = i64::try_from(*value).map_err(|_| { - FractalError::new( - "integer-range", - "a KDL integer must fit a signed 64-bit value", - ) - })?; - Value::Number(integer.into()) - } - KdlValue::Float(value) => Value::Number( - serde_json::Number::from_f64(*value) - .ok_or_else(|| FractalError::new("invalid-number", "a float must be finite"))?, - ), - KdlValue::Bool(value) => Value::Bool(*value), - KdlValue::Null => Value::Null, - }) -} +pub use crate::declaration_kdl::{json_value, parse_duration}; pub fn one_string(node: &KdlNode) -> Result { ensure_no_children(node)?; @@ -177,35 +157,6 @@ pub fn one_string_with_children(node: &KdlNode) -> Result }) } -pub fn parse_duration(value: &str, positive: bool) -> Result { - let (number, multiplier) = if let Some(value) = value.strip_suffix("ms") { - (value, 1) - } else if let Some(value) = value.strip_suffix('s') { - (value, 1_000) - } else if let Some(value) = value.strip_suffix('m') { - (value, 60_000) - } else if let Some(value) = value.strip_suffix('h') { - (value, 3_600_000) - } else if let Some(value) = value.strip_suffix('d') { - (value, 86_400_000) - } else { - (value, 1_000) - }; - let number = number - .parse::() - .map_err(|_| FractalError::new("invalid-duration", format!("invalid duration `{value}`")))?; - let duration = number - .checked_mul(multiplier) - .ok_or_else(|| FractalError::new("duration-range", "duration is too large"))?; - if positive && duration == 0 { - return Err(FractalError::new( - "invalid-duration", - "a required duration must be positive", - )); - } - Ok(duration) -} - pub fn parse_map_child( document: &KdlDocument, name: &str, diff --git a/crates/fractal-store/src/graph.rs b/crates/fractal-store/src/graph.rs index bcc7eaf69a1..6d1b86b072a 100644 --- a/crates/fractal-store/src/graph.rs +++ b/crates/fractal-store/src/graph.rs @@ -1,10 +1,11 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use kdl::{KdlDocument, KdlNode, KdlValue}; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; use crate::declaration_syntax::*; +use crate::declaration_kdl::canonical_node; pub use crate::declaration_syntax::parse_duration; #[cfg(test)] use crate::model::GateSpec; @@ -4424,54 +4425,6 @@ fn insert_subject(context: &mut ParseContext, subject: DesiredSubject) -> Result Ok(()) } -fn canonical_node(node: &KdlNode) -> Result { - let mut properties = BTreeMap::::new(); - let mut arguments = Vec::new(); - for entry in node.entries() { - let value = json_value(entry.value())?; - if let Some(name) = entry.name() { - if properties.insert(name.value().into(), value).is_some() { - return Err(FractalError::new( - "duplicate-property", - format!( - "node `{}` repeats property `{}`", - node.name().value(), - name.value() - ), - )); - } - } else { - arguments.push(value); - } - } - let children = node - .children() - .map(|document| { - document - .nodes() - .iter() - .map(canonical_node) - .collect::, _>>() - }) - .transpose()? - .unwrap_or_default(); - let mut output = Map::new(); - output.insert("name".into(), Value::String(node.name().value().into())); - if !arguments.is_empty() { - output.insert("arguments".into(), Value::Array(arguments)); - } - if !properties.is_empty() { - output.insert( - "properties".into(), - serde_json::to_value(properties).expect("BTreeMap serializes"), - ); - } - if !children.is_empty() { - output.insert("children".into(), Value::Array(children)); - } - Ok(Value::Object(output)) -} - fn hash_json(value: &Value) -> String { let bytes = serde_json::to_vec(value).expect("normalized JSON serializes"); hex::encode(Sha256::digest(bytes)) diff --git a/docs/st3/objectives.md b/docs/st3/objectives.md new file mode 100644 index 00000000000..db2da559a46 --- /dev/null +++ b/docs/st3/objectives.md @@ -0,0 +1,164 @@ +# Objectives + +An objective is a target for something Fractal measures, with an error budget and an optional +response when it is missed. There is one concept and one KDL kind, `objective`. Fractal ships its +own objectives (request latency, the store, CPU, CI) and a fleet declares more with `fractal apply`, +like agents, missions and accounts. Both are read the same way: `fractal slo ls`, `fractal slo +explain ID` and `fractal slo chart ID`. + +## The `objective` kind + +```kdl +version 2 + +objective "ops/change-rollout" { + about "A change, from its author marking it ready to every member that serves it" + indicator "interval" { + subject-prefix "custom/acme/change/" + start "custom.acme.change-ready" at="ready_at" + expect "custom.acme.change-merged" set="hosts" at="merged_at" + stop "custom.acme.change-applied" member="host" at="applied_at" + cancel "custom.acme.change-abandoned" + label "class" + exclude "exclusion" "deferred-launch" "deferred-runtime" "deferred-workspace" + } + window "7d" min-samples=20 + target "p50" within="60s" + target "p95" within="5m" + on-breach "fault" owner="agent/ops/release-manager" backup="agent/ops/observability" condition="change-rollout-slo-missed" +} +``` + +- The name is the objective's id and its subject is `objective/NAME`. It is lowercase words joined + by `-`, in segments joined by `/`. A declared objective is named `NAMESPACE/NAME` or deeper, like + an agent; the objectives Fractal ships are one segment each, so the two never collide. +- `about` says what is measured, in one sentence. +- `indicator` says how a sample is measured. Each kind is below. +- `target STAT BOUND` bounds one statistic. `STAT` is what the indicator reports (`p50`, `p99`, + `max`, `cores`, ...) and the bound is `within=DURATION`, `max=NUMBER` or `min-percent=NUMBER`. + Each indicator names its rows (table below). A percentile over claims or CI is the row + `NAME/pNN` (`ops/change-rollout/p95`), one per target. +- `window` is the span the budget is measured over, and `min-samples` the samples it needs before it + is met or missed; with fewer it is `no-data`. Only an indicator over claims takes a window. + Native telemetry keeps its own windows, below. +- `on-breach` is what happens when a row is missed. It is optional; without it a miss is shown and + nothing is delivered. + +A missing, unknown, repeated or nonsensical child is refused, never defaulted. An objective is a root +declaration; it cannot sit inside a mission. + +## Indicators + +### Native telemetry + +Fractal measures these itself, in memory, on every member. They are the objectives Fractal ships, in +[`slo/objectives.kdl`](../../slo/objectives.kdl), which is generated from +[`slo/objectives.kdl.genie.ts`](../../slo/objectives.kdl.genie.ts). A fleet cannot declare them: +each path, store series and CPU reading counts toward exactly one objective, and that is the one +the daemon was built with. + +| Indicator | Measures | Targets | Rows | Windows | +|---|---|---|---|---| +| `latency` | Each `path`: a daemon route (`GET /v1/client/agents`), a stream's first snapshot (`stream agents`), a refresher's publication (`publish missions`) or a paired phone's report (`client/ios/connect`) | `p99 within=`; again with `scope="remote"` for the same reads served from another member | `NAME`, `NAME/remote` | 1m, 5m, 1h; budget over 1h | +| `share` | A phone's live foreground time over its foreground time, at `path` | `live-share min-percent=` | `NAME` | 1m, 5m, 1h | +| `statement` | One SQL statement | `p99 within=`, `max within=` | `NAME` (the p99) | 1m, 5m, 1h | +| `transaction` | Read and write transactions and writer holds | `max within=` | `NAME/read`, `NAME/write`, `NAME/writer_hold` | 1m, 5m, 1h | +| `writer-hold` | Each writer hold and long read, by holder | `max within=` | `NAME` | 1m, 5m, 1h | +| `cpu` | The daemon and its replication workers over closed UTC minutes; children `idle-min-minutes` and `load-max-cores` | `cores max=`, `idle-cores max=` | `NAME`, `NAME/idle` | the hour | +| `database` | The store's pages in use and their daily growth | `live-gb max=`, `growth-mb-per-day max=` | `NAME` | the newest hourly measurement | +| `divergence` | Open projection divergence faults | `open max=` | `NAME`, once a divergence check has run | now | +| `ci-duration` | A CI `series` (`ci/merge-queue`) read by the repository observer | `pNN within=` | `NAME/pNN` | 1d | +| `ci-count` | Events in a CI `series` | `per-hour max=` | `NAME` | 1h | + +The statement, transaction, writer-hold, CPU, database and divergence objectives are one each, +named as the table's rows say (`sql-statement`, `transaction`, `writer-hold`, `cpu`, `database`, +`projection-divergence`): `fractal doctor` and the OTLP export read them by these names. + +These keep the meanings they had as built-in targets: a p99 is missed in a window when more than +1% of its samples are over, a max when any one is, and CI rows are no-data on a member that reads no +Workspace CI runs. + +### Interval over claims + +`interval` measures work that starts and stops in different processes and on different members, +from claims those processes write. Each subject under `subject-prefix` is one sample. + +| Child | Meaning | +|---|---| +| `subject-prefix "P"` | The sample subjects: every subject that starts with `P`. Custom claims (`custom/NS/...`) need no registration and replicate to every member. | +| `start KIND at=FIELD` | The sample starts at the earliest such claim. | +| `expect KIND set=FIELD` | The members a sample must stop on, a list of strings in the newest such claim. An empty list stops the sample at the expect claim's time. Without `expect`, one stop claim ends the sample. | +| `stop KIND member=FIELD` | One member has stopped: the earliest such claim per value of `FIELD`. The sample stops when every expected member has, at the latest of their times. | +| `cancel KIND` | An open sample with such a claim is cancelled. A sample that stopped first keeps its value. | +| `pause KIND resume=KIND` | The clock stops from each pause to the next resume, and a sample still paused is not aging. | +| `label "FIELD"` | Also one row per value of `FIELD` on the start or expect claim: `ops/change-rollout/p95{class=routine}`. `label "member"` splits by stop member, each sample's value being that member's stop. | +| `exclude "FIELD" "A" "B"` | A sample whose start or expect claim has one of these values is excluded. | + +`at=FIELD` reads a time from the claim's fields: an RFC 3339 string or Unix milliseconds. Without +`at`, the time is when the claim's origin accepted it, which every member sees the same. + +A sample is **closed** when it stopped, **open** while it has started and not stopped or been +cancelled, **cancelled**, or **excluded**. A closed sample belongs to the window by its stop time. + +**Open samples are censored, not dropped.** For each target, an open sample older than the target's +`within` enters that target's percentile at its current age: it is certain to be over, so a stuck +change spends budget instead of hiding. A younger open sample is left out of the percentile, since +it may still finish within. The row counts every class, so nothing is silently dropped: + +``` +ops/change-rollout/p95 missed p95 ≤ 5 min p95 41 min over 23 samples (1 open over) 7d 0% +``` + +The value is the nearest-rank percentile. The budget is the share over `within` against the share +the percentile allows: p95 allows 5 in 100. + +### Ratio over Prometheus metrics + +There is none: Fractal does not read Prometheus. It exports its native readings as OTLP gauges +(`st_slo_*`), and Grafana computes ratios from them and from other exporters. An objective whose good +and total events exist only as Prometheus series stays in Grafana until its events reach Fractal as +claims or native telemetry. + +## Breach response + +```kdl +on-breach "fault" owner="agent/NS/NAME" backup="agent/NS/OTHER" condition="NAME-slo-missed" +``` + +A missed row opens one `operational.failure` on `daemon/MEMBER` addressed to `owner`, with the +`condition`. It stays one episode until the row is met again, when `operational.recovered` ends it. +A row with no data neither opens nor ends one. The member that delivers it is the one the owner +agent is declared on: every member evaluates the objective from the same replicated claims, and one +delivers. An owner that is not a live declaration gets nothing, and the explanation says so. + +`backup` is held: it is recorded and shown, and never becomes the fault's reviewer. It differs from +the owner. + +Objectives Fractal ships respond as they always have: a missed CI row is a fault for the fleet fault +agent. + +## Publishing, revisions and replication + +An objective is published with `fractal apply`, like an account. It is stored as the subject's +`intent.desired` claim, and its revision is the hash of the canonical declaration, which `fractal +apply --dry-run` prints as the old and new revision. The claim replicates to every member, so every +member evaluates the same declaration. A revision restarts the objective's evaluation from the claims; +nothing about past samples is stored apart from them. Retiring the subject removes the objective and +its rows. + +A daemon that does not know the `objective` family refuses the replicated claim, so publish an +objective only after every member runs a build that knows it. + +## Reading objectives + +`fractal slo ls` lists every row, shipped and declared alike, from `GET /v1/slo`. `fractal slo explain +ID` adds, for a declared objective, its subject and revision, the sample counts by class, each open +sample over target with its age, and its response. `fractal slo chart ID` draws the budget left a +minute at a time from the local seven-day history, which records declared rows like any other. + +## Listing claims by kind or subject prefix + +`GET /v1/claims` filters by `kind`, by `subject_prefix` or by an exact `subject`, and pages by store +index. `fractal claims ls --kind KIND --subject-prefix PREFIX` prints them. Both read through the +claims' kind and subject indexes. The interval indicator reads its claims the same way, keeping each +objective's samples in memory and reading only claims newer than the last it saw. diff --git a/slo/objectives.kdl b/slo/objectives.kdl new file mode 100644 index 00000000000..ae5671ac411 --- /dev/null +++ b/slo/objectives.kdl @@ -0,0 +1,235 @@ +// The objectives every Fractal daemon is built with (docs/st3/objectives.md). Each daemon shows +// each latency objective's live 1-minute, 5-minute and 1-hour windows on +// `GET /v1/client/request-latency` and in `fractal doctor`, and CI's daemon_load reads them too. +// An objective describes; it gates nothing by itself. daemon_load's budgets stay its gates. +// +// A latency path is `METHOD /route` as the daemon routes it, or `stream COLLECTION` for the time +// from a subscription on the client collections socket to its first snapshot or screen. A request +// that asks to wait (`wait_ms` above zero, or `wait=true`) is a long poll and counts toward no +// objective. An agents list asked to be fresh (`fresh=true`) waits for one roster refresh by +// design: it is the path `GET /v1/client/agents (fresh)` and counts toward its own objective, not +// the first page's. `publish COLLECTION` is a refresher's publication: the time from the first +// claim this node committed after the previous publication to the publication that carries it. +version 2 + +objective "person-read" { + about "Reads a person waits on: agents list, missions list, open a conversation, conversation changes, work list, Now, what a run waits on, documents" + indicator "latency" { + // Agents list + path "GET /v1/client/agents" + path "GET /v1/client/agents/{*id}" + path "stream agents" + // Missions list + path "GET /v1/client/missions" + path "GET /v1/client/missions/{*id}" + path "stream missions" + // Open a conversation, and its changes + path "GET /v1/client/sessions/{*id}" + path "stream conversation" + path "GET /v1/client/conversations/{id}/changes" + // Work list + path "GET /v1/client/work" + path "GET /v1/client/work/{*id}" + path "GET /v1/work" + path "stream work" + // Now + path "GET /v1/client/now" + path "GET /v1/client/attention" + path "stream attention" + // Why a run is stuck, and every blocked run + path "GET /v1/mission-runs/{run}/waiting-on" + path "GET /v1/blocked-runs" + // Documents + path "GET /v1/client/documents/content" + path "GET /v1/documents" + path "GET /v1/documents/content" + } + target "p99" within="300ms" + // The same reads through this daemon from another machine that owns them. + target "p99" within="300ms" scope="remote" +} + +objective "person-read-fresh" { + about "An agents list with legacy fresh=true or an explicit min_cut asks to wait for publication; ordinary st agents ls and tree do not. Explicit waits have their own target and count toward no other" + indicator "latency" { + path "GET /v1/client/agents (fresh)" + } + target "p99" within="300ms" +} + +objective "projection-fresh" { + about "From a commit to the published missions, agents or attention view that carries it, timed by each refresher; refreshers fold at most once a second" + indicator "latency" { + path "publish missions" + path "publish agents" + path "publish attention" + } + target "p99" within="1500ms" +} + +objective "write-ack" { + about "Writes and acks: send, work actions, harness events" + indicator "latency" { + path "POST /v1/messages" + path "POST /v1/client/actions" + path "POST /v1/work/{action}/{*subject}" + path "POST /v1/work/extend/{*subject}" + path "POST /v1/work/done" + path "POST /v1/work/ask" + path "POST /v1/work/cancel-ask" + path "POST /v1/work/start" + path "POST /v1/work/handoff/{*subject}" + path "POST /v1/work/acknowledge/{*subject}" + path "POST /v1/harness-events" + path "POST /v1/mailbox/receipts" + path "POST /v1/messages/read/{*subject}" + } + target "p99" within="300ms" +} + +objective "terminal-attach" { + about "Terminal attach to first screen" + indicator "latency" { + path "GET /v1/client/terminals/{id}/screen" + path "stream terminal" + } + target "p99" within="200ms" +} + +objective "sql-statement" { + about "One SQL statement" + indicator "statement" + target "p99" within="10ms" + target "max" within="100ms" +} + +objective "transaction" { + about "Any transaction, read or write, and any hold of the store's writer" + indicator "transaction" + target "max" within="300ms" +} + +objective "writer-hold" { + about "Each hold of the store's single writer, and each read transaction past the same target, by who held it: a request route or background task, its client, and the code that asked" + indicator "writer-hold" + target "max" within="300ms" +} + +// The member's CPU is its daemon process and every replication worker process beside it, all +// their work included (maintenance, heartbeat, anti-entropy, replication), over closed UTC +// minutes. An idle minute is one known to be idle: no seat on this member working, no daemon +// start, deploy or replication catch-up in it, and every figure known. An hour with fewer than +// `idle-min-minutes` idle minutes, or that the daemon was not up for all of, is unknown. +objective "cpu" { + about "Member CPU, the daemon and its replication workers: at most 1 core over the hour, and 0.5 core over the hour's idle minutes" + indicator "cpu" { + idle-min-minutes 10 + // daemon_load's bound in CI under its synthetic load, which is not the production target. + load-max-cores 2.0 + } + target "cores" max=1.0 + target "idle-cores" max=0.5 +} + +// Closed UTC minutes reported by paired clients; distinct from daemon response timing. + +objective "ios-open-to-live" { + about "Foreground to first live Home snapshot; reported phone population only" + indicator "latency" { + path "client/ios/open-to-live" + } + target "p99" within="3000ms" +} + +objective "ios-connect" { + about "Client connection; reported phone population only" + indicator "latency" { + path "client/ios/connect" + } + target "p99" within="1500ms" +} + +objective "ios-message-ack" { + about "Client message acknowledgement; reported phone population only" + indicator "latency" { + path "client/ios/message-ack" + } + target "p99" within="500ms" +} + +objective "ios-conversation-open" { + about "Client conversation opening; reported phone population only" + indicator "latency" { + path "client/ios/conversation-open" + } + target "p99" within="1000ms" +} + +objective "ios-terminal-open" { + about "Client terminal opening; reported phone population only" + indicator "latency" { + path "client/ios/terminal-open" + } + target "p99" within="1500ms" +} + +objective "ios-recover" { + about "Client feed recovery; reported phone population only" + indicator "latency" { + path "client/ios/recover" + } + target "p99" within="10000ms" +} + +objective "ios-live-share" { + about "Live foreground time / reported foreground time; disconnected foreground is over-target" + indicator "share" { + path "client/ios/live-share" + } + target "live-share" min-percent=99 +} + +objective "projection-divergence" { + about "Open P1 projection bugs: two members that hold the same envelopes and project a table differently. Each is a fault for the fleet's fault agent with what a reproduction needs; nothing repairs it from a peer" + indicator "divergence" + target "open" max=0 +} + +objective "database" { + about "This member's store: the pages in use, and how much they grow a day once checkpoints and retention have run" + indicator "database" + target "live-gb" max=8.0 + target "growth-mb-per-day" max=300 +} + +// CI, observed outside the daemon: the merge queue and the workspace's checks. A `ci-duration` +// target is one percentile of a duration, the row `NAME/pPERCENTILE`. A `ci-count` is the most +// events an hour, the row `NAME`. `series` names what feeds it, `ci/NAME`. They are objectives in +// `fractal slo` only, not in the request-latency read or `fractal doctor`. Workspace CI's runs, +// read by the repository observer that reads main_performance_failures and held in memory, feed +// them; other members show no-data. + +objective "merge-latency" { + about "A pull request's time in the merge queue, from entering it to merging" + indicator "ci-duration" { + series "ci/merge-queue" + } + target "p50" within="10m" + target "p95" within="10m" +} + +objective "ci-critical-path" { + about "The workspace CI's critical path, from a change to all checks green" + indicator "ci-duration" { + series "ci/workspace-critical-path" + } + target "p95" within="13m" +} + +objective "flakes" { + about "Flaky results: tests nextest passed only on a retry, and merge groups ejected from the queue" + indicator "ci-count" { + series "ci/flakes" + } + target "per-hour" max=0 +} From b820f33c99eb4a29099abb32e77dd77241eda05a Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 9 Nov 2026 20:00:41 +0100 Subject: [PATCH 057/132] refactor(telemetry): build the daemon's targets from slo/objectives.kdl The shipped objectives are read by fractal_kernel::objective, the same reader as a declared objective, and slo/targets.toml is deleted. Targets keeps its shape and every existing check, and adds writer_hold (its own objective, 300 ms, equal to the transaction bound it used). Every shipped objective is one segment, none is an interval, and the statement, transaction, writer-hold, cpu, database and divergence objectives are one each under their fixed names. slo::shipped() exposes the parsed objectives. The toml dependency leaves fractal-telemetry. --- Cargo.lock | 1 - crates/fractal-delivery/src/maintenance.rs | 10 +- crates/fractal-telemetry/Cargo.toml | 1 - crates/fractal-telemetry/src/slo.rs | 342 +++++++++++++++------ slo/targets.toml | 206 ------------- 5 files changed, 260 insertions(+), 300 deletions(-) delete mode 100644 slo/targets.toml diff --git a/Cargo.lock b/Cargo.lock index fc3d79e12a9..69958628352 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2591,7 +2591,6 @@ dependencies = [ "smallclaims", "tempfile", "tokio", - "toml 0.9.12+spec-1.1.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", diff --git a/crates/fractal-delivery/src/maintenance.rs b/crates/fractal-delivery/src/maintenance.rs index 3056390198b..9df0c1a79d4 100644 --- a/crates/fractal-delivery/src/maintenance.rs +++ b/crates/fractal-delivery/src/maintenance.rs @@ -18,14 +18,14 @@ const DIVERGENCE_BUSY_BACKOFF: Duration = Duration::from_secs(60); pub fn daemon_busy(store: &Store) -> bool { use smallclaims::windows::{StoreWork, store_series}; let now = Instant::now(); - let max_ms = crate::slo::targets().transaction.max_ms as f64; - let over = |kind| { + let targets = crate::slo::targets(); + let over = |kind, max_ms: u64| { let minute = &store_series(kind).snapshot(now)["1m"]; minute["over_target"].as_u64().unwrap_or(0) > 0 - || minute["max_ms"].as_f64().unwrap_or(0.0) > max_ms + || minute["max_ms"].as_f64().unwrap_or(0.0) > max_ms as f64 }; - over(StoreWork::WriterHold) - || over(StoreWork::WriteTransaction) + over(StoreWork::WriterHold, targets.writer_hold.max_ms) + || over(StoreWork::WriteTransaction, targets.transaction.max_ms) || store.replication_catching_up() || store.replication_projection_deferred() } diff --git a/crates/fractal-telemetry/Cargo.toml b/crates/fractal-telemetry/Cargo.toml index 5ff2eff06ac..b8ca4a498aa 100644 --- a/crates/fractal-telemetry/Cargo.toml +++ b/crates/fractal-telemetry/Cargo.toml @@ -30,7 +30,6 @@ fractal-kernel = { path = "../fractal-kernel" } fractal-runtime = { path = "../fractal-runtime" } tempfile = "3" tokio = { version = "1", features = ["full"] } -toml = "0.9" tracing = "0.1" tracing-opentelemetry = "0.31" tracing-subscriber = { version = "0.3", default-features = false, features = ["ansi", "env-filter", "fmt", "registry", "std"] } diff --git a/crates/fractal-telemetry/src/slo.rs b/crates/fractal-telemetry/src/slo.rs index c11ed2e4894..284a14a2a7d 100644 --- a/crates/fractal-telemetry/src/slo.rs +++ b/crates/fractal-telemetry/src/slo.rs @@ -1,24 +1,27 @@ -//! The daemon's targets, from `slo/targets.toml`, which is built in. The same file is what -//! `st doctor` and CI's daemon_load read. A target describes what we aim for; it gates nothing. +//! The daemon's targets, from the objectives it ships in `slo/objectives.kdl`, which is built in +//! and read by the same reader as a declared objective (`fractal_kernel::objective`). The same +//! file is what `st doctor` and CI's daemon_load read. A target describes what we aim for; it +//! gates nothing. -use std::sync::OnceLock; +use std::sync::{LazyLock, OnceLock}; use std::time::Duration; -use anyhow::{Context, Result, ensure}; -use serde::{Deserialize, Serialize}; +use anyhow::{Context, Result, anyhow, bail, ensure}; +use fractal_kernel::objective::{self, Bound, Indicator, Objective}; +use serde::Serialize; use serde_json::{Value, json}; use smallclaims::windows::StoreWork; -/// The targets file this daemon was built with. -pub const SOURCE: &str = include_str!("../../../slo/targets.toml"); +/// The objectives file this daemon was built with. +pub const SOURCE: &str = include_str!("../../../slo/objectives.kdl"); -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Targets { pub latency: Vec, pub share: Vec, pub statement: Statement, pub transaction: Transaction, + pub writer_hold: WriterHold, pub cpu: Cpu, pub divergence: Divergence, pub database: Database, @@ -26,8 +29,7 @@ pub struct Targets { pub count: Vec, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Latency { pub name: String, pub about: String, @@ -37,8 +39,7 @@ pub struct Latency { pub paths: Vec, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Share { pub name: String, pub about: String, @@ -46,25 +47,30 @@ pub struct Share { pub path: String, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Statement { pub about: String, pub p99_ms: u64, pub max_ms: u64, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Transaction { pub about: String, pub max_ms: u64, } +/// Each hold of the store's single writer, and each read transaction past the same bound, by who +/// held it. +#[derive(Debug)] +pub struct WriterHold { + pub about: String, + pub max_ms: u64, +} + /// The member's CPU: its daemon process and every replication worker process beside it, over /// closed UTC minutes. See `smallclaims::windows::cpu_minutes` for what makes a minute idle. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Cpu { pub about: String, /// The most cores the member may use over the hour. @@ -79,15 +85,13 @@ pub struct Cpu { /// Projection divergences, counted fleet-wide from their open faults. A projection is a pure /// function of the claims, so each one is a bug. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Divergence { pub about: String, pub max_open: u64, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Database { pub about: String, /// The pages in use, not counting free pages SQLite reuses before the file grows. @@ -98,8 +102,7 @@ pub struct Database { /// One percentile of a duration observed outside the daemon, such as a CI run: the objective /// `NAME/pPERCENTILE`. A name may declare several percentiles, each its own objective. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct DurationTarget { pub name: String, pub about: String, @@ -119,8 +122,7 @@ impl DurationTarget { /// Events observed outside the daemon, such as flaky CI results, counted per hour: the /// objective `NAME`. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct CountTarget { pub name: String, pub about: String, @@ -150,10 +152,142 @@ impl Latency { } } -/// Read and check a targets file. A missing, unknown or nonsensical field is an error, never a -/// default. +/// Read and check an objectives file of the objectives Fractal ships. A missing, unknown or +/// nonsensical child is an error, never a default. pub fn parse(text: &str) -> Result { - let targets: Targets = toml::from_str(text).context("the targets file is not valid")?; + read(text).map(|(_, targets)| targets) +} + +fn read(text: &str) -> Result<(Vec, Targets)> { + let objectives = + objective::parse_document(text).map_err(|error| anyhow!("the objectives file is not valid: {error}"))?; + let targets = build(&objectives)?; + check(&targets)?; + Ok((objectives, targets)) +} + +/// Put `value` in the one slot its indicator has, named `expected`: `fractal doctor` and the OTLP +/// export read these by name. +fn one(slot: &mut Option, indicator: &str, expected: &str, name: &str, value: T) -> Result<()> { + ensure!(name == expected, "the {indicator} objective is named {name}, not {expected}"); + ensure!(slot.is_none(), "there are two {indicator} objectives"); + *slot = Some(value); + Ok(()) +} + +/// The targets the shipped objectives say. Every one is a single segment; an interval is only +/// ever declared. +fn build(objectives: &[Objective]) -> Result { + let mut latency = Vec::new(); + let mut share = Vec::new(); + let mut statement = None; + let mut transaction = None; + let mut writer_hold = None; + let mut cpu = None; + let mut divergence = None; + let mut database = None; + let mut duration = Vec::new(); + let mut count = Vec::new(); + for objective in objectives { + let name = objective.name.as_str(); + ensure!(!name.contains('/'), "objective {name} is not one segment: only a declared objective has a namespace"); + let about = objective.about.clone(); + let within = |stat: &str, remote: bool| objective.target(stat, remote).and_then(objective::Target::within_ms); + let required = |stat: &str| within(stat, false).with_context(|| format!("objective {name} has no {stat} duration")); + let max = |stat: &str| { + objective.target(stat, false).and_then(objective::Target::max).with_context(|| format!("objective {name} has no {stat} maximum")) + }; + let whole = |value: f64, what: &str| -> Result { + ensure!(value >= 0.0 && value.fract() == 0.0, "objective {name}'s {what} is not a whole number"); + Ok(value as u64) + }; + match &objective.indicator { + Indicator::Latency { paths } => latency.push(Latency { + name: name.to_owned(), + about, + p99_ms: required("p99")?, + remote_p99_ms: within("p99", true), + paths: paths.clone(), + }), + Indicator::Share { path } => { + let min_percent = match objective.target("live-share", false).map(|target| target.bound) { + Some(Bound::MinPercent(min)) => whole(min, "minimum percent")?, + _ => bail!("objective {name} has no live-share minimum"), + }; + share.push(Share { name: name.to_owned(), about, min_percent, path: path.clone() }); + } + Indicator::Statement => { + let value = Statement { about, p99_ms: required("p99")?, max_ms: required("max")? }; + one(&mut statement, "statement", "sql-statement", name, value)?; + } + Indicator::Transaction => { + let value = Transaction { about, max_ms: required("max")? }; + one(&mut transaction, "transaction", "transaction", name, value)?; + } + Indicator::WriterHold => { + let value = WriterHold { about, max_ms: required("max")? }; + one(&mut writer_hold, "writer-hold", "writer-hold", name, value)?; + } + Indicator::Cpu { idle_min_minutes, load_max_cores } => { + let value = Cpu { + about, + max_cores: max("cores")?, + idle_max_cores: max("idle-cores")?, + idle_min_minutes: *idle_min_minutes, + load_max_cores: *load_max_cores, + }; + one(&mut cpu, "cpu", "cpu", name, value)?; + } + Indicator::Database => { + let value = Database { + about, + max_live_gb: max("live-gb")?, + max_growth_mb_per_day: max("growth-mb-per-day")?, + }; + one(&mut database, "database", "database", name, value)?; + } + Indicator::Divergence => { + let value = Divergence { about, max_open: whole(max("open")?, "open maximum")? }; + one(&mut divergence, "divergence", "projection-divergence", name, value)?; + } + Indicator::CiDuration { series } => { + for target in &objective.targets { + duration.push(DurationTarget { + name: name.to_owned(), + about: about.clone(), + percentile: target.percentile().with_context(|| format!("objective {name}'s target {} is no percentile", target.stat))?, + target_ms: target.within_ms().with_context(|| format!("objective {name}'s target {} has no duration", target.stat))?, + path: series.clone(), + }); + } + } + Indicator::CiCount { series } => count.push(CountTarget { + name: name.to_owned(), + about, + max_per_hour: whole(max("per-hour")?, "per-hour maximum")?, + path: series.clone(), + }), + Indicator::Interval(_) => { + bail!("objective {name} is an interval: Fractal ships none; a fleet declares one with `fractal apply`") + } + } + } + let missing = |indicator: &str| format!("there is no {indicator} objective"); + Ok(Targets { + latency, + share, + statement: statement.with_context(|| missing("statement"))?, + transaction: transaction.with_context(|| missing("transaction"))?, + writer_hold: writer_hold.with_context(|| missing("writer-hold"))?, + cpu: cpu.with_context(|| missing("cpu"))?, + divergence: divergence.with_context(|| missing("divergence"))?, + database: database.with_context(|| missing("database"))?, + duration, + count, + }) +} + +fn check(targets: &Targets) -> Result<()> { let mut names = std::collections::BTreeSet::new(); let mut paths = std::collections::BTreeSet::new(); for latency in &targets.latency { @@ -201,6 +335,7 @@ pub fn parse(text: &str) -> Result { "the statement targets are inconsistent" ); ensure!(targets.transaction.max_ms > 0, "the transaction target is zero"); + ensure!(targets.writer_hold.max_ms > 0, "the writer-hold target is zero"); let cpu = &targets.cpu; ensure!( [cpu.max_cores, cpu.idle_max_cores, cpu.load_max_cores] @@ -245,13 +380,22 @@ pub fn parse(text: &str) -> Result { count.path ); } - Ok(targets) + Ok(()) +} + +/// The shipped objectives and the targets they say. The file is checked by a test, so this cannot +/// fail in a release. +static BUILT_IN: LazyLock<(Vec, Targets)> = + LazyLock::new(|| read(SOURCE).expect("the built-in objectives file is valid")); + +/// The objectives Fractal ships, as the objective reader reads them. +pub fn shipped() -> &'static [Objective] { + &BUILT_IN.0 } -/// The built-in targets. The file is checked by a test, so this cannot fail in a release. +/// The built-in targets. pub fn targets() -> &'static Targets { - static TARGETS: OnceLock = OnceLock::new(); - TARGETS.get_or_init(|| parse(SOURCE).expect("the built-in targets file is valid")) + &BUILT_IN.1 } impl Targets { @@ -274,6 +418,10 @@ impl Targets { pub fn transaction_target(&self) -> Duration { Duration::from_millis(self.transaction.max_ms) } + + pub fn writer_hold_target(&self) -> Duration { + Duration::from_millis(self.writer_hold.max_ms) + } } /// Give the store's windows their targets, once per process. @@ -285,13 +433,10 @@ pub fn install() { StoreWork::Statement, Some(targets.statement_target()), ); - for work in [ - StoreWork::ReadTransaction, - StoreWork::WriteTransaction, - StoreWork::WriterHold, - ] { + for work in [StoreWork::ReadTransaction, StoreWork::WriteTransaction] { smallclaims::windows::set_store_target(work, Some(targets.transaction_target())); } + smallclaims::windows::set_store_target(StoreWork::WriterHold, Some(targets.writer_hold_target())); }); } @@ -486,18 +631,18 @@ const OPEN_READS: usize = 8; /// Routes whose read loans are reported beside the writer's holds; the rest are summed. const LOAN_ROUTES: usize = 8; -/// The writer-hold objective: each hold of the store's single writer against the transaction -/// target, and who held it; each read transaction past that target, and who kept it open. +/// The writer-hold objective: each hold of the store's single writer against its target, and who +/// held it; each read transaction past that target, and who kept it open. fn writer_hold_row() -> Value { let targets = targets(); let series = |work| smallclaims::windows::store_series(work).snapshot(std::time::Instant::now()); let mut row = json!({ "name": "writer-hold", - "about": "Each hold of the store's single writer, and each read transaction past the same target, by who held it: a request route or background task, its client, and the code that asked", - "max_ms": targets.transaction.max_ms, + "about": targets.writer_hold.about, + "max_ms": targets.writer_hold.max_ms, "windows": series(StoreWork::WriterHold), "read_windows": series(StoreWork::ReadTransaction), - "open_reads": smallclaims::sqlite::open_snapshots_over(targets.transaction_target(), OPEN_READS), + "open_reads": smallclaims::sqlite::open_snapshots_over(targets.writer_hold_target(), OPEN_READS), // Every read loan by the route it works for, checked out or with a confirmed snapshot: // a loan held past its budget keeps the WAL from recycling even before it reads. "read_loans": smallclaims::sqlite::loans_by_route(LOAN_ROUTES), @@ -777,8 +922,10 @@ mod tests { use super::*; #[test] - fn the_built_in_targets_file_is_valid_and_names_the_mission_targets() { + fn the_built_in_objectives_file_is_valid_and_names_the_mission_targets() { let targets = parse(SOURCE).unwrap(); + assert_eq!(shipped().len(), 21); + assert!(shipped().iter().all(|objective| !objective.declarable())); let read = targets.for_path("GET /v1/client/agents").unwrap(); assert_eq!((read.p99_ms, read.remote_p99_ms), (300, Some(300))); assert_eq!(read.target(true), Duration::from_millis(300)); @@ -819,7 +966,7 @@ mod tests { let attach = targets.for_path("stream terminal").unwrap(); assert_eq!(attach.p99_ms, 200); assert_eq!((targets.statement.p99_ms, targets.statement.max_ms), (10, 100)); - assert_eq!(targets.transaction.max_ms, 300); + assert_eq!((targets.transaction.max_ms, targets.writer_hold.max_ms), (300, 300)); assert_eq!( (targets.cpu.max_cores, targets.cpu.idle_max_cores, targets.cpu.idle_min_minutes, targets.cpu.load_max_cores), (1.0, 0.5, 10, 2.0) @@ -846,7 +993,7 @@ mod tests { } #[test] - fn every_route_path_in_the_targets_file_is_a_route_the_daemon_serves() { + fn every_route_path_in_the_objectives_file_is_a_route_the_daemon_serves() { // api.rs's route table and the route families the daemon adds to it (fractal-daemon's `api::router`). let source = [ include_str!("../../fractal-api-core/src/api.rs"), @@ -929,7 +1076,7 @@ mod tests { install(); let rows = store_report(json!({})); let row = rows.iter().find(|row| row["name"] == "writer-hold").unwrap(); - assert_eq!(row["max_ms"], targets().transaction.max_ms); + assert_eq!(row["max_ms"], targets().writer_hold.max_ms); for field in ["windows", "read_windows", "open_reads", "holders", "long_holds", "long_reads", "in_progress", "waiting"] { assert!(row.get(field).is_some(), "{field} missing from {row}"); } @@ -942,61 +1089,82 @@ mod tests { assert!(row["windows"]["1h"].is_object()); } + /// The shipped file without objective `name`. + fn without(name: &str) -> String { + let start = SOURCE.find(&format!("\nobjective \"{name}\" {{")).unwrap() + 1; + let end = start + SOURCE[start..].find("\n}\n").unwrap() + 3; + format!("{}{}", &SOURCE[..start], &SOURCE[end..]) + } + + fn error(text: &str) -> String { + parse(text).unwrap_err().to_string() + } + #[test] - fn a_broken_targets_file_is_refused_not_defaulted() { - let without_cpu = SOURCE.split("[cpu]").next().unwrap(); - assert!(parse(without_cpu).is_err()); + fn a_broken_objectives_file_is_refused_not_defaulted() { + for name in ["sql-statement", "transaction", "writer-hold", "cpu", "database", "projection-divergence"] { + assert!(error(&without(name)).contains("there is no"), "{name}"); + } for (from, to) in [ - ("\nmax_cores = 1.0", "\nmax_cores = -1.0"), - ("\nmax_cores = 1.0", "\nmax_cores = 0.4"), - ("idle_max_cores = 0.5", "idle_max_cores = 0.0"), - ("idle_max_cores = 0.5", "idle_max_cores = nan"), - ("load_max_cores = 2.0", "load_max_cores = inf"), - ("idle_min_minutes = 10", "idle_min_minutes = 0"), - ("idle_min_minutes = 10", "idle_min_minutes = 61"), - ("idle_min_minutes = 10", "idle_min_minutes = 10.5"), - ("load_max_cores = 2.0\n", ""), - ("idle_max_cores = 0.5\n", ""), + ("target \"cores\" max=1.0", "target \"cores\" max=-1.0"), + ("target \"cores\" max=1.0", "target \"cores\" max=0.4"), + ("target \"idle-cores\" max=0.5", "target \"idle-cores\" max=0.0"), + ("target \"idle-cores\" max=0.5", "target \"idle-cores\" max=#nan"), + ("load-max-cores 2.0", "load-max-cores #inf"), + ("idle-min-minutes 10", "idle-min-minutes 0"), + ("idle-min-minutes 10", "idle-min-minutes 61"), + ("idle-min-minutes 10", "idle-min-minutes 10.5"), + (" load-max-cores 2.0\n", ""), + (" target \"idle-cores\" max=0.5\n", ""), + ("target \"live-gb\" max=8.0", "target \"live-gb\" max=0.0"), + (" indicator \"statement\"\n", " indicator \"statement\"\n typo 1\n"), + ("target \"live-share\" min-percent=99", "target \"live-share\" min-percent=99.5"), + ("target \"open\" max=0", "target \"open\" max=0.5"), ] { assert!(SOURCE.contains(from), "{from}"); assert!(parse(&SOURCE.replace(from, to)).is_err(), "{to}"); } - assert!(parse(SOURCE.split("[database]").next().unwrap()).is_err()); - assert!(parse(&SOURCE.replace("max_live_gb = 8.0", "max_live_gb = 0.0")).is_err()); - assert!(parse(&SOURCE.replace("p99_ms = 10\n", "p99_ms = 10\ntypo = 1\n")).is_err()); let twice = format!( - "{SOURCE}\n[[latency]]\nname = \"again\"\nabout = \"\"\np99_ms = 1\npaths = [\"GET /v1/client/now\"]\n" + "{SOURCE}\nobjective \"again\" {{\n about \"x\"\n indicator \"latency\" {{\n path \"GET /v1/client/now\"\n }}\n target \"p99\" within=\"1ms\"\n}}\n" ); - assert!(parse(&twice).unwrap_err().to_string().contains("two targets")); + assert!(error(&twice).contains("two targets")); + // Each statement, transaction, writer-hold, CPU, database and divergence objective is + // the one of its name; the shipped ones are one segment, and none is an interval. + assert!(error(&SOURCE.replace("objective \"database\"", "objective \"store-size\"")).contains("named store-size, not database")); + assert!(error(&SOURCE.replace("objective \"flakes\"", "objective \"ci/flakes\"")).contains("not one segment")); + let interval = format!( + "{SOURCE}\nobjective \"change-rollout\" {{\n about \"x\"\n indicator \"interval\" {{\n subject-prefix \"custom/acme/change/\"\n start \"custom.acme.change-ready\"\n stop \"custom.acme.change-applied\"\n }}\n window \"7d\"\n target \"p95\" within=\"5m\"\n}}\n" + ); + assert!(error(&interval).contains("is an interval"), "{}", error(&interval)); } #[test] fn ci_durations_and_counts_are_checked() { - let error = |text: &str| parse(text).unwrap_err().to_string(); - let p95 = "percentile = 95\ntarget_ms = 600000\npath = \"ci/merge-queue\""; - assert!(parse(SOURCE.split("[[count]]").next().unwrap()).is_err(), "the count kind is required"); - for percentile in ["0", "100", "250"] { - let text = SOURCE.replacen("percentile = 95", &format!("percentile = {percentile}"), 1); - assert!(error(&text).contains("between 1 and 99"), "{percentile}"); + for percentile in ["p0", "p100", "p250", "p-5"] { + let text = SOURCE.replacen("target \"p95\" within=\"13m\"", &format!("target \"{percentile}\" within=\"13m\""), 1); + assert!(error(&text).contains("no target"), "{percentile}"); + } + assert!(parse(&SOURCE.replacen("target \"p95\" within=\"13m\"", "target \"p95\" within=\"0ms\"", 1)).is_err()); + for to in ["max=-1", "max=0.5", "max=0 min-per-hour=0"] { + assert!(parse(&SOURCE.replace("target \"per-hour\" max=0", &format!("target \"per-hour\" {to}"))).is_err(), "{to}"); + } + for series in ["GET /v1/flakes", "ci/Flakes", "ci/"] { + assert!(error(&SOURCE.replace("\"ci/flakes\"", &format!("\"{series}\""))).contains("is not `ci/`"), "{series}"); } - assert!(parse(&SOURCE.replacen("percentile = 95", "percentile = -5", 1)).is_err()); - assert!(error(&SOURCE.replacen("target_ms = 780000", "target_ms = 0", 1)).contains("no duration")); - assert!(parse(&SOURCE.replace("max_per_hour = 0", "max_per_hour = -1")).is_err()); - assert!(parse(&SOURCE.replace("max_per_hour = 0", "max_per_hour = 0.5")).is_err()); - assert!(parse(&SOURCE.replace("max_per_hour = 0", "max_per_hour = 0\nmin_per_hour = 0")).is_err()); - assert!(error(&SOURCE.replace("\"ci/flakes\"", "\"GET /v1/flakes\"")).contains("not a CI series")); - assert!(error(&SOURCE.replace("\"ci/flakes\"", "\"ci/Flakes\"")).contains("not a CI series")); - assert!(error(&SOURCE.replace("\"ci/flakes\"", "\"ci/\"")).contains("not a CI series")); - // The same percentile twice is one objective named twice. - let again = format!("{SOURCE}\n[[duration]]\nname = \"merge-latency\"\nabout = \"\"\n{p95}\n"); - assert!(error(&again).contains("named twice")); + // The same percentile twice is one target repeated. + let again = SOURCE.replace(" target \"p95\" within=\"10m\"\n", " target \"p95\" within=\"10m\"\n target \"p95\" within=\"9m\"\n"); + assert!(error(&again).contains("repeats target")); // Another name may not take a series that feeds one already. - let other = format!("{SOURCE}\n[[duration]]\nname = \"other\"\nabout = \"\"\n{p95}\n"); + let other = format!( + "{SOURCE}\nobjective \"other\" {{\n about \"x\"\n indicator \"ci-duration\" {{\n series \"ci/merge-queue\"\n }}\n target \"p95\" within=\"10m\"\n}}\n" + ); assert!(error(&other).contains("two targets")); - let count = format!("{SOURCE}\n[[count]]\nname = \"other\"\nabout = \"\"\nmax_per_hour = 1\npath = \"ci/merge-queue\"\n"); + let count = format!( + "{SOURCE}\nobjective \"other\" {{\n about \"x\"\n indicator \"ci-count\" {{\n series \"ci/merge-queue\"\n }}\n target \"per-hour\" max=1\n}}\n" + ); assert!(error(&count).contains("two targets")); - let named = SOURCE.replace("name = \"flakes\"", "name = \"write-ack\""); - assert!(error(&named).contains("named twice")); + let named = SOURCE.replace("objective \"flakes\"", "objective \"write-ack\""); + assert!(error(&named).contains("declared twice")); } #[test] diff --git a/slo/targets.toml b/slo/targets.toml deleted file mode 100644 index 559aa61931d..00000000000 --- a/slo/targets.toml +++ /dev/null @@ -1,206 +0,0 @@ -# What the st daemon aims for. Every daemon builds this file in, shows each target's live -# 1-minute, 5-minute and 1-hour windows on `GET /v1/client/request-latency` and in `st doctor`, -# and CI's daemon_load reads it too. A latency is a 99th percentile unless it says max. -# -# A target describes; it gates nothing by itself. daemon_load's budgets stay its gates. -# -# A path is `METHOD /route` as the daemon routes it, or `stream COLLECTION` for the time from a -# subscription on the client collections socket to its first snapshot or screen. A request that -# asks to wait (`wait_ms` above zero, or `wait=true`) is a long poll and counts toward no target. -# An agents list asked to be fresh (`fresh=true`) waits for one roster refresh by design: it is -# the path `GET /v1/client/agents (fresh)` and counts toward its own target, not the first page's. -# `publish COLLECTION` is a refresher's publication: the time from the first claim this node -# committed after the previous publication to the publication that carries it. - -[[latency]] -name = "person-read" -about = "Reads a person waits on: agents list, missions list, open a conversation, conversation changes, work list, Now, what a run waits on, documents" -p99_ms = 300 -# The same reads through this daemon from another machine that owns them. -remote_p99_ms = 300 -paths = [ - # Agents list - "GET /v1/client/agents", - "GET /v1/client/agents/{*id}", - "stream agents", - # Missions list - "GET /v1/client/missions", - "GET /v1/client/missions/{*id}", - "stream missions", - # Open a conversation, and its changes - "GET /v1/client/sessions/{*id}", - "stream conversation", - "GET /v1/client/conversations/{id}/changes", - # Work list - "GET /v1/client/work", - "GET /v1/client/work/{*id}", - "GET /v1/work", - "stream work", - # Now - "GET /v1/client/now", - "GET /v1/client/attention", - "stream attention", - # Why a run is stuck, and every blocked run - "GET /v1/mission-runs/{run}/waiting-on", - "GET /v1/blocked-runs", - # Documents - "GET /v1/client/documents/content", - "GET /v1/documents", - "GET /v1/documents/content", -] - -[[latency]] -name = "person-read-fresh" -about = "An agents list with legacy fresh=true or an explicit min_cut asks to wait for publication; ordinary st agents ls and tree do not. Explicit waits have their own target and count toward no other" -p99_ms = 300 -paths = [ - "GET /v1/client/agents (fresh)", -] - -[[latency]] -name = "projection-fresh" -about = "From a commit to the published missions, agents or attention view that carries it, timed by each refresher; refreshers fold at most once a second" -p99_ms = 1500 -paths = [ - "publish missions", - "publish agents", - "publish attention", -] - -[[latency]] -name = "write-ack" -about = "Writes and acks: send, work actions, harness events" -p99_ms = 300 -paths = [ - "POST /v1/messages", - "POST /v1/client/actions", - "POST /v1/work/{action}/{*subject}", - "POST /v1/work/extend/{*subject}", - "POST /v1/work/done", - "POST /v1/work/ask", - "POST /v1/work/cancel-ask", - "POST /v1/work/start", - "POST /v1/work/handoff/{*subject}", - "POST /v1/work/acknowledge/{*subject}", - "POST /v1/harness-events", - "POST /v1/mailbox/receipts", - "POST /v1/messages/read/{*subject}", -] - -[[latency]] -name = "terminal-attach" -about = "Terminal attach to first screen" -p99_ms = 200 -paths = [ - "GET /v1/client/terminals/{id}/screen", - "stream terminal", -] - -[statement] -about = "One SQL statement" -p99_ms = 10 -max_ms = 100 - -[transaction] -about = "Any transaction, read or write, and any hold of the store's writer" -max_ms = 300 - -# The member's CPU is its daemon process and every replication worker process beside it, all -# their work included (maintenance, heartbeat, anti-entropy, replication), over closed UTC -# minutes. An idle minute is one known to be idle: no seat on this member working, no daemon -# start, deploy or replication catch-up in it, and every figure known. An hour with fewer than -# `idle_min_minutes` idle minutes, or that the daemon was not up for all of, is unknown. -[cpu] -about = "Member CPU, the daemon and its replication workers: at most 1 core over the hour, and 0.5 core over the hour's idle minutes" -max_cores = 1.0 -idle_max_cores = 0.5 -idle_min_minutes = 10 -# daemon_load's bound in CI under its synthetic load, which is not the production target. -load_max_cores = 2.0 - -# Closed UTC minutes reported by paired clients; distinct from daemon response timing. - -[[latency]] -name = "ios-open-to-live" -about = "Foreground to first live Home snapshot; reported phone population only" -p99_ms = 3000 -paths = ["client/ios/open-to-live"] - -[[latency]] -name = "ios-connect" -about = "Client connection; reported phone population only" -p99_ms = 1500 -paths = ["client/ios/connect"] - -[[latency]] -name = "ios-message-ack" -about = "Client message acknowledgement; reported phone population only" -p99_ms = 500 -paths = ["client/ios/message-ack"] - -[[latency]] -name = "ios-conversation-open" -about = "Client conversation opening; reported phone population only" -p99_ms = 1000 -paths = ["client/ios/conversation-open"] - -[[latency]] -name = "ios-terminal-open" -about = "Client terminal opening; reported phone population only" -p99_ms = 1500 -paths = ["client/ios/terminal-open"] - -[[latency]] -name = "ios-recover" -about = "Client feed recovery; reported phone population only" -p99_ms = 10000 -paths = ["client/ios/recover"] - -[[share]] -name = "ios-live-share" -about = "Live foreground time / reported foreground time; disconnected foreground is over-target" -min_percent = 99 -path = "client/ios/live-share" - -[divergence] -about = "Open P1 projection bugs: two members that hold the same envelopes and project a table differently. Each is a fault for the fleet's fault agent with what a reproduction needs; nothing repairs it from a peer" -max_open = 0 - -[database] -about = "This member's store: the pages in use, and how much they grow a day once checkpoints and retention have run" -max_live_gb = 8.0 -max_growth_mb_per_day = 300 - -# CI, observed outside the daemon: the merge queue and the workspace's checks. A `[[duration]]` is -# one percentile of a duration, the objective `NAME/pPERCENTILE`; a name may declare several. A -# `[[count]]` is the most events an hour, the objective `NAME`. `path` names the series that feeds -# it, `ci/NAME`. They are objectives in `st slo` only, not in the request-latency read or `st -# doctor`. Workspace CI's runs, read by the repository observer that reads -# main_performance_failures and held in memory, feed them; other members show no-data. - -[[duration]] -name = "merge-latency" -about = "A pull request's time in the merge queue, from entering it to merging" -percentile = 50 -target_ms = 600000 -path = "ci/merge-queue" - -[[duration]] -name = "merge-latency" -about = "A pull request's time in the merge queue, from entering it to merging" -percentile = 95 -target_ms = 600000 -path = "ci/merge-queue" - -[[duration]] -name = "ci-critical-path" -about = "The workspace CI's critical path, from a change to all checks green" -percentile = 95 -target_ms = 780000 -path = "ci/workspace-critical-path" - -[[count]] -name = "flakes" -about = "Flaky results: tests nextest passed only on a retry, and merge groups ejected from the queue" -max_per_hour = 0 -path = "ci/flakes" From 2305eeaa507b4baa134d2fd0bb712be312e1fed3 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 9 Nov 2026 20:00:42 +0100 Subject: [PATCH 058/132] docs: name slo/objectives.kdl wherever slo/targets.toml was named Comments, docs, CI scripts and lane path inputs point at the objectives file; docs/st3/README.md links docs/st3/objectives.md; a release note records the unchanged behavior. --- .github/workflows/perf-load.ts | 2 +- .st/evidence-skip-policy.json | 4 ++-- .st/required-lanes.json | 4 ++-- crates/fractal-api-core/src/api.rs | 8 ++++---- .../fractal-api-core/src/api/request_latency.rs | 6 +++--- crates/fractal-api-core/src/objective_faults.rs | 2 +- crates/fractal-api-core/src/objectives.rs | 4 ++-- .../src/resource/github_workflows.rs | 2 +- crates/fractal-store/src/store/database_size.rs | 4 ++-- crates/fractal-telemetry/src/otlp/slo.rs | 4 ++-- crates/fractal/tests/daemon_load.rs | 16 ++++++++-------- crates/smallclaims/src/windows/cpu_minutes.rs | 2 +- docs/ci.md | 6 +++--- docs/st3/README.md | 2 ++ docs/st3/client-v0/observations.md | 4 ++-- docs/st3/gate-evidence.md | 2 +- docs/st3/profiling.md | 4 ++-- docs/st3/replication.md | 2 +- release-notes/shipped-objectives-kdl.json | 11 +++++++++++ scripts/ci-perf | 4 ++-- scripts/ci-perf-slot | 2 +- 21 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 release-notes/shipped-objectives-kdl.json diff --git a/.github/workflows/perf-load.ts b/.github/workflows/perf-load.ts index 5e72dc48a6b..e74b29fc350 100644 --- a/.github/workflows/perf-load.ts +++ b/.github/workflows/perf-load.ts @@ -106,7 +106,7 @@ python3 scripts/ci-perf-cache pack`, }, ], }, - // The same daemon beside a CPU and disk hog at seat priority, reported against slo/targets.toml. A missed + // The same daemon beside a CPU and disk hog at seat priority, reported against slo/objectives.kdl. A missed // target only reports (until ST_LOAD_HOG_STRICT=1); a failed request or a hog that never loaded the // machine fails the job, and a red daily lane alerts. It takes a second performance runner for up to 45 // minutes, so it runs once a day, not on pull requests. diff --git a/.st/evidence-skip-policy.json b/.st/evidence-skip-policy.json index 8da8120337d..8532fc18160 100644 --- a/.st/evidence-skip-policy.json +++ b/.st/evidence-skip-policy.json @@ -262,7 +262,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ], @@ -497,7 +497,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ], diff --git a/.st/required-lanes.json b/.st/required-lanes.json index c30dd4ac71e..6d5ef8c1d84 100644 --- a/.st/required-lanes.json +++ b/.st/required-lanes.json @@ -630,7 +630,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ] @@ -836,7 +836,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ] diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 977cc16d20a..9b03d5d0229 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -1618,12 +1618,12 @@ pub fn request_latency_snapshot() -> Vec { request_latency().lock().unwrap().snapshot() } -/// Only the targets' live windows, for the collector: see `slo/targets.toml`. +/// Only the targets' live windows, for the collector: see `slo/objectives.kdl`. pub fn request_latency_target_windows() -> Value { request_latency().lock().unwrap().targets_only(Instant::now()) } -/// Every target's live windows, and every path's: see `slo/targets.toml`. +/// Every target's live windows, and every path's: see `slo/objectives.kdl`. pub fn request_latency_windows() -> Value { request_latency().lock().unwrap().windows(Instant::now()) } @@ -1633,8 +1633,8 @@ fn note_cpu_busy(busy: smallclaims::windows::cpu_minutes::Busy) { request_latency().lock().unwrap_or_else(std::sync::PoisonError::into_inner).cpu_minutes().note(busy); } -/// Close each UTC minute of the member's CPU as it ends, for the CPU targets in -/// `slo/targets.toml`: read the daemon's CPU at the boundary beside the newest reading each of +/// Close each UTC minute of the member's CPU as it ends, for the `cpu` objective in +/// `slo/objectives.kdl`: read the daemon's CPU at the boundary beside the newest reading each of /// the `workers` replication workers this member runs has sent, note a replication catch-up, and /// check the seats of the minute closed one boundary earlier, once every harness observation /// stamped inside it has committed. The first boundary is this daemon's start. diff --git a/crates/fractal-api-core/src/api/request_latency.rs b/crates/fractal-api-core/src/api/request_latency.rs index 9de20cfc29d..33c4cf44174 100644 --- a/crates/fractal-api-core/src/api/request_latency.rs +++ b/crates/fractal-api-core/src/api/request_latency.rs @@ -1,7 +1,7 @@ //! Completed response-envelope timings. Action buckets are subsets of route totals. //! -//! Each path also keeps rolling 1-minute, 5-minute and 1-hour windows, and each target in -//! `slo/targets.toml` keeps the same windows over all its paths. +//! Each path also keeps rolling 1-minute, 5-minute and 1-hour windows, and each latency objective +//! in `slo/objectives.kdl` keeps the same windows over all its paths. use std::collections::{BTreeMap, VecDeque}; use std::time::{Duration, Instant}; @@ -246,7 +246,7 @@ pub struct Meter { work_stages: BTreeMap, /// Windows by path: `GET /route`, `stream COLLECTION`, or a long poll's route. paths: BTreeMap, - /// Windows by target position in `slo/targets.toml`: served here, then from another machine. + /// Windows by target position in `slo/objectives.kdl`: served here, then from another machine. targets: Vec<[smallclaims::windows::Series; 2]>, /// The member's CPU by closed minute: the daemon's and its replication workers'. cpu: smallclaims::windows::cpu_minutes::CpuMinutes, diff --git a/crates/fractal-api-core/src/objective_faults.rs b/crates/fractal-api-core/src/objective_faults.rs index b82bea81ff6..dab1e41529d 100644 --- a/crates/fractal-api-core/src/objective_faults.rs +++ b/crates/fractal-api-core/src/objective_faults.rs @@ -1,6 +1,6 @@ //! A missed CI objective is a fault for the fleet's fault agent. //! -//! The objectives `slo/targets.toml` declares for CI (each `[[duration]]` and `[[count]]`) are +//! The objectives `slo/objectives.kdl` ships for CI (each `ci-duration` and `ci-count`) are //! read from the rows the SLO history samples once a minute. An objective whose row is missed //! opens one `operational.failure` on this member's `daemon/NODE` subject, addressed to the fleet //! fault agent and never to a person; it stays one episode until the row is met again, when an diff --git a/crates/fractal-api-core/src/objectives.rs b/crates/fractal-api-core/src/objectives.rs index ec7fb92c018..f3b8d846f93 100644 --- a/crates/fractal-api-core/src/objectives.rs +++ b/crates/fractal-api-core/src/objectives.rs @@ -2,8 +2,8 @@ //! //! An objective is a target with windows and an error budget: the daemon's latency targets, its //! store and CPU targets, the database's size and the paired phone's reported targets, all from -//! `slo/targets.toml` and the same report as `GET /v1/client/request-latency`, and CI's durations -//! and counts, which `slo/targets.toml` declares and the Workspace CI runs a repository observer +//! `slo/objectives.kdl` and the same report as `GET /v1/client/request-latency`, and CI's durations +//! and counts, which `slo/objectives.kdl` ships and the Workspace CI runs a repository observer //! read feed, held in memory on the member that observes. A budget is an //! allowance that resets: each account's weekly and 5-hour limits, from the readings `st usage` //! shows. Nothing here measures anything. It reads those sources and says, for each row, its diff --git a/crates/fractal-github-base/src/resource/github_workflows.rs b/crates/fractal-github-base/src/resource/github_workflows.rs index a01c4ae8a85..15f08b532e3 100644 --- a/crates/fractal-github-base/src/resource/github_workflows.rs +++ b/crates/fractal-github-base/src/resource/github_workflows.rs @@ -157,7 +157,7 @@ const WORKSPACE_WORKFLOW: &str = "Workspace CI"; const WORKSPACE_WORKFLOW_PATH: &str = ".github/workflows/fleet.yml"; /// A merge group's head branch: `gh-readonly-queue/BASE/pr-N-SHA`. const MERGE_GROUP_BRANCH_PREFIX: &str = "gh-readonly-queue/"; -/// The series these runs feed, as `slo/targets.toml` names them. +/// The series these runs feed, as `slo/objectives.kdl` names them. pub const CRITICAL_PATH_SERIES: &str = "ci/workspace-critical-path"; pub const MERGE_QUEUE_SERIES: &str = "ci/merge-queue"; pub const FLAKES_SERIES: &str = "ci/flakes"; diff --git a/crates/fractal-store/src/store/database_size.rs b/crates/fractal-store/src/store/database_size.rs index a976bbe6f6f..9146b173646 100644 --- a/crates/fractal-store/src/store/database_size.rs +++ b/crates/fractal-store/src/store/database_size.rs @@ -1,5 +1,5 @@ -//! How big this member's store is, and how much it grows a day: the database target in -//! `slo/targets.toml`, and the physical growth that disk-filling conditions read. The daemon +//! How big this member's store is, and how much it grows a day: the database objective in +//! `slo/objectives.kdl`, and the physical growth that disk-filling conditions read. The daemon //! samples the store hourly and keeps two days of samples in `meta`, so the daily growth survives //! restarts. A sample reads three pragmas and the WAL file's length. It is the one sampler: the //! newest sample is cached for every reader (`fractal_daemon::slo::database_size`). diff --git a/crates/fractal-telemetry/src/otlp/slo.rs b/crates/fractal-telemetry/src/otlp/slo.rs index 2cdf09ff9cf..87a312e942d 100644 --- a/crates/fractal-telemetry/src/otlp/slo.rs +++ b/crates/fractal-telemetry/src/otlp/slo.rs @@ -5,7 +5,7 @@ //! collector holds the history. Nothing here runs on a request path; once a minute a task reads //! the same report as `GET /v1/client/request-latency` and sends it to `[observations.otlp]`. //! -//! Every point carries bounded labels, all from `slo/targets.toml` or fixed words: `target`, the +//! Every point carries bounded labels, all from `slo/objectives.kdl` or fixed words: `target`, the //! `series` inside it (`owner` or `remote` for a read target, `reported` for a latency a paired //! phone reported, `statement`, `read`, `write` or `writer_hold` for the store), the `window` //! (`1m`, `5m`, `1h`) and, for the latency gauge, the `stat` (`p50`, `p99`, `max`). The target @@ -154,7 +154,7 @@ pub fn metrics(node: &str, report: &Value, now_unix_ms: u64) -> Option { gauge( "st_slo_target_ms", "ms", - "The target in slo/targets.toml for a series: its 99th percentile, or its maximum", + "The target in slo/objectives.kdl for a series: its 99th percentile, or its maximum", targets, ), gauge( diff --git a/crates/fractal/tests/daemon_load.rs b/crates/fractal/tests/daemon_load.rs index 97cba643bf7..1af65c48215 100644 --- a/crates/fractal/tests/daemon_load.rs +++ b/crates/fractal/tests/daemon_load.rs @@ -78,8 +78,8 @@ const BASELINE_RUNS: usize = 5; /// The daemon's CPU, in cores, may differ from the baseline by this much whatever the ratio. const CPU_SLACK: f64 = 0.05; -/// The daemon's average CPU over the run may not pass this many cores: `load_max_cores` in -/// `slo/targets.toml`, built into st3, the bound under this synthetic load rather than the +/// The daemon's average CPU over the run may not pass this many cores: the `cpu` objective's +/// `load-max-cores` in `slo/objectives.kdl`, built into st3, the bound under this synthetic load rather than the /// member's production target. A missing file fails the build and an invalid one fails the run; /// there is no default. fn cpu_budget() -> f64 { @@ -348,7 +348,7 @@ struct PathReport { p99_ms: f64, max_ms: f64, budget_ms: f64, - /// The path's p99 target in `slo/targets.toml`, when it has one. It describes; the run's + /// The path's p99 target in `slo/objectives.kdl`, when it has one. It describes; the run's /// gates are `budget_ms` and main's baseline, not this. #[serde(default, skip_serializing_if = "Option::is_none")] descriptive_target_ms: Option, @@ -439,7 +439,7 @@ const HOG_MEASURES: &[&str] = &["person-read", "person-read-fresh", "write-ack", /// Runs the daemon twice on the same generated store, once alone and once beside a CPU and disk /// hog at the priority seats get by default, with the load average held above the core count. /// It reports person-read, person-read-fresh, write-ack and message send against the targets in -/// `slo/targets.toml` (what `GET /v1/client/request-latency` reports) for each run. +/// `slo/objectives.kdl` (what `GET /v1/client/request-latency` reports) for each run. /// /// ```sh /// ST_LOAD_GATE=1 ST_LOAD_HOG=1 ST_LOAD_HOG_REPORT=out TMPDIR=/var/tmp cargo test --release \ @@ -1273,7 +1273,7 @@ fn targets_come_from_the_targets_file_and_describe_without_gating() { ] { let target = targets .for_path(path) - .unwrap_or_else(|| panic!("{path} has no target in slo/targets.toml")); + .unwrap_or_else(|| panic!("{path} has no target in slo/objectives.kdl")); assert_eq!(descriptive_target(name), Some(target.target(false)), "{name}"); } for name in ["claim", "replication exchange", LONG_POLL] { @@ -1479,7 +1479,7 @@ fn print(report: &Report) { name, path.count, path.p50_ms, path.p99_ms, path.max_ms, path.budget_ms, target ); } - println!("target: slo/targets.toml's p99, described and never gated"); + println!("target: slo/objectives.kdl's p99, described and never gated"); if !report.targets.is_empty() { println!("{:<20} {:>7} {:>8} {:>8} {:>8} {:>8}", "target", "n", "p50 ms", "p99 ms", "max ms", "target"); for (name, target) in &report.targets { @@ -2948,7 +2948,7 @@ fn runtime_of(seat: &str) -> String { format!("load-{}-runtime", seat.rsplit('/').next().unwrap_or(seat)) } -/// The path in `slo/targets.toml` a request stands for, when it stands for one. Lease renewal +/// The path in `slo/objectives.kdl` a request stands for, when it stands for one. Lease renewal /// calls the store directly; it is timed as the work action it is. fn target_path(name: &str) -> Option { match name { @@ -3003,7 +3003,7 @@ fn target_rows(timings: &BTreeMap>) -> BTreeMap Option { let path = target_path(name)?; Some(fractal_daemon::slo::targets().for_path(&path)?.target(false)) diff --git a/crates/smallclaims/src/windows/cpu_minutes.rs b/crates/smallclaims/src/windows/cpu_minutes.rs index 2af590f86db..f8779b95c36 100644 --- a/crates/smallclaims/src/windows/cpu_minutes.rs +++ b/crates/smallclaims/src/windows/cpu_minutes.rs @@ -1,4 +1,4 @@ -//! The member's CPU by closed UTC minute, for the CPU targets in `slo/targets.toml`: the daemon's +//! The member's CPU by closed UTC minute, for the `cpu` objective in `slo/objectives.kdl`: the daemon's //! own process CPU and each replication worker's, with what kept each minute from being idle. //! //! The daemon reads its own CPU at each minute's boundary. A replication worker sends its diff --git a/docs/ci.md b/docs/ci.md index 899ded2c823..bb86df4ad20 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1294,8 +1294,8 @@ those sparse p99s are effectively observed maxima. This bounded tolerance still regressions on rare paths. CPU tolerates 0.05 cores. A PR without a main baseline fails as P0; a main bootstrap may check only absolute budgets and errors. -The CPU budget (2 cores, `load_max_cores`) comes from [`slo/targets.toml`](../slo/targets.toml), which Fractal builds in. It is -the bound under this synthetic load, not the member's production CPU target (`max_cores`). The file is built in: +The CPU budget (2 cores, the `cpu` objective's `load-max-cores`) comes from [`slo/objectives.kdl`](../slo/objectives.kdl), which Fractal builds in. It is +the bound under this synthetic load, not the member's production CPU target (`cores`). The file is built in: a missing file fails the build and an invalid one fails the run, with no default. The report and step summary also show each path's p99 target from that file (`descriptive_target_ms`, `over_descriptive_target`) when the path stands for one. Targets describe and never gate: the budgets and main's baseline remain the gates. @@ -1362,7 +1362,7 @@ not past it at the start, at its lowest 5-second sample or at the end. Both runs add a faster measured mix: person reads and message sends at 2 a second and a fresh agents list at 1 a second, so each of the four measures has a p99 worth reading. The report pools -every path a target in [`slo/targets.toml`](../slo/targets.toml) covers, the targets that +every path a target in [`slo/objectives.kdl`](../slo/objectives.kdl) covers, the targets that `GET /v1/client/request-latency` serves: `person-read` (300 ms p99), `person-read-fresh` (300 ms), `write-ack` (300 ms) and `message-send` (a send against the write-ack target), each with and without the hog, and prints them as `summary.md`. A request that fails fails the run; a missed target does diff --git a/docs/st3/README.md b/docs/st3/README.md index 8977f8e2c4f..886841007b1 100644 --- a/docs/st3/README.md +++ b/docs/st3/README.md @@ -95,6 +95,8 @@ Use these documents for implementation details: - [Gate evidence](gate-evidence.md) explains signed test and review records, local checks and CI verification. - [Profiling the daemon](profiling.md) explains how the daemon accounts for its own time: waits for the writer and read connections, SQLite statements, CPU, and callers. +- [Objectives](objectives.md) explains the `objective` kind: the objectives Fractal ships in + `slo/objectives.kdl`, the ones a fleet declares, their indicators, targets and breach response. - [What a run waits on](waiting-on.md) explains `fractal missions waiting-on RUN` and `fractal missions blocked`: each open step's wait, a run's stalled verdict, and the fleet's blocked runs by cause, read in milliseconds from published state. diff --git a/docs/st3/client-v0/observations.md b/docs/st3/client-v0/observations.md index 8a6719c15e9..f5d3936523b 100644 --- a/docs/st3/client-v0/observations.md +++ b/docs/st3/client-v0/observations.md @@ -62,10 +62,10 @@ Live share has no histogram, max or latency count. Foreground is 1–60,000 ms a (`foreground_ms - live_ms`) forms over-target. It aims for at least 99% live foreground time. Neither background nor unreported/disconnected phone time is inferred. -The six latency targets in `slo/targets.toml` are `ios-open-to-live` (3000 ms), +The six latency objectives in `slo/objectives.kdl` are `ios-open-to-live` (3000 ms), `ios-connect` (1500), `ios-message-ack` (500), `ios-conversation-open` (1000), `ios-terminal-open` (1500) and `ios-recover` (10000). `ios-live-share` is a separate share -entry at 99%. Client paths are `client/ios/NAME`, with carrier/path populations reported +objective at 99%. Client paths are `client/ios/NAME`, with carrier/path populations reported separately from daemon response envelopes. The existing request-latency reader and `slo/NAME` doctor lines show them; no new reader is required. diff --git a/docs/st3/gate-evidence.md b/docs/st3/gate-evidence.md index af6ba067678..0d47131376a 100644 --- a/docs/st3/gate-evidence.md +++ b/docs/st3/gate-evidence.md @@ -1243,7 +1243,7 @@ done, the pull requests still refused on the old key can be lifted in either of members of the aggregate read: the whole of `.github` (workflows, the clippy and fleet-compat baselines) and `scripts` (the stage scripts and their tests), `fixtures` and `templates`. A pull request that changes none of those paths (documentation outside `docs/st3` and the retained `docs/vrs` migration reference, `apps`, `packages`, `release-notes`, `AGENTS.md`, `README*`) has the same key as a tree -on which the whole gate passed. `slo` (`include_str!` of `slo/targets.toml` into the Fractal crate), `evals` (the example and graph tests read `evals/fractal`), +on which the whole gate passed. `slo` (`include_str!` of `slo/objectives.kdl` into the Fractal crate), `evals` (the example and graph tests read `evals/fractal`), `claude-channel` and `plugins` (the plugin files `include_bytes!`d into fractal-drivers) and `docs/st3` (`crates/fractal/tests/invariants.rs` includes `docs/st3/invariants.md`) are declared because Rust sources open them; a committed-policy test fails when any literal `include_str!`, `include_bytes!` or `include!` path in the Rust sources resolves outside the declared inputs. `release-notes` is deliberately not declared: the Rust sources only name `release-notes` as an diff --git a/docs/st3/profiling.md b/docs/st3/profiling.md index 2a47b88b483..1fc93e82fcd 100644 --- a/docs/st3/profiling.md +++ b/docs/st3/profiling.md @@ -152,7 +152,7 @@ move observers after acknowledgements. ## Targets and live windows -[`slo/targets.toml`](../../slo/targets.toml) holds the daemon's targets: reads a person waits on, +[`slo/objectives.kdl`](../../slo/objectives.kdl) holds the objectives Fractal ships ([objectives](objectives.md)): reads a person waits on, writes and acks, terminal attach to first screen, one SQL statement, any transaction or hold of the writer, daemon CPU, this member's database size, and CI's objectives. Every daemon builds the file in. CI's daemon_load reads the same file. A target describes what we aim for and gates nothing by itself. @@ -306,7 +306,7 @@ holds the history. Each point carries `target`, `series` (`owner` or `remote` fo the store), `window` (`1m`, `5m`, `1h`) and, on latency, `stat` (`p50`, `p99`, `max`), under the resource's `host.name`. The gauges are `st_slo_latency_ms`, `st_slo_over_target_ratio`, `st_slo_samples`, `st_slo_target_ms` (the -figure from `slo/targets.toml`, so one query sets a host's number against its target), +figure from `slo/objectives.kdl`, so one query sets a host's number against its target), `st_slo_cpu_cores` (the member's cores by `window`, sent only once known) and `st_slo_cpu_max_cores`. A window with no samples sends its count only. A phone's live share is a minimum over foreground milliseconds, so it is not in those: it goes out as `st_slo_live_share`, `st_slo_foreground_ms` and `st_slo_min_share_percent`. The task reads only diff --git a/docs/st3/replication.md b/docs/st3/replication.md index e3643ae51cd..2084f376c54 100644 --- a/docs/st3/replication.md +++ b/docs/st3/replication.md @@ -1296,7 +1296,7 @@ episode stays open; that projection opens its own episode. The same pair and pro diverging again reopens the same episode. Comparisons are held in memory, so after a restart an open fault stays open until the next comparison that agrees. -The `projection-divergence` objective in `slo/targets.toml` counts open divergence faults +The `projection-divergence` objective in `slo/objectives.kdl` counts open divergence faults fleet-wide, with a target of none. A daemon that runs the check keeps the count its last check read, so `fractal doctor` (as `slo/projection-divergence`), `fractal slo` and the SLO history read no claims for it; a daemon that never checks shows neither the line nor the row. A miss is `info`, like any diff --git a/release-notes/shipped-objectives-kdl.json b/release-notes/shipped-objectives-kdl.json new file mode 100644 index 00000000000..be668617d61 --- /dev/null +++ b/release-notes/shipped-objectives-kdl.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "The daemon's built-in targets are the objectives it ships in slo/objectives.kdl, read by the objective reader; slo/targets.toml is gone", + "replay": {"status": "none", "detail": "No replay or rebuild."}, + "schema": {"status": "none", "detail": "No tables, columns, indexes or triggers."}, + "rules": {"status": "none", "detail": "Checkpoint rules and digests are unchanged."}, + "compatibility": {"status": "none", "detail": "Behavior is unchanged: the same 25 rows with the same ids, abouts, targets and windows in st slo, GET /v1/slo, the request-latency read, st doctor and the OTLP gauges. The file is built into each daemon, so no running config changes. writer-hold reads its own objective's 300 ms bound, equal to the transaction bound it used before; st_slo_target_ms's description names slo/objectives.kdl."}, + "downtime": {"status": "none", "detail": "Standard daemon restart only."}, + "manual": {"status": "none", "detail": "No manual step."}, + "recovery": {"status": "none", "detail": "Binary rollback restores the TOML-built targets, which are the same numbers."} +} diff --git a/scripts/ci-perf b/scripts/ci-perf index 83df95431b5..f21be80d856 100755 --- a/scripts/ci-perf +++ b/scripts/ci-perf @@ -56,7 +56,7 @@ summarize_load() { printf '| Request | n | p50 ms | p99 ms | max ms | budget ms | target ms |\n| --- | ---: | ---: | ---: | ---: | ---: | ---: |\n' jq -r '.paths | to_entries[] | "| \(.key) | \(.value.count) | \(.value.p50_ms | floor) | \(.value.p99_ms | floor) | \(.value.max_ms | floor) | \(.value.budget_ms | floor) | \(if .value.descriptive_target_ms == null then "-" else "\(.value.descriptive_target_ms | floor)\(if .value.over_descriptive_target then " over" else "" end)" end) |"' "$report" - printf '\nTarget: the p99 in slo/targets.toml, described and never gated. Budgets and main'"'"'s baseline are the gates.\n' + printf '\nTarget: the p99 in slo/objectives.kdl, described and never gated. Budgets and main'"'"'s baseline are the gates.\n' printf 'Runner reference: %s ms to sort 2^21 values on one core (the same work on every host).\n' \ "$(jq -r '.runner_reference_ms // "-"' "$report")" jq -r 'if (.unavailable // {}) == {} then empty else @@ -169,7 +169,7 @@ PY if [ -s "$RUNNER_TEMP/perf-source-state.json" ]; then export AGENT_SPEC_REVISION="$(git rev-parse HEAD)" fi - # The daemon beside a CPU and disk hog at seat priority; reported against slo/targets.toml, + # The daemon beside a CPU and disk hog at seat priority; reported against slo/objectives.kdl, # never a required check (docs/ci.md). # arn:lmig:smalltalk:2026-10-16-env-fractal-dual-read FRACTAL_LOAD_GATE=1 ST_LOAD_GATE=1 FRACTAL_LOAD_HOG=1 ST_LOAD_HOG=1 FRACTAL_LOAD_HOG_REPORT="$out/hog" ST_LOAD_HOG_REPORT="$out/hog" \ diff --git a/scripts/ci-perf-slot b/scripts/ci-perf-slot index 78d83a290dd..7db3eb6e598 100755 --- a/scripts/ci-perf-slot +++ b/scripts/ci-perf-slot @@ -47,7 +47,7 @@ Measuring a change (docs/ci.md, "Performance gate") Run the load test above on your branch alone: the slot makes sure nothing else of ours is measuring at the same time. Its report is a table of each request's p50/p99/max against its budget, and the daemon's CPU in cores. The budgets that apply are the absolute p99 budget of - every request and the 2-core CPU ceiling (slo/targets.toml); locally there is no baseline, so + every request and the 2-core CPU ceiling (slo/objectives.kdl); locally there is no baseline, so those are the whole gate. CI adds the relative gate, twice the worst of main's last five reports, when you dispatch Performance on your branch. A run that misses a budget fails; do not retry it into passing, find the cause. From a3934b937740caecf5f7c4e3650714d324931ea9 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 9 Nov 2026 20:00:43 +0100 Subject: [PATCH 059/132] chore(kernel): register the shipped objectives include in the large-value inventory --- crates/fractal-kernel/src/objective_tests.rs | 2 +- docs/st3/large-value-sites.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/fractal-kernel/src/objective_tests.rs b/crates/fractal-kernel/src/objective_tests.rs index ed73de5b076..6bb2504565f 100644 --- a/crates/fractal-kernel/src/objective_tests.rs +++ b/crates/fractal-kernel/src/objective_tests.rs @@ -1,6 +1,6 @@ use super::*; -const SHIPPED: &str = include_str!("../../../slo/objectives.kdl"); +const SHIPPED: &str = /* sqlite-large-value: allow-for-now lv-ha01213f3c4b5 owner=fractal_kernel::objective_tests TODO=trace-statement-and-value-representation */ include_str!("../../../slo/objectives.kdl"); fn one(text: &str) -> Result { parse_document(&format!("version 2\n{text}")).map(|mut objectives| objectives.remove(0)) diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 446c37256ac..3de69df6e86 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -3470,6 +3470,7 @@ {"id": "lv-h9fe75ab6969f", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "5650a756ab06b458f8cec93155e560e26236256f1a42304a160aaee0e9f35d99", "owner": "smallclaims::store::receive_writer_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 438, "excerpt": "INSERT INTO checkpoint_envelopes VALUES(?1,?2,?3,?4,'checkpoint/test')"}, {"id": "lv-h9fe9d50b5675", "path": "crates/smallclaims/src/store.rs", "kind": "sql-template", "fingerprint": "c2b3267891be1c5288ab67ef77b10dea76d97ea534255953cccd90889ec18aa8", "owner": "smallclaims::store", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 4009, "excerpt": "INSERT INTO replica_records( record_ref, writer, sequence, envelope_hash, position, raw, state, error_code, error_message, updated_at_unix_ms ) VALUES (?1, ?2, ?3, ?4, 0, ?5, 'invalid', ?6, ?7, ?8) ON"}, {"id": "lv-ha00b1a8a0c42", "path": "crates/fractal-store/src/store/retirements.rs", "kind": "unresolved-sql-sink", "fingerprint": "77abb748296f8d6588bb377e43060276c4b87198064f076635a1ebdf01ae0d6a", "owner": "fractal_store::store::retirements", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 153, "excerpt": "params ! [ recipient , claim . id ] , | row | row . get ( 0 )"}, + {"id": "lv-ha01213f3c4b5", "path": "crates/fractal-kernel/src/objective_tests.rs", "kind": "included-source", "fingerprint": "ffb22acfbbe448358e6dc69c839f176fdf8f9f010e44552da1930ecef54322c4", "owner": "fractal_kernel::objective_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 3, "excerpt": "include_str ! ( \"../../../slo/objectives.kdl\" )"}, {"id": "lv-ha013271629ac", "path": "crates/fractal-github/src/changes/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "bb3beb90db6d03b3fab4ef5029cccc3de43c7be1d9cde0dbf6e2e2ec075bb0af", "owner": "fractal_github::changes::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 288, "excerpt": "fixture . record . clone ( )"}, {"id": "lv-ha017db4fd2e3", "path": "crates/smallclaims/src/store/inventory_generation.rs", "kind": "sql-template", "fingerprint": "2ef160e13f1bd806c0e0cad2a0756638563f1a687c249c8b9725c9c753fa2e4c", "owner": "smallclaims::store::inventory_generation", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 46, "excerpt": "INSERT INTO meta(key,value) VALUES(?1,'1') ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER)+1"}, {"id": "lv-ha019ff5d6de8", "path": "crates/smallclaims/src/ivm/install.rs", "kind": "sql-template", "fingerprint": "89c588db4d094aa63643b605232e1c67071c36b68dc2df42d02c962c1bbb387a", "owner": "smallclaims::ivm::install", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 322, "excerpt": "UPDATE ivm_install_jobs SET phase='stopped',error=?2 WHERE source=?1 AND phase IN ('scan','catchup')"}, From cd8870743ca8b10927f450566e4779b962e100d0 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 9 Nov 2026 20:06:06 +0100 Subject: [PATCH 060/132] kernel: box the interval indicator so Indicator stays small (clippy large_enum_variant) --- crates/fractal-kernel/src/objective.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fractal-kernel/src/objective.rs b/crates/fractal-kernel/src/objective.rs index 63890e42b33..db233efa125 100644 --- a/crates/fractal-kernel/src/objective.rs +++ b/crates/fractal-kernel/src/objective.rs @@ -50,7 +50,7 @@ pub enum Indicator { /// Events in a CI series per hour. CiCount { series: String }, /// Work that starts and stops in claims, one sample per subject. - Interval(Interval), + Interval(Box), } impl Indicator { @@ -376,7 +376,7 @@ fn parse_indicator(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Resul } if kind == "ci-duration" { Indicator::CiDuration { series } } else { Indicator::CiCount { series } } } - "interval" => Indicator::Interval(parse_interval(&kids, fail)?), + "interval" => Indicator::Interval(Box::new(parse_interval(&kids, fail)?)), other => return Err(fail(format!("has an unknown indicator `{other}`"))), }) } From e172fbc984a6358cbd5ad8bf61d3fcab3b482300 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 9 Nov 2026 20:08:39 +0100 Subject: [PATCH 061/132] Objectives: a fleet declares an interval objective with fractal apply An `objective` is a root declaration, refused inside a mission, read by the kernel's strict reader and admitted only as an interval over claims named NAMESPACE/NAME. Its subject is objective/NAME, a new registered family, stored as intent.desired like an account. The store reads the current declarations (an undecodable body is kept with its error) and pages claims of several kinds after a store index under a subject prefix through claims_kind_index. --- crates/fractal-missions-base/src/mission.rs | 18 +++ crates/fractal-schema/src/lib.rs | 9 ++ crates/fractal-store/src/graph.rs | 93 +++++++++++++++ crates/fractal-store/src/store.rs | 1 + .../src/store/declared_objectives.rs | 112 ++++++++++++++++++ docs/st3/large-value-sites.json | 2 + docs/st3/schema.md | 3 +- 7 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 crates/fractal-store/src/store/declared_objectives.rs diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index d8f53486697..bbc0a3fdfc6 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -503,6 +503,12 @@ fn parse_mission( format!("mission `{id}` cannot own an account"), )); } + "objective" => { + return Err(FractalError::new( + "objective-inside-mission", + format!("mission `{id}` cannot own an objective"), + )); + } "capacity" => { if capacity.is_some() { return Err(FractalError::new( @@ -1405,6 +1411,12 @@ fn parse_step( format!("step `{path}` cannot own an account"), )); } + "objective" => { + return Err(FractalError::new( + "objective-inside-mission", + format!("step `{path}` cannot own an objective"), + )); + } "lane" => { return Err(FractalError::new( "lane-inside-step", @@ -3115,6 +3127,12 @@ fn validate_deferred_declaration_in(node: &KdlNode, parent: Option<&str>) -> Res "an account declaration must be at the root", )); } + if node.name().value() == "objective" { + return Err(FractalError::new( + "objective-inside-mission", + "an objective declaration must be at the root", + )); + } if node.name().value() == "env" { validate_string_map(node, true)?; } diff --git a/crates/fractal-schema/src/lib.rs b/crates/fractal-schema/src/lib.rs index 8b822a5387e..56a0b020502 100644 --- a/crates/fractal-schema/src/lib.rs +++ b/crates/fractal-schema/src/lib.rs @@ -1143,6 +1143,12 @@ fn build_registry() -> Registry { "One bounded loop execution.", false, ), + ( + "objective", + "objective/NAME", + "A declared objective: an interval over claims, its targets, window and breach response.", + false, + ), ( "owned-set", "owned-set/NAME", @@ -1562,6 +1568,7 @@ fn claim_specs() -> BTreeMap { "lane", "message", "observer", + "objective", "mission", "mission-run", "planning-session", @@ -4889,6 +4896,8 @@ mod tests { let mut current = registry().clone(); current.claims.remove(usage_limits::KIND).unwrap(); current.claims.remove("client.operation-authorized").unwrap(); + current.subjects.remove("objective").unwrap(); + current.claims.get_mut("intent.desired").unwrap().source_kdl.retain(|kind| kind != "objective"); assert_eq!(current.subjects["assistant"].pattern, "assistant/{chatgpt|mcp}[/session/assistant-grant/ID]"); let mut previous = current.clone(); previous.subjects.get_mut("assistant").unwrap().pattern = diff --git a/crates/fractal-store/src/graph.rs b/crates/fractal-store/src/graph.rs index 6d1b86b072a..26741a6ce40 100644 --- a/crates/fractal-store/src/graph.rs +++ b/crates/fractal-store/src/graph.rs @@ -27,6 +27,7 @@ const ROOT_NODES: &[&str] = &[ "mission-run", "planning-session", "message", + "objective", "repair", "schedule", "stop", @@ -380,6 +381,12 @@ fn parse_desired_node( "an account declaration must be at the root", )); } + if kind == "objective" && context.owner_run.is_some() { + return Err(FractalError::new( + "objective-inside-mission", + "an objective declaration must be at the root", + )); + } match kind { "host" => parse_host(node, context), "agent" => parse_agent(node, enclosing_host, context), @@ -1688,6 +1695,7 @@ fn parse_structure(node: &KdlNode, kind: &str, context: &mut ParseContext) -> Re validate_name(&name, false)?; match kind { "account" => validate_account(node)?, + "objective" => validate_objective(node)?, "doc" => validate_doc(node)?, "resource" => validate_resource(node)?, "observer" => validate_observer(node)?, @@ -1703,6 +1711,8 @@ fn parse_structure(node: &KdlNode, kind: &str, context: &mut ParseContext) -> Re } let subject = match kind { "doc" => format!("doc/{name}"), + // The kernel reader names the subject, so `objective "objective/x"` is not `objective/x`. + "objective" => format!("objective/{name}"), "lane" | "observer" | "subscription" | "schedule" if context.owner_run.is_some() => { format!( "{kind}/{}/{}", @@ -1742,6 +1752,23 @@ fn parse_structure(node: &KdlNode, kind: &str, context: &mut ParseContext) -> Re Ok(()) } +/// An objective a fleet declares: the kernel's strict reader, and only an interval over claims +/// named `NAMESPACE/NAME`, since native telemetry counts toward the objectives Fractal ships. +fn validate_objective(node: &KdlNode) -> Result<(), FractalError> { + let objective = crate::objective::parse(&canonical_node(node)?)?; + if !objective.declarable() { + return Err(FractalError::new( + "objective-not-declarable", + format!( + "objective `{}`: a fleet declares an `interval` objective named NAMESPACE/NAME; `{}` indicators and one-segment names are the objectives Fractal ships", + objective.name, + objective.indicator.kind() + ), + )); + } + Ok(()) +} + fn validate_account(node: &KdlNode) -> Result<(), FractalError> { ensure_no_properties(node)?; one_string_with_children(node)?; @@ -4728,6 +4755,72 @@ version 2 ); } + const OBJECTIVE: &str = r#" +version 2 + +objective "ops/change-rollout" { + about "A change, from its author marking it ready to every member that serves it" + indicator "interval" { + subject-prefix "custom/acme/change/" + start "custom.acme.change-ready" at="ready_at" + expect "custom.acme.change-merged" set="hosts" at="merged_at" + stop "custom.acme.change-applied" member="host" at="applied_at" + cancel "custom.acme.change-abandoned" + label "class" + } + window "7d" min-samples=3 + target "p50" within="60s" + target "p95" within="5m" + on-breach "fault" owner="agent/ops/release-manager" backup="agent/ops/observability" condition="change-rollout-slo-missed" +} +"#; + + #[test] + fn an_objective_is_a_root_declaration_the_kernel_reads() { + let intent = parse_intent(OBJECTIVE, "node").unwrap(); + let desired = &intent.subjects["objective/ops/change-rollout"]; + assert_eq!((desired.kind.as_str(), desired.member.is_none()), ("objective", true)); + let objective = crate::objective::parse(&desired.desired).unwrap(); + assert_eq!(objective.name, "ops/change-rollout"); + assert!(objective.declarable()); + + let nested = parse_execution_intent(OBJECTIVE, "node", "run-1").expect_err("a run cannot own an objective"); + assert_eq!(nested.code, "objective-inside-mission"); + let body = OBJECTIVE.trim_start().trim_start_matches("version 2").trim(); + for inside in [ + format!("version 2\nmission \"bad\" state=\"ready\" {{\n goal \"No.\"\n {body}\n step \"work\" {{ agentless }}\n}}\n"), + format!("version 2\nmission \"bad\" state=\"ready\" {{\n goal \"No.\"\n step \"work\" {{\n {body}\n }}\n}}\n"), + ] { + assert_eq!(parse_intent(&inside, "node").unwrap_err().code, "objective-inside-mission", "{inside}"); + } + } + + #[test] + fn only_an_interval_named_namespace_slash_name_is_declarable() { + let one_segment = OBJECTIVE.replace("\"ops/change-rollout\"", "\"change-rollout\""); + assert_eq!(parse_intent(&one_segment, "node").unwrap_err().code, "objective-not-declarable"); + + let native = r#" +version 2 +objective "ops/statement" { + about "One SQL statement" + indicator "statement" + target "p99" within="25ms" + target "max" within="250ms" +} +"#; + assert_eq!(parse_intent(native, "node").unwrap_err().code, "objective-not-declarable"); + + for broken in [ + OBJECTIVE.replace(" window \"7d\" min-samples=3\n", ""), + OBJECTIVE.replace("target \"p95\" within=\"5m\"", "target \"p95\" max=5"), + OBJECTIVE.replace(" label \"class\"\n", " label \"class\"\n retry \"x\"\n"), + OBJECTIVE.replace("backup=\"agent/ops/observability\"", "backup=\"agent/ops/release-manager\""), + ] { + assert_eq!(parse_intent(&broken, "node").unwrap_err().code, "invalid-objective", "{broken}"); + } + } + #[test] fn a_missions_agent_names_an_account_the_way_a_top_level_seat_does() { let mission = |harness_body: &str| { diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index a365f14086a..19c0d3e774c 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -219,6 +219,7 @@ use smallclaims::windows::holds; pub mod accounts; +pub mod declared_objectives; pub mod runtime_starts; pub use runtime_starts::RuntimeStartOperation; pub mod roster_admission; diff --git a/crates/fractal-store/src/store/declared_objectives.rs b/crates/fractal-store/src/store/declared_objectives.rs new file mode 100644 index 00000000000..873ef101c73 --- /dev/null +++ b/crates/fractal-store/src/store/declared_objectives.rs @@ -0,0 +1,112 @@ +//! Declared objectives (docs/st3/objectives.md): each `objective/NAME` declaration, and the claims +//! an interval objective reads. + +use super::*; +use crate::objective::Objective; + +/// One current `objective/NAME` declaration. A body the kernel reader refuses is kept with its +/// error, so the objective's row says so instead of disappearing. +#[derive(Clone, Debug)] +pub struct DeclaredObjective { + pub subject: String, + pub revision: String, + pub claim_id: String, + pub objective: std::result::Result, +} + +impl DeclaredObjective { + /// The objective's name: its subject after `objective/`. + pub fn name(&self) -> &str { + self.subject.strip_prefix("objective/").unwrap_or(&self.subject) + } +} + +pub fn declared_objectives_at(connection: &Connection) -> Result> { + let mut statement = connection.prepare_cached( + "SELECT subject, revision, claim_id, body FROM desired WHERE kind='objective' ORDER BY subject", + )?; + let rows = statement.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?)) + })?; + let mut objectives = Vec::new(); + for row in rows { + let (subject, revision, claim_id, body) = row?; + let objective = serde_json::from_str::(&body) + .map_err(|error| format!("its body is not JSON: {error}")) + .and_then(|desired| crate::objective::parse(&desired).map_err(|error| format!("{}: {}", error.code, error.message))); + objectives.push(DeclaredObjective { subject, revision, claim_id, objective }); + } + Ok(objectives) +} + +/// Claims of `kinds` (`?5`…) after store index `?1`, at most `?2` rows, under the subject range +/// `?3 <= subject < ?4` when `prefix`. +fn kinds_after_query(kinds: usize, prefix: bool) -> String { + let placeholders = (0..kinds).map(|n| format!("?{}", n + 5)).collect::>().join(","); + let subject_filter = if prefix { " AND subject>=?3 AND subject?1{subject_filter} + ORDER BY store_index LIMIT ?2" + ) +} + +impl Store { + pub fn declared_objectives(&self) -> Result> { + declared_objectives_at(&self.readers.get()) + } + + /// The member `agent` is declared on, when it is a current agent declaration. + pub fn agent_host(&self, agent: &str) -> Result> { + Ok(self + .readers + .get() + .prepare_cached("SELECT json_extract(member,'$.host') FROM desired WHERE subject=?1 AND kind='agent'")? + .query_row(/* sqlite-large-value: allow-for-now lv-hbb373c1f9535 owner=fractal_store::store::declared_objectives TODO=trace-statement-and-value-representation */ [agent], |row| row.get::<_, Option>(0)) + .optional()? + .flatten()) + } + + /// The next `limit` claims of any of `kinds` after `after_index`, ascending, optionally under + /// `subject_prefix`. Each kind seeks `claims_kind_index` past the cursor, so a caller that keeps + /// the cursor reads only claims newer than the last it saw. + pub fn claims_by_kinds_after( + &self, + kinds: &[&str], + subject_prefix: Option<&str>, + after_index: u64, + limit: usize, + ) -> Result { + if kinds.is_empty() { + return Ok(ClaimsPage { claims: Vec::new(), next_cursor: None }); + } + let range = subject_prefix + .map(|prefix| { + prefix_upper_bound(prefix).map(|upper| (prefix, upper)).ok_or_else(|| { + anyhow::Error::from(FractalError::new( + "invalid-claims-query", + "a subject prefix must be non-empty ASCII without `*`, `?` or `[`", + )) + }) + }) + .transpose()?; + let query = kinds_after_query(kinds.len(), range.is_some()); + let scan_limit = i64::try_from(limit.saturating_add(1)).unwrap_or(i64::MAX); + let (lower, upper) = range.map_or((None, None), |(lower, upper)| (Some(lower), Some(upper))); + let mut values: Vec<&dyn rusqlite::ToSql> = vec![&after_index, &scan_limit, &lower, &upper]; + values.extend(kinds.iter().map(|kind| kind as &dyn rusqlite::ToSql)); + let connection = self.readers.get(); + let mut statement = connection.prepare_cached(/* sqlite-large-value: allow-for-now lv-h5073cff5e5e7 owner=fractal_store::store::declared_objectives TODO=trace-statement-and-value-representation */ &query)?; + let mut claims = statement + .query_map(rusqlite::params_from_iter(values), claim_from_row)? + .collect::>>()?; + let next_cursor = if claims.len() > limit { + claims.pop(); + claims.last().map(|claim| claim.store_index) + } else { + None + }; + Ok(ClaimsPage { claims, next_cursor }) + } +} diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 3de69df6e86..f465b83f8f6 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -2142,6 +2142,7 @@ {"id": "lv-h5037e9e530a6", "path": "crates/fractal-delivery/src/store/mailbox_lease.rs", "kind": "unresolved-sql-sink", "fingerprint": "8edb630b01a1f78d4abc5ee08b714ce7a8578b35d2269aec45684bf19ac1e4fc", "owner": "fractal_delivery::store::mailbox_lease", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 43, "excerpt": "& query"}, {"id": "lv-h503f5092bdb7", "path": "crates/fractal-seats/src/store/reconcile_work_context/tests.rs", "kind": "sql-template", "fingerprint": "dd5d07fbeeccb12f04d2a12e42a544ae48b4d7cad4b7ebbcaa3df9913d96bb6e", "owner": "fractal_seats::store::reconcile_work_context::tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 302, "excerpt": "UPDATE claims SET body=json_set(body,'$.fields.origin_step','step-run/another-origin') WHERE id=?1"}, {"id": "lv-h5047e43e1ca2", "path": "crates/smallclaims/src/store/prepared_envelope_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "a72b18f27662dd2ffbf1f57e072616cc1cd2c02504978dba276a7b0e6050da37", "owner": "smallclaims::store::prepared_envelope_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 298, "excerpt": "[ & record_ref ] , | row | row . get : : < _ , String > ( 0 )"}, + {"id": "lv-h5073cff5e5e7", "path": "crates/fractal-store/src/store/declared_objectives.rs", "kind": "unresolved-sql-sink", "fingerprint": "8edb630b01a1f78d4abc5ee08b714ce7a8578b35d2269aec45684bf19ac1e4fc", "owner": "fractal_store::store::declared_objectives", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 100, "excerpt": "& query"}, {"id": "lv-h5083d144d1c8", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "4cf3d8d56cf6584bad9ad8dd8c53e27677aadf2245ea6f1dadade03b8dce3ba8", "owner": "fractal_store::store::resources", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 106, "excerpt": "INSERT OR IGNORE INTO local_resource_projection_pending SELECT DISTINCT subject FROM claims WHERE kind='vcs.item.state'"}, {"id": "lv-h5096ce555101", "path": "crates/fractal-store/src/store/identities.rs", "kind": "sql-template", "fingerprint": "8fb69ab2362828438f5fdbbb974d979a6844ad5ae243fa0ac035cc889a9c542f", "owner": "fractal_store::store::identities", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 587, "excerpt": "INSERT INTO identity_heads(identity,kind,canonical_id,naming_revision,unavailable) VALUES (?1,?2,?3,?4,?5) ON CONFLICT(identity) DO UPDATE SET kind=excluded.kind,canonical_id=excluded.canonical_id,nam"}, {"id": "lv-h50985c5da7f6", "path": "crates/smallclaims/src/store/repair_operation_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "smallclaims::store::repair_operation_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 131, "excerpt": "DELETE FROM claims WHERE id=?1"}, @@ -3926,6 +3927,7 @@ {"id": "lv-hbaf11ae2e68b", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "5d3baa4cb1a6ebdc4527226258f80f2f07dfd1bd8aa4ff43204d68c70a101ee7", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 3902, "excerpt": "INSERT INTO idempotency(operation_id, response) VALUES (?1, ?2)"}, {"id": "lv-hbaf37261e5a0", "path": "crates/smallclaims/src/store/sealed_view_cache_tests/mutation_tests.rs", "kind": "sql-template", "fingerprint": "1db7f40539728faad5dd9e8915b9946096ae8fbfc4e69aca3d89e5c92efc0e9e", "owner": "smallclaims::store::sealed_view_cache_tests::mutation_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 99, "excerpt": "UPDATE batches SET id='temporary-batch' WHERE id=?1"}, {"id": "lv-hbb02f708c2c5", "path": "crates/fractal-delivery/src/store/mailbox_lease.rs", "kind": "unresolved-sql-sink", "fingerprint": "e8e0bb8a64c8c7cc2ff1889442a2e07a1d22347b0860b43855afef790e981dba", "owner": "fractal_delivery::store::mailbox_lease", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 229, "excerpt": "params ! [ fence . subject , episode ] , | row | { Ok ( ( row . get ( 0 ) ? , row . get ( 1 ) ? ) ) }"}, + {"id": "lv-hbb373c1f9535", "path": "crates/fractal-store/src/store/declared_objectives.rs", "kind": "unresolved-sql-sink", "fingerprint": "4aa7af888f0eb091e0fd5cc90601a0b7752fdd23d3b1a05045615f7bb1fc305e", "owner": "fractal_store::store::declared_objectives", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 66, "excerpt": "[ agent ] , | row | row . get : : < _ , Option < String > > ( 0 )"}, {"id": "lv-hbb4d7ef8a2a5", "path": "crates/fractal-store/src/store/messages_shard.rs", "kind": "unresolved-sql-sink", "fingerprint": "8ffaf16307b0b778471946170f039ab55a6c4a8e018436c074afbec06e2ebf6b", "owner": "fractal_store::store::messages_shard", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 196, "excerpt": "& format ! ( \"SELECT item.subject, item.store_index FROM stream_messages AS item WHERE {} ORDER BY item.store_index LIMIT :limit\" , filter . sql ( ) )"}, {"id": "lv-hbb840ed5c2a8", "path": "crates/smallclaims/src/store/checkpoint_staging/scoped.rs", "kind": "unresolved-sql-sink", "fingerprint": "c32b884bb11aa9d33ad9fa5b6cbd7b18be6b5eec40e5397aa0319a5e4069ab0d", "owner": "smallclaims::store::checkpoint_staging::scoped", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 133, "excerpt": "& sql"}, {"id": "lv-hbb8f1fc66365", "path": "crates/fractal-delivery/src/store/delivery.rs", "kind": "unresolved-sql-sink", "fingerprint": "05d1fca38c802b1f8682dc8dd0b15fed8fbff745fec02413bdf5de12122b884e", "owner": "fractal_delivery::store::delivery", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 419, "excerpt": "& canonical_sql ( \"WITH candidates AS ( SELECT id FROM claims INDEXED BY claims_message_to_index WHERE kind='message.sent' AND json_extract(body,'$.fields.to')=?1 AND store_index<=?2 UNION SELECT id F"}, diff --git a/docs/st3/schema.md b/docs/st3/schema.md index fcbc51e8263..6bd1b0e5176 100644 --- a/docs/st3/schema.md +++ b/docs/st3/schema.md @@ -36,6 +36,7 @@ Storage digest: `c98d0a28f88e6d5e8cf58f93fee817e690662de7a334089feeb8be0e6cdb2d5 | `message` | `message/ID` | yes | A Fractal message. | | `mission` | `mission/ID` | no | An immutable mission revision lineage. | | `mission-run` | `mission-run/ID` | no | A mission execution. | +| `objective` | `objective/NAME` | no | A declared objective: an interval over claims, its targets, window and breach response. | | `observer` | `observer/RUN/LOCAL_ID` | no | A mission-run resource observer. | | `owned-set` | `owned-set/NAME` | no | A graph-owned set of declarations with source ordering and omission retirement. | | `person` | `person/IDENTITY` | no | A human actor. | @@ -140,7 +141,7 @@ Custom subjects use `custom/NAMESPACE/NAME`. Custom claims use `custom.NAMESPACE | `harness.timeline` | `agent` | `same-subject-actor` | `append` | `local` | `body!:object`, `driver!:string`, `entry_id!:string`, `entry_type!:string`, `final!:boolean`, `incarnation_id!:string`, `observed_at_unix_ms:integer`, `operation!:string`, `revision!:integer`, `role!:string`, `sequence:integer`, `source_id:string` | | | `harness.todo.observed` | `agent` | `same-subject-actor` | `append` | `latest` | `harness!:string`, `incarnation_id!:string`, `observed_at!:string`, `phases!:array`, `session_id!:string`, `source_op!:string`, `totals!:object`, `truncated!:boolean` | | | `harness.usage` | `agent` | `same-subject-actor` | `append` | `latest` | `account:string`, `cache_write_1h_tokens:integer`, `cache_write_tokens:integer`, `cached_tokens:integer`, `compactions:integer`, `context_used_percent:number`, `context_used_tokens:integer`, `context_window_tokens:integer`, `cost:number`, `cost_microusd:integer`, `currency:string`, `driver!:string`, `host:string`, `incarnation_id!:string`, `input_tokens:integer`, `last_compaction_ms:integer`, `last_compaction_trigger:string`, `model:string`, `native_session_id:string`, `observed_at_unix_ms:integer`, `output_tokens:integer`, `owner_run:string`, `owner_step:string`, `pricing:string`, `pricing_provenance:array`, `reported_cost_microusd:integer`, `semantics!:string`, `total_tokens:integer`, `unpriced_tokens:integer` | | -| `intent.desired` | `*` | `authorized-requester` | `state-transition` | `durable` | `accountable:subject-reference(person)`, `desired:object`, `kind:string`, `revision:string` | `account`, `agent`, `doc`, `exec`, `host`, `lane`, `message`, `observer`, `mission`, `mission-run`, `planning-session`, `pty`, `resource`, `schedule`, `step`, `stop`, `subscription` | +| `intent.desired` | `*` | `authorized-requester` | `state-transition` | `durable` | `accountable:subject-reference(person)`, `desired:object`, `kind:string`, `revision:string` | `account`, `agent`, `doc`, `exec`, `host`, `lane`, `message`, `observer`, `objective`, `mission`, `mission-run`, `planning-session`, `pty`, `resource`, `schedule`, `step`, `stop`, `subscription` | | `lane.approved` | `lane` | `authorized-participant` | `append` | `durable` | `entry!:subject-reference`, `reason:string` | | | `lane.joined` | `lane` | `authorized-participant` | `append` | `durable` | `entry!:subject-reference`, `reason:string` | | | `lane.left` | `lane` | `authorized-participant` | `append` | `durable` | `entry!:subject-reference`, `outcome!:string`, `reason:string` | | From f06f9388e242a1ecb3f70ab11169ba2e978ba7e8 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 9 Nov 2026 20:08:40 +0100 Subject: [PATCH 062/132] Objectives: evaluate declared objectives and deliver their breach response Each declared objective keeps its samples in memory and reads only claims newer than its cursor; a new revision reads them again. Rows NAME/pNN and NAME/pNN{label=value} join /v1/slo before the budgets, with open samples over target counted at their age, a window by stop time and min-samples. The SLO history minute task opens one operational.failure per missed row for the declaration's owner, on the member the owner is declared on, and ends it when the row is met. fractal slo chart percent-encodes the whole row id. --- crates/fractal-api-core/src/api.rs | 2 + .../src/declared_objectives.rs | 626 ++++++++++++++++++ .../src/declared_objectives_tests.rs | 387 +++++++++++ crates/fractal-api-core/src/lib.rs | 1 + crates/fractal-api-core/src/objectives.rs | 23 +- crates/fractal-daemon/src/lib.rs | 1 + crates/fractal/src/main.rs | 3 +- docs/st3/objectives.md | 27 +- release-notes/declared-objectives.json | 32 + 9 files changed, 1085 insertions(+), 17 deletions(-) create mode 100644 crates/fractal-api-core/src/declared_objectives.rs create mode 100644 crates/fractal-api-core/src/declared_objectives_tests.rs create mode 100644 release-notes/declared-objectives.json diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 9b03d5d0229..069a808529f 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -11284,6 +11284,8 @@ pub fn slo_rows(store: &crate::store::Store) -> anyhow::Result> { let budgets = rows.iter().position(|row| row["kind"] == "budget").unwrap_or(rows.len()); rows.insert(budgets, crate::objectives::divergence(open)); } + let budgets = rows.iter().position(|row| row["kind"] == "budget").unwrap_or(rows.len()); + drop(rows.splice(budgets..budgets, crate::declared_objectives::rows(store, now)?)); Ok(rows) } diff --git a/crates/fractal-api-core/src/declared_objectives.rs b/crates/fractal-api-core/src/declared_objectives.rs new file mode 100644 index 00000000000..29c8a699af1 --- /dev/null +++ b/crates/fractal-api-core/src/declared_objectives.rs @@ -0,0 +1,626 @@ +//! The objectives a fleet declares (docs/st3/objectives.md): each `objective/NAME` is an interval +//! over claims, measured here from the replicated claims every member holds. +//! +//! Each objective keeps its samples in memory, one per subject under its prefix, with the store +//! index of the newest claim it has read; an evaluation reads only the claims after it, through +//! `claims_kind_index`. A new revision of the declaration starts again from the claims. A sample +//! that stopped or was cancelled before the window is dropped from memory. +//! +//! An open sample is censored, not dropped: for each target it enters the percentile at its age +//! once that age is over the target's `within`, since it is certain to be over. +//! +//! A missed row opens one `operational.failure` for the declaration's owner, on the member that +//! owner is declared on: every member evaluates the same claims, and one delivers. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, PoisonError}; + +use serde_json::{Map, Value, json}; + +use crate::model::{ClaimRecord, FractalError}; +use crate::objective::{Breach, Indicator, Interval, Objective, Window, percentile_row}; +use crate::objective_faults::{ObjectiveFaults, ObjectiveResponse, check_with_response}; +use crate::objectives::{clamp_percent, duration, nearest_rank, plural}; +use crate::store::Store; +use crate::store::declared_objectives::DeclaredObjective; +use smallclaims::error::internal; + +/// Claims read per page while catching up. +const PAGE: usize = 1_000; +/// Open samples over target an explanation names; the rest are counted. +const LISTED_OPEN: usize = 5; + +/// Every declared objective this store evaluates, by subject. +#[derive(Default)] +struct Evaluations(Mutex>); + +struct Tracked { + revision: String, + claim_id: String, + objective: Result, + /// The store index of the newest claim read. + cursor: u64, + samples: BTreeMap, + /// Claims of the interval's kinds whose time, set or member could not be read. + unreadable: u64, + /// The rows the last evaluation produced, for the breach response. + ids: BTreeSet, +} + +#[derive(Debug, Default)] +struct Sample { + /// The earliest start, and the label and exclude fields of its claim. + start: Option<(u64, BTreeMap)>, + expect: Option, + /// Each member's stop times; without `member=`, under "". + stops: BTreeMap>, + /// The earliest cancel. + cancel: Option, + /// Pauses (true) and resumes (false), in time order. + pauses: Vec<(u64, bool)>, + /// The latest time any of its claims carries. + last_seen: u64, +} + +/// The newest expect claim, by its time and then its claim id, so every member picks the same one. +#[derive(Debug)] +struct Expect { + at: u64, + id: String, + set: Vec, + fields: BTreeMap, +} + +/// What one sample (or one member of it) is at `now`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Class { + Closed { value: u64, stop: u64 }, + Open { age: u64 }, + Cancelled { at: u64 }, + Excluded { last_seen: u64 }, + /// Claims under the prefix with no start claim yet. + Unstarted, +} + +/// One point a row counts: a sample, or one member of it under `label "member"`. +struct Point<'a> { + subject: &'a str, + member: Option<&'a str>, + class: Class, +} + +/// The declared objectives' rows, evaluated at `now`. +pub fn rows(store: &Store, now: u64) -> anyhow::Result> { + let declared = store.declared_objectives()?; + let evaluations = store.fractal.extensions.get::(); + let mut tracked = evaluations.0.lock().unwrap_or_else(PoisonError::into_inner); + tracked.retain(|subject, _| declared.iter().any(|declaration| &declaration.subject == subject)); + let mut rows = Vec::new(); + for declaration in declared { + let entry = tracked + .entry(declaration.subject.clone()) + .or_insert_with(|| Tracked::new(&declaration)); + if entry.revision != declaration.revision { + *entry = Tracked::new(&declaration); + } + let name = declaration.name(); + let interval = match &entry.objective { + Ok(objective) => match (&objective.indicator, objective.window) { + (Indicator::Interval(interval), Some(window)) if objective.declarable() => Some((interval.clone(), window)), + _ => None, + }, + Err(_) => None, + }; + let Some((interval, window)) = interval else { + let row = entry.unreadable_row(&declaration.subject, name); + entry.ids = BTreeSet::from([name.to_owned()]); + rows.push(row); + continue; + }; + entry.read(store, &interval)?; + entry.prune(&interval, window, now); + let response = response(store, entry.objective.as_ref().ok().and_then(|objective| objective.on_breach.as_ref())); + let produced = entry.rows(&declaration.subject, &interval, window, now, &response); + entry.ids = produced.iter().filter_map(|row| row["id"].as_str().map(str::to_owned)).collect(); + rows.extend(produced); + } + Ok(rows) +} + +/// Respond to `rows`, the rows just evaluated, and report a failure once until a check succeeds +/// again. +pub fn check_and_report(store: &Store, rows: &[Value]) { + static FAILING: AtomicBool = AtomicBool::new(false); + match check(store, rows) { + Ok(_) => FAILING.store(false, Ordering::Relaxed), + Err(error) => { + if !FAILING.swap(true, Ordering::Relaxed) { + eprintln!("st3: responding to declared objectives failed: {error:?}"); + } + } + } +} + +/// For each declared objective with `on-breach` whose owner is declared on this member, open a +/// fault for each of its missed rows and end it when the row is met again. +pub fn check(store: &Store, rows: &[Value]) -> Result { + let responders: Vec<(String, Breach, BTreeSet)> = { + let evaluations = store.fractal.extensions.get::(); + let tracked = evaluations.0.lock().unwrap_or_else(PoisonError::into_inner); + tracked + .values() + .filter_map(|tracked| { + let objective = tracked.objective.as_ref().ok()?; + Some((objective.name.clone(), objective.on_breach.clone()?, tracked.ids.clone())) + }) + .collect() + }; + let mut total = ObjectiveFaults::default(); + for (name, breach, ids) in responders { + if store.agent_host(&breach.owner).map_err(internal)?.as_deref() != Some(store.origin()) { + continue; + } + let check = check_with_response( + store, + rows, + &ids, + Some(ObjectiveResponse { reviewer: &breach.owner, condition: &breach.condition, title: &name }), + )?; + total.opened += check.opened; + total.recovered += check.recovered; + total.unowned += check.unowned; + } + Ok(total) +} + +impl Tracked { + fn new(declaration: &DeclaredObjective) -> Self { + Self { + revision: declaration.revision.clone(), + claim_id: declaration.claim_id.clone(), + objective: declaration.objective.clone(), + cursor: 0, + samples: BTreeMap::new(), + unreadable: 0, + ids: BTreeSet::new(), + } + } + + /// Read the claims after the cursor. + fn read(&mut self, store: &Store, interval: &Interval) -> anyhow::Result<()> { + let kinds = interval.kinds(); + loop { + let page = store.claims_by_kinds_after(&kinds, Some(&interval.subject_prefix), self.cursor, PAGE)?; + for claim in &page.claims { + self.cursor = self.cursor.max(claim.store_index); + self.observe(interval, claim); + } + if page.next_cursor.is_none() { + return Ok(()); + } + } + } + + fn observe(&mut self, interval: &Interval, claim: &ClaimRecord) { + let sample = self.samples.entry(claim.subject.clone()).or_default(); + if sample.observe(interval, claim).is_none() { + self.unreadable += 1; + } + } + + /// Drop the samples that stopped, were cancelled or were excluded before the window. + fn prune(&mut self, interval: &Interval, window: Window, now: u64) { + let horizon = now.saturating_sub(window.ms); + self.samples.retain(|_, sample| match sample.class(interval, None, now) { + Class::Closed { stop, .. } => stop >= horizon, + Class::Cancelled { at } => at >= horizon, + Class::Excluded { last_seen } => last_seen >= horizon, + Class::Open { .. } | Class::Unstarted => true, + }); + } + + fn short_revision(&self) -> &str { + &self.revision[..self.revision.len().min(12)] + } + + /// The row of a declaration this build cannot evaluate. + fn unreadable_row(&self, subject: &str, name: &str) -> Value { + let why = match &self.objective { + Err(error) => format!("does not decode on this build: {error}"), + Ok(objective) => format!( + "is a `{}` indicator named `{}`, and a fleet declares only an interval named NAMESPACE/NAME", + objective.indicator.kind(), + objective.name + ), + }; + json!({ + "id": name, "kind": "objective", "about": format!("{subject} cannot be evaluated"), + "target": "unreadable", "current": "unreadable declaration", "window": "now", + "remaining_percent": null, "status": "no-data", + "explain": format!( + "{subject} at revision {} (claim {}) {why}. Nothing is measured until it is published again.", + self.short_revision(), self.claim_id, + ), + }) + } + + fn rows(&self, subject: &str, interval: &Interval, window: Window, now: u64, response: &str) -> Vec { + let Ok(objective) = &self.objective else { + return Vec::new(); + }; + let points: Vec> = self + .samples + .iter() + .map(|(subject, sample)| Point { subject, member: None, class: sample.class(interval, None, now) }) + .collect(); + let unstarted = points.iter().filter(|point| point.class == Class::Unstarted).count() as u64; + // Each label's rows, by value. + let mut groups: Vec<(String, Vec>)> = Vec::new(); + for label in &interval.labels { + let mut by_value: BTreeMap>> = BTreeMap::new(); + for (sample_subject, sample) in &self.samples { + if label == "member" { + for member in sample.members() { + by_value.entry(member.to_owned()).or_default().push(Point { + subject: sample_subject, + member: Some(member), + class: sample.class(interval, Some(member), now), + }); + } + } else if let Some(value) = sample.label(label) { + by_value.entry(value.to_owned()).or_default().push(Point { + subject: sample_subject, + member: None, + class: sample.class(interval, None, now), + }); + } + } + groups.extend(by_value.into_iter().map(|(value, points)| (format!("{{{label}={value}}}"), points))); + } + let context = Context { + objective, + subject, + revision: self.short_revision(), + window, + now, + unreadable: self.unreadable, + unstarted, + response, + }; + let mut rows = Vec::new(); + for target in &objective.targets { + let (Some(p), Some(within)) = (target.percentile(), target.within_ms()) else { + continue; + }; + let id = percentile_row(&objective.name, p); + rows.push(context.row(&id, p, within, &points)); + for (suffix, points) in &groups { + rows.push(context.row(&format!("{id}{suffix}"), p, within, points)); + } + } + rows + } +} + +impl Sample { + /// Fold one claim in; None when its time, set or member cannot be read. + fn observe(&mut self, interval: &Interval, claim: &ClaimRecord) -> Option<()> { + let fields = claim.body.get("fields").and_then(Value::as_object); + let accepted = u64::try_from(claim.accepted_at_unix_ms).unwrap_or(u64::MAX); + let time = |at: &Option| match at { + Some(field) => fields.and_then(|fields| fields.get(field)).and_then(time_ms), + None => Some(accepted), + }; + let kind = claim.kind.as_str(); + let at = if kind == interval.start.kind { + let at = time(&interval.start.at)?; + if self.start.as_ref().is_none_or(|(start, _)| at < *start) { + self.start = Some((at, kept_fields(interval, fields))); + } + at + } else if let Some((event, set)) = interval.expect.as_ref().filter(|(event, _)| event.kind == kind) { + let at = time(&event.at)?; + let set = fields.and_then(|fields| fields.get(set)).and_then(string_list)?; + if self.expect.as_ref().is_none_or(|expect| (at, claim.id.as_str()) > (expect.at, expect.id.as_str())) { + self.expect = Some(Expect { at, id: claim.id.clone(), set, fields: kept_fields(interval, fields) }); + } + at + } else if kind == interval.stop.0.kind { + let at = time(&interval.stop.0.at)?; + let member = match &interval.stop.1 { + Some(field) => fields.and_then(|fields| fields.get(field)).and_then(text)?, + None => String::new(), + }; + self.stops.entry(member).or_default().insert(at); + at + } else if interval.cancel.as_deref() == Some(kind) { + self.cancel = Some(self.cancel.map_or(accepted, |cancel| cancel.min(accepted))); + accepted + } else if let Some((pause, _)) = interval.pause.as_ref().filter(|(pause, resume)| pause == kind || resume == kind) { + let event = (accepted, pause == kind); + let at = self.pauses.partition_point(|existing| *existing <= event); + self.pauses.insert(at, event); + accepted + } else { + return None; + }; + self.last_seen = self.last_seen.max(at); + Some(()) + } + + /// The members a sample stops on: its expected set, else the members that stopped. + fn members(&self) -> Vec<&str> { + match &self.expect { + Some(expect) => expect.set.iter().map(String::as_str).collect(), + None => self.stops.keys().map(String::as_str).filter(|member| !member.is_empty()).collect(), + } + } + + fn label(&self, field: &str) -> Option<&str> { + let start = self.start.as_ref().and_then(|(_, fields)| fields.get(field)); + start.or_else(|| self.expect.as_ref().and_then(|expect| expect.fields.get(field))).map(String::as_str) + } + + fn excluded(&self, interval: &Interval) -> bool { + interval.excludes.iter().any(|exclude| { + let start = self.start.as_ref().and_then(|(_, fields)| fields.get(&exclude.field)); + let expect = self.expect.as_ref().and_then(|expect| expect.fields.get(&exclude.field)); + [start, expect].into_iter().flatten().any(|value| exclude.values.contains(value)) + }) + } + + /// The earliest stop of `member` at or after `start`. + fn stop_of(&self, member: &str, start: u64) -> Option { + self.stops.get(member)?.range(start..).next().copied() + } + + /// The sample at `now`, or with `member`, that member's part of it. + fn class(&self, interval: &Interval, member: Option<&str>, now: u64) -> Class { + let Some((start, _)) = &self.start else { + return Class::Unstarted; + }; + let start = *start; + if self.excluded(interval) { + return Class::Excluded { last_seen: self.last_seen }; + } + let close = match (member, &interval.expect, &self.expect) { + (Some(member), ..) => self.stop_of(member, start), + (None, Some(_), None) => None, + (None, Some(_), Some(expect)) if expect.set.is_empty() => Some(expect.at.max(start)), + (None, Some(_), Some(expect)) => expect + .set + .iter() + .map(|member| self.stop_of(member, start)) + .collect::>>() + .and_then(|stops| stops.into_iter().max()), + (None, None, _) => self.stops.keys().filter_map(|member| self.stop_of(member, start)).min(), + }; + if let Some(cancel) = self.cancel + && close.is_none_or(|close| cancel < close) + { + return Class::Cancelled { at: cancel }; + } + match close { + Some(stop) => Class::Closed { value: (stop - start).saturating_sub(self.paused(start, stop)), stop }, + None => Class::Open { age: now.saturating_sub(start).saturating_sub(self.paused(start, now.max(start))) }, + } + } + + /// The time paused between `start` and `end`; a pause with no resume runs to `end`. + fn paused(&self, start: u64, end: u64) -> u64 { + let mut total = 0; + let mut since = None; + for &(at, pause) in &self.pauses { + let at = at.clamp(start, end); + match (pause, since) { + (true, None) => since = Some(at), + (false, Some(from)) => { + total += at - from; + since = None; + } + _ => {} + } + } + total + since.map_or(0, |from| end - from) + } +} + +/// The start or expect fields a sample keeps: its labels' and its excludes'. +fn kept_fields(interval: &Interval, fields: Option<&Map>) -> BTreeMap { + let Some(fields) = fields else { + return BTreeMap::new(); + }; + interval + .labels + .iter() + .chain(interval.excludes.iter().map(|exclude| &exclude.field)) + .filter_map(|name| Some((name.clone(), text(fields.get(name)?)?))) + .collect() +} + +/// A time from an RFC 3339 string or Unix milliseconds, either possibly JSON-encoded in a string. +fn time_ms(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_u64(), + Value::String(text) => chrono::DateTime::parse_from_rfc3339(text) + .ok() + .and_then(|at| u64::try_from(at.timestamp_millis()).ok()) + .or_else(|| text.parse().ok()) + .or_else(|| serde_json::from_str::(text).ok().filter(Value::is_string).and_then(|value| time_ms(&value))), + _ => None, + } +} + +/// A field's text: a string (a JSON-encoded one decoded), a number or a boolean. +fn text(value: &Value) -> Option { + match value { + Value::String(text) => Some(serde_json::from_str::(text).unwrap_or_else(|_| text.clone())), + Value::Number(number) => Some(number.to_string()), + Value::Bool(flag) => Some(flag.to_string()), + _ => None, + } +} + +/// A list of strings, or a string holding one as JSON. +fn string_list(value: &Value) -> Option> { + match value { + Value::Array(items) => items.iter().map(|item| item.as_str().map(str::to_owned)).collect(), + Value::String(text) => serde_json::from_str::(text).ok().filter(Value::is_array).and_then(|value| string_list(&value)), + _ => None, + } +} + +/// A span as its largest whole unit: `7d`, `90m`. +fn span(ms: u64) -> String { + for (unit, size) in [("d", 86_400_000), ("h", 3_600_000), ("m", 60_000), ("s", 1_000)] { + if ms >= size && ms % size == 0 { + return format!("{}{unit}", ms / size); + } + } + format!("{ms}ms") +} + +/// What a miss does, as the explanation says it. +fn response(store: &Store, breach: Option<&Breach>) -> String { + let Some(breach) = breach else { + return "No response is declared: a miss is shown and nothing is delivered.".to_owned(); + }; + let backup = breach + .backup + .as_ref() + .map(|backup| format!(" {backup} is the held backup: recorded and shown, never the reviewer.")) + .unwrap_or_default(); + let owner = &breach.owner; + let live = store.fault_reviewer_is_declared(owner); + let host = store.agent_host(owner); + match (live, host) { + (Ok(true), Ok(Some(host))) => format!( + "A missed row opens one operational.failure `{}` on daemon/{host} addressed to {owner}, delivered by {host}, the member {owner} is declared on{}.{backup}", + breach.condition, + if host == store.origin() { " (this member)" } else { "" }, + ), + (Ok(_), Ok(_)) => format!( + "A miss would open an operational.failure `{}` for {owner}, but {owner} is not a live agent declaration, so nothing is delivered.{backup}", + breach.condition, + ), + (Err(error), _) | (_, Err(error)) => format!("The response to {owner} could not be read: {error}.{backup}"), + } +} + +struct Context<'a> { + objective: &'a Objective, + subject: &'a str, + revision: &'a str, + window: Window, + now: u64, + unreadable: u64, + unstarted: u64, + response: &'a str, +} + +impl Context<'_> { + /// The row of percentile `p` within `within` over `points`. + fn row(&self, id: &str, p: u64, within: u64, points: &[Point<'_>]) -> Value { + let horizon = self.now.saturating_sub(self.window.ms); + let (mut closed, mut open_within, mut cancelled, mut excluded) = (0u64, 0u64, 0u64, 0u64); + let mut population = Vec::new(); + let mut open_over: Vec<(&Point<'_>, u64)> = Vec::new(); + for point in points { + match point.class { + Class::Closed { value, stop } if stop >= horizon => { + closed += 1; + population.push(value); + } + Class::Open { age } if age > within => { + open_over.push((point, age)); + population.push(age); + } + Class::Open { .. } => open_within += 1, + Class::Cancelled { at } if at >= horizon => cancelled += 1, + Class::Excluded { last_seen } if last_seen >= horizon => excluded += 1, + _ => {} + } + } + open_over.sort_by(|a, b| b.1.cmp(&a.1)); + let window = span(self.window.ms); + let goal_within = duration(within); + let target = format!("p{p} ≤ {goal_within}"); + let about = &self.objective.about; + let goal = format!("Goal: {p} of every 100 stop within {goal_within}, so {} in 100 may run over.", 100 - p); + let mut counted = format!( + "In the last {window}: {closed} closed, {} open over {goal_within}, {open_within} open within it, {cancelled} cancelled, {excluded} excluded, {} unreadable {}.", + open_over.len(), + self.unreadable, + if self.unreadable == 1 { "claim" } else { "claims" }, + ); + if self.unstarted > 0 { + counted.push_str(&format!(" {} {} claims but no start yet.", plural(self.unstarted, "subject"), if self.unstarted == 1 { "has" } else { "have" })); + } + let declared = format!("Declared as {} at revision {}.", self.subject, self.revision); + let listed = if open_over.is_empty() { + String::new() + } else { + let names = open_over + .iter() + .take(LISTED_OPEN) + .map(|(point, age)| match point.member { + Some(member) => format!("{} on {member} (open {})", point.subject, duration(*age)), + None => format!("{} (open {})", point.subject, duration(*age)), + }) + .collect::>() + .join(", "); + let more = open_over.len().saturating_sub(LISTED_OPEN); + format!( + " Open over target, counted at their age: {names}{}.", + if more > 0 { format!(", and {more} more") } else { String::new() } + ) + }; + let n = population.len() as u64; + let base = json!({ + "id": id, "kind": "objective", "about": about, "target": target, "window": window, + }); + let mut row = base; + let Some(value) = (n > 0).then(|| nearest_rank(&population, p)) else { + row["current"] = json!("no samples"); + row["remaining_percent"] = Value::Null; + row["status"] = json!("no-data"); + row["explain"] = json!(format!( + "{about}. {goal} {declared} {counted} No sample stopped in the window and none is open over target, so there is no budget to spend or save. {}", + self.response, + )); + return row; + }; + let over = population.iter().filter(|value| **value > within).count() as u64; + let share = over as f64 / n as f64; + let allowed = (100 - p) as f64 / 100.0; + let remaining = clamp_percent(100.0 * (1.0 - share / allowed)); + let value = duration(value); + let open = if open_over.is_empty() { String::new() } else { format!(" ({} open over)", open_over.len()) }; + let enough = n >= self.window.min_samples; + row["current"] = json!(if enough { + format!("p{p} {value} over {}{open}", plural(n, "sample")) + } else { + format!("p{p} {value} over {}{open}, needs {}", plural(n, "sample"), self.window.min_samples) + }); + row["remaining_percent"] = if enough { json!(remaining) } else { Value::Null }; + let (status, verdict) = match (enough, share <= allowed) { + (false, _) => ("no-data", format!("It needs {} before it is met or missed.", plural(self.window.min_samples, "sample"))), + (true, true) => ("met", "The goal is met.".to_owned()), + (true, false) => ("missed", format!("The goal is missed in the last {window}.")), + }; + row["status"] = json!(status); + row["explain"] = json!(format!( + "{about}. {goal} {declared} {counted} An open sample over {goal_within} counts at its age, since it is certain to be over; a younger one is left out until it stops. Over {goal_within}: {over} of {} ({:.1}%), against an allowance of {}%, so {remaining:.0}% of the error budget is left. The p{p} is {value}. {verdict}{listed} {}", + plural(n, "sample"), + share * 100.0, + 100 - p, + self.response, + )); + row + } +} + +#[cfg(test)] +#[path = "declared_objectives_tests.rs"] +mod tests; diff --git a/crates/fractal-api-core/src/declared_objectives_tests.rs b/crates/fractal-api-core/src/declared_objectives_tests.rs new file mode 100644 index 00000000000..b6472cd8252 --- /dev/null +++ b/crates/fractal-api-core/src/declared_objectives_tests.rs @@ -0,0 +1,387 @@ +use super::*; +use crate::model::{ClaimInput, IntentInput}; + +const MINUTE: u64 = 60_000; +/// The evaluation time; claim times are given relative to it in their fields. It is the clock's, +/// since a cancel or pause is timed by its acceptance. +static NOW: std::sync::LazyLock = std::sync::LazyLock::new(|| { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64 +}); +const READY: &str = "custom.acme.change-ready"; +const MERGED: &str = "custom.acme.change-merged"; +const APPLIED: &str = "custom.acme.change-applied"; +const ABANDONED: &str = "custom.acme.change-abandoned"; + +fn declaration(extra_interval: &str, window: &str, breach: &str) -> String { + format!( + r#"version 2 +objective "ops/change-rollout" {{ + about "A change, from ready to every member that serves it" + indicator "interval" {{ + subject-prefix "custom/acme/change/" + start "{READY}" at="ready_at" + expect "{MERGED}" set="hosts" at="merged_at" + stop "{APPLIED}" member="host" at="applied_at" + cancel "{ABANDONED}" +{extra_interval} }} + window "{window}" + target "p50" within="60s" + target "p95" within="5m" +{breach}}} +"# + ) +} + +fn apply(store: &Store, source: &str) { + let intent = crate::graph::parse_intent(source, "node").unwrap(); + let preview = store.mission(&intent, IntentInput { kdl: source.into(), source_name: None }).unwrap(); + // One key per publication: a repeated key returns the first publication's result. + store.apply(&intent, &preview.subject_tokens, &intent.source_hash).unwrap(); +} + +fn store_with(source: &str) -> Store { + let store = Store::open_memory("node").unwrap(); + apply(&store, source); + store +} + +fn claim(store: &Store, change: &str, kind: &str, fields: Value) -> u64 { + store + .append_claim(&ClaimInput { + subject: format!("custom/acme/change/{change}"), + kind: kind.into(), + actor: None, + fields: fields.as_object().unwrap().clone().into_iter().collect(), + evidence: Vec::new(), + expected_subject: None, + idempotency_key: None, + }) + .unwrap() + .store_index +} + +/// A change ready `ago` minutes before NOW, merged for `hosts`, and applied on each `(host, after)` +/// `after` seconds past ready. +fn change(store: &Store, name: &str, ago_ms: u64, hosts: &[&str], applied: &[(&str, u64)], class: &str) { + let ready = *NOW - ago_ms; + claim(store, name, READY, json!({"ready_at": ready, "class": class})); + claim(store, name, MERGED, json!({"merged_at": ready + 1_000, "hosts": hosts})); + for (host, after_s) in applied { + claim(store, name, APPLIED, json!({"host": host, "applied_at": ready + after_s * 1_000})); + } +} + +fn evaluate(store: &Store) -> BTreeMap { + rows(store, *NOW).unwrap().into_iter().map(|row| (row["id"].as_str().unwrap().to_owned(), row)).collect() +} + +fn s(text: &str) -> String { + text.to_owned() +} + +#[test] +fn a_change_closes_at_its_last_expected_member_and_rows_have_the_base_keys() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta", "hostb"], &[("hosta", 40), ("hostb", 55)], "routine"); + let rows = evaluate(&store); + assert_eq!(rows.keys().cloned().collect::>(), [s("ops/change-rollout/p50"), s("ops/change-rollout/p95")]); + let p50 = &rows["ops/change-rollout/p50"]; + let mut keys = p50.as_object().unwrap().keys().cloned().collect::>(); + keys.sort(); + assert_eq!(keys, ["about", "current", "explain", "id", "kind", "remaining_percent", "status", "target", "window"]); + assert_eq!(p50["kind"], "objective"); + assert_eq!(p50["target"], "p50 ≤ 1 min"); + assert_eq!(p50["current"], "p50 55 s over 1 sample", "the latest member's stop, not the first"); + assert_eq!(p50["window"], "7d"); + assert_eq!((p50["status"].as_str(), p50["remaining_percent"].as_f64()), (Some("met"), Some(100.0))); + let explain = p50["explain"].as_str().unwrap(); + assert!(explain.contains("Declared as objective/ops/change-rollout at revision "), "{explain}"); + assert!(explain.contains("1 closed, 0 open over 1 min, 0 open within it, 0 cancelled, 0 excluded, 0 unreadable claims."), "{explain}"); + assert!(explain.ends_with("No response is declared: a miss is shown and nothing is delivered."), "{explain}"); +} + +#[test] +fn an_open_change_over_target_counts_at_its_age_and_a_younger_one_is_left_out() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta", "hostb"], &[("hosta", 40), ("hostb", 55)], "routine"); + change(&store, "b", 10 * MINUTE, &["hosta"], &[("hosta", 130)], "routine"); + // Merged for two hosts, applied on one, ready 20 minutes ago: open and overdue. + change(&store, "c", 20 * MINUTE, &["hosta", "hostb"], &[("hosta", 30)], "routine"); + // Ready 30 seconds ago: open, within both targets. + change(&store, "d", 30_000, &["hosta"], &[], "routine"); + let rows = evaluate(&store); + let p50 = &rows["ops/change-rollout/p50"]; + // p50 population: 55 s, 130 s, and c at 20 min (over 60 s); d (30 s) is within, left out. + assert_eq!(p50["current"], "p50 2.2 min over 3 samples (1 open over)"); + assert_eq!(p50["status"], "missed", "2 of 3 over 60 s against 50 in 100"); + let explain = p50["explain"].as_str().unwrap(); + assert!(explain.contains("2 closed, 1 open over 1 min, 1 open within it"), "{explain}"); + assert!(explain.contains("custom/acme/change/c (open 20 min)"), "{explain}"); + let p95 = &rows["ops/change-rollout/p95"]; + // p95 population: 55 s, 130 s and c at 20 min (over 5 min); its nearest rank is c's age. + assert_eq!(p95["current"], "p95 20 min over 3 samples (1 open over)"); + assert_eq!((p95["status"].as_str(), p95["remaining_percent"].as_f64()), (Some("missed"), Some(0.0))); +} + +#[test] +fn a_cancel_ends_an_open_change_but_not_one_that_stopped_first() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "open", 20 * MINUTE, &["hosta"], &[], "routine"); + claim(&store, "open", ABANDONED, json!({})); + // Stopped long before the cancel's acceptance (now, on the real clock). + change(&store, "closed", 20 * MINUTE, &["hosta"], &[("hosta", 45)], "routine"); + claim(&store, "closed", ABANDONED, json!({})); + let p50 = &evaluate(&store)["ops/change-rollout/p50"]; + assert_eq!(p50["current"], "p50 45 s over 1 sample"); + assert!(p50["explain"].as_str().unwrap().contains("1 closed, 0 open over 1 min, 0 open within it, 1 cancelled")); +} + +#[test] +fn an_empty_expected_set_stops_the_change_at_the_expect_claim() { + let store = store_with(&declaration("", "7d", "")); + let ready = *NOW - 10 * MINUTE; + claim(&store, "noop", READY, json!({"ready_at": ready})); + claim(&store, "noop", MERGED, json!({"merged_at": ready + 20_000, "hosts": "[]"})); + let p50 = &evaluate(&store)["ops/change-rollout/p50"]; + assert_eq!(p50["current"], "p50 20 s over 1 sample", "a JSON-encoded empty list"); +} + +#[test] +fn an_excluded_change_is_counted_apart_and_never_in_a_percentile() { + let store = store_with(&declaration(" exclude \"exclusion\" \"deferred-launch\"\n", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta"], &[("hosta", 40)], "routine"); + let ready = *NOW - 20 * MINUTE; + claim(&store, "x", READY, json!({"ready_at": ready})); + claim(&store, "x", MERGED, json!({"merged_at": ready, "hosts": ["hosta"], "exclusion": "deferred-launch"})); + let p95 = &evaluate(&store)["ops/change-rollout/p95"]; + assert_eq!(p95["current"], "p95 40 s over 1 sample"); + assert!(p95["explain"].as_str().unwrap().contains("0 cancelled, 1 excluded")); +} + +#[test] +fn labels_split_rows_by_a_field_and_by_member() { + let store = store_with(&declaration(" label \"class\"\n label \"member\"\n", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta", "hostb"], &[("hosta", 40), ("hostb", 55)], "routine"); + change(&store, "b", 10 * MINUTE, &["hosta"], &[("hosta", 130)], "urgent"); + change(&store, "c", 20 * MINUTE, &["hosta", "hostb"], &[("hosta", 30)], "routine"); + let rows = evaluate(&store); + let current = |id: &str| rows[id]["current"].as_str().unwrap().to_owned(); + // a (55 s) and c (open 20 minutes): the nearest rank of p50 over two is the first. + assert_eq!(current("ops/change-rollout/p50{class=routine}"), "p50 55 s over 2 samples (1 open over)"); + assert_eq!(current("ops/change-rollout/p50{class=urgent}"), "p50 2.2 min over 1 sample"); + // hosta stopped in 40 s, 130 s and 30 s; hostb in 55 s and is open on c for 20 minutes. + assert_eq!(current("ops/change-rollout/p95{member=hosta}"), "p95 2.2 min over 3 samples"); + assert_eq!(current("ops/change-rollout/p95{member=hostb}"), "p95 20 min over 2 samples (1 open over)"); + assert!(rows["ops/change-rollout/p95{member=hostb}"]["explain"].as_str().unwrap().contains("custom/acme/change/c on hostb (open 20 min)")); + assert_eq!(rows.len(), 2 * 5); +} + +#[test] +fn a_closed_change_belongs_to_the_window_by_its_stop_time_and_older_ones_are_dropped() { + let store = store_with(&declaration("", "1h", "")); + // Ready two hours ago, stopped 30 minutes ago: in the window. + change(&store, "late", 120 * MINUTE, &["hosta"], &[("hosta", 90 * 60)], "routine"); + // Stopped 100 minutes ago: out of it. + change(&store, "old", 120 * MINUTE, &["hosta"], &[("hosta", 20 * 60)], "routine"); + let p50 = &evaluate(&store)["ops/change-rollout/p50"]; + assert_eq!(p50["current"], "p50 90 min over 1 sample"); + let evaluations = store.fractal.extensions.get::(); + let tracked = evaluations.0.lock().unwrap(); + assert_eq!(tracked["objective/ops/change-rollout"].samples.keys().collect::>(), ["custom/acme/change/late"]); +} + +#[test] +fn fewer_samples_than_the_minimum_is_no_data_and_none_at_all_says_so() { + let source = declaration("", "7d", "").replace("window \"7d\"", "window \"7d\" min-samples=3"); + let store = store_with(&source); + let empty = &evaluate(&store)["ops/change-rollout/p50"]; + assert_eq!((empty["current"].as_str(), empty["status"].as_str()), (Some("no samples"), Some("no-data"))); + assert!(empty["remaining_percent"].is_null()); + change(&store, "a", 10 * MINUTE, &["hosta"], &[("hosta", 400)], "routine"); + change(&store, "b", 10 * MINUTE, &["hosta"], &[("hosta", 500)], "routine"); + let p50 = &evaluate(&store)["ops/change-rollout/p50"]; + assert_eq!(p50["current"], "p50 6.7 min over 2 samples, needs 3"); + assert_eq!(p50["status"], "no-data", "both over, but too few to judge"); + assert!(p50["remaining_percent"].is_null()); + change(&store, "c", 10 * MINUTE, &["hosta"], &[("hosta", 600)], "routine"); + assert_eq!(evaluate(&store)["ops/change-rollout/p50"]["status"], "missed"); +} + +#[test] +fn a_new_revision_reads_the_claims_again() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta"], &[("hosta", 40)], "routine"); + change(&store, "b", 10 * MINUTE, &["hosta"], &[("hosta", 50)], "urgent"); + assert_eq!(evaluate(&store).len(), 2); + // The first revision kept no `class` field; the label needs the claims read again. + apply(&store, &declaration(" label \"class\"\n", "7d", "")); + let rows = evaluate(&store); + assert_eq!(rows["ops/change-rollout/p50{class=routine}"]["current"], "p50 40 s over 1 sample"); + assert_eq!(rows["ops/change-rollout/p50{class=urgent}"]["current"], "p50 50 s over 1 sample"); + assert_eq!(rows["ops/change-rollout/p50"]["current"], "p50 40 s over 2 samples"); +} + +#[test] +fn an_evaluation_reads_only_the_claims_after_its_cursor() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta"], &[("hosta", 40)], "routine"); + evaluate(&store); + let evaluations = store.fractal.extensions.get::(); + let cursor = { + let mut tracked = evaluations.0.lock().unwrap(); + let tracked = tracked.get_mut("objective/ops/change-rollout").unwrap(); + // Forget what was read: a reread would bring `a` back. + tracked.samples.clear(); + tracked.cursor + }; + // Claims of other kinds or under another prefix do not move it. + store + .append_claim(&ClaimInput { + subject: "custom/acme/other/z".into(), + kind: READY.into(), + actor: None, + fields: BTreeMap::from([("ready_at".into(), json!(*NOW))]), + evidence: Vec::new(), + expected_subject: None, + idempotency_key: None, + }) + .unwrap(); + claim(&store, "a", "custom.acme.note", json!({})); + change(&store, "b", 10 * MINUTE, &["hosta"], &[("hosta", 50)], "routine"); + assert_eq!(evaluate(&store)["ops/change-rollout/p50"]["current"], "p50 50 s over 1 sample"); + let tracked = evaluations.0.lock().unwrap(); + let tracked = &tracked["objective/ops/change-rollout"]; + assert!(tracked.cursor > cursor); + assert_eq!(tracked.samples.keys().collect::>(), ["custom/acme/change/b"]); +} + +#[test] +fn a_claim_without_a_readable_time_or_member_is_counted_unreadable() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta"], &[("hosta", 40)], "routine"); + claim(&store, "a", APPLIED, json!({"applied_at": "yesterday", "host": "hostb"})); + claim(&store, "a", APPLIED, json!({"applied_at": *NOW})); + let explain = evaluate(&store)["ops/change-rollout/p50"]["explain"].as_str().unwrap().to_owned(); + assert!(explain.contains("2 unreadable claims"), "{explain}"); +} + +fn record(kind: &str, accepted: u64, fields: Value) -> ClaimRecord { + ClaimRecord { + id: format!("{kind}-{accepted}"), + store_index: 0, + batch_id: String::new(), + subject: "custom/acme/change/p".into(), + kind: kind.into(), + origin: "node".into(), + actor: None, + operation_id: None, + request_digest: None, + body: json!({ "fields": fields }), + predecessors: Vec::new(), + accepted_at_unix_ms: u128::from(accepted), + } +} + +#[test] +fn paused_time_is_not_counted_and_a_paused_change_does_not_age() { + let source = declaration(" pause \"custom.acme.change-held\" resume=\"custom.acme.change-released\"\n", "7d", ""); + let document = crate::objective::parse_document(&source).unwrap(); + let Indicator::Interval(interval) = &document[0].indicator else { panic!("an interval") }; + let start = *NOW - 30 * MINUTE; + let mut sample = Sample::default(); + sample.observe(interval, &record(READY, start, json!({"ready_at": start}))).unwrap(); + sample.observe(interval, &record(MERGED, start, json!({"merged_at": start, "hosts": ["hosta"]}))).unwrap(); + sample.observe(interval, &record("custom.acme.change-held", start + 5 * MINUTE, json!({}))).unwrap(); + sample.observe(interval, &record("custom.acme.change-released", start + 15 * MINUTE, json!({}))).unwrap(); + sample.observe(interval, &record("custom.acme.change-held", start + 20 * MINUTE, json!({}))).unwrap(); + // Thirty minutes old, ten paused and still paused for the last ten: it aged ten minutes. + assert_eq!(sample.class(interval, None, *NOW), Class::Open { age: 10 * MINUTE }); + assert_eq!(sample.class(interval, None, *NOW + 60 * MINUTE), Class::Open { age: 10 * MINUTE }); + sample.observe(interval, &record("custom.acme.change-released", start + 25 * MINUTE, json!({}))).unwrap(); + sample.observe(interval, &record(APPLIED, start + 28 * MINUTE, json!({"host": "hosta", "applied_at": start + 28 * MINUTE}))).unwrap(); + assert_eq!(sample.class(interval, None, *NOW), Class::Closed { value: 13 * MINUTE, stop: start + 28 * MINUTE }); +} + +#[test] +fn a_declaration_this_build_cannot_read_is_a_row_that_says_so() { + // As a replicated body from a newer build reads here. + let error = crate::objective::parse(&json!({"name": "objective", "arguments": ["ops/next"]})).unwrap_err(); + let declaration = DeclaredObjective { + subject: "objective/ops/next".into(), + revision: "0123456789abcdef".into(), + claim_id: "claim-1".into(), + objective: Err(format!("{}: {}", error.code, error.message)), + }; + let row = Tracked::new(&declaration).unreadable_row(&declaration.subject, declaration.name()); + assert_eq!((row["id"].as_str(), row["status"].as_str()), (Some("ops/next"), Some("no-data"))); + assert_eq!(row["current"], "unreadable declaration"); + let explain = row["explain"].as_str().unwrap(); + assert!(explain.starts_with("objective/ops/next at revision 0123456789ab (claim claim-1) does not decode on this build: invalid-objective"), "{explain}"); +} + +fn with_owner(store: &Store, host: &str) { + let source = format!("version 2\nagent \"{host}.ops\" {{ command \"true\"; host \"{host}\" }}\n"); + apply(store, &source); + let owner = format!("agent/{host}.ops"); + assert_eq!(store.agent_host(&owner).unwrap().as_deref(), Some(host)); + assert!(store.fault_reviewer_is_declared(&owner).unwrap()); +} + +const BREACH: &str = " on-breach \"fault\" owner=\"agent/node.ops\" backup=\"agent/node.backup\" condition=\"change-rollout-slo-missed\"\n"; + +#[test] +fn a_missed_row_is_a_fault_for_its_owner_on_the_member_the_owner_is_declared_on() { + let store = store_with(&declaration("", "7d", BREACH)); + with_owner(&store, "node"); + change(&store, "a", 30 * MINUTE, &["hosta"], &[("hosta", 600)], "routine"); + let rows = rows(&store, *NOW).unwrap(); + let explain = rows[0]["explain"].as_str().unwrap(); + assert!(explain.contains("addressed to agent/node.ops, delivered by node, the member agent/node.ops is declared on (this member). agent/node.backup is the held backup"), "{explain}"); + let opened = check(&store, &rows).unwrap(); + assert_eq!(opened.opened, 2, "p50 and p95 are both missed"); + let failure = store.operational_failure("slo-objective/node/ops/change-rollout/p95").unwrap().unwrap(); + assert_eq!((failure.status.as_str(), failure.reviewer.as_str()), ("pending", "agent/node.ops"), "the owner, never the backup"); + assert_eq!(check(&store, &rows).unwrap(), ObjectiveFaults::default(), "one episode while missed"); + + // Twenty fast changes put the slow one within both allowances: met again, the fault ends. + for name in ["b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u"] { + change(&store, name, 10 * MINUTE, &["hosta"], &[("hosta", 10)], "routine"); + } + let rows = super::rows(&store, *NOW).unwrap(); + assert_eq!(rows.iter().map(|row| row["status"].as_str().unwrap()).collect::>(), ["met", "met"]); + assert_eq!(check(&store, &rows).unwrap().recovered, 2); + assert_eq!(store.operational_failure("slo-objective/node/ops/change-rollout/p95").unwrap().unwrap().status, "resolved"); +} + +#[test] +fn an_owner_declared_on_another_member_is_delivered_there_not_here() { + let store = store_with(&declaration("", "7d", &BREACH.replace("node.ops", "other.ops"))); + with_owner(&store, "other"); + change(&store, "a", 30 * MINUTE, &["hosta"], &[("hosta", 600)], "routine"); + let rows = rows(&store, *NOW).unwrap(); + assert_eq!(rows[0]["status"], "missed"); + assert!(rows[0]["explain"].as_str().unwrap().contains("delivered by other, the member agent/other.ops is declared on.")); + assert_eq!(check(&store, &rows).unwrap(), ObjectiveFaults::default()); + assert!(store.operational_failure("slo-objective/node/ops/change-rollout/p95").unwrap().is_none()); +} + +#[test] +fn an_owner_that_is_not_declared_gets_nothing_and_the_explanation_says_so() { + let store = store_with(&declaration("", "7d", BREACH)); + change(&store, "a", 30 * MINUTE, &["hosta"], &[("hosta", 600)], "routine"); + let rows = rows(&store, *NOW).unwrap(); + assert!(rows[0]["explain"].as_str().unwrap().contains("agent/node.ops is not a live agent declaration, so nothing is delivered")); + assert_eq!(check(&store, &rows).unwrap(), ObjectiveFaults::default()); +} + +#[test] +fn spans_and_field_values_read_the_ways_claims_write_them() { + assert_eq!([span(7 * 86_400_000), span(90 * MINUTE), span(1_500)], ["7d", "90m", "1500ms"]); + assert_eq!(time_ms(&json!("2026-11-09T10:00:00Z")), Some(1_794_218_400_000)); + assert_eq!(time_ms(&json!("1794218400000")), Some(1_794_218_400_000)); + assert_eq!(time_ms(&json!("\"2026-11-09T10:00:00Z\"")), Some(1_794_218_400_000)); + assert_eq!(time_ms(&json!("soon")), None); + assert_eq!(string_list(&json!("[\"a\",\"b\"]")), Some(vec![s("a"), s("b")])); + assert_eq!(string_list(&json!(["a", 1])), None); + assert_eq!(text(&json!("\"routine\"")).as_deref(), Some("routine")); +} diff --git a/crates/fractal-api-core/src/lib.rs b/crates/fractal-api-core/src/lib.rs index 47aef1f6bc0..bff5f21ca70 100644 --- a/crates/fractal-api-core/src/lib.rs +++ b/crates/fractal-api-core/src/lib.rs @@ -18,6 +18,7 @@ pub mod api; pub mod claude_channel; pub mod creation; pub mod credit_objectives; +pub mod declared_objectives; pub mod delivery_hold; pub mod file_inventory; pub mod gate_check; diff --git a/crates/fractal-api-core/src/objectives.rs b/crates/fractal-api-core/src/objectives.rs index f3b8d846f93..1e4a10eaf35 100644 --- a/crates/fractal-api-core/src/objectives.rs +++ b/crates/fractal-api-core/src/objectives.rs @@ -111,11 +111,11 @@ pub fn ms(value: f64) -> String { if value >= 10.0 { format!("{} ms", value.round()) } else { format!("{:.1} ms", value) } } -fn clamp_percent(value: f64) -> f64 { +pub(crate) fn clamp_percent(value: f64) -> f64 { (value.clamp(0.0, 100.0) * 10.0).round() / 10.0 } -fn plural(n: u64, word: &str) -> String { +pub(crate) fn plural(n: u64, word: &str) -> String { format!("{n} {word}{}", if n == 1 { "" } else { "s" }) } @@ -329,18 +329,23 @@ pub fn divergence(open: u64) -> Value { }) } -/// A duration in minutes once it is a minute or more: CI's targets are minutes, not milliseconds. -fn duration(target_ms: u64) -> String { - if target_ms < 60_000 { +/// A duration in seconds under a minute and in minutes from a minute on: CI's targets are +/// minutes, a declared interval's may be seconds. +pub(crate) fn duration(target_ms: u64) -> String { + if target_ms < 1_000 { return ms(target_ms as f64); } + if target_ms < 60_000 { + let seconds = target_ms as f64 / 1_000.0; + return if seconds.fract() == 0.0 { format!("{seconds} s") } else { format!("{seconds:.1} s") }; + } let minutes = target_ms as f64 / 60_000.0; if minutes.fract() == 0.0 { format!("{minutes} min") } else { format!("{minutes:.1} min") } } /// The value at `percentile` of `samples`, nearest rank: the smallest sample that at least /// `percentile` in 100 are at or under. -fn nearest_rank(samples: &[u64], percentile: u64) -> u64 { +pub(crate) fn nearest_rank(samples: &[u64], percentile: u64) -> u64 { let mut sorted = samples.to_vec(); sorted.sort_unstable(); let rank = (percentile as usize * sorted.len()).div_ceil(100).max(1); @@ -1198,10 +1203,12 @@ mod tests { } #[test] - fn a_ci_duration_reads_in_minutes() { + fn a_duration_reads_in_minutes_from_a_minute_and_seconds_below() { assert_eq!(duration(600_000), "10 min"); assert_eq!(duration(780_000), "13 min"); assert_eq!(duration(90_000), "1.5 min"); - assert_eq!(duration(1_500), "1500 ms"); + assert_eq!(duration(55_000), "55 s"); + assert_eq!(duration(1_500), "1.5 s"); + assert_eq!(duration(400), "400 ms"); } } diff --git a/crates/fractal-daemon/src/lib.rs b/crates/fractal-daemon/src/lib.rs index 6119b3c72b8..6e357dc5368 100644 --- a/crates/fractal-daemon/src/lib.rs +++ b/crates/fractal-daemon/src/lib.rs @@ -71,6 +71,7 @@ pub use fractal_store::native_resume; /// Durable seat-scoped first native launch outcomes. pub use fractal_reconcile::native_seed; pub use fractal_store::node_identity; +pub use fractal_api_mailbox::declared_objectives; pub use fractal_api_mailbox::objective_faults; pub use fractal_api_mailbox::objectives; pub use fractal_api_mailbox::readiness_slo; diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 13e67000c22..1885350ee9f 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -7595,6 +7595,7 @@ async fn run_up(args: UpArgs) -> Result<()> { move || { let mut rows = fractal_daemon::api::slo_rows(&store)?; fractal_daemon::objective_faults::check_and_report(&store, &rows); + fractal_daemon::declared_objectives::check_and_report(&store, &rows); rows.extend(fractal_daemon::readiness_slo::sample(&store)); Ok(rows) }, @@ -10772,7 +10773,7 @@ async fn run_slo(client: &Client, args: SloArgs, json_output: bool) -> Result<() let history: Value = client .get(&format!( "/v1/slo/history?id={}&since_ms={since}&until_ms={until}&steps={columns}", - row["id"].as_str().unwrap_or_default().replace('/', "%2F"), + urlencoding::encode(row["id"].as_str().unwrap_or_default()), )) .await?; if json_output { diff --git a/docs/st3/objectives.md b/docs/st3/objectives.md index db2da559a46..7ee8e642644 100644 --- a/docs/st3/objectives.md +++ b/docs/st3/objectives.md @@ -87,18 +87,23 @@ from claims those processes write. Each subject under `subject-prefix` is one sa |---|---| | `subject-prefix "P"` | The sample subjects: every subject that starts with `P`. Custom claims (`custom/NS/...`) need no registration and replicate to every member. | | `start KIND at=FIELD` | The sample starts at the earliest such claim. | -| `expect KIND set=FIELD` | The members a sample must stop on, a list of strings in the newest such claim. An empty list stops the sample at the expect claim's time. Without `expect`, one stop claim ends the sample. | +| `expect KIND set=FIELD` | The members a sample must stop on, a list of strings in the newest such claim (by its time, then its claim id, so every member picks the same one). An empty list stops the sample at the expect claim's time. Without `expect`, one stop claim ends the sample. | | `stop KIND member=FIELD` | One member has stopped: the earliest such claim per value of `FIELD`. The sample stops when every expected member has, at the latest of their times. | -| `cancel KIND` | An open sample with such a claim is cancelled. A sample that stopped first keeps its value. | -| `pause KIND resume=KIND` | The clock stops from each pause to the next resume, and a sample still paused is not aging. | -| `label "FIELD"` | Also one row per value of `FIELD` on the start or expect claim: `ops/change-rollout/p95{class=routine}`. `label "member"` splits by stop member, each sample's value being that member's stop. | +| `cancel KIND` | An open sample with such a claim is cancelled. A sample that stopped before the cancel was accepted keeps its value. | +| `pause KIND resume=KIND` | The clock stops from each pause to the next resume, and a sample still paused is not aging. Pauses and resumes are timed when they were accepted. | +| `label "FIELD"` | Also one row per value of `FIELD` on the start or expect claim: `ops/change-rollout/p95{class=routine}`. A sample without the field is only in the unlabelled row. `label "member"` splits by member, each sample's value being that member's stop: the expected members, or without `expect` the members that stopped. | | `exclude "FIELD" "A" "B"` | A sample whose start or expect claim has one of these values is excluded. | -`at=FIELD` reads a time from the claim's fields: an RFC 3339 string or Unix milliseconds. Without -`at`, the time is when the claim's origin accepted it, which every member sees the same. +`at=FIELD` reads a time from the claim's fields: an RFC 3339 string or Unix milliseconds, either +also as a JSON-encoded string. Without `at`, the time is when the claim's origin accepted it, which +every member sees the same: a replicated claim keeps its origin's acceptance time. A list field may +also be a JSON-encoded string. A claim whose time, list or member cannot be read is counted as +unreadable and changes no sample. A sample is **closed** when it stopped, **open** while it has started and not stopped or been -cancelled, **cancelled**, or **excluded**. A closed sample belongs to the window by its stop time. +cancelled, **cancelled**, or **excluded**. A closed sample belongs to the window by its stop time, a +cancelled one by its cancel and an excluded one by its newest claim; an open sample is always +current. A subject with claims but no start yet is counted apart and in no class. **Open samples are censored, not dropped.** For each target, an open sample older than the target's `within` enters that target's percentile at its current age: it is certain to be over, so a stuck @@ -110,7 +115,11 @@ ops/change-rollout/p95 missed p95 ≤ 5 min p95 41 min over 23 samples (1 ope ``` The value is the nearest-rank percentile. The budget is the share over `within` against the share -the percentile allows: p95 allows 5 in 100. +the percentile allows: p95 allows 5 in 100. With fewer than `min-samples` in the percentile the row +is `no-data` and spends no budget; with none it reads `no samples`. + +Each evaluation reads only the claims newer than the last it read, keeping each objective's samples +in memory; a sample closed, cancelled or excluded before the window is dropped from memory. ### Ratio over Prometheus metrics @@ -155,6 +164,8 @@ objective only after every member runs a build that knows it. ID` adds, for a declared objective, its subject and revision, the sample counts by class, each open sample over target with its age, and its response. `fractal slo chart ID` draws the budget left a minute at a time from the local seven-day history, which records declared rows like any other. +A declaration this build cannot read, as one replicated from a newer build, is the one row `NAME`, +`no-data`, whose explanation names its revision and why. ## Listing claims by kind or subject prefix diff --git a/release-notes/declared-objectives.json b/release-notes/declared-objectives.json new file mode 100644 index 00000000000..5143d92a25f --- /dev/null +++ b/release-notes/declared-objectives.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "summary": "A fleet declares objectives with fractal apply: an `objective` named NAMESPACE/NAME whose interval indicator measures work from custom claims, with labels, a window, min-samples, open samples counted at their age once over target, and an optional on-breach fault for an owner agent; fractal slo ls, explain and chart read its rows beside the shipped ones", + "replay": { + "status": "none", + "detail": "No new claim kind. A declaration is the subject's intent.desired claim, projected into the desired table like an account; the rows are evaluated in memory from claims." + }, + "schema": { + "status": "changed", + "detail": "The schema registry adds the `objective` subject family (objective/NAME) and lists `objective` among intent.desired's source declarations, so the registry digest changes. No database schema change." + }, + "rules": { + "status": "none", + "detail": "No checkpoint rules change." + }, + "compatibility": { + "status": "changed", + "detail": "An older daemon does not know the `objective` subject family and refuses a replicated objective/ intent.desired claim. Publish an objective only after every member runs this build. fractal slo chart now percent-encodes the whole row id, so ids with `{label=value}` reach /v1/slo/history intact." + }, + "downtime": { + "status": "none", + "detail": "No additional service interruption." + }, + "manual": { + "status": "none", + "detail": "Upgrade every member, then apply objective declarations." + }, + "recovery": { + "status": "none", + "detail": "No recovery change; retiring an objective's subject removes it and its rows." + } +} From aaa4869588172ed533d7814a593f0c8aa07555b0 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 9 Nov 2026 20:33:50 +0100 Subject: [PATCH 063/132] Objectives: read interval claims through Store::claims_by_kinds_after from the claims-list change The evaluator's private copy of the reader is gone now that this stack sits on the change that adds it. Also two clippy fixes in the evaluator. --- .../src/declared_objectives.rs | 4 +- .../src/store/declared_objectives.rs | 59 +------------------ docs/st3/large-value-sites.json | 1 - 3 files changed, 4 insertions(+), 60 deletions(-) diff --git a/crates/fractal-api-core/src/declared_objectives.rs b/crates/fractal-api-core/src/declared_objectives.rs index 29c8a699af1..357c71f43ab 100644 --- a/crates/fractal-api-core/src/declared_objectives.rs +++ b/crates/fractal-api-core/src/declared_objectives.rs @@ -474,7 +474,7 @@ fn string_list(value: &Value) -> Option> { /// A span as its largest whole unit: `7d`, `90m`. fn span(ms: u64) -> String { for (unit, size) in [("d", 86_400_000), ("h", 3_600_000), ("m", 60_000), ("s", 1_000)] { - if ms >= size && ms % size == 0 { + if ms >= size && ms.is_multiple_of(size) { return format!("{}{unit}", ms / size); } } @@ -542,7 +542,7 @@ impl Context<'_> { _ => {} } } - open_over.sort_by(|a, b| b.1.cmp(&a.1)); + open_over.sort_by_key(|point| std::cmp::Reverse(point.1)); let window = span(self.window.ms); let goal_within = duration(within); let target = format!("p{p} ≤ {goal_within}"); diff --git a/crates/fractal-store/src/store/declared_objectives.rs b/crates/fractal-store/src/store/declared_objectives.rs index 873ef101c73..ec8cd094a6f 100644 --- a/crates/fractal-store/src/store/declared_objectives.rs +++ b/crates/fractal-store/src/store/declared_objectives.rs @@ -1,5 +1,5 @@ -//! Declared objectives (docs/st3/objectives.md): each `objective/NAME` declaration, and the claims -//! an interval objective reads. +//! Declared objectives (docs/st3/objectives.md): each `objective/NAME` declaration, and the member +//! an objective's owner is declared on. use super::*; use crate::objective::Objective; @@ -39,19 +39,6 @@ pub fn declared_objectives_at(connection: &Connection) -> Result String { - let placeholders = (0..kinds).map(|n| format!("?{}", n + 5)).collect::>().join(","); - let subject_filter = if prefix { " AND subject>=?3 AND subject?1{subject_filter} - ORDER BY store_index LIMIT ?2" - ) -} - impl Store { pub fn declared_objectives(&self) -> Result> { declared_objectives_at(&self.readers.get()) @@ -67,46 +54,4 @@ impl Store { .optional()? .flatten()) } - - /// The next `limit` claims of any of `kinds` after `after_index`, ascending, optionally under - /// `subject_prefix`. Each kind seeks `claims_kind_index` past the cursor, so a caller that keeps - /// the cursor reads only claims newer than the last it saw. - pub fn claims_by_kinds_after( - &self, - kinds: &[&str], - subject_prefix: Option<&str>, - after_index: u64, - limit: usize, - ) -> Result { - if kinds.is_empty() { - return Ok(ClaimsPage { claims: Vec::new(), next_cursor: None }); - } - let range = subject_prefix - .map(|prefix| { - prefix_upper_bound(prefix).map(|upper| (prefix, upper)).ok_or_else(|| { - anyhow::Error::from(FractalError::new( - "invalid-claims-query", - "a subject prefix must be non-empty ASCII without `*`, `?` or `[`", - )) - }) - }) - .transpose()?; - let query = kinds_after_query(kinds.len(), range.is_some()); - let scan_limit = i64::try_from(limit.saturating_add(1)).unwrap_or(i64::MAX); - let (lower, upper) = range.map_or((None, None), |(lower, upper)| (Some(lower), Some(upper))); - let mut values: Vec<&dyn rusqlite::ToSql> = vec![&after_index, &scan_limit, &lower, &upper]; - values.extend(kinds.iter().map(|kind| kind as &dyn rusqlite::ToSql)); - let connection = self.readers.get(); - let mut statement = connection.prepare_cached(/* sqlite-large-value: allow-for-now lv-h5073cff5e5e7 owner=fractal_store::store::declared_objectives TODO=trace-statement-and-value-representation */ &query)?; - let mut claims = statement - .query_map(rusqlite::params_from_iter(values), claim_from_row)? - .collect::>>()?; - let next_cursor = if claims.len() > limit { - claims.pop(); - claims.last().map(|claim| claim.store_index) - } else { - None - }; - Ok(ClaimsPage { claims, next_cursor }) - } } diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index f465b83f8f6..97d476f9dd6 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -2142,7 +2142,6 @@ {"id": "lv-h5037e9e530a6", "path": "crates/fractal-delivery/src/store/mailbox_lease.rs", "kind": "unresolved-sql-sink", "fingerprint": "8edb630b01a1f78d4abc5ee08b714ce7a8578b35d2269aec45684bf19ac1e4fc", "owner": "fractal_delivery::store::mailbox_lease", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 43, "excerpt": "& query"}, {"id": "lv-h503f5092bdb7", "path": "crates/fractal-seats/src/store/reconcile_work_context/tests.rs", "kind": "sql-template", "fingerprint": "dd5d07fbeeccb12f04d2a12e42a544ae48b4d7cad4b7ebbcaa3df9913d96bb6e", "owner": "fractal_seats::store::reconcile_work_context::tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 302, "excerpt": "UPDATE claims SET body=json_set(body,'$.fields.origin_step','step-run/another-origin') WHERE id=?1"}, {"id": "lv-h5047e43e1ca2", "path": "crates/smallclaims/src/store/prepared_envelope_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "a72b18f27662dd2ffbf1f57e072616cc1cd2c02504978dba276a7b0e6050da37", "owner": "smallclaims::store::prepared_envelope_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 298, "excerpt": "[ & record_ref ] , | row | row . get : : < _ , String > ( 0 )"}, - {"id": "lv-h5073cff5e5e7", "path": "crates/fractal-store/src/store/declared_objectives.rs", "kind": "unresolved-sql-sink", "fingerprint": "8edb630b01a1f78d4abc5ee08b714ce7a8578b35d2269aec45684bf19ac1e4fc", "owner": "fractal_store::store::declared_objectives", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 100, "excerpt": "& query"}, {"id": "lv-h5083d144d1c8", "path": "crates/fractal-store/src/store/resources.rs", "kind": "sql-template", "fingerprint": "4cf3d8d56cf6584bad9ad8dd8c53e27677aadf2245ea6f1dadade03b8dce3ba8", "owner": "fractal_store::store::resources", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 106, "excerpt": "INSERT OR IGNORE INTO local_resource_projection_pending SELECT DISTINCT subject FROM claims WHERE kind='vcs.item.state'"}, {"id": "lv-h5096ce555101", "path": "crates/fractal-store/src/store/identities.rs", "kind": "sql-template", "fingerprint": "8fb69ab2362828438f5fdbbb974d979a6844ad5ae243fa0ac035cc889a9c542f", "owner": "fractal_store::store::identities", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 587, "excerpt": "INSERT INTO identity_heads(identity,kind,canonical_id,naming_revision,unavailable) VALUES (?1,?2,?3,?4,?5) ON CONFLICT(identity) DO UPDATE SET kind=excluded.kind,canonical_id=excluded.canonical_id,nam"}, {"id": "lv-h50985c5da7f6", "path": "crates/smallclaims/src/store/repair_operation_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "smallclaims::store::repair_operation_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 131, "excerpt": "DELETE FROM claims WHERE id=?1"}, From c4bbaf1f9d862111f378b82c5359902dd64918f4 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 9 Nov 2026 21:58:22 +0100 Subject: [PATCH 064/132] ci: declare slo/objectives.kdl as a fractal-kernel test input objective_tests.rs includes the shipped objectives, so the kernel's per-crate test key must cover slo/objectives.kdl (scripts/rust-test-keys-test). --- .st/rust-test-inputs.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.st/rust-test-inputs.json b/.st/rust-test-inputs.json index c272ffcbf5a..c056b4555cc 100644 --- a/.st/rust-test-inputs.json +++ b/.st/rust-test-inputs.json @@ -234,7 +234,8 @@ "crates/fractal-daemon/hooks/claude-observe.sh", "crates/fractal-daemon/hooks/claude-statusline.sh", "crates/fractal-daemon/hooks/omp-channel.ts", - "crates/fractal-daemon/hooks/pi-channel.ts" + "crates/fractal-daemon/hooks/pi-channel.ts", + "slo/objectives.kdl" ] }, "fractal-migrate": { From b86390beb72a70fc9f3d85670097bf69d4c908c1 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 9 Nov 2026 22:21:26 +0100 Subject: [PATCH 065/132] release-notes: Fractal.objective and the generated shipped objectives --- release-notes/fractal-objective-authoring.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 release-notes/fractal-objective-authoring.json diff --git a/release-notes/fractal-objective-authoring.json b/release-notes/fractal-objective-authoring.json new file mode 100644 index 00000000000..6ad6dda3949 --- /dev/null +++ b/release-notes/fractal-objective-authoring.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Author objectives in TypeScript: the private `fractal` package adds `Fractal.objective`, `Fractal.indicator.*` and `Fractal.response.fault`, the tree loader reads `objective.ts`/`objective.kdl`, and `slo/objectives.kdl` is generated from `slo/objectives.kdl.genie.ts`", + "replay": {"status": "none", "detail": "No receipt, replay or historical interpretation changes; the package only renders KDL."}, + "schema": {"status": "none", "detail": "No database tables, indexes, migrations, claim kinds or schema registry entries change."}, + "rules": {"status": "none", "detail": "No checkpoint rules change."}, + "compatibility": {"status": "none", "detail": "No daemon, client, wire, CLI or KDL contract change. The package is a private workspace member, not published to npm. The generated slo/objectives.kdl parses to the same objectives as the hand-written file the objective reader ships with: the differences are comments, blank lines, property order and `1` for `1.0`."}, + "downtime": {"status": "none", "detail": "No deployment, restart or migration."}, + "manual": {"status": "none", "detail": "No manual step. Shipped objectives are edited in slo/objectives.kdl.genie.ts and rendered with genie."}, + "recovery": {"status": "none", "detail": "No persistent state; reverting the package and the genie source needs no recovery action."} +} From a7015bdef4480ccb1f9a06e69ddec932ede3fd46 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:00:08 +0100 Subject: [PATCH 066/132] fix(runtime): resolve default terminal cwd to host home --- crates/fractal-runtime/src/pty.rs | 39 +++++++++++++++++++++++++++++++ docs/seat-lifecycle.md | 8 ++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/fractal-runtime/src/pty.rs b/crates/fractal-runtime/src/pty.rs index c48ed4e55ee..9022eec4b3d 100644 --- a/crates/fractal-runtime/src/pty.rs +++ b/crates/fractal-runtime/src/pty.rs @@ -510,6 +510,16 @@ impl PtyRuntime { guard: Option<&dyn Fn() -> Result<()>>, producer_entered: &mut bool, ) -> Result<()> { + // "." is the default terminal's home sentinel, not the daemon's working directory. + // Resolve it on the launching host so PTY records carry an absolute directory. + let home = if cwd == Path::new(".") { + let home = std::env::home_dir().context("resolve default terminal home directory")?; + anyhow::ensure!(home.is_absolute(), "default terminal home directory must be absolute"); + Some(home) + } else { + None + }; + let cwd = home.as_deref().unwrap_or(cwd); // The lane is the scope's, never the harness's environment. let mut env = env.clone(); let capacity = crate::lane::Capacity::take(&mut env); @@ -2205,6 +2215,35 @@ exit 0 } } + #[test] + fn default_terminal_forwards_absolute_host_home_as_cwd() { + let root = tempfile::tempdir().unwrap(); + let binary = fake_pty(root.path(), "fake-pty-home-cwd", " publish new"); + let runtime = + PtyRuntime::new(root.path().join("registry")).with_binary(binary.to_string_lossy()); + + spawn_work(&runtime, Path::new("."), &BTreeMap::new()).unwrap(); + + let arguments = fs::read_to_string(binary.with_extension("args")).unwrap(); + let cwd = arguments.lines().skip_while(|arg| *arg != "--cwd").nth(1).unwrap(); + assert!(Path::new(cwd).is_absolute(), "{arguments}"); + assert_eq!(Path::new(cwd), std::env::home_dir().unwrap()); + } + + #[test] + fn explicit_terminal_cwd_is_forwarded_unchanged() { + let root = tempfile::tempdir().unwrap(); + let binary = fake_pty(root.path(), "fake-pty-explicit-cwd", " publish new"); + let runtime = + PtyRuntime::new(root.path().join("registry")).with_binary(binary.to_string_lossy()); + + spawn_work(&runtime, root.path(), &BTreeMap::new()).unwrap(); + + let arguments = fs::read_to_string(binary.with_extension("args")).unwrap(); + let cwd = arguments.lines().skip_while(|arg| *arg != "--cwd").nth(1).unwrap(); + assert_eq!(Path::new(cwd), root.path()); + } + #[test] fn spawn_preserves_an_explicit_terminal_type() { let root = tempfile::tempdir().unwrap(); diff --git a/docs/seat-lifecycle.md b/docs/seat-lifecycle.md index c4ffe8d5968..7d8ec09606a 100644 --- a/docs/seat-lifecycle.md +++ b/docs/seat-lifecycle.md @@ -510,9 +510,11 @@ fractal terminals end terminal/pty/person/ada/UUID ``` `terminals new` prints the new terminal ID. It runs the host's preferred shell (`$SHELL`, falling -back to `/bin/sh`) with no agent harness. Locally its directory defaults to your current directory; -with a remote `--host`, pass an absolute `--cwd` or use that daemon's directory. The shell does not -restart after exit. `end` publishes a durable stop. Only its creator may create or end its declaration. +back to `/bin/sh`) with no agent harness. An omitted `--cwd` (or `"."` through the API) +starts in the launching host user's home directory. The runtime resolves that directory to an +absolute path before passing it to PTY, including for remote clients. An explicit `--cwd` is +passed unchanged. The shell does not restart after exit. `end` publishes a durable stop. +Only its creator may create or end its declaration. The client crates expose `terminal_create` and `terminal_end` (camel case in TypeScript and Swift). ## Pending runtime starts From 6c633a6c153020a2512f9f9ffc6fff6aedd9737c Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:46:33 +0100 Subject: [PATCH 067/132] fix(terminals): scope home cwd resolution to default shells --- crates/fractal-api-core/src/creation.rs | 46 +++++++-------------- crates/fractal-runtime/src/pty.rs | 53 +++++++++++++++++++++---- 2 files changed, 60 insertions(+), 39 deletions(-) diff --git a/crates/fractal-api-core/src/creation.rs b/crates/fractal-api-core/src/creation.rs index ba33485db07..a6bb0dc470a 100644 --- a/crates/fractal-api-core/src/creation.rs +++ b/crates/fractal-api-core/src/creation.rs @@ -154,22 +154,20 @@ pub fn terminal_document( creation_key: &str, ) -> String { let identity = format!("{person}/{id}"); - // With no directory named, the shell starts in the host user's home: a service's own working - // directory is `/` (Nathan, 2026-10-05). A home that is unset or missing leaves it where it is. - let command = if cwd == "." { - "cd \"${HOME:-.}\" 2>/dev/null; exec \"${SHELL:-/bin/sh}\" -i" - } else { - "exec \"${SHELL:-/bin/sh}\" -i" - }; + // The launching runtime resolves this intent, not a potentially remote API client. + let mut tags = creation_tags(creation_key); + if cwd == "." { + tags.entries_mut().push(KdlEntry::new_prop("st3.cwd", "home")); + } let mut terminal = kdl_node("pty", [identity.as_str()]); let mut body = KdlDocument::new(); body.nodes_mut().extend([ kdl_node("name", [args.name.as_str()]), kdl_node("host", [host]), kdl_node("workspace", [cwd]), - kdl_node("command", [command]), + kdl_node("command", ["exec \"${SHELL:-/bin/sh}\" -i"]), kdl_node("restart", ["never"]), - creation_tags(creation_key), + tags, ]); terminal.set_children(body); publication_document(terminal) @@ -275,33 +273,17 @@ mod tests { ..Default::default() }; let plain = terminal_document("person/example", "one", &args, "host", ".", "key"); - // The command is quoted for KDL, so its own quotes are escaped. - assert!( - plain.contains("cd \\\"${HOME:-.}\\\" 2>/dev/null; exec"), - "{plain}" - ); + assert!(plain.contains("st3.cwd=\"home\""), "{plain}"); + assert!(!plain.contains("cd "), "{plain}"); assert!(plain.contains("${SHELL:-/bin/sh}"), "{plain}"); + let intent = crate::graph::parse_intent(&plain, "host").unwrap(); + let member = intent.subjects["pty/person/example/one"].member.as_ref().unwrap(); + assert_eq!(member.cwd, "."); + assert_eq!(member.tags["st3.cwd"], "home"); let placed = terminal_document("person/example", "two", &args, "host", "/srv/work", "key"); assert!(!placed.contains("HOME"), "{placed}"); + assert!(!placed.contains("st3.cwd"), "{placed}"); assert!(placed.contains("workspace \"/srv/work\""), "{placed}"); - // The command really lands in HOME, and falls back when HOME is unset or missing. - for (home, expected) in [(Some("/tmp"), "/tmp"), (Some("/no/such/dir"), "/")] { - let mut shell = std::process::Command::new("sh"); - shell.current_dir("/"); - shell.args(["-c", "cd \"${HOME:-.}\" 2>/dev/null; pwd"]); - match home { - Some(home) => shell.env("HOME", home), - None => shell.env_remove("HOME"), - }; - let out = shell.output().unwrap(); - let expected = std::fs::canonicalize(expected).unwrap(); - let got = String::from_utf8(out.stdout).unwrap(); - assert_eq!( - std::fs::canonicalize(got.trim()).unwrap(), - expected, - "HOME={home:?}" - ); - } } #[test] fn created_agents_use_checkout_for_new_and_existing_branches_and_keep_plain_workspaces() { diff --git a/crates/fractal-runtime/src/pty.rs b/crates/fractal-runtime/src/pty.rs index 9022eec4b3d..34f46973e72 100644 --- a/crates/fractal-runtime/src/pty.rs +++ b/crates/fractal-runtime/src/pty.rs @@ -510,11 +510,14 @@ impl PtyRuntime { guard: Option<&dyn Fn() -> Result<()>>, producer_entered: &mut bool, ) -> Result<()> { - // "." is the default terminal's home sentinel, not the daemon's working directory. - // Resolve it on the launching host so PTY records carry an absolute directory. - let home = if cwd == Path::new(".") { + // Only API-created default terminals opt into home resolution. A seat or mission + // workspace of "." retains its ordinary working-directory semantics. + let home = if cwd == Path::new(".") && tags.get("st3.cwd").is_some_and(|value| value == "home") { let home = std::env::home_dir().context("resolve default terminal home directory")?; - anyhow::ensure!(home.is_absolute(), "default terminal home directory must be absolute"); + anyhow::ensure!( + home.is_absolute(), + "default terminal home directory must be absolute" + ); Some(home) } else { None @@ -2222,10 +2225,24 @@ exit 0 let runtime = PtyRuntime::new(root.path().join("registry")).with_binary(binary.to_string_lossy()); - spawn_work(&runtime, Path::new("."), &BTreeMap::new()).unwrap(); + runtime + .spawn( + "work", + &Launch::Argv(vec!["true".into()]), + Path::new("."), + &BTreeMap::new(), + None, + &BTreeMap::from([("st3.cwd".into(), "home".into())]), + None, + ) + .unwrap(); let arguments = fs::read_to_string(binary.with_extension("args")).unwrap(); - let cwd = arguments.lines().skip_while(|arg| *arg != "--cwd").nth(1).unwrap(); + let cwd = arguments + .lines() + .skip_while(|arg| *arg != "--cwd") + .nth(1) + .unwrap(); assert!(Path::new(cwd).is_absolute(), "{arguments}"); assert_eq!(Path::new(cwd), std::env::home_dir().unwrap()); } @@ -2240,10 +2257,32 @@ exit 0 spawn_work(&runtime, root.path(), &BTreeMap::new()).unwrap(); let arguments = fs::read_to_string(binary.with_extension("args")).unwrap(); - let cwd = arguments.lines().skip_while(|arg| *arg != "--cwd").nth(1).unwrap(); + let cwd = arguments + .lines() + .skip_while(|arg| *arg != "--cwd") + .nth(1) + .unwrap(); assert_eq!(Path::new(cwd), root.path()); } + #[test] + fn untagged_dot_workspace_is_forwarded_unchanged() { + let root = tempfile::tempdir().unwrap(); + let binary = fake_pty(root.path(), "fake-pty-dot-cwd", " publish new"); + let runtime = + PtyRuntime::new(root.path().join("registry")).with_binary(binary.to_string_lossy()); + + spawn_work(&runtime, Path::new("."), &BTreeMap::new()).unwrap(); + + let arguments = fs::read_to_string(binary.with_extension("args")).unwrap(); + let cwd = arguments + .lines() + .skip_while(|arg| *arg != "--cwd") + .nth(1) + .unwrap(); + assert_eq!(cwd, "."); + } + #[test] fn spawn_preserves_an_explicit_terminal_type() { let root = tempfile::tempdir().unwrap(); From 4fc6558536ce7760a8c4f20d5b5b8fc0f981b7f8 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 05:05:03 +0100 Subject: [PATCH 068/132] test(terminals): assert home intent through parsed declarations --- crates/fractal-api-core/src/creation.rs | 15 +++++++++++---- crates/fractal-runtime/src/pty.rs | 4 +++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/fractal-api-core/src/creation.rs b/crates/fractal-api-core/src/creation.rs index a6bb0dc470a..0bd85cfbe67 100644 --- a/crates/fractal-api-core/src/creation.rs +++ b/crates/fractal-api-core/src/creation.rs @@ -273,17 +273,24 @@ mod tests { ..Default::default() }; let plain = terminal_document("person/example", "one", &args, "host", ".", "key"); - assert!(plain.contains("st3.cwd=\"home\""), "{plain}"); assert!(!plain.contains("cd "), "{plain}"); assert!(plain.contains("${SHELL:-/bin/sh}"), "{plain}"); let intent = crate::graph::parse_intent(&plain, "host").unwrap(); - let member = intent.subjects["pty/person/example/one"].member.as_ref().unwrap(); + let member = intent.subjects["pty/person/example/one"] + .member + .as_ref() + .unwrap(); assert_eq!(member.cwd, "."); assert_eq!(member.tags["st3.cwd"], "home"); let placed = terminal_document("person/example", "two", &args, "host", "/srv/work", "key"); assert!(!placed.contains("HOME"), "{placed}"); - assert!(!placed.contains("st3.cwd"), "{placed}"); - assert!(placed.contains("workspace \"/srv/work\""), "{placed}"); + let intent = crate::graph::parse_intent(&placed, "host").unwrap(); + let member = intent.subjects["pty/person/example/two"] + .member + .as_ref() + .unwrap(); + assert_eq!(member.cwd, "/srv/work"); + assert!(!member.tags.contains_key("st3.cwd")); } #[test] fn created_agents_use_checkout_for_new_and_existing_branches_and_keep_plain_workspaces() { diff --git a/crates/fractal-runtime/src/pty.rs b/crates/fractal-runtime/src/pty.rs index 34f46973e72..92aa1147b1f 100644 --- a/crates/fractal-runtime/src/pty.rs +++ b/crates/fractal-runtime/src/pty.rs @@ -512,7 +512,9 @@ impl PtyRuntime { ) -> Result<()> { // Only API-created default terminals opt into home resolution. A seat or mission // workspace of "." retains its ordinary working-directory semantics. - let home = if cwd == Path::new(".") && tags.get("st3.cwd").is_some_and(|value| value == "home") { + let home = if cwd == Path::new(".") + && tags.get("st3.cwd").is_some_and(|value| value == "home") + { let home = std::env::home_dir().context("resolve default terminal home directory")?; anyhow::ensure!( home.is_absolute(), From 32424b5c5c111e8957466712b52b8ee413168e7c Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 05:12:10 +0100 Subject: [PATCH 069/132] test(terminals): inspect creation document tags directly --- crates/fractal-api-core/src/creation.rs | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/fractal-api-core/src/creation.rs b/crates/fractal-api-core/src/creation.rs index 0bd85cfbe67..d63d124a812 100644 --- a/crates/fractal-api-core/src/creation.rs +++ b/crates/fractal-api-core/src/creation.rs @@ -275,22 +275,22 @@ mod tests { let plain = terminal_document("person/example", "one", &args, "host", ".", "key"); assert!(!plain.contains("cd "), "{plain}"); assert!(plain.contains("${SHELL:-/bin/sh}"), "{plain}"); - let intent = crate::graph::parse_intent(&plain, "host").unwrap(); - let member = intent.subjects["pty/person/example/one"] - .member - .as_ref() - .unwrap(); - assert_eq!(member.cwd, "."); - assert_eq!(member.tags["st3.cwd"], "home"); + let document = plain.parse::().unwrap(); + let body = document.nodes()[1].children().unwrap(); + let tags = body.get("tags").unwrap(); + assert_eq!( + tags.get("st3.cwd").unwrap().value().as_string(), + Some("home") + ); let placed = terminal_document("person/example", "two", &args, "host", "/srv/work", "key"); assert!(!placed.contains("HOME"), "{placed}"); - let intent = crate::graph::parse_intent(&placed, "host").unwrap(); - let member = intent.subjects["pty/person/example/two"] - .member - .as_ref() - .unwrap(); - assert_eq!(member.cwd, "/srv/work"); - assert!(!member.tags.contains_key("st3.cwd")); + let document = placed.parse::().unwrap(); + let body = document.nodes()[1].children().unwrap(); + assert!(body.get("tags").unwrap().get("st3.cwd").is_none()); + assert_eq!( + body.get("workspace").unwrap().get(0).unwrap().value().as_string(), + Some("/srv/work") + ); } #[test] fn created_agents_use_checkout_for_new_and_existing_branches_and_keep_plain_workspaces() { From 2c862d2d4e262c96664592b1d6548f6da5192cc7 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 05:25:13 +0100 Subject: [PATCH 070/132] test(terminals): use KDL value accessors --- crates/fractal-api-core/src/creation.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/fractal-api-core/src/creation.rs b/crates/fractal-api-core/src/creation.rs index d63d124a812..780d88579c1 100644 --- a/crates/fractal-api-core/src/creation.rs +++ b/crates/fractal-api-core/src/creation.rs @@ -278,17 +278,14 @@ mod tests { let document = plain.parse::().unwrap(); let body = document.nodes()[1].children().unwrap(); let tags = body.get("tags").unwrap(); - assert_eq!( - tags.get("st3.cwd").unwrap().value().as_string(), - Some("home") - ); + assert_eq!(tags.get("st3.cwd").unwrap().as_string(), Some("home")); let placed = terminal_document("person/example", "two", &args, "host", "/srv/work", "key"); assert!(!placed.contains("HOME"), "{placed}"); let document = placed.parse::().unwrap(); let body = document.nodes()[1].children().unwrap(); assert!(body.get("tags").unwrap().get("st3.cwd").is_none()); assert_eq!( - body.get("workspace").unwrap().get(0).unwrap().value().as_string(), + body.get("workspace").unwrap().get(0).unwrap().as_string(), Some("/srv/work") ); } From 9fdecd0bea0a2fd627617a6d95d4310722e6954a Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 05:55:02 +0100 Subject: [PATCH 071/132] fix(terminals): retain shell home cd for mixed-runtime rollout --- crates/fractal-api-core/src/creation.rs | 31 +++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/fractal-api-core/src/creation.rs b/crates/fractal-api-core/src/creation.rs index 780d88579c1..c26ccb5cd8c 100644 --- a/crates/fractal-api-core/src/creation.rs +++ b/crates/fractal-api-core/src/creation.rs @@ -159,13 +159,19 @@ pub fn terminal_document( if cwd == "." { tags.entries_mut().push(KdlEntry::new_prop("st3.cwd", "home")); } + // Older launching hosts ignore the home intent, so keep the shell-side cd during rollout. + let command = if cwd == "." { + "cd \"${HOME:-.}\" 2>/dev/null; exec \"${SHELL:-/bin/sh}\" -i" + } else { + "exec \"${SHELL:-/bin/sh}\" -i" + }; let mut terminal = kdl_node("pty", [identity.as_str()]); let mut body = KdlDocument::new(); body.nodes_mut().extend([ kdl_node("name", [args.name.as_str()]), kdl_node("host", [host]), kdl_node("workspace", [cwd]), - kdl_node("command", ["exec \"${SHELL:-/bin/sh}\" -i"]), + kdl_node("command", [command]), kdl_node("restart", ["never"]), tags, ]); @@ -273,7 +279,10 @@ mod tests { ..Default::default() }; let plain = terminal_document("person/example", "one", &args, "host", ".", "key"); - assert!(!plain.contains("cd "), "{plain}"); + assert!( + plain.contains("cd \\\"${HOME:-.}\\\" 2>/dev/null; exec"), + "{plain}" + ); assert!(plain.contains("${SHELL:-/bin/sh}"), "{plain}"); let document = plain.parse::().unwrap(); let body = document.nodes()[1].children().unwrap(); @@ -288,6 +297,24 @@ mod tests { body.get("workspace").unwrap().get(0).unwrap().as_string(), Some("/srv/work") ); + // The command also lands in HOME when an older runtime launches it. + for (home, expected) in [(Some("/tmp"), "/tmp"), (Some("/no/such/dir"), "/")] { + let mut shell = std::process::Command::new("sh"); + shell.current_dir("/"); + shell.args(["-c", "cd \"${HOME:-.}\" 2>/dev/null; pwd"]); + match home { + Some(home) => shell.env("HOME", home), + None => shell.env_remove("HOME"), + }; + let out = shell.output().unwrap(); + let expected = std::fs::canonicalize(expected).unwrap(); + let got = String::from_utf8(out.stdout).unwrap(); + assert_eq!( + std::fs::canonicalize(got.trim()).unwrap(), + expected, + "HOME={home:?}" + ); + } } #[test] fn created_agents_use_checkout_for_new_and_existing_branches_and_keep_plain_workspaces() { From dfe85aa88ba9e9c7ff25888eac3fe28741423893 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 07:42:32 +0100 Subject: [PATCH 072/132] slo: whole-number objective literals as genie renders them --- crates/fractal-telemetry/src/slo.rs | 10 +++++----- slo/objectives.kdl | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/fractal-telemetry/src/slo.rs b/crates/fractal-telemetry/src/slo.rs index 284a14a2a7d..40d6f9887f8 100644 --- a/crates/fractal-telemetry/src/slo.rs +++ b/crates/fractal-telemetry/src/slo.rs @@ -1106,17 +1106,17 @@ mod tests { assert!(error(&without(name)).contains("there is no"), "{name}"); } for (from, to) in [ - ("target \"cores\" max=1.0", "target \"cores\" max=-1.0"), - ("target \"cores\" max=1.0", "target \"cores\" max=0.4"), + ("target \"cores\" max=1", "target \"cores\" max=-1.0"), + ("target \"cores\" max=1", "target \"cores\" max=0.4"), ("target \"idle-cores\" max=0.5", "target \"idle-cores\" max=0.0"), ("target \"idle-cores\" max=0.5", "target \"idle-cores\" max=#nan"), - ("load-max-cores 2.0", "load-max-cores #inf"), + ("load-max-cores 2", "load-max-cores #inf"), ("idle-min-minutes 10", "idle-min-minutes 0"), ("idle-min-minutes 10", "idle-min-minutes 61"), ("idle-min-minutes 10", "idle-min-minutes 10.5"), - (" load-max-cores 2.0\n", ""), + (" load-max-cores 2\n", ""), (" target \"idle-cores\" max=0.5\n", ""), - ("target \"live-gb\" max=8.0", "target \"live-gb\" max=0.0"), + ("target \"live-gb\" max=8", "target \"live-gb\" max=0.0"), (" indicator \"statement\"\n", " indicator \"statement\"\n typo 1\n"), ("target \"live-share\" min-percent=99", "target \"live-share\" min-percent=99.5"), ("target \"open\" max=0", "target \"open\" max=0.5"), diff --git a/slo/objectives.kdl b/slo/objectives.kdl index ae5671ac411..5c983834a9d 100644 --- a/slo/objectives.kdl +++ b/slo/objectives.kdl @@ -125,9 +125,9 @@ objective "cpu" { indicator "cpu" { idle-min-minutes 10 // daemon_load's bound in CI under its synthetic load, which is not the production target. - load-max-cores 2.0 + load-max-cores 2 } - target "cores" max=1.0 + target "cores" max=1 target "idle-cores" max=0.5 } @@ -198,7 +198,7 @@ objective "projection-divergence" { objective "database" { about "This member's store: the pages in use, and how much they grow a day once checkpoints and retention have run" indicator "database" - target "live-gb" max=8.0 + target "live-gb" max=8 target "growth-mb-per-day" max=300 } From 999efd629b1b8be0835d01479d33f1aba820df4c Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:47:15 +0100 Subject: [PATCH 073/132] Regenerate canonical SQLite large-value inventory agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- docs/st3/large-value-sites.json | 40 ++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index fa55fde0ab4..b3289fc0266 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -1175,6 +1175,7 @@ {"id": "lv-h1650bf6a58bc", "path": "crates/fractal-missions/src/store/resource_preparation/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "713a1c59ffff86cb801fa5892aec98a04d6796cb005c7ef94339e94c3da332f6", "owner": "fractal_missions::store::resource_preparation::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 117, "excerpt": "& other , | _ | ( )"}, {"id": "lv-h16586c1e56da", "path": "crates/smallclaims/src/store/principals.rs", "kind": "sql-template", "fingerprint": "bf5873267aa948343437eaa2ca0053f5e123c1b8d089c7c933a34e432baa6d90", "owner": "smallclaims::store::principals", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 279, "excerpt": "INSERT OR IGNORE INTO claim_signature_candidates(claim_id,signature,key,nonce) VALUES (?1,?2,?3,?4)"}, {"id": "lv-h166835b7b8cb", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "unresolved-sql-sink", "fingerprint": "fca5d96f6c68c4cc7e0a68c2245ed850d4a3f62fc48485354ad7706c45b31ac4", "owner": "smallclaims::store::saved_inventory", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 73, "excerpt": "[ rowid ] , | row | { Ok ( ReplicaEnvelopeId { writer : row . get ( 0 ) ? , sequence : row . get ( 1 ) ? , hash : row . get ( 2 ) ? , } ) }"}, + {"id": "lv-h16928b5eb896", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "c3a0532ab17a84e1ad0287749b26ea41efe293bb61b1c62a42a4093e94414017", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 320, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3 WHERE subject=?1"}, {"id": "lv-h1693db03cced", "path": "crates/smallclaims/src/store/prepared_envelope.rs", "kind": "sql-template", "fingerprint": "7512424f1b97db6e6318c1bce828a00664863a9c0fca3740e0629c5e37936fe6", "owner": "smallclaims::store::prepared_envelope", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 455, "excerpt": "INSERT INTO replica_records( record_ref, writer, sequence, envelope_hash, position, raw, state, claim_id, subject_hint, kind_hint, error_code, error_message, updated_at_unix_ms ) VALUES (?1, ?2, ?3, ?"}, {"id": "lv-h16a68f6ab5e8", "path": "crates/fractal-kernel/src/conversation_search.rs", "kind": "unresolved-sql-sink", "fingerprint": "b9543199298420fe9e37bb76665ae9eb0e08741f55cce132d2c0d5adc31f65eb", "owner": "fractal_kernel::conversation_search", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 45, "excerpt": "\"CREATE VIRTUAL TABLE entries USING fts5(source UNINDEXED, conversation_id UNINDEXED, entry_id UNINDEXED, agent_id UNINDEXED, timestamp UNINDEXED, entry_type UNINDEXED, text, tokenize='unicode61');\" ,"}, {"id": "lv-h16ae820a3029", "path": "crates/fractal-drivers/src/harness_admission/omp.rs", "kind": "included-source", "fingerprint": "8f84f980ca707f77841efd4f297097ee44736098a6abd7e1786847d15e61b38b", "owner": "fractal_drivers::harness_admission::omp", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 47, "excerpt": "include_str ! ( \"omp-probe.ts\" )"}, @@ -1265,6 +1266,7 @@ {"id": "lv-h1c1e8afac510", "path": "crates/fractal-api-client-v0/src/api/client_v0/runtime_start_diagnostics/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "4ec7c53222c8a758c722e2111541035ce700d5ae7bd0898c5f1b1a743e6450fd", "owner": "fractal_api_client_v0::api::client_v0::runtime_start_diagnostics::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 97, "excerpt": "sql"}, {"id": "lv-h1c315e6d5b5b", "path": "crates/fractal-store/src/store/shared.rs", "kind": "sql-template", "fingerprint": "fcb74cd0f861438e431998d8d16c6c6d5ad3c3cc2a58f09d3403116cc7df6065", "owner": "fractal_store::store::shared", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 234, "excerpt": "INSERT OR IGNORE INTO claims(id, batch_id, subject, kind, origin, actor, body, predecessors, accepted_at_unix_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"}, {"id": "lv-h1c33889b46f2", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "6e791f3e9ac7e947bd20c73bc6bf2fa922ffbde010045c414512363268d394be", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6168, "excerpt": "INSERT INTO local_work_lease_renewals (subject, attempt, lease_owner, lease_incarnation, lease_expires_at_unix_ms, updated_at_unix_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(subject) DO UPDATE SE"}, + {"id": "lv-h1c3e44d7086a", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "51cc0656fb3569edd52d628ebfaf71a0d8cc9b159da6fb3069aaaf55b3cdc1a5", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6492, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?3, lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h1c43f8efcade", "path": "crates/fractal-store/src/store/resources_preparation_tests.rs", "kind": "sql-template", "fingerprint": "529ba97714e0bbd2b2f54353606bb7ee6c45f0ab9b67021c1df54d3249100e7f", "owner": "fractal_store::store::resources_preparation_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 643, "excerpt": "UPDATE operations SET state='active',canonical_claim_id=?1 WHERE id='operation/example-preparation'"}, {"id": "lv-h1c4dc4dcb731", "path": "crates/fractal-store/src/store/resource_references.rs", "kind": "sql-template", "fingerprint": "baf25f332fef9bac6c8bd426255a4bd93809dcadaea875d9cc73602621f424db", "owner": "fractal_store::store::resource_references", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 190, "excerpt": "UPDATE meta SET value='missions:' WHERE key='{CURSOR_KEY}'"}, {"id": "lv-h1c54bb0d403b", "path": "crates/fractal-store/src/store/glass_heads.rs", "kind": "unresolved-sql-sink", "fingerprint": "a11ff17b351a8a31b9d385dd17f6f0c5b983c8147434a126bef9fd827da833f9", "owner": "fractal_store::store::glass_heads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 221, "excerpt": "& canonical_sql ( \"SELECT id FROM claims WHERE subject=?1 AND kind='glass.upserted' ORDER BY CANONICAL_ASC(claims) LIMIT 1\" , ) , [ subject ] , | row | row . get ( 0 ) ,"}, @@ -1338,6 +1340,7 @@ {"id": "lv-h20cb3d2e4d9e", "path": "crates/fractal-store/src/store/mission_capacity.rs", "kind": "unresolved-sql-sink", "fingerprint": "e318d0369b4e7bf334d1427a20489fefe9c9d84d83b7c4eb72f6a2ca2749c235", "owner": "fractal_store::store::mission_capacity", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 75, "excerpt": "& canonical_sql ( \"SELECT json_extract(claims.body,'$.fields.features.mission_capacity')=1 FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='daemon.starte"}, {"id": "lv-h20ede67dacaa", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "6d5b306c813337790dcc59d3ea0244058138d994c293b1d1b62b4d21f61d94ca", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1608, "excerpt": "params ! [ subject , store_index ] , | row | row . get : : < _ , u64 > ( 0 )"}, {"id": "lv-h20ff92cef130", "path": "crates/fractal-store/src/store/resources_preparation_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::resources_preparation_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1352, "excerpt": "DELETE FROM claims WHERE id=?1"}, + {"id": "lv-h2102123ed04c", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 818, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h21121b909ab8", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2beeceba08cc58c8d4101eebee3d7bdc5ff8260a291b89568ff5ea3680d6d8c7", "owner": "fractal_missions::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6497, "excerpt": "& format ! ( \"SELECT json_extract(claims.body, '$.fields.state'), claims.accepted_at_unix_ms FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='harness.obs"}, {"id": "lv-h212e51b40c29", "path": "crates/smallclaims/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "edfd88af61f0d89329bd18c65812dcd3cbfa77084f879f2aba400ba1e8046e83", "owner": "smallclaims::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 9607, "excerpt": "CHECKPOINT_OPERATION_KEYED_QUERY"}, {"id": "lv-h213133341fb4", "path": "crates/smallclaims/src/store/principals.rs", "kind": "unresolved-sql-sink", "fingerprint": "adf3de0c2d3da5d7033336aaa81da7847ca9767ac4bc8794fb5b9517cb6252bf", "owner": "smallclaims::store::principals", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 420, "excerpt": "params ! [ subject , kind , actor ]"}, @@ -1465,6 +1468,7 @@ {"id": "lv-h28691152dda6", "path": "crates/fractal-store/src/store/resources_preparation.rs", "kind": "unresolved-sql-sink", "fingerprint": "b7eafcbb01a79e6feb02839905b41bafc8a5be2f355d21ab6b4b392a34092eac", "owner": "fractal_store::store::resources_preparation", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 414, "excerpt": "& QUERY"}, {"id": "lv-h287bdf68b661", "path": "crates/fractal-store/src/store/run_eta_tests.rs", "kind": "sql-template", "fingerprint": "52f120d54eceb3dd75ce5a7e2c8b2459f7e6a63b55e20299a2f265be6d7b1749", "owner": "fractal_store::store::run_eta_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 159, "excerpt": "UPDATE mission_runs SET status='failed' WHERE id='r-69'"}, {"id": "lv-h287e13415dd4", "path": "crates/fractal-store/src/store/message_sent.rs", "kind": "unresolved-sql-sink", "fingerprint": "4d4947cbb244446f69537a5aaf2a9086598317c4a68325c448415c3c7663f606", "owner": "fractal_store::store::message_sent", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 49, "excerpt": "SENT_IN_GRAPH_FILE"}, + {"id": "lv-h28928374ca14", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1d84c831a4aa0ece376bf0e40064fb0bd36cd58e7f04211c859df103c1e40e8c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 823, "excerpt": "UPDATE step_runs SET status=?2 WHERE subject=?1"}, {"id": "lv-h28991f3f5745", "path": "crates/fractal-store/src/store/schedule_active_run_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "90dbe74bec90013705bd92e02619372a20cb7733a5375be33830ea72c895a370", "owner": "fractal_store::store::schedule_active_run_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 89, "excerpt": "[ subject ] , | row | row . get ( 0 )"}, {"id": "lv-h28ac822f10fc", "path": "crates/fractal-store/src/store/client_messages.rs", "kind": "unresolved-sql-sink", "fingerprint": "ed5f7c6ec77211c2891b82298edc2d1b76ae76f8ed8e33a3298e288b1bd8d322", "owner": "fractal_store::store::client_messages", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 315, "excerpt": "[ & work . subject ] , | row | Ok ( ( row . get ( 0 ) ? , row . get ( 1 ) ? ) )"}, {"id": "lv-h28bd1a5d15fb", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "d8c07a9a3bd4948049486819192015c314d0a617ea538ab66f285ebd7b75a291", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 678, "excerpt": "CREATE TEMP TABLE held AS SELECT {COLUMNS} FROM replica_envelopes ORDER BY rowid; DELETE FROM replica_envelopes; INSERT INTO replica_envelopes({COLUMNS}) SELECT {COLUMNS} FROM held;"}, @@ -1681,6 +1685,7 @@ {"id": "lv-h3476265a54c6", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "6d10ea77208b3c0ccc9a279b3c26b63991cdc16af0515e542f369a78bc153a89", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 15361, "excerpt": "& canonical_sql ( \"SELECT id FROM claims INDEXED BY claims_accepted_order_index WHERE store_index<=?1 ORDER BY CANONICAL_DESC(claims) LIMIT 1\" ) , [ frontier ] , | row | row . get : : < _ , String > ("}, {"id": "lv-h3477b3e003c0", "path": "crates/fractal-store/src/store/client_messages_version_benchmark.rs", "kind": "sql-template", "fingerprint": "2015f8bd109ba33bc2640da808dcd77e98dad5b5fccf496dc7e00b938bd7e970", "owner": "fractal_store::store::client_messages_version_benchmark", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 114, "excerpt": "INSERT INTO local_client_message_versions VALUES(?1,?2,NULL,?3,?4,?5,?6,?7,?8,?9,?10)"}, {"id": "lv-h3486ce730024", "path": "crates/smallclaims/src/store.rs", "kind": "sql-template", "fingerprint": "4f361d8da67df90d12543d3633d31ea0c1ac3fc12de1279ece08cebb3dd66278", "owner": "smallclaims::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 5969, "excerpt": "INSERT OR IGNORE INTO blobs(hash, bytes, size) VALUES (?1, ?2, ?3)"}, + {"id": "lv-h34894d4454a8", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 994, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, {"id": "lv-h34b0045efaf9", "path": "crates/smallclaims/src/ivm/claim_source.rs", "kind": "sql-template", "fingerprint": "ff61a09f4bd5378817cf14f23f195214d95d0266c752d73daa349502c77a5ff5", "owner": "smallclaims::ivm::claim_source", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 147, "excerpt": "DROP TRIGGER IF EXISTS ivm_install_claim_record_insert"}, {"id": "lv-h34b493bc8e0a", "path": "crates/fractal-store/src/store/client_messages.rs", "kind": "sql-template", "fingerprint": "96443f5f09414404e98d6b22a06bf09fd5109892d090c48f6951cea264dcca69", "owner": "fractal_store::store::client_messages", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1042, "excerpt": "DELETE FROM local_client_message_selectors_v1 WHERE subject=?1 AND born_index=-1"}, {"id": "lv-h34c24f65b594", "path": "crates/fractal-store/src/store/message_marks.rs", "kind": "unresolved-sql-sink", "fingerprint": "60da735f8761d8ca4e4890163291cd1f5b396e1492d6103570abb3fa8d4659c4", "owner": "fractal_store::store::message_marks", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 207, "excerpt": "[ message ] , | row | Ok ( ( row . get : : < _ , String > ( 0 ) ? , row . get : : < _ , i64 > ( 1 ) ? . max ( 0 ) as u128 ) )"}, @@ -2001,6 +2006,7 @@ {"id": "lv-h48ab0ffcf698", "path": "crates/smallclaims/src/store.rs", "kind": "sql-template", "fingerprint": "c1aacb3fede25950692e1bd5ff8a4d94e7103d64fd39930e45ce02f5a4a51fa5", "owner": "smallclaims::store", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 567, "excerpt": " CREATE INDEX IF NOT EXISTS claims_batch_index ON claims(batch_id, store_index); -- Heals once compared claim identities through this index; nothing reads it any more. DROP INDEX IF EXISTS claims_batc"}, {"id": "lv-h48b3ca7ad3bd", "path": "crates/smallclaims/src/ivm.rs", "kind": "sql-template", "fingerprint": "bab8b3f1fb0dc4e7285da9d46afc90898d07b56062965bda597b6326c851788e", "owner": "smallclaims::ivm", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 838, "excerpt": "INSERT INTO ivm_contributions VALUES(?1,?2,?3,?4,?5,?6) ON CONFLICT(view,key,register,claim_id) DO UPDATE SET value=excluded.value,rank=excluded.rank WHERE value<>excluded.value OR rank<>excluded.rank"}, {"id": "lv-h48c2f0251057", "path": "crates/fractal-reconcile/src/reconcile/tests/start_preemption.rs", "kind": "sql-template", "fingerprint": "e1da33fa7f7a8b72d496ce9b7fd025bfd68585109e931326bd697e3350c7354e", "owner": "fractal_reconcile::reconcile::tests::start_preemption", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 351, "excerpt": "CREATE TEMP TABLE aba_a AS SELECT * FROM desired WHERE subject=?1"}, + {"id": "lv-h48c2f2d4b497", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 930, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-h48c487bfe664", "path": "crates/fractal-store/src/store/wake_lookup.rs", "kind": "sql-template", "fingerprint": "cf108edbd6b5605053d2bd547857f497ca909049ab233f1b865ba602f73fdf85", "owner": "fractal_store::store::wake_lookup", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 153, "excerpt": "UPDATE local_work_wake_backfill SET cursor=?1,complete=?2 WHERE singleton=1"}, {"id": "lv-h48c97b07300c", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "c657f8e081eea0e5dab78e468952abad110b99276c6078eaf1bab639d117932b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 511, "excerpt": "INSERT INTO mission_runs SELECT 'page-other',mission_id,initial_revision,current_generation_id, root_revision,'another-root',parent_step_run,workspace,requester,inputs, mode,status,phase,created_at_un"}, {"id": "lv-h48de1ba2ea36", "path": "crates/smallclaims/src/ivm.rs", "kind": "sql-template", "fingerprint": "a00ace2e63a8007b9c110b64ae019551cb4ffe51e5c1df2c038b31b4bf92b9ac", "owner": "smallclaims::ivm", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 767, "excerpt": "DELETE FROM ivm_claim_views WHERE view=?1 AND input_claim_id=?2"}, @@ -2772,6 +2778,7 @@ {"id": "lv-h75174e01c7b9", "path": "crates/fractal-store/src/store/glasses.rs", "kind": "unresolved-sql-sink", "fingerprint": "3af344ff1fd37a66087ce591d8e63060f8be28942ca3145226c9eb6b7579d662", "owner": "fractal_store::store::glasses", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6, "excerpt": "& canonical_sql ( \"WITH ranked AS ( SELECT claims.id, ROW_NUMBER() OVER (PARTITION BY claims.subject, claims.kind ORDER BY CANONICAL_ASC(claims)) AS first, ROW_NUMBER() OVER (PARTITION BY claims.subje"}, {"id": "lv-h75356a2b8bf0", "path": "crates/smallclaims/src/store/retired_facts.rs", "kind": "unresolved-sql-sink", "fingerprint": "b7eafcbb01a79e6feb02839905b41bafc8a5be2f355d21ab6b4b392a34092eac", "owner": "smallclaims::store::retired_facts", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 93, "excerpt": "& QUERY"}, {"id": "lv-h7554008ab79c", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "692b891eaf4f608d7adcbc24e4f06d36a27ed830efcde23ff111aa095833860d", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12016, "excerpt": "& format ! ( \"SELECT claims.store_index FROM claims INDEXED BY claims_subject_kind_index JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND {} AND claims.store_index<=?2 ORDER BY {"}, + {"id": "lv-h755979e290e9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 523, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, {"id": "lv-h75681bdb4431", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "f11929340270a16d3eaab3c7795e06e30950976c771ce9a1cac08e0a5ec0964e", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 17244, "excerpt": "INSERT INTO mission_definitions(mission_id, revision, state, claim_id) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(mission_id) DO UPDATE SET revision=excluded.revision, state=excluded.state, claim_id=excluded"}, {"id": "lv-h756acba9d94c", "path": "crates/fractal-store/src/store/backup.rs", "kind": "unresolved-sql-sink", "fingerprint": "c03ab0bb4218ae1137c8325b80f94fdd00947c146885ba2f75e191cb999e38b8", "owner": "fractal_store::store::backup", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 626, "excerpt": "\"WITH live AS (SELECT writer,sequence,envelope_hash FROM replica_envelopes WHERE (writer,sequence,envelope_hash)>(?1,?2,?3) AND (batch_id IS NOT NULL OR ?5) ORDER BY writer,sequence,envelope_hash LIMI"}, {"id": "lv-h759f01e6116b", "path": "crates/smallclaims/src/store/schema_install_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "3897109847c3a19ec97cde30e4aea640d36ccbd989e9312169cf6f2ceed3f66d", "owner": "smallclaims::store::schema_install_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 141, "excerpt": "\"BEGIN IMMEDIATE;\""}, @@ -2781,6 +2788,7 @@ {"id": "lv-h75e67f417bdf", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "unresolved-sql-sink", "fingerprint": "f87e92b08082c1a5768f5a1d274ebbbb9d0fa9428c4d495b2f8c280477f139b4", "owner": "smallclaims::store::checkpoint_trim", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1048, "excerpt": "params ! [ cursor . envelope . 0 , cursor . envelope . 1 , cursor . envelope . 2 ] , | row | { Ok ( EnvelopeTombstone { writer : row . get ( 0 ) ? , sequence : row . get ( 1 ) ? , envelope_hash : row "}, {"id": "lv-h75fd665f1236", "path": "crates/smallclaims/src/store/repair_operation_tests.rs", "kind": "sql-template", "fingerprint": "0880674f57b838172ac1420fc767020d5e8391b6a6290c9461350fa9b9a69304", "owner": "smallclaims::store::repair_operation_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 75, "excerpt": "INSERT INTO checkpoint_claims(id,writer,sequence,envelope_hash,subject,kind, actor,predecessors,operation_id,request_digest,accepted_at_unix_ms,checkpoint) VALUES (?1,?2,1,'fixture-envelope',?3,?4,NUL"}, {"id": "lv-h76009380070e", "path": "crates/fractal-store/src/store/backup.rs", "kind": "sql-template", "fingerprint": "715b7832ed9f5968b1975060bb0aba4a0e1595690c6c1c05600a9ff424756c2c", "owner": "fractal_store::store::backup", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 338, "excerpt": "INSERT INTO replica_envelopes(writer,sequence,envelope_hash,previous_hash,accepted_at_unix_ms, payload,relay,receipt_state,received_at_unix_ms) VALUES(?1,?2,?3,?4,?5,?6,'backup','pending',?7)"}, + {"id": "lv-h76038fa53ef8", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "e21446038f569a6eec8b9b9a7e8201f454cd571dd47d6c8c72dedd6f6cbfbb2b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 9598, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker', lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h760886f5273f", "path": "crates/fractal-reconcile/src/reconcile/tests/incremental_deadlines.rs", "kind": "sql-template", "fingerprint": "0f9531316b08f058e2c0d39a03024e5449ca43438fdf44adc34e053c26465dbe", "owner": "fractal_reconcile::reconcile::tests::incremental_deadlines", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 1213, "excerpt": "DELETE FROM replica_records WHERE claim_id IN (?1,?2)"}, {"id": "lv-h7609ad2c9eca", "path": "crates/smallclaims/src/store/principals.rs", "kind": "unresolved-sql-sink", "fingerprint": "fd4c6f3ec130c52a2b235ee2129f972a35eaa20929b85326b0c3506b781d7683", "owner": "smallclaims::store::principals", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 282, "excerpt": "params ! [ claim_id , text , signature . key , signature . nonce ]"}, {"id": "lv-h7609b11cc853", "path": "crates/smallclaims/src/ivm.rs", "kind": "sql-template", "fingerprint": "df21b7d33237210ade2853def81712bdfc2fea8c94e2d32ab47f1f689b50b4eb", "owner": "smallclaims::ivm", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 622, "excerpt": "INSERT INTO ivm_views(name,fingerprint,epoch,ready,generation) VALUES(?1,?2,?3,?4,0)"}, @@ -2839,6 +2847,7 @@ {"id": "lv-h78cc0dbbbfdf", "path": "crates/fractal-seats/src/store/runtime_starts.rs", "kind": "unresolved-sql-sink", "fingerprint": "670d6c12ea983a626949e5782c1d54db6dccc8f2ea68d30d296fe871626aadaf", "owner": "fractal_seats::store::runtime_starts", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 507, "excerpt": "& format ! ( \"{} LIMIT 1\" , newest_claims_of_kind_query ( \"claims.id\" , \"runtime.observed\" ) ) , params ! [ input . subject , i64 : : MAX ] , | row | row . get ( 0 ) ,"}, {"id": "lv-h78d8ef99ef2b", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "e03ff7f0a71930d5c5d6083910068bfe154ec51f6bc01e133c429c2cfc91cdd5", "owner": "fractal_seats::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 229, "excerpt": "params ! [ later , \"mission-run/x\" , \"harness.timeline\" ]"}, {"id": "lv-h78de6ffed031", "path": "crates/fractal-seats/src/store/step_labels/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "89772bebf3f0240f364fdf2d898527feb3650484c515dd76efcabfe0ef49b52d", "owner": "fractal_seats::store::step_labels::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 47, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {QUERY}\" )"}, + {"id": "lv-h78e96a5b9d99", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 966, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-h78f223f76c65", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "11962b6d1a71b779748eeb516ee88832780f3b025fe316441be02891568d703f", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6144, "excerpt": "params ! [ subject , i64 : : MAX , incarnation ] , | row | row . get ( 1 )"}, {"id": "lv-h78f4c9ab8fb0", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "990cbb165a23077232f26d13723754e956c38eced662ebabe920afb0d68c90fd", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 18764, "excerpt": "& canonical_sql ( \"SELECT body FROM claims WHERE subject=?1 AND kind='harness.observed' AND accepted_at_unix_ms<=?2 ORDER BY CANONICAL_DESC(claims)\" , )"}, {"id": "lv-h78f68e820943", "path": "crates/fractal-store/src/store/agent_view.rs", "kind": "sql-template", "fingerprint": "077c9f331431c6934709d8a9503267b47b98c79ec7d0c3d87c455ee1369fbfd0", "owner": "fractal_store::store::agent_view", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 508, "excerpt": "INSERT INTO local_agent_harness_rows VALUES(?1,?2) ON CONFLICT(subject) DO UPDATE SET observed=excluded.observed"}, @@ -2917,6 +2926,7 @@ {"id": "lv-h7d2ddb9152a1", "path": "crates/fractal-store/src/store/checkpoint_capture_epoch_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "7f278b69cec646287b3aade63b955b9f70fb9971553ab08b7d204585f1d0ef51", "owner": "fractal_store::store::checkpoint_capture_epoch_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 184, "excerpt": "sql , [ ]"}, {"id": "lv-h7d4a5fcca7d0", "path": "crates/fractal-store/src/store/messages_shard/stream_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "cc1ca77503be6a8012163857a8fe815d4b4949bb48e38eb1e8376865dfeb1ba9", "owner": "fractal_store::store::messages_shard::stream_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 56, "excerpt": "rusqlite : : params ! [ at as i64 ] , | row | row . get : : < _ , Option < i64 > > ( 0 )"}, {"id": "lv-h7d50ca37c5a8", "path": "crates/smallclaims/src/store/repair_operation_tests.rs", "kind": "sql-template", "fingerprint": "400236f13df7316e04adb677dc16166ab3b1b2701277721ec220cc75aec69529", "owner": "smallclaims::store::repair_operation_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 516, "excerpt": "INSERT INTO projection_digest_repaired_claims(id) VALUES(?1)"}, + {"id": "lv-h7d7fcc2328ee", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d35271b78634a314d43a3538f1037107387d271a4fa449cfd3f69afad27e28ac", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 937, "excerpt": "UPDATE step_runs SET lease_owner=?2, status=?3 WHERE subject=?1"}, {"id": "lv-h7d81d766ec47", "path": "crates/fractal-store/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "670380e6c1568143746eefd5717d30685a73d61436c5663c11ede8fb9d9aec6f", "owner": "fractal_store::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6887, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {query}\" )"}, {"id": "lv-h7d84468a5931", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "dd2b5f9f07e0760d8d29d36a0234e46940679161a2bccaeec3851ef4253997eb", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6922, "excerpt": "UPDATE idempotency SET expires_at_unix_ms=0"}, {"id": "lv-h7d8afd8a5d72", "path": "crates/fractal-store/src/store/message_marks.rs", "kind": "unresolved-sql-sink", "fingerprint": "60da735f8761d8ca4e4890163291cd1f5b396e1492d6103570abb3fa8d4659c4", "owner": "fractal_store::store::message_marks", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 256, "excerpt": "[ message ] , | row | Ok ( ( row . get : : < _ , String > ( 0 ) ? , row . get : : < _ , i64 > ( 1 ) ? . max ( 0 ) as u128 ) )"}, @@ -3267,6 +3277,7 @@ {"id": "lv-h932a52c91553", "path": "crates/fractal-store/src/store/work_summaries_ivm.rs", "kind": "sql-template", "fingerprint": "9a0e6b5fe06e7302fe6dc0b8279892925a5786122c036d53bf30b5c5a7c8bb9a", "owner": "fractal_store::store::work_summaries_ivm", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 15, "excerpt": " CREATE TABLE IF NOT EXISTS local_work_summary_clock ( singleton INTEGER PRIMARY KEY CHECK(singleton=1), through_ms BLOB NOT NULL CHECK(length(through_ms)=16) ); INSERT OR IGNORE INTO local_work_summa"}, {"id": "lv-h934301fb4a5c", "path": "crates/fractal-api-core/src/api/storage_contention_response_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "b2cbbe76784ce7e1e5c37479f1d38c960c8035dd74974ae04e0aad4649d0dc87", "owner": "fractal_api_core::api::storage_contention_response_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 40, "excerpt": "\"BEGIN IMMEDIATE\""}, {"id": "lv-h9344d032159a", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "c82173d3f027536ecae5ea31b9f95e9718406c75c8aec8f449e035e78fdc763b", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6143, "excerpt": "& claude_attachment_query ( )"}, + {"id": "lv-h934585192b73", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "386e7e1231a36489f2cceb29bad211b82fb2995a96133e6a5663bffbd895e0aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 991, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms='1' WHERE subject=?1"}, {"id": "lv-h936ac0b978c0", "path": "crates/fractal-store/src/store/tests.rs", "kind": "sql-template", "fingerprint": "b2bb09729dbe5040bcad714e4c6a5da5662a151d932f87c414eb10c750176e1e", "owner": "fractal_store::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 150, "excerpt": "UPDATE local_runtime_starts SET {column}=?1 WHERE id='legacy-op'"}, {"id": "lv-h936ef40a4b1a", "path": "crates/fractal/src/main_tests.rs", "kind": "included-source", "fingerprint": "926eaf7cac906fb8fdc1408d4f645bee662c4ad8c26d308562817e08dd64451b", "owner": "fractal::main_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 894, "excerpt": "include_str ! ( \"../tests/fixtures/missions-tree.txt\" )"}, {"id": "lv-h937bff82e5ae", "path": "crates/smallclaims/src/store/checkpoint.rs", "kind": "sql-template", "fingerprint": "b625a094cc9efb55f81fb5e325205b4a29b743ae029c893ab89c80fc031928f9", "owner": "smallclaims::store::checkpoint", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 757, "excerpt": "INSERT OR IGNORE INTO temp.sealed_envelopes VALUES (?1, ?2, ?3)"}, @@ -3500,6 +3511,7 @@ {"id": "lv-ha09adbfb1aac", "path": "crates/smallclaims/src/store/projection_busy_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "b2cbbe76784ce7e1e5c37479f1d38c960c8035dd74974ae04e0aad4649d0dc87", "owner": "smallclaims::store::projection_busy_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 545, "excerpt": "\"BEGIN IMMEDIATE\""}, {"id": "lv-ha09c14267009", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "00269fc304355e61cbacee4f31dde25e977e7fe8afebb9fe1e2a7156d309ddda", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 21592, "excerpt": "params ! [ run_id , reason , updated_at_unix_ms . to_string ( ) , \"the root mission run is terminal\" ] ,"}, {"id": "lv-ha0a315cfb9e7", "path": "crates/fractal-delivery/src/store/message_send_tests.rs", "kind": "sql-template", "fingerprint": "9cbbdf7b8760c993debd0ea2ff4f8e00bba73c72c64d0f231fd7243c9c00c453", "owner": "fractal_delivery::store::message_send_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 121, "excerpt": "DROP TRIGGER reject_fixture_send"}, + {"id": "lv-ha0cd354483f3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 868, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, {"id": "lv-ha0cfaa1ec5cc", "path": "crates/smallclaims/src/store/principals.rs", "kind": "sql-template", "fingerprint": "b00efd86cc227528fbfa988943c50c7bc8f88c73ef6908072ddf7ed7cac1bc74", "owner": "smallclaims::store::principals", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1765, "excerpt": "DELETE FROM meta WHERE key=?1"}, {"id": "lv-ha0d48a2a03f2", "path": "crates/fractal-store/src/store/resources_preparation_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::resources_preparation_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1287, "excerpt": "DELETE FROM claims WHERE id=?1"}, {"id": "lv-ha0dce1e45fb7", "path": "crates/fractal-missions/src/store/resource_preparation/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "dc372daedd324b47f1261987900c1a08eff3ccdf253bfcf666d0ebecf17142ad", "owner": "fractal_missions::store::resource_preparation::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 182, "excerpt": "& store , | _ | ( )"}, @@ -3687,6 +3699,7 @@ {"id": "lv-habfb2b0c05da", "path": "crates/fractal-drivers/src/harness_events.rs", "kind": "sql-template", "fingerprint": "a4de6218a5e6b8ef0952c248d783d3f7c23b967bc4ed55fa29cd97be9b913513", "owner": "fractal_drivers::harness_events", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 311, "excerpt": "INSERT INTO events(runtime_incarnation,queued_at_ms,kind,body) VALUES (?1,?2,?3,?4)"}, {"id": "lv-hac01b510f6aa", "path": "crates/fractal-store/src/store/terminal_gate_evidence.rs", "kind": "sql-template", "fingerprint": "4ab028661942ae374c9aa98859505ae497d849f2c3f8da017a0619bf468909a1", "owner": "fractal_store::store::terminal_gate_evidence", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 349, "excerpt": "DELETE FROM test_terminal_meta WHERE namespace=?1"}, {"id": "lv-hac292d5d4ef7", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "3edada985fa7f0ed998965d239aab79abb45b29ef59bbe850c29f4bb5ebee977", "owner": "fractal_seats::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 722, "excerpt": "& format ! ( \"SELECT COUNT(*) FROM {table}\" ) , [ ] , | row | row . get : : < _ , u64 > ( 0 )"}, + {"id": "lv-hac330e1ef0a3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "23d384584d619edc1b8135734fe4536e80b585db990943fa67e8dc9b10b08fa9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 666, "excerpt": "UPDATE step_runs SET assignee='agent/node/other', available_to='[]', status='ready', lease_owner=NULL, lease_incarnation=NULL, lease_expires_at_unix_ms=NULL WHERE subject=?1"}, {"id": "lv-hac42e9bbbb24", "path": "crates/fractal-store/src/store/message_canonical_sort_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2ffd897d72bda85ddc6e414124c591f9804a4c42e2ad7a174c09ec7618be40e5", "owner": "fractal_store::store::message_canonical_sort_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 116, "excerpt": "& query , [ & message . subject ] , | row | row . get ( 0 )"}, {"id": "lv-hac6412f7b9a9", "path": "crates/fractal-store/src/store/run_eta_tests.rs", "kind": "sql-template", "fingerprint": "b3f2615139e7e6c555490b22cd2f59d6ed21382583e42d59f05b430c103dddc3", "owner": "fractal_store::store::run_eta_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 165, "excerpt": "UPDATE claims SET accepted_at_unix_ms='100' WHERE subject IN('s-0','mission-run/r-0') AND kind IN('step-run.state','mission-run.state')"}, {"id": "lv-hac726e7443c2", "path": "crates/fractal-store/src/store/attention_ivm.rs", "kind": "sql-template", "fingerprint": "107c51545db6e1c417e98c05ec8e5449ea093a898afdccbf59123b972a3eae13", "owner": "fractal_store::store::attention_ivm", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 14, "excerpt": " CREATE INDEX IF NOT EXISTS attention_open_person_sources_v3 ON step_runs(subject) WHERE assignee>='person/' AND assignee<'person0'; CREATE TABLE IF NOT EXISTS local_attention_open ( family TEXT NOT N"}, @@ -3878,6 +3891,7 @@ {"id": "lv-hb7b64cc10c51", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "7a41ee06927a48e907a8b303d67368f1f76113a73b7efcaf20c658e641deaac8", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1838, "excerpt": "params ! [ subject , at_index . unwrap_or ( i64 : : MAX as u64 ) ] , | row | row . get : : < _ , String > ( 0 ) ,"}, {"id": "lv-hb7bee44be324", "path": "crates/fractal-store/src/store/tests.rs", "kind": "sql-template", "fingerprint": "af5beb3369f127015aa1f89c22e5b4abfb98318601a8b0482ff3fbced8deafe2", "owner": "fractal_store::store::tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 6348, "excerpt": "INSERT OR IGNORE INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES(?1,?2,?3,?1,?4)"}, {"id": "lv-hb7c02123e5e8", "path": "crates/fractal-store/src/store/delivery.rs", "kind": "unresolved-sql-sink", "fingerprint": "de52185ad8dcad7dc3fa1d30bf1dc897e611878e1021c43109bbe8f41484c4d4", "owner": "fractal_store::store::delivery", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 105, "excerpt": "[ & message . subject ] , | row | row . get ( 0 )"}, + {"id": "lv-hb7cf1dc01667", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "39c0ce46943adc6d3fa133a261008f77c00d77aa927d346dd8295ee44b6ef104", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 363, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms=?3 WHERE subject=?1"}, {"id": "lv-hb7ed2976f44f", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "d1f5ab3e6b8522867ac512eae576dbb7ef1e4800b4dbf53a0ec146f1b4054166", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 18506, "excerpt": "params ! [ claim . subject , to ] ,"}, {"id": "lv-hb7f8923255f2", "path": "crates/smallclaims/src/sqlite/read_lifetime/tests.rs", "kind": "sql-template", "fingerprint": "69029be48c65949b99471da0adf147accfc0c78d768e7131ce9386c27e664aff", "owner": "smallclaims::sqlite::read_lifetime::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 81, "excerpt": "UPDATE payload SET value=zeroblob(8192)"}, {"id": "lv-hb806bfc38d25", "path": "crates/smallclaims/src/store/transport_observation_index.rs", "kind": "sql-template", "fingerprint": "0eb11da108f89988de47a90451cafe08bc166a8ab47c59147718245906dfdb64", "owner": "smallclaims::store::transport_observation_index", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 26, "excerpt": " CREATE TABLE IF NOT EXISTS transport_observation_index ( claim_id TEXT PRIMARY KEY, subject TEXT NOT NULL, origin TEXT NOT NULL, accepted_at_unix_ms TEXT NOT NULL ); -- This CREATE INDEX only ever se"}, @@ -3947,6 +3961,7 @@ {"id": "lv-hbb840ed5c2a8", "path": "crates/smallclaims/src/store/checkpoint_staging/scoped.rs", "kind": "unresolved-sql-sink", "fingerprint": "c32b884bb11aa9d33ad9fa5b6cbd7b18be6b5eec40e5397aa0319a5e4069ab0d", "owner": "smallclaims::store::checkpoint_staging::scoped", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 133, "excerpt": "& sql"}, {"id": "lv-hbb8f1fc66365", "path": "crates/fractal-delivery/src/store/delivery.rs", "kind": "unresolved-sql-sink", "fingerprint": "05d1fca38c802b1f8682dc8dd0b15fed8fbff745fec02413bdf5de12122b884e", "owner": "fractal_delivery::store::delivery", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 419, "excerpt": "& canonical_sql ( \"WITH candidates AS ( SELECT id FROM claims INDEXED BY claims_message_to_index WHERE kind='message.sent' AND json_extract(body,'$.fields.to')=?1 AND store_index<=?2 UNION SELECT id F"}, {"id": "lv-hbbb29f6a8f86", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 72, "excerpt": "SCHEMA"}, + {"id": "lv-hbbb772f82848", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "f5a08f92b6484e142bfc0fac291d42996b27093a57f64739f414596a86da5406", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 690, "excerpt": "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1"}, {"id": "lv-hbbba4668bfe8", "path": "crates/fractal-store/src/store/coordination.rs", "kind": "sql-template", "fingerprint": "121efaa5ed56abda9af318740eb032c5ca65527348d2dc8d2f6c815b24b479f9", "owner": "fractal_store::store::coordination", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 51, "excerpt": "CREATE INDEX IF NOT EXISTS coordination_sends_time ON coordination_sends(sent_ms,agent_to_agent,silent,to_person); CREATE INDEX IF NOT EXISTS coordination_sends_held ON coordination_sends(held,sent_ms"}, {"id": "lv-hbbc4fec93bc3", "path": "crates/fractal-seats/src/store/canonical_audit.rs", "kind": "sql-template", "fingerprint": "0ffd6f6cf65077624de24ce755a746fb82c2b9795a1ab03ab03a0d385e43add2", "owner": "fractal_seats::store::canonical_audit", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 633, "excerpt": "INSERT INTO temp.write_clock(offset_ms, at_ms) VALUES (0, ?1)"}, {"id": "lv-hbbd28d35da4a", "path": "crates/fractal-store/src/store/person_work.rs", "kind": "sql-template", "fingerprint": "28cc48d36dcfe25db7c1a426f57a2aa683b9d6bd3b4ac416cefa767f774c98ed", "owner": "fractal_store::store::person_work", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 611, "excerpt": "UPDATE mission_runs SET status=?2,phase='terminal',updated_at_unix_ms=?3 WHERE id=?1"}, @@ -4361,6 +4376,7 @@ {"id": "lv-hd355c60374f5", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "a2f391be823a4eafe796bd93333cd89211b452282a5154b2f8c6470e936601e3", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 254, "excerpt": "INSERT INTO step_runs(subject,run_id,generation_id,step_path,definition_hash, status,attempt,goals,created_at_unix_ms,updated_at_unix_ms) VALUES('step-run/'||?1,?1,?1,'build','hash','working',1,'[]','"}, {"id": "lv-hd36f92918948", "path": "crates/fractal-store/src/store/seats.rs", "kind": "unresolved-sql-sink", "fingerprint": "63d233d1b84eedf8c57c7d6d6054137f1456a76f414ca73fbb3fe3e662259782", "owner": "fractal_store::store::seats", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 263, "excerpt": "& canonical_sql ( \"SELECT id FROM claims INDEXED BY claims_harness_auth_diagnostic_index WHERE subject=?1 AND kind='harness.diagnostic' AND json_extract(body, '$.fields.incarnation_id')=?2 AND json_ex"}, {"id": "lv-hd38e7477ff17", "path": "crates/fractal-store/src/store/glass_heads.rs", "kind": "sql-template", "fingerprint": "2bab0f99fb8330ed98b3fb0b31a378077390bea2f9888bc170b4271886f67d69", "owner": "fractal_store::store::glass_heads", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 253, "excerpt": "INSERT INTO glass_heads(subject,person,created_key,head_key,claim_id,deleted) VALUES(?1,?2,?3,?4,?5,?6) ON CONFLICT(subject) DO UPDATE SET person=excluded.person,created_key=excluded.created_key,head_"}, + {"id": "lv-hd38e77dd6432", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 811, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-hd38e7cfc3d89", "path": "crates/fractal-store/src/store/limits_purity_tests.rs", "kind": "sql-template", "fingerprint": "f8c4c93563c0c8238178ec4a9d4d1bd0f7326a6fd3c15cf50bcd871385ed6f44", "owner": "fractal_store::store::limits_purity_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 171, "excerpt": "INSERT INTO account_limit_seats (subject, driver, account, account_ref, accepted_at_unix_ms, store_index) VALUES (?1,'claude','claude/one','',1,1) ON CONFLICT(subject) DO UPDATE SET driver=excluded.dr"}, {"id": "lv-hd3906354fc71", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "e994daae338c29fa43d6e8fbac011091d151437326784277140db7af7ffbeb5b", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 276, "excerpt": "UPDATE run_generations SET id='unrelated-next' WHERE id='unrelated'"}, {"id": "lv-hd395e63608d9", "path": "crates/smallclaims/src/store/checkpoint_staging.rs", "kind": "unresolved-sql-sink", "fingerprint": "bc824287c54f7dcf052acfea5e28d5d9080619e3bebb48ded44da68e332ced27", "owner": "smallclaims::store::checkpoint_staging", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 632, "excerpt": "params ! [ row . 0 , row . 1 , row . 2 , row . 3 , row . 4 , row . 5 , row . 6 , row . 7 , row . 8 , row . 9 , row . 1 1 , row . 1 0 = = \"valid\" ]"}, @@ -4407,6 +4423,7 @@ {"id": "lv-hd5f90ba66291", "path": "crates/fractal-store/src/store/custom.rs", "kind": "unresolved-sql-sink", "fingerprint": "45de5b742be9d0e5cd0ba59d294584a610a7f05e563491431ae653f5ffca4e86", "owner": "fractal_store::store::custom", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 118, "excerpt": "& canonical_sql ( \"SELECT claims.id,claims.store_index,claims.batch_id,claims.subject,claims.kind,claims.origin,claims.actor,claims.body,claims.predecessors,claims.accepted_at_unix_ms FROM claims JOIN"}, {"id": "lv-hd5fb23096b84", "path": "crates/fractal-store/src/store/client_messages.rs", "kind": "sql-template", "fingerprint": "93a6ae20b41f3b50adacb2d9a47be1570b76c7dc285491a696c3395ce1db9f43", "owner": "fractal_store::store::client_messages", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 758, "excerpt": "INSERT INTO local_client_message_selectors_v1(subject,born_index,created_index,dirty_flags) VALUES(?1,-1,1,2) ON CONFLICT(subject,born_index) DO UPDATE SET dirty_flags=dirty_flags|2,created_index=crea"}, {"id": "lv-hd60ba3ca89ff", "path": "crates/smallclaims/src/store/inventory_generation.rs", "kind": "unresolved-sql-sink", "fingerprint": "5a8493ce00029e2325ab8e1257235dcaac72db782142700f36a73961b0cc40b5", "owner": "smallclaims::store::inventory_generation", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 45, "excerpt": "[ KEY ] , | row | row . get ( 0 )"}, + {"id": "lv-hd60f12cbb0cd", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "2c428368447719337e62bf6baa77578a1f95a5cda24f8fab6b5471192613c58e", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 761, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1"}, {"id": "lv-hd60f51383124", "path": "crates/fractal-attention/src/store/alert_action_controls.rs", "kind": "sql-template", "fingerprint": "f7eee9a8920111e3326b09d284281d362715dc916c127be5d139b2a4d2e65433", "owner": "fractal_attention::store::alert_action_controls", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 98, "excerpt": "UPDATE claims SET body=?1 WHERE id=?2"}, {"id": "lv-hd61a62736b0a", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "7253cb476f96e83b5411c1487a9be9635820964f70c525ef5f88d7c57da5709c", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 11356, "excerpt": "params ! [ subject , at_index , incarnation_id ] , | row | Ok ( ( row . get : : < _ , String > ( 0 ) ? , row . get : : < _ , String > ( 1 ) ? , row . get : : < _ , String > ( 2 ) ? ) )"}, {"id": "lv-hd6399516a97a", "path": "crates/fractal-store/src/store/run_eta.rs", "kind": "unresolved-sql-sink", "fingerprint": "6e4db109e4ae560950a7301b0a4e357f7b7663c394ef6af46e901160eee56dcc", "owner": "fractal_store::store::run_eta", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 102, "excerpt": "& format ! ( \"SELECT kind,body,claims.accepted_at_unix_ms FROM claims JOIN batches ON batches.id=claims.batch_id WHERE subject=?1 AND (json_extract(body,'$.fields.attempt') IS NULL OR json_extract(bod"}, @@ -4422,6 +4439,7 @@ {"id": "lv-hd6d5825156e4", "path": "crates/fractal-secrets/src/sekrets/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "fractal_secrets::sekrets::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 180, "excerpt": "SCHEMA"}, {"id": "lv-hd6e104a05fb6", "path": "crates/smallclaims/src/store/document_index.rs", "kind": "unresolved-sql-sink", "fingerprint": "5e9e57b81ceeffbc1630a50f92ef3b700c2b62b3e263bff52cc527c81d2d484b", "owner": "smallclaims::store::document_index", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 55, "excerpt": "params ! [ name , hash , id , key ]"}, {"id": "lv-hd6e8a46ee987", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "sql-template", "fingerprint": "39d0916851f71003495abee2210e1ced45da0388cf5184925ffa91d54322c837", "owner": "smallclaims::store::checkpoint_trim", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 1164, "excerpt": "INSERT OR IGNORE INTO meta(key, value) VALUES (?1, (SELECT COALESCE(MAX(rowid), 0) FROM checkpoint_envelopes) || ' ' || (SELECT COALESCE(MAX(rowid), 0) FROM checkpoint_claims))"}, + {"id": "lv-hd700e0550a21", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 861, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, {"id": "lv-hd7107683547f", "path": "crates/fractal-delivery/src/store/conversation_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "43356247c35c7c91f2201887e1d25b61cae1f422a2c020edb6d7cd298b51383a", "owner": "fractal_delivery::store::conversation_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 427, "excerpt": "TIMELINE_OWNER_IDS"}, {"id": "lv-hd71123037179", "path": "crates/fractal-delivery/src/store/actual_purity_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_delivery::store::actual_purity_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 133, "excerpt": "DELETE FROM claims WHERE id=?1"}, {"id": "lv-hd729ec9593f5", "path": "crates/fractal-store/src/store/tests.rs", "kind": "included-source", "fingerprint": "6a05cbb4717caa2a60a7abeba00f522c7d6804fbd77fa845016f39ac3ba984e9", "owner": "fractal_store::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 287, "excerpt": "include ! ( \"subject_reference_readmission_tests.rs\" )"}, @@ -4840,6 +4858,7 @@ {"id": "lv-hf0005c722374", "path": "crates/fractal-store/src/store/slo_breaches.rs", "kind": "sql-template", "fingerprint": "72c42082bb7327a69ecfb944a9b3da418cedbbb9ba6e56444020bf2d9764e900", "owner": "fractal_store::store::slo_breaches", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 160, "excerpt": "INSERT INTO slo_episode_heads(fleet,member,objective,episode,ordinal,conflict) VALUES(?1,?2,?3,?4,?5,?6) ON CONFLICT(fleet,member,objective) DO UPDATE SET episode=excluded.episode,ordinal=excluded.ord"}, {"id": "lv-hf003c2793536", "path": "crates/smallclaims/src/store/projection_digest.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "smallclaims::store::projection_digest", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 135, "excerpt": "SCHEMA"}, {"id": "lv-hf00f330bcb9a", "path": "crates/fractal-store/src/store/checkpoint_capture_epoch_tests.rs", "kind": "sql-template", "fingerprint": "be4832edabef59c7b5168b7abb00f547104e1c3264e49ceaf20a249ffc1d9784", "owner": "fractal_store::store::checkpoint_capture_epoch_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 46, "excerpt": "INSERT INTO mission_revisions(mission_id,revision,state,body,claim_id,created_index) SELECT 'copy',revision,state,body,claim_id,created_index FROM mission_revisions WHERE mission_id='capture'"}, + {"id": "lv-hf01b6d5cecf5", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "95923a5d49ab1c3c1f6b3254e6cf3ccafb8ca33833d9566fa5e90626612d746e", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 19298, "excerpt": "params ! [ spellings , run_id ] , | row | { Ok ( ( row . get : : < _ , Option < i64 > > ( 0 ) ? , row . get : : < _ , Option < String > > ( 1 ) ? , ) ) }"}, {"id": "lv-hf01d490c1378", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "1d725f730d91a1aabeefc8917346db4353d66cc952e2df205cebd12b6f4d9d39", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 16910, "excerpt": "UPDATE revision_proposals SET status=?2, successor_generation_id=?3, updated_at_unix_ms=?4 WHERE id=?1"}, {"id": "lv-hf02160169cc7", "path": "crates/fractal-missions/src/store/resource_preparation/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "dc372daedd324b47f1261987900c1a08eff3ccdf253bfcf666d0ebecf17142ad", "owner": "fractal_missions::store::resource_preparation::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 96, "excerpt": "& store , | _ | ( )"}, {"id": "lv-hf0338a02af4f", "path": "crates/fractal-store/src/store/client_messages.rs", "kind": "unresolved-sql-sink", "fingerprint": "b53ffc8bab004056486a57354fa6c8d825fe6a3bc6a38e259c2d388b34d610fe", "owner": "fractal_store::store::client_messages", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1086, "excerpt": "& RETIRED , params ! [ expired , PRUNE_ROWS ]"}, @@ -5094,25 +5113,6 @@ {"id": "lv-hffa489fdc3a4", "path": "crates/fractal-delivery/src/store/actual_purity_tests.rs", "kind": "sql-template", "fingerprint": "956c1937e6f1df2654443d7d1cf94a2205155878520a58271ba7c75ec53ffcc9", "owner": "fractal_delivery::store::actual_purity_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 189, "excerpt": "DELETE FROM replica_records WHERE claim_id=?1"}, {"id": "lv-hffada0345e64", "path": "crates/fractal-evidence/src/store/owned_sets_tests.rs", "kind": "sql-template", "fingerprint": "6a82fc24d210dca694a92da0f80f60f336c03f7d8d7cfc7df49eab02a783e55a", "owner": "fractal_evidence::store::owned_sets_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 169, "excerpt": "DROP INDEX claims_owned_set_subject_index"}, {"id": "lv-hffd9a012f0b4", "path": "crates/fractal-store/src/store/publication.rs", "kind": "unresolved-sql-sink", "fingerprint": "b083d66261bfa22f874abd9ac071b20ad1a2e4943fd23f512f9c04f3abdc94c7", "owner": "fractal_store::store::publication", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 137, "excerpt": "params ! [ proposed . subject , origin , at . min ( i64 : : MAX as u64 ) ] , | row | row . get ( 0 ) ,"}, - {"id": "lv-hffefd094582d", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "083207b3eb2fe222def918c823d087f768ee4f1c42845263e9ba3465b9bbd2a7", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 10847, "excerpt": "params ! [ fence . subject , i64 : : MAX ] , | row | { Ok ( ( row . get ( 0 ) ? , row . get ( 1 ) ? ) ) }"}, - {"id": "lv-h755979e290e9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 523, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, - {"id": "lv-hb7cf1dc01667", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "39c0ce46943adc6d3fa133a261008f77c00d77aa927d346dd8295ee44b6ef104", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 363, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms=?3 WHERE subject=?1"}, - {"id": "lv-hd60f12cbb0cd", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "2c428368447719337e62bf6baa77578a1f95a5cda24f8fab6b5471192613c58e", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 761, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1"}, - {"id": "lv-hd38e77dd6432", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 811, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, - {"id": "lv-hbbb772f82848", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "f5a08f92b6484e142bfc0fac291d42996b27093a57f64739f414596a86da5406", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 690, "excerpt": "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1"}, - {"id": "lv-hac330e1ef0a3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "23d384584d619edc1b8135734fe4536e80b585db990943fa67e8dc9b10b08fa9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 666, "excerpt": "UPDATE step_runs SET assignee='agent/node/other', available_to='[]', status='ready', lease_owner=NULL, lease_incarnation=NULL, lease_expires_at_unix_ms=NULL WHERE subject=?1"}, - {"id": "lv-ha0cd354483f3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 868, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, - {"id": "lv-h7d7fcc2328ee", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d35271b78634a314d43a3538f1037107387d271a4fa449cfd3f69afad27e28ac", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 937, "excerpt": "UPDATE step_runs SET lease_owner=?2, status=?3 WHERE subject=?1"}, - {"id": "lv-h2102123ed04c", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 818, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, - {"id": "lv-h1c3e44d7086a", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "51cc0656fb3569edd52d628ebfaf71a0d8cc9b159da6fb3069aaaf55b3cdc1a5", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6492, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?3, lease_owner=?2 WHERE subject=?1"}, - {"id": "lv-h934585192b73", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "386e7e1231a36489f2cceb29bad211b82fb2995a96133e6a5663bffbd895e0aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 991, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms='1' WHERE subject=?1"}, - {"id": "lv-h48c2f2d4b497", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 930, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, - {"id": "lv-h34894d4454a8", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 994, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, - {"id": "lv-h28928374ca14", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1d84c831a4aa0ece376bf0e40064fb0bd36cd58e7f04211c859df103c1e40e8c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 823, "excerpt": "UPDATE step_runs SET status=?2 WHERE subject=?1"}, - {"id": "lv-h76038fa53ef8", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "e21446038f569a6eec8b9b9a7e8201f454cd571dd47d6c8c72dedd6f6cbfbb2b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 9598, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker', lease_owner=?2 WHERE subject=?1"}, - {"id": "lv-hf01b6d5cecf5", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "95923a5d49ab1c3c1f6b3254e6cf3ccafb8ca33833d9566fa5e90626612d746e", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 19298, "excerpt": "params ! [ spellings , run_id ] , | row | { Ok ( ( row . get : : < _ , Option < i64 > > ( 0 ) ? , row . get : : < _ , Option < String > > ( 1 ) ? , ) ) }"}, - {"id": "lv-hd700e0550a21", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 861, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, - {"id": "lv-h78e96a5b9d99", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 966, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, - {"id": "lv-h16928b5eb896", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "c3a0532ab17a84e1ad0287749b26ea41efe293bb61b1c62a42a4093e94414017", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 320, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3 WHERE subject=?1"} + {"id": "lv-hffefd094582d", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "083207b3eb2fe222def918c823d087f768ee4f1c42845263e9ba3465b9bbd2a7", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 10847, "excerpt": "params ! [ fence . subject , i64 : : MAX ] , | row | { Ok ( ( row . get ( 0 ) ? , row . get ( 1 ) ? ) ) }"} ] } From 2ac842df3fd32b4033adf89d3eed2ebd2fee5375 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:07:08 +0100 Subject: [PATCH 074/132] Register durable mailbox expiry outcome contract --- crates/fractal-schema/retention.toml | 1 + crates/fractal-schema/src/lib.rs | 16 +- crates/fractal-store/src/store.rs | 24 +- .../fractal-store/src/store/message_expiry.rs | 207 + .../fractal-store/src/store/messages_shard.rs | 7 +- crates/fractal-store/src/store/runtime.rs | 7 + crates/fractal/tests/convergence.rs | 63 +- docs/st3/schema.md | 1 + docs/st3/seat-deploys.md | 29 +- fixtures/convergence/README.md | 12 + fixtures/convergence/claims-v18.json | 10946 ++++++++++++++++ release-notes/mail-expiry-contract.json | 11 + 12 files changed, 11306 insertions(+), 18 deletions(-) create mode 100644 crates/fractal-store/src/store/message_expiry.rs create mode 100644 fixtures/convergence/claims-v18.json create mode 100644 release-notes/mail-expiry-contract.json diff --git a/crates/fractal-schema/retention.toml b/crates/fractal-schema/retention.toml index 4300b393d00..0b9e35c7229 100644 --- a/crates/fractal-schema/retention.toml +++ b/crates/fractal-schema/retention.toml @@ -121,6 +121,7 @@ "loop.state" = { class = "transition", window = "7d" } "message.closed" = { class = "durable" } "message.delivered" = { class = "transition", window = "7d", pending = "delivery state reads it until the message closes" } +"message.expired" = { class = "durable" } "message.read" = { class = "transition", window = "7d", pending = "unread state reads it until the message closes" } "message.sent" = { class = "durable" } "message.staged" = { class = "transition", window = "7d", pending = "delivery state reads it until the message closes" } diff --git a/crates/fractal-schema/src/lib.rs b/crates/fractal-schema/src/lib.rs index 8b822a5387e..8ef5b9463c4 100644 --- a/crates/fractal-schema/src/lib.rs +++ b/crates/fractal-schema/src/lib.rs @@ -2518,6 +2518,15 @@ fn claim_specs() -> BTreeMap { true, &[], ), + ( + "message.expired", + &["message"], + WritePolicy::SystemOnly, + Cardinality::Once, + Some("messages"), + true, + &[], + ), ( "planning-session.started", &["planning-session"], @@ -4206,6 +4215,10 @@ fn claim_fields(kind: &str) -> BTreeMap { ], "message.read" => &[("status", required_enum(&["read"])), ("acting_for", reference_to(&["agent"]))], "message.closed" => &[("status", required_enum(&["closed"])), ("acting_for", reference_to(&["agent"]))], + "message.expired" => &[ + ("status", required_enum(&["expired"])), + ("reason", required_enum(&["cutoff-never-offered", "cutoff-uncertain-attempt", "no-live-recipient"])), + ], "planning-session.started" => &[ ("mission", reference()), ("request", reference()), @@ -4885,10 +4898,11 @@ mod tests { #[test] fn assistant_principal_registry_digest_transition() { - // Reconstruct the registry at the principal transition, before usage observations. + // Reconstruct the principal-transition registry, excluding subsequently added kinds. let mut current = registry().clone(); current.claims.remove(usage_limits::KIND).unwrap(); current.claims.remove("client.operation-authorized").unwrap(); + current.claims.remove("message.expired").unwrap(); assert_eq!(current.subjects["assistant"].pattern, "assistant/{chatgpt|mcp}[/session/assistant-grant/ID]"); let mut previous = current.clone(); previous.subjects.get_mut("assistant").unwrap().pattern = diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 91270bfd95a..ca454d40222 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -16,6 +16,7 @@ macro_rules! message_desired_recipient_match { // Store methods by domain (the crate plan's domain rules); core stays in this file. pub mod attention; pub mod delivery; +pub mod message_expiry; pub mod deploy; mod device_renewal; pub mod evidence; @@ -11198,6 +11199,10 @@ fn validate_message_transition( transaction: &Transaction<'_>, input: &ClaimInput, ) -> Result { + if input.kind == message_expiry::KIND { + message_expiry::validate_actor(&input.kind, input.actor.as_deref())?; + return message_expiry::validate_transition(transaction, &input.subject); + } let requested = match input.kind.as_str() { "message.sent" => "sent", "message.staged" => "staged", @@ -11264,6 +11269,8 @@ fn validate_message_transition( .filter(|kind| *kind == "message") .map(|_| "sent") }; + let late_consumption = matches!(requested, "delivered" | "read") + && message_expiry::exists(transaction, &input.subject).map_err(internal)?; if requested != "sent" && current == Some(requested) && !(requested == "read" && input.fields.contains_key("acting_for")) { if input.fields.contains_key("delegation") { @@ -11279,7 +11286,7 @@ fn validate_message_transition( (Some("delivered" | "read" | "closed"), "staged") | (Some("read" | "closed"), "delivered") | (Some("closed"), "read") - ) && input.actor.is_some() + ) && input.actor.is_some() && !late_consumption { let index: Option = transaction .query_row("SELECT created_index FROM message_index WHERE subject=?1", [&input.subject], |row| row.get(0)) @@ -11302,7 +11309,7 @@ fn validate_message_transition( | (Some("sent"), "delivered") | (Some("delivered"), "read") | (Some("read"), "closed") - ); + ) || (late_consumption && matches!(current, Some("sent" | "staged" | "read" | "closed"))); let custody_read = requested == "read" && input.fields.contains_key("acting_for") && matches!(current, Some("sent" | "staged" | "read")); @@ -11432,6 +11439,10 @@ pub fn append_claim_tx_with_assistant_answer( } // Daemon notifications already inside a larger transaction use the same admission seam // as API sends. Replication inserts authenticated history through its separate import path. + if kind == message_expiry::KIND { + return Err(anyhow::Error::new(FractalError::new( + "invalid-message-expiry", "expiry requires runtime receipt admission"))); + } if kind == retirements::KIND { return Err(anyhow::Error::new(retirements::writer_required())); } @@ -16033,6 +16044,7 @@ pub fn classify_replicated_claim_with_registry( ), )); } + message_expiry::validate_actor(&claim.kind, claim.actor.as_deref())?; identities::validate_replicated(claim)?; if claim.kind == retirements::KIND && (fields.get("owner_host").and_then(Value::as_str) != Some(claim.origin.as_str()) @@ -22691,6 +22703,8 @@ pub struct ClaimCommitContext<'a> { pub retirement_writer: bool, /// Only rename_identity supplies naming CAS, owner and fleet registry admission. pub identity_writer: bool, + /// Runtime append owns cross-file expiry/receipt serialization. + pub expiry_writer: bool, pub admission: Option<&'a ClaimAdmission<'a>>, pub assistant: Option<&'a assistant_write::AssistantWriteAuthorization>, pub native_receipt: Option<&'a crate::conversation_delivery::NativeReceipt>, @@ -22712,6 +22726,10 @@ pub fn append_claim_with_commit_context( context: ClaimCommitContext<'_>, ) -> Result<(ClaimRecord, bool), FractalError> { let admission = |connection: &Connection| { + message_expiry::validate_actor(&input.kind, input.actor.as_deref())?; + if input.kind == message_expiry::KIND && !context.expiry_writer { + return Err(FractalError::new("invalid-message-expiry", "expiry requires runtime receipt admission")); + } if input.kind == retirements::KIND && !context.retirement_writer { return Err(retirements::writer_required()); } @@ -22830,6 +22848,8 @@ pub fn append_claim_with_commit_context( } matches!((input.kind.as_str(), message.status.as_str()), ("message.staged", "delivered" | "read" | "closed") | ("message.delivered", "read" | "closed") | ("message.read", "closed")) + && !(matches!(input.kind.as_str(), "message.delivered" | "message.read") + && message_expiry::exists(transaction, &input.subject).map_err(internal)?) } else { false }; if let Some((operation_id, request_digest)) = &operation && let Some((stored_digest, canonical_claim, state)) = diff --git a/crates/fractal-store/src/store/message_expiry.rs b/crates/fractal-store/src/store/message_expiry.rs new file mode 100644 index 00000000000..6561630e878 --- /dev/null +++ b/crates/fractal-store/src/store/message_expiry.rs @@ -0,0 +1,207 @@ +//! Durable product outcomes, separate from member-local transport receipts. No expiry writer +//! runs here. Readers retain expiry history even when later consumption evidence supersedes it. +use super::*; + +pub const KIND: &str = "message.expired"; + +/// Cross-file ordering: take this before either writer and retain it through commit. +#[derive(Default)] +pub(super) struct ReceiptGate(pub Mutex<()>); + +#[derive(Clone, Debug)] +pub struct MessageExpiry { + pub claim: ClaimRecord, + /// Actual delivery/read evidence wins; expiry never asserts that consumption was impossible. + pub superseded: bool, +} + +impl Store { + pub fn message_expiry(&self, subject: &str) -> Result> { + let Some(claim) = self.latest_claim(subject, Some(KIND))? else { return Ok(None); }; + let mut superseded = message_marks::recorded_at(&self.readers.get(), subject, + &["message.delivered", "message.read"], None)?.is_some(); + for kind in ["message.delivered", "message.read"] { + if superseded { break; } + if self.latest_claim(subject, Some(kind))?.is_some() + || messages_shard::mailbox_reads::lifecycle_step(self, subject, kind)?.is_some() + { + superseded = true; + break; + } + } + Ok(Some(MessageExpiry { claim, superseded })) + } +} + +pub(super) fn validate_actor(kind: &str, actor: Option<&str>) -> Result<(), FractalError> { + if kind == KIND && actor != Some("daemon/runtime") { + return Err(FractalError::new("invalid-message-expiry", "only daemon/runtime may expire mail")); + } + Ok(()) +} + +pub(super) fn exists(connection: &Connection, subject: &str) -> Result { + Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM claims WHERE subject=?1 AND kind='message.expired')", + [subject], |row| row.get(0))?) +} + +/// Runtime admission holds ReceiptGate before entering the graph writer. +pub(super) fn validate_shard(runtime: &FractalRuntime, connection: &Connection, subject: &str) -> Result<(), FractalError> { + if exists(connection, subject).map_err(internal)? { return Ok(()); } + let slot = runtime.extensions.get::(); + if let Some(shard) = slot.get() { + let terminal = shard.readers.get().query_row( + "SELECT EXISTS(SELECT 1 FROM stream_lifecycle WHERE subject=?1 AND kind IN ('message.delivered','message.read','message.closed'))", + [subject], |row| row.get::<_, bool>(0), + ).map_err(internal)?; + if terminal { return Err(FractalError::new("invalid-message-transition", "shard mail already has a terminal receipt")); } + } + Ok(()) +} + +pub(super) fn validate_transition(connection: &Connection, subject: &str) -> Result { + if exists(connection, subject).map_err(internal)? { return Ok(true); } + let terminal: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM claims WHERE subject=?1 AND kind IN ('message.delivered','message.read','message.closed')) + OR EXISTS(SELECT 1 FROM local_message_marks WHERE message=?1) + OR EXISTS(SELECT 1 FROM local_stream_lifecycle WHERE subject=?1 AND kind IN ('message.delivered','message.read','message.closed'))", + [subject], |row| row.get(0), + ).map_err(internal)?; + if !message_sent::message_sent_tx(connection, subject).map_err(internal)? || terminal { + return Err(FractalError::new("invalid-message-transition", "expiry requires sent or uncertain staged mail")); + } + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(kind: &str, actor: &str) -> ClaimInput { + ClaimInput { subject: "message/expiry-contract".into(), kind: kind.into(), actor: Some(actor.into()), + fields: BTreeMap::from([("status".into(), json!(kind.trim_start_matches("message.")))]), + evidence: vec![], expected_subject: None, idempotency_key: None } + } + + fn fixture(storage: &str) -> (tempfile::TempDir, Arc, ClaimInput) { + let directory = tempfile::tempdir().unwrap(); + let store = Arc::new(Store::open(&directory.path().join("claims.sqlite3"), "fixture").unwrap()); + let mut sent = input("message.sent", "agent/sender"); + sent.fields.extend([("from".into(), json!("agent/sender")), ("to".into(), json!("agent/fixture.recipient")), + ("content".into(), json!("Expiry contract fixture"))]); + if storage == "shard" { + store.bind_fleet("expiry-fixture").unwrap(); + store.load_member_key(Some(Arc::new(smallclaims::fleet::MemberKey::generate().unwrap().0))).unwrap(); + store.use_key_directory(&directory.path().join("keys")).unwrap(); + messages_shard::open(&store, directory.path()).unwrap().append_claim(&sent).unwrap(); + stream_mirror::mirror(&store).unwrap(); + } else { + store.append_claim(&sent).unwrap(); + if storage == "marks" { + let intent = crate::graph::parse_intent("version 2\nagent \"recipient\" { host \"fixture\"; command \"true\"; }", "fixture").unwrap(); + crate::store::card_fold_tests_shared_missions::declare(&store, &intent, "expiry-local-seat"); + assert!(store.refresh_marks_switch(&[]).unwrap().0); + } + } + let mut expiry = input(KIND, "daemon/runtime"); + expiry.fields.insert("reason".into(), json!("cutoff-uncertain-attempt")); + (directory, store, expiry) + } + + fn receipt(store: &Store, storage: &str, kind: &str, actor: &str) { + let input = input(kind, actor); + if storage == "shard" { + messages_shard::record_lifecycle(store, &input.subject, kind, input.actor.as_deref(), &input.fields).unwrap(); + assert!(messages_shard::mailbox_reads::lifecycle_step(store, &input.subject, kind).unwrap().is_some()); + } else { store.append_claim(&input).unwrap(); } + } + + #[test] + fn expiry_contract_rejects_transactional_bypass() { + let (_directory, store, expiry) = fixture("graph"); + let mut connection = store.connection.write(); + let transaction = connection.transaction().unwrap(); + let body = json!({"fields": expiry.fields}); + assert!(runtime().append_claim_tx(&transaction, "fixture", &expiry.subject, KIND, + expiry.actor.as_deref(), &body, &[], None).is_err()); + assert!(append_claim_tx_with_assistant_answer(&transaction, "fixture", &expiry.subject, KIND, + expiry.actor.as_deref(), &body, &[], None, None).is_err()); + assert!(!exists(&transaction, &expiry.subject).unwrap()); + transaction.commit().unwrap(); + } + + #[test] + fn expiry_contract_read_then_delivered_is_handled() { + for storage in ["graph", "marks", "shard"] { + let (_directory, store, expiry) = fixture(storage); + store.append_claim(&expiry).unwrap(); + receipt(&store, storage, "message.read", "agent/fixture.recipient"); + receipt(&store, storage, "message.delivered", "agent/fixture.recipient"); + assert!(store.message_expiry(&expiry.subject).unwrap().unwrap().superseded, "{storage}"); + } + } + + #[test] + fn expiry_contract_history_marks_and_late_receipts() { + for storage in ["graph", "marks", "shard"] { + for step in ["message.delivered", "message.read"] { + for withdrawn in [false, true] { + let (directory, store, mut expiry) = fixture(storage); + for actor in ["agent/fixture.recipient", "daemon/other"] { + expiry.actor = Some(actor.into()); + assert!(store.append_claim(&expiry).is_err()); + } + expiry.actor = Some("daemon/runtime".into()); + if withdrawn { receipt(&store, storage, "message.staged", "agent/fixture.recipient"); } + let claim = store.append_claim(&expiry).unwrap(); + assert_eq!(store.append_claim(&expiry).unwrap().id, claim.id); + if withdrawn { receipt(&store, storage, "message.closed", "daemon/runtime"); } + assert!(!store.message_expiry(&expiry.subject).unwrap().unwrap().superseded); + receipt(&store, storage, step, "agent/fixture.recipient"); + assert!(store.message_expiry(&expiry.subject).unwrap().unwrap().superseded, "{storage}/{step}/{withdrawn}"); + if storage == "marks" { + assert!(message_marks::recorded_at(&store.readers.get(), &expiry.subject, &[step], None).unwrap().is_some()); + } + drop(store); + let reopened = Store::open(&directory.path().join("claims.sqlite3"), "fixture").unwrap(); + assert_eq!(reopened.message_expiry(&expiry.subject).unwrap().unwrap().claim.id, claim.id); + } + } + } + } + + #[test] + fn expiry_contract_rejects_terminal_receipts_without_a_mirror_refresh() { + for storage in ["graph", "marks", "shard"] { + for terminal in ["message.delivered", "message.read", "message.closed"] { + let (_directory, store, mut expiry) = fixture(storage); + if terminal == "message.read" { receipt(&store, storage, "message.delivered", "agent/fixture.recipient"); } + receipt(&store, storage, terminal, if terminal == "message.closed" { "daemon/runtime" } else { "agent/fixture.recipient" }); + assert!(store.append_claim(&expiry).is_err(), "{storage}/{terminal}"); + expiry.subject = "message/missing-expiry".into(); + assert!(store.append_claim(&expiry).is_err()); + } + } + } + + #[test] + fn expiry_contract_replication_and_old_registry_are_compatible() { + let registry = fractal_schema::registry(); + let mut old = registry.clone(); + old.claims.remove(KIND); + assert_ne!(old.digest(), registry.digest()); + for reason in ["cutoff-never-offered", "cutoff-uncertain-attempt", "no-live-recipient"] { + let fields = BTreeMap::from([("status".into(), json!("expired")), ("reason".into(), json!(reason))]); + registry.validate_claim("message/expiry-contract", KIND, &fields).unwrap(); + let mut claim = messages_shard::lifecycle_receipt("fixture", "message/expiry-contract", KIND, + Some("daemon/runtime"), &fields); + assert!(matches!(classify_replicated_claim_with_registry(&claim, &old).unwrap(), ReplicatedClaimAdmission::UnknownKind)); + assert!(matches!(classify_replicated_claim_with_registry(&claim, registry).unwrap(), ReplicatedClaimAdmission::Valid)); + claim.actor = Some("agent/recipient".into()); + assert!(classify_replicated_claim_with_registry(&claim, registry).is_err()); + assert!(messages_shard::classify_stream_item(&claim).is_err()); + } + let fields = BTreeMap::from([("status".into(), json!("expired")), ("reason".into(), json!("other"))]); + assert!(registry.validate_claim("message/expiry-contract", KIND, &fields).is_err()); + } +} diff --git a/crates/fractal-store/src/store/messages_shard.rs b/crates/fractal-store/src/store/messages_shard.rs index 32b016d9b35..b5f4983a867 100644 --- a/crates/fractal-store/src/store/messages_shard.rs +++ b/crates/fractal-store/src/store/messages_shard.rs @@ -369,6 +369,8 @@ fn record_lifecycle_checked( let Some((sent, _)) = stream_item(&shard, subject).map_err(internal)? else { return Err(FractalError::new("missing-message", format!("no stream item `{subject}`"))); }; + let gate = graph.extension::(); + let _held = gate.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // The step is decided and recorded under the shard's writer, in one transaction, so two // steps at once can't both pass a check made before either was recorded. let mut writer = shard.connection.write(); @@ -383,10 +385,12 @@ fn record_lifecycle_checked( .unwrap_or(0); // A successor records its own custody read of mail its predecessor already read, once. let custody_reread = checked && rank == 3 && current == 3 && fields.contains_key("acting_for"); + let late_consumption = checked && matches!(rank, 2 | 3) + && super::message_expiry::exists(&graph.readers.get(), subject).map_err(internal)?; // A replayed or earlier step settles to what is recorded, as a graph receipt does. An // admitted step is recorded wherever the item stands, as the graph appends its claim, so the // receipt it names exists. - if checked && rank <= current && !custody_reread { + if checked && rank <= current && !custody_reread && !late_consumption { return Ok(false); } // Forward steps follow the graph's message transitions, so a stream item moves as the same @@ -394,6 +398,7 @@ fn record_lifecycle_checked( let recipient = sent.body.pointer("/fields/to").and_then(Value::as_str); let valid = !checked || custody_reread + || late_consumption || matches!((current, rank), (0, 1) | (1, 2) | (0, 2) | (2, 3) | (3, 4)) // A person's displayed message has no harness handoff; their read is its delivery. || (rank == 3 && current <= 1 && actor.is_some_and(|actor| actor.starts_with("person/")) && actor == recipient) diff --git a/crates/fractal-store/src/store/runtime.rs b/crates/fractal-store/src/store/runtime.rs index 03e7204d77d..9158b9f29bb 100644 --- a/crates/fractal-store/src/store/runtime.rs +++ b/crates/fractal-store/src/store/runtime.rs @@ -464,6 +464,13 @@ impl Runtime for FractalRuntime { input: &ClaimInput, ) -> Result<(ClaimRecord, bool), FractalError> { self.validate_usage_limits_writer(&input.kind, input.actor.as_deref())?; + if input.kind == message_expiry::KIND { + let gate = self.extensions.get::(); + let _held = gate.0.lock().unwrap_or_else(PoisonError::into_inner); + let admission = |connection: &Connection| message_expiry::validate_shard(self, connection, &input.subject); + return append_claim_with_commit_context(store, input, None, None, None, None, + ClaimCommitContext { expiry_writer: true, admission: Some(&admission), ..Default::default() }); + } append_claim_fenced_outcome(store, input, None) } diff --git a/crates/fractal/tests/convergence.rs b/crates/fractal/tests/convergence.rs index 1e5411dc27a..4af51da2532 100644 --- a/crates/fractal/tests/convergence.rs +++ b/crates/fractal/tests/convergence.rs @@ -79,7 +79,7 @@ const SEEDS: &[u64] = &[ fn corpus() -> &'static BTreeMap { static CORPUS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - serde_json::from_str(include_str!("../../../fixtures/convergence/claims-v17.json")) + serde_json::from_str(include_str!("../../../fixtures/convergence/claims-v18.json")) .expect("versioned convergence input schema") }); &CORPUS @@ -1020,7 +1020,24 @@ impl World { }); } let family = self.rng.pick(&spec.subjects).clone(); - let subject = self.subject_of(&family, index)?; + let subject = if spec.kind == "message.expired" { + // Expiry requires existing sent mail. A fresh subject would make every random + // expiry fail admission even with the runtime actor; native admission still + // decides whether delivery or closure has already made this mail ineligible. + let mut subjects = claims(&self.nodes[index].store) + .into_values() + .filter(|claim| claim.kind == "message.sent") + .map(|claim| claim.subject) + .collect::>(); + subjects.sort(); + subjects.dedup(); + if subjects.is_empty() { + return None; + } + self.rng.pick(&subjects).clone() + } else { + self.subject_of(&family, index)? + }; let deployment = deployment_fields(&spec.kind); let (fields, actor) = if let Some(mut fields) = deployment { if spec.kind == "deploy.go" && self.rng.chance(40) { @@ -1035,11 +1052,15 @@ impl World { fields.insert(name.clone(), self.value_of(field, index)); } } - let actor = match self.rng.below(4) { - 0 => None, - 1 => Some(PERSON.to_owned()), - 2 if subject.starts_with("agent/") => Some(subject.clone()), - _ => Some("agent/run-0/worker-0".to_owned()), + let actor = if spec.kind == "message.expired" { + Some("daemon/runtime".to_owned()) + } else { + match self.rng.below(4) { + 0 => None, + 1 => Some(PERSON.to_owned()), + 2 if subject.starts_with("agent/") => Some(subject.clone()), + _ => Some("agent/run-0/worker-0".to_owned()), + } }; (fields, actor) }; @@ -1692,6 +1713,33 @@ fn append_generated_claim( .expect("publication API wrote the generated mission")) } +#[test] +fn generated_message_expiry_is_admitted_and_replicates() { + let mut world = World::new(1, Sabotage::None); + let mut sent = world.claim_of(&corpus()["message.sent"], 0).unwrap(); + sent.actor = Some(PERSON.into()); + sent.fields = BTreeMap::from([ + ("status".into(), json!("sent")), + ("from".into(), json!(PERSON)), + ("to".into(), json!("agent/run-0/worker-0")), + ("content".into(), json!("Convergence expiry fixture")), + ]); + append_generated_claim(&world.nodes[0].store, &sent).unwrap(); + + // Use the random schedule's exact generation and admission paths, not a + // hand-authored expiry that only proves corpus membership. + let input = world.claim_of(&corpus()["message.expired"], 0).unwrap(); + assert_eq!(input.subject, sent.subject); + assert_eq!(input.actor.as_deref(), Some("daemon/runtime")); + assert_eq!(input.fields["status"], json!("expired")); + let claim = append_generated_claim(&world.nodes[0].store, &input).unwrap(); + let expiry = world.nodes[0].store.latest_claim(&input.subject, Some("message.expired")).unwrap().unwrap(); + assert_eq!(expiry.id, claim.id); + world.tap(0); + assert!(claims(&world.oracle).contains_key(&claim.id)); + assert!(world.failures.is_empty(), "{}", world.report()); +} + #[test] fn generated_mission_publications_replicate_as_canonical_missions() { let mut world = World::new(1, Sabotage::None); @@ -1814,7 +1862,6 @@ const NEVER_TRIMS: &[u64] = &[2]; /// One fixed seed: it converges, and its checkpoints trim unless it is a seed that never does. fn seed_converges(seed: u64) { let outcome = run(seed, Sabotage::None); - eprintln!("convergence corpus v17 seed {seed}: trimmed={}", outcome.trimmed); if let Some(failure) = &outcome.failure { panic!("seed {seed} failed\n{failure}"); } diff --git a/docs/st3/schema.md b/docs/st3/schema.md index fcbc51e8263..c353669f98f 100644 --- a/docs/st3/schema.md +++ b/docs/st3/schema.md @@ -152,6 +152,7 @@ Custom subjects use `custom/NAMESPACE/NAME`. Custom claims use `custom.NAMESPACE | `member.admission-override` | `host` | `authorized-requester` | `append` | `durable` | `peers!:array`, `reason!:string`, `start!:string` | | | `message.closed` | `message` | `authorized-participant` | `once-per-actor` | `durable` | `acted_for:subject-reference(person)`, `acting_for:subject-reference(agent)`, `delegation:object`, `status!:string` | | | `message.delivered` | `message` | `system-only` | `once-per-actor` | `durable` | `recipient:subject-reference`, `runtime_id:string`, `status!:string`, `transport:string` | `message` | +| `message.expired` | `message` | `system-only` | `once` | `durable` | `reason!:string`, `status!:string` | | | `message.read` | `message` | `authorized-participant` | `once-per-actor` | `durable` | `acting_for:subject-reference(agent)`, `status!:string` | | | `message.sent` | `message` | `ordinary-client` | `once` | `durable` | `assistant_grant_id:string`, `assistant_owner:subject-reference`, `assistant_session_actor:string`, `attachments:array`, `content:string`, `from:subject-reference`, `in_reply_to:subject-reference`, `sender_class:string`, `session_id:string`, `status!:string`, `tags:array`, `title:string`, `to:subject-reference` | `message` | | `message.staged` | `message` | `system-only` | `once-per-actor` | `durable` | `recipient:subject-reference`, `runtime_id:string`, `status!:string`, `transport:string` | `message` | diff --git a/docs/st3/seat-deploys.md b/docs/st3/seat-deploys.md index 4dde7323c63..888e187e27f 100644 --- a/docs/st3/seat-deploys.md +++ b/docs/st3/seat-deploys.md @@ -128,8 +128,13 @@ New seats receive `ST3_MAILBOX_TRANSPORT=push`. Each delivery component connects over the local daemon Unix socket. The stream sends the full seat record and pushes new mail. Every connection starts a new automatic-delivery boundary. Never-offered pre-connection mail sent within the preceding seven days is eligible for recovery; older mail stays held in the graph -mailbox. Of that recovery backlog, mail older than one hour is admitted oldest first, at most five -messages per connection. Mail at most one hour old and new live mail do not consume that cap. +mailbox. Of that recovery backlog, mail older than one hour is admitted oldest first, in batches of +at most five. Only one batch is outstanding per connection: the next batch opens when every +member is delivered or withdrawn. A terminally closed message is withdrawn, not delivered; +withdrawal creates no delivery receipt. Staging or offering a message does not release a slot; +one stuck, non-closed member holds the whole next batch. Mail at most one hour old and new live +mail do not consume that budget. Delivery or withdrawal wakes the stream to admit the next +batch without a reconnect. Both age boundaries use the original send time relative to connection start, including late imports. Previously staged or delivered-but-unread mail stays held regardless of age; a missing receipt never authorizes another offer. SQLite fences both subscriptions and receipts to the live runtime @@ -148,15 +153,27 @@ No push component projects message bodies into `resources/inbox` or `resources/a `delivered` records native transport acceptance, not model consumption. A boot or channel reconnect never authorizes another offer of previously offered mail. Polling drivers use the same seven-day -recovery window and five-message oldest-first backlog cap. Their connection boundary is the -consumer's boot/session floor; the daemon's store keeps one in-memory budget per recipient and -replaces it when that floor changes. Pagination and filtered reads share the budget. Admitted mail -remains visible through the current boot's staging attempt until its receipts finish; their local +recovery window and settlement-gated batches of at most five oldest backlog messages. Their +connection boundary is the consumer's boot/session floor; the daemon's store keeps one bounded +outstanding batch per recipient and replaces it when that floor changes. Pagination and filtered +reads share that batch; a stale boot cannot refill it. Admitted mail remains visible through the +current boot's staging attempt until its receipts finish; their local archive projection removes old native inbox files without closing the graph messages. An agent can explicitly inspect that retained mail with `fractal conversations ls --as "$ST_AGENT"` and read it with `fractal conversations read MESSAGE --as "$ST_AGENT"`. New mail on the live connection continues to arrive normally, with the channel's usual handoff deduplication and receipt handling. +The registry also accepts a durable graph `message.expired` outcome, written only by +`daemon/runtime`, with reason `cutoff-never-offered`, `cutoff-uncertain-attempt`, or +`no-live-recipient`. This reader contract does not enable automatic expiration or change +mailbox recovery. Expiry is not a delivered/read receipt; later actual delivery or read evidence +supersedes the outcome without removing the expiry claim from history. Harness receipts cannot +write expiry, and the messages shard still replicates only `message.sent`. Outcome readers, +projections and sender surfaces must be deployed to every pinned host before any expiry writer +is enabled; older registries retain the new kind as unknown rather than interpreting its outcome. +The writer must also enforce original-accepting-member authority before activation; replicated +expiry admission here checks the daemon actor, not that future writer prerequisite. + Claude distinguishes native queue acceptance (`queue-operation/enqueue`, which records `delivered`) from consumption (a user transcript entry, including `isMeta`, or an explicit `queue-operation/remove` with `reason: absorbed_mid_turn`, which records `read`). Receipt matching diff --git a/fixtures/convergence/README.md b/fixtures/convergence/README.md index 0d35ae1a393..7f2ad6ae876 100644 --- a/fixtures/convergence/README.md +++ b/fixtures/convergence/README.md @@ -235,3 +235,15 @@ The first characterization run failed only the four stale v14 trim expectations. `claims-v16.json` adds `runtime.refresh` and optional receipt `launch_contract` fields; v1–v15 stay frozen. Generate with `cargo run -p fractal-schema --example claims_json -- fixtures/convergence/claims-v16.json`. Shared RNG draws shift: all 32 fixed seeds converge; 30 trim, exactly `[2, 34]` never trim (27 now trims). Refresh claims stay whole. + +`claims-v18.json` captures the complete registry with `message.expired`, the durable, +runtime-authored mail expiry outcome. Versions 1 through 17 stay frozen. Generate it +from the merged expiry registry with +`cargo run -p fractal-schema --example claims_json -- fixtures/convergence/claims-v18.json`; +the example serializes `fractal_schema::registry().claims` with +`serde_json::to_string_pretty`, followed by a newline. Do not hand-edit the snapshot. +The simulation selects v18. Expiry draws select an existing sent message in stable +subject order and use `daemon/runtime`; native admission still rejects mail with a +terminal receipt. The focused expiry regression generates the outcome through the +same path as random schedules and checks native admission and replication to the +oracle, rather than treating corpus membership as write coverage. diff --git a/fixtures/convergence/claims-v18.json b/fixtures/convergence/claims-v18.json new file mode 100644 index 00000000000..da97c02df0d --- /dev/null +++ b/fixtures/convergence/claims-v18.json @@ -0,0 +1,10946 @@ +{ + "agent.account": { + "kind": "agent.account", + "subjects": [ + "agent" + ], + "fields": { + "account": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "account" + ] + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "state-transition", + "projection": "agents", + "wakes_reconciler": false + }, + "agent.helper-launched": { + "kind": "agent.helper-launched", + "subjects": [ + "agent" + ], + "fields": { + "credential_home_label": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "declaration": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "helper_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launcher": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "operation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true, + "retention": "local" + }, + "agent.launcher-bound": { + "kind": "agent.launcher-bound", + "subjects": [ + "agent" + ], + "fields": { + "creation_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "credential_home": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "desired_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launcher": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true, + "retention": "local" + }, + "agent.placement.source-offline": { + "kind": "agent.placement.source-offline", + "subjects": [ + "agent" + ], + "fields": { + "desired_token": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "destination": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sources": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true + }, + "agent.presence": { + "kind": "agent.presence", + "subjects": [ + "agent" + ], + "fields": { + "presence": { + "value_type": "string", + "required": true, + "values": [ + "available", + "busy", + "dnd", + "offline" + ], + "immutable": false, + "reference": false + }, + "reachability": { + "value_type": "string", + "required": false, + "values": [ + "reachable", + "unreachable", + "indeterminate" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true + }, + "agent.queue.moved": { + "kind": "agent.queue.moved", + "subjects": [ + "agent" + ], + "fields": { + "anchor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + }, + "placement": { + "value_type": "string", + "required": true, + "values": [ + "top", + "bottom", + "before", + "after" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "run": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true + }, + "agent.renamed": { + "kind": "agent.renamed", + "subjects": [ + "agent" + ], + "fields": { + "declaration": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "from": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "mission" + ] + }, + "owner_host": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "preserved_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "previous_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "to": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "mission" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true + }, + "agent.retired": { + "kind": "agent.retired", + "subjects": [ + "agent" + ], + "fields": { + "declaration": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "desired_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "owner_host": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "retired_by": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person", + "daemon" + ] + }, + "runtime_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "successor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "transfer": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "agents", + "wakes_reconciler": true + }, + "arrangement.edited": { + "kind": "arrangement.edited", + "subjects": [ + "arrangement" + ], + "fields": { + "action_digest": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "action_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operations": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "owner": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "version": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "ordinary-client", + "cardinality": "append", + "projection": "arrangements", + "wakes_reconciler": false + }, + "attention.requested": { + "kind": "attention.requested", + "subjects": [ + "attention" + ], + "fields": { + "closed_by": { + "value_type": "string", + "required": false, + "values": [ + "person", + "st" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "warning", + "error" + ], + "immutable": false, + "reference": false + }, + "step": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "step-run" + ] + }, + "step_attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "targets": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "title": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "until": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once", + "projection": "attention", + "wakes_reconciler": false + }, + "attention.resolved": { + "kind": "attention.resolved", + "subjects": [ + "attention" + ], + "fields": { + "outcome": { + "value_type": "string", + "required": true, + "values": [ + "resolved", + "dismissed", + "withdrawn" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once", + "projection": "attention", + "wakes_reconciler": false + }, + "checkpoint.excused": { + "kind": "checkpoint.excused", + "subjects": [ + "checkpoint-excusal" + ], + "fields": { + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "writer": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "checkpoints", + "wakes_reconciler": false + }, + "checkpoint.sealed": { + "kind": "checkpoint.sealed", + "subjects": [ + "checkpoint" + ], + "fields": { + "build": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "checkpoint_protocol": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "cut_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "participants": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "rules_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sealed_count": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "sealed_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "checkpoints", + "wakes_reconciler": false + }, + "checkpoint.verified": { + "kind": "checkpoint.verified", + "subjects": [ + "checkpoint" + ], + "fields": { + "build": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "checkpoint_protocol": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "cut_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "drop_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "dropped_claims": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "dropped_envelopes": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "graph_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "participants": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "reader_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "retained_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "rules_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sealed_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "checkpoints", + "wakes_reconciler": false + }, + "client.action-result": { + "kind": "client.action-result", + "subjects": [ + "custom" + ], + "fields": { + "authority_actor": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "result": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": true, + "retention": "local" + }, + "client.hang": { + "kind": "client.hang", + "subjects": [ + "client" + ], + "fields": { + "app_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "capture_source": { + "value_type": "string", + "required": true, + "values": [ + "js", + "native-marker", + "metrickit" + ], + "immutable": false, + "reference": false + }, + "captured_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "event_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id_basis": { + "value_type": "string", + "required": true, + "values": [ + "process", + "metric-interval" + ], + "immutable": false, + "reference": false + }, + "native_build": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "occurred_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "occurrence_time_basis": { + "value_type": "string", + "required": true, + "values": [ + "exact", + "metric-interval-end" + ], + "immutable": false, + "reference": false + }, + "os_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "paired_device": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "pairing_grant": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "payload": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "person_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "platform": { + "value_type": "string", + "required": true, + "values": [ + "ios" + ], + "immutable": false, + "reference": false + }, + "runtime_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "info", + "warning", + "error", + "fatal" + ], + "immutable": false, + "reference": false + }, + "update_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "local" + }, + "client.js-error": { + "kind": "client.js-error", + "subjects": [ + "client" + ], + "fields": { + "app_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "capture_source": { + "value_type": "string", + "required": true, + "values": [ + "js", + "native-marker", + "metrickit" + ], + "immutable": false, + "reference": false + }, + "captured_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "event_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id_basis": { + "value_type": "string", + "required": true, + "values": [ + "process", + "metric-interval" + ], + "immutable": false, + "reference": false + }, + "native_build": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "occurred_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "occurrence_time_basis": { + "value_type": "string", + "required": true, + "values": [ + "exact", + "metric-interval-end" + ], + "immutable": false, + "reference": false + }, + "os_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "paired_device": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "pairing_grant": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "payload": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "person_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "platform": { + "value_type": "string", + "required": true, + "values": [ + "ios" + ], + "immutable": false, + "reference": false + }, + "runtime_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "info", + "warning", + "error", + "fatal" + ], + "immutable": false, + "reference": false + }, + "update_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "local" + }, + "client.launch": { + "kind": "client.launch", + "subjects": [ + "client" + ], + "fields": { + "app_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "capture_source": { + "value_type": "string", + "required": true, + "values": [ + "js", + "native-marker", + "metrickit" + ], + "immutable": false, + "reference": false + }, + "captured_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "event_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id_basis": { + "value_type": "string", + "required": true, + "values": [ + "process", + "metric-interval" + ], + "immutable": false, + "reference": false + }, + "native_build": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "occurred_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "occurrence_time_basis": { + "value_type": "string", + "required": true, + "values": [ + "exact", + "metric-interval-end" + ], + "immutable": false, + "reference": false + }, + "os_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "paired_device": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "pairing_grant": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "payload": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "person_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "platform": { + "value_type": "string", + "required": true, + "values": [ + "ios" + ], + "immutable": false, + "reference": false + }, + "runtime_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "info", + "warning", + "error", + "fatal" + ], + "immutable": false, + "reference": false + }, + "update_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "local" + }, + "client.native-crash": { + "kind": "client.native-crash", + "subjects": [ + "client" + ], + "fields": { + "app_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "capture_source": { + "value_type": "string", + "required": true, + "values": [ + "js", + "native-marker", + "metrickit" + ], + "immutable": false, + "reference": false + }, + "captured_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "event_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id_basis": { + "value_type": "string", + "required": true, + "values": [ + "process", + "metric-interval" + ], + "immutable": false, + "reference": false + }, + "native_build": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "occurred_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "occurrence_time_basis": { + "value_type": "string", + "required": true, + "values": [ + "exact", + "metric-interval-end" + ], + "immutable": false, + "reference": false + }, + "os_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "paired_device": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "pairing_grant": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "payload": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "person_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "platform": { + "value_type": "string", + "required": true, + "values": [ + "ios" + ], + "immutable": false, + "reference": false + }, + "runtime_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "info", + "warning", + "error", + "fatal" + ], + "immutable": false, + "reference": false + }, + "update_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "local" + }, + "client.operation-authorized": { + "kind": "client.operation-authorized", + "subjects": [ + "custom" + ], + "fields": { + "completion_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "record_hash": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "record_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": true + }, + "custom.client.action-result": { + "kind": "custom.client.action-result", + "subjects": [ + "custom" + ], + "fields": { + "authority_actor": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "result": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": true + }, + "daemon.diagnostic": { + "kind": "daemon.diagnostic", + "subjects": [ + "daemon" + ], + "fields": { + "code": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "warning", + "error" + ], + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "daemons", + "wakes_reconciler": true, + "retention": "local" + }, + "daemon.started": { + "kind": "daemon.started", + "subjects": [ + "daemon" + ], + "fields": { + "features": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "pid": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "schema": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "schema_digest": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "running" + ], + "immutable": false, + "reference": false + }, + "version": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "daemons", + "wakes_reconciler": true, + "source_kdl": [ + "reset" + ] + }, + "delivery.hold": { + "kind": "delivery.hold", + "subjects": [ + "agent" + ], + "fields": { + "held": { + "value_type": "boolean", + "required": true, + "immutable": false, + "reference": false + }, + "legacy_adoption": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "until_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "state-transition", + "projection": "delivery-holds", + "wakes_reconciler": false + }, + "deploy.compat-signoff": { + "kind": "deploy.compat-signoff", + "subjects": [ + "resource" + ], + "fields": { + "newCommit": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "oldCommit": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "trainId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "deploy.go": { + "kind": "deploy.go", + "subjects": [ + "resource" + ], + "fields": { + "actor": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "combined": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "compatSignoffId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "expiresAt": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "failureRestore": { + "value_type": "boolean", + "required": true, + "immutable": false, + "reference": false + }, + "host": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "issuedAt": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "new": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "old": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": true, + "values": [ + "install", + "rollback" + ], + "immutable": false, + "reference": false + }, + "previewDigest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "trainId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "deploy.go-consumed": { + "kind": "deploy.go-consumed", + "subjects": [ + "resource" + ], + "fields": { + "goId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": true, + "values": [ + "install", + "rollback" + ], + "immutable": false, + "reference": false + }, + "result": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "deploy.go-revoked": { + "kind": "deploy.go-revoked", + "subjects": [ + "resource" + ], + "fields": { + "goId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "deploy.stop": { + "kind": "deploy.stop", + "subjects": [ + "resource" + ], + "fields": { + "hosts": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "liftCondition": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "scope": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "stopId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "deploy.stop-lifted": { + "kind": "deploy.stop-lifted", + "subjects": [ + "resource" + ], + "fields": { + "releaseGoId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "stopId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "doc.bound": { + "kind": "doc.bound", + "subjects": [ + "doc" + ], + "fields": { + "executable": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "name": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "size": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "documents", + "wakes_reconciler": true, + "source_kdl": [ + "doc" + ] + }, + "eval.verdict": { + "kind": "eval.verdict", + "subjects": [ + "mission-run" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "residue": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "verdict": { + "value_type": "string", + "required": true, + "values": [ + "pass", + "fail", + "void" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "evals", + "wakes_reconciler": true + }, + "fault.dismissed": { + "kind": "fault.dismissed", + "subjects": [ + "agent", + "exec", + "pty", + "observer", + "subscription", + "schedule", + "daemon", + "machine", + "step-run", + "mission-run", + "loop-run", + "resource", + "checkpoint" + ], + "fields": { + "episode": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "operational", + "wakes_reconciler": true + }, + "file.observed": { + "kind": "file.observed", + "subjects": [ + "file" + ], + "fields": { + "blob_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "content": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "content_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "path": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "observed", + "unreadable" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "files", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "fleet.invite-created": { + "kind": "fleet.invite-created", + "subjects": [ + "fleet-invite" + ], + "fields": { + "created_by": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "expires_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "name": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "sponsor": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "host" + ] + }, + "transports": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.invite-redeemed": { + "kind": "fleet.invite-redeemed", + "subjects": [ + "fleet-invite" + ], + "fields": { + "member_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "name": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.invite-revoked": { + "kind": "fleet.invite-revoked", + "subjects": [ + "fleet-invite" + ], + "fields": { + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "revoked_by": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.member-admitted": { + "kind": "fleet.member-admitted", + "subjects": [ + "host" + ], + "fields": { + "admitted_by": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "fleet_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "invite": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "fleet-invite" + ] + }, + "member_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": true, + "values": [ + "listening", + "dial-out" + ], + "immutable": false, + "reference": false + }, + "node_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "sponsor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "host" + ] + }, + "via": { + "value_type": "string", + "required": true, + "values": [ + "anchor", + "invite", + "migration" + ], + "immutable": false, + "reference": false + }, + "writer_floor": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.member-endpoints": { + "kind": "fleet.member-endpoints", + "subjects": [ + "host" + ], + "fields": { + "build": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "endpoints": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "member_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": true, + "values": [ + "listening", + "dial-out" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.member-left": { + "kind": "fleet.member-left", + "subjects": [ + "host" + ], + "fields": { + "high_water": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "member_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.member-removed": { + "kind": "fleet.member-removed", + "subjects": [ + "host" + ], + "fields": { + "high_water": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "member_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "removed_by": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.shard-bounds": { + "kind": "fleet.shard-bounds", + "subjects": [ + "host" + ], + "fields": { + "member_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "shard_high_water": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "gate.head-bound": { + "kind": "gate.head-bound", + "subjects": [ + "gate-operation" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "author": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "boundary": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "change": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "generation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "head": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "mission_revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "observation": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "owner": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "previous_binding": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request_operation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "gate-operation" + ] + }, + "review_targets": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "scope": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "step_definition": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.remediation-attached": { + "kind": "gate.remediation-attached", + "subjects": [ + "gate-operation" + ], + "fields": { + "attempt_receipt": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "author_attempt": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "author_generation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "author_step": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "step-run" + ] + }, + "binding_claim": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "boundary_attempt": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "evidence": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "finding_refs": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "gate_subject": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "gate-operation" + ] + }, + "generation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reviewed_head": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "verdict": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.requested": { + "kind": "gate.requested", + "subjects": [ + "gate-operation" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "author": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "baseline": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "binding_claim": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "capability_expires_at": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "capability_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "change": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "decisions": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "delta_ref": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "gate": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "gate_subject": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "gate-operation" + ] + }, + "generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "head": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "mission_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": false, + "values": [ + "approve", + "feedback" + ], + "immutable": false, + "reference": false + }, + "model": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "owner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "question": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "review_targets": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "runner": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "scope": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "step_definition": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "token_budget": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "tools": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.result": { + "kind": "gate.result", + "subjects": [ + "gate-operation" + ], + "fields": { + "acted_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "baseline": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "decision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "delegation": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "field": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "gate": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "stage": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "token_usage": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "value": { + "value_type": "any", + "required": false, + "immutable": false, + "reference": false + }, + "verdict": { + "value_type": "string", + "required": true, + "values": [ + "pass", + "fail", + "error", + "feedback" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "capability-holder", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.review-verdict": { + "kind": "gate.review-verdict", + "subjects": [ + "gate-operation" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "binding_claim": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "carry_forward_from": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "change": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "evidence": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "finding_refs": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "gate_subject": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "gate-operation" + ] + }, + "generation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "review_targets": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "reviewed_head": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "scope": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "source": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "verdict": { + "value_type": "string", + "required": true, + "values": [ + "approved", + "changes-requested", + "abstained" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "capability-holder", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.review-withdrawn": { + "kind": "gate.review-withdrawn", + "subjects": [ + "gate-operation" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "authorization_evidence": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "binding_claim": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "gate_subject": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "gate-operation" + ] + }, + "generation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "previous_binding": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "verdict": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "withdrawing_actor": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.reviewer-reassigned": { + "kind": "gate.reviewer-reassigned", + "subjects": [ + "gate-operation" + ], + "fields": { + "owner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "previous_reviewer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "request": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "github.event": { + "kind": "github.event", + "subjects": [ + "github-event" + ], + "fields": { + "at": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "detail": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "event": { + "value_type": "string", + "required": true, + "values": [ + "opened", + "closed", + "merged", + "reopened", + "head-changed", + "became-draft", + "became-ready", + "merge-queue-entered", + "merge-queue-left", + "checks-passed", + "checks-failed", + "comment", + "review" + ], + "immutable": false, + "reference": false + }, + "head_sha": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "item": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "resource" + ] + }, + "observer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "observer" + ] + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "resources", + "wakes_reconciler": true + }, + "github.posted": { + "kind": "github.posted", + "subjects": [ + "github-post" + ], + "fields": { + "agent": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "id": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "item": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "kind": { + "value_type": "string", + "required": true, + "values": [ + "comment", + "review" + ], + "immutable": false, + "reference": false + }, + "login": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "repository": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "url": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "subscriptions", + "wakes_reconciler": true + }, + "glass.deleted": { + "kind": "glass.deleted", + "subjects": [ + "glass" + ], + "fields": { + "base_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "replaced_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "glasses", + "wakes_reconciler": false + }, + "glass.upserted": { + "kind": "glass.upserted", + "subjects": [ + "glass" + ], + "fields": { + "base_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "body": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "replaced_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "glasses", + "wakes_reconciler": false + }, + "harness-question.observed": { + "kind": "harness-question.observed", + "subjects": [ + "harness-question" + ], + "fields": { + "agent": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "native_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "questions": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "runtime_incarnation": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "session": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "harness-question.resolved": { + "kind": "harness-question.resolved", + "subjects": [ + "harness-question" + ], + "fields": { + "answers": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "receipt": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "answered", + "answered_elsewhere", + "expired", + "cancelled" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "harness.context-clear.requested": { + "kind": "harness.context-clear.requested", + "subjects": [ + "agent" + ], + "fields": { + "context_epoch": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation_status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "harness-control", + "wakes_reconciler": true + }, + "harness.context-clear.result": { + "kind": "harness.context-clear.result", + "subjects": [ + "agent" + ], + "fields": { + "context_epoch": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "result": { + "value_type": "string", + "required": true, + "values": [ + "confirmed", + "failed", + "indeterminate" + ], + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "harness-control", + "wakes_reconciler": true + }, + "harness.diagnostic": { + "kind": "harness.diagnostic", + "subjects": [ + "agent" + ], + "fields": { + "acted_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "auth_attention_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "code": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "delegation": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "matched_line": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "observed_since_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "ownership_sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "provider_auth_sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "retry_after_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "retry_attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "selector_scope": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": false, + "values": [ + "warning", + "error" + ], + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "step_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "step-run" + ] + }, + "wake_attempts": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": false + }, + "harness.inventory.observed": { + "kind": "harness.inventory.observed", + "subjects": [ + "agent" + ], + "fields": { + "commands": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "dynamic_commands": { + "value_type": "string", + "required": true, + "values": [ + "supported", + "unsupported", + "unavailable" + ], + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": true, + "values": [ + "omp" + ], + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "observed_at": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "latest" + }, + "harness.limits": { + "kind": "harness.limits", + "subjects": [ + "agent" + ], + "fields": { + "account": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "account_ref": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "five_hour_percent": { + "value_type": "number", + "required": false, + "immutable": false, + "reference": false + }, + "five_hour_resets_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "measured_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "plan": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "weekly_percent": { + "value_type": "number", + "required": false, + "immutable": false, + "reference": false + }, + "weekly_resets_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": false + }, + "harness.observed": { + "kind": "harness.observed", + "subjects": [ + "agent" + ], + "fields": { + "ask": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "background_jobs": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "blocked_on": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "blocking": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "evidence_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "exit": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "input_buffer": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "observed_at_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "observed_since_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "ownership_sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "provider_auth": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "provider_auth_sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "quiescent": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "rollout_operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": true, + "values": [ + "starting", + "ready", + "idle", + "working", + "blocked", + "ended", + "indeterminate" + ], + "immutable": false, + "reference": false + }, + "status_transition": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "transition_sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "transport": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": true, + "retention": "latest" + }, + "harness.session-file": { + "kind": "harness.session-file", + "subjects": [ + "agent" + ], + "fields": { + "account_ref": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "agent": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "desired_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "discovery_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "modified_at": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "path": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "source_session": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "values": [ + "active", + "inactive", + "missing", + "unknown" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": false + }, + "harness.telemetry": { + "kind": "harness.telemetry", + "subjects": [ + "agent" + ], + "fields": { + "driver": { + "value_type": "string", + "required": true, + "values": [ + "claude" + ], + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "signals": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "unit": { + "value_type": "string", + "required": true, + "values": [ + "hook" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "local" + }, + "harness.timeline": { + "kind": "harness.timeline", + "subjects": [ + "agent" + ], + "fields": { + "body": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "entry_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "entry_type": { + "value_type": "string", + "required": true, + "values": [ + "message", + "content", + "tool_call", + "tool_result", + "status", + "error", + "usage", + "redaction", + "truncation" + ], + "immutable": false, + "reference": false + }, + "final": { + "value_type": "boolean", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "observed_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": true, + "values": [ + "append", + "replace", + "finalize" + ], + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "role": { + "value_type": "string", + "required": true, + "values": [ + "system", + "user", + "assistant", + "tool" + ], + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "source_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": false, + "retention": "local" + }, + "harness.todo.observed": { + "kind": "harness.todo.observed", + "subjects": [ + "agent" + ], + "fields": { + "harness": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "observed_at": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "phases": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "source_op": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "totals": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "truncated": { + "value_type": "boolean", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "latest" + }, + "harness.usage": { + "kind": "harness.usage", + "subjects": [ + "agent" + ], + "fields": { + "account": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "cache_write_1h_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "cache_write_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "cached_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "compactions": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "context_used_percent": { + "value_type": "number", + "required": false, + "immutable": false, + "reference": false + }, + "context_used_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "context_window_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "cost": { + "value_type": "number", + "required": false, + "immutable": false, + "reference": false + }, + "cost_microusd": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "currency": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "input_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "last_compaction_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "last_compaction_trigger": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "model": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "native_session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "observed_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "output_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "owner_run": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "owner_step": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "pricing": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "pricing_provenance": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "reported_cost_microusd": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "semantics": { + "value_type": "string", + "required": true, + "values": [ + "context_occupancy", + "session_cumulative", + "response", + "response_rollup" + ], + "immutable": false, + "reference": false + }, + "total_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "unpriced_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": false, + "retention": "latest" + }, + "intent.desired": { + "kind": "intent.desired", + "subjects": [ + "*" + ], + "fields": { + "accountable": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "desired": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "kind": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "state-transition", + "projection": "desired", + "wakes_reconciler": true, + "source_kdl": [ + "account", + "agent", + "doc", + "exec", + "host", + "lane", + "message", + "observer", + "mission", + "mission-run", + "planning-session", + "pty", + "resource", + "schedule", + "step", + "stop", + "subscription" + ] + }, + "lane.approved": { + "kind": "lane.approved", + "subjects": [ + "lane" + ], + "fields": { + "entry": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "lanes", + "wakes_reconciler": false + }, + "lane.joined": { + "kind": "lane.joined", + "subjects": [ + "lane" + ], + "fields": { + "entry": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "lanes", + "wakes_reconciler": false + }, + "lane.left": { + "kind": "lane.left", + "subjects": [ + "lane" + ], + "fields": { + "entry": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "outcome": { + "value_type": "string", + "required": true, + "values": [ + "completed", + "removed" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "lanes", + "wakes_reconciler": false + }, + "lane.marked": { + "kind": "lane.marked", + "subjects": [ + "lane" + ], + "fields": { + "detail": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "entry": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "head": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": true, + "values": [ + "waiting", + "held", + "ready", + "running" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "lanes", + "wakes_reconciler": false + }, + "lane.moved": { + "kind": "lane.moved", + "subjects": [ + "lane" + ], + "fields": { + "anchor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "entry": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "placement": { + "value_type": "string", + "required": true, + "values": [ + "top", + "bottom", + "before", + "after" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "lanes", + "wakes_reconciler": false + }, + "loop.round-dispatch": { + "kind": "loop.round-dispatch", + "subjects": [ + "loop-run" + ], + "fields": { + "candidate": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "dispatch": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "item_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "mission_run": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "round": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "rescheduled" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "loops", + "wakes_reconciler": true, + "source_kdl": [ + "loop", + "round" + ] + }, + "loop.round-result": { + "kind": "loop.round-result", + "subjects": [ + "loop-run" + ], + "fields": { + "candidate": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "feedback": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "doc" + ] + }, + "item": { + "value_type": "any", + "required": false, + "immutable": false, + "reference": false + }, + "metrics": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "mission_run": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "round": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "completed", + "failed", + "discarded" + ], + "immutable": false, + "reference": false + }, + "token_usage": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "loops", + "wakes_reconciler": true, + "source_kdl": [ + "loop", + "round" + ] + }, + "loop.state": { + "kind": "loop.state", + "subjects": [ + "loop-run" + ], + "fields": { + "best_metrics": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "best_round": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "feedback": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "doc" + ] + }, + "items": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "round": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "running", + "completed", + "failed", + "exhausted" + ], + "immutable": false, + "reference": false + }, + "winner": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "loops", + "wakes_reconciler": true, + "source_kdl": [ + "loop" + ] + }, + "member.admission-override": { + "kind": "member.admission-override", + "subjects": [ + "host" + ], + "fields": { + "peers": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "start": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": true + }, + "message.closed": { + "kind": "message.closed", + "subjects": [ + "message" + ], + "fields": { + "acted_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "acting_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "delegation": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "closed" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once-per-actor", + "projection": "messages", + "wakes_reconciler": true + }, + "message.delivered": { + "kind": "message.delivered", + "subjects": [ + "message" + ], + "fields": { + "recipient": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "delivered" + ], + "immutable": false, + "reference": false + }, + "transport": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once-per-actor", + "projection": "messages", + "wakes_reconciler": true, + "source_kdl": [ + "message" + ] + }, + "message.expired": { + "kind": "message.expired", + "subjects": [ + "message" + ], + "fields": { + "reason": { + "value_type": "string", + "required": true, + "values": [ + "cutoff-never-offered", + "cutoff-uncertain-attempt", + "no-live-recipient" + ], + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "expired" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "messages", + "wakes_reconciler": true + }, + "message.read": { + "kind": "message.read", + "subjects": [ + "message" + ], + "fields": { + "acting_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "read" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once-per-actor", + "projection": "messages", + "wakes_reconciler": true + }, + "message.sent": { + "kind": "message.sent", + "subjects": [ + "message" + ], + "fields": { + "assistant_grant_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "assistant_owner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "assistant_session_actor": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "attachments": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "content": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "from": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "in_reply_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "sender_class": { + "value_type": "string", + "required": false, + "values": [ + "external-assistant" + ], + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "sent" + ], + "immutable": false, + "reference": false + }, + "tags": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "title": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "ordinary-client", + "cardinality": "once", + "projection": "messages", + "wakes_reconciler": true, + "source_kdl": [ + "message" + ] + }, + "message.staged": { + "kind": "message.staged", + "subjects": [ + "message" + ], + "fields": { + "recipient": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "staged" + ], + "immutable": false, + "reference": false + }, + "transport": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once-per-actor", + "projection": "messages", + "wakes_reconciler": true, + "source_kdl": [ + "message" + ] + }, + "mission-run.created": { + "kind": "mission-run.created", + "subjects": [ + "mission-run" + ], + "fields": { + "ad_hoc_title": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "after": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "current_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "deadline_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "default_selector": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "initial_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "inputs": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "mission": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "mission_spec": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "parent_step_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "report_completed": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "report_stalls": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "report_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "root_mission_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "root_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "stalled_after_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "timeout_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "workspace": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "mission-runs", + "wakes_reconciler": true, + "source_kdl": [ + "mission-run" + ] + }, + "mission-run.report-to": { + "kind": "mission-run.report-to", + "subjects": [ + "mission-run" + ], + "fields": { + "report_completed": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "report_stalls": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "report_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "stalled_after_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "mission-runs", + "wakes_reconciler": true + }, + "mission-run.state": { + "kind": "mission-run.state", + "subjects": [ + "mission-run" + ], + "fields": { + "completion": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "finally": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "previous_phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "mission-runs", + "wakes_reconciler": true, + "source_kdl": [ + "mission-run", + "completion", + "finally", + "cancellation" + ] + }, + "mission.produced": { + "kind": "mission.produced", + "subjects": [ + "mission", + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "mission": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "name": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "step_definition": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "capability-holder", + "cardinality": "append", + "projection": "mission-products", + "wakes_reconciler": true, + "source_kdl": [ + "produces" + ] + }, + "mission.provenance": { + "kind": "mission.provenance", + "subjects": [ + "mission" + ], + "fields": { + "mission": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission" + ] + }, + "provenance": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "missions", + "wakes_reconciler": true, + "source_kdl": [ + "provenance" + ] + }, + "mission.published": { + "kind": "mission.published", + "subjects": [ + "mission" + ], + "fields": { + "body": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "missions", + "wakes_reconciler": true, + "source_kdl": [ + "mission" + ] + }, + "mission.renamed": { + "kind": "mission.renamed", + "subjects": [ + "mission" + ], + "fields": { + "declaration": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "from": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "mission" + ] + }, + "owner_host": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "preserved_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "previous_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "to": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "mission" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "missions", + "wakes_reconciler": true + }, + "observer.observed": { + "kind": "observer.observed", + "subjects": [ + "observer" + ], + "fields": { + "attempt": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "changed": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "changed_fields": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "cursor": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "locator": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "next_check_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "observation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "provider": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "resource": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "observers", + "wakes_reconciler": true, + "source_kdl": [ + "observer" + ] + }, + "observer.refresh-requested": { + "kind": "observer.refresh-requested", + "subjects": [ + "observer" + ], + "fields": { + "attempt": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "observers", + "wakes_reconciler": true, + "source_kdl": [ + "refresh" + ] + }, + "observer.state": { + "kind": "observer.state", + "subjects": [ + "observer" + ], + "fields": { + "attempt": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "error_code": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "next_check_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": true, + "values": [ + "healthy", + "degraded", + "unreachable", + "stopped" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "observers", + "wakes_reconciler": true, + "source_kdl": [ + "observer" + ] + }, + "operational.failure": { + "kind": "operational.failure", + "subjects": [ + "agent", + "exec", + "pty", + "observer", + "subscription", + "schedule", + "daemon", + "machine", + "step-run", + "mission-run", + "loop-run", + "resource", + "checkpoint" + ], + "fields": { + "condition": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reproduction": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "severity": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "targets": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "title": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "operational", + "wakes_reconciler": true + }, + "operational.recovered": { + "kind": "operational.recovered", + "subjects": [ + "agent", + "exec", + "pty", + "observer", + "subscription", + "schedule", + "daemon", + "machine", + "step-run", + "mission-run", + "loop-run", + "resource", + "checkpoint" + ], + "fields": { + "episode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "failure": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "operational", + "wakes_reconciler": true + }, + "ordered-membership.edited": { + "kind": "ordered-membership.edited", + "subjects": [ + "arrangement" + ], + "fields": { + "action_digest": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "action_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operations": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "owner": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "ordinary-client", + "cardinality": "append", + "projection": "ordered-memberships", + "wakes_reconciler": false + }, + "owned-set.revised": { + "kind": "owned-set.revised", + "subjects": [ + "owned-set" + ], + "fields": { + "body": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "owned-sets", + "wakes_reconciler": true + }, + "person.delegation-set": { + "kind": "person.delegation-set", + "subjects": [ + "person" + ], + "fields": { + "actions": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "state-transition", + "projection": null, + "wakes_reconciler": false + }, + "person.directive-note-set": { + "kind": "person.directive-note-set", + "subjects": [ + "person" + ], + "fields": { + "expires_at": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "text": { + "value_type": "string", + "required": true, + "nullable": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "state-transition", + "projection": "directive-notes", + "wakes_reconciler": false + }, + "person.rename.completed": { + "kind": "person.rename.completed", + "subjects": [ + "person" + ], + "fields": { + "new_person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "old_person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "report": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "planning-session.approved": { + "kind": "planning-session.approved", + "subjects": [ + "planning-session" + ], + "fields": { + "candidate_revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "kdl": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "markdown": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "mission_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "preview_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "preview_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "variant": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once", + "projection": "planning", + "wakes_reconciler": true + }, + "planning-session.cancelled": { + "kind": "planning-session.cancelled", + "subjects": [ + "planning-session" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once", + "projection": "planning", + "wakes_reconciler": true, + "source_kdl": [ + "cancellation" + ] + }, + "planning-session.candidate-submitted": { + "kind": "planning-session.candidate-submitted", + "subjects": [ + "planning-session" + ], + "fields": { + "candidate_revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "kdl": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "markdown": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "mission_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "variant": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "planning", + "wakes_reconciler": true + }, + "planning-session.previewed": { + "kind": "planning-session.previewed", + "subjects": [ + "planning-session" + ], + "fields": { + "candidate_revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "diff": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "graph": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "mission": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "preview_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "store_index": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "variant": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "planning", + "wakes_reconciler": true + }, + "planning-session.question-answered": { + "kind": "planning-session.question-answered", + "subjects": [ + "planning-session" + ], + "fields": { + "decision_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "expected_revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "explanation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "response": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "planning", + "wakes_reconciler": true + }, + "planning-session.question-requested": { + "kind": "planning-session.question-requested", + "subjects": [ + "planning-session" + ], + "fields": { + "decision_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "decision_type": { + "value_type": "string", + "required": true, + "values": [ + "boolean", + "single-choice", + "multiple-choice", + "rank" + ], + "immutable": false, + "reference": false + }, + "options": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "planner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "question": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "planning", + "wakes_reconciler": true + }, + "planning-session.revision-requested": { + "kind": "planning-session.revision-requested", + "subjects": [ + "planning-session" + ], + "fields": { + "candidate_revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "feedback": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "variant": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "planning", + "wakes_reconciler": true, + "source_kdl": [ + "feedback" + ] + }, + "planning-session.started": { + "kind": "planning-session.started", + "subjects": [ + "planning-session" + ], + "fields": { + "mission": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "planner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "planner_config": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "target_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "target_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "workspace": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once", + "projection": "planning", + "wakes_reconciler": true, + "source_kdl": [ + "planning-session" + ] + }, + "principal.key-granted": { + "kind": "principal.key-granted", + "subjects": [ + "person", + "agent" + ], + "fields": { + "issuer": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "issuer_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "label": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "role": { + "value_type": "string", + "required": true, + "values": [ + "root", + "device", + "agent", + "plugin" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "principals", + "wakes_reconciler": false + }, + "principal.key-revoked": { + "kind": "principal.key-revoked", + "subjects": [ + "person", + "agent" + ], + "fields": { + "key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "principals", + "wakes_reconciler": false + }, + "publication.operation": { + "kind": "publication.operation", + "subjects": [ + "*" + ], + "fields": { + "action": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "accepted" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": true, + "source_kdl": [ + "revision", + "reset", + "cancellation", + "refresh", + "feedback" + ] + }, + "reconcile.fault": { + "kind": "reconcile.fault", + "subjects": [ + "daemon", + "mission-run", + "observer", + "schedule", + "step-run", + "subscription" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "scope": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "faulted", + "recovered" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "daemons", + "wakes_reconciler": false + }, + "record.repaired": { + "kind": "record.repaired", + "subjects": [ + "repair" + ], + "fields": { + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "record": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "replacement": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "ordinary-client", + "cardinality": "once", + "projection": "replication", + "wakes_reconciler": false, + "source_kdl": [ + "repair" + ] + }, + "render.applied": { + "kind": "render.applied", + "subjects": [ + "agent", + "exec", + "pty" + ], + "fields": { + "warnings": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "writes": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": false, + "retention": "local" + }, + "repair.applied": { + "kind": "repair.applied", + "subjects": [ + "repair" + ], + "fields": { + "affected_subjects": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "item_count": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "token": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "repairs", + "wakes_reconciler": true + }, + "resource.observed": { + "kind": "resource.observed", + "subjects": [ + "resource" + ], + "fields": { + "attribution_only": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "kind": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "observed_at": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "any", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": true, + "write_policy": "ordinary-client", + "cardinality": "append", + "projection": "resources", + "wakes_reconciler": true, + "source_kdl": [ + "resource" + ] + }, + "revision-proposal.applied": { + "kind": "revision-proposal.applied", + "subjects": [ + "revision-proposal" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "successor_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "revision-proposals", + "wakes_reconciler": true + }, + "revision-proposal.approved": { + "kind": "revision-proposal.approved", + "subjects": [ + "revision-proposal" + ], + "fields": { + "all_approved": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "preview_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once-per-actor", + "projection": "revision-proposals", + "wakes_reconciler": true + }, + "revision-proposal.cancelled": { + "kind": "revision-proposal.cancelled", + "subjects": [ + "revision-proposal" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once", + "projection": "revision-proposals", + "wakes_reconciler": true + }, + "revision-proposal.created": { + "kind": "revision-proposal.created", + "subjects": [ + "revision-proposal" + ], + "fields": { + "candidate_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "compatible_steps": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "cutover": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "preview_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reviewers": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "source_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once", + "projection": "revision-proposals", + "wakes_reconciler": true + }, + "rule.audited": { + "kind": "rule.audited", + "subjects": [ + "rule" + ], + "fields": { + "action": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "actor": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "rule": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "target": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "rules", + "wakes_reconciler": false + }, + "rule.set": { + "kind": "rule.set", + "subjects": [ + "rule" + ], + "fields": { + "actors": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "description": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "except": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "kinds": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": true, + "values": [ + "off", + "audit", + "enforce" + ], + "immutable": false, + "reference": false + }, + "subjects": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "unless_subjects": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "rules", + "wakes_reconciler": false + }, + "run-generation.created": { + "kind": "run-generation.created", + "subjects": [ + "run-generation" + ], + "fields": { + "compatible_steps": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "predecessor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "run-generations", + "wakes_reconciler": true, + "source_kdl": [ + "mission-run", + "revision" + ] + }, + "run-generation.state": { + "kind": "run-generation.state", + "subjects": [ + "run-generation" + ], + "fields": { + "phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "previous_phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "successor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "run-generations", + "wakes_reconciler": true, + "source_kdl": [ + "mission-run", + "step", + "completion", + "finally", + "revision", + "cancellation" + ] + }, + "run-generation.superseded": { + "kind": "run-generation.superseded", + "subjects": [ + "run-generation" + ], + "fields": { + "phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "previous_phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "successor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "run-generations", + "wakes_reconciler": true, + "source_kdl": [ + "revision" + ] + }, + "runtime.action.deadline-reached": { + "kind": "runtime.action.deadline-reached", + "subjects": [ + "agent", + "exec", + "pty", + "gate-operation" + ], + "fields": { + "action": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "blocking": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "code": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "credential_home": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "desired_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "launch_contract": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "native_session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation_status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "rollout": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "rollout_operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid_start_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "signal": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source_host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "terminal": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtime-actions", + "wakes_reconciler": true, + "source_kdl": [ + "stop", + "gate" + ], + "retention": "system-local" + }, + "runtime.action.failed": { + "kind": "runtime.action.failed", + "subjects": [ + "agent", + "exec", + "pty", + "gate-operation" + ], + "fields": { + "action": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "blocking": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "code": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "credential_home": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "desired_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "launch_contract": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "native_session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation_status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "rollout": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "rollout_operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid_start_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "signal": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source_host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "terminal": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtime-actions", + "wakes_reconciler": true, + "source_kdl": [ + "stop", + "gate" + ], + "retention": "system-local" + }, + "runtime.action.requested": { + "kind": "runtime.action.requested", + "subjects": [ + "agent", + "exec", + "pty", + "gate-operation" + ], + "fields": { + "action": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "fresh_session": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "ignore_fit": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "rollout": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "signal": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source_host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "terminal": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "runtime-actions", + "wakes_reconciler": true, + "source_kdl": [ + "stop", + "gate" + ], + "retention": "system-local" + }, + "runtime.action.succeeded": { + "kind": "runtime.action.succeeded", + "subjects": [ + "agent", + "exec", + "pty", + "gate-operation" + ], + "fields": { + "action": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "blocking": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "code": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "credential_home": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "desired_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "launch_contract": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "native_session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation_status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "rollout": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "rollout_operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid_start_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "signal": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source_host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "terminal": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtime-actions", + "wakes_reconciler": true, + "source_kdl": [ + "stop", + "gate" + ], + "retention": "system-local" + }, + "runtime.observed": { + "kind": "runtime.observed", + "subjects": [ + "agent", + "exec", + "pty", + "gate-operation" + ], + "fields": { + "adopted": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "exit_code": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "exit_signal": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reachability": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "restarting_from": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "shutdown_timeout_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "terminal": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": true + }, + "runtime.readiness-deadline-reached": { + "kind": "runtime.readiness-deadline-reached", + "subjects": [ + "agent" + ], + "fields": { + "deadline_unix_ms": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": true, + "retention": "local" + }, + "runtime.reconcile-decision": { + "kind": "runtime.reconcile-decision", + "subjects": [ + "agent", + "exec", + "pty", + "schedule" + ], + "fields": { + "decision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "gate": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "input_number": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "owner_run": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reachability": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "restart_at_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": true + }, + "runtime.refresh": { + "kind": "runtime.refresh", + "subjects": [ + "agent" + ], + "fields": { + "episode": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": true + }, + "runtime.restart-window-reset": { + "kind": "runtime.restart-window-reset", + "subjects": [ + "agent", + "exec", + "pty" + ], + "fields": { + "desired_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": true, + "source_kdl": [ + "reset" + ] + }, + "schedule.occurrence-cancelled": { + "kind": "schedule.occurrence-cancelled", + "subjects": [ + "schedule" + ], + "fields": { + "occurrence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "schedule.occurrence-reached": { + "kind": "schedule.occurrence-reached", + "subjects": [ + "schedule" + ], + "fields": { + "at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "occurrence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "scheduled": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "scheduled_at_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "standby": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "schedule.occurrence-scheduled": { + "kind": "schedule.occurrence-scheduled", + "subjects": [ + "schedule" + ], + "fields": { + "at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "occurrence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "scheduled_at_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "standby": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "schedule.work-failed": { + "kind": "schedule.work-failed", + "subjects": [ + "schedule" + ], + "fields": { + "code": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "schedule.work-requested": { + "kind": "schedule.work-requested", + "subjects": [ + "schedule" + ], + "fields": { + "inputs": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "mission": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission" + ] + }, + "mission_revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "occurrence": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "workspace": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "schedule.work-started": { + "kind": "schedule.work-started", + "subjects": [ + "schedule" + ], + "fields": { + "mission_run": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "sekret.called": { + "kind": "sekret.called", + "subjects": [ + "sekret" + ], + "fields": { + "argv": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "caller": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "cwd": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "grant": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "login": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "person": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "profile": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "seq": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "tty": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "sekrets", + "wakes_reconciler": false, + "retention": "local" + }, + "sekret.changed": { + "kind": "sekret.changed", + "subjects": [ + "sekret" + ], + "fields": { + "at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "caller": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "change": { + "value_type": "string", + "required": true, + "values": [ + "profile-created", + "profile-removed", + "policy-set", + "put", + "unset", + "grant-added", + "grant-removed", + "locked", + "unlocked", + "daemon-registered" + ], + "immutable": false, + "reference": false + }, + "detail": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "person": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "profile": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "seq": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "sekrets", + "wakes_reconciler": false, + "retention": "local" + }, + "sekret.exited": { + "kind": "sekret.exited", + "subjects": [ + "sekret" + ], + "fields": { + "at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "call": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "caller": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "error": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "exit_code": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "person": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "profile": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "seq": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "signal": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "sekrets", + "wakes_reconciler": false, + "retention": "local" + }, + "sekret.refused": { + "kind": "sekret.refused", + "subjects": [ + "sekret" + ], + "fields": { + "argv": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "caller": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "person": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "profile": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "seq": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "sekrets", + "wakes_reconciler": false, + "retention": "local" + }, + "slo.breach-transition": { + "kind": "slo.breach-transition", + "subjects": [ + "resource" + ], + "fields": { + "record": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "slo-breaches", + "wakes_reconciler": true + }, + "step-run.carried": { + "kind": "step-run.carried", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "definition_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "source_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "source_step_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "step-runs", + "wakes_reconciler": true, + "source_kdl": [ + "step" + ] + }, + "step-run.retried": { + "kind": "step-run.retried", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "goals": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "not_before_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "step-runs", + "wakes_reconciler": true, + "source_kdl": [ + "step" + ] + }, + "step-run.state": { + "kind": "step-run.state", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "step-runs", + "wakes_reconciler": true, + "source_kdl": [ + "step" + ] + }, + "subagent.appeared": { + "kind": "subagent.appeared", + "subjects": [ + "agent" + ], + "fields": { + "description": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "lease_expires_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "started_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "step_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "step-run" + ] + }, + "subagent_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "subagent_type": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "subagents", + "wakes_reconciler": false + }, + "subagent.ended": { + "kind": "subagent.ended", + "subjects": [ + "agent" + ], + "fields": { + "cache_write_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "cached_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "duration_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "ended_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "input_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "outcome": { + "value_type": "string", + "required": true, + "values": [ + "completed", + "failed", + "interrupted", + "expired", + "session-ended", + "harness-exited", + "seat-stopped" + ], + "immutable": false, + "reference": false + }, + "output_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "subagent_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "total_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "subagents", + "wakes_reconciler": false + }, + "subagent.renewed": { + "kind": "subagent.renewed", + "subjects": [ + "agent" + ], + "fields": { + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "lease_expires_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "subagent_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "subagents", + "wakes_reconciler": false + }, + "subscription.batch-sent": { + "kind": "subscription.batch-sent", + "subjects": [ + "subscription" + ], + "fields": { + "entries": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "message": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "message" + ] + }, + "through": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.batched": { + "kind": "subscription.batched", + "subjects": [ + "subscription" + ], + "fields": { + "delivery_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "entries": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-deferred": { + "kind": "subscription.mission-deferred", + "subjects": [ + "subscription" + ], + "fields": { + "not_before_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-failed": { + "kind": "subscription.mission-failed", + "subjects": [ + "subscription" + ], + "fields": { + "code": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-request-cancelled": { + "kind": "subscription.mission-request-cancelled", + "subjects": [ + "subscription" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-request-released": { + "kind": "subscription.mission-request-released", + "subjects": [ + "subscription" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-requested": { + "kind": "subscription.mission-requested", + "subjects": [ + "subscription" + ], + "fields": { + "delivery_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "discovery": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "held": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "mission": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission" + ] + }, + "mission_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "resource": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "resource" + ] + }, + "resource_input": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "text": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "text_input": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "workspace": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-started": { + "kind": "subscription.mission-started", + "subjects": [ + "subscription" + ], + "fields": { + "mission_run": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.state": { + "kind": "subscription.state", + "subjects": [ + "subscription" + ], + "fields": { + "fields": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "observer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": true, + "values": [ + "pending", + "active", + "stopped" + ], + "immutable": false, + "reference": false + }, + "to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.watch-ended": { + "kind": "subscription.watch-ended", + "subjects": [ + "subscription" + ], + "fields": { + "message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "message" + ] + }, + "reason": { + "value_type": "string", + "required": true, + "values": [ + "closed", + "merged", + "deadline", + "unwatched", + "seat-ended" + ], + "immutable": false, + "reference": false + }, + "since_unix_ms": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "terminal.input.requested": { + "kind": "terminal.input.requested", + "subjects": [ + "agent", + "pty" + ], + "fields": { + "byte_count": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "intent": { + "value_type": "string", + "required": false, + "values": [ + "context-compaction" + ], + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": false, + "values": [ + "line", + "raw", + "key", + "text", + "binary" + ], + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "sha256": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "terminal-control", + "wakes_reconciler": true + }, + "terminal.input.result": { + "kind": "terminal.input.result", + "subjects": [ + "agent", + "pty" + ], + "fields": { + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "result": { + "value_type": "string", + "required": true, + "values": [ + "written", + "failed" + ], + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "terminal-control", + "wakes_reconciler": true + }, + "terminal.launch-geometry": { + "kind": "terminal.launch-geometry", + "subjects": [ + "person" + ], + "fields": { + "columns": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "rows": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "transport.observed": { + "kind": "transport.observed", + "subjects": [ + "host" + ], + "fields": { + "last_success_at": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "protocol": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "remote_heads": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "up", + "down", + "unknown" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "transport", + "wakes_reconciler": true + }, + "usage.limits.observed": { + "kind": "usage.limits.observed", + "subjects": [ + "agent" + ], + "fields": { + "credit_readings": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "readings": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "vcs.item.state": { + "kind": "vcs.item.state", + "subjects": [ + "resource" + ], + "fields": { + "checks_state": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "draft": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "head_sha": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "item_kind": { + "value_type": "string", + "required": true, + "values": [ + "vcs.pull-request", + "vcs.issue" + ], + "immutable": false, + "reference": false + }, + "merge_queue": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "merged": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "observer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "observer" + ] + }, + "required_checks": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "review_decision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": true, + "values": [ + "open", + "closed" + ], + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "resources", + "wakes_reconciler": true + }, + "work.claimed": { + "kind": "work.claimed", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "state-transition", + "projection": "work", + "wakes_reconciler": true + }, + "work.extended": { + "kind": "work.extended", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.failed": { + "kind": "work.failed", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once-per-attempt", + "projection": "work", + "wakes_reconciler": true + }, + "work.handoff-returned": { + "kind": "work.handoff-returned", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "disposition": { + "value_type": "string", + "required": false, + "values": [ + "withdrawn", + "declined" + ], + "immutable": false, + "reference": false + }, + "handoff_claim": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "return_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.nudged": { + "kind": "work.nudged", + "subjects": [ + "step-run" + ], + "fields": { + "agent": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "idle_since_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "waits": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "work.person-asked": { + "kind": "work.person-asked", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "default_answer": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "legacy_request": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "mission_spec": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "origin_attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "origin_step": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "owner_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "owner_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "requester_declaration": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "title": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "waiting_since": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.person-cancelled": { + "kind": "work.person-cancelled", + "subjects": [ + "step-run" + ], + "fields": { + "answer": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "resolved_by": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.person-done": { + "kind": "work.person-done", + "subjects": [ + "step-run" + ], + "fields": { + "acted_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "answer": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "assistant_grant_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "assistant_owner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "assistant_session_actor": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "delegation": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "resolved_by": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "sender_class": { + "value_type": "string", + "required": false, + "values": [ + "external-assistant" + ], + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.person-reassigned": { + "kind": "work.person-reassigned", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "previous_person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.progress": { + "kind": "work.progress", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.released": { + "kind": "work.released", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.renewed": { + "kind": "work.renewed", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.submitted": { + "kind": "work.submitted", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "submitted_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once-per-attempt", + "projection": "work", + "wakes_reconciler": true + }, + "workspace.observed": { + "kind": "workspace.observed", + "subjects": [ + "agent" + ], + "fields": { + "host": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "repository": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "workspace": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": true, + "retention": "latest" + } +} diff --git a/release-notes/mail-expiry-contract.json b/release-notes/mail-expiry-contract.json new file mode 100644 index 00000000000..3d07899d0de --- /dev/null +++ b/release-notes/mail-expiry-contract.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Register durable mailbox expiry outcomes and preserve later delivery evidence without enabling automatic expiry", + "replay": {"status": "changed", "detail": "The graph registry admits daemon/runtime message.expired claims with explicit reason codes. Later delivered/read evidence remains admissible and supersedes the expiry outcome while its claim stays in history. No production expiry writer or recovery-policy change is enabled."}, + "schema": {"status": "changed", "detail": "Adds the durable message.expired graph claim kind and a store outcome reader. The messages shard still replicates only message.sent; no local receipt-table format changes."}, + "rules": {"status": "changed", "detail": "message.expired is durable retention history, not a transport receipt or proof of non-consumption. Transactional notification entry points cannot bypass expiry admission; late delivered evidence after read remains admissible."}, + "compatibility": {"status": "changed", "detail": "The registry digest changes. Older registries retain expiry as an unknown kind and cannot interpret the outcome. Deploy all outcome readers, projections and sender surfaces to every pinned host before enabling a writer. Harness receipt authorization is unchanged."}, + "downtime": {"status": "none", "detail": "No data migration or startup rebuild is introduced."}, + "manual": {"status": "none", "detail": "This release enables no expiry writer and requires no operator action."}, + "recovery": {"status": "changed", "detail": "Rollback retains authenticated expiry history as an unknown kind on older readers; it does not authorize replay of an uncertain handoff. Do not enable expiry writers until the full reader rollout is complete."} +} From 95dfcf312d89cd0e756d61bab632e054753efe5d Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:13:47 +0100 Subject: [PATCH 075/132] Initialize browser pairing state in native seed driver tests agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal/src/native_seed_driver_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/fractal/src/native_seed_driver_tests.rs b/crates/fractal/src/native_seed_driver_tests.rs index 4e6f73be22f..b906118e336 100644 --- a/crates/fractal/src/native_seed_driver_tests.rs +++ b/crates/fractal/src/native_seed_driver_tests.rs @@ -134,6 +134,7 @@ fn managed_startup_reads_its_declaration_over_authenticated_unix() { Some("person/operator"), ).unwrap(); let state = AppState { + browser_pairing_login: None, seat_admission: Default::default(), owning_person: Some("person/operator".into()), store, From 928106dc8fcfc8f9e7bc6f69c0893ad2ad8cc489 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 09:20:24 +0100 Subject: [PATCH 076/132] Report a stall when a held step only renews its lease A run's stall clock counted the end of every step's lease, and each lease renewal also moved the step's last change. A live seat's driver renews every minute, so a run with a held step never stalled while its holder was alive, however long it recorded nothing. A held step now shows life through the start of its current execution interval, its last progress record, and a declared wait until that wait ends. Steps nobody holds count from their last change as before. Co-Authored-By: Claude Fable 5.2 --- .../src/reconcile/run_report.rs | 30 +++-- .../src/reconcile/tests/run_report_tests.rs | 108 +++++++++++++++++- docs/st3/mission-graph-runtime.md | 8 +- 3 files changed, 132 insertions(+), 14 deletions(-) diff --git a/crates/fractal-reconcile/src/reconcile/run_report.rs b/crates/fractal-reconcile/src/reconcile/run_report.rs index ca9dfa9f4e9..eaa6b174910 100644 --- a/crates/fractal-reconcile/src/reconcile/run_report.rs +++ b/crates/fractal-reconcile/src/reconcile/run_report.rs @@ -45,24 +45,36 @@ impl RunEvent { } } -/// The last sign of life in a run: its own last change, any step's last change or progress, and -/// the end of any step's lease. +/// The last sign of life in a run: its own last change and each step's last sign of life. pub fn run_activity(run: &MissionRunView) -> u128 { run.steps .iter() - .flat_map(|step| { - [ - Some(step.updated_at_unix_ms), - step.progress_at_unix_ms, - step.claim_expires_at_unix_ms, - ] - }) + .flat_map(step_activity) .flatten() .chain([run.created_at_unix_ms, run.updated_at_unix_ms]) .max() .unwrap_or_default() } +/// A step's signs of life. A step nobody holds changes only when something happens to it, so +/// its last change counts. A held step is different: its holder's driver renews the lease every +/// minute, and each renewal moves both the lease end and the step's last change, so neither says +/// that work is being done. A held step shows life through the start of its current execution +/// interval (its claim, or the end of its last wait), its last progress record, and a declared +/// wait until that wait ends on its own. +fn step_activity(step: &StepRunView) -> [Option; 3] { + let held = step.claimant.is_some() && matches!(step.status.as_str(), "claimed" | "working"); + let declared = [ + step.execution_started_at_unix_ms, + step.execution_paused_until_unix_ms, + ]; + if held && declared.iter().any(Option::is_some) { + [declared[0], step.progress_at_unix_ms, declared[1]] + } else { + [Some(step.updated_at_unix_ms), step.progress_at_unix_ms, None] + } +} + /// Whether a run is still working, so that silence means something. A run in cleanup is ending, /// and a run that waits for the run before it has not started. fn can_stall(run: &MissionRunView) -> bool { diff --git a/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs b/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs index eb057e211c2..ec4d14e73a2 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs @@ -283,10 +283,11 @@ fn a_quiet_parent_counts_child_round_progress_and_then_reports_real_silence() { ); assert_eq!( fixture.reconciler.incremental.next_due(&parent.subject), - Some(progress_at + 20 * MINUTE) + Some(progress_at + 10 * MINUTE), + "the lease that progress renewed for ten minutes is not a sign of life" ); - // The live worker's progress renews its lease for ten minutes. Release it before - // checking true silence, so a later lease-expiry transition cannot count as activity. + // Release the step before checking true silence, so the stall is the parent's own and a + // later lease-expiry transition cannot count as activity. fixture .store .work_action( @@ -577,6 +578,107 @@ fn a_stalled_run_is_reported_once_and_again_only_after_more_progress() { ); } +/// A live seat's driver renews its lease every minute. Renewals move the lease end and the step's +/// last change, and neither is work: a held step that records nothing stalls like any other. +#[test] +fn a_held_step_that_only_renews_its_lease_stalls() { + let (_clock, fixture) = fixture(); + let run = fixture.start("watched", "held-quiet-run"); + let build = Fixture::step(&run, "build"); + fixture.passes(3); + let builder = fixture + .reconciler + .runtime + .started_members + .lock() + .unwrap() + .iter() + .find(|member| member.runtime_id.ends_with("builder")) + .unwrap() + .clone(); + fixture + .reconciler + .runtime + .ptys + .lock() + .unwrap() + .push(RuntimeObservation { + runtime_id: builder.runtime_id, + terminal: builder.terminal, + status: "running".into(), + exit_code: None, + incarnation_id: Some("quiet-worker".into()), + }); + fixture.passes(1); + let act = |action: &str, key: &str, summary: Option<&str>| { + fixture + .store + .work_action( + &build, + action, + &crate::model::WorkRequest { + actor: Some("agent/node.builder".into()), + incarnation: Some("quiet-worker".into()), + summary: summary.map(str::to_owned), + reason: None, + evidence: Vec::new(), + wait_ms: None, + idempotency_key: key.into(), + }, + ) + .unwrap(); + }; + let stalled = || { + fixture + .reports(REPORTER) + .into_iter() + .filter(|message| message.tags.contains(&"st3-run-report:stalled".to_owned())) + .count() + }; + let claimed_at = start() + MINUTE; + { + let _at = Clock::at(&fixture.store, claimed_at); + act("claim", "claim-quiet", None); + fixture.passes(2); + } + for minute in 1..=9 { + let _at = Clock::at(&fixture.store, claimed_at + minute * MINUTE); + act("renew", &format!("renew-quiet-{minute}"), None); + fixture.passes(1); + } + let held = fixture.store.step_run(&build).unwrap().unwrap(); + assert!(held.claim_expires_at_unix_ms.unwrap() > claimed_at + 10 * MINUTE); + assert_eq!(stalled(), 0, "nine minutes into a ten-minute limit"); + assert_eq!( + fixture.reconciler.incremental.next_due(&run.subject), + Some(claimed_at + 10 * MINUTE), + "the limit counts from the claim, not from the renewed lease" + ); + { + let _at = Clock::at(&fixture.store, claimed_at + 11 * MINUTE); + act("renew", "renew-quiet-11", None); + fixture.passes(2); + assert_eq!(stalled(), 1); + } + { + // More renewals are the same silence, so the same event. + let _at = Clock::at(&fixture.store, claimed_at + 12 * MINUTE); + act("renew", "renew-quiet-12", None); + fixture.passes(2); + assert_eq!(stalled(), 1); + } + { + let _at = Clock::at(&fixture.store, claimed_at + 13 * MINUTE); + act("progress", "progress-quiet", Some("Building.")); + fixture.passes(2); + assert_eq!(stalled(), 1, "progress is a sign of life"); + } + let _at = Clock::at(&fixture.store, claimed_at + 24 * MINUTE); + act("renew", "renew-quiet-24", None); + fixture.passes(2); + assert_eq!(stalled(), 2, "silence after progress is a new stall"); +} + #[test] fn a_stall_is_a_deadline_on_the_run_not_a_scan() { let (_clock, fixture) = fixture(); diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index 26c81fc9054..64b26a2a24a 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -1272,8 +1272,12 @@ mission's policy; schedules retain their timers, catch-up and no-overlap behavio does not change schedule admission or schedule faults. `stalled-after` remains a positive duration even when stall reports are disabled. -A stall is measured from the run's last sign of life: its last state change, any step's last -change or progress record, and the end of any step's lease. Activity in child runs linked from its steps, +A stall is measured from the run's last sign of life: its last state change and any step's last +change or progress record. A step that a worker holds counts from the start of its current +execution interval (its claim, or the end of its last wait), its last progress record, and a +declared `work wait` until that wait ends. Lease renewals are not a sign of life: a live seat that +holds a step and records no progress is reported like any other silence, so a worker on a long +step records progress with `fractal work progress`. Activity in child runs linked from its steps, including loop rounds and nested work, counts too. A run in cleanup, and a run that still waits on `after`, cannot stall. A stalled run is not failed and can go on; if it makes progress and then goes quiet again, that is a new stall and a new message. The daemon keeps each run's stall limit as a From a5ae6c42b405e1cf7b4a34a8f3f79d46b518be01 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 10:02:58 +0100 Subject: [PATCH 077/132] Count a held step's own progress record in the stall clock A run read for evaluation does not carry step progress, so once a held step's last change stopped counting, its progress records were not seen. Read each held step's own progress in the stall check, and keep the lease alive in the regression test as a driver does. Co-Authored-By: Claude Fable 5.2 --- .../src/reconcile/run_report.rs | 33 +++++++++++++++++-- .../src/reconcile/tests/run_report_tests.rs | 7 ++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/fractal-reconcile/src/reconcile/run_report.rs b/crates/fractal-reconcile/src/reconcile/run_report.rs index eaa6b174910..c4fd64d4c96 100644 --- a/crates/fractal-reconcile/src/reconcile/run_report.rs +++ b/crates/fractal-reconcile/src/reconcile/run_report.rs @@ -63,7 +63,7 @@ pub fn run_activity(run: &MissionRunView) -> u128 { /// interval (its claim, or the end of its last wait), its last progress record, and a declared /// wait until that wait ends on its own. fn step_activity(step: &StepRunView) -> [Option; 3] { - let held = step.claimant.is_some() && matches!(step.status.as_str(), "claimed" | "working"); + let held = is_held(step); let declared = [ step.execution_started_at_unix_ms, step.execution_paused_until_unix_ms, @@ -75,6 +75,11 @@ fn step_activity(step: &StepRunView) -> [Option; 3] { } } +/// Whether a worker holds the step, so that its driver renews the step's lease. +fn is_held(step: &StepRunView) -> bool { + step.claimant.is_some() && matches!(step.status.as_str(), "claimed" | "working") +} + /// Whether a run is still working, so that silence means something. A run in cleanup is ending, /// and a run that waits for the run before it has not started. fn can_stall(run: &MissionRunView) -> bool { @@ -89,7 +94,7 @@ impl Reconciler { /// register every run and step read, including nested work, so progress moves the stall /// deadline on the next incremental evaluation rather than waiting for a scan. fn run_report_activity(&self, run: &MissionRunView) -> Result { - let mut activity = run_activity(run); + let mut activity = run_activity(run).max(self.held_progress(run)?); let mut pending = run .steps .iter() @@ -101,13 +106,35 @@ impl Reconciler { if !visited.insert(child.subject.clone()) { continue; } - activity = activity.max(run_activity(&child)); + activity = activity + .max(run_activity(&child)) + .max(self.held_progress(&child)?); pending.extend(child.steps.into_iter().map(|step| step.subject)); } } Ok(activity) } + /// The last progress record of the steps a worker holds. A run read for evaluation does not + /// carry step progress, and a held step's last change cannot stand in for it because lease + /// renewals move it, so read each held step's own record. + fn held_progress(&self, run: &MissionRunView) -> Result { + let mut progress = 0; + for step in &run.steps { + if step.progress_at_unix_ms.is_some() || !is_held(step) { + continue; + } + if let Some(at) = self + .store + .step_run(&step.subject)? + .and_then(|step| step.progress_at_unix_ms) + { + progress = progress.max(at); + } + } + Ok(progress) + } + /// Who a run reports to: its latest `st missions report-to`, else what it was created with. pub fn run_report(&self, run: &str) -> Result> { let claim = match self diff --git a/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs b/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs index ec4d14e73a2..6ca9077c253 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs @@ -673,6 +673,13 @@ fn a_held_step_that_only_renews_its_lease_stalls() { fixture.passes(2); assert_eq!(stalled(), 1, "progress is a sign of life"); } + // Keep the ten-minute lease alive through the next silence, as a driver does. + for minute in 14..=22 { + let _at = Clock::at(&fixture.store, claimed_at + minute * MINUTE); + act("renew", &format!("renew-quiet-{minute}"), None); + fixture.passes(1); + assert_eq!(stalled(), 1, "minute {minute} is inside the limit after progress"); + } let _at = Clock::at(&fixture.store, claimed_at + 24 * MINUTE); act("renew", "renew-quiet-24", None); fixture.passes(2); From d8650e5039dbc0ef42f0ee6f864f35ce4828f806 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 10:25:18 +0100 Subject: [PATCH 078/132] Add the release note for the stall clock change Co-Authored-By: Claude Fable 5.2 --- release-notes/stall-ignores-lease-renewals.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 release-notes/stall-ignores-lease-renewals.json diff --git a/release-notes/stall-ignores-lease-renewals.json b/release-notes/stall-ignores-lease-renewals.json new file mode 100644 index 00000000000..1402e6b4181 --- /dev/null +++ b/release-notes/stall-ignores-lease-renewals.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Report a stalled run when a worker holds a step and only renews its lease; lease renewals no longer count as a sign of life", + "replay": { "status": "none", "detail": "No replay or projection changes. The stall deadline is recomputed at each run evaluation from existing step timing and progress claims." }, + "schema": { "status": "none", "detail": "No persisted schema, claim vocabulary or migration changes." }, + "rules": { "status": "none", "detail": "Checkpoint and retention rules are unchanged." }, + "compatibility": { "status": "changed", "detail": "Behaviour change for runs that name a reporter (report-to): a step a live seat holds now stalls after stalled-after (30 minutes unless set) without a work progress record, a new claim or resume, or a declared work wait. Before, the minute-by-minute lease renewal postponed the stall for as long as the seat lived. A worker on a long step should record progress with fractal work progress, or the reporter gets one stalled message per silence. Runs without report-to are unaffected. Members on different builds each evaluate only their own runs, so a mixed fleet reports by the build of the member that owns the run. Not tested against an older build." }, + "downtime": { "status": "none", "detail": "Normal binary rollout. The stall check adds one indexed step read per held step, only for runs that name a reporter and only when the run is evaluated." }, + "manual": { "status": "none", "detail": "Nothing to configure. To silence stall reports for a run, use report-stalls=#false or fractal missions report-to RUN --report-stalls false. The rule is in docs/st3/mission-graph-runtime.md under Run reports." }, + "recovery": { "status": "none", "detail": "Ordinary binary rollback restores the previous stall clock. Stalled messages already sent stay in the reporter's mailbox." } +} From f73a546678056a56878371624d3ecdc1259644e4 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:39:59 +0100 Subject: [PATCH 079/132] fix(web): keep browser pairing status read-only --- .../server/pairing.integration.test.ts | 86 ++++++++++++++++--- apps/fractal-web/server/pairing.mts | 17 +++- apps/fractal-web/src/data/browserPairing.ts | 12 ++- .../web/BrowserPairing.integration.test.tsx | 19 ++++ .../browser-pairing-readonly-status.json | 11 +++ 5 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 release-notes/browser-pairing-readonly-status.json diff --git a/apps/fractal-web/server/pairing.integration.test.ts b/apps/fractal-web/server/pairing.integration.test.ts index fe1a2c1c259..40861afe6b4 100644 --- a/apps/fractal-web/server/pairing.integration.test.ts +++ b/apps/fractal-web/server/pairing.integration.test.ts @@ -22,7 +22,7 @@ const login = 'owner@example.com' const publisherAuth = `Bearer ${'r'.repeat(43)}` const device = 'd'.repeat(43) const flags = '; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=' -const fixture = async () => { +const fixture = async (admitted = false) => { const root = await mkdtemp(join(tmpdir(), 'fractal-pairing-test-')) cleanups.push(() => rm(root, { recursive: true, force: true })) const received: { path: string; auth: string | undefined; body: Record; cookie: string | undefined }[] = [] @@ -48,7 +48,7 @@ const fixture = async () => { cleanups.push(() => close(daemon)) let listening: string | undefined const admit = tailnetAdmission({ origin, logins: [login], listener: () => listening }) - const server = createServer((req, res) => { void handlePairing(req, res, { socketPath, host: 'localhost', authorization: publisherAuth, timeoutMs: 1000 }, { admit }).catch(() => res.destroy()) }) + const server = createServer((req, res) => { void handlePairing(req, res, { socketPath, host: 'localhost', authorization: publisherAuth, timeoutMs: 1000 }, { admit: admitted ? () => true : admit }).catch(() => res.destroy()) }) server.listen(0, '127.0.0.1') await once(server, 'listening') cleanups.push(() => close(server)) @@ -56,8 +56,8 @@ const fixture = async () => { if (address === null || typeof address === 'string') throw new TypeError('Missing listener') listening = `127.0.0.1:${address.port}` const headers = { host: new URL(origin).host, origin, 'tailscale-user-login': login } - const call = (action: string, cookie?: string, overrides: Record = {}) => fetch(`http://127.0.0.1:${address.port}/_fractal/pair/${action}`, { - method: action === 'status' ? 'GET' : 'POST', headers: { ...headers, ...(cookie === undefined ? {} : { cookie }), ...overrides }, + const call = (action: string, cookie?: string, overrides: Record = {}) => fetch(`http://127.0.0.1:${address.port}/_fractal/pair/${action}`, { + method: action === 'status' ? 'GET' : 'POST', headers: Object.fromEntries(Object.entries({ ...headers, ...(cookie === undefined ? {} : { cookie }), ...overrides }).filter((entry): entry is [string, string] => entry[1] !== undefined)), ...(action === 'start' ? { body: JSON.stringify({ deviceLabel: 'Personal browser' }) } : {}), }) return { call, received, setStatus: (value: string) => { status = value }, setGrantStatus: (value: number) => { grantStatus = value } } @@ -76,28 +76,39 @@ it('keeps request secrets and grants only in secure cookies and revokes before f expect(setup.received[0]).toEqual({ path: '/v1/client/pairing/browser-requests', auth: publisherAuth, cookie: undefined, body: { deviceLabel: 'Personal browser', tailnetLogin: login, requestKeyDigest: createHash('sha256').update(pending.requestKey).digest('hex'), } }) - expect(await (await setup.call('status', pairHeader)).json()).toEqual({ status: 'pending', displayCode: '123456' }) + const status = await setup.call('status', pairHeader) + expect(await status.json()).toEqual({ status: 'pending', displayCode: '123456' }) + expect(status.headers.getSetCookie()).toEqual([]) + expect(setup.received).toHaveLength(1) + expect(await (await setup.call('redeem', pairHeader)).json()).toEqual({ status: 'pending', displayCode: '123456' }) setup.setStatus('approved') - const approved = await setup.call('status', pairHeader) + const approved = await setup.call('redeem', pairHeader) expect(await approved.json()).toEqual({ status: 'paired', credentialChanged: true }) expect(approved.headers.getSetCookie()).toEqual([`fractal_device=${device}${flags}2592000`, `fractal_pair=${flags}0`]) expect(setup.received[2]?.auth).toBe(publisherAuth) expect(setup.received[2]?.body).toEqual({ requestKey: pending.requestKey }) + const paired = await setup.call('status', `fractal_device=${device}`) + expect(await paired.json()).toEqual({ status: 'paired' }) + expect(paired.headers.getSetCookie()).toEqual([]) const forgotten = await setup.call('forget', `fractal_device=${device}; other=never-forward`) expect(await forgotten.json()).toEqual({ status: 'unpaired' }) expect(forgotten.headers.getSetCookie()).toEqual([`fractal_device=${flags}0`, `fractal_pair=${flags}0`]) - expect(setup.received[3]).toEqual({ path: '/v1/client/pairing/browser-requests/revoke-current', auth: `Bearer ${device}`, body: {}, cookie: undefined }) + expect(setup.received[4]).toEqual({ path: '/v1/client/pairing/browser-requests/revoke-current', auth: `Bearer ${device}`, body: {}, cookie: undefined }) }) it.each(['denied', 'expired'])('clears a %s pairing cookie', async status => { const setup = await fixture() const start = await setup.call('start') const cookie = start.headers.getSetCookie()[0]!.split(';')[0]! setup.setStatus(status) - const result = await setup.call('status', cookie) + const read = await setup.call('status', cookie) + expect(await read.json()).toEqual({ status: 'pending', displayCode: '123456' }) + expect(read.headers.getSetCookie()).toEqual([]) + expect(setup.received).toHaveLength(1) + const result = await setup.call('redeem', cookie) expect(await result.json()).toEqual({ status }) expect(result.headers.getSetCookie()).toEqual([`fractal_pair=${flags}0`]) }) -it.each(['start', 'status', 'forget'])('refuses %s without admitted personal login or with cross-origin requests', async action => { +it.each(['start', 'status', 'forget', 'redeem'])('refuses %s without admitted personal login or with cross-origin requests', async action => { const setup = await fixture() for (const overrides of [{ 'tailscale-user-login': '' }, { 'tailscale-user-login': 'tag:dev-host' }, { origin: 'https://other.example' }]) { expect((await setup.call(action, undefined, overrides)).status).toBe(403) @@ -119,11 +130,60 @@ it('validates a device cookie with the daemon before reporting paired', async () expect(response.headers.getSetCookie()).toEqual([]) expect(setup.received).toEqual([{ path: '/v1/client/capabilities', auth: `Bearer ${device}`, cookie: undefined, body: {} }]) }) -it.each([401, 403])('clears a revoked or expired device cookie when daemon authentication returns %s', async status => { +it.each([401, 403])('clears a rejected device cookie only through POST when authentication returns %s', async status => { const setup = await fixture() setup.setGrantStatus(status) const response = await setup.call('status', `fractal_device=${device}`) - expect(await response.json()).toEqual({ status: 'unpaired', credentialChanged: true }) - expect(response.headers.getSetCookie()).toEqual([`fractal_device=${flags}0`]) - expect(setup.received[0]?.auth).toBe(`Bearer ${device}`) + expect(await response.json()).toEqual({ status: 'unpaired', credentialRejected: true }) + expect(response.headers.getSetCookie()).toEqual([]) + const redeemed = await setup.call('redeem', `fractal_device=${device}`) + expect(await redeemed.json()).toEqual({ status: 'unpaired', credentialChanged: true }) + expect(redeemed.headers.getSetCookie()).toEqual([`fractal_device=${flags}0`]) + expect(setup.received.every(call => call.path === '/v1/client/capabilities' && call.auth === `Bearer ${device}`)).toBe(true) +}) + +it.each(['start', 'forget', 'redeem'])('enforces same-origin %s independently of admission', async action => { + const setup = await fixture(true) + for (const overrides of [ + { origin: undefined }, + { origin: 'https://other.example' }, + { origin: 'http://personal.example.ts.net' }, + { 'sec-fetch-site': 'cross-site' }, + { 'sec-fetch-site': 'same-site' }, + { 'sec-fetch-site': 'none' }, + ]) { + const response = await setup.call(action, undefined, overrides) + expect(response.status).toBe(403) + expect(response.headers.getSetCookie()).toEqual([]) + } + expect(setup.received).toEqual([]) + expect((await setup.call(action, undefined, { origin: undefined, 'sec-fetch-site': 'same-origin' })).status).toBe(200) +}) + +it.each([undefined, 'fractal_pair=malformed', 'fractal_device=malformed'])('keeps unpaired GET read-only for %s', async cookie => { + const setup = await fixture() + const response = await setup.call('status', cookie) + expect(await response.json()).toEqual({ status: 'unpaired' }) + expect(response.headers.getSetCookie()).toEqual([]) + expect(setup.received).toEqual([]) +}) + +it('keeps a failed capability GET read-only', async () => { + const setup = await fixture() + setup.setGrantStatus(500) + const response = await setup.call('status', `fractal_device=${device}`) + expect(response.status).toBe(502) + expect(response.headers.getSetCookie()).toEqual([]) + expect(setup.received.map(call => call.path)).toEqual(['/v1/client/capabilities']) +}) + +it.each(['pending', 'approved', 'denied', 'expired', 'invalid'])('never redeems a pending cookie during GET even when the daemon outcome is %s', async status => { + const setup = await fixture() + const start = await setup.call('start') + const cookie = start.headers.getSetCookie()[0]!.split(';')[0]! + setup.setStatus(status) + const response = await setup.call('status', cookie) + expect(await response.json()).toEqual({ status: 'pending', displayCode: '123456' }) + expect(response.headers.getSetCookie()).toEqual([]) + expect(setup.received.map(call => call.path)).toEqual(['/v1/client/pairing/browser-requests']) }) diff --git a/apps/fractal-web/server/pairing.mts b/apps/fractal-web/server/pairing.mts index 244c16bf5f8..4fd2ce96b84 100644 --- a/apps/fractal-web/server/pairing.mts +++ b/apps/fractal-web/server/pairing.mts @@ -67,9 +67,15 @@ export const handlePairing = async (req: IncomingMessage, res: ServerResponse, g if (!options.admit(req)) { json(res, 403, { error: 'Forbidden' }); return } const path = req.url?.split('?')[0] const action = path?.slice(prefix.length) - if ((action === 'status' ? 'GET' : 'POST') !== req.method || !['start', 'status', 'forget'].includes(action ?? '')) { + if ((action === 'status' ? 'GET' : 'POST') !== req.method || !['start', 'status', 'forget', 'redeem'].includes(action ?? '')) { json(res, 404, { error: 'Not found' }); return } + // Pairing cookies are HTTPS-only, including when TLS terminates at the admitted proxy. + const site = req.headers['sec-fetch-site'] + if (req.method === 'POST' && (site !== undefined ? site !== 'same-origin' + : req.headers.origin === undefined || req.headers.origin !== `https://${req.headers.host}`)) { + json(res, 403, { error: 'Forbidden' }); return + } try { if (action === 'forget') { const bearer = browserBearer(req.headers) @@ -105,14 +111,19 @@ export const handlePairing = async (req: IncomingMessage, res: ServerResponse, g if (bearer !== undefined) { const grant = await daemonCall(gateway, '/v1/client/capabilities', undefined, `Bearer ${bearer}`, 'GET') if (grant.status === 401 || grant.status === 403) { - res.setHeader('set-cookie', secureCookie(deviceCookie, '', 0)) - json(res, 200, { status: 'unpaired', credentialChanged: true }); return + if (action === 'redeem') { + res.setHeader('set-cookie', secureCookie(deviceCookie, '', 0)) + json(res, 200, { status: 'unpaired', credentialChanged: true }); return + } + json(res, 200, { status: 'unpaired', credentialRejected: true }); return } if (grant.status !== 200) { json(res, 502, { error: 'Could not validate this browser' }); return } json(res, 200, { status: 'paired' }); return } const pending = pendingPair(req) if (pending === undefined) { json(res, 200, { status: 'unpaired' }); return } + // The daemon exposes no read-only browser-request status endpoint. + if (action === 'status') { json(res, 200, { status: 'pending', displayCode: pending.displayCode }); return } const result = await daemonCall(gateway, `/v1/client/pairing/browser-requests/${encodeURIComponent(pending.requestId)}/redeem`, { requestKey: pending.requestKey }) const response = result.body if (result.status !== 200 || !record(response)) { json(res, 502, { error: 'Could not check browser pairing' }); return } diff --git a/apps/fractal-web/src/data/browserPairing.ts b/apps/fractal-web/src/data/browserPairing.ts index 2734d65459c..19333eaf90b 100644 --- a/apps/fractal-web/src/data/browserPairing.ts +++ b/apps/fractal-web/src/data/browserPairing.ts @@ -5,6 +5,7 @@ const decodeStatus = Schema.decodeUnknownSync(Schema.Struct({ status: Schema.Literals(['unpaired', 'paired', 'pending', 'denied', 'expired']), displayCode: Schema.optional(Schema.String), credentialChanged: Schema.optional(Schema.Boolean), + credentialRejected: Schema.optional(Schema.Boolean), })) const decodeStarted = Schema.decodeUnknownSync(Schema.Struct({ requestId: Schema.String, displayCode: Schema.String })) @@ -25,8 +26,8 @@ export const browserPairingPort = ({ baseUrl, fetch: fetchImpl = (input, init) = readonly baseUrl: string readonly fetch?: (input: URL, init: RequestInit) => Promise }): BrowserPairingPort => { - const request = (path: 'start' | 'status' | 'forget', body?: unknown) => fetchImpl(new URL(`/_fractal/pair/${path}`, baseUrl), { - method: body === undefined ? 'GET' : 'POST', + const request = (path: 'start' | 'status' | 'forget' | 'redeem', body?: unknown) => fetchImpl(new URL(`/_fractal/pair/${path}`, baseUrl), { + method: path === 'status' ? 'GET' : 'POST', credentials: 'same-origin', cache: 'no-store', headers: body === undefined ? { accept: 'application/json' } : { accept: 'application/json', 'content-type': 'application/json' }, @@ -39,7 +40,12 @@ export const browserPairingPort = ({ baseUrl, fetch: fetchImpl = (input, init) = return { status: async () => { const response = await request('status') - return response.status === 404 ? undefined : decodeStatus(await json(response)) + if (response.status === 404) return undefined + const status = decodeStatus(await json(response)) + // Reads never adopt or discard credentials. Explicit same-origin POSTs do that work. + return status.status === 'pending' || status.credentialRejected === true + ? decodeStatus(await json(await request('redeem', {}))) + : status }, start: async (deviceLabel) => decodeStarted(await json(await request('start', { deviceLabel }))), forget: async () => { diff --git a/apps/fractal-web/src/web/BrowserPairing.integration.test.tsx b/apps/fractal-web/src/web/BrowserPairing.integration.test.tsx index 5987bad9806..f52c5f367c6 100644 --- a/apps/fractal-web/src/web/BrowserPairing.integration.test.tsx +++ b/apps/fractal-web/src/web/BrowserPairing.integration.test.tsx @@ -242,10 +242,29 @@ describe('browserPairingPort', () => { expect(await paired.port.status()).toEqual({ status: 'pending', displayCode: 'K7Q-4MX' }) expect(paired.calls[0]!.url.href).toBe('https://fractal.example/_fractal/pair/status') expect(paired.calls[0]!.init).toMatchObject({ method: 'GET', credentials: 'same-origin', cache: 'no-store' }) + expect(paired.calls[1]!.url.pathname).toBe('/_fractal/pair/redeem') + expect(paired.calls[1]!.init).toMatchObject({ method: 'POST', credentials: 'same-origin', body: '{}' }) expect(await recorded(() => new Response('', { status: 404 })).port.status()).toBeUndefined() await expect(recorded(() => new Response('', { status: 500 })).port.status()).rejects.toBeInstanceOf(PairingHttpError) }) + it.each([ + [{ status: 'pending', displayCode: 'K7Q-4MX' }, { status: 'paired', credentialChanged: true }], + [{ status: 'unpaired', credentialRejected: true }, { status: 'unpaired', credentialChanged: true }], + ])('adopts or clears credentials by POST after reading %j', async (status, redeemed) => { + const { port, calls } = recorded(url => Response.json(url.pathname.endsWith('/status') ? status : redeemed)) + expect(await port.status()).toEqual(redeemed) + expect(calls.map(call => [call.url.pathname, call.init.method])).toEqual([ + ['/_fractal/pair/status', 'GET'], ['/_fractal/pair/redeem', 'POST'], + ]) + }) + + it.each(['paired', 'unpaired'])('does not POST for an ordinary %s status', async status => { + const { port, calls } = recorded(() => Response.json({ status })) + expect(await port.status()).toEqual({ status }) + expect(calls).toHaveLength(1) + }) + it('starts with the device label and forgets with a POST', async () => { const { port, calls } = recorded(url => url.pathname.endsWith('/start') ? Response.json({ requestId: 'pair-request/1', displayCode: 'K7Q-4MX', expiresAt: '2030-01-01T00:00:00Z' }) diff --git a/release-notes/browser-pairing-readonly-status.json b/release-notes/browser-pairing-readonly-status.json new file mode 100644 index 00000000000..97230779bef --- /dev/null +++ b/release-notes/browser-pairing-readonly-status.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Keep browser pairing status reads free of credential mutations", + "replay": {"status": "none", "detail": "No replay changes."}, + "schema": {"status": "none", "detail": "No persistent schema changes."}, + "rules": {"status": "none", "detail": "No rule changes."}, + "compatibility": {"status": "none", "detail": "The web client polls GET status and uses same-origin POST redeem to adopt approved credentials or clear rejected credentials. Pending status reads use the cookie because the daemon has no read-only request status endpoint. Start, forget and redeem reject cross-origin requests."}, + "downtime": {"status": "none", "detail": "No interruption is required."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "No persistent state or recovery changes."} +} From 4b1b3ad0aedaf2ccec61fca00a86d4c79ac1fdf5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:30:47 +0100 Subject: [PATCH 080/132] Correct corpus v18 non-trimming seeds from hosted CI --- crates/fractal/tests/convergence.rs | 6 +++--- release-notes/convergence-v18-hosted-seed34.json | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 release-notes/convergence-v18-hosted-seed34.json diff --git a/crates/fractal/tests/convergence.rs b/crates/fractal/tests/convergence.rs index 4af51da2532..17c3c87b821 100644 --- a/crates/fractal/tests/convergence.rs +++ b/crates/fractal/tests/convergence.rs @@ -1855,9 +1855,9 @@ fn run_seeds(seeds: impl IntoIterator) { assert!(trimmed * 2 >= runs, "only {trimmed} of {runs} runs trimmed"); } -/// Corpus v17 adds operation receipts to kind selection, shifting the shared RNG schedule. -/// Hosted run 42507264532 observed seed 2 converge without trimming; the other fixed seeds trim. -const NEVER_TRIMS: &[u64] = &[2]; +/// Corpus v18 adds durable expiry to kind selection, shifting the shared RNG schedule. +/// Hosted run 42793993503 observed seeds 2 and 34 converge without trimming; the other fixed seeds trim. +const NEVER_TRIMS: &[u64] = &[2, 34]; /// One fixed seed: it converges, and its checkpoints trim unless it is a seed that never does. fn seed_converges(seed: u64) { diff --git a/release-notes/convergence-v18-hosted-seed34.json b/release-notes/convergence-v18-hosted-seed34.json new file mode 100644 index 00000000000..69cce58e438 --- /dev/null +++ b/release-notes/convergence-v18-hosted-seed34.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Correct the v18 convergence seed trim expectation from hosted CI evidence (test only)", + "replay": {"status": "none", "detail": "Test-only correction: hosted run 42793993503 establishes seeds 2 and 34 as non-trimming for corpus v18. Production replay and divergence assertions are unchanged."}, + "schema": {"status": "none", "detail": "No registry or generated corpus change."}, + "rules": {"status": "none", "detail": "No production rule change; existing exact trim and majority-trim assertions remain."}, + "compatibility": {"status": "none", "detail": "No wire, client or stored-state change."}, + "downtime": {"status": "none", "detail": "No operator downtime is required."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "No recovery behavior changes."} +} From cb77f893282487eb864e40df6eae3ef9c44a0f35 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:23:49 +0100 Subject: [PATCH 081/132] test(web): preserve published Host in pairing requests --- .../server/pairing.integration.test.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/fractal-web/server/pairing.integration.test.ts b/apps/fractal-web/server/pairing.integration.test.ts index 40861afe6b4..41b26833240 100644 --- a/apps/fractal-web/server/pairing.integration.test.ts +++ b/apps/fractal-web/server/pairing.integration.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, it } from 'vitest' import { createHash } from 'node:crypto' -import { createServer } from 'node:http' +import { createServer, request } from 'node:http' import type { Server } from 'node:http' import { once } from 'node:events' import { mkdtemp, rm } from 'node:fs/promises' @@ -56,10 +56,30 @@ const fixture = async (admitted = false) => { if (address === null || typeof address === 'string') throw new TypeError('Missing listener') listening = `127.0.0.1:${address.port}` const headers = { host: new URL(origin).host, origin, 'tailscale-user-login': login } - const call = (action: string, cookie?: string, overrides: Record = {}) => fetch(`http://127.0.0.1:${address.port}/_fractal/pair/${action}`, { - method: action === 'status' ? 'GET' : 'POST', headers: Object.fromEntries(Object.entries({ ...headers, ...(cookie === undefined ? {} : { cookie }), ...overrides }).filter((entry): entry is [string, string] => entry[1] !== undefined)), - ...(action === 'start' ? { body: JSON.stringify({ deviceLabel: 'Personal browser' }) } : {}), - }) + // Use the HTTP transport so Host is preserved. Node fetch may replace it with the + // loopback URL's host, which is not the HTTPS origin published by the proxy. + const call = (action: string, cookie?: string, overrides: Record = {}): Promise => { + const { promise, resolve, reject } = Promise.withResolvers() + const upstream = request({ + hostname: '127.0.0.1', port: address.port, path: `/_fractal/pair/${action}`, + method: action === 'status' ? 'GET' : 'POST', + headers: Object.fromEntries(Object.entries({ ...headers, ...(cookie === undefined ? {} : { cookie }), ...overrides }).filter((entry): entry is [string, string] => entry[1] !== undefined)), + }, response => { + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + response.once('error', reject) + response.once('end', () => { + const responseHeaders = new Headers() + for (const [name, values] of Object.entries(response.headersDistinct)) { + for (const value of values ?? []) responseHeaders.append(name, value) + } + resolve(new Response(Buffer.concat(chunks), { status: response.statusCode, headers: responseHeaders })) + }) + }) + upstream.once('error', reject) + upstream.end(action === 'start' ? JSON.stringify({ deviceLabel: 'Personal browser' }) : undefined) + return promise + } return { call, received, setStatus: (value: string) => { status = value }, setGrantStatus: (value: number) => { grantStatus = value } } } From bb11a995ad1d0aaa6c291e8ea2aaf930bdd39ae1 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:12:18 +0100 Subject: [PATCH 082/132] Specify the CPU reservation ledger contract (#2964) --- docs/st3/priority.md | 103 ++++++++++++++++++ .../cpu-reservation-ledger-contract-2964.json | 32 ++++++ 2 files changed, 135 insertions(+) create mode 100644 release-notes/cpu-reservation-ledger-contract-2964.json diff --git a/docs/st3/priority.md b/docs/st3/priority.md index 298ec719c2b..eb73e23d542 100644 --- a/docs/st3/priority.md +++ b/docs/st3/priority.md @@ -90,6 +90,7 @@ Declared priority accepts the complete set `background < normal`; declared lane **Budget window.** A mission's `token-budget` is the total cost-weighted tokens for one mission run, including all attempts and retries; retrying does not start a new window. `cpu-seconds` is accumulated CPU seconds over that same mission-run window. +See the [CPU reservation ledger contract](#cpu-reservation-ledger-contract) for the intended accounting. A step-run window is a future extension, not an accepted declaration location. Cost-weighted tokens are `sum(tokens * (class price / base input price))` across @@ -226,6 +227,108 @@ claim kinds are not scanned. A valid ancestry continuation beyond 64 vertices al refuses the whole report, even if the mission was already found; missing provenance remains distinct and may be reported as unattributed. +### CPU reservation ledger (contract) + +Status: contract only, data only. No code implements it yet. Owner of the semantics: +st-budgets. Implementer: the gate-slot retirement program (job reserve/settle hooks +and the cgroup v2 `cpu.stat` collector), after its durable-job work. Decision: Johannes, +`step-run/ask-139e87e814cd8ef4cc9b64262722413d/ask` (R5), with inclusive charging from +`step-run/ask-ba7954f15024216f4209fa4f421b3f2a/ask` (R2). + +**Purpose.** A mission's `cpu-seconds` becomes a total cap on the CPU seconds that its +jobs use in one mission run. One authoritative ledger per mission run records +reservations and settlements. + +**Order of delivery.** + +1. Report: a per-run CPU report (settled, reserved, unknown) ships and is observed on real runs. +2. Admission: then the ledger can refuse to start a job whose reservation does not fit. + +The first stage, reporting, changes no behavior. Turning admission on for real missions +needs Johannes's explicit approval, because budget declarations are data only until then. + +**Unit and measurement.** + +- One CPU second is 10^6 `usage_usec` from cgroup v2 `cpu.stat` of the job attempt's own scope. +- The collector reads the scope at start and at end, and records the delta, the physical + host and the observation timestamps. `usage_usec` includes descendant cgroups. +- A transient scope's cgroup disappears at exit. The collector reads it before removal, + or uses the unit's `CPUUsageNSec` with `CPUAccounting=yes`. +- A job attempt without its own cgroup v2 scope has unknown usage. This includes macOS + hosts, remote builders and a lost host. Unknown is never zero. + +**Ledger records** (append-only, one ledger per mission run, fields named for the contract): + +- `reservation`: ledger run, job id, attempt, host, `reserved_cpu_seconds`, the list of + charged mission runs, and the time. +- `settlement`: the reservation id, then `measured_cpu_seconds` or `unknown` with a reason, + coverage, and the time. + +**Reservation size.** + +- The reservation is the job attempt's provable CPU upper bound, in CPU seconds: the + wall-clock deadline × min(the `cpu.max` rate of the attempt's scope and of every ancestor + cgroup, the CPU count of the scope's `cpuset.cpus.effective`). +- The cgroup hierarchy enforces that minimum. The implementer records which cgroup + supplied the bound. +- A job attempt without a deadline, or on a host without cgroup v2 (macOS, remote + builders), has no provable bound. It can run in report mode only; in admission mode + it is refused for a capped mission. +- The bound is conservative: an uncapped attempt reserves all of its effective CPUs for + its whole deadline, so a capped mission can wait for a long time in admission mode. + Settlement corrects the charge when the attempt ends. A per-job `cpu.max` or a shorter + deadline gives a tighter bound. + +**Reserve before each job attempt.** + +- Before the attempt starts, the ledger adds one reservation for that attempt. +- The ledger charges the reservation to every mission run that declares `cpu-seconds` + on the job's ancestry: the job's own run and every declaring ancestor (R2). A job is + counted at most once in each ledger. +- In admission mode, the attempt starts only if, for every charged run: + settled + open reservations + this reservation ≤ that run's `cpu-seconds`. Otherwise: + - If the attempt could fit after open reservations settle, it waits in its queue. + It is not killed and not failed. + - If the attempt's reservation alone exceeds a charged run's `cpu-seconds` minus that + run's settled usage, the attempt can never fit. The ledger refuses it with the typed + reason `budget-cannot-fit`, which names the run and the numbers, and the report shows it. + +**Settle after each job attempt.** + +- When the attempt ends, the settlement replaces the reservation with the measured value. +- If the measurement is unknown, the reservation stays charged in full (R5). +- If the measured value exceeds the reservation, the measured value is charged, and the + report marks a bound violation: the bound was wrong. + +**Retries and delegation.** + +- Each retry attempt is reserved and settled separately in the same mission-run window + (the #6092 budget window); a retry does not start a new window. +- A delegated child run charges its own ledger and every declaring ancestor's ledger. + +**Limits pin and scope.** The limit comes from each run's initial revision. Only runs +that start after the feature activates are charged (new runs only). + +**Running work.** No missing, late or unknown usage stops or kills running work. Only the +admission of new attempts is affected. + +**Authority and failure.** + +- Each mission run has one authoritative ledger writer: the replica that admits the run's jobs. +- If the ledger is unreachable in admission mode, new attempts of a capped mission wait, + and running attempts continue. +- A replica never admits against a copy that it cannot prove current. + +**Report fields** (per mission run): + +- `cpu_seconds` declared, or tagged `undeclared`; +- `charged_settled`; +- `charged_reserved_open`; +- `charged_unknown_retained`; +- `inclusive_measured` (all usage in the subtree, separate from charged); +- `unknown_attempts`; +- `refused_cannot_fit` (attempts refused with `budget-cannot-fit`). + ## Linux On Linux with a systemd user manager, Fractal starts each terminal in a transient scope that it records diff --git a/release-notes/cpu-reservation-ledger-contract-2964.json b/release-notes/cpu-reservation-ledger-contract-2964.json new file mode 100644 index 00000000000..b17a3f9f73c --- /dev/null +++ b/release-notes/cpu-reservation-ledger-contract-2964.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "summary": "Specify the CPU reservation ledger contract; data only, with no implementation or enforcement.", + "replay": { + "status": "none", + "detail": "Documentation only; no claim, projection or replay changes." + }, + "schema": { + "status": "none", + "detail": "Contract only; no ledger storage or database schema is implemented." + }, + "rules": { + "status": "none", + "detail": "Documentation only; no admission, reservation, settlement or checkpoint rules change." + }, + "compatibility": { + "status": "none", + "detail": "Documentation only; no CLI, API, protocol or harness behavior changes." + }, + "downtime": { + "status": "none", + "detail": "Documentation only; no service interruption or restart is required." + }, + "manual": { + "status": "none", + "detail": "Documentation only; no manual upgrade action or enforcement activation." + }, + "recovery": { + "status": "none", + "detail": "Documentation only; no state migration or recovery behavior changes." + } +} From ae813548466a29a521cfd64e51fc0de44a834713 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:16:31 +0100 Subject: [PATCH 083/132] Use valid service grants when testing ready-work route refusal agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../src/api/client_v0/external_grants.rs | 4 +++ .../src/api/ready_work_tests.rs | 27 ++++++++++--------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants.rs b/crates/fractal-api-core/src/api/client_v0/external_grants.rs index f02d9c090ef..3669ab54c70 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants.rs @@ -373,6 +373,10 @@ mod tests { let service_subject = subject("333333333333333333333333").unwrap(); let mut service = source.clone(); service["profile"] = json!("service"); + service["principal"] = json!("plugin/test/reader"); + service["grant_id"] = json!("333333333333333333333333"); + service["session_actor"] = json!("plugin/test/reader/session/333333333333333333333333"); + service["scopes"] = json!(SERVICE_SCOPES); // Simulate an existing profile record without enabling service issuance. let grant = state.store.append_claim(&ClaimInput { subject: service_subject.clone(), kind: GRANTED.into(), actor: Some("person/owner".into()), diff --git a/crates/fractal-api-core/src/api/ready_work_tests.rs b/crates/fractal-api-core/src/api/ready_work_tests.rs index ed46676dc90..f58376b5978 100644 --- a/crates/fractal-api-core/src/api/ready_work_tests.rs +++ b/crates/fractal-api-core/src/api/ready_work_tests.rs @@ -397,28 +397,29 @@ async fn ready_work_refuses_authenticated_assistant_and_external_service_grants( let (_root, mut state, _) = fixture(); state.owning_person = Some("person/operator".into()); let id = "111111111111111111111111"; + let principal = if profile == "assistant" { "assistant/chatgpt" } else { "plugin/test/reader" }; + let scopes = if profile == "assistant" { &["read.assistant.summary"][..] } else { external_grants::SERVICE_SCOPES }; grant_claim(&state, &external_grants::subject(id).unwrap(), GRANTED, json!({ - "profile": profile, "principal": "assistant/chatgpt", "grant_id": id, - "session_actor": format!("assistant/chatgpt/session/{id}"), "issuer": "person/operator", + "profile": profile, "principal": principal, "grant_id": id, + "session_actor": format!("{principal}/session/{id}"), "issuer": "person/operator", "credential_hash": client_v0::credential_digest("ready-demand-external"), "expires_at_unix_ms": u64::MAX, "minimal_read_mode": "approval-once", - "disclosure_consent": true, "scopes": ["read.assistant.summary"] + "disclosure_consent": true, "scopes": scopes })); - if profile == "assistant" { - // Prove this bearer authenticates: refusal is the explicit route allowlist, - // not an absent, malformed, expired, or otherwise unusable grant. - let session = external_grants::authenticate(&state, - &client_v0::credential_digest("ready-demand-external"), "unix").unwrap().unwrap(); - assert_eq!(session.authority_actor, "assistant/chatgpt"); - } + // Both bearers authenticate: refusal must come from their closed route + // registries, not malformed principals, scopes or unusable grants. + let session = external_grants::authenticate(&state, + &client_v0::credential_digest("ready-demand-external"), "unix").unwrap().unwrap(); + assert_eq!(session.authority_actor, principal); + assert_eq!(external_grants::is_service(&session), profile == "service"); let response = router(state).oneshot(Request::builder().uri(PATH) .header("authorization", "Bearer ready-demand-external") .body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); let error: Value = serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap(); assert_eq!(error["code"], "forbidden"); - assert!(error["message"].as_str().unwrap().contains(if profile == "assistant" { - "explicit proof-bearing read projections" - } else { "service grant issuance is disabled" }), "{error}"); + assert_eq!(error["message"], if profile == "assistant" { + "assistants may access only explicit proof-bearing read projections" + } else { "service requires a registered pure GET and its exact read scope" }, "{error}"); } } From 92a9676d079dd6b4fd7c0f426213dd258479eedd Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:24:01 +0100 Subject: [PATCH 084/132] fix(terminals): preserve shell startup when home lookup is unavailable --- crates/fractal-runtime/src/pty.rs | 19 +++++++++++++------ docs/seat-lifecycle.md | 6 ++++-- .../default-terminal-absolute-home-cwd.json | 11 +++++++++++ 3 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 release-notes/default-terminal-absolute-home-cwd.json diff --git a/crates/fractal-runtime/src/pty.rs b/crates/fractal-runtime/src/pty.rs index 92aa1147b1f..b4518e127b6 100644 --- a/crates/fractal-runtime/src/pty.rs +++ b/crates/fractal-runtime/src/pty.rs @@ -515,12 +515,19 @@ impl PtyRuntime { let home = if cwd == Path::new(".") && tags.get("st3.cwd").is_some_and(|value| value == "home") { - let home = std::env::home_dir().context("resolve default terminal home directory")?; - anyhow::ensure!( - home.is_absolute(), - "default terminal home directory must be absolute" - ); - Some(home) + if let Some(home) = std::env::home_dir() { + anyhow::ensure!( + home.is_absolute(), + "default terminal home directory must be absolute" + ); + Some(home) + } else { + tracing::warn!( + runtime_id = id, + "default terminal home is unavailable; retaining '.' for shell-side home resolution" + ); + None + } } else { None }; diff --git a/docs/seat-lifecycle.md b/docs/seat-lifecycle.md index 7d8ec09606a..78aa60c7485 100644 --- a/docs/seat-lifecycle.md +++ b/docs/seat-lifecycle.md @@ -512,8 +512,10 @@ fractal terminals end terminal/pty/person/ada/UUID `terminals new` prints the new terminal ID. It runs the host's preferred shell (`$SHELL`, falling back to `/bin/sh`) with no agent harness. An omitted `--cwd` (or `"."` through the API) starts in the launching host user's home directory. The runtime resolves that directory to an -absolute path before passing it to PTY, including for remote clients. An explicit `--cwd` is -passed unchanged. The shell does not restart after exit. `end` publishes a durable stop. +absolute path before passing it to PTY, including for remote clients. If the host home cannot +be resolved, it warns and retains `"."`; the shell still attempts to change to `$HOME`. +An explicit `--cwd` is passed unchanged. The shell does not restart after exit. +`end` publishes a durable stop. Only its creator may create or end its declaration. The client crates expose `terminal_create` and `terminal_end` (camel case in TypeScript and Swift). diff --git a/release-notes/default-terminal-absolute-home-cwd.json b/release-notes/default-terminal-absolute-home-cwd.json new file mode 100644 index 00000000000..6baddaccdca --- /dev/null +++ b/release-notes/default-terminal-absolute-home-cwd.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Record the launching host's absolute home directory as the cwd of newly created default API terminals.", + "replay": {"status": "none", "detail": "No graph replay or historical declaration rewrite."}, + "schema": {"status": "none", "detail": "No database, claim schema or stored-data migration."}, + "rules": {"status": "none", "detail": "No checkpoint or fleet coordination rule change."}, + "compatibility": {"status": "changed", "detail": "New default API terminals carry st3.cwd=home intent. Updated launching runtimes resolve that intent to the host user's absolute home before passing cwd to PTY. Explicit directories and untagged relative workspaces are unchanged. Older runtimes accept the tag and retain the shell-side cd to HOME. When the host home cannot be resolved, the runtime warns and retains the previous relative cwd and shell-side home behavior."}, + "downtime": {"status": "none", "detail": "No service interruption required; updated runtimes apply the behavior to new launches."}, + "manual": {"status": "none", "detail": "No manual migration; existing PTY records and declarations remain unchanged."}, + "recovery": {"status": "none", "detail": "No data migration to undo. Older runtimes continue to accept declarations and use their shell-side cd."} +} From b7e037e29c6380e18ba35189d681476961a4415b Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 13:40:17 +0100 Subject: [PATCH 085/132] docs: program engine design (draft) How the daemon runs Fractal.mission(intent, program): a daemon-supervised Bun host per member evaluates bundles; the daemon owns the journal (program.step claims), replay, versioning by bundle hash, run authority, linked child runs, serialized sections, visibility and stall detection, and the data DSL lowers into the same runtime. --- docs/st3/README.md | 2 + docs/st3/program-engine.md | 337 +++++++++++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 docs/st3/program-engine.md diff --git a/docs/st3/README.md b/docs/st3/README.md index 8977f8e2c4f..9b2a6b9be54 100644 --- a/docs/st3/README.md +++ b/docs/st3/README.md @@ -10,6 +10,8 @@ Use these documents for implementation details: - [Architecture](design.md) defines the stable system shape. - [Native runtime invariants](invariants.md) names the current delivery, harness and lifecycle proof obligations. - [Mission graph runtime](mission-graph-runtime.md) defines the KDL language and execution model. +- [Program engine](program-engine.md) is the draft design for missions whose body is a program: where + it runs, its journal, versioning by bundle hash, authority, child runs and visibility. - [Configuration and KDL](../site/reference/configuration.md) defines day-to-day publication and revision workflows. - [Native driver contracts](native-driver-contracts.md) retains launch, scope, helper, harness-state and context obligations after catalog-runner retirement. - [Owned sets](owned-sets.md) defines complete membership publication, source ordering and retirement. diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md new file mode 100644 index 00000000000..41bb227cc10 --- /dev/null +++ b/docs/st3/program-engine.md @@ -0,0 +1,337 @@ +# Program engine + +Status: draft design. The spike on branch `schickling/2026-11-10-daemon-program-engine` implements +§2–§5 for agentless steps. Nothing here is released behavior. + +This document specifies how the daemon runs `Fractal.mission(intent, program)`: a mission whose +body is code that branches on step results. It builds on the [mission graph +runtime](mission-graph-runtime.md) (runs, generations, steps, revisions) and the [schema +registry](schema.md). The preview API it executes is in `docs/site/concepts/steps.md` and +`docs/site/guides/missions-in-practice.md` (Effect variant). + +## Why + +A static mission graph cannot branch on a step's value, pass values between steps, or know which +step failed when it compensates. Factory's landing mission needs all three: it routes a change by +its classes, reviews the merged commit, and reverts exactly what a refused publication brought in. +As data it took five per-class missions, a publish mission per main head, a subscription intake +and run-data side channels. As a program it is one 80-line body. + +Two earlier ways to run it were measured on scratch instances: + +| Run model | Routine change live (median) | Cost | +|---|---|---| +| Data DSL, native graph | 11.4 s | ~3.3 s reconciler hop per step handoff, 1.6–9.6 s subscription hop to publish | +| Per-run engine agent (program in a run-scoped seat) | 6.9 s | ~4.2 s agent launch and first claim per run; the seat needs the union of every step's authority | +| **Daemon program engine (this design, spike)** | **4.0 s** | handoff 0.01–0.02 s; start → first step one reconciler pass | + +## Requirements + +### Durable + +- **R01 Journal before continuing:** a program continues past a step only after the step's result + is durable in the graph. +- **R02 Resume:** a program interrupted by a daemon restart, a host crash or a member restart + continues from its journal without repeating a journaled step. +- **R03 Fenced effects:** a step key has at most one journaled result per attempt. A second, + different result for the same key and attempt is refused. + +### Versioned + +- **R04 Pinned code:** a run executes exactly the bytes its mission revision pins. +- **R05 Old runs finish on old code:** publishing new program code never changes a running + generation; a run moves to new code only by a new generation. +- **R06 No divergent replay:** replay never feeds a journal to different code. + +### Accountable + +- **R07 Steps act as the run:** a program's effects act as the mission run under authority the + mission revision declares, not as an engine agent and not with the union of all steps' authority. + +### Composable + +- **R08 Linked children:** a child run a program starts is linked to the program's step; + cancelling the parent cancels the child, and a failed child is a typed failure in the parent. +- **R09 Serialized sections:** a program can run a section that at most one run executes at a time. + +### Observable + +- **R10 Inner steps are visible:** the run view, `missions show` and `missions waiting-on` show + each inner step's status, timing and failure. +- **R11 Stalls are detected:** a program step that makes no journal progress, or whose host is + gone, raises the same run fault as other stalled work. + +### One model + +- **R12 One execution model:** a mission authored in the data DSL runs on the same engine as a + program; the native step graph stays the substrate for worker claims, gates and products. + +### Fast + +- **R13 Handoff:** the median handoff between two inner steps is under 50 ms on an idle member. +- **R14 First step:** the first inner step starts within one reconciler pass of the run start. + +## Acceptable tradeoffs + +- **T01 At-least-once effects:** an effect interrupted between running and journaling runs again. + Every step effect must be idempotent (merge by `Factory-Change`, revert by `Factory-Revert-Of`). +- **T02 Shared crash domain per member:** one host process runs every program on a member; a host + crash interrupts all of them, and each resumes from its journal. +- **T03 Fresh start on a new generation:** a revised run restarts its program from the top under + the new code; only native steps carry forward. + +## Spec + +### 1. Where program bodies execute + +``` +fractal daemon (Rust) program host (Bun, one per member) +┌──────────────────────────────────────┐ stdin ┌──────────────────────────────────┐ +│ reconciler: program step ready → │──────▶ │ loads bundle@sha256 (once) │ +│ ProgramEngine.dispatch(step, journal)│ │ runs body; Fractal.step → effect │ +│ journal writes (program.step claims) │◀────── │ journal entry → waits for ack │ +│ step outcome (completed/failed) │ stdout │ effects: process groups it owns │ +│ supervision: restart host, resume │ └──────────────────────────────────┘ +└──────────────────────────────────────┘ +``` + +The engine (dispatch, journal, replay, identity, outcome, supervision) lives in the daemon. Only +evaluation of the program's JavaScript is delegated, to one daemon-supervised host process per +member. Options considered: + +| | Embedded JS runtime in the daemon (deno_core/V8 or rquickjs) | **Supervised host per member (chosen)** | Per-run engine agent (prototype) | +|---|---|---|---| +| Start of a run | none | none (host stays warm) | 2.2–3.6 s launch + ~2 s first claim | +| Handoff between steps | in-process | one pipe round trip, 0.01–0.02 s | 0.02–0.05 s, CLI calls | +| A program bug (loop, OOM, crash) | stalls or kills the daemon and every seat | a crash kills the host and the daemon restarts it, programs resume; a busy loop stalls that host's programs, never the daemon (its writes to the host are queued) | kills one run's agent | +| Effect 4 + Node/Bun APIs the step libraries use | V8 needs Node shims; QuickJS lacks them | native Bun | native Bun | +| Daemon build and binary | +V8 (tens of MB, longer builds) | +1 embedded 5 KB script | none | +| Authority | daemon-held | daemon-held; host has no credential | agent identity = union of steps | +| Process count | 0 | 1 per member | 1 per run | + +The host contract is a narrow protocol (§2), so a later embedded runtime can replace the host +without changing a bundle. + +### 2. Host protocol + +One JSON object per line. The pipe is the host's only write path: a host orphaned by a dead daemon +can run an effect to its end but can never journal it. + +| Direction | Message | Fields | Meaning | +|---|---|---|---| +| daemon → host | `start` | `step`, `attempt`, `module`, `sha256`, `payload`, `env`, `cwd`, `journal[]` | start or resume one program step | +| host → daemon | `journal` | `step`, `attempt`, `seq`, `key`, `status` (`started`/`completed`/`failed`), `value`/`error`, `ms` | an inner step began or ended | +| daemon → host | `ack` | `step`, `attempt`, `seq` | the completed/failed entry is durable (R01) | +| daemon → host | `nack` | `step`, `attempt`, `seq`, `error` | the entry could not be written; the program fails with a defect | +| daemon → host | `cancel` | `step`, `attempt`, `reason` | the step ended or moved to another attempt; stop the program and its effects | +| host → daemon | `done` | `step`, `attempt`, `exit` (`{_tag: Success, value}` or `{_tag: Failure, error}`) | the program ended | + +Both sides drop a message for another attempt than the one running, and the daemon drops messages +from a host generation it no longer runs. A host that exits takes exactly its own programs with it; +the next host resumes each from its journal. + +A bundle is one self-contained ES module whose default export carries the entry +`Symbol.for('fractal.program.v1')`. The program API (`Fractal.mission/step/command`) and Effect +are inside the bundle, so the host is version-free and the bundle hash pins the runtime library +too. The host verifies the hash before loading, runs each step effect in its own process group, +and kills those groups on `cancel` and when its stdin closes. The second does not yet hold in every +case (DQ2). + +### 3. Declaration and authority + +```kdl +mission "factory/land-program" state="ready" timeout="2h" { + concurrent-runs max=8 + input "change" kind="text" + goal "Land one change on main and prove it live, or leave main as it was." + authority { publish "scratch/*"; write "resource/factory/*" } // §3, not in the spike + step "program" timeout="1h" { + program "/abs/bundles/land-aff9555cbaf1.mjs" sha256="aff9555c…48f09" + } +} +``` + +`program` makes the step agentless and gives the engine sole ownership of its outcome: the step +may not declare an assignee, gates, products, nested or used missions, or `after-run`, and it may not +be a `finally` step: cancellation cleanup ends final agentless work that has no runtime process, so +a program compensates inside its body. + +Steps act as the run (R07). The engine starts every effect with a run credential scoped to +`(run, step run, attempt)`; the daemon binds a request carrying it to the actor +`mission-run/RUN`. That actor holds what the revision's `authority` block grants, intersected with +the authority of whoever started the run, and nothing else. The daemon writes journal claims as +that actor. Changing `authority` is a mission revision, so factory classifies it as an +authority-class change. In the spike, journal claims are system claims (no actor) and effects call +the CLI as before; agentless execs have the same gap today. + +### 4. Versioning + +A program's revision is its bundle hash: `sha256` is part of the step definition, so it enters the +step's `definition_hash` and the mission revision. A run generation binds one revision, hence one +bundle. + +1. On first dispatch of a revision on a member, the engine reads the declared path, checks its + sha256 and copies the bytes to `drivers/programs/bundles/.mjs`. +2. Every later dispatch and every resume reads that pinned copy, whatever the source path holds now + (R05). +3. A source that no longer matches and was never pinned fails the step: + `program bundle … has sha256 X, but this mission revision pins Y` (R06). +4. Revising a running run creates a new generation with a new step run, hence an empty journal: the + program restarts under the new code (T03). There is no Temporal-style patching. + +Publication target: `fractal apply` of a mission with a `program` stores the bundle as an +immutable blob keyed by its hash, replicated like documents, so the member that runs the step does +not need the author's path (DQ1). + +### 5. Journal + +Each inner step writes registered `program.step` claims on the program's step run subject +(`step-run/GENERATION/PATH`), system-only, append, durable: + +| Field | Type | Meaning | +|---|---|---| +| `key` | string | step identity in this execution: `id`, then `id#2`, `id#3`… for repeats | +| `status` | `started` / `completed` / `failed` | `started` is for visibility only and never replayed | +| `attempt` | integer | the program step's attempt that wrote it | +| `program` | string | the bundle sha256 that wrote it | +| `value` / `error` | any | the encoded success value, or the typed error with `_tag` and fields | +| `ms` | integer | effect duration | + +Idempotency keys: `program:STEP:ATTEMPT:KEY:completed` and `program:STEP:ATTEMPT:KEY:failed`. A +double execution that produces a different result is refused by the store (R03); the daemon then +sends `nack` and the program fails instead of continuing past a result that is not durable. + +Replay reads the subject's claims in pages of 500 inside the daemon (no CLI trace limit) and returns +the `completed` and `failed` entries of the current attempt. A resumed attempt continues where it +stopped. A retried attempt starts from the top, because an earlier attempt's compensation (a revert) +may have undone what its completed steps did. A replayed failure comes back with its `_tag` and +fields, so `catchTag` matches as it did live. + +Step identity in loops: the n-th occurrence of an id in one execution has key `id#n`. That is +deterministic only for a deterministic body, so two concurrent steps with the same id are refused +(a defect naming the id). `Effect.retry` of a step re-runs it as `id#2`, `id#3`, … + +Determinism contract for code between steps: no clock, randomness or I/O outside a step. The program +API offers journaled `Fractal.now` and `Fractal.random` (DQ5). Typed failures are journaled; defects +fail the step (a bug), interrupts (host or daemon gone) are never journaled and resume. + +Long-lived loops do not grow one journal forever: a recurring program is a schedule of finite runs, +and an iterative program that needs more than a few thousand steps runs rounds as child runs (§6). + +### 6. Linked child runs and worker turns + +`Fractal.run(mission, inputs)` asks the daemon to create a child run with +`create_child_mission_run`, the path `uses-mission` already takes: `parent_step_run` is the program +step, the idempotency key is `program:STEP:KEY`, so a resumed program adopts its child instead of +starting another. The child is natively linked: cancelling the parent run cancels the child through +`cancel_descendant_mission_runs_tx` (R08); a capacity-full child start waits (mission graph runtime +§Run ownership). The engine learns the child's terminal state from its own store transition, not by +polling, and journals it under the child step's key. + +A worker turn (`Fractal.step(...).pipe(Effect.provide(Fractal.Worker.layer(agent)))`) is a child run +of a one-step mission whose step is assigned to that agent, with the step's `goal` and `success` +schema; the result is the child's declared product. A run-scoped reviewer +(`agent/${ST_MISSION_RUN}/reviewer`, one-shot) is declared by that child mission, so independence +holds as in LandingMission. + +Cancellation is eager: when the reconciler terminates a program step (run cancelled, timed out), it +tells the engine, which sends `cancel` to the host. The spike cancels lazily, at the program's next +journal write. + +`Fractal.ask` is a native person ask on the program step (`work ask --step` semantics, deadlines and +defaults), resolved through the same journal key. + +### 7. Serialized sections + +Two concurrent landings must not publish one host at once (R09). + +| | Native lane around publish | **Child run of `factory/publish`, `concurrent-runs max=1`** | +|---|---|---| +| Native semantics | a lane needs an owning run that works its front entry | a capacity-full child start waits; already native | +| Visibility | lane entries | a run per publication, with its own journal | +| Cancel | leave the lane | cascades from the landing run | +| Coalescing | the owner can skip superseded heads | a queued publish for an older head finds the host at or past it and is a no-op | +| Cost | lane bookkeeping (0.03 s) plus an owner | child run start (no agent: one reconciler pass) | + +Choice: a child run. It needs no standing owner and reuses §6. + +### 8. Visibility and stall detection + +- `missions show` and the run view project `program.step` claims under the program step: each key + with its latest status, duration and failure, and child runs linked by key. +- `missions waiting-on` names the innermost open key: a running effect, a child run or an ask. +- Stalls: a program step whose journal shows no new entry within its `stalled-after` raises the + run fault with scope `program/stalled`, naming the open key. A dead host that the engine cannot + restart raises `program/host` on every program step it held. + +### 9. One execution model + +The data DSL lowers into a program instead of a step graph. A data-DSL mission becomes a generated +program body whose bundle the publisher pins; the KDL a person reads stays a projection. + +| Data DSL construct | Lowers to | +|---|---| +| `depends-on { step "x" completed }` | `yield*` order | +| sibling steps without mutual dependencies | `Effect.all([...], { concurrency: 'unbounded' })` | +| agentless `exec` step + `exit_code is 0` gate | `Fractal.step({ id }, Fractal.command(argv))` failing on a non-zero exit | +| `assigned-to` worker step, products | worker turn (§6); products are its success schema | +| field / exists / exec gate | a step that reads through the host contract and fails typed | +| human gate | `Fractal.ask` | +| `retry { attempts n }` | `Effect.retry(Schedule.recurs(n - 1))` | +| `finally { revert }` | `Effect.onError` / `catchTag` with the failed key in scope | +| `uses-mission` | `Fractal.run` (§6) | +| run-data side channels | step values | + +Native graph execution remains for published KDL missions until each is republished from the DSL. +The `Fractal.steps.*` catalogue (classify, check, merge, publish, verify, revert, review) is one set +of program steps that both authoring forms call. + +### 10. Priority + +The daemon starts the host directly, so the host and its effects run at the daemon's live-path +priority, never under macOS `taskpolicy -b`. A step that is background work says so on its +command (`Fractal.command(argv, { priority: 'background' })`); this is the program form of +`ST_SEAT_PRIORITY`. + +## Design questions + +- **DQ1 Bundle distribution:** store bundles as immutable replicated blobs at `apply` time, or require + the publisher to place the file on each member? Resolved by the first program that runs on a + member other than its author's. +- **DQ2 Effect processes across a daemon crash:** effects run in their own process groups so a + cancelled program leaves nothing behind. In one of three spike kills, the old effect finished 2.2 s + after the daemon died, beside its resumed copy. Candidate fix: the host joins one process group per + member that the next daemon kills before it starts a host. Resolved by a kill test that asserts no + survivor. +- **DQ3 Host isolation per bundle:** one process for all bundles, or a Bun worker per bundle hash, so + one program's busy loop cannot starve others. +- **DQ4 Large values:** step values above the claim field limit go to documents and the journal keeps + the reference. +- **DQ5 Deterministic helpers:** which non-step sources (clock, randomness, environment) the API + journals, and whether replay checks the body's step sequence against the journal and refuses a + mismatch. +- **DQ6 Execution clock:** whether time a program waits on a child run or an ask stops the step's + timeout clock, as `work wait` does for seats. +- **DQ7 Native graph retirement:** whether native step-graph execution is retired once every + data-DSL mission lowers to a program. +- **DQ8 Host liveness:** a host that is alive but stops reading (a synchronous busy loop) stalls its + programs. A ping with a deadline that kills an unresponsive host, or one host per bundle (DQ3), + would bound it. + +## Spike results (scratch, macOS, load 80–280) + +LandingProgram's routine-change program, unchanged apart from its runtime import, on instance `dpe` +with a local bare repository. Times are seconds after `fractal missions start` returned. + +| Run | First step started | Live (publish done) | Run terminal | +|---|---|---|---| +| 9 routine runs | median 0.70 (0.34–2.05) | **median 3.97 (3.41–5.57)** | median 4.78 (4.39–6.17) | +| admission refused → revert | 0.09 | refused 2.94; **main reverted 3.45** | failed 4.12 (`Reverted`) | +| daemon SIGKILL after merge journaled | — | 6.09 | completed 8.43; one merge commit | +| daemon SIGKILL during check | — | 5.48 | completed 6.51 | +| daemon SIGKILL during publish | — | 6.34 | completed 7.63 | +| run cancelled during check | — | — | cancelled; merge never ran; main unchanged | +| revision pins a different hash | — | — | failed at once with the hash mismatch | + +Inner step handoff: 0.01–0.02 s. The remaining time is the step effects themselves (render, git, +`fractal apply`) and one reconciler pass before the first step and after the last. From 8dcaf3b6b3f71becee712dd234e5142a6645bdcf Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:45:27 +0100 Subject: [PATCH 086/132] docs(web): record read-only pairing status behavior --- .../.changelog/2026-11-10-pairing-readonly-status.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 apps/fractal-web/.changelog/2026-11-10-pairing-readonly-status.ts diff --git a/apps/fractal-web/.changelog/2026-11-10-pairing-readonly-status.ts b/apps/fractal-web/.changelog/2026-11-10-pairing-readonly-status.ts new file mode 100644 index 00000000000..2b9f3d3fce6 --- /dev/null +++ b/apps/fractal-web/.changelog/2026-11-10-pairing-readonly-status.ts @@ -0,0 +1,7 @@ +import type { ChangelogEntrySource } from '../src/changelog/contract.ts' + +export default { + title: 'Keep browser pairing status reads read-only', + summary: 'Checking browser pairing status with GET no longer changes pairing state or cookies. Pairing approval and credential cleanup use explicit same-origin POST requests while preserving the existing pairing flow.', + kind: 'fix', +} satisfies ChangelogEntrySource From 838386726dd4a0fcc0c30ca0df398282e95db3ca Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 14:48:18 +0100 Subject: [PATCH 087/132] docs: accept the program engine design and record its build plan --- docs/st3/README.md | 2 +- docs/st3/program-engine.md | 97 +++++++++++++++++------- release-notes/program-engine-design.json | 11 +++ 3 files changed, 81 insertions(+), 29 deletions(-) create mode 100644 release-notes/program-engine-design.json diff --git a/docs/st3/README.md b/docs/st3/README.md index 9b2a6b9be54..c80ea7a625e 100644 --- a/docs/st3/README.md +++ b/docs/st3/README.md @@ -10,7 +10,7 @@ Use these documents for implementation details: - [Architecture](design.md) defines the stable system shape. - [Native runtime invariants](invariants.md) names the current delivery, harness and lifecycle proof obligations. - [Mission graph runtime](mission-graph-runtime.md) defines the KDL language and execution model. -- [Program engine](program-engine.md) is the draft design for missions whose body is a program: where +- [Program engine](program-engine.md) is the accepted design for missions whose body is a program: where it runs, its journal, versioning by bundle hash, authority, child runs and visibility. - [Configuration and KDL](../site/reference/configuration.md) defines day-to-day publication and revision workflows. - [Native driver contracts](native-driver-contracts.md) retains launch, scope, helper, harness-state and context obligations after catalog-runner retirement. diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md index 41bb227cc10..f057a196875 100644 --- a/docs/st3/program-engine.md +++ b/docs/st3/program-engine.md @@ -1,7 +1,8 @@ # Program engine -Status: draft design. The spike on branch `schickling/2026-11-10-daemon-program-engine` implements -§2–§5 for agentless steps. Nothing here is released behavior. +Status: accepted design (owner, 2026-11-10). The decisions below are taken; the [build +plan](#build-plan) says which milestone ships each part. Behavior this document describes is +released only as far as the milestones that shipped it. This document specifies how the daemon runs `Fractal.mission(intent, program)`: a mission whose body is code that branches on step results. It builds on the [mission graph @@ -25,6 +26,20 @@ Two earlier ways to run it were measured on scratch instances: | Per-run engine agent (program in a run-scoped seat) | 6.9 s | ~4.2 s agent launch and first claim per run; the seat needs the union of every step's authority | | **Daemon program engine (this design, spike)** | **4.0 s** | handoff 0.01–0.02 s; start → first step one reconciler pass | +## Decisions + +Taken by the owner on 2026-11-10: + +| # | Decision | Section | +|---|---|---| +| D1 | Program JavaScript runs in one daemon-supervised Bun host per member; the engine (dispatch, journal, replay, identity, outcome, supervision) is in the daemon. | §1, §2 | +| D2 | Steps act as `mission-run/RUN` with the authority the revision declares, intersected with the starter's authority. | §3 | +| D3 | A program revision is its bundle's sha256. | §4 | +| D4 | The journal is the registered `program.step` claim on the program's step run. | §5 | +| D5 | A serialized section is a child run of a mission with `concurrent-runs max=1`, not a native lane. | §7 | +| D6 | The data DSL compiles into programs: one execution model. | §9 | +| D7 | The program API is the `Fractal` namespace of the `fractal` package, built on Effect's `effect/workflow`: `Fractal.step` is an `Activity`, `Fractal.ask` a `DurableDeferred`, the daemon is the `WorkflowEngine` and its journal is native claims. Mission inputs and outputs are Effect Schemas. | §11 | + ## Requirements ### Durable @@ -144,7 +159,7 @@ mission "factory/land-program" state="ready" timeout="2h" { concurrent-runs max=8 input "change" kind="text" goal "Land one change on main and prove it live, or leave main as it was." - authority { publish "scratch/*"; write "resource/factory/*" } // §3, not in the spike + authority { publish "scratch/*"; write "resource/factory/*" } step "program" timeout="1h" { program "/abs/bundles/land-aff9555cbaf1.mjs" sha256="aff9555c…48f09" } @@ -161,8 +176,7 @@ Steps act as the run (R07). The engine starts every effect with a run credential `mission-run/RUN`. That actor holds what the revision's `authority` block grants, intersected with the authority of whoever started the run, and nothing else. The daemon writes journal claims as that actor. Changing `authority` is a mission revision, so factory classifies it as an -authority-class change. In the spike, journal claims are system claims (no actor) and effects call -the CLI as before; agentless execs have the same gap today. +authority-class change. ### 4. Versioning @@ -170,19 +184,18 @@ A program's revision is its bundle hash: `sha256` is part of the step definition step's `definition_hash` and the mission revision. A run generation binds one revision, hence one bundle. -1. On first dispatch of a revision on a member, the engine reads the declared path, checks its - sha256 and copies the bytes to `drivers/programs/bundles/.mjs`. -2. Every later dispatch and every resume reads that pinned copy, whatever the source path holds now - (R05). -3. A source that no longer matches and was never pinned fails the step: - `program bundle … has sha256 X, but this mission revision pins Y` (R06). +1. `fractal apply` of a mission with a `program` reads the declared path, checks its sha256 and + stores the bytes as a blob keyed by that hash. The step definition names the hash in a + `bundle_hash` field, so the graph replicates the blob with the mission declaration, the way it + replicates every blob a claim's `bundle_hash` names ([attachments](attachments.md#what-travels-and-what-does-not)). + The member that runs the step reads the blob, never the author's path, which stays as provenance. +2. Every dispatch and every resume reads the blob of the revision's hash, whatever the source path + holds now (R05). +3. A declaration whose source no longer matches its sha256 is refused at `apply`; a step whose blob + is missing fails with `program bundle … is not in this member's store` (R06). 4. Revising a running run creates a new generation with a new step run, hence an empty journal: the program restarts under the new code (T03). There is no Temporal-style patching. -Publication target: `fractal apply` of a mission with a `program` stores the bundle as an -immutable blob keyed by its hash, replicated like documents, so the member that runs the step does -not need the author's path (DQ1). - ### 5. Journal Each inner step writes registered `program.step` claims on the program's step run subject @@ -235,8 +248,8 @@ schema; the result is the child's declared product. A run-scoped reviewer holds as in LandingMission. Cancellation is eager: when the reconciler terminates a program step (run cancelled, timed out), it -tells the engine, which sends `cancel` to the host. The spike cancels lazily, at the program's next -journal write. +tells the engine, which sends `cancel` to the host; the host interrupts the program and kills its +effects' process groups. `Fractal.ask` is a native person ask on the program step (`work ask --step` semantics, deadlines and defaults), resolved through the same journal key. @@ -293,16 +306,33 @@ priority, never under macOS `taskpolicy -b`. A step that is background work says command (`Fractal.command(argv, { priority: 'background' })`); this is the program form of `ST_SEAT_PRIORITY`. +### 11. Program API + +The author-facing API is the `Fractal` namespace of the `fractal` package (D7), on `effect/workflow`: + +| `Fractal` | `effect/workflow` | Engine | +|---|---|---| +| `Fractal.mission(intent, program)` | `Workflow.make` with `intent.input` as payload and `intent.output` as success schema | one `program` step; the bundle's entry runs the workflow body for the step run's current attempt | +| `Fractal.step({ id, success, error }, effect)` | `Activity.make` named by the step's key (`id`, `id#2`, …) | a `program.step` entry under that key, replayed by the key | +| `Fractal.ask({ id, for, request, deadline, default })` | `DurableDeferred.make` named by the ask's key | a native person ask on the program step; the answer is journaled under the key | +| `Fractal.run(mission, input)` | a child workflow execution | a linked child run (§6), its terminal result journaled under the key | +| — | `WorkflowEngine` | the host's engine: `activityExecute` returns a journaled result or runs the activity and records it through the host protocol; `deferredResult` reads the journal | + +Inputs and outputs are Effect Schemas. `intent.input` is a Struct: each field renders as a native +mission `input`, `missions start` checks the inputs against it, and the program receives the decoded +value. The success value is encoded with `intent.output` and journaled under the reserved key +`$result`; a typed failure is encoded with its `_tag` and fields and fails the step with it. + +A program waiting on an ask or a child run stays loaded in the host. A replaced host replays the +program to the same wait. + ## Design questions -- **DQ1 Bundle distribution:** store bundles as immutable replicated blobs at `apply` time, or require - the publisher to place the file on each member? Resolved by the first program that runs on a - member other than its author's. -- **DQ2 Effect processes across a daemon crash:** effects run in their own process groups so a - cancelled program leaves nothing behind. In one of three spike kills, the old effect finished 2.2 s - after the daemon died, beside its resumed copy. Candidate fix: the host joins one process group per - member that the next daemon kills before it starts a host. Resolved by a kill test that asserts no - survivor. +- **DQ1 Bundle distribution:** decided: replicated blobs stored at `apply` (§4). +- **DQ2 Effect processes across a daemon crash:** decided: the host records itself and every effect + process group it starts in the engine's state directory and removes an entry when that group ends; + a daemon kills every recorded process and group before it starts a host. Proved by a kill test that + asserts no survivor. - **DQ3 Host isolation per bundle:** one process for all bundles, or a Bun worker per bundle hash, so one program's busy loop cannot starve others. - **DQ4 Large values:** step values above the claim field limit go to documents and the journal keeps @@ -314,9 +344,20 @@ command (`Fractal.command(argv, { priority: 'background' })`); this is the progr timeout clock, as `work wait` does for seats. - **DQ7 Native graph retirement:** whether native step-graph execution is retired once every data-DSL mission lowers to a program. -- **DQ8 Host liveness:** a host that is alive but stops reading (a synchronous busy loop) stalls its - programs. A ping with a deadline that kills an unresponsive host, or one host per bundle (DQ3), - would bound it. +- **DQ8 Host liveness:** decided: the engine pings the host and kills a host that does not answer + within a deadline; its programs resume on the next host. Whether a busy loop should take only its + own bundle down stays DQ3. + +## Build plan + +Each milestone ships as reviewed and merged pull requests, and routine-landing latency is measured +on a scratch instance at each. + +| Milestone | Ships | +|---|---| +| M1 Engine | `program` steps and the `program.step` journal (§5); the supervised host with replay, eager cancellation, the per-member process group (DQ2) and the liveness ping (DQ8); bundles pinned by hash in the member's blob store, read from the declared path on first dispatch; linked child runs with cancellation cascade and serialized sections (§6, §7); the `effect/workflow` binding (§11); mission-run authority (§3); inner steps in the run view and `waiting-on`, and the `program/stalled` and `program/host` faults (§8). | +| M2 Publication | `fractal apply` stores a program mission's bundle as a replicated blob (§4); the `fractal` package's loader renders `Fractal.mission(intent, program)` to a bundle and its KDL declaration, and validates both. Factory's landing mission becomes the first production program. | +| M3 One model | The data DSL compiles into programs (§9), with byte and behaviour parity for existing data-DSL missions; published KDL missions keep running on the native graph until they are republished. | ## Spike results (scratch, macOS, load 80–280) diff --git a/release-notes/program-engine-design.json b/release-notes/program-engine-design.json new file mode 100644 index 00000000000..f66f174e8ef --- /dev/null +++ b/release-notes/program-engine-design.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Documentation only: the accepted program engine design (docs/st3/program-engine.md), with its decisions and build plan", + "replay": {"status": "none", "detail": "Documentation only; no claim, projection or replay change."}, + "schema": {"status": "none", "detail": "Documentation only; no table, index or migration change."}, + "rules": {"status": "none", "detail": "Documentation only; no rule or authority change."}, + "compatibility": {"status": "none", "detail": "Documentation only; no client, wire, CLI or behaviour change."}, + "downtime": {"status": "none", "detail": "No restart or migration."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "Revert the commit."} +} From fe520a9aa204d913d0a7f6fde1e55bcd63b057ac Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:50:11 +0100 Subject: [PATCH 088/132] chore(ci): advance compatibility baselines to v0.3.32 agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .github/fleet-compat-baseline.json | 12 ++++---- .github/messaging-compat-baseline.json | 12 ++++---- release-notes/compat-baselines-v0.3.32.json | 32 +++++++++++++++++++++ 3 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 release-notes/compat-baselines-v0.3.32.json diff --git a/.github/fleet-compat-baseline.json b/.github/fleet-compat-baseline.json index 9e38bf0c098..a7d73f4f201 100644 --- a/.github/fleet-compat-baseline.json +++ b/.github/fleet-compat-baseline.json @@ -1,16 +1,16 @@ { - "commit": "15c8db96ce66921cdeb487bf6978030f8fbfea64", - "tag": "v0.3.31", + "commit": "d853248908ec308713d327f5c666111fbc76e09e", + "tag": "v0.3.32", "why": "Rolling previous-published-release compatibility baseline; advance after each stable release.", "exception_reason": null, "bundles": { "x86_64-unknown-linux-gnu": { - "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.31/fractal-x86_64-unknown-linux-gnu.tar.gz", - "sha256": "78759918a6a487e1a1bacd5d73489fe7560a396911c0cfb639325a413f9f2bdc" + "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.32/fractal-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "fbdd870ea0dbe01fdf1563ea2388f1ff4dccdc67761df6220a8f4684a8d370e7" }, "aarch64-apple-darwin": { - "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.31/fractal-aarch64-apple-darwin.tar.gz", - "sha256": "f5bd99274dc0aaff49e562c09666be28af290157c363b0d441dd203343e7594b" + "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.32/fractal-aarch64-apple-darwin.tar.gz", + "sha256": "387d94c9782b3e2cb741dda7bad1e684c743dfe8318a4a5a829615fc2e46c024" } } } diff --git a/.github/messaging-compat-baseline.json b/.github/messaging-compat-baseline.json index 9e38bf0c098..a7d73f4f201 100644 --- a/.github/messaging-compat-baseline.json +++ b/.github/messaging-compat-baseline.json @@ -1,16 +1,16 @@ { - "commit": "15c8db96ce66921cdeb487bf6978030f8fbfea64", - "tag": "v0.3.31", + "commit": "d853248908ec308713d327f5c666111fbc76e09e", + "tag": "v0.3.32", "why": "Rolling previous-published-release compatibility baseline; advance after each stable release.", "exception_reason": null, "bundles": { "x86_64-unknown-linux-gnu": { - "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.31/fractal-x86_64-unknown-linux-gnu.tar.gz", - "sha256": "78759918a6a487e1a1bacd5d73489fe7560a396911c0cfb639325a413f9f2bdc" + "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.32/fractal-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "fbdd870ea0dbe01fdf1563ea2388f1ff4dccdc67761df6220a8f4684a8d370e7" }, "aarch64-apple-darwin": { - "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.31/fractal-aarch64-apple-darwin.tar.gz", - "sha256": "f5bd99274dc0aaff49e562c09666be28af290157c363b0d441dd203343e7594b" + "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.32/fractal-aarch64-apple-darwin.tar.gz", + "sha256": "387d94c9782b3e2cb741dda7bad1e684c743dfe8318a4a5a829615fc2e46c024" } } } diff --git a/release-notes/compat-baselines-v0.3.32.json b/release-notes/compat-baselines-v0.3.32.json new file mode 100644 index 00000000000..009f23b41c5 --- /dev/null +++ b/release-notes/compat-baselines-v0.3.32.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "summary": "Roll compatibility baselines forward to v0.3.32", + "replay": { + "status": "none", + "detail": "Compatibility test inputs only; no runtime contract or operator change." + }, + "schema": { + "status": "none", + "detail": "Compatibility test inputs only; no runtime contract or operator change." + }, + "rules": { + "status": "none", + "detail": "Compatibility test inputs only; no runtime contract or operator change." + }, + "compatibility": { + "status": "none", + "detail": "Compatibility test inputs only; no runtime contract or operator change." + }, + "downtime": { + "status": "none", + "detail": "Compatibility test inputs only; no runtime contract or operator change." + }, + "manual": { + "status": "none", + "detail": "Compatibility test inputs only; no runtime contract or operator change." + }, + "recovery": { + "status": "none", + "detail": "Compatibility test inputs only; no runtime contract or operator change." + } +} From 10cb65784d3eab8dc337bfc9bccbe3a0a421971d Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 14:56:31 +0100 Subject: [PATCH 089/132] docs(program-engine): attempt-scoped child keys, one result key, safe DQ2 kills, worker liveness --- docs/st3/program-engine.md | 60 +++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md index f057a196875..a7e2526b377 100644 --- a/docs/st3/program-engine.md +++ b/docs/st3/program-engine.md @@ -90,8 +90,9 @@ Taken by the owner on 2026-11-10: - **T01 At-least-once effects:** an effect interrupted between running and journaling runs again. Every step effect must be idempotent (merge by `Factory-Change`, revert by `Factory-Revert-Of`). -- **T02 Shared crash domain per member:** one host process runs every program on a member; a host - crash interrupts all of them, and each resumes from its journal. +- **T02 Shared crash domain per member:** one host process runs every program on a member, each + attempt in its own worker; a host process crash interrupts all of them, and each resumes from its + journal. - **T03 Fresh start on a new generation:** a revised run restarts its program from the top under the new code; only native steps carry forward. @@ -118,7 +119,7 @@ member. Options considered: |---|---|---|---| | Start of a run | none | none (host stays warm) | 2.2–3.6 s launch + ~2 s first claim | | Handoff between steps | in-process | one pipe round trip, 0.01–0.02 s | 0.02–0.05 s, CLI calls | -| A program bug (loop, OOM, crash) | stalls or kills the daemon and every seat | a crash kills the host and the daemon restarts it, programs resume; a busy loop stalls that host's programs, never the daemon (its writes to the host are queued) | kills one run's agent | +| A program bug (loop, OOM, crash) | stalls or kills the daemon and every seat | a crash kills the host and the daemon restarts it, programs resume; a busy loop is confined to its program's worker, which the host terminates after the liveness deadline (DQ8); the daemon's writes to the host are queued, so it never blocks | kills one run's agent | | Effect 4 + Node/Bun APIs the step libraries use | V8 needs Node shims; QuickJS lacks them | native Bun | native Bun | | Daemon build and binary | +V8 (tens of MB, longer builds) | +1 embedded 5 KB script | none | | Authority | daemon-held | daemon-held; host has no credential | agent identity = union of steps | @@ -139,7 +140,12 @@ can run an effect to its end but can never journal it. | daemon → host | `ack` | `step`, `attempt`, `seq` | the completed/failed entry is durable (R01) | | daemon → host | `nack` | `step`, `attempt`, `seq`, `error` | the entry could not be written; the program fails with a defect | | daemon → host | `cancel` | `step`, `attempt`, `reason` | the step ended or moved to another attempt; stop the program and its effects | +| host → daemon | `run` | `step`, `attempt`, `seq`, `key`, `mission`, `input` | start or adopt the linked child run for `key` (§6) | +| host → daemon | `ask` | `step`, `attempt`, `seq`, `key`, `for`, `request`, `deadline`, `default` | open or adopt the person ask for `key` (§6) | +| daemon → host | `resolved` | `step`, `attempt`, `seq`, `key`, `exit` | the child run or ask for `key` ended; its result is journaled | | host → daemon | `done` | `step`, `attempt`, `exit` (`{_tag: Success, value}` or `{_tag: Failure, error}`) | the program ended | +| host → daemon | `unresponsive` | `step`, `attempt`, `ms` | the program's worker missed the liveness deadline and was terminated (DQ8) | +| daemon → host / host → daemon | `ping` / `pong` | `seq` | host liveness (DQ8) | Both sides drop a message for another attempt than the one running, and the daemon drops messages from a host generation it no longer runs. A host that exits takes exactly its own programs with it; @@ -148,9 +154,9 @@ the next host resumes each from its journal. A bundle is one self-contained ES module whose default export carries the entry `Symbol.for('fractal.program.v1')`. The program API (`Fractal.mission/step/command`) and Effect are inside the bundle, so the host is version-free and the bundle hash pins the runtime library -too. The host verifies the hash before loading, runs each step effect in its own process group, -and kills those groups on `cancel` and when its stdin closes. The second does not yet hold in every -case (DQ2). +too. The host verifies the hash before loading and runs each program attempt in its own worker. It +starts each step effect in its own process group and kills those groups on `cancel` and when its +stdin closes; groups that outlive a crashed host or daemon are ended by the next daemon (DQ2). ### 3. Declaration and authority @@ -210,9 +216,10 @@ Each inner step writes registered `program.step` claims on the program's step ru | `value` / `error` | any | the encoded success value, or the typed error with `_tag` and fields | | `ms` | integer | effect duration | -Idempotency keys: `program:STEP:ATTEMPT:KEY:completed` and `program:STEP:ATTEMPT:KEY:failed`. A -double execution that produces a different result is refused by the store (R03); the daemon then -sends `nack` and the program fails instead of continuing past a result that is not durable. +Idempotency key: `program:STEP:ATTEMPT:KEY:result` for both `completed` and `failed`, so a key has +one result per attempt whatever its status, and a double execution that produces a different +result is refused by the store (R03); the daemon then sends `nack` and the program fails instead of +continuing past a result that is not durable. `started` entries are not fenced. Replay reads the subject's claims in pages of 500 inside the daemon (no CLI trace limit) and returns the `completed` and `failed` entries of the current attempt. A resumed attempt continues where it @@ -235,11 +242,12 @@ and an iterative program that needs more than a few thousand steps runs rounds a `Fractal.run(mission, inputs)` asks the daemon to create a child run with `create_child_mission_run`, the path `uses-mission` already takes: `parent_step_run` is the program -step, the idempotency key is `program:STEP:KEY`, so a resumed program adopts its child instead of -starting another. The child is natively linked: cancelling the parent run cancels the child through -`cancel_descendant_mission_runs_tx` (R08); a capacity-full child start waits (mission graph runtime -§Run ownership). The engine learns the child's terminal state from its own store transition, not by -polling, and journals it under the child step's key. +step, the idempotency key is `program:STEP:ATTEMPT:KEY`, so a resumed attempt adopts its child +instead of starting another, while a retried attempt starts a new one (§5). The child is natively +linked: cancelling the parent run cancels the child through `cancel_descendant_mission_runs_tx` +(R08); a capacity-full child start waits (mission graph runtime §Run ownership). The engine learns +the child's terminal state from its own store transition, not by polling, and journals it under the +child step's key. A worker turn (`Fractal.step(...).pipe(Effect.provide(Fractal.Worker.layer(agent)))`) is a child run of a one-step mission whose step is assigned to that agent, with the step's `goal` and `success` @@ -329,12 +337,15 @@ program to the same wait. ## Design questions - **DQ1 Bundle distribution:** decided: replicated blobs stored at `apply` (§4). -- **DQ2 Effect processes across a daemon crash:** decided: the host records itself and every effect - process group it starts in the engine's state directory and removes an entry when that group ends; - a daemon kills every recorded process and group before it starts a host. Proved by a kill test that +- **DQ2 Effect processes across a daemon crash:** decided: each effect's process group ends with its + leader (the host kills the group when the leader exits). The host records each group in the + engine's state directory with the boot id and the leader's start time, and removes the record when + it kills the group. Before it starts a host, a daemon kills each recorded group whose leader still + has the recorded start time, or whose leader is gone while the group lives on, and drops records + from an earlier boot without a kill, so a reused pid is never signalled. Proved by a kill test that asserts no survivor. -- **DQ3 Host isolation per bundle:** one process for all bundles, or a Bun worker per bundle hash, so - one program's busy loop cannot starve others. +- **DQ3 Host isolation:** decided: one host process per member, one worker per program attempt, so + one program's busy loop cannot starve the others (DQ8). - **DQ4 Large values:** step values above the claim field limit go to documents and the journal keeps the reference. - **DQ5 Deterministic helpers:** which non-step sources (clock, randomness, environment) the API @@ -344,9 +355,10 @@ program to the same wait. timeout clock, as `work wait` does for seats. - **DQ7 Native graph retirement:** whether native step-graph execution is retired once every data-DSL mission lowers to a program. -- **DQ8 Host liveness:** decided: the engine pings the host and kills a host that does not answer - within a deadline; its programs resume on the next host. Whether a busy loop should take only its - own bundle down stays DQ3. +- **DQ8 Program liveness:** decided: the host pings each program's worker; a worker that does not + answer within the deadline is terminated and its step fails with the `program/host` fault naming + the deadline, so a deterministic busy loop fails once instead of looping across restarts. The + engine pings the host the same way and replaces a host that does not answer; its programs resume. ## Build plan @@ -355,8 +367,8 @@ on a scratch instance at each. | Milestone | Ships | |---|---| -| M1 Engine | `program` steps and the `program.step` journal (§5); the supervised host with replay, eager cancellation, the per-member process group (DQ2) and the liveness ping (DQ8); bundles pinned by hash in the member's blob store, read from the declared path on first dispatch; linked child runs with cancellation cascade and serialized sections (§6, §7); the `effect/workflow` binding (§11); mission-run authority (§3); inner steps in the run view and `waiting-on`, and the `program/stalled` and `program/host` faults (§8). | -| M2 Publication | `fractal apply` stores a program mission's bundle as a replicated blob (§4); the `fractal` package's loader renders `Fractal.mission(intent, program)` to a bundle and its KDL declaration, and validates both. Factory's landing mission becomes the first production program. | +| M1 Engine | `program` steps and the `program.step` journal (§5); the supervised host with a worker per program, replay, eager cancellation, recorded effect process groups (DQ2) and liveness pings (DQ8); the step definition names its bundle by `bundle_hash` and publishing the declaration stores the blob, so neither the field nor definition hashes change in later milestones (§4); linked child runs with cancellation cascade and serialized sections (§6, §7); the `effect/workflow` binding (§11); mission-run authority (§3); inner steps in the run view and `waiting-on`, and the `program/stalled` and `program/host` faults (§8). | +| M2 Publication | `fractal apply` carries a program mission's bundle to the daemon it publishes on and validates it (§4); the `fractal` package's loader renders `Fractal.mission(intent, program)` to a bundle and its KDL declaration. Factory's landing mission becomes the first production program. | | M3 One model | The data DSL compiles into programs (§9), with byte and behaviour parity for existing data-DSL missions; published KDL missions keep running on the native graph until they are republished. | ## Spike results (scratch, macOS, load 80–280) From e43bca83abaeb02f65bd8bf4caa4aeb11f397fcd Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:04:04 +0100 Subject: [PATCH 090/132] test: synchronize unchanged-through roster publication --- crates/fractal-api-client-v0/src/api/client_v0.rs | 12 +++++++++--- .../fresh-roster-unchanged-through-test-barrier.json | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 release-notes/fresh-roster-unchanged-through-test-barrier.json diff --git a/crates/fractal-api-client-v0/src/api/client_v0.rs b/crates/fractal-api-client-v0/src/api/client_v0.rs index 9ed8761df7d..a0b214145d2 100644 --- a/crates/fractal-api-client-v0/src/api/client_v0.rs +++ b/crates/fractal-api-client-v0/src/api/client_v0.rs @@ -12284,8 +12284,15 @@ mission "differential" state="ready" { "runtime_id":"fresh-roster", "incarnation_id":"one"})); let mut published = state.store.subscribe_agent_roster(); crate::api::start_agent_roster(&state); - tokio::time::timeout(Duration::from_secs(5), published.wait_for(|revision| *revision > 0)) - .await.unwrap().unwrap(); + let complete = { + let store = state.store.clone(); + move |_: &u64| store.published_agent_roster(store.index().unwrap(), false).is_some() + }; + tokio::time::timeout(Duration::from_secs(5), published.wait_for(complete)) + .await.expect("the refresher publishes a complete initial roster").unwrap(); + // Freeze the refresher before advancing the graph so the unchanged-through read + // must use the old publication, not an identical one published at a newer cut. + let _admission = state.store.admit_agent_resources("other").await; let revision = *published.borrow_and_update(); let (_, Json(before)) = client_agents(State(state.clone()), Extension(new_client_snapshot(&state)), Query(ClientListQuery::default())).await.unwrap(); @@ -12313,7 +12320,6 @@ mission "differential" state="ready" { // A claim about the agent changes its card. Start a healthy attempt and hold it: // the fresh read must wait for this attempt's publication, not answer early. - let _admission = state.store.admit_agent_resources("other").await; append(subject, "harness.observed", json!({"state":"working", "driver":"codex", "incarnation_id":"one"})); let written = state.store.index().unwrap(); diff --git a/release-notes/fresh-roster-unchanged-through-test-barrier.json b/release-notes/fresh-roster-unchanged-through-test-barrier.json new file mode 100644 index 00000000000..6b80de4c0b7 --- /dev/null +++ b/release-notes/fresh-roster-unchanged-through-test-barrier.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Test only: hold roster refresh admission after the complete initial publication so unchanged-through assertions cannot race a newer publication of identical cards.", + "replay": {"status": "none", "detail": "Test synchronization only; no replay or recovery behavior changes."}, + "schema": {"status": "none", "detail": "No schema or persistent data changes."}, + "rules": {"status": "none", "detail": "No checkpoint or coordinated upgrade changes."}, + "compatibility": {"status": "none", "detail": "No protocol, API, or production publication selection changes."}, + "downtime": {"status": "none", "detail": "No startup work or expected interruption."}, + "manual": {"status": "none", "detail": "No operator actions or configuration changes."}, + "recovery": {"status": "none", "detail": "No persistent state changes or new swap-back restrictions."} +} From d04f548c7d6218f307092547e5d3949ee7b51cb9 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:07:27 +0100 Subject: [PATCH 091/132] ci: add burst workflow for exact-commit suite runs Dispatch burst.yml on main with sha, suite (cargo-test, nextest, st-test-all), args and request. The job checks out exactly sha, posts a burst/ commit status, keeps the log as burst-log- and ends with one `burst suite=... exit=... seconds=...` line. It runs at priority 3 on the 8x16 Namespace shape (BURST_RUNS_ON overrides) and restores main's perf-cost Nix and Cargo entries. --- .github/workflows/burst.yml | 239 +++++++++++++++++++++++++++ .github/workflows/burst.yml.genie.ts | 152 +++++++++++++++++ docs/ci.md | 38 +++++ release-notes/burst-workflow.json | 11 ++ 4 files changed, 440 insertions(+) create mode 100644 .github/workflows/burst.yml create mode 100644 .github/workflows/burst.yml.genie.ts create mode 100644 release-notes/burst-workflow.json diff --git a/.github/workflows/burst.yml b/.github/workflows/burst.yml new file mode 100644 index 00000000000..bc361420a95 --- /dev/null +++ b/.github/workflows/burst.yml @@ -0,0 +1,239 @@ +# Generated file - DO NOT EDIT +# Source: burst.yml.genie.ts + +name: burst + +run-name: "burst ${{ inputs.suite }} ${{ inputs.sha }} ${{ inputs.request }}" + +on: + workflow_dispatch: + inputs: + sha: + description: Full 40-character commit SHA to test + required: true + type: string + suite: + description: "One of: cargo-test, nextest, st-test-all" + required: true + type: string + args: + description: Extra arguments for cargo-test or nextest, split on spaces + required: false + type: string + default: "" + request: + description: Caller request id ([A-Za-z0-9._-], at most 64) + required: true + type: string + +permissions: + contents: read + statuses: write + +concurrency: + group: "burst-${{ inputs.request }}" + cancel-in-progress: false + +jobs: + burst: + name: burst + runs-on: ${{ fromJSON(vars.BURST_RUNS_ON || format('["nscloud-ubuntu-24.04-amd64-8x16-with-features;job.priority=3","namespace-features:github.run-id={0}"]', github.run_id)) }} + timeout-minutes: 180 + defaults: + run: + shell: bash + env: + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + CARGO_INCREMENTAL: "0" + SHA: ${{ inputs.sha }} + SUITE: ${{ inputs.suite }} + ARGS: ${{ inputs.args }} + REQUEST: ${{ inputs.request }} + RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + steps: + - name: Validate the request + run: | + [ "$GITHUB_REF" = refs/heads/main ] || { echo "::error::burst runs only from main's workflow (got $GITHUB_REF)"; exit 1; } + sha_pattern='^[0-9a-f]{40}$' request_pattern='^[A-Za-z0-9._-]{1,64}$' args_pattern='^[A-Za-z0-9_:./=@ ,+-]*$' + [[ "$SHA" =~ $sha_pattern ]] || { echo "::error::sha must match $sha_pattern"; exit 1; } + [[ "$REQUEST" =~ $request_pattern ]] || { echo "::error::request must match $request_pattern"; exit 1; } + [[ "$ARGS" =~ $args_pattern ]] || { echo "::error::args must match $args_pattern"; exit 1; } + case "$SUITE" in + cargo-test|nextest|st-test-all) ;; + *) echo "::error::suite must be one of: cargo-test nextest st-test-all"; exit 1 ;; + esac + if [ "$SUITE" = st-test-all ] && [ -n "$ARGS" ]; then echo "::error::st-test-all takes no args"; exit 1; fi + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha }} + persist-credentials: false + fetch-depth: 1 + - name: Fetch the whole history without evidence tags + if: inputs.suite == 'st-test-all' + env: + FETCH_TOKEN: ${{ github.token }} + run: | + if [ -z "$(git config --local --get-regexp '^http\..*\.extraheader$' || true)" ]; then + export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$FETCH_TOKEN" | base64 | tr -d '\n')" + export GIT_CONFIG_VALUE_0 + fi + head=$(git rev-parse HEAD) + shallow=$(git rev-parse --git-path shallow) + errors=$(mktemp) + fetch() { + status=0 + git fetch --quiet --refetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' '^refs/tags/evidence/*' "$head" 2> "$errors" || status=$? + cat "$errors" >&2 + return "$status" + } + transport() { grep -Eq 'HTTP (408|429|5[0-9][0-9])|RPC failed|remote end hung up|early EOF|Connection (timed out|reset)|Operation timed out' "$errors"; } + [ ! -f "$shallow" ] || mv "$shallow" "$shallow.full-fetch" + if ! fetch && ! { transport && echo 'full history fetch: transport failure; trying once more' >&2 && fetch; }; then + [ ! -f "$shallow.full-fetch" ] || mv "$shallow.full-fetch" "$shallow" + exit 1 + fi + rm -f "$shallow.full-fetch" + git fsck --connectivity-only --no-dangling --no-progress + - name: Require the requested commit + run: 'head=$(git rev-parse HEAD); [ "$head" = "$SHA" ] || { echo "::error::checked out $head, not $SHA"; exit 1; }' + - name: Report the run as pending + id: pending + env: + GH_TOKEN: ${{ github.token }} + run: | + curl -fsS --retry 3 -X POST \ + -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$SHA" \ + -d "{\"state\":\"pending\",\"context\":\"burst/$SUITE\",\"description\":\"request=$REQUEST\",\"target_url\":\"$RUN_URL\"}" > /dev/null + - name: Use the runner's own caches + run: | + if [ -n "${CI_LOCAL_CARGO_HOME:-}" ]; then echo CI_LOCAL_CACHES=1 >> "$GITHUB_ENV"; fi + printf 'CARGO_HOME=%s\nCI_CACHE_DIR=%s\n' "${CI_LOCAL_CARGO_HOME:-$RUNNER_TEMP/cargo-home}" "$RUNNER_TEMP/fractal-ci-cache" >> "$GITHUB_ENV" + - name: Restore the Cargo target and registry + id: cargo-cache + if: env.CI_LOCAL_CACHES != '1' && env.CI_BUILD_SNAPSHOT_HIT != '1' + uses: actions/cache/restore@v4 + with: + path: | + ${{ github.workspace }}/target + ${{ runner.temp }}/cargo-home/registry + ${{ runner.temp }}/cargo-home/git + key: "cargo-perf-cost-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'flake.lock', 'Cargo.toml', 'crates/**/Cargo.toml', '.cargo/config.toml') }}" + restore-keys: "cargo-perf-cost-${{ runner.os }}-" + - name: Restore the local Nix cache + id: nix-cache + if: env.CI_LOCAL_CACHES != '1' && env.CI_BUILD_SNAPSHOT_HIT != '1' + uses: actions/cache/restore@v4 + with: + path: "${{ runner.temp }}/fractal-ci-cache" + key: "nix5-perf-cost-${{ runner.os }}-${{ hashFiles('flake.lock', 'flake.nix', 'nix/**/*.nix', '.github/fleet-compat-baseline.json', '.github/messaging-compat-baseline.json') }}" + restore-keys: | + nix5-perf-cost-${{ runner.os }}- + nix4-perf-cost-${{ runner.os }}- + - # v3.24.1 + name: Install Nix + uses: DeterminateSystems/determinate-nix-action@92957199488000b908fdad19f63711000965d8f3 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + extra-conf: | + experimental-features = nix-command flakes + accept-flake-config = true + connect-timeout = 10 + stalled-download-timeout = 60 + download-attempts = 3 + extra-substituters = https://overeng-effect-utils.cachix.org + extra-trusted-public-keys = overeng-effect-utils.cachix.org-1:KFmqYNF6Q7ZzVYPl2znpJYZGEolage9YNCA9res6vKc= + access-tokens = github.com=${{ github.token }} + summarize: true + - name: Isolate test home and XDG state + run: | + # Cargo's home and the CI cache directory live under RUNNER_TEMP, not HOME: tests get an isolated HOME, + # and actions/cache expands a leading tilde against the HOME of the step that runs it. + printf 'CARGO_HOME=%s\nCI_CACHE_DIR=%s\n' "${CI_LOCAL_CARGO_HOME:-$RUNNER_TEMP/cargo-home}" "$RUNNER_TEMP/fractal-ci-cache" >> "$GITHUB_ENV" + home="$RUNNER_TEMP/test-home" + mkdir -p "$home" "$home/.config" "$home/.cache" "$home/.local/state" + printf 'HOME=%s\nXDG_CONFIG_HOME=%s/.config\nXDG_CACHE_HOME=%s/.cache\nXDG_STATE_HOME=%s/.local/state\n' "$home" "$home" "$home" "$home" >> "$GITHUB_ENV" + - name: Use the cached Nix outputs + if: env.CI_LOCAL_CACHES != '1' + run: 'bash scripts/ci-nix-cache use || echo "::warning::the local Nix cache is unavailable; this run builds everything"' + - name: Materialize megarepo members + env: + MEGAREPO_STORE: "${{ runner.temp }}/megarepo" + run: | + python3 scripts/prepare-pnpm-source-inputs.py apply + printf 'MEGAREPO_STORE=%s\n' "$MEGAREPO_STORE" >> "$GITHUB_ENV" + if: inputs.suite == 'st-test-all' + - name: Run the suite + id: suite + env: + ST_TEST_ALL: | + python3 - <<'BURST_ST_TEST_ALL' + import importlib.machinery, importlib.util, shlex, subprocess, sys + loader = importlib.machinery.SourceFileLoader('st_test', 'scripts/st-test') + module = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader)) + loader.exec_module(module) + skip = set(module.KEYED) | {module.RUST_TESTS, module.WEB} + status = 0 + for name, argv in module.LOCAL.items(): + if name in skip: + continue + print(f'burst: {name}: {shlex.join(argv)}', flush=True) + code = subprocess.run(argv, stdin=subprocess.DEVNULL).returncode + print(f'burst: {name} exit={code}', flush=True) + status = status or code + sys.exit(status) + BURST_ST_TEST_ALL + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + run: | + read -r -a argv <<< "$ARGS" + case "$SUITE" in + cargo-test) command=(nix develop -c cargo test --locked "${argv[@]}") ;; + nextest) command=(nix develop -c cargo nextest run --locked "${argv[@]}") ;; + st-test-all) command=(bash -c "$ST_TEST_ALL") ;; + esac + log="$RUNNER_TEMP/burst.log" + start=$(date +%s) + code=0 + set -o pipefail + "${command[@]}" 2>&1 | tee "$log" || code=$? + line="burst suite=$SUITE sha=$SHA request=$REQUEST exit=$code seconds=$(( $(date +%s) - start ))" + printf 'exit=%s\nline=%s\n' "$code" "$line" >> "$GITHUB_OUTPUT" + printf '%s\n' "$line" >> "$GITHUB_STEP_SUMMARY" + printf '%s\n' "$line" | tee -a "$log" + - name: Retain the suite log + if: always() && steps.suite.outcome != 'skipped' + uses: actions/upload-artifact@v4 + with: + name: "burst-log-${{ inputs.request }}" + path: "${{ runner.temp }}/burst.log" + if-no-files-found: warn + retention-days: 14 + - name: Report the result on the commit + if: always() && steps.pending.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + EXIT: ${{ steps.suite.outputs.exit }} + run: | + if [ -z "$EXIT" ]; then state=error; elif [ "$EXIT" = 0 ]; then state=success; else state=failure; fi + curl -fsS --retry 3 -X POST \ + -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$SHA" \ + -d "{\"state\":\"$state\",\"context\":\"burst/$SUITE\",\"description\":\"exit=${EXIT:-none} request=$REQUEST\",\"target_url\":\"$RUN_URL\"}" > /dev/null + - name: Finish with the suite exit + if: always() + env: + EXIT: ${{ steps.suite.outputs.exit }} + LINE: ${{ steps.suite.outputs.line }} + run: | + [ -n "$EXIT" ] || { echo "::error::the suite did not run"; exit 1; } + printf '%s\n' "$LINE" + exit "$EXIT" + - name: Report job cache coverage + if: always() + shell: bash + env: + CACHE_ENTRIES: '[{"name":"Restore the Cargo target and registry","outcome":"${{ steps.cargo-cache.outcome }}","hit":"${{ steps.cargo-cache.outputs.cache-hit }}","matched":"${{ steps.cargo-cache.outputs.cache-matched-key }}","opaqueFallback":false,"sourceOnly":false,"avoidable":"","lookup":false},{"name":"Restore the local Nix cache","outcome":"${{ steps.nix-cache.outcome }}","hit":"${{ steps.nix-cache.outputs.cache-hit }}","matched":"${{ steps.nix-cache.outputs.cache-matched-key }}","opaqueFallback":false,"sourceOnly":false,"avoidable":"","lookup":false}]' + run: 'python3 "${CI_CACHE_AUDIT_SCRIPT:-scripts/ci-cache-audit}"' diff --git a/.github/workflows/burst.yml.genie.ts b/.github/workflows/burst.yml.genie.ts new file mode 100644 index 00000000000..aa7ab06710e --- /dev/null +++ b/.github/workflows/burst.yml.genie.ts @@ -0,0 +1,152 @@ +import { auditCaches } from './cache-audit.ts' +import { buildSnapshotPrepare, buildSnapshotRestore } from './build-snapshot.ts' +import { scopeLinuxNixSandbox } from './nix-sandbox-scope.ts' +import { defaultActionlintConfig, githubWorkflow } from '../../repos/effect-utils/genie/external.ts' +import { buildEnv, cargoCacheStep, commonSetupSteps, fullHistoryCheckoutSteps, megarepoApplyStep, nixCacheStep } from './workspace-ci.ts' + +/** + * Burst: one allowlisted suite on one exact commit, dispatched on demand (docs/ci.md, "Burst runs"). + * The suite commands come from this workflow on main; the scripts they call come from the requested commit. + * The dispatch input, the checkout and the `burst/` commit status name the same SHA. + */ +const suites = ['cargo-test', 'nextest', 'st-test-all'] as const + +// Optional work: the same 8x16 shape and lowest queue class as Performance, below merge groups and PR checks. +// `vars.BURST_RUNS_ON` (a JSON label array) replaces it without a workflow change. +const defaultRunsOn = JSON.stringify([ + 'nscloud-ubuntu-24.04-amd64-8x16-with-features;job.priority=3', + 'namespace-features:github.run-id={0}', +]) +const runsOn = `\${{ fromJSON(vars.BURST_RUNS_ON || format('${defaultRunsOn}', github.run_id)) }}` + +// Main upkeep's perf-cost job keeps the default dev shell's Nix outputs and a Cargo home (registry, proc macros, +// build scripts) on main; restoring them keeps a burst run from starting fully cold. Restore only: burst saves nothing. +const fromPerfCost = }>(step: T): T => ({ + ...step, + with: Object.fromEntries(Object.entries(step.with).map(([key, value]) => [key, value.replaceAll('${{ github.job }}', 'perf-cost')])), +}) +const setupSteps = commonSetupSteps + .filter((step) => step !== buildSnapshotRestore && step !== buildSnapshotPrepare + && !('uses' in step && String(step.uses).startsWith('actions/checkout@')) + && !('name' in step && step.name === 'Fetch the whole history without evidence tags')) + .map((step) => step === cargoCacheStep ? fromPerfCost(cargoCacheStep) : step === nixCacheStep ? fromPerfCost(nixCacheStep) : step) + +const validate = `[ "$GITHUB_REF" = refs/heads/main ] || { echo "::error::burst runs only from main's workflow (got $GITHUB_REF)"; exit 1; } +sha_pattern='^[0-9a-f]{40}$' request_pattern='^[A-Za-z0-9._-]{1,64}$' args_pattern='^[A-Za-z0-9_:./=@ ,+-]*$' +[[ "$SHA" =~ $sha_pattern ]] || { echo "::error::sha must match $sha_pattern"; exit 1; } +[[ "$REQUEST" =~ $request_pattern ]] || { echo "::error::request must match $request_pattern"; exit 1; } +[[ "$ARGS" =~ $args_pattern ]] || { echo "::error::args must match $args_pattern"; exit 1; } +case "$SUITE" in + ${suites.join('|')}) ;; + *) echo "::error::suite must be one of: ${suites.join(' ')}"; exit 1 ;; +esac +if [ "$SUITE" = st-test-all ] && [ -n "$ARGS" ]; then echo "::error::st-test-all takes no args"; exit 1; fi` + +const postStatus = (state: string, description: string) => `curl -fsS --retry 3 -X POST \\ + -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' \\ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$SHA" \\ + -d "{\\"state\\":\\"${state}\\",\\"context\\":\\"burst/$SUITE\\",\\"description\\":\\"${description}\\",\\"target_url\\":\\"$RUN_URL\\"}" > /dev/null` + +// st-test-all: every whole-run check in scripts/st-test's LOCAL table at this commit, unsigned and in its order. +// Report readers (linux-clippy, linux-fleet-compat, rust-tests) read linux-gate's run; fractal-web is path-filtered +// and stays with Workspace CI. Every check runs; the first failure's exit is the suite's. +const stTestAll = `python3 - <<'BURST_ST_TEST_ALL' +import importlib.machinery, importlib.util, shlex, subprocess, sys +loader = importlib.machinery.SourceFileLoader('st_test', 'scripts/st-test') +module = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader)) +loader.exec_module(module) +skip = set(module.KEYED) | {module.RUST_TESTS, module.WEB} +status = 0 +for name, argv in module.LOCAL.items(): + if name in skip: + continue + print(f'burst: {name}: {shlex.join(argv)}', flush=True) + code = subprocess.run(argv, stdin=subprocess.DEVNULL).returncode + print(f'burst: {name} exit={code}', flush=True) + status = status or code +sys.exit(status) +BURST_ST_TEST_ALL` + +const runSuite = `read -r -a argv <<< "$ARGS" +case "$SUITE" in + cargo-test) command=(nix develop -c cargo test --locked "\${argv[@]}") ;; + nextest) command=(nix develop -c cargo nextest run --locked "\${argv[@]}") ;; + st-test-all) command=(bash -c "$ST_TEST_ALL") ;; +esac +log="$RUNNER_TEMP/burst.log" +start=$(date +%s) +code=0 +set -o pipefail +"\${command[@]}" 2>&1 | tee "$log" || code=$? +line="burst suite=$SUITE sha=$SHA request=$REQUEST exit=$code seconds=$(( $(date +%s) - start ))" +printf 'exit=%s\\nline=%s\\n' "$code" "$line" >> "$GITHUB_OUTPUT" +printf '%s\\n' "$line" >> "$GITHUB_STEP_SUMMARY" +printf '%s\\n' "$line" | tee -a "$log"` + +export default githubWorkflow(scopeLinuxNixSandbox(auditCaches({ + name: 'burst', + 'run-name': 'burst ${{ inputs.suite }} ${{ inputs.sha }} ${{ inputs.request }}', + on: { + workflow_dispatch: { + inputs: { + sha: { description: 'Full 40-character commit SHA to test', required: true, type: 'string' }, + suite: { description: `One of: ${suites.join(', ')}`, required: true, type: 'string' }, + args: { description: 'Extra arguments for cargo-test or nextest, split on spaces', required: false, type: 'string', default: '' }, + request: { description: 'Caller request id ([A-Za-z0-9._-], at most 64)', required: true, type: 'string' }, + }, + }, + }, + permissions: { contents: 'read', statuses: 'write' }, + concurrency: { group: 'burst-${{ inputs.request }}', 'cancel-in-progress': false }, + actionlint: defaultActionlintConfig, + jobs: { + burst: { + name: 'burst', + 'runs-on': runsOn, + 'timeout-minutes': 180, + defaults: { run: { shell: 'bash' } }, + env: { + ...buildEnv, + SHA: '${{ inputs.sha }}', + SUITE: '${{ inputs.suite }}', + ARGS: '${{ inputs.args }}', + REQUEST: '${{ inputs.request }}', + RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}', + }, + steps: [ + { name: 'Validate the request', run: validate }, + // st-test-all's checks read the history (genie freshness, the CI script self-tests); the other suites need one commit. + ...fullHistoryCheckoutSteps({ ref: '${{ inputs.sha }}', 'persist-credentials': false }, {}, "inputs.suite == 'st-test-all'"), + { + name: 'Require the requested commit', + run: 'head=$(git rev-parse HEAD); [ "$head" = "$SHA" ] || { echo "::error::checked out $head, not $SHA"; exit 1; }', + }, + { name: 'Report the run as pending', id: 'pending', env: { GH_TOKEN: '${{ github.token }}' }, run: postStatus('pending', 'request=$REQUEST') }, + ...setupSteps, + { ...megarepoApplyStep, if: "inputs.suite == 'st-test-all'" }, + { name: 'Run the suite', id: 'suite', env: { ST_TEST_ALL: stTestAll, COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' }, run: runSuite }, + { + name: 'Retain the suite log', + if: "always() && steps.suite.outcome != 'skipped'", + uses: 'actions/upload-artifact@v4', + with: { name: 'burst-log-${{ inputs.request }}', path: '${{ runner.temp }}/burst.log', 'if-no-files-found': 'warn', 'retention-days': 14 }, + }, + { + name: 'Report the result on the commit', + if: "always() && steps.pending.outcome == 'success'", + env: { GH_TOKEN: '${{ github.token }}', EXIT: '${{ steps.suite.outputs.exit }}' }, + run: `if [ -z "$EXIT" ]; then state=error; elif [ "$EXIT" = 0 ]; then state=success; else state=failure; fi +${postStatus('$state', 'exit=${EXIT:-none} request=$REQUEST')}`, + }, + { + name: 'Finish with the suite exit', + if: 'always()', + env: { EXIT: '${{ steps.suite.outputs.exit }}', LINE: '${{ steps.suite.outputs.line }}' }, + run: `[ -n "$EXIT" ] || { echo "::error::the suite did not run"; exit 1; } +printf '%s\\n' "$LINE" +exit "$EXIT"`, + }, + ], + }, + }, +}))) diff --git a/docs/ci.md b/docs/ci.md index c8f09ded951..e996e25e2af 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1519,6 +1519,7 @@ release-integration and the default shell. | `daily-long.yml` | Scheduled and manually dispatched systemd-scope, mail, compatibility, full-scale and performance coverage on main; reports failures through its daily issue. | | `release-daily.yml` | Publishes the newest successful main release archives when the source changes; see [binary releases](st3/binary-releases.md#daily-releases). | | `public-repo.yml` | Public-content checks on PRs, merge groups, main pushes and dispatch, on GitHub-hosted Ubuntu. | +| `burst.yml` | One allowlisted suite on one exact commit, by manual dispatch on main; see [burst runs](#burst-runs). | The content guard still rejects real machine/home identities and private fleet configuration references. Release workflows remain separate from the required gate; neither package @@ -1575,6 +1576,43 @@ and [profile concurrency controls](https://namespace.so/docs/solutions/github-ac for the scheduler's limits, and GitHub's [merge queue settings](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue) for the distinction between build concurrency and merge batch size. +## Burst runs + +`burst.yml` (generated from `burst.yml.genie.ts`) runs one allowlisted suite on one exact commit, +on demand, in place of a long local build-host queue. It is not a required check and changes +no evidence or merge-queue rule. + +```sh +gh workflow run burst.yml --ref main -f sha=FULL_SHA -f suite=cargo-test \ + -f args='-p fractal-runtime --lib' -f request=REQUEST_ID +``` + +| Suite | Command at the requested commit | +| --- | --- | +| `cargo-test` | `nix develop -c cargo test --locked ARGS` | +| `nextest` | `nix develop -c cargo nextest run --locked ARGS` | +| `st-test-all` | Every whole-run check in `scripts/st-test`'s local table, in its order and unsigned: today `typescript-client`, `genie-freshness`, `ci-script-tests` and `linux-gate` (`scripts/ci-local tests clippy fleet-compat`). The report readers sign nothing here, and the path-filtered `fractal-web` stays with Workspace CI. Takes no `args`. | + +`sha` must be 40 lowercase hex characters, `request` must match `^[A-Za-z0-9._-]{1,64}$`, and `args` +must match `^[A-Za-z0-9_:./=@ ,+-]*$`. `args` reaches the command only through the environment and +is split on spaces. Only a dispatch of main's workflow runs. The suite commands come from that +workflow; the scripts they call come from the requested commit, which the job checks out with +its credentials dropped and refuses unless `HEAD` is exactly `sha`. Runs sharing a `request` queue +behind each other and are never cancelled. + +The job posts a `burst/SUITE` commit status on `sha`: pending with the run's URL, then success +exactly when the suite exited 0, with the description `exit=CODE request=REQUEST`. The suite log +is the `burst-log-REQUEST` artifact. The last line of the suite step and the job summary is +`burst suite=SUITE sha=SHA request=REQUEST exit=CODE seconds=N`, and the job fails exactly when +`CODE` is not 0. + +The job runs on the 8x16 Namespace shape at priority 3, the same class as Performance, so it +never outranks merge groups or required pull request work. The repository variable +`BURST_RUNS_ON` (a JSON label array) replaces that runner without a workflow change. Builds use the +fleet's dev/test settings. The job restores, but never saves, main upkeep's `perf-cost` Nix and +Cargo entries: the dev shell's tools and the crate registry are warm, and build units the two +builds share (such as build scripts) are reused; `perf-cost`'s own optimized units are not. + ## Superseded merge groups The public guard scopes push concurrency to protected main; a new main push cancels diff --git a/release-notes/burst-workflow.json b/release-notes/burst-workflow.json new file mode 100644 index 00000000000..a169e341bbd --- /dev/null +++ b/release-notes/burst-workflow.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Add a manually dispatched burst workflow that runs one allowlisted suite (cargo-test, nextest or st-test-all) on an exact commit and reports a burst/SUITE commit status", + "replay": {"status": "none", "detail": "No claim replay or recovery behavior changes."}, + "schema": {"status": "none", "detail": "No tables, indexes, triggers, schema-version changes or data rewrites."}, + "rules": {"status": "none", "detail": "No checkpoint rules or digest changes; no coordinated member upgrade required."}, + "compatibility": {"status": "none", "detail": "CI only. A new workflow_dispatch workflow; required checks, evidence rules and the merge queue are unchanged."}, + "downtime": {"status": "none", "detail": "No restart."}, + "manual": {"status": "none", "detail": "No action required. Callers may dispatch burst.yml on main; the optional BURST_RUNS_ON repository variable overrides its runner."}, + "recovery": {"status": "none", "detail": "No persistent state. Removing the workflow removes the capability; existing burst/SUITE statuses stay on their commits."} +} From 86a47d8815458785aace3b2aed3a1786e6807219 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:10:12 +0100 Subject: [PATCH 092/132] Parse, render and store a mission's run authority block --- crates/fractal-kernel/src/model.rs | 37 ++++ crates/fractal-missions-base/src/mission.rs | 162 +++++++++++++++++- .../src/mission_render.rs | 22 ++- docs/st3/mission-graph-runtime.md | 12 ++ .../mission-run-authority-block.json | 11 ++ 5 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 release-notes/mission-run-authority-block.json diff --git a/crates/fractal-kernel/src/model.rs b/crates/fractal-kernel/src/model.rs index 9ba505849f9..1fb3089a4d9 100644 --- a/crates/fractal-kernel/src/model.rs +++ b/crates/fractal-kernel/src/model.rs @@ -899,6 +899,39 @@ pub struct DeclaredResourceReference { pub reason: Option, } +/// One rule of a mission's `authority` block: what its runs may do as `mission-run/RUN` +/// (docs/st3/program-engine.md §3). Mission verbs take a mission ID pattern, `write` a subject +/// pattern; either is exact or a terminal `/*` namespace. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct AuthorityGrant { + pub verb: AuthorityVerb, + pub pattern: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthorityVerb { + Publish, + Start, + Revise, + Cancel, + Write, +} + +impl AuthorityVerb { + pub const ALL: [Self; 5] = [Self::Publish, Self::Start, Self::Revise, Self::Cancel, Self::Write]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Publish => "publish", + Self::Start => "start", + Self::Revise => "revise", + Self::Cancel => "cancel", + Self::Write => "write", + } + } +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct MissionSpec { pub id: String, @@ -911,6 +944,10 @@ pub struct MissionSpec { pub system: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub resources: Vec, + /// What runs of this mission may do as `mission-run/RUN`, in canonical order; empty grants + /// nothing. Omitted when empty, so an undeclared mission hashes as it did before the field. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authority: Vec, #[serde(default)] pub inputs: BTreeMap, #[serde(default = "default_mission_run_limit")] diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index d8f53486697..0c2884286f0 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -7,10 +7,10 @@ use sha2::{Digest as _, Sha256}; use crate::declaration_syntax::*; use crate::model::{ - BaselineSpec, CompletionSpec, DependencySpec, GateSpec, LoopAttentionSpec, LoopExhaustionSpec, - LoopSpec, LoopStopSpec, MetricSource, MetricSpec, MissionInputKind, MissionInputSpec, - MissionSpec, MissionState, ProductSpec, RetrySpec, RevisionCutover, FractalError, StepSpec, - UsedMissionSpec, WorkSelector, + AuthorityGrant, AuthorityVerb, BaselineSpec, CompletionSpec, DependencySpec, GateSpec, + LoopAttentionSpec, LoopExhaustionSpec, LoopSpec, LoopStopSpec, MetricSource, MetricSpec, + MissionInputKind, MissionInputSpec, MissionSpec, MissionState, ProductSpec, RetrySpec, + RevisionCutover, FractalError, StepSpec, UsedMissionSpec, WorkSelector, }; pub const LOOP_ROUND_INPUT: &str = "__st3_loop_round"; @@ -308,6 +308,7 @@ fn parse_mission( let mut revision_owners = Vec::new(); let mut system = false; let mut capacity = None; + let mut authority = None; for child in children.nodes() { match child.name().value() { "declared-priority" | "declared-lane" | "declared-budgets" => {} @@ -353,6 +354,21 @@ fn parse_mission( } resources.push(reference); } + "authority" if top_level => { + if authority.is_some() { + return Err(FractalError::new( + "duplicate-mission-field", + format!("mission `{id}` repeats `authority`"), + )); + } + authority = Some(parse_authority(child, &id)?); + } + "authority" => { + return Err(FractalError::new( + "nested-mission-authority", + format!("mission `{id}` is nested; declare authority on the top-level mission"), + )); + } "concurrent-runs" => { if concurrent_runs_seen { return Err(FractalError::new( @@ -565,6 +581,7 @@ fn parse_mission( declared_lane: admission.lane, declared_budgets: admission.budgets, resources, + authority: authority.unwrap_or_default(), inputs, max_active_runs, timeout_ms, @@ -597,6 +614,86 @@ fn parse_mission( Ok(mission) } +/// `authority { VERB "PATTERN" … }`: what runs of a top-level mission may do as +/// `mission-run/RUN` (docs/st3/program-engine.md §3). The shape of an agent's `*-authority` +/// block; `publish`, `start`, `revise` and `cancel` take mission IDs as `mission-authority` does, +/// `write` takes a subject. The rules are kept in canonical order, so reordering is no revision. +fn parse_authority(node: &KdlNode, id: &str) -> Result, FractalError> { + ensure_bare(node)?; + let empty = || { + FractalError::new( + "empty-authority", + format!("mission `{id}` authority needs at least one rule"), + ) + }; + let body = node.children().ok_or_else(empty)?; + let mut grants = BTreeSet::new(); + for rule in body.nodes() { + let name = rule.name().value(); + let verb = AuthorityVerb::ALL + .into_iter() + .find(|verb| verb.as_str() == name) + .ok_or_else(|| { + FractalError::new( + "invalid-authority", + format!( + "mission `{id}` authority cannot contain `{name}`; its rules are publish, start, revise, cancel and write" + ), + ) + })?; + ensure_no_properties(rule)?; + ensure_no_children(rule)?; + let pattern = one_string(rule)?; + validate_authority_pattern(verb, &pattern)?; + if !grants.insert(AuthorityGrant { verb, pattern: pattern.clone() }) { + return Err(FractalError::new( + "duplicate-authority", + format!("mission `{id}` repeats authority `{name} {pattern}`"), + )); + } + } + if grants.is_empty() { + return Err(empty()); + } + Ok(grants.into_iter().collect()) +} + +/// Exact, or a terminal `/*` namespace covering everything under it: a mission ID without +/// `mission/` for the mission verbs, a subject (or a bare registered family before `/*`) for +/// `write`. +fn validate_authority_pattern(verb: AuthorityVerb, pattern: &str) -> Result<(), FractalError> { + let invalid = || { + FractalError::new( + "invalid-authority-pattern", + format!( + "authority `{} {pattern}` needs {} or a terminal `/*` namespace", + verb.as_str(), + if verb == AuthorityVerb::Write { "an exact subject" } else { "an exact mission ID without `mission/`" }, + ), + ) + }; + let namespace = pattern.strip_suffix("/*"); + let exact = namespace.unwrap_or(pattern); + if exact.is_empty() || exact.contains('*') { + return Err(invalid()); + } + if verb != AuthorityVerb::Write { + if exact.starts_with("mission/") { + return Err(invalid()); + } + return validate_mission_id(exact).map_err(|_| invalid()); + } + // A namespace is a registered family, alone or followed by non-empty path segments. + let registry = fractal_schema::registry(); + let Some(prefix) = namespace else { + return registry.validate_subject(exact).map(|_| ()).map_err(|_| invalid()); + }; + let well_formed = registry.subject(&format!("{prefix}/_")).is_some() + && !prefix.split('/').any(str::is_empty) + && !prefix.chars().any(|character| character.is_whitespace() || character.is_control()); + well_formed.then_some(()).ok_or_else(invalid) +} + fn parse_loop_group( node: &KdlNode, parent_mission: &str, @@ -3230,4 +3327,61 @@ mod tests { .unwrap(); assert_eq!(command, format!("printf '%s' '{message}'")); } + + fn authority_error(body: &str) -> String { + let source = format!(r#"mission "land" state="ready" {{ goal "Land."; step "work" {{ agentless; }}; {body} }}"#); + super::parse_missions(&source.parse().unwrap(), "node").unwrap_err().code + } + + #[test] + fn mission_authority_parses_in_canonical_order_and_enters_the_revision() { + use crate::model::{AuthorityGrant, AuthorityVerb}; + let parse = |authority: &str| { + let source = format!(r#"mission "land" state="ready" {{ goal "Land."; step "work" {{ agentless; }}; {authority} }}"#); + super::parse_missions(&source.parse().unwrap(), "node").unwrap().remove("land").unwrap() + }; + let mission = parse(r#"authority { write "resource/factory/*"; publish "scratch/*"; start "factory/publish"; cancel "factory/*"; revise "factory/*"; write "custom/factory/landing"; write "resource/*" }"#); + let grant = |verb, pattern: &str| AuthorityGrant { verb, pattern: pattern.into() }; + assert_eq!(mission.authority, [ + grant(AuthorityVerb::Publish, "scratch/*"), + grant(AuthorityVerb::Start, "factory/publish"), + grant(AuthorityVerb::Revise, "factory/*"), + grant(AuthorityVerb::Cancel, "factory/*"), + grant(AuthorityVerb::Write, "custom/factory/landing"), + grant(AuthorityVerb::Write, "resource/*"), + grant(AuthorityVerb::Write, "resource/factory/*"), + ]); + let reordered = parse(r#"authority { revise "factory/*"; cancel "factory/*"; start "factory/publish"; publish "scratch/*"; write "resource/*"; write "resource/factory/*"; write "custom/factory/landing" }"#); + assert_eq!(reordered.revision, mission.revision); + let narrower = parse(r#"authority { revise "factory/*"; cancel "factory/*"; start "factory/publish"; publish "scratch/*"; write "resource/factory/*"; write "custom/factory/landing" }"#); + assert_ne!(narrower.revision, mission.revision); + let undeclared = parse(""); + assert!(undeclared.authority.is_empty()); + assert!(serde_json::to_value(&undeclared).unwrap().get("authority").is_none()); + } + + #[test] + fn mission_authority_refusals() { + for (body, code) in [ + (r#"authority"#, "empty-authority"), + (r#"authority {}"#, "empty-authority"), + (r#"authority "none""#, "unexpected-value"), + (r#"authority { write "resource/a"; }; authority { write "resource/b"; }"#, "duplicate-mission-field"), + (r#"authority { write "resource/a"; write "resource/a"; }"#, "duplicate-authority"), + (r#"authority { move "agent/a"; }"#, "invalid-authority"), + (r#"authority { write "resource/a" mode="audit"; }"#, "unknown-property"), + (r#"authority { write "resource/*/x"; }"#, "invalid-authority-pattern"), + (r#"authority { write "resource/a*"; }"#, "invalid-authority-pattern"), + (r#"authority { write "*"; }"#, "invalid-authority-pattern"), + (r#"authority { write "unregistered/a"; }"#, "invalid-authority-pattern"), + (r#"authority { write "unregistered/*"; }"#, "invalid-authority-pattern"), + (r#"authority { write "resource"; }"#, "invalid-authority-pattern"), + (r#"authority { start "mission/factory"; }"#, "invalid-authority-pattern"), + (r#"authority { start "factory/*/land"; }"#, "invalid-authority-pattern"), + (r#"authority { publish "/*"; }"#, "invalid-authority-pattern"), + (r#"step "outer" { mission "inner" { goal "Inner."; authority { write "resource/a"; }; step "x" { agentless; }; }; }"#, "nested-mission-authority"), + ] { + assert_eq!(authority_error(body), code, "{body}"); + } + } } diff --git a/crates/fractal-missions-base/src/mission_render.rs b/crates/fractal-missions-base/src/mission_render.rs index 5e1fb6b1ea5..38575cd9784 100644 --- a/crates/fractal-missions-base/src/mission_render.rs +++ b/crates/fractal-missions-base/src/mission_render.rs @@ -35,7 +35,7 @@ fn mission_node(mission: &MissionSpec) -> Result { fn mission_body(mission: &MissionSpec, parent: Option<&MissionSpec>) -> Result, FractalError> { // Exhaustive destructuring makes a new MissionSpec field a renderer coverage decision. - let MissionSpec { id: _, subject: _, state: _, revision: _, system, resources, inputs, + let MissionSpec { id: _, subject: _, state: _, revision: _, system, resources, authority, inputs, max_active_runs, timeout_ms: _, report_to: _, stalled_after_ms: _, report_stalls: _, report_completed: _, revision_owners: _, revisions_human_only: _, revision_reviewer: _, revision_cutover: _, declarations_kdl: _, capacity, work_selector, completion, goals, @@ -71,6 +71,10 @@ fn mission_body(mission: &MissionSpec, parent: Option<&MissionSpec>) -> Result>(); + assert_eq!(rules, ["publish scratch/*", "start factory/publish", "write resource/factory/*"]); + } + #[test] fn canonical_declared_admission_retains_typed_metadata() { let mut spec = parse(r#"mission "test" state="ready" { goal "Keep declaration metadata."; }"#); diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index a060aa19457..a8009f56104 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -194,6 +194,7 @@ mission "MISSION_ID" assigned-to "agent/${ST_MISSION_RUN}/owner" // Or repeat available-to. A mission cannot declare agentless. capacity cores=2 memory="4G" // the lane of each agent here that declares none + authority { start "factory/publish"; write "resource/factory/*" } // top-level only completion { when "all-steps-exhausted" } // Or: completion { depends-on { step "publish" completed } } @@ -235,6 +236,17 @@ An exact idempotent retry returns its existing run before the capacity check. A The reviewer defaults to the mission run requester. `revision-cutover` is `restart-active` by default or `when-idle` when declared. The value in the current generation controls how its successor starts; a candidate cannot select its own cutover. +`authority` is optional and belongs on a top-level mission only. It says what the mission's runs +may do as the actor `mission-run/RUN` when a program step acts as the run +([program engine §3](program-engine.md#3-declaration-and-authority)): one `VERB "PATTERN"` rule per +line, in the shape of an agent's `*-authority` blocks. `publish`, `start`, `revise` and `cancel` take +a mission ID without `mission/`; `write` takes a subject, or a registered family alone, before +`/*`. A pattern is exact or ends in `/*`, which covers everything under it. Parsing refuses an empty +block, a repeated block or rule, an unknown verb, any other `*`, and the block in a nested or loop +mission. Rules are stored in canonical order, so reordering them is not a revision; any other change +is. A mission without the block grants its runs nothing. The block is stored with the revision; +the daemon does not yet bind a run actor or enforce it. + A mission can contain direct declarations. Direct agents in the mission can revise the complete mission. A mission can contain zero steps. Missions default to `completion { when "all-steps-exhausted" }`, diff --git a/release-notes/mission-run-authority-block.json b/release-notes/mission-run-authority-block.json new file mode 100644 index 00000000000..bdf822d6d4f --- /dev/null +++ b/release-notes/mission-run-authority-block.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "A top-level mission can declare `authority { VERB \"PATTERN\" }`: what its runs may do as `mission-run/RUN` (publish, start, revise and cancel missions by ID, write subjects); it parses, renders and is stored with the revision, and is not enforced yet", + "replay": {"status": "none", "detail": "No claim kind or projection change. `mission.published` bodies of missions that declare the block carry a new optional `authority` array in the mission spec; absent otherwise."}, + "schema": {"status": "none", "detail": "No table, index or migration change."}, + "rules": {"status": "changed", "detail": "New declared authority grammar for mission runs. Nothing binds a run actor or enforces it in this change; later changes bind program steps to `mission-run/RUN` and enforce the block intersected with the starter's authority."}, + "compatibility": {"status": "changed", "detail": "An undeclared mission's revision hash is unchanged (the field is omitted when empty; pinned by an existing hash test). A member on an older build refuses a mission revision that declares `authority` as an unknown mission child, so publish the block only once every active member runs this build, as for program steps."}, + "downtime": {"status": "none", "detail": "No restart or migration beyond a normal deploy."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "Remove the `authority` block and publish again to return to an undeclared revision."} +} From 77b91d78c425a6463d8ec67a5571ef2b4fcc50d2 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:15:21 +0100 Subject: [PATCH 093/132] Hold run authority publication until every member reads it --- .../src/store/mission_capacity.rs | 125 ++++++++++++++---- crates/fractal/src/main.rs | 2 +- docs/st3/large-value-sites.json | 2 +- docs/st3/mission-graph-runtime.md | 6 +- .../mission-run-authority-block.json | 2 +- 5 files changed, 104 insertions(+), 33 deletions(-) diff --git a/crates/fractal-store/src/store/mission_capacity.rs b/crates/fractal-store/src/store/mission_capacity.rs index e59e8f3aa00..3a9eb7cbde2 100644 --- a/crates/fractal-store/src/store/mission_capacity.rs +++ b/crates/fractal-store/src/store/mission_capacity.rs @@ -1,12 +1,39 @@ -//! A mission revision that declares `capacity`, on the mission or on a step, is published only -//! once every current member reads the field. A member whose build does not checks a replicated -//! revision by hashing the mission as it reads it, without the field, and quarantines the claim -//! (docs/st3/seat-lanes.md, "Mixed versions"). Each member advertises the field on its own +//! A mission revision that declares a field an older build cannot read (`capacity` on the +//! mission or a step, or the mission's run `authority`) is published only once every current +//! member reads the field. A member whose build does not checks a replicated revision by hashing +//! the mission as it reads it, without the field, and quarantines the claim +//! (docs/st3/seat-lanes.md, "Mixed versions"). Each member advertises each field on its own //! `daemon.started`, as the standby barrier reads `schedule_standby`. use super::*; /// The `daemon.started` feature of a build that reads mission and step capacity. pub const FEATURE: &str = "mission_capacity"; +/// The `daemon.started` feature of a build that reads a mission's run `authority`. +pub const AUTHORITY_FEATURE: &str = "mission_authority"; + +/// One mission field behind a fleet barrier: the feature that says a member reads it, the +/// refusal code, and what to do once every member is upgraded. +struct Barrier { + feature: &'static str, + code: &'static str, + declares: fn(&MissionSpec) -> bool, + later: &'static str, +} + +const BARRIERS: [Barrier; 2] = [ + Barrier { + feature: FEATURE, + code: "mission-capacity-unavailable", + declares: declares_capacity, + later: "declare capacity on a mission or step", + }, + Barrier { + feature: AUTHORITY_FEATURE, + code: "mission-authority-unavailable", + declares: |mission| !mission.authority.is_empty(), + later: "declare authority on a mission", + }, +]; const WRITER_TABLES: [(&str, &str); 3] = [ ("replica_envelopes", "writer"), ("batches", "origin"), ("checkpoint_envelopes", "writer"), @@ -15,10 +42,6 @@ fn writer_seek(table: &str, column: &str) -> String { format!("SELECT {column} FROM {table} WHERE {column}>?1 ORDER BY {column} LIMIT 1") } -fn refused(reason: impl Into) -> FractalError { - FractalError::new("mission-capacity-unavailable", reason) -} - fn declares_capacity(mission: &MissionSpec) -> bool { mission.capacity.is_some() || mission.steps.values().any(|step| { @@ -26,15 +49,17 @@ fn declares_capacity(mission: &MissionSpec) -> bool { }) } -/// Refuses each new mission revision in `intent` that declares capacity while a current member -/// has not advertised [`FEATURE`]. A revision already published passes, and so does a host with -/// no other member. +/// Refuses each new mission revision in `intent` that declares a field behind a barrier while a +/// current member has not advertised its feature. A revision already published passes, and so +/// does a host with no other member. pub(super) fn validate_publication( connection: &Connection, origin: &str, intent: &NormalizedIntent, ) -> Result<(), FractalError> { - for mission in intent.missions.values().filter(|mission| declares_capacity(mission)) { + for mission in intent.missions.values() + .filter(|mission| BARRIERS.iter().any(|barrier| (barrier.declares)(mission))) + { let published: Option = connection .query_row( "SELECT revision FROM mission_definitions WHERE mission_id=?1", @@ -55,9 +80,15 @@ pub(super) fn validate_mission( origin: &str, mission: &MissionSpec, ) -> Result<(), FractalError> { - if !declares_capacity(mission) { - return Ok(()); + for barrier in BARRIERS.iter().filter(|barrier| (barrier.declares)(mission)) { + validate_barrier(connection, origin, barrier)?; } + Ok(()) +} + +fn validate_barrier(connection: &Connection, origin: &str, barrier: &Barrier) -> Result<(), FractalError> { + let Barrier { feature, code, later, .. } = barrier; + let refused = |reason: String| FractalError::new(*code, reason); let membership = fleet_membership_tx(connection).map_err(internal)?; for member in membership.incarnations().filter(|member| member.end.is_none()) { // This host runs this build. @@ -66,14 +97,14 @@ pub(super) fn validate_mission( } if !matches!(membership.state(&member.name), crate::fleet::MemberState::Current(_)) { return Err(refused(format!( - "the fleet membership of host/{} is ambiguous, so its build cannot be checked; declare capacity on a mission or step once it is settled", + "the fleet membership of host/{} is ambiguous, so its build cannot be checked; {later} once it is settled", member.name ))); } - let advertised: Option> = connection + let features: Option> = connection .query_row( - /* sqlite-large-value: allow-for-now lv-h20cb3d2e4d9e owner=fractal_store::store::mission_capacity TODO=trace-statement-and-value-representation */ &canonical_sql( - "SELECT json_extract(claims.body,'$.fields.features.mission_capacity')=1 + /* sqlite-large-value: allow-for-now lv-h88ff6fefeb9f owner=fractal_store::store::mission_capacity TODO=trace-statement-and-value-representation */ &canonical_sql( + "SELECT json_extract(claims.body,'$.fields.features') FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='daemon.started' AND claims.origin=?2 AND batches.replica_sequence>=?3 ORDER BY CANONICAL_DESC(claims) LIMIT 1", @@ -83,9 +114,12 @@ pub(super) fn validate_mission( ) .optional() .map_err(internal)?; - if advertised.flatten() != Some(true) { + let advertised = features.flatten() + .and_then(|features| serde_json::from_str::(&features).ok()) + .is_some_and(|features| features.get(*feature) == Some(&json!(1))); + if !advertised { return Err(refused(format!( - "host/{} runs an older build that would refuse this revision (it has not advertised {FEATURE}=1); declare capacity on a mission or step once every member is upgraded", + "host/{} runs an older build that would refuse this revision (it has not advertised {feature}=1); {later} once every member is upgraded", member.name ))); } @@ -102,7 +136,7 @@ pub(super) fn validate_mission( { if writer != origin && !membership.is_keyed_writer(&writer) && !fenced.contains(&writer) { return Err(refused(format!( - "`{writer}` replicates to this host outside the fleet membership, so its build cannot be checked; declare capacity on a mission or step once it is a member or fenced" + "`{writer}` replicates to this host outside the fleet membership, so its build cannot be checked; {later} once it is a member or fenced" ))); } previous = writer; @@ -150,14 +184,8 @@ mission "plain" state="ready" { json!({"status": "running", "features": {FEATURE: u8::from(reads_capacity)}})); } - #[test] - fn a_revision_that_declares_capacity_waits_until_every_member_reads_it() { - // A host alone publishes at once, with or without a fleet. - publish(&Store::open_memory("alder").unwrap(), LANED, "alone").unwrap(); - let anchored = Store::open_memory("alder").unwrap(); - enable(&anchored); - publish(&anchored, LANED, "anchored-alone").unwrap(); - + /// `alder` with `birch` admitted, synced and started on a build that reads no barrier field. + fn two_members() -> (Store, Store) { let store = Store::open_memory("alder").unwrap(); let anchor = enable(&store); let peer_key = Arc::new(MemberKey::generate().unwrap().0); @@ -171,6 +199,18 @@ mission "plain" state="ready" { sync(&store, &peer); started(&peer, false); sync(&peer, &store); + (store, peer) + } + + #[test] + fn a_revision_that_declares_capacity_waits_until_every_member_reads_it() { + // A host alone publishes at once, with or without a fleet. + publish(&Store::open_memory("alder").unwrap(), LANED, "alone").unwrap(); + let anchored = Store::open_memory("alder").unwrap(); + enable(&anchored); + publish(&anchored, LANED, "anchored-alone").unwrap(); + + let (store, peer) = two_members(); // A member on an older build would quarantine the revision: the plan shows why and the // publication is refused. A mission without capacity is unaffected. @@ -202,4 +242,31 @@ mission "plain" state="ready" { assert!(publish(&store, LANED, "upgraded").unwrap().is_empty()); assert_eq!(store.mission_spec("laned", None).unwrap().unwrap().capacity, mission.capacity); } + + #[test] + fn a_revision_that_declares_run_authority_waits_until_every_member_reads_it() { + const AUTHORIZED: &str = r#"version 2 +mission "authorized" state="ready" { + goal "Work." + authority { write "resource/example/*"; } + step "work" { agentless; } +} +"#; + publish(&Store::open_memory("alder").unwrap(), AUTHORIZED, "alone").unwrap(); + let (store, peer) = two_members(); + // Reading capacity is not reading authority. + claim(&peer, "daemon/birch", "daemon.started", None, + json!({"status": "running", "features": {FEATURE: 1}})); + sync(&peer, &store); + let refusal = publish(&store, AUTHORIZED, "older").unwrap_err(); + assert_eq!(refusal.code, "mission-authority-unavailable"); + assert!(refusal.message.contains("has not advertised mission_authority=1; declare authority on a mission once every member is upgraded"), + "{}", refusal.message); + assert!(store.mission_spec("authorized", None).unwrap().is_none()); + claim(&peer, "daemon/birch", "daemon.started", None, + json!({"status": "running", "features": {FEATURE: 1, AUTHORITY_FEATURE: 1}})); + sync(&peer, &store); + assert!(publish(&store, AUTHORIZED, "upgraded").unwrap().is_empty()); + assert_eq!(store.mission_spec("authorized", None).unwrap().unwrap().authority.len(), 1); + } } diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index c74d4983c76..3419819b32c 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -7467,7 +7467,7 @@ async fn run_up(args: UpArgs) -> Result<()> { fields: BTreeMap::from([ ( "features".into(), - serde_json::json!({"owned_sets":1,"seat_rollout":1,"seat_rollout_manual":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"agent_charter":1,"schedule_standby":1,"github_plugin":2,"seat_freshness":1,"seat_freshness_reads":1,"message_offsets":1,"message_view_reads":1,"message_staged_reads":1,"mission_capacity":1, + serde_json::json!({"owned_sets":1,"seat_rollout":1,"seat_rollout_manual":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"agent_charter":1,"schedule_standby":1,"github_plugin":2,"seat_freshness":1,"seat_freshness_reads":1,"message_offsets":1,"message_view_reads":1,"message_staged_reads":1,"mission_capacity":1,"mission_authority":1, "harness_access_auto": u8::from(config.active_access_mode() == fractal_drivers::access::AccessMode::Auto), "messages_stream": u8::from(messages_shard.is_ok()), // Counts its stream mail in its idle, unread, held and usage indexes. It diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 7e0db80d782..df875d31391 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -1336,7 +1336,6 @@ {"id": "lv-h20b4ce3791b8", "path": "crates/fractal-store/src/store/published_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "00e16953f852b16396fd7199a772beaa8544e3ecb26b3e18f6e8461c55971338", "owner": "fractal_store::store::published_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 435, "excerpt": "CHILD_RUNS"}, {"id": "lv-h20b935346aaf", "path": "crates/fractal-store/src/store/checkpoint_tests.rs", "kind": "sql-template", "fingerprint": "a466c1bb6842b49c1d34a00a24fdcb27190d6711850fee20c555c3c6c8bfc672", "owner": "fractal_store::store::checkpoint_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 493, "excerpt": "UPDATE claims SET accepted_at_unix_ms=?1 WHERE id=?2"}, {"id": "lv-h20bffcbd0763", "path": "crates/smallclaims/src/store/checkpoint_staging.rs", "kind": "sql-template", "fingerprint": "f742addfcfcc6af8ad5172ff290f31172a85780c82d523ed4e99b3b71e76f2e2", "owner": "smallclaims::store::checkpoint_staging", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 455, "excerpt": "INSERT INTO meta(id, identity, phase, cursor) VALUES (1, ?1, ?2, 0)"}, - {"id": "lv-h20cb3d2e4d9e", "path": "crates/fractal-store/src/store/mission_capacity.rs", "kind": "unresolved-sql-sink", "fingerprint": "e318d0369b4e7bf334d1427a20489fefe9c9d84d83b7c4eb72f6a2ca2749c235", "owner": "fractal_store::store::mission_capacity", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 75, "excerpt": "& canonical_sql ( \"SELECT json_extract(claims.body,'$.fields.features.mission_capacity')=1 FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='daemon.starte"}, {"id": "lv-h20ede67dacaa", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "6d5b306c813337790dcc59d3ea0244058138d994c293b1d1b62b4d21f61d94ca", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1608, "excerpt": "params ! [ subject , store_index ] , | row | row . get : : < _ , u64 > ( 0 )"}, {"id": "lv-h20ff92cef130", "path": "crates/fractal-store/src/store/resources_preparation_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::resources_preparation_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1352, "excerpt": "DELETE FROM claims WHERE id=?1"}, {"id": "lv-h21121b909ab8", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2beeceba08cc58c8d4101eebee3d7bdc5ff8260a291b89568ff5ea3680d6d8c7", "owner": "fractal_missions::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6497, "excerpt": "& format ! ( \"SELECT json_extract(claims.body, '$.fields.state'), claims.accepted_at_unix_ms FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='harness.obs"}, @@ -3128,6 +3127,7 @@ {"id": "lv-h88d82d562560", "path": "crates/fractal-missions/src/store/eta_tests.rs", "kind": "sql-template", "fingerprint": "7648f29922e944fcaaf997a490ebfc96315a26ef7da5badc340a90e8a2ecba8b", "owner": "fractal_missions::store::eta_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 158, "excerpt": "INSERT INTO local_run_eta_samples SELECT 'extra',run_id,definition_hash,step_path,2,duration FROM local_run_eta_samples WHERE subject='sample-0'"}, {"id": "lv-h88ea458f2e12", "path": "crates/fractal-attention/src/store/attention_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "a42ff4c289a7e734672cb0b0042e0fbce20df6f7f3fdbdb1fae520e39ff319ee", "owner": "fractal_attention::store::attention_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 425, "excerpt": "params ! [ after , through , i64 : : try_from ( now ) . unwrap_or ( i64 : : MAX ) ] , | row | row . get ( 0 )"}, {"id": "lv-h88ebca0a4792", "path": "crates/fractal-seats/src/store/usage_period.rs", "kind": "unresolved-sql-sink", "fingerprint": "6c2ed65f6add7630a3428cd5a407227fa4efd13a56a9d5bfcc0e68db8bb931da", "owner": "fractal_seats::store::usage_period", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 22, "excerpt": "& format ! ( \"SELECT id,subject,store_index,body FROM claims INDEXED BY {INDEX} WHERE {PREDICATE} AND {series}=?1 AND {observed}=?2 LIMIT 2\" )"}, + {"id": "lv-h88ff6fefeb9f", "path": "crates/fractal-store/src/store/mission_capacity.rs", "kind": "unresolved-sql-sink", "fingerprint": "54829f8306fffea3dfe5d84e805a161bde817b5ae41888c4f253d3879ed5b995", "owner": "fractal_store::store::mission_capacity", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 106, "excerpt": "& canonical_sql ( \"SELECT json_extract(claims.body,'$.fields.features') FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='daemon.started' AND claims.origi"}, {"id": "lv-h8911c1538a9c", "path": "crates/fractal-store/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "8edb630b01a1f78d4abc5ee08b714ce7a8578b35d2269aec45684bf19ac1e4fc", "owner": "fractal_store::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 447, "excerpt": "& query"}, {"id": "lv-h89145e643a90", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "6972dcbe1981439c50004941304392991f4b835e7c273105cb3287dede71d3a4", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 471, "excerpt": "UPDATE checkpoint_envelopes SET envelope_hash=?1 WHERE sequence>=2000000"}, {"id": "lv-h8928d49246cd", "path": "crates/fractal-store/src/store/backup_ledgers.rs", "kind": "sql-template", "fingerprint": "0783930aa84e56d8b6563d8387b70ad63b424bdc19a08715e60f9f7ebe73fdb5", "owner": "fractal_store::store::backup_ledgers", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 145, "excerpt": "INSERT OR IGNORE INTO local_message_marks(message, kind, consumer, recorded_at_unix_ms, receipt) \\ VALUES ({0}, {1}, {2}, {3}, {4})"}, diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index a8009f56104..9b4a4c3d5ab 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -245,7 +245,11 @@ a mission ID without `mission/`; `write` takes a subject, or a registered family block, a repeated block or rule, an unknown verb, any other `*`, and the block in a nested or loop mission. Rules are stored in canonical order, so reordering them is not a revision; any other change is. A mission without the block grants its runs nothing. The block is stored with the revision; -the daemon does not yet bind a run actor or enforce it. +the daemon does not yet bind a run actor or enforce it. An older member would quarantine a +revision that declares it (it hashes the mission without the field), so publication holds such a +revision until every member advertises `mission_authority=1` on its `daemon.started` +(`mission-authority-unavailable`, also an `apply --dry-run` blocker), as for `capacity` +([seat lanes](seat-lanes.md)). A mission can contain direct declarations. Direct agents in the mission can revise the complete mission. diff --git a/release-notes/mission-run-authority-block.json b/release-notes/mission-run-authority-block.json index bdf822d6d4f..91c1df15a69 100644 --- a/release-notes/mission-run-authority-block.json +++ b/release-notes/mission-run-authority-block.json @@ -4,7 +4,7 @@ "replay": {"status": "none", "detail": "No claim kind or projection change. `mission.published` bodies of missions that declare the block carry a new optional `authority` array in the mission spec; absent otherwise."}, "schema": {"status": "none", "detail": "No table, index or migration change."}, "rules": {"status": "changed", "detail": "New declared authority grammar for mission runs. Nothing binds a run actor or enforces it in this change; later changes bind program steps to `mission-run/RUN` and enforce the block intersected with the starter's authority."}, - "compatibility": {"status": "changed", "detail": "An undeclared mission's revision hash is unchanged (the field is omitted when empty; pinned by an existing hash test). A member on an older build refuses a mission revision that declares `authority` as an unknown mission child, so publish the block only once every active member runs this build, as for program steps."}, + "compatibility": {"status": "changed", "detail": "An undeclared mission's revision hash is unchanged (the field is omitted when empty; pinned by an existing hash test). Each daemon advertises `mission_authority=1` on `daemon.started`; a new revision that declares `authority` is refused with `mission-authority-unavailable` (and shown as an `apply --dry-run` blocker) while any current member has not advertised it, the barrier `capacity` already uses, now one table of mission-field barriers. An older CLI refuses a file that declares `authority` at apply as an unknown mission child."}, "downtime": {"status": "none", "detail": "No restart or migration beyond a normal deploy."}, "manual": {"status": "none", "detail": "No operator action is required."}, "recovery": {"status": "none", "detail": "Remove the `authority` block and publish again to return to an undeclared revision."} From b429edc66380287c2022896c30ba8e139531e68a Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:16:23 +0100 Subject: [PATCH 094/132] ci: post burst statuses from jobs that never check out the commit The suite job runs the requested commit with a contents:read token only. Separate pending and report jobs hold statuses:write: pending validates the inputs, report posts success/failure from the suite exit and error when the suite never finished. Document that request ids are unique per dispatch. --- .github/workflows/burst.yml | 104 +++++++++++++++++++-------- .github/workflows/burst.yml.genie.ts | 66 ++++++++++++----- docs/ci.md | 22 +++--- 3 files changed, 135 insertions(+), 57 deletions(-) diff --git a/.github/workflows/burst.yml b/.github/workflows/burst.yml index bc361420a95..d395015426e 100644 --- a/.github/workflows/burst.yml +++ b/.github/workflows/burst.yml @@ -22,30 +22,28 @@ on: type: string default: "" request: - description: Caller request id ([A-Za-z0-9._-], at most 64) + description: Unique request id per dispatch ([A-Za-z0-9._-], at most 64) required: true type: string permissions: contents: read - statuses: write concurrency: group: "burst-${{ inputs.request }}" cancel-in-progress: false jobs: - burst: - name: burst - runs-on: ${{ fromJSON(vars.BURST_RUNS_ON || format('["nscloud-ubuntu-24.04-amd64-8x16-with-features;job.priority=3","namespace-features:github.run-id={0}"]', github.run_id)) }} - timeout-minutes: 180 + pending: + name: pending + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + statuses: write defaults: run: shell: bash env: - CARGO_PROFILE_DEV_DEBUG: "0" - CARGO_PROFILE_TEST_DEBUG: "0" - CARGO_INCREMENTAL: "0" SHA: ${{ inputs.sha }} SUITE: ${{ inputs.suite }} ARGS: ${{ inputs.args }} @@ -64,6 +62,40 @@ jobs: *) echo "::error::suite must be one of: cargo-test nextest st-test-all"; exit 1 ;; esac if [ "$SUITE" = st-test-all ] && [ -n "$ARGS" ]; then echo "::error::st-test-all takes no args"; exit 1; fi + - name: Report the run as pending + env: + GH_TOKEN: ${{ github.token }} + run: | + curl -fsS --retry 3 -X POST \ + -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$SHA" \ + -d "{\"state\":\"pending\",\"context\":\"burst/$SUITE\",\"description\":\"request=$REQUEST\",\"target_url\":\"$RUN_URL\"}" > /dev/null + - name: Report job cache coverage + if: always() + shell: bash + env: + CACHE_REASON: Validates the request and posts a commit status without a checkout. + run: "printf 'Cache coverage: **not applicable** — %s\\n' \"$CACHE_REASON\" >> \"$GITHUB_STEP_SUMMARY\"" + burst: + name: burst + needs: [pending] + runs-on: ${{ fromJSON(vars.BURST_RUNS_ON || format('["nscloud-ubuntu-24.04-amd64-8x16-with-features;job.priority=3","namespace-features:github.run-id={0}"]', github.run_id)) }} + timeout-minutes: 180 + defaults: + run: + shell: bash + env: + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + CARGO_INCREMENTAL: "0" + SHA: ${{ inputs.sha }} + SUITE: ${{ inputs.suite }} + ARGS: ${{ inputs.args }} + REQUEST: ${{ inputs.request }} + RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + outputs: + exit: ${{ steps.suite.outputs.exit }} + steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.sha }} @@ -98,15 +130,6 @@ jobs: git fsck --connectivity-only --no-dangling --no-progress - name: Require the requested commit run: 'head=$(git rev-parse HEAD); [ "$head" = "$SHA" ] || { echo "::error::checked out $head, not $SHA"; exit 1; }' - - name: Report the run as pending - id: pending - env: - GH_TOKEN: ${{ github.token }} - run: | - curl -fsS --retry 3 -X POST \ - -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' \ - "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$SHA" \ - -d "{\"state\":\"pending\",\"context\":\"burst/$SUITE\",\"description\":\"request=$REQUEST\",\"target_url\":\"$RUN_URL\"}" > /dev/null - name: Use the runner's own caches run: | if [ -n "${CI_LOCAL_CARGO_HOME:-}" ]; then echo CI_LOCAL_CACHES=1 >> "$GITHUB_ENV"; fi @@ -211,17 +234,6 @@ jobs: path: "${{ runner.temp }}/burst.log" if-no-files-found: warn retention-days: 14 - - name: Report the result on the commit - if: always() && steps.pending.outcome == 'success' - env: - GH_TOKEN: ${{ github.token }} - EXIT: ${{ steps.suite.outputs.exit }} - run: | - if [ -z "$EXIT" ]; then state=error; elif [ "$EXIT" = 0 ]; then state=success; else state=failure; fi - curl -fsS --retry 3 -X POST \ - -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' \ - "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$SHA" \ - -d "{\"state\":\"$state\",\"context\":\"burst/$SUITE\",\"description\":\"exit=${EXIT:-none} request=$REQUEST\",\"target_url\":\"$RUN_URL\"}" > /dev/null - name: Finish with the suite exit if: always() env: @@ -237,3 +249,37 @@ jobs: env: CACHE_ENTRIES: '[{"name":"Restore the Cargo target and registry","outcome":"${{ steps.cargo-cache.outcome }}","hit":"${{ steps.cargo-cache.outputs.cache-hit }}","matched":"${{ steps.cargo-cache.outputs.cache-matched-key }}","opaqueFallback":false,"sourceOnly":false,"avoidable":"","lookup":false},{"name":"Restore the local Nix cache","outcome":"${{ steps.nix-cache.outcome }}","hit":"${{ steps.nix-cache.outputs.cache-hit }}","matched":"${{ steps.nix-cache.outputs.cache-matched-key }}","opaqueFallback":false,"sourceOnly":false,"avoidable":"","lookup":false}]' run: 'python3 "${CI_CACHE_AUDIT_SCRIPT:-scripts/ci-cache-audit}"' + report: + name: report + needs: [pending, burst] + if: always() && needs.pending.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + statuses: write + defaults: + run: + shell: bash + env: + SHA: ${{ inputs.sha }} + SUITE: ${{ inputs.suite }} + ARGS: ${{ inputs.args }} + REQUEST: ${{ inputs.request }} + RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + steps: + - name: Report the result on the commit + env: + GH_TOKEN: ${{ github.token }} + EXIT: ${{ needs.burst.outputs.exit }} + run: | + if [ -z "$EXIT" ]; then state=error; elif [ "$EXIT" = 0 ]; then state=success; else state=failure; fi + curl -fsS --retry 3 -X POST \ + -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$SHA" \ + -d "{\"state\":\"$state\",\"context\":\"burst/$SUITE\",\"description\":\"exit=${EXIT:-none} request=$REQUEST\",\"target_url\":\"$RUN_URL\"}" > /dev/null + - name: Report job cache coverage + if: always() + shell: bash + env: + CACHE_REASON: Posts the final commit status without a checkout. + run: "printf 'Cache coverage: **not applicable** — %s\\n' \"$CACHE_REASON\" >> \"$GITHUB_STEP_SUMMARY\"" diff --git a/.github/workflows/burst.yml.genie.ts b/.github/workflows/burst.yml.genie.ts index aa7ab06710e..5556ef777a7 100644 --- a/.github/workflows/burst.yml.genie.ts +++ b/.github/workflows/burst.yml.genie.ts @@ -83,6 +83,16 @@ printf 'exit=%s\\nline=%s\\n' "$code" "$line" >> "$GITHUB_OUTPUT" printf '%s\\n' "$line" >> "$GITHUB_STEP_SUMMARY" printf '%s\\n' "$line" | tee -a "$log"` +const requestEnv = { + SHA: '${{ inputs.sha }}', + SUITE: '${{ inputs.suite }}', + ARGS: '${{ inputs.args }}', + REQUEST: '${{ inputs.request }}', + RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}', +} + +// Only the two status jobs hold `statuses: write`, and neither checks out the requested commit: +// the suite job runs that commit's code with a read-only token. export default githubWorkflow(scopeLinuxNixSandbox(auditCaches({ name: 'burst', 'run-name': 'burst ${{ inputs.suite }} ${{ inputs.sha }} ${{ inputs.request }}', @@ -92,36 +102,41 @@ export default githubWorkflow(scopeLinuxNixSandbox(auditCaches({ sha: { description: 'Full 40-character commit SHA to test', required: true, type: 'string' }, suite: { description: `One of: ${suites.join(', ')}`, required: true, type: 'string' }, args: { description: 'Extra arguments for cargo-test or nextest, split on spaces', required: false, type: 'string', default: '' }, - request: { description: 'Caller request id ([A-Za-z0-9._-], at most 64)', required: true, type: 'string' }, + request: { description: 'Unique request id per dispatch ([A-Za-z0-9._-], at most 64)', required: true, type: 'string' }, }, }, }, - permissions: { contents: 'read', statuses: 'write' }, + permissions: { contents: 'read' }, concurrency: { group: 'burst-${{ inputs.request }}', 'cancel-in-progress': false }, actionlint: defaultActionlintConfig, jobs: { + pending: { + name: 'pending', + 'runs-on': 'ubuntu-latest', + 'timeout-minutes': 5, + permissions: { statuses: 'write' }, + defaults: { run: { shell: 'bash' } }, + env: requestEnv, + steps: [ + { name: 'Validate the request', run: validate }, + { name: 'Report the run as pending', env: { GH_TOKEN: '${{ github.token }}' }, run: postStatus('pending', 'request=$REQUEST') }, + ], + }, burst: { name: 'burst', + needs: ['pending'], 'runs-on': runsOn, 'timeout-minutes': 180, defaults: { run: { shell: 'bash' } }, - env: { - ...buildEnv, - SHA: '${{ inputs.sha }}', - SUITE: '${{ inputs.suite }}', - ARGS: '${{ inputs.args }}', - REQUEST: '${{ inputs.request }}', - RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}', - }, + env: { ...buildEnv, ...requestEnv }, + outputs: { exit: '${{ steps.suite.outputs.exit }}' }, steps: [ - { name: 'Validate the request', run: validate }, // st-test-all's checks read the history (genie freshness, the CI script self-tests); the other suites need one commit. ...fullHistoryCheckoutSteps({ ref: '${{ inputs.sha }}', 'persist-credentials': false }, {}, "inputs.suite == 'st-test-all'"), { name: 'Require the requested commit', run: 'head=$(git rev-parse HEAD); [ "$head" = "$SHA" ] || { echo "::error::checked out $head, not $SHA"; exit 1; }', }, - { name: 'Report the run as pending', id: 'pending', env: { GH_TOKEN: '${{ github.token }}' }, run: postStatus('pending', 'request=$REQUEST') }, ...setupSteps, { ...megarepoApplyStep, if: "inputs.suite == 'st-test-all'" }, { name: 'Run the suite', id: 'suite', env: { ST_TEST_ALL: stTestAll, COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' }, run: runSuite }, @@ -131,13 +146,6 @@ export default githubWorkflow(scopeLinuxNixSandbox(auditCaches({ uses: 'actions/upload-artifact@v4', with: { name: 'burst-log-${{ inputs.request }}', path: '${{ runner.temp }}/burst.log', 'if-no-files-found': 'warn', 'retention-days': 14 }, }, - { - name: 'Report the result on the commit', - if: "always() && steps.pending.outcome == 'success'", - env: { GH_TOKEN: '${{ github.token }}', EXIT: '${{ steps.suite.outputs.exit }}' }, - run: `if [ -z "$EXIT" ]; then state=error; elif [ "$EXIT" = 0 ]; then state=success; else state=failure; fi -${postStatus('$state', 'exit=${EXIT:-none} request=$REQUEST')}`, - }, { name: 'Finish with the suite exit', if: 'always()', @@ -148,5 +156,25 @@ exit "$EXIT"`, }, ], }, + // A suite job that failed before the suite, was cancelled or timed out has no exit: the status is error. + report: { + name: 'report', + needs: ['pending', 'burst'], + if: "always() && needs.pending.result == 'success'", + 'runs-on': 'ubuntu-latest', + 'timeout-minutes': 5, + permissions: { statuses: 'write' }, + defaults: { run: { shell: 'bash' } }, + env: requestEnv, + steps: [{ + name: 'Report the result on the commit', + env: { GH_TOKEN: '${{ github.token }}', EXIT: '${{ needs.burst.outputs.exit }}' }, + run: `if [ -z "$EXIT" ]; then state=error; elif [ "$EXIT" = 0 ]; then state=success; else state=failure; fi +${postStatus('$state', 'exit=${EXIT:-none} request=$REQUEST')}`, + }], + }, }, +}, { + pending: 'Validates the request and posts a commit status without a checkout.', + report: 'Posts the final commit status without a checkout.', }))) diff --git a/docs/ci.md b/docs/ci.md index e996e25e2af..1d968a0b586 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1596,15 +1596,19 @@ gh workflow run burst.yml --ref main -f sha=FULL_SHA -f suite=cargo-test \ `sha` must be 40 lowercase hex characters, `request` must match `^[A-Za-z0-9._-]{1,64}$`, and `args` must match `^[A-Za-z0-9_:./=@ ,+-]*$`. `args` reaches the command only through the environment and is split on spaces. Only a dispatch of main's workflow runs. The suite commands come from that -workflow; the scripts they call come from the requested commit, which the job checks out with -its credentials dropped and refuses unless `HEAD` is exactly `sha`. Runs sharing a `request` queue -behind each other and are never cancelled. - -The job posts a `burst/SUITE` commit status on `sha`: pending with the run's URL, then success -exactly when the suite exited 0, with the description `exit=CODE request=REQUEST`. The suite log -is the `burst-log-REQUEST` artifact. The last line of the suite step and the job summary is -`burst suite=SUITE sha=SHA request=REQUEST exit=CODE seconds=N`, and the job fails exactly when -`CODE` is not 0. +workflow; the scripts they call come from the requested commit, which the `burst` job checks out +with its credentials dropped and refuses unless `HEAD` is exactly `sha`. Give every dispatch its own +`request`: runs are grouped by it and never cancelled, but a newer dispatch with the same id +replaces one that is still waiting in the group. + +The `burst` job's token can only read the repository. Two small jobs that never check out `sha` +post the `burst/SUITE` commit status on it: `pending` validates the inputs and posts pending with +the run's URL; `report` posts success exactly when the suite exited 0, failure for another exit, +and error when the suite never finished (setup failure, cancellation, timeout), with the +description `exit=CODE request=REQUEST`. The suite log is the `burst-log-REQUEST` artifact. The +last line of the suite step and the job summary is +`burst suite=SUITE sha=SHA request=REQUEST exit=CODE seconds=N`, and the `burst` job fails exactly +when `CODE` is not 0. The job runs on the 8x16 Namespace shape at priority 3, the same class as Performance, so it never outranks merge groups or required pull request work. The repository variable From 37cc08b90353c9a8e57ef697a4bd3c2a48cb2a85 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:20:50 +0100 Subject: [PATCH 095/132] Exercise managed wrapper inputs in launch subscription fault test agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-reconcile/src/reconcile.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index 7313428822b..32b4957c249 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -27243,18 +27243,31 @@ agent "import/omp/fixture" {{ fn launch_input_measurement_failure_is_unknown_and_subscription_faults_target_daemon() { use std::os::unix::fs::PermissionsExt as _; let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("managed/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let wrapper = bin.join("claude"); + // Managed seats measure the selected wrapper and its interpreter, not raw PATH claude. + std::fs::write(&wrapper, "#!/usr/bin/env missing-claude-interpreter\n").unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let profile = root.path().join("profile.json"); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"generalist": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); let desired = crate::graph::parse_test_intent(&format!("version 2\nagent \"seat\" {{ workspace {:?}; harness \"claude\" {{ model \"model-a\"; }} }}", root.path().to_str().unwrap()), "node").unwrap().subjects.into_values().next().unwrap(); let store = Arc::new(Store::open_memory("node").unwrap()); let reconciler = Reconciler::new(store.clone(), Arc::new(FakeRuntime::default()), "node".into(), Arc::new(Notify::new())); let mut member = desired.member.clone().unwrap(); member.environment.insert("PATH".into(), root.path().display().to_string()); + member.environment.insert("AGENT_RUNTIME_PROFILE".into(), profile.display().to_string()); + member.environment.insert("AGENT_PERSONA".into(), "generalist".into()); let member = Arc::new(Mutex::new(member)); let prepared = member.clone(); crate::contract::install(&store, Box::new(move |_| Ok(prepared.lock().unwrap().clone()))); - assert!(crate::contract::declared(&store, &desired).is_err()); // Settle the environment, not the missing provider. + assert!(crate::contract::declared(&store, &desired).is_err()); // Settle the environment, not the missing interpreter. reconciler.watch_launch_inputs(&desired).unwrap(); assert!(crate::contract::declared_ready(&store, &desired).is_err()); - assert!(reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", root.path().join("claude").display()))); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", wrapper.display()))); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", root.path().join("missing-claude-interpreter").display()))); let scope = format!("stage/launch-inputs/{}", desired.subject); let denied = root.path().join("denied"); std::fs::create_dir(&denied).unwrap(); @@ -27268,7 +27281,7 @@ agent "import/omp/fixture" {{ } std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o755)).unwrap(); assert!(reconciler.isolate(&scope, "daemon/node", || reconciler.watch_launch_inputs(&desired)).is_some()); - assert!(reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", denied.join("claude").display()))); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", denied.join("missing-claude-interpreter").display()))); assert!(store.reconcile_fault("daemon/node", &scope).unwrap().is_none()); } From 1e5f81716d6d2a4961e460ef7b94a3177e465906 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:20:54 +0100 Subject: [PATCH 096/132] fractal: program missions on effect/workflow (fractal/effect) Fractal.mission(intent, program) builds a Workflow whose default export carries the fractal.program.v1 entry. Fractal.step is an Activity keyed id, id#2, ...; worker turns, Fractal.ask and Fractal.run are DurableDeferreds the host resolves; the host-side WorkflowEngine replays from the journal and records new results through the host contract. Typed failures are journaled with _tag and fields, defects and interrupts are not. --- packages/fractal/package.json | 1 + packages/fractal/package.json.genie.ts | 1 + packages/fractal/src/effect.ts | 24 + packages/fractal/src/fractal-effect.ts | 7 + packages/fractal/src/program-engine.ts | 323 ++++++++++++ packages/fractal/src/program-host.fixture.ts | 65 +++ packages/fractal/src/program.ts | 315 ++++++++++++ packages/fractal/src/program.unit.test.ts | 482 ++++++++++++++++++ .../program-api-effect-workflow.json | 11 + 9 files changed, 1229 insertions(+) create mode 100644 packages/fractal/src/effect.ts create mode 100644 packages/fractal/src/fractal-effect.ts create mode 100644 packages/fractal/src/program-engine.ts create mode 100644 packages/fractal/src/program-host.fixture.ts create mode 100644 packages/fractal/src/program.ts create mode 100644 packages/fractal/src/program.unit.test.ts create mode 100644 release-notes/program-api-effect-workflow.json diff --git a/packages/fractal/package.json b/packages/fractal/package.json index 72c6da0ce17..a949c352e26 100644 --- a/packages/fractal/package.json +++ b/packages/fractal/package.json @@ -6,6 +6,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./effect": "./src/effect.ts", "./testing": "./src/testing.ts", "./tree": "./src/tree.ts" }, diff --git a/packages/fractal/package.json.genie.ts b/packages/fractal/package.json.genie.ts index a7d75b52cf2..29eaf67c37f 100644 --- a/packages/fractal/package.json.genie.ts +++ b/packages/fractal/package.json.genie.ts @@ -32,6 +32,7 @@ export default packageJson({ type: 'module', exports: { '.': './src/index.ts', + './effect': './src/effect.ts', './tree': './src/tree.ts', './testing': './src/testing.ts', }, diff --git a/packages/fractal/src/effect.ts b/packages/fractal/src/effect.ts new file mode 100644 index 00000000000..628aca51d06 --- /dev/null +++ b/packages/fractal/src/effect.ts @@ -0,0 +1,24 @@ +/** + * Program-form missions, imported as `import { Fractal } from 'fractal/effect'`. The named exports + * are the host contract a program bundle implements (docs/st3/program-engine.md §2). + */ +export * as Fractal from './fractal-effect.ts' +export { + AskAnswer, + AskCancelled, + ChildFailed, + ChildRun, + type InputSchema, + type ProgramIntent, + type ProgramMission, + type StepOptions, +} from './program.ts' +export { + Program, + ProgramEntry, + type CommandOptions, + type CommandResult, + type HostContextV1, + type ProgramExit, + type ProgramServices, +} from './program-engine.ts' diff --git a/packages/fractal/src/fractal-effect.ts b/packages/fractal/src/fractal-effect.ts new file mode 100644 index 00000000000..0ec8b492ab7 --- /dev/null +++ b/packages/fractal/src/fractal-effect.ts @@ -0,0 +1,7 @@ +/** + * The authoring namespace of `fractal/effect`: everything `Fractal` in `fractal` holds, with the + * program forms of `mission` and `step` and the program calls `ask`, `run`, `command`, `env`, `cwd` + * and `Worker`. + */ +export * from './fractal.ts' +export { ask, command, cwd, env, mission, run, step, Worker } from './program.ts' diff --git a/packages/fractal/src/program-engine.ts b/packages/fractal/src/program-engine.ts new file mode 100644 index 00000000000..6e660d66ad9 --- /dev/null +++ b/packages/fractal/src/program-engine.ts @@ -0,0 +1,323 @@ +/** + * The bundle side of the program host contract (docs/st3/program-engine.md §2, §5, §11): the + * `fractal.program.v1` entry, the journal keys and the `WorkflowEngine` that runs one program + * attempt inside the daemon's program host. + * + * The engine replays from the journal the daemon hands over: a journaled step returns its recorded + * value, or fails again with its recorded typed error, without running. A new step runs, and the + * program continues only once the daemon has acknowledged its result. Typed failures are journaled + * with their `_tag` and fields; defects and interrupts are not, so a resumed attempt runs that step + * again. Asks and child runs wait in the host until the daemon resolves them; it journals their + * result under the same key. + */ +import { Cause, Context, Effect, Exit, Option, Schema, SchemaGetter, SchemaIssue } from 'effect' +import { Workflow, WorkflowEngine } from 'effect/workflow' + +/** An outcome as the host protocol carries it: a program's, or one journaled key's. */ +export type ProgramExit = + | { readonly _tag: 'Success'; readonly value: unknown } + | { readonly _tag: 'Failure'; readonly error: unknown } + +/** The key of the entry a bundle's default export carries: version 1 of the host contract. */ +export const ProgramEntry: unique symbol = Symbol.for('fractal.program.v1') + +export interface CommandOptions { + readonly env?: Readonly> + readonly cwd?: string + /** Background work runs at background priority; the default is the daemon's live priority. */ + readonly priority?: 'background' +} + +export interface CommandResult { + readonly code: number + readonly signal: string | null + readonly stdout: string + readonly stderr: string +} + +/** What the program host hands a bundle's entry for one attempt of one program step. */ +export interface HostContextV1 { + /** The program's step run subject. */ + readonly step: string + readonly attempt: number + /** The mission run subject. */ + readonly run: string + /** The run's native inputs, by name. */ + readonly payload: Readonly> + /** The run's `ST_*` variables. */ + readonly env: Readonly> + readonly cwd: string + /** This attempt's completed and failed results by key. */ + readonly journal: ReadonlyMap + /** Aborted when the daemon cancels the attempt. */ + readonly signal: AbortSignal + started(key: string): void + /** Resolves once the daemon has made the entry durable; rejects on nack or cancel. */ + record(entry: { + readonly key: string + readonly status: 'completed' | 'failed' + readonly value?: unknown + readonly error?: unknown + readonly ms: number + }): Promise + /** + * Starts or adopts the linked child run for `key` (protocol message `run`); the daemon journals + * its end under `key`. + */ + child(request: { + readonly key: string + readonly mission: string + readonly input: Readonly> + }): Promise + /** Opens or adopts the person ask for `key` on the program step; journaled under `key`. */ + ask(request: { + readonly key: string + readonly for: string + readonly title: string + readonly request: unknown + readonly deadline?: string + readonly default?: 'approve' | 'reject' + }): Promise + /** Runs a command as its own process group, which the host ends on cancel. */ + spawn(argv: readonly string[], options?: CommandOptions): Promise +} + +/** One running program attempt: its host, the keys taken so far and its open asks and runs. */ +export class Program extends Context.Service< + Program, + { + readonly host: HostContextV1 + readonly occurrences: Map + readonly active: Set + readonly requests: Map Promise> + } +>()('fractal/Program') {} + +/** What a program body may require: the attempt and the workflow it runs as. */ +export type ProgramServices = + | Program + | WorkflowEngine.WorkflowEngine + | WorkflowEngine.WorkflowInstance + +/** + * Runs `use` under the next key of `id`: `id`, then `id#2`, `id#3`… in one execution, which is + * deterministic for a deterministic body. Two concurrent uses of one id would take their keys in + * scheduling order, which replay cannot reproduce, so that is a defect. + */ +export const keyed = ( + id: string, + use: (key: string, program: Program['Service']) => Effect.Effect, +): Effect.Effect => + Effect.flatMap(Effect.service(Program), (program): Effect.Effect => { + if (program.active.has(id)) + return Effect.die( + new Error(`two concurrent steps share the id \`${id}\`; give each its own id`), + ) + const n = (program.occurrences.get(id) ?? 0) + 1 + program.occurrences.set(id, n) + program.active.add(id) + return Effect.ensuring( + use(n === 1 ? id : `${id}#${n}`, program), + Effect.sync(() => program.active.delete(id)), + ) + }) + +/** A typed failure: an error with a string `_tag`. */ +export type Tagged = { readonly _tag: string } + +const isTagged = (u: unknown): u is Tagged => + typeof u === 'object' && u !== null && '_tag' in u && typeof u._tag === 'string' + +/** + * The error codec of steps and programs that declare none: a tagged error is journaled as its + * `_tag` plus its own fields and replays as that plain object, so `catchTag` matches; anything + * else fails to encode, which makes it a defect. + */ +export const TaggedFailure = Schema.Json.pipe( + Schema.decodeTo(Schema.Unknown, { + decode: SchemaGetter.transformEffect((json: Schema.Json) => + isTagged(json) + ? Effect.succeed(json) + : Effect.fail( + new SchemaIssue.InvalidValue({ message: 'a typed failure has a _tag' }, json), + ), + ), + encode: SchemaGetter.transformEffect((error: unknown) => + isTagged(error) + ? Effect.succeed(JSON.parse(JSON.stringify({ ...error, _tag: error._tag })) as Schema.Json) + : Effect.fail( + new SchemaIssue.InvalidValue({ message: 'an untagged error is a defect' }, error), + ), + ), + }), +) + +const toExit = (exit: ProgramExit): Exit.Exit => + exit._tag === 'Success' ? Exit.succeed(exit.value) : Exit.fail(exit.error) + +const notInPrograms = (what: string) => + Effect.die(new Error(`${what} is not available in a Fractal program`)) + +/** The engine of one program attempt; it runs the one workflow registered with it. */ +const hostEngine = (program: Program['Service']): WorkflowEngine.Encoded => { + const { host, requests } = program + const workflows = new Map< + string, + ( + payload: object, + executionId: string, + ) => Effect.Effect< + unknown, + unknown, + WorkflowEngine.WorkflowInstance | WorkflowEngine.WorkflowEngine + > + >() + return { + register: (workflow, execute) => Effect.sync(() => void workflows.set(workflow._tag, execute)), + execute: ( + workflow: Workflow.Any, + options: { + readonly executionId: string + readonly payload: object + readonly discard: Discard + }, + ) => { + const execute = workflows.get(workflow._tag) + if (execute === undefined || options.discard) return notInPrograms('Workflow.execute') + return execute(options.payload, options.executionId).pipe( + Workflow.intoResult, + Effect.provideService( + WorkflowEngine.WorkflowInstance, + WorkflowEngine.WorkflowInstance.initial(workflow, options.executionId), + ), + // Asks and child runs wait in the host, so nothing can resume a suspended program. + Effect.flatMap((result) => + result._tag === 'Suspended' + ? Effect.die(new Error('the program suspended on a wait the host cannot resume')) + : Effect.succeed(result), + ), + // The workflow engine service reaches the body through `register`. + ) as Effect.Effect> + }, + poll: () => notInPrograms('Workflow.poll'), + interrupt: () => notInPrograms('Workflow.interrupt'), + interruptUnsafe: () => notInPrograms('Workflow.interrupt'), + resume: () => notInPrograms('Workflow.resume'), + scheduleClock: () => notInPrograms('DurableClock'), + deferredDone: () => notInPrograms('DurableDeferred.done'), + activityExecute: (activity) => + Effect.gen(function* () { + const key = activity.name + const journaled = host.journal.get(key) + if (journaled !== undefined) return new Workflow.Complete({ exit: toExit(journaled) }) + const instance = yield* WorkflowEngine.WorkflowInstance + host.started(key) + const started = Date.now() + const result: Workflow.Result = yield* activity.executeEncoded.pipe( + Workflow.intoResult, + Effect.provideService( + WorkflowEngine.WorkflowInstance, + WorkflowEngine.WorkflowInstance.initial(instance.workflow, instance.executionId), + ), + ) + if (result._tag === 'Suspended') return result + const ms = Date.now() - started + const { exit } = result + if (Exit.isSuccess(exit)) { + yield* Effect.promise(() => + host.record({ key, status: 'completed', value: exit.value ?? null, ms }), + ) + } else if ( + Cause.hasDies(exit.cause) === false && + Cause.hasInterrupts(exit.cause) === false + ) { + const error = Cause.findErrorOption(exit.cause) + if (Option.isSome(error)) + yield* Effect.promise(() => + host.record({ key, status: 'failed', error: error.value, ms }), + ) + } + return result + }), + deferredResult: (deferred) => { + const journaled = host.journal.get(deferred.name) + if (journaled !== undefined) return Effect.succeed(Option.some(toExit(journaled))) + const request = requests.get(deferred.name) + if (request === undefined) return Effect.succeed(Option.none()) + return Effect.map(Effect.promise(request), (exit) => Option.some(toExit(exit))) + }, + } +} + +/** A program as its bundle runs it. */ +export interface ProgramDefinition { + readonly id: string + readonly input: Workflow.AnyStructSchema & Schema.Codec>> + readonly output: Schema.Codec + readonly error: Schema.Codec + readonly body: (input: P) => Effect.Effect +} + +const encodeJson = (schema: Schema.Codec, value: T) => + Effect.orDie(Schema.encodeUnknownEffect(Schema.toCodecJson(schema))(value)) + +/** + * The `fractal.program.v1` entry: decodes the run's inputs, runs the body as a workflow whose + * execution is the step run, and returns the success encoded with `output` or the typed failure + * encoded with `error`. Inputs that do not decode refuse the run with `InvalidInput`; a defect + * returns `Defect` with the cause. After a cancel the host discards the exit. + */ +export const programEntry = + (definition: ProgramDefinition) => + async (host: HostContextV1): Promise => { + const program: Program['Service'] = { + host, + occurrences: new Map(), + active: new Set(), + requests: new Map(), + } + const engine = WorkflowEngine.makeUnsafe(hostEngine(program)) + const workflow = Workflow.make(definition.id, { + payload: definition.input, + success: definition.output, + error: definition.error, + idempotencyKey: () => host.step, + }) + const attempt = Effect.gen(function* () { + const decoded = yield* Effect.result( + Schema.decodeUnknownEffect(definition.input, { onExcessProperty: 'error' })(host.payload), + ) + if (decoded._tag === 'Failure') + return { + _tag: 'Failure', + error: { _tag: 'InvalidInput', message: decoded.failure.message }, + } satisfies ProgramExit + yield* engine.register(workflow, (input) => definition.body(input)) + const exit = yield* Effect.exit( + engine.execute(workflow, { executionId: host.step, payload: decoded.success }), + ) + if (Exit.isSuccess(exit)) + return { + _tag: 'Success', + value: (yield* encodeJson(definition.output, exit.value)) ?? null, + } satisfies ProgramExit + const error = Cause.findErrorOption(exit.cause) + if (Cause.hasDies(exit.cause) || Cause.hasInterrupts(exit.cause) || Option.isNone(error)) + return yield* Effect.failCause(exit.cause) + return { + _tag: 'Failure', + error: yield* encodeJson(definition.error, error.value), + } satisfies ProgramExit + }).pipe( + Effect.scoped, + Effect.provideService(Program, program), + Effect.provideService(WorkflowEngine.WorkflowEngine, engine), + ) + const exit = await Effect.runPromiseExit(attempt, { signal: host.signal }) + if (Exit.isSuccess(exit)) return exit.value + const message = Cause.pretty(exit.cause).slice(0, 2000) + return { + _tag: 'Failure', + error: host.signal.aborted ? { _tag: 'Interrupted', message } : { _tag: 'Defect', message }, + } + } diff --git a/packages/fractal/src/program-host.fixture.ts b/packages/fractal/src/program-host.fixture.ts new file mode 100644 index 00000000000..7eb05251e81 --- /dev/null +++ b/packages/fractal/src/program-host.fixture.ts @@ -0,0 +1,65 @@ +import { Effect } from 'effect' + +import { ProgramEntry, type HostContextV1, type ProgramExit } from './program-engine.ts' + +export interface Recorded { + readonly key: string + readonly status: 'completed' | 'failed' + readonly value?: unknown + readonly error?: unknown +} + +/** An in-memory host for one attempt; `record` keeps what a daemon would journal. */ +export const fakeHost = ( + options: { + readonly payload?: Readonly> + readonly journal?: Iterable + readonly cwd?: string + readonly child?: HostContextV1['child'] + /** Fails every journal write, as a daemon nack does. */ + readonly nack?: string + readonly ask?: HostContextV1['ask'] + readonly spawn?: HostContextV1['spawn'] + } = {}, +) => { + const recorded: Recorded[] = [] + const started: string[] = [] + const controller = new AbortController() + const context: HostContextV1 = { + step: 'step-run/gen-1/program', + attempt: 1, + run: 'mission-run/run-1', + payload: options.payload ?? { change: 'abc' }, + env: { ST_MISSION_RUN: 'mission-run/run-1' }, + cwd: options.cwd ?? '/work', + journal: new Map(options.journal ?? []), + signal: controller.signal, + started: (key) => void started.push(key), + record: async ({ key, status, value, error }) => { + if (options.nack !== undefined) throw new Error(options.nack) + // The daemon stores JSON: anything else would not replay. + recorded.push(JSON.parse(JSON.stringify({ key, status, value, error }))) + }, + child: options.child ?? (() => Promise.reject(new Error('unexpected child run'))), + ask: options.ask ?? (() => Promise.reject(new Error('unexpected ask'))), + spawn: + options.spawn ?? + (async (argv) => ({ code: 0, signal: null, stdout: argv.join(' '), stderr: '' })), + } + return { context, recorded, started, cancel: () => controller.abort() } +} + +/** The journal a daemon replays from what an attempt recorded. */ +export const journalOf = (recorded: readonly Recorded[]) => + recorded.map((entry): readonly [string, ProgramExit] => [ + entry.key, + entry.status === 'completed' + ? { _tag: 'Success', value: entry.value } + : { _tag: 'Failure', error: entry.error }, + ]) + +/** Runs one attempt of `program` on `host`, as the program host does. */ +export const start = ( + program: { readonly [ProgramEntry]: (host: HostContextV1) => Promise }, + host: { readonly context: HostContextV1 }, +) => Effect.promise(() => program[ProgramEntry](host.context)) diff --git a/packages/fractal/src/program.ts b/packages/fractal/src/program.ts new file mode 100644 index 00000000000..8bf02354ca3 --- /dev/null +++ b/packages/fractal/src/program.ts @@ -0,0 +1,315 @@ +/** + * The program form of a mission, `Fractal.mission(intent, program)`, and what its body calls: + * `Fractal.step` (an `Activity`), worker turns, `Fractal.ask` and `Fractal.run` (`DurableDeferred`s + * the daemon resolves), `Fractal.command`, `Fractal.env` and `Fractal.cwd`. + * See docs/st3/program-engine.md §11. + */ +import { Context, Effect, Layer, Schedule, Schema, SchemaAST } from 'effect' +import { Activity, DurableDeferred, type Workflow } from 'effect/workflow' + +import { mission as dataMission, type MissionIntent } from './define.ts' +import type { PersonReference } from './mod.ts' +import { + keyed, + Program, + programEntry, + ProgramEntry, + TaggedFailure, + type CommandOptions, + type CommandResult, + type HostContextV1, + type ProgramExit, + type ProgramServices, + type Tagged, +} from './program-engine.ts' +import type { SubjectDefinition } from './subject-ref.ts' + +/** A schema without decoding or encoding services. */ +type Codec = Schema.Codec + +/** A mission input Struct: every field is required and encodes to a string. */ +export type InputSchema = Workflow.AnyStructSchema & + Schema.Codec + +/** A linked child run that completed: its subject and the products its steps declare. */ +export const ChildRun = Schema.Struct({ + run: Schema.String, + status: Schema.Literal('completed'), + products: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}) +export type ChildRun = typeof ChildRun.Type + +/** A linked child run ended without completing. */ +export class ChildFailed extends Schema.TaggedError()('ChildFailed', { + run: Schema.String, + status: Schema.String, + reason: Schema.String, +}) {} + +/** A person's answer to an ask; `resolved_by` is the person or `deadline`. */ +export const AskAnswer = Schema.Struct({ + answer: Schema.optionalKey( + Schema.Struct({ + type: Schema.String, + outcome: Schema.optionalKey(Schema.String), + id: Schema.optionalKey(Schema.String), + label: Schema.optionalKey(Schema.String), + text: Schema.optionalKey(Schema.String), + }), + ), + summary: Schema.optionalKey(Schema.String), + resolved_by: Schema.String, +}) +export type AskAnswer = typeof AskAnswer.Type + +/** The ask was withdrawn while the run went on. */ +export class AskCancelled extends Schema.TaggedError()('AskCancelled', { + ask: Schema.String, +}) {} + +/** The intent of a program mission; `input` fields become its native text inputs. */ +export interface ProgramIntent< + I extends InputSchema, + O extends Codec, + ES extends Codec | undefined, +> { + readonly id: string + readonly goal: string + readonly reportTo: MissionIntent['reportTo'] + readonly timeout?: string + readonly input: I + readonly output?: O + readonly error?: ES +} + +/** A mission whose body is a program; its default export is the bundle's entry. */ +export interface ProgramMission< + I extends InputSchema, + O extends Codec, + E, +> extends SubjectDefinition<'mission', ProgramIntent, never> { + readonly intent: ProgramIntent + readonly program: (input: I['Type']) => Effect.Effect + readonly [ProgramEntry]: (host: HostContextV1) => Promise +} + +type Meta = { readonly meta: { readonly url: string } } + +/** Each input is one native `input "name" kind="text"`, and a run supplies every one. */ +const encodesToString = (ast: SchemaAST.AST): boolean => + SchemaAST.isString(ast) || + SchemaAST.isTemplateLiteral(ast) || + (SchemaAST.isLiteral(ast) && typeof ast.literal === 'string') || + (SchemaAST.isEnum(ast) && ast.enums.every(([, value]) => typeof value === 'string')) || + (SchemaAST.isUnion(ast) && ast.types.every(encodesToString)) + +/** + * Declares a mission. With flat fields it is a data mission (see `Fractal.mission` in `fractal`). + * With `(intent, program)` it is a program: `program` receives the decoded `intent.input`, its + * success is encoded with `intent.output` and its typed failure with `intent.error`, or as `_tag` + * plus fields when the intent declares none. + */ +export function mission( + intent: Meta & MissionIntent, +): SubjectDefinition<'mission', MissionIntent, never> +export function mission< + I extends InputSchema, + O extends Codec = typeof Schema.Void, + E extends Tagged = never, +>( + intent: Meta & ProgramIntent, + program: (input: I['Type']) => Effect.Effect, +): ProgramMission +export function mission< + I extends InputSchema, + ES extends Codec, + O extends Codec = typeof Schema.Void, +>( + intent: Meta & ProgramIntent & { readonly error: ES }, + program: (input: I['Type']) => Effect.Effect, +): ProgramMission +export function mission( + intent: Meta & (MissionIntent | ProgramIntent), + program?: (input: object) => Effect.Effect, +): SubjectDefinition<'mission', unknown, never> { + if (program === undefined) return dataMission(intent as Meta & MissionIntent) + const { meta, ...rest } = intent as Meta & ProgramIntent + for (const [name, field] of Object.entries(rest.input.fields)) + if ( + SchemaAST.isOptional(field.ast) || + encodesToString(SchemaAST.toEncoded(field.ast)) === false + ) + throw new TypeError(`Program input ${name} must be required and encode to a string`) + const definition: ProgramMission = { + _tag: 'SubjectRef', + kind: 'mission', + url: meta.url, + intent: rest, + program, + [ProgramEntry]: programEntry({ + id: rest.id, + input: rest.input, + output: rest.output ?? Schema.Void, + error: rest.error ?? TaggedFailure, + body: program, + }), + } + return definition +} + +/** The child mission a worker turn runs, and the product below the child run that is its value. */ +export class Worker extends Context.Service< + Worker, + { + readonly mission: string + readonly input?: Readonly> + readonly product?: string + } +>()('fractal/Worker') { + /** Runs the worker turns it is provided to as child runs of `mission`. */ + static readonly layer = (options: Worker['Service']) => Layer.succeed(Worker, options) +} + +/** Waits for the linked child run `key` names; the daemon journals its end under `key`. */ +const child = (id: string, mission: string, input: Readonly>) => + keyed(id, (key, program) => { + program.requests.set(key, () => program.host.child({ key, mission, input })) + return DurableDeferred.await( + DurableDeferred.make(key, { success: ChildRun, error: ChildFailed }), + ).pipe(Effect.ensuring(Effect.sync(() => program.requests.delete(key)))) + }) + +const noInterruptRetry = Schedule.recurs(0) + +export interface StepOptions { + readonly id: string + readonly goal?: string +} + +/** + * A step of a program, journaled under its key (`id`, then `id#2`… for repeats, so `Effect.retry` + * runs it again under a new key). With an effect it is an `Activity`: replay returns the journaled + * value decoded with `success` (void without one) or fails again with the journaled typed error, + * rebuilt with `error` or as `_tag` plus fields. Without an effect it is a worker turn: a child run + * of the provided `Worker` mission, whose product decoded with `success` is the value. + */ +export function step( + options: StepOptions & { readonly success?: undefined; readonly error?: undefined }, + effect: Effect.Effect, +): Effect.Effect +export function step( + options: StepOptions & { readonly success: S; readonly error?: undefined }, + effect: Effect.Effect, +): Effect.Effect +export function step( + options: StepOptions & { readonly success?: S; readonly error: ES }, + effect: Effect.Effect, +): Effect.Effect +export function step( + options: StepOptions & { readonly success?: S }, +): Effect.Effect +export function step( + options: StepOptions & { + readonly success?: Codec | undefined + readonly error?: Codec | undefined + }, + effect?: Effect.Effect, +): Effect.Effect { + const { success } = options + if (effect !== undefined) + return keyed(options.id, (key) => + Activity.make({ + name: key, + success: success ?? Schema.Void, + error: options.error ?? TaggedFailure, + execute: success === undefined ? Effect.asVoid(effect) : effect, + interruptRetryPolicy: noInterruptRetry, + }), + ) + return Effect.flatMap(Effect.service(Worker), (worker) => + Effect.flatMap(child(options.id, worker.mission, worker.input ?? {}), (done) => { + if (success === undefined) return Effect.void + const subject = `${done.run}/${worker.product}` + const value = worker.product === undefined ? undefined : done.products?.[subject] + if (value === undefined) + return Effect.die( + new Error(`worker turn ${options.id}: ${done.run} has no product ${subject}`), + ) + return Effect.orDie(Schema.decodeUnknownEffect(success)(value)) + }), + ) +} + +/** + * Asks a person on the program step and waits for the answer, journaled under `id`. An ask that a + * cancelled run withdraws is never answered; one withdrawn while the run goes on fails with + * `AskCancelled`. + */ +export const ask = (options: { + readonly id: string + readonly for: PersonReference + readonly title: string + readonly request: Schema.Json + readonly deadline?: string + readonly default?: 'approve' | 'reject' +}): Effect.Effect => + keyed(options.id, (key, program) => { + const { id: _, for: person, ...request } = options + program.requests.set(key, () => program.host.ask({ ...request, key, for: person.subject })) + return DurableDeferred.await( + DurableDeferred.make(key, { success: AskAnswer, error: AskCancelled }), + ).pipe(Effect.ensuring(Effect.sync(() => program.requests.delete(key)))) + }) + +/** + * Starts a linked child run of `mission` and waits for its end, journaled under `options.id` + * (default: the mission's id). Cancelling the parent run cancels the child; a child that does not + * complete fails with `ChildFailed`. + */ +export function run( + mission: ProgramMission, + input: I['Type'], + options?: { readonly id?: string }, +): Effect.Effect +export function run( + mission: string, + input: Readonly>, + options?: { readonly id?: string }, +): Effect.Effect +export function run( + mission: string | ProgramMission, + input: object, + options?: { readonly id?: string }, +): Effect.Effect { + if (typeof mission === 'string') + return child(options?.id ?? mission, mission, input as Readonly>) + const { id, input: schema } = mission.intent + return Effect.flatMap(Effect.orDie(Schema.encodeUnknownEffect(schema)(input)), (encoded) => + child(options?.id ?? id, id, encoded), + ) +} + +/** + * Runs a command as its own process group, which the host ends when the program is cancelled. + * `priority: 'background'` runs it as background work. Commands are not journaled: run them + * inside a step. + */ +export const command = ( + argv: readonly string[], + options?: CommandOptions, +): Effect.Effect => + Effect.flatMap(Effect.service(Program), (program) => + Effect.promise(() => program.host.spawn(argv, options)), + ) + +/** The run's `ST_*` variables (`ST_MISSION_RUN`, `ST_STEP_RUN`, …). */ +export const env: Effect.Effect>, never, Program> = Effect.map( + Effect.service(Program), + (program) => program.host.env, +) + +/** The run's workspace directory. */ +export const cwd: Effect.Effect = Effect.map( + Effect.service(Program), + (program) => program.host.cwd, +) diff --git a/packages/fractal/src/program.unit.test.ts b/packages/fractal/src/program.unit.test.ts new file mode 100644 index 00000000000..1a33da1d071 --- /dev/null +++ b/packages/fractal/src/program.unit.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, it } from '@effect/vitest' +import { Data, Effect, Schedule, Schema } from 'effect' + +import { AskCancelled, ChildFailed, Fractal, type ProgramExit } from './effect.ts' +import { fakeHost, journalOf, start } from './program-host.fixture.ts' +import type { AgentRef } from './subject-ref.ts' + +class StepFailed extends Data.TaggedError('StepFailed')<{ + readonly step: string + readonly reason: string +}> {} +class Refused extends Schema.TaggedError()('Refused', { reason: Schema.String }) {} + +const reportTo: AgentRef = { + _tag: 'SubjectRef', + kind: 'agent', + url: 'file:///tree/factory/agent.ts', +} +const meta = { url: 'file:///tree/factory/test/mission.ts' } +const Change = Schema.Struct({ change: Schema.String }) + +describe('program replay', () => { + const effects: string[] = [] + const land = Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'Merge the change.', + reportTo, + input: Change, + output: Schema.Struct({ merged: Schema.String, errors: Schema.Number }), + }, + ({ change }) => + Effect.gen(function* () { + const checked = yield* Fractal.step( + { id: 'check', success: Schema.Struct({ errors: Schema.Array(Schema.String) }) }, + Effect.sync(() => { + effects.push('check') + return { errors: [] } + }), + ) + const merged = yield* Fractal.step( + { id: 'merge', success: Schema.String }, + Effect.sync(() => { + effects.push('merge') + return `merged ${change}` + }), + ) + return { merged, errors: checked.errors.length } + }), + ) + + it.effect('returns journaled values without running their effects again', () => + Effect.gen(function* () { + const first = fakeHost() + expect(yield* start(land, first)).toEqual({ + _tag: 'Success', + value: { merged: 'merged abc', errors: 0 }, + }) + expect(first.recorded).toEqual([ + { key: 'check', status: 'completed', value: { errors: [] } }, + { key: 'merge', status: 'completed', value: 'merged abc' }, + ]) + expect(effects.splice(0)).toEqual(['check', 'merge']) + + const replay = fakeHost({ journal: journalOf(first.recorded) }) + expect(yield* start(land, replay)).toEqual({ + _tag: 'Success', + value: { merged: 'merged abc', errors: 0 }, + }) + expect(effects).toEqual([]) + expect(replay.recorded).toEqual([]) + expect(replay.started).toEqual([]) + + const resume = fakeHost({ journal: journalOf(first.recorded.slice(0, 1)) }) + yield* start(land, resume) + expect(effects.splice(0)).toEqual(['merge']) + expect(resume.started).toEqual(['merge']) + }), + ) + + it.effect('stops with a defect when the daemon refuses a journal write', () => + Effect.gen(function* () { + const exit = yield* start(land, fakeHost({ nack: 'idempotency-conflict' })) + expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Defect' } }) + expect(JSON.stringify(exit)).toContain('idempotency-conflict') + // The program does not continue past a result that is not durable. + expect(effects.splice(0)).toEqual(['check']) + }), + ) +}) + +describe('typed failures', () => { + const effects: string[] = [] + const publish = Fractal.mission( + { meta, id: 'factory/test', goal: 'Publish.', reportTo, input: Change, output: Schema.String }, + () => + Effect.gen(function* () { + const reverted = yield* Fractal.step( + { id: 'publish' }, + Effect.suspend(() => { + effects.push('publish') + return Effect.fail(new StepFailed({ step: 'publish', reason: 'admission refused' })) + }), + ).pipe(Effect.catchTag('StepFailed', (e) => Effect.succeed(`reverted: ${e.reason}`))) + const refused = yield* Fractal.step( + { id: 'verify', error: Refused }, + Effect.suspend(() => { + effects.push('verify') + return Effect.fail(new Refused({ reason: 'stale' })) + }), + ).pipe( + Effect.catchTag('Refused', (e) => + Effect.succeed(e instanceof Refused ? `rebuilt ${e.reason}` : 'plain'), + ), + ) + return `${reverted}; ${refused}` + }), + ) + + it.effect('are journaled with their tag and fields and match catchTag on replay', () => + Effect.gen(function* () { + const first = fakeHost() + const exit = { _tag: 'Success', value: 'reverted: admission refused; rebuilt stale' } + expect(yield* start(publish, first)).toEqual(exit) + expect(first.recorded).toEqual([ + { + key: 'publish', + status: 'failed', + error: { _tag: 'StepFailed', step: 'publish', reason: 'admission refused' }, + }, + { key: 'verify', status: 'failed', error: { _tag: 'Refused', reason: 'stale' } }, + ]) + expect(effects.splice(0)).toEqual(['publish', 'verify']) + + expect(yield* start(publish, fakeHost({ journal: journalOf(first.recorded) }))).toEqual(exit) + expect(effects).toEqual([]) + }), + ) + + it.effect('fail the program with their tag and fields; defects are not journaled', () => + Effect.gen(function* () { + const failing = Fractal.mission( + { meta, id: 'factory/test', goal: 'Fail.', reportTo, input: Change }, + () => + Fractal.step( + { id: 'check' }, + Effect.fail(new StepFailed({ step: 'check', reason: 'red' })), + ), + ) + expect(yield* start(failing, fakeHost())).toEqual({ + _tag: 'Failure', + error: { _tag: 'StepFailed', step: 'check', reason: 'red' }, + }) + + const buggy = Fractal.mission( + { meta, id: 'factory/test', goal: 'Fail.', reportTo, input: Change }, + () => Fractal.step({ id: 'check' }, Effect.die(new Error('bug'))), + ) + const host = fakeHost() + const exit = yield* start(buggy, host) + expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Defect' } }) + expect(host.recorded).toEqual([]) + expect(host.started).toEqual(['check']) + }), + ) +}) + +describe('step keys', () => { + it.effect('repeat as id#n under Effect.retry and replay in that order', () => + Effect.gen(function* () { + let runs = 0 + const verify = Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'Verify.', + reportTo, + input: Change, + output: Schema.Number, + }, + () => + Fractal.step( + { id: 'verify', success: Schema.Number }, + Effect.sync(() => ++runs), + ).pipe( + Effect.flatMap((n) => + n < 3 + ? Effect.fail(new StepFailed({ step: 'verify', reason: 'stale' })) + : Effect.succeed(n), + ), + Effect.retry(Schedule.recurs(5)), + ), + ) + const first = fakeHost() + expect(yield* start(verify, first)).toEqual({ _tag: 'Success', value: 3 }) + expect(first.recorded.map((entry) => [entry.key, entry.value])).toEqual([ + ['verify', 1], + ['verify#2', 2], + ['verify#3', 3], + ]) + expect(yield* start(verify, fakeHost({ journal: journalOf(first.recorded) }))).toEqual({ + _tag: 'Success', + value: 3, + }) + expect(runs).toBe(3) + }), + ) + + it.effect('refuse two concurrent steps with one id as a defect', () => + Effect.gen(function* () { + const twice = Fractal.mission( + { meta, id: 'factory/test', goal: 'Check twice.', reportTo, input: Change }, + () => + Effect.asVoid( + Effect.all( + [ + Fractal.step({ id: 'check' }, Effect.never), + Fractal.step({ id: 'check' }, Effect.never), + ], + { concurrency: 'unbounded' }, + ), + ), + ) + const host = fakeHost() + const exit = yield* start(twice, host) + expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Defect' } }) + expect(JSON.stringify(exit)).toContain('two concurrent steps share the id `check`') + expect(host.recorded).toEqual([]) + }), + ) +}) + +describe('asks, child runs and worker turns', () => { + const Verdict = Schema.Struct({ verdict: Schema.Literals(['GO', 'NO-GO']) }) + const review = Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'Review.', + reportTo, + input: Change, + output: Schema.Array(Schema.String), + }, + ({ change }) => + Effect.gen(function* () { + const ack = yield* Fractal.ask({ + id: 'owner-ack', + for: Fractal.person('person/owner'), + title: 'Accept the change', + request: { version: 1, type: 'decision' }, + default: 'reject', + }) + const child = yield* Fractal.run('factory/publish', { change }, { id: 'publish' }) + const failed = yield* Fractal.run('factory/publish', { change }, { id: 'publish' }).pipe( + Effect.catchTag('ChildFailed', (e) => + Effect.succeed(e instanceof ChildFailed ? `failed ${e.reason}` : 'plain'), + ), + ) + const verdict = yield* Fractal.step({ id: 'review', success: Verdict }).pipe( + Effect.provide( + Fractal.Worker.layer({ + mission: 'factory/step/review', + input: { change }, + product: 'review/verdict', + }), + ), + ) + return [ + ack.answer?.id ?? 'none', + ack.resolved_by, + child.run, + String(failed), + verdict.verdict, + ] + }), + ) + const output = ['approve', 'person/owner', 'mission-run/child-1', 'failed cancelled', 'GO'] + + it.effect('wait for the daemon, then replay from its journal', () => + Effect.gen(function* () { + const requests: unknown[] = [] + const resolutions: Record = { + 'owner-ack': { + _tag: 'Success', + value: { + answer: { type: 'decision', outcome: 'accept', id: 'approve' }, + resolved_by: 'person/owner', + }, + }, + publish: { _tag: 'Success', value: { run: 'mission-run/child-1', status: 'completed' } }, + 'publish#2': { + _tag: 'Failure', + error: { + _tag: 'ChildFailed', + run: 'mission-run/child-2', + status: 'cancelled', + reason: 'cancelled', + }, + }, + review: { + _tag: 'Success', + value: { + run: 'mission-run/child-3', + status: 'completed', + products: { 'mission-run/child-3/review/verdict': { verdict: 'GO', head: 'h1' } }, + }, + }, + } + const host = fakeHost({ + ask: async (request) => { + requests.push(request) + return resolutions[request.key]! + }, + child: async (request) => { + requests.push(request) + return resolutions[request.key]! + }, + }) + expect(yield* start(review, host)).toEqual({ _tag: 'Success', value: output }) + expect(requests).toEqual([ + { + key: 'owner-ack', + for: 'person/owner', + title: 'Accept the change', + request: { version: 1, type: 'decision' }, + default: 'reject', + }, + { key: 'publish', mission: 'factory/publish', input: { change: 'abc' } }, + { key: 'publish#2', mission: 'factory/publish', input: { change: 'abc' } }, + { key: 'review', mission: 'factory/step/review', input: { change: 'abc' } }, + ]) + expect(host.recorded).toEqual([]) + + const replay = fakeHost({ journal: Object.entries(resolutions) }) + expect(yield* start(review, replay)).toEqual({ _tag: 'Success', value: output }) + }), + ) + + it.effect('fail typed when the daemon withdraws an ask', () => + Effect.gen(function* () { + const asking = Fractal.mission( + { meta, id: 'factory/test', goal: 'Ask.', reportTo, input: Change, output: Schema.String }, + () => + Fractal.ask({ + id: 'ack', + for: Fractal.person('person/owner'), + title: 'Ack', + request: null, + }).pipe( + Effect.as('answered'), + Effect.catchTag('AskCancelled', (e) => + Effect.succeed(e instanceof AskCancelled ? e.ask : ''), + ), + ), + ) + const journal = [ + ['ack', { _tag: 'Failure', error: { _tag: 'AskCancelled', ask: 'step-run/g/ask-1' } }], + ] as const + expect(yield* start(asking, fakeHost({ journal }))).toEqual({ + _tag: 'Success', + value: 'step-run/g/ask-1', + }) + }), + ) +}) + +describe('inputs and outputs', () => { + const counted = Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'Count.', + reportTo, + input: Schema.Struct({ + count: Schema.NumberFromString, + reviewer: Schema.Literals(['llm', 'scripted']), + }), + output: Schema.Struct({ next: Schema.NumberFromString }), + }, + ({ count }) => Effect.succeed({ next: count + 1 }), + ) + + it.effect('decode the payload and encode the success with the output schema', () => + Effect.gen(function* () { + expect( + yield* start(counted, fakeHost({ payload: { count: '41', reviewer: 'llm' } })), + ).toEqual({ + _tag: 'Success', + value: { next: '42' }, + }) + }), + ) + + it.effect('refuse a payload that does not decode', () => + Effect.gen(function* () { + for (const payload of [ + { count: '1', reviewer: 'nobody' }, + { count: '1' }, + { count: '1', reviewer: 'llm', extra: 'x' }, + ]) + expect(yield* start(counted, fakeHost({ payload }))).toMatchObject({ + _tag: 'Failure', + error: { _tag: 'InvalidInput' }, + }) + }), + ) + + it('refuse input fields that are optional or do not encode to a string', () => { + const body = () => Effect.void + expect(() => + Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'g', + reportTo, + input: Schema.Struct({ change: Schema.optionalKey(Schema.String) }), + }, + body, + ), + ).toThrow('Program input change must be required and encode to a string') + expect(() => + Fractal.mission( + // @ts-expect-error a number does not encode to a native text input + { + meta, + id: 'factory/test', + goal: 'g', + reportTo, + input: Schema.Struct({ count: Schema.Number }), + }, + body, + ), + ).toThrow('Program input count must be required and encode to a string') + }) +}) + +describe('cancellation', () => { + it.effect('interrupts the running step through the host signal and journals nothing', () => + Effect.gen(function* () { + let interrupted = false + const host = fakeHost() + // The step's effect is running when the daemon cancels the attempt. + const waiting = Fractal.mission( + { meta, id: 'factory/test', goal: 'Wait.', reportTo, input: Change }, + () => + Fractal.step( + { id: 'merge' }, + Effect.sync(() => host.cancel()).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Effect.sync(() => (interrupted = true))), + ), + ), + ) + const exit = yield* start(waiting, host) + expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Interrupted' } }) + expect(interrupted).toBe(true) + expect(host.started).toEqual(['merge']) + expect(host.recorded).toEqual([]) + }), + ) +}) + +describe('commands', () => { + it.effect('run through the host and return its result', () => + Effect.gen(function* () { + const echo = Fractal.mission( + { meta, id: 'factory/test', goal: 'Echo.', reportTo, input: Change, output: Schema.String }, + ({ change }) => + Fractal.step( + { id: 'echo', success: Schema.String }, + Effect.map( + Fractal.command(['echo', change], { priority: 'background' }), + (out) => out.stdout, + ), + ), + ) + expect(yield* start(echo, fakeHost())).toEqual({ _tag: 'Success', value: 'echo abc' }) + }), + ) +}) diff --git a/release-notes/program-api-effect-workflow.json b/release-notes/program-api-effect-workflow.json new file mode 100644 index 00000000000..73d26e36b13 --- /dev/null +++ b/release-notes/program-api-effect-workflow.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "The private `fractal` package gains `fractal/effect`: program-form missions (`Fractal.mission(intent, program)`) whose steps, worker turns, asks and child runs are `effect/workflow` activities and durable deferreds, and whose bundle entry implements the program host contract v1", + "replay": {"status": "none", "detail": "No daemon, claim or projection change. The package implements the bundle side of the program.step journal: it reads the journal the host hands over and writes entries through the host; the daemon side ships separately."}, + "schema": {"status": "none", "detail": "No table, index, migration or claim kind change."}, + "rules": {"status": "none", "detail": "No rule or authority change."}, + "compatibility": {"status": "changed", "detail": "Additive: a new `fractal/effect` package entry point. Existing `fractal`, `fractal/tree` and `fractal/testing` exports are unchanged. The host contract's child-run method is `child(request)`, since the context already has a `run` field naming the mission run."}, + "downtime": {"status": "none", "detail": "No deploy-time effect; the package is private and not published to npm."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "Nothing to recover: no state is written outside a program host."} +} From ad9d4d7290d1367d18a9d1d87c2bb5ec57dfb701 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:20:55 +0100 Subject: [PATCH 097/132] fractal: port the landing program to fractal/effect and run its bun bundle The factory landing program (routing, worker-turn reviews, owner ask, revert compensation, verify retries) as a fixture, typechecked with the package. A test builds it with bun build --target=bun and runs the bundle's entry against an in-memory host, live and replayed. README: the Programs section replaces the roadmap note. --- packages/fractal/README.md | 44 ++++- .../fixtures/programs/factory/agent.ts | 9 + .../programs/factory/land-program/mission.ts | 121 ++++++++++++ .../programs/factory/land-program/steps.ts | 183 ++++++++++++++++++ .../fractal/src/program-bundle.unit.test.ts | 85 ++++++++ 5 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 packages/fractal/fixtures/programs/factory/agent.ts create mode 100644 packages/fractal/fixtures/programs/factory/land-program/mission.ts create mode 100644 packages/fractal/fixtures/programs/factory/land-program/steps.ts create mode 100644 packages/fractal/src/program-bundle.unit.test.ts diff --git a/packages/fractal/README.md b/packages/fractal/README.md index 3eb70d36e85..0c30737b3cd 100644 --- a/packages/fractal/README.md +++ b/packages/fractal/README.md @@ -7,6 +7,7 @@ Typed authoring for Fractal declarations: agents, missions and schedules kept as | `fractal` | isomorphic | The `Fractal` authoring namespace; declaration schemas and the KDL lowering (`agent`, `mission`, `schedule` nodes, `emit`, `fractalKdl`) as named exports | | `fractal/tree` | Node | `loadSubjectTree` plus the define forms and reference types | | `fractal/testing` | Node | `scratchDaemonLayer`, an isolated daemon for conformance tests | +| `fractal/effect` | Bun program host, isomorphic | The `Fractal` namespace with the program forms of `mission` and `step`, plus `ask`, `run`, `command`, `env`, `cwd` and `Worker`; the host contract (`ProgramEntry`, `HostContextV1`) as named exports | ## Authoring @@ -59,9 +60,48 @@ Validate the output with `fractal validate` before publishing it with `fractal a [`@fractal/declarations`](../../clients/typescript/fractal-declarations/README.md) is the generated, dependency-free encoder for a subset of the grammar. This package covers more of the grammar (gates, loops, inputs and products, documents, observers, calendar schedules) and path-derived trees. For the shared subset both produce the same declarations; `src/parity.unit.test.ts` and `crates/fractal/tests/validate.rs` check that. [COVERAGE.md](./COVERAGE.md) records grammar coverage and conformance evidence; [docs/requirements.md](./docs/requirements.md) and [docs/spec.md](./docs/spec.md) hold the contract. -## Roadmap +## Programs -Program-form missions, `Fractal.mission(intent, program)` with `Fractal.step(...)` effects and `Fractal.ask`, are a design target shown in the documentation's Effect tab. They have no runtime yet: missions are declared as data. +`fractal/effect` holds the program form of a mission (design: [`docs/st3/program-engine.md`](../../docs/st3/program-engine.md)), built on Effect's `effect/workflow`. A program's body branches on step values. Its bundle runs in the daemon's program host, which journals every step and replays the journal after a restart; this package implements the bundle's side of that host contract. + +```ts +import { Effect, Schema } from 'effect' +import { Fractal } from 'fractal/effect' + +import factory from '../agent.ts' + +export default Fractal.mission( + { + meta: import.meta, + id: 'factory/land', + goal: 'Land one change on main.', + reportTo: factory, + input: Schema.Struct({ change: Schema.String }), + output: Schema.Struct({ commit: Schema.String }), + }, + ({ change }) => + Effect.gen(function* () { + const out = yield* Fractal.step( + { id: 'merge', success: Schema.String }, + Effect.map(Fractal.command(['./merge', change]), (result) => result.stdout.trim()), + ) + return { commit: out } + }), +) +``` + +| Call | `effect/workflow` | Journal | +|---|---|---| +| `Fractal.mission(intent, program)` | a `Workflow` with `intent.input` as payload and `intent.output` as success | the success, encoded with `output`, or the typed failure fails the program step | +| `Fractal.step({ id, success?, error? }, effect)` | an `Activity` named by its key: `id`, then `id#2`… for repeats, so `Effect.retry` runs it again under a new key | its encoded value, or its typed failure with `_tag` and fields; defects and interrupts are not journaled | +| `Fractal.step({ id, success }).pipe(Effect.provide(Fractal.Worker.layer({ mission, input, product })))` | a `DurableDeferred` | a worker turn: a linked child run of `mission`; the product `/`, decoded with `success`, is the value | +| `Fractal.ask({ id, for, title, request, deadline?, default? })` | a `DurableDeferred` | a native person ask on the program step, answered `AskAnswer` or failed `AskCancelled` | +| `Fractal.run(mission, input, { id? })` | a `DurableDeferred` | a linked child run, `ChildRun` or `ChildFailed` | +| `Fractal.command(argv, { env?, cwd?, priority? })` | — | not journaled: run it inside a step; the host runs it as a process group it ends on cancel | + +Every `input` field must be required and encode to a string, since each one is a native `input "name" kind="text"`; a payload that does not decode refuses the run with `InvalidInput`. Without `error`, a step or program journals any tagged error as `_tag` plus its fields and replays it as that plain object, so `catchTag` matches; an untagged error is a defect. With `error`, the schema rebuilds the error on replay. Two concurrent steps with one id are a defect, because their keys would depend on scheduling. + +A program mission's default export carries `[ProgramEntry]` (`Symbol.for('fractal.program.v1')`), the entry the program host calls with a `HostContextV1`; `fixtures/programs/factory/land-program` is the factory's landing program, and `src/program-bundle.unit.test.ts` runs its `bun build --target=bun` bundle. Bundling a program and rendering its `program` step declaration are not part of `loadSubjectTree` yet. ## Checks diff --git a/packages/fractal/fixtures/programs/factory/agent.ts b/packages/fractal/fixtures/programs/factory/agent.ts new file mode 100644 index 00000000000..64bab8d4bc8 --- /dev/null +++ b/packages/fractal/fixtures/programs/factory/agent.ts @@ -0,0 +1,9 @@ +import { Fractal } from '../../../src/index.ts' + +/** The factory seat landing missions report to. */ +export default Fractal.agent({ + meta: import.meta, + workspace: '/work/factory', + restart: 'always', + harness: { kind: 'claude', model: 'sonnet' }, +}) diff --git a/packages/fractal/fixtures/programs/factory/land-program/mission.ts b/packages/fractal/fixtures/programs/factory/land-program/mission.ts new file mode 100644 index 00000000000..75bd6449ac0 --- /dev/null +++ b/packages/fractal/fixtures/programs/factory/land-program/mission.ts @@ -0,0 +1,121 @@ +/** + * The factory's landing mission as a program: route a change by its classes, review what needs + * review, merge, publish and verify, and revert exactly what reached main when a later step fails. + */ +import { Data, Effect, Schedule, Schema } from 'effect' + +import { Fractal } from '../../../../src/effect.ts' +import factory from '../agent.ts' +import * as steps from './steps.ts' + +const owner = Fractal.person('person/owner') +const host = 'local' + +class NoGo extends Data.TaggedError('NoGo')<{ + readonly step: string + readonly findings: readonly string[] +}> {} +class Refused extends Data.TaggedError('Refused')<{ readonly reason: string }> {} +class Reverted extends Data.TaggedError('Reverted')<{ + readonly failed: string + readonly reason: string + readonly commits: readonly string[] +}> {} + +/** The strictest class wins: code > removal > authority > cross-owner > routine. */ +const route = (classes: readonly steps.Class[]): steps.Class => + (['code', 'removal', 'authority', 'cross-owner'] as const).find((c) => classes.includes(c)) ?? + 'routine' + +/** Undo what reached main, then fail the run with the reason. */ +const revertBecause = (failed: string, reason: string) => + Effect.flatMap(steps.revert({ failed, reason }), (r) => + Effect.fail(new Reverted({ failed, reason, commits: r.reverted.map((c) => c.revert_commit) })), + ) + +const requireGo = (verdict: steps.Verdict, step: string) => + verdict.verdict === 'GO' + ? Effect.void + : Effect.fail(new NoGo({ step, findings: verdict.findings })) + +export default Fractal.mission( + { + meta: import.meta, + id: 'factory/land-program', + goal: 'Land one change on main and prove it live, or leave main as it was.', + reportTo: factory, + timeout: '2h', + input: Schema.Struct({ change: Schema.String, reviewer: Schema.Literals(['llm', 'scripted']) }), + output: Schema.Struct({ class: steps.Class, merged: Schema.String, head: Schema.String }), + }, + ({ change, reviewer }) => + Effect.gen(function* () { + const classification = yield* steps.classify({ change }) + const cls = route(classification.classes) + const mode: steps.Mode = cls === 'code' ? 'full' : 'declaration' + + // Pre-merge: check, plus whatever this class needs before main may move. + if (cls === 'authority') { + const [, verdict] = yield* Effect.all( + [ + steps.check({ change, mode }), + steps.review({ + id: 'review-authority', + change, + checklist: 'authority', + mode: 'pre-merge', + reviewer, + }), + ], + { concurrency: 'unbounded' }, + ) + yield* requireGo(verdict, 'review-authority') + } else if (cls === 'cross-owner') { + const [, ack] = yield* Effect.all( + [steps.check({ change, mode }), steps.ownerAck({ owner, change, deadline: '1h' })], + { concurrency: 'unbounded' }, + ) + if (ack === 'Rejected') + return yield* new Refused({ + reason: 'the owner declined (or the deadline default applied)', + }) + } else { + yield* steps.check({ change, mode }) + } + + const merged = yield* steps.merge({ change, mode }) + + // Post-hoc review for code and removals: main already moved, so NO-GO means revert. + if (cls === 'code' || cls === 'removal') { + const checklist = cls === 'code' ? 'publisher-code' : 'removal' + const verdict = yield* steps.review({ + id: `review-${checklist}`, + change, + checklist, + mode: 'post-hoc', + commit: merged.commit, + reviewer, + }) + if (verdict.verdict === 'NO-GO') + return yield* revertBecause(`review-${checklist}`, verdict.findings.join('; ')) + } + + // Publish and verify; an admission refusal or a stale subject reverts the offending commits. + const published = yield* steps + .publish({ host }) + .pipe(Effect.catchTag('StepFailed', (e) => revertBecause(e.step, e.reason))) + yield* steps.verify({ host }).pipe( + Effect.flatMap((v) => + v.stale.length === 0 + ? Effect.void + : Effect.fail( + new steps.StepFailed({ step: 'verify', reason: `stale: ${v.stale.join(', ')}` }), + ), + ), + // The same step id runs again under the keys verify-local#2, verify-local#3. + Effect.retry(Schedule.recurs(2)), + Effect.catchTag('StepFailed', (e) => revertBecause(e.step, e.reason)), + ) + return { class: cls, merged: merged.commit, head: published.head } + }), +) diff --git a/packages/fractal/fixtures/programs/factory/land-program/steps.ts b/packages/fractal/fixtures/programs/factory/land-program/steps.ts new file mode 100644 index 00000000000..bb14f6b5e71 --- /dev/null +++ b/packages/fractal/fixtures/programs/factory/land-program/steps.ts @@ -0,0 +1,183 @@ +/** + * The landing step catalogue as program steps. Agentless steps run `steps/factory.py ` in the + * run's workspace, which writes the step's JSON result to `rundata//.json`. A review + * is a worker turn of the factory's `factory/step/review` mission; the owner's acknowledgement is a + * native ask. + */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { Data, Effect, Schema } from 'effect' + +import { Fractal } from '../../../../src/effect.ts' +import type { PersonReference } from '../../../../src/index.ts' + +export class StepFailed extends Data.TaggedError('StepFailed')<{ + readonly step: string + readonly reason: string +}> {} + +export type Mode = 'declaration' | 'full' +export const Class = Schema.Literals(['routine', 'authority', 'code', 'removal', 'cross-owner']) +export type Class = typeof Class.Type + +/** One `factory.py ` effect; its JSON result file, decoded with `schema`, is the value. */ +const tool = >( + verb: string, + env: Readonly>, + result: string, + schema: S, +) => + Effect.gen(function* () { + const run = (yield* Fractal.env).ST_MISSION_RUN ?? 'adhoc' + const cwd = yield* Fractal.cwd + const out = yield* Fractal.command(['python3', 'steps/factory.py', verb], { + env: { ...env, FACTORY_SCRATCH_ROOT: cwd }, + }) + if (out.code !== 0) + return yield* new StepFailed({ + step: verb, + reason: out.stderr.trim().split('\n').slice(-3).join(' '), + }) + const file = join(cwd, 'rundata', run.replaceAll('/', '_'), `${result}.json`) + const json = yield* Effect.promise(() => readFile(file, 'utf8')) + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(json).pipe( + Effect.mapError((error) => new StepFailed({ step: verb, reason: error.message })), + ) + }) + +export const Classification = Schema.Struct({ + change: Schema.String, + author: Schema.String, + classes: Schema.Array(Class), + reasons: Schema.Array(Schema.String), +}) +/** classify: every class this change belongs to; the program routes, so every class is accepted. */ +export const classify = ({ change }: { readonly change: string }) => + Fractal.step( + { id: 'classify', success: Classification }, + tool('classify', { CHANGE: change, ACCEPT: Class.literals.join(',') }, 'class', Classification), + ) + +export const CheckResult = Schema.Struct({ + mode: Schema.String, + errors: Schema.Array(Schema.String), +}) +/** check: declaration mode (render, offline validate, guard, `Removes:`) or full (plus the suite). */ +export const check = ({ change, mode }: { readonly change: string; readonly mode: Mode }) => + Fractal.step( + { id: 'check', success: CheckResult }, + tool('check', { CHANGE: change, MODE: mode }, 'check', CheckResult), + ) + +export const Merged = Schema.Struct({ + commit: Schema.String, + parent: Schema.String, + attempts: Schema.Number, +}) +/** merge: squash onto main by compare-and-swap; re-checks the exact merge result when main moved. */ +export const merge = ({ change, mode }: { readonly change: string; readonly mode: Mode }) => + Fractal.step( + { id: 'merge', success: Merged }, + tool('merge', { CHANGE: change, MODE: mode }, 'merged', Merged), + ) + +export const Published = Schema.Struct({ + host: Schema.String, + head: Schema.String, + changed: Schema.Array(Schema.String), +}) +/** publish: self-admission on one host (render main, dry-run every changed subject), then apply. */ +export const publish = ({ host }: { readonly host: string }) => + Fractal.step( + { id: `publish-${host}`, success: Published }, + tool('publish', { HOST: host }, 'publish', Published), + ) + +export const Verified = Schema.Struct({ head: Schema.String, stale: Schema.Array(Schema.String) }) +/** verify: every subject this run applied is live at its rendered revision. */ +export const verify = ({ host }: { readonly host: string }) => + Fractal.step( + { id: `verify-${host}`, success: Verified }, + tool('verify', { HOST: host }, 'verify', Verified), + ) + +export const Reverted = Schema.Struct({ + why: Schema.String, + reverted: Schema.Array(Schema.Struct({ reverted: Schema.String, revert_commit: Schema.String })), +}) +/** revert: undo exactly the offending commits on main; the program says which step failed and why. */ +export const revert = ({ failed, reason }: { readonly failed: string; readonly reason: string }) => + Fractal.step( + { id: 'revert', success: Reverted }, + tool('revert', { FAILED: failed, FAILED_REASON: reason }, 'revert', Reverted), + ) + +export type Checklist = 'authority' | 'publisher-code' | 'removal' | 'cross-owner' +export const Verdict = Schema.Struct({ + verdict: Schema.Literals(['GO', 'NO-GO']), + head: Schema.String, + findings: Schema.Array(Schema.String), +}) +export type Verdict = typeof Verdict.Type + +/** + * review: a worker turn by a fresh run-scoped reviewer that `factory/step/review` declares, never + * the author. The checklist picks the prompt; the verdict is the child run's product. + */ +export const review = (options: { + readonly id: string + readonly change: string + readonly checklist: Checklist + readonly mode: 'pre-merge' | 'post-hoc' + readonly commit?: string + readonly reviewer: 'llm' | 'scripted' +}) => + Fractal.step({ + id: options.id, + goal: `Review the exact change against the ${options.checklist} checklist.`, + success: Verdict, + }).pipe( + Effect.provide( + Fractal.Worker.layer({ + mission: 'factory/step/review', + product: 'review/verdict', + input: { + change: options.change, + checklist: options.checklist, + mode: options.mode, + commit: options.commit ?? options.change, + reviewer: options.reviewer, + }, + }), + ), + ) + +/** owner-ack: the owning person accepts a cross-owner write; the deadline default declines. */ +export const ownerAck = (options: { + readonly owner: PersonReference + readonly change: string + readonly deadline: string +}) => + Fractal.ask({ + id: 'owner-ack', + for: options.owner, + title: `Accept change ${options.change.slice(0, 10)} into your subjects`, + request: { + version: 1, + type: 'decision', + question: `Accept the write into your subjects by change ${options.change.slice(0, 10)}?`, + why_person: 'The owner decides writes into their own namespace.', + answers: [ + { id: 'approve', label: 'Accept', consequence: 'The change lands.', outcome: 'accept' }, + { + id: 'reject', + label: 'Decline', + consequence: 'The landing fails as refused.', + outcome: 'decline', + }, + ], + }, + deadline: options.deadline, + default: 'reject', + }).pipe(Effect.map((answer) => (answer.answer?.id === 'approve' ? 'Approved' : 'Rejected'))) diff --git a/packages/fractal/src/program-bundle.unit.test.ts b/packages/fractal/src/program-bundle.unit.test.ts new file mode 100644 index 00000000000..afc1a6e6e50 --- /dev/null +++ b/packages/fractal/src/program-bundle.unit.test.ts @@ -0,0 +1,85 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { describe, expect, it } from '@effect/vitest' +import { Effect } from 'effect' + +import { ProgramEntry, type HostContextV1, type ProgramExit } from './program-engine.ts' +import { fakeHost, journalOf } from './program-host.fixture.ts' + +const hasBun = spawnSync('bun', ['--version']).status === 0 +const landing = fileURLToPath( + new URL('../fixtures/programs/factory/land-program/mission.ts', import.meta.url), +) + +/** What `steps/factory.py ` writes for a routine change, by verb: result file and value. */ +const routine: Readonly> = { + classify: ['class', { change: 'abc', author: 'dev', classes: ['routine'], reasons: [] }], + check: ['check', { mode: 'declaration', errors: [] }], + merge: ['merged', { commit: 'c1', parent: 'p0', attempts: 1 }], + publish: ['publish', { host: 'local', head: 'c1', changed: ['mission/factory/x'] }], + verify: ['verify', { head: 'c1', stale: [] }], +} + +describe.skipIf(hasBun === false)('a program bundle', () => { + it.effect( + 'built with bun --target=bun runs the landing program against a host', + () => + Effect.gen(function* () { + const cwd = mkdtempSync(join(tmpdir(), 'fractal-program-')) + const bundle = join(cwd, 'land.mjs') + const build = spawnSync( + 'bun', + ['build', landing, '--target=bun', '--format=esm', `--outfile=${bundle}`], + { encoding: 'utf8' }, + ) + expect(build.status, build.stderr).toBe(0) + const module: { + readonly default: { + readonly [ProgramEntry]: (host: HostContextV1) => Promise + } + } = yield* Effect.promise(() => import(pathToFileURL(bundle).href)) + + const verbs: string[] = [] + const host = fakeHost({ + payload: { change: 'abc', reviewer: 'scripted' }, + cwd, + spawn: async (argv) => { + const verb = argv[2]! + verbs.push(verb) + const [result, value] = routine[verb]! + const dir = join(cwd, 'rundata', 'mission-run_run-1') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, `${result}.json`), JSON.stringify(value)) + return { code: 0, signal: null, stdout: '', stderr: '' } + }, + }) + const exit = { _tag: 'Success', value: { class: 'routine', merged: 'c1', head: 'c1' } } + expect(yield* Effect.promise(() => module.default[ProgramEntry](host.context))).toEqual( + exit, + ) + expect(verbs).toEqual(['classify', 'check', 'merge', 'publish', 'verify']) + expect(host.recorded.map((entry) => entry.key)).toEqual([ + 'classify', + 'check', + 'merge', + 'publish-local', + 'verify-local', + ]) + + const replay = fakeHost({ + payload: { change: 'abc', reviewer: 'scripted' }, + cwd, + journal: journalOf(host.recorded), + spawn: () => Promise.reject(new Error('a replayed step ran its command')), + }) + expect(yield* Effect.promise(() => module.default[ProgramEntry](replay.context))).toEqual( + exit, + ) + }), + 60_000, + ) +}) From b1668d6de2d2351f00055e75cbb10113f8e2297e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:21:39 +0100 Subject: [PATCH 098/132] Restore same-principal service record in cross-profile admission test agent-identity: agent/dotfiles/st-delegation-2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-core/src/api/client_v0/external_grants.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants.rs b/crates/fractal-api-core/src/api/client_v0/external_grants.rs index 3669ab54c70..f02d9c090ef 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants.rs @@ -373,10 +373,6 @@ mod tests { let service_subject = subject("333333333333333333333333").unwrap(); let mut service = source.clone(); service["profile"] = json!("service"); - service["principal"] = json!("plugin/test/reader"); - service["grant_id"] = json!("333333333333333333333333"); - service["session_actor"] = json!("plugin/test/reader/session/333333333333333333333333"); - service["scopes"] = json!(SERVICE_SCOPES); // Simulate an existing profile record without enabling service issuance. let grant = state.store.append_claim(&ClaimInput { subject: service_subject.clone(), kind: GRANTED.into(), actor: Some("person/owner".into()), From 8f0d0c64e5188888cdd3ee6ff3f3016e0f234593 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:22:57 +0100 Subject: [PATCH 099/132] fractal: keep the input type assertion on the offending field after formatting --- packages/fractal/src/program.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fractal/src/program.unit.test.ts b/packages/fractal/src/program.unit.test.ts index 1a33da1d071..0e3f83e7d12 100644 --- a/packages/fractal/src/program.unit.test.ts +++ b/packages/fractal/src/program.unit.test.ts @@ -422,12 +422,12 @@ describe('inputs and outputs', () => { ).toThrow('Program input change must be required and encode to a string') expect(() => Fractal.mission( - // @ts-expect-error a number does not encode to a native text input { meta, id: 'factory/test', goal: 'g', reportTo, + // @ts-expect-error a number does not encode to a native text input input: Schema.Struct({ count: Schema.Number }), }, body, From 675987e3649c88f6880ab14dffc45185677d38d2 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:24:54 +0100 Subject: [PATCH 100/132] Expect person holders as person actors in mission presentation test agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../src/missions/presentation/tests.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/fractal-ui-model/src/missions/presentation/tests.rs b/crates/fractal-ui-model/src/missions/presentation/tests.rs index 05c35eddc3d..0862ee816cd 100644 --- a/crates/fractal-ui-model/src/missions/presentation/tests.rs +++ b/crates/fractal-ui-model/src/missions/presentation/tests.rs @@ -293,7 +293,17 @@ fn invalid_or_non_agent_holders_never_emit_agent_actors() { let agents = Agents::default().with(id, "stopped", None, &[]); let shown = present(&mission, &[], &agents); assert_eq!(shown.who, Actor::Nobody, "{id}"); - assert_eq!(shown.steps[0].owner, Actor::Nobody, "{id}"); + let owner = if id == "person/avery" { + Actor::Person { + person: id.into(), + label: "avery".into(), + } + } else { + Actor::Nobody + }; + assert_eq!(shown.steps[0].owner, owner, "{id}"); + assert!(!matches!(shown.who, Actor::Agent { .. }), "{id}"); + assert!(!matches!(shown.steps[0].owner, Actor::Agent { .. }), "{id}"); assert_eq!(shown.stuck.unwrap().agent, None, "{id}"); } let mission = mission("running", vec![step("build", "ready", json!({}))]); @@ -306,6 +316,8 @@ fn invalid_or_non_agent_holders_never_emit_agent_actors() { let shown = present(&mission, &[], &agents); assert_eq!(shown.who, Actor::Nobody, "{id}"); assert_eq!(shown.steps[0].owner, Actor::Nobody, "{id}"); + assert!(!matches!(shown.who, Actor::Agent { .. }), "{id}"); + assert!(!matches!(shown.steps[0].owner, Actor::Agent { .. }), "{id}"); assert_eq!(shown.queued_behind, None, "{id}"); } } From 90ecd1e9ba0d937a6c19c2cc004a948ae892926c Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:35:00 +0100 Subject: [PATCH 101/132] fractal: bind step ids to one fiber, end the attempt on a refused journal entry, type untyped step failures as journaled - An id used by a second fiber is a defect, so concurrent branches cannot take id#n keys in completion order and swap values on replay. - A nack (or a record rejection) interrupts the attempt instead of dying inside the step, so a body that catches defects cannot continue past an entry that is not durable; the entry reports it as a Defect. - A step without `error` fails with Journaled, the plain _tag-plus- fields shape it delivers live and on replay, so a program error class raised from such a step is a type error instead of a runtime Defect. - The bundle test fails with a clear message when bun is missing. --- packages/fractal/README.md | 2 +- packages/fractal/src/effect.ts | 2 + .../fractal/src/program-bundle.unit.test.ts | 6 +- packages/fractal/src/program-engine.ts | 108 +++++++++++------- packages/fractal/src/program.ts | 23 +++- packages/fractal/src/program.unit.test.ts | 90 ++++++++++++++- 6 files changed, 181 insertions(+), 50 deletions(-) diff --git a/packages/fractal/README.md b/packages/fractal/README.md index 0c30737b3cd..d9d56f0788d 100644 --- a/packages/fractal/README.md +++ b/packages/fractal/README.md @@ -99,7 +99,7 @@ export default Fractal.mission( | `Fractal.run(mission, input, { id? })` | a `DurableDeferred` | a linked child run, `ChildRun` or `ChildFailed` | | `Fractal.command(argv, { env?, cwd?, priority? })` | — | not journaled: run it inside a step; the host runs it as a process group it ends on cancel | -Every `input` field must be required and encode to a string, since each one is a native `input "name" kind="text"`; a payload that does not decode refuses the run with `InvalidInput`. Without `error`, a step or program journals any tagged error as `_tag` plus its fields and replays it as that plain object, so `catchTag` matches; an untagged error is a defect. With `error`, the schema rebuilds the error on replay. Two concurrent steps with one id are a defect, because their keys would depend on scheduling. +Every `input` field must be required and encode to a string, since each one is a native `input "name" kind="text"`; a payload that does not decode refuses the run with `InvalidInput`. Without `error`, a step or program journals any tagged error as `_tag` plus its fields and the step fails with that plain object (typed `Journaled`), live and on replay, so `catchTag` matches; an untagged error is a defect. With `error`, the schema rebuilds the error as its class, which a program's `error` class needs. Only one fiber may use a step id: concurrent branches would take `id#n` keys in completion order, which replay does not reproduce, so a second branch using the id is a defect. A journal entry the daemon refuses ends the attempt with a defect that the body cannot catch. A program mission's default export carries `[ProgramEntry]` (`Symbol.for('fractal.program.v1')`), the entry the program host calls with a `HostContextV1`; `fixtures/programs/factory/land-program` is the factory's landing program, and `src/program-bundle.unit.test.ts` runs its `bun build --target=bun` bundle. Bundling a program and rendering its `program` step declaration are not part of `loadSubjectTree` yet. diff --git a/packages/fractal/src/effect.ts b/packages/fractal/src/effect.ts index 628aca51d06..bd1c3ad47f1 100644 --- a/packages/fractal/src/effect.ts +++ b/packages/fractal/src/effect.ts @@ -9,6 +9,7 @@ export { ChildFailed, ChildRun, type InputSchema, + type Journaled, type ProgramIntent, type ProgramMission, type StepOptions, @@ -19,6 +20,7 @@ export { type CommandOptions, type CommandResult, type HostContextV1, + type JournalEntry, type ProgramExit, type ProgramServices, } from './program-engine.ts' diff --git a/packages/fractal/src/program-bundle.unit.test.ts b/packages/fractal/src/program-bundle.unit.test.ts index afc1a6e6e50..becfee5df29 100644 --- a/packages/fractal/src/program-bundle.unit.test.ts +++ b/packages/fractal/src/program-bundle.unit.test.ts @@ -10,7 +10,6 @@ import { Effect } from 'effect' import { ProgramEntry, type HostContextV1, type ProgramExit } from './program-engine.ts' import { fakeHost, journalOf } from './program-host.fixture.ts' -const hasBun = spawnSync('bun', ['--version']).status === 0 const landing = fileURLToPath( new URL('../fixtures/programs/factory/land-program/mission.ts', import.meta.url), ) @@ -24,11 +23,14 @@ const routine: Readonly> = { verify: ['verify', { head: 'c1', stale: [] }], } -describe.skipIf(hasBun === false)('a program bundle', () => { +describe('a program bundle', () => { it.effect( 'built with bun --target=bun runs the landing program against a host', () => Effect.gen(function* () { + // Programs are Bun bundles; `nix develop .#web` puts bun on PATH. + const version = spawnSync('bun', ['--version'], { encoding: 'utf8' }) + expect(version.status, 'bun must be on PATH: run the tests in `nix develop .#web`').toBe(0) const cwd = mkdtempSync(join(tmpdir(), 'fractal-program-')) const bundle = join(cwd, 'land.mjs') const build = spawnSync( diff --git a/packages/fractal/src/program-engine.ts b/packages/fractal/src/program-engine.ts index 6e660d66ad9..6199e2e36ab 100644 --- a/packages/fractal/src/program-engine.ts +++ b/packages/fractal/src/program-engine.ts @@ -35,6 +35,15 @@ export interface CommandResult { readonly stderr: string } +/** A step's result as the bundle hands it to the daemon to journal. */ +export interface JournalEntry { + readonly key: string + readonly status: 'completed' | 'failed' + readonly value?: unknown + readonly error?: unknown + readonly ms: number +} + /** What the program host hands a bundle's entry for one attempt of one program step. */ export interface HostContextV1 { /** The program's step run subject. */ @@ -53,13 +62,7 @@ export interface HostContextV1 { readonly signal: AbortSignal started(key: string): void /** Resolves once the daemon has made the entry durable; rejects on nack or cancel. */ - record(entry: { - readonly key: string - readonly status: 'completed' | 'failed' - readonly value?: unknown - readonly error?: unknown - readonly ms: number - }): Promise + record(entry: JournalEntry): Promise /** * Starts or adopts the linked child run for `key` (protocol message `run`); the daemon journals * its end under `key`. @@ -88,7 +91,8 @@ export class Program extends Context.Service< { readonly host: HostContextV1 readonly occurrences: Map - readonly active: Set + /** By id: the fiber that uses it. */ + readonly owners: Map readonly requests: Map Promise> } >()('fractal/Program') {} @@ -100,27 +104,28 @@ export type ProgramServices = | WorkflowEngine.WorkflowInstance /** - * Runs `use` under the next key of `id`: `id`, then `id#2`, `id#3`… in one execution, which is - * deterministic for a deterministic body. Two concurrent uses of one id would take their keys in - * scheduling order, which replay cannot reproduce, so that is a defect. + * Runs `use` under the next key of `id`: `id`, then `id#2`, `id#3`… in one execution. Only one + * fiber may use an id: repeats within a fiber (a loop, `Effect.retry`) take their keys in program + * order, while concurrent branches would take them in completion order, which replay does not + * reproduce, so a second fiber using the id is a defect. */ export const keyed = ( id: string, use: (key: string, program: Program['Service']) => Effect.Effect, ): Effect.Effect => - Effect.flatMap(Effect.service(Program), (program): Effect.Effect => { - if (program.active.has(id)) - return Effect.die( - new Error(`two concurrent steps share the id \`${id}\`; give each its own id`), - ) - const n = (program.occurrences.get(id) ?? 0) + 1 - program.occurrences.set(id, n) - program.active.add(id) - return Effect.ensuring( - use(n === 1 ? id : `${id}#${n}`, program), - Effect.sync(() => program.active.delete(id)), - ) - }) + Effect.withFiber((fiber) => + Effect.flatMap(Effect.service(Program), (program): Effect.Effect => { + const owner = program.owners.get(id) + if (owner !== undefined && owner !== fiber.id) + return Effect.die( + new Error(`steps in concurrent branches share the id \`${id}\`; give each its own id`), + ) + program.owners.set(id, fiber.id) + const n = (program.occurrences.get(id) ?? 0) + 1 + program.occurrences.set(id, n) + return use(n === 1 ? id : `${id}#${n}`, program) + }), + ) /** A typed failure: an error with a string `_tag`. */ export type Tagged = { readonly _tag: string } @@ -159,8 +164,19 @@ const notInPrograms = (what: string) => Effect.die(new Error(`${what} is not available in a Fractal program`)) /** The engine of one program attempt; it runs the one workflow registered with it. */ -const hostEngine = (program: Program['Service']): WorkflowEngine.Encoded => { +const hostEngine = ( + program: Program['Service'], + refuse: (key: string, cause: unknown) => void, +): WorkflowEngine.Encoded => { const { host, requests } = program + /** Waits for the ack; a refused entry ends the attempt and never resumes the step. */ + const durable = (entry: JournalEntry) => + Effect.promise(() => + host.record(entry).catch((cause: unknown) => { + refuse(entry.key, cause) + return new Promise(() => {}) + }), + ) const workflows = new Map< string, ( @@ -224,18 +240,14 @@ const hostEngine = (program: Program['Service']): WorkflowEngine.Encoded => { const ms = Date.now() - started const { exit } = result if (Exit.isSuccess(exit)) { - yield* Effect.promise(() => - host.record({ key, status: 'completed', value: exit.value ?? null, ms }), - ) + yield* durable({ key, status: 'completed', value: exit.value ?? null, ms }) } else if ( Cause.hasDies(exit.cause) === false && Cause.hasInterrupts(exit.cause) === false ) { const error = Cause.findErrorOption(exit.cause) if (Option.isSome(error)) - yield* Effect.promise(() => - host.record({ key, status: 'failed', error: error.value, ms }), - ) + yield* durable({ key, status: 'failed', error: error.value, ms }) } return result }), @@ -264,19 +276,31 @@ const encodeJson = (schema: Schema.Codec, value: T) => /** * The `fractal.program.v1` entry: decodes the run's inputs, runs the body as a workflow whose * execution is the step run, and returns the success encoded with `output` or the typed failure - * encoded with `error`. Inputs that do not decode refuse the run with `InvalidInput`; a defect - * returns `Defect` with the cause. After a cancel the host discards the exit. + * encoded with `error`. Inputs that do not decode refuse the run with `InvalidInput`; a defect or + * a refused journal entry returns `Defect`. After a cancel the host discards the exit. */ export const programEntry = (definition: ProgramDefinition) => async (host: HostContextV1): Promise => { + // Ends the attempt when the daemon cancels it or refuses a journal entry. Interrupting the root + // fiber, rather than failing the step, keeps the body from continuing past a result that is + // not durable. + const stop = new AbortController() + const cancel = () => stop.abort() + host.signal.addEventListener('abort', cancel, { once: true }) + if (host.signal.aborted) stop.abort() + let refused: string | undefined + const refuse = (key: string, cause: unknown) => { + refused ??= `the daemon refused the journal entry for \`${key}\`: ${cause instanceof Error ? cause.message : String(cause)}` + stop.abort() + } const program: Program['Service'] = { host, occurrences: new Map(), - active: new Set(), + owners: new Map(), requests: new Map(), } - const engine = WorkflowEngine.makeUnsafe(hostEngine(program)) + const engine = WorkflowEngine.makeUnsafe(hostEngine(program, refuse)) const workflow = Workflow.make(definition.id, { payload: definition.input, success: definition.output, @@ -313,11 +337,13 @@ export const programEntry = Effect.provideService(Program, program), Effect.provideService(WorkflowEngine.WorkflowEngine, engine), ) - const exit = await Effect.runPromiseExit(attempt, { signal: host.signal }) + const exit = await Effect.runPromiseExit(attempt, { signal: stop.signal }).finally(() => + host.signal.removeEventListener('abort', cancel), + ) + const message = Exit.isSuccess(exit) ? '' : Cause.pretty(exit.cause).slice(0, 2000) + if (host.signal.aborted) return { _tag: 'Failure', error: { _tag: 'Interrupted', message } } + if (refused !== undefined) + return { _tag: 'Failure', error: { _tag: 'Defect', message: refused } } if (Exit.isSuccess(exit)) return exit.value - const message = Cause.pretty(exit.cause).slice(0, 2000) - return { - _tag: 'Failure', - error: host.signal.aborted ? { _tag: 'Interrupted', message } : { _tag: 'Defect', message }, - } + return { _tag: 'Failure', error: { _tag: 'Defect', message } } } diff --git a/packages/fractal/src/program.ts b/packages/fractal/src/program.ts index 8bf02354ca3..3f8463b1fff 100644 --- a/packages/fractal/src/program.ts +++ b/packages/fractal/src/program.ts @@ -186,21 +186,36 @@ export interface StepOptions { readonly goal?: string } +/** + * The failure a step without `error` delivers, live and on replay: its tag and data fields as a + * plain object, without the class's prototype, `Error` members or methods. + */ +export type Journaled = E extends unknown + ? { + readonly [K in keyof E as K extends keyof Error | symbol + ? never + : E[K] extends (...args: never) => unknown + ? never + : K]: E[K] + } + : never + /** * A step of a program, journaled under its key (`id`, then `id#2`… for repeats, so `Effect.retry` * runs it again under a new key). With an effect it is an `Activity`: replay returns the journaled * value decoded with `success` (void without one) or fails again with the journaled typed error, - * rebuilt with `error` or as `_tag` plus fields. Without an effect it is a worker turn: a child run - * of the provided `Worker` mission, whose product decoded with `success` is the value. + * rebuilt as its class with `error`, or as its plain `Journaled` shape without. Without an effect + * it is a worker turn: a child run of the provided `Worker` mission, whose product decoded with + * `success` is the value. */ export function step( options: StepOptions & { readonly success?: undefined; readonly error?: undefined }, effect: Effect.Effect, -): Effect.Effect +): Effect.Effect, R | ProgramServices> export function step( options: StepOptions & { readonly success: S; readonly error?: undefined }, effect: Effect.Effect, -): Effect.Effect +): Effect.Effect, R | ProgramServices> export function step( options: StepOptions & { readonly success?: S; readonly error: ES }, effect: Effect.Effect, diff --git a/packages/fractal/src/program.unit.test.ts b/packages/fractal/src/program.unit.test.ts index 0e3f83e7d12..9806226f161 100644 --- a/packages/fractal/src/program.unit.test.ts +++ b/packages/fractal/src/program.unit.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from '@effect/vitest' import { Data, Effect, Schedule, Schema } from 'effect' -import { AskCancelled, ChildFailed, Fractal, type ProgramExit } from './effect.ts' +import { + AskCancelled, + ChildFailed, + Fractal, + type ProgramExit, + type ProgramServices, +} from './effect.ts' import { fakeHost, journalOf, start } from './program-host.fixture.ts' import type { AgentRef } from './subject-ref.ts' @@ -88,6 +94,37 @@ describe('program replay', () => { expect(effects.splice(0)).toEqual(['check']) }), ) + + it.effect('ends the attempt on a refused journal write even when the body catches defects', () => + Effect.gen(function* () { + const ran: string[] = [] + const catching = Fractal.mission( + { meta, id: 'factory/test', goal: 'Merge.', reportTo, input: Change }, + () => + Effect.gen(function* () { + yield* Effect.exit( + Fractal.step( + { id: 'check' }, + Effect.sync(() => ran.push('check')), + ), + ) + yield* Fractal.step( + { id: 'merge' }, + Effect.sync(() => ran.push('merge')), + ) + }), + ) + const exit = yield* start(catching, fakeHost({ nack: 'idempotency-conflict' })) + expect(exit).toEqual({ + _tag: 'Failure', + error: { + _tag: 'Defect', + message: 'the daemon refused the journal entry for `check`: idempotency-conflict', + }, + }) + expect(ran).toEqual(['check']) + }), + ) }) describe('typed failures', () => { @@ -164,6 +201,30 @@ describe('typed failures', () => { expect(host.started).toEqual(['check']) }), ) + + it.effect('fail a program that declares an error class when the step declares it too', () => + Effect.gen(function* () { + const refused = Fractal.mission( + { meta, id: 'factory/test', goal: 'Refuse.', reportTo, input: Change, error: Refused }, + () => + Fractal.step( + { id: 'verify', error: Refused }, + Effect.fail(new Refused({ reason: 'stale' })), + ), + ) + expect(yield* start(refused, fakeHost())).toEqual({ + _tag: 'Failure', + error: { _tag: 'Refused', reason: 'stale' }, + }) + // Without `error` the step fails with the plain journaled shape, which is not the class. + // @ts-expect-error a plain `{ _tag, reason }` is not a `Refused` instance + const plain: Effect.Effect = Fractal.step( + { id: 'verify' }, + Effect.fail(new Refused({ reason: 'stale' })), + ) + expect(plain).toBeDefined() + }), + ) }) describe('step keys', () => { @@ -225,10 +286,35 @@ describe('step keys', () => { const host = fakeHost() const exit = yield* start(twice, host) expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Defect' } }) - expect(JSON.stringify(exit)).toContain('two concurrent steps share the id `check`') + expect(JSON.stringify(exit)).toContain('steps in concurrent branches share the id `check`') expect(host.recorded).toEqual([]) }), ) + + it.effect('refuse one id in two concurrent branches even when the uses do not overlap', () => + Effect.gen(function* () { + // Live, the fast branch would take `x` and the slow one `x#2`; on replay `a` returns at once + // and the branches would swap keys and values. + const branches = Fractal.mission( + { meta, id: 'factory/test', goal: 'Branch.', reportTo, input: Change }, + () => + Effect.asVoid( + Effect.all( + [ + Fractal.step({ id: 'a' }, Effect.yieldNow).pipe( + Effect.andThen(Fractal.step({ id: 'x' }, Effect.void)), + ), + Fractal.step({ id: 'x' }, Effect.void), + ], + { concurrency: 'unbounded' }, + ), + ), + ) + const exit = yield* start(branches, fakeHost()) + expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Defect' } }) + expect(JSON.stringify(exit)).toContain('steps in concurrent branches share the id `x`') + }), + ) }) describe('asks, child runs and worker turns', () => { From 31de4e6a07ae748a584d251913be1b67a36dfec2 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:40:58 +0100 Subject: [PATCH 102/132] Compare authority refusal codes as strings --- crates/fractal-missions-base/src/mission.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index 0c2884286f0..7670d6dad34 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -3330,7 +3330,7 @@ mod tests { fn authority_error(body: &str) -> String { let source = format!(r#"mission "land" state="ready" {{ goal "Land."; step "work" {{ agentless; }}; {body} }}"#); - super::parse_missions(&source.parse().unwrap(), "node").unwrap_err().code + super::parse_missions(&source.parse().unwrap(), "node").unwrap_err().code.to_string() } #[test] From 379ca20555798db8f59914c9fefcca792995490e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:42:23 +0100 Subject: [PATCH 103/132] fix: isolate named-instance native session discovery --- crates/fractal-kernel/src/instance.rs | 73 +++++++++++++++++- crates/fractal-seats/src/external_sessions.rs | 75 +++++++++++++++++++ crates/fractal/src/main.rs | 9 ++- docs/st3/instances.md | 13 +++- .../instance-native-session-isolation.json | 32 ++++++++ 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 release-notes/instance-native-session-isolation.json diff --git a/crates/fractal-kernel/src/instance.rs b/crates/fractal-kernel/src/instance.rs index 153f155f263..ae2ecfa3c34 100644 --- a/crates/fractal-kernel/src/instance.rs +++ b/crates/fractal-kernel/src/instance.rs @@ -16,6 +16,7 @@ //! bin/ the st3, st and pty the installer puts there //! claude/ the instance's Claude account (CLAUDE_CONFIG_DIR) //! codex/ the instance's Codex account (CODEX_HOME) +//! home/ native harness HOME and pi/omp profiles; aliases existing provider and data dirs //! work/ the default work root: workspaces of new agents, in work/agents/NAME //! app/ where a macOS app bundle for the instance goes //! ``` @@ -140,6 +141,35 @@ impl Instance { self.root.join("codex") } + /// HOME-shaped session view, shared by discovery and native harness processes. + /// Keep installed generic harness skills in the same home. + pub fn native_session_home(&self) -> PathBuf { + self.root.join("home") + } + + /// Preserve the existing provider account and XDG data locations without copying sessions. + pub fn prepare_native_session_home(&self) -> Result { + let home = self.native_session_home(); + std::fs::create_dir_all(home.join(".local"))?; + for (alias, target) in [ + (home.join(".claude"), self.claude_config_dir()), + (home.join(".codex"), self.codex_home()), + (home.join(".local/share"), self.data_home()), + ] { + if let Err(error) = std::os::unix::fs::symlink(&target, &alias) { + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(error).with_context(|| format!("create native session alias {}", alias.display())); + } + anyhow::ensure!( + std::fs::read_link(&alias)? == target, + "native session alias {} does not point to its selected instance directory", + alias.display() + ); + } + } + Ok(home) + } + /// The instance's default work root: where its agents work, beside the rest of the instance. pub fn work_dir(&self) -> PathBuf { // Workspaces are a separate migration; do not move them with config/state. @@ -191,7 +221,7 @@ impl Instance { /// process: guessing would run the default install when a person asked for a separate one. pub fn current() -> Option { let name = environment_name()?; - let home = match env::var_os("HOME").map(PathBuf::from) { + let home = match instance_host_home() { Some(home) => home, None => fail("HOME is not set, so the instance directory cannot be found"), }; @@ -201,6 +231,42 @@ pub fn current() -> Option { } } +/// A native driver changes HOME, but nested CLI invocations still resolve the same instance. +fn instance_host_home() -> Option { + environment_name() + .and_then(|_| env::var_os("ST_INSTANCE_HOST_HOME")) + .or_else(|| env::var_os("HOME")) + .map(PathBuf::from) +} + +/// Select the discovery root without ever falling back to the login home inside an instance. +pub fn native_session_home() -> Result> { + match current() { + Some(instance) => instance.prepare_native_session_home().map(Some), + None => Ok(env::var_os("HOME").map(PathBuf::from)), + } +} + +/// Apply only at the native driver boundary; the CLI and service manager retain the login HOME. +/// +/// # Safety +/// +/// Changes the process environment, so no other thread may be running. +pub unsafe fn activate_native_session_home() -> Result<()> { + let Some(instance) = current() else { + return Ok(()); + }; + let home = instance.prepare_native_session_home()?; + let host = instance_host_home().context("HOME is not set")?; + unsafe { + env::set_var("ST_INSTANCE_HOST_HOME", host); + env::set_var("HOME", home); + // An ambient profile override must not send pi/omp back to the login session store. + env::remove_var("PI_CODING_AGENT_DIR"); + } + Ok(()) +} + fn fail(message: &str) -> ! { eprintln!("st: {message}"); std::process::exit(2); @@ -240,10 +306,11 @@ pub unsafe fn activate(name: &str) -> Result { { anyhow::bail!("this process already runs in instance `{active}`, not `{name}`"); } - let home = env::var_os("HOME") - .map(PathBuf::from) + let home = instance_host_home() .context("HOME is not set, so the instance directory cannot be found")?; let instance = Instance::new(&home, name)?; + // A nested non-driver CLI restores the login HOME before resolving service-manager paths. + unsafe { env::set_var("HOME", &home) }; let already_inside = active.as_deref() == Some(name); for (variable, remembered) in XDG { // The host's own value is kept once, on the way in; a child started from inside the diff --git a/crates/fractal-seats/src/external_sessions.rs b/crates/fractal-seats/src/external_sessions.rs index 0854e7e872d..04eb1d583f5 100644 --- a/crates/fractal-seats/src/external_sessions.rs +++ b/crates/fractal-seats/src/external_sessions.rs @@ -2817,6 +2817,81 @@ mod tests { #[cfg(target_os = "linux")] use fractal_store::external_sessions::shared_tests_seats::path_executable; + #[test] + fn native_session_discovery_is_instance_scoped() { + use fractal_kernel::instance; + const CHILD: &str = "FRACTAL_TEST_NATIVE_INSTANCE_HOME"; + if let Ok(case) = std::env::var(CHILD) { + let login_home = PathBuf::from(std::env::var_os("HOME").unwrap()); + let write_session = |home: &Path, id: &str| { + let directory = home.join(".codex/sessions/2026/11/10"); + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join(format!("rollout-{id}.jsonl")), + format!("{}\n", json!({"type":"session_meta", "payload":{"id":id, "cwd":home}})), + ).unwrap(); + }; + write_session(&login_home, "login-only"); + if case == "instance" { + // SAFETY: isolated single-test subprocess; no runtime or other readers exist. + let selected = unsafe { instance::activate("trial") }.unwrap(); + let native = instance::native_session_home().unwrap().unwrap(); + assert_eq!(native, selected.native_session_home()); + fs::create_dir_all(selected.codex_home()).unwrap(); + write_session(&native, "instance-only"); + let home = Some(native.as_path()); + // Inventory and search use these discovery entrypoints, including the cached history. + for discovery in [ + crate::external_sessions::discover_fresh(home, true).unwrap(), + crate::external_sessions::discover(home, true).unwrap(), + ] { + assert_eq!(discovery.sessions.len(), 1); + assert_eq!(discovery.sessions[0].native_id, "instance-only"); + } + let login_id = crate::external_sessions::external_session_id( + crate::external_sessions::ExternalDriver::Codex, "login-only", + ); + assert!(crate::external_sessions::find_conversation(home, &login_id).unwrap().is_none()); + unsafe { instance::activate_native_session_home() }.unwrap(); + assert_eq!(std::env::var_os("HOME").map(PathBuf::from), Some(native.clone())); + assert!(std::env::var_os("PI_CODING_AGENT_DIR").is_none()); + assert_eq!(instance::current(), Some(selected.clone())); + assert_eq!(PathBuf::from(std::env::var_os("CODEX_HOME").unwrap()), selected.codex_home()); + assert_eq!(fs::canonicalize(native.join(".codex")).unwrap(), fs::canonicalize(selected.codex_home()).unwrap()); + assert_eq!(fs::read_link(native.join(".claude")).unwrap(), selected.claude_config_dir()); + assert_eq!(fs::read_link(native.join(".local/share")).unwrap(), selected.data_home()); + // Re-exec and nested CLI selection must not nest instances beneath the synthetic HOME. + unsafe { instance::activate("trial") }.unwrap(); + assert_eq!(instance::current(), Some(selected)); + assert_eq!(std::env::var_os("HOME").map(PathBuf::from), Some(login_home)); + } else { + assert!(instance::current().is_none()); + let native = instance::native_session_home().unwrap().unwrap(); + assert_eq!(native, login_home); + let discovery = crate::external_sessions::discover_fresh(Some(&native), true).unwrap(); + assert_eq!(discovery.sessions.len(), 1); + assert_eq!(discovery.sessions[0].native_id, "login-only"); + let before: Vec<_> = std::env::vars_os().collect(); + unsafe { instance::activate_native_session_home() }.unwrap(); + assert_eq!(std::env::vars_os().collect::>(), before); + } + return; + } + for case in ["default", "instance"] { + let home = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "external_sessions::tests::native_session_discovery_is_instance_scoped", "--nocapture", "--test-threads=1"]) + .env(CHILD, case) + .env("HOME", home.path()) + .env("PI_CODING_AGENT_DIR", home.path().join("ambient-profile")) + .env_remove("ST_INSTANCE") + .env_remove("FRACTAL_INSTANCE") + .env_remove("ST_INSTANCE_HOST_HOME") + .output().unwrap(); + assert!(output.status.success(), "{case}: {}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + } + } + #[cfg(target_os = "linux")] #[test] fn omp_old_hook_recovery_requires_exact_live_lineage_account_and_header() { diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 24e3e1dc5e1..a62df1438d4 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -5255,6 +5255,13 @@ fn cli_main() -> ExitCode { return ExitCode::from(2); } } + if native_driver_requested(arguments.iter().skip(1).cloned()) { + // SAFETY: native harness HOME is selected before any runtime or worker thread starts. + if let Err(error) = unsafe { fractal_daemon::instance::activate_native_session_home() } { + stderr_message("st", format_args!("native session home: {error:#}")); + return ExitCode::from(2); + } + } // A shell stub from `st completions` calls back with `COMPLETE=` on each TAB. Answer // before any config, runtime, or daemon work; this exits when the variable is set. clap_complete::env::CompleteEnv::with_factory(cli_command).complete(); @@ -7568,7 +7575,7 @@ async fn run_up(args: UpArgs) -> Result<()> { configured_peers: config.peers.iter().map(|peer| peer.name.clone()).collect(), client_relay: fractal_daemon::peer::ClientRelay::from_config(&config)? .map(|relay| relay.with_links(store.clone())), - native_session_home: std::env::var_os("HOME").map(PathBuf::from), + native_session_home: fractal_daemon::instance::native_session_home()?, planner_default: config.planner.clone(), harness_question_sink: None, assistant_receipts: Default::default(), diff --git a/docs/st3/instances.md b/docs/st3/instances.md index ac7a4fa281a..bf29b3a5ec0 100644 --- a/docs/st3/instances.md +++ b/docs/st3/instances.md @@ -65,7 +65,7 @@ an existing migrated root, except that `work/` stays at its legacy location): | `bin/` | the `st3`, `st` and `pty` that the installer puts there | | | `claude/` | the instance's Claude account, plugins and `st` skill | `CLAUDE_CONFIG_DIR` | | `codex/` | the instance's Codex account and `st` skill | `CODEX_HOME` | -| `home/` | the skill copy for pi, omp and opencode (`home/.agents/skills`) | | +| `home/` | native harness HOME, pi/omp session stores and shared skills (`home/.agents/skills`) | `HOME` for native drivers and hooks | | `work/` | the instance's work root: default workspaces for new agents in `work/agents/NAME`, including the Fractal Assistant | | | `app/` | the macOS app bundle | | @@ -74,6 +74,17 @@ and everything it starts (seats, harnesses, hooks) inherit it, so code that read itself lands in the instance without knowing about instances. Seats of an instance sign in to Claude and Codex separately from your own; that is what makes the first run clean. +Native-session inventory, search, session lists and conversation timelines discover only the +instance's `home/`, never the login user's HOME. This existing instance-owned home also holds +generic harness skills, so native drivers and their children use it as HOME. Its `.claude`, +`.codex` and `.local/share` are checked aliases to the instance's existing `claude/`, `codex/` +and `data/` directories; provider logins and OpenCode sessions stay in their existing locations. +An inherited `PI_CODING_AGENT_DIR` is cleared at the native-driver boundary so pi/omp use the +instance profile. Managed sessions with explicit driver-owned transcript paths retain those paths. +The default instance keeps the login HOME and its existing discovery behavior unchanged. +Ordinary CLI and service-manager operations retain the login HOME, including nested CLI calls +from an instance's harness. + Beyond directories, an instance has its own names: - **Machine name:** the host's name plus the instance name (`laptop-trial`), so a fleet sees two diff --git a/release-notes/instance-native-session-isolation.json b/release-notes/instance-native-session-isolation.json new file mode 100644 index 00000000000..df47827285d --- /dev/null +++ b/release-notes/instance-native-session-isolation.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "summary": "Isolate native session discovery and harness HOME in named instances", + "replay": { + "status": "none", + "detail": "No replay or projection changes." + }, + "schema": { + "status": "none", + "detail": "No claim or wire schema changes." + }, + "rules": { + "status": "changed", + "detail": "Native session readers use the selected instance home; native drivers and hooks use the same home." + }, + "compatibility": { + "status": "changed", + "detail": "The default instance keeps its existing HOME and native discovery. Named instances use their existing home directory, with checked aliases to existing provider accounts and XDG data." + }, + "downtime": { + "status": "changed", + "detail": "Restart an existing named instance daemon to use the isolated discovery root." + }, + "manual": { + "status": "changed", + "detail": "Named-instance pi/omp profiles are separate from the login profile; sign in or configure the instance profile as needed. Existing provider account directories are unchanged." + }, + "recovery": { + "status": "changed", + "detail": "No session files are copied, moved or deleted. An unexpected native-home alias is rejected rather than read." + } +} From e731df9df132179949dc842bc76a424b6d8df15a Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:42:48 +0100 Subject: [PATCH 104/132] Pin that runtime-held gate steps are not held steps An agentless gate step (human gate, fact gate) is working without a claimant or a lease, so it takes the unchanged branch of the stall clock: its last change is its sign of life, exactly as before lease renewals stopped counting. Add a test that shows this and the resulting single stall report. Co-Authored-By: Claude Fable 5.2 --- .../src/reconcile/tests/run_report_tests.rs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs b/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs index 6ca9077c253..5903b83d7dc 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs @@ -57,6 +57,21 @@ mission "absent-reporter" state="ready" report-to="agent/node.nobody" { goal "Name an agent that is not declared." step "build" { assigned-to "agent/node.builder" } } +mission "gated" state="ready" report-to="agent/node.reporter" stalled-after="10m" { + concurrent-runs + goal "Wait on a person and on a fact." + step "review" { + agentless + gate "accept" type="human" { + reviewer "person/example" + question "Accept this?" + } + } + step "observe" { + agentless + gate "signal" { field "status" "resource/loop-result" is "ready" } + } +} mission "loop-parent" state="ready" report-to="agent/node.reporter" stalled-after="10m" { concurrent-runs goal "Watch progress in child rounds." @@ -686,6 +701,54 @@ fn a_held_step_that_only_renews_its_lease_stalls() { assert_eq!(stalled(), 2, "silence after progress is a new stall"); } +/// The runtime holds an agentless gate step without a claimant or a lease, so it is not a held +/// step and nothing renews it: its last change is its sign of life, as before lease renewals +/// stopped counting. A run that only waits on open gates therefore stalls at its limit, once; +/// `report-stalls=#false` is how a run that intends such a wait says so. +#[test] +fn a_gate_step_the_runtime_holds_is_not_a_held_step() { + let (_clock, fixture) = fixture(); + let run = fixture.start("gated", "gated-run"); + fixture.passes(4); + let view = fixture.store.mission_run(&run.id).unwrap().unwrap(); + assert_eq!(view.steps.len(), 2); + for step in &view.steps { + assert!(step.agentless, "{}", step.step); + assert_eq!(step.status, "working", "{}", step.step); + assert!(step.claimant.is_none(), "{}", step.step); + assert!(step.claim_expires_at_unix_ms.is_none(), "{}", step.step); + } + // No held step: the stall clock is the newest change of the run or of a step. + let quiet_since = crate::reconcile::run_report::run_activity(&view); + let last_change = view + .steps + .iter() + .map(|step| step.updated_at_unix_ms) + .chain([view.created_at_unix_ms, view.updated_at_unix_ms]) + .max() + .unwrap(); + assert_eq!(quiet_since, last_change); + assert_eq!( + fixture.reconciler.incremental.next_due(&run.subject), + Some(quiet_since + 10 * MINUTE) + ); + let stalled = || { + fixture + .reports(REPORTER) + .into_iter() + .filter(|message| message.tags.contains(&"st3-run-report:stalled".to_owned())) + .count() + }; + { + let _inside = Clock::at(&fixture.store, quiet_since + 9 * MINUTE); + fixture.passes(2); + assert_eq!(stalled(), 0); + } + let _later = Clock::at(&fixture.store, quiet_since + 3 * 24 * 60 * MINUTE); + fixture.passes(3); + assert_eq!(stalled(), 1, "one report, however long the gates stay open"); +} + #[test] fn a_stall_is_a_deadline_on_the_run_not_a_scan() { let (_clock, fixture) = fixture(); From 5472b4bf8b882c6481b973212ddac3b37e241b9e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:43:14 +0100 Subject: [PATCH 105/132] test: finish environment setup before session discovery workers --- crates/fractal-seats/src/external_sessions.rs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/fractal-seats/src/external_sessions.rs b/crates/fractal-seats/src/external_sessions.rs index 04eb1d583f5..dcc95b9b7a4 100644 --- a/crates/fractal-seats/src/external_sessions.rs +++ b/crates/fractal-seats/src/external_sessions.rs @@ -2840,18 +2840,6 @@ mod tests { fs::create_dir_all(selected.codex_home()).unwrap(); write_session(&native, "instance-only"); let home = Some(native.as_path()); - // Inventory and search use these discovery entrypoints, including the cached history. - for discovery in [ - crate::external_sessions::discover_fresh(home, true).unwrap(), - crate::external_sessions::discover(home, true).unwrap(), - ] { - assert_eq!(discovery.sessions.len(), 1); - assert_eq!(discovery.sessions[0].native_id, "instance-only"); - } - let login_id = crate::external_sessions::external_session_id( - crate::external_sessions::ExternalDriver::Codex, "login-only", - ); - assert!(crate::external_sessions::find_conversation(home, &login_id).unwrap().is_none()); unsafe { instance::activate_native_session_home() }.unwrap(); assert_eq!(std::env::var_os("HOME").map(PathBuf::from), Some(native.clone())); assert!(std::env::var_os("PI_CODING_AGENT_DIR").is_none()); @@ -2864,16 +2852,28 @@ mod tests { unsafe { instance::activate("trial") }.unwrap(); assert_eq!(instance::current(), Some(selected)); assert_eq!(std::env::var_os("HOME").map(PathBuf::from), Some(login_home)); + // Inventory and search use these discovery entrypoints, including the cached history. + for discovery in [ + crate::external_sessions::discover_fresh(home, true).unwrap(), + crate::external_sessions::discover(home, true).unwrap(), + ] { + assert_eq!(discovery.sessions.len(), 1); + assert_eq!(discovery.sessions[0].native_id, "instance-only"); + } + let login_id = crate::external_sessions::external_session_id( + crate::external_sessions::ExternalDriver::Codex, "login-only", + ); + assert!(crate::external_sessions::find_conversation(home, &login_id).unwrap().is_none()); } else { assert!(instance::current().is_none()); let native = instance::native_session_home().unwrap().unwrap(); assert_eq!(native, login_home); - let discovery = crate::external_sessions::discover_fresh(Some(&native), true).unwrap(); - assert_eq!(discovery.sessions.len(), 1); - assert_eq!(discovery.sessions[0].native_id, "login-only"); let before: Vec<_> = std::env::vars_os().collect(); unsafe { instance::activate_native_session_home() }.unwrap(); assert_eq!(std::env::vars_os().collect::>(), before); + let discovery = crate::external_sessions::discover_fresh(Some(&native), true).unwrap(); + assert_eq!(discovery.sessions.len(), 1); + assert_eq!(discovery.sessions[0].native_id, "login-only"); } return; } From 3f5788219593514f5dbeb2e8c74efe7fa1a7c80c Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:45:49 +0100 Subject: [PATCH 106/132] feat(ios): prepare isolated test variant and XCUITest observer agent-identity: agent/compoundingtech/st-ios agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/ios/README.md | 111 +++++++++++++++++++- apps/ios/app.config.js | 27 +++-- apps/ios/appConfig.test.mjs | 24 +++++ apps/ios/plugins/ClientControlUITests.swift | 70 ++++++++++++ apps/ios/plugins/withTestClient.cjs | 102 ++++++++++++++++++ apps/ios/scripts/release_delivery.py | 71 ++++++++++--- apps/ios/scripts/test_release_delivery.py | 61 ++++++++++- release-notes/ios-test-variant-prep.json | 11 ++ 8 files changed, 441 insertions(+), 36 deletions(-) create mode 100644 apps/ios/plugins/ClientControlUITests.swift create mode 100644 apps/ios/plugins/withTestClient.cjs create mode 100644 release-notes/ios-test-variant-prep.json diff --git a/apps/ios/README.md b/apps/ios/README.md index d675550e14c..3b356df1377 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -131,10 +131,11 @@ CI and the web shell use the same preparation command. | --- | --- | --- | --- | | `dev` (default) | Fractal Dev, amber DEV-badged icon | `com.compoundingtech.fractal.starter` | Debug with Metro, simulator and device smoke tests | | `daily` | Fractal, ordinary icon | `com.compoundingtech.fractal` | Release build used every day on a phone | +| `test` | Fractal Test, ordinary icon | `com.compoundingtech.fractal.test` | Isolated client-control Release app; never the everyday pairing | -The two apps coexist on one phone and keep separate Keychain profiles, so each is paired on its own. Any other value fails the configuration. Changing variants requires a clean prebuild, and the same `APP_VARIANT` must stay exported for the Xcode build that embeds the bundle. The EAS simulator profile selects dev; preview selects daily. +The apps coexist and keep separate Keychain profiles, so each is paired on its own. Any other value fails the configuration. Changing variants requires a clean prebuild, and the same `APP_VARIANT` must stay exported for the Xcode build that embeds the bundle. The EAS simulator profile selects dev; preview selects daily. -The Expo name `fractal` and slug `fractal-starter` generate `ios/fractal.xcodeproj`, `ios/fractal.xcworkspace`, and the `fractal` target/scheme for both variants. Each variant's URL scheme matches its bundle identifier. Device signing keys use the Keychain service `com.compoundingtech.fractal.device-key`, isolated by the app's bundle identity. +The Expo name `fractal` and slug `fractal-starter` generate `ios/fractal.xcodeproj`, `ios/fractal.xcworkspace`, and the `fractal` app target/scheme for every variant. Device signing keys use the Keychain service `com.compoundingtech.fractal.device-key`, isolated by the app's bundle identity. The test variant additionally declares its own explicit keychain access group and app group; neither is shared with daily or dev. When moving from the former app identity, install the new app, pair it once, delete the old app by hand, and revoke the old device's grant. The new bundle identifier cannot read the old app's Keychain; there is deliberately no fallback to the former service. @@ -163,6 +164,20 @@ ref must support `APP_VARIANT=daily`; a ref without that variant is refused rather than replacing the separate Debug app. First prepare a signed artifact without phone access: +For the isolated test app, select `--variant test` (or `FRACTAL_IOS_VARIANT=test`) +and supply `--test-app-profile` / `FRACTAL_IOS_TEST_APP_PROFILE`, the installed +Apple Development profile name that Johannes prepared. `--test-runner-profile` / +`FRACTAL_IOS_TEST_RUNNER_PROFILE` supplies the separate XCUITest runner profile +when building the observation scheme. The lane uses manual signing for test, +never requests provisioning updates, and appends `ios-test-variant` to the +supplied state directory, isolating checkout, caches, version floor and artifacts. +Normal remains the default and keeps its existing state path and automatic +signing policy. Both variants run the same #6207 Fabric build and pre-install +guard; a snapshot with another bundle identifier is refused before installation. +The test app can bootstrap from an absent installed bundle; the normal lane +still requires its existing app readback. No lane configuration grants pairing, +enrollment, installation or process-launch consent. + ```sh python3 apps/ios/scripts/release_delivery.py \ --repo "$SOURCE_REPO" --state "$DELIVERY_STATE" \ @@ -267,7 +282,8 @@ framework output fails closed before linking. The hash participates in the build and native-generation identities and is recorded as `fabric_framework_hash` in `success.json`. A changed framework invalidates reuse. The worker uses `pnpm exec expo config` and clean `pnpm exec expo prebuild`, -then `pnpm run export:ios`, all with `APP_VARIANT=daily` and the daily Fabric +then `pnpm run export:ios`, with the selected variant (`APP_VARIANT=daily` by +default, `test` only when explicitly selected) and the daily Fabric settings pinned before invoking Xcode. Before publishing the signed snapshot, it requires defined Rust bridge symbols in the app executable: the real module and disabled stub share the same Swift class name, so that name alone is not proof. @@ -294,8 +310,9 @@ from the last successful fetch, updated on every fetch, including reverts and app-irrelevant commits. Its content key hashes the Git trees of `apps/ios` and its local-package closure together with the inputs outside the revision: the Xcode directory, Node/pnpm/CocoaPods paths, inherited `EXPO_PUBLIC_*` values, -the compiled Fabric framework hash, and the pinned native environment. Builds -always run with `APP_VARIANT=daily`, `FRACTAL_FABRIC=1`, +the compiled Fabric framework hash, and the pinned native environment. Test +profile selections are also build inputs. Builds always run with the selected +`APP_VARIANT` and `FRACTAL_FABRIC=1`, `FRACTAL_FABRIC_PROOF=0` (read by `FractalFabric.podspec`), and `FRACTAL_FABRIC_OFFLINE_DEBUG=0` (read by `metro.config.js`), with the legacy `ST3_` counterparts pinned to the same respective values. Inherited settings @@ -531,3 +548,87 @@ graph subject. Session and PTY IDs only navigate; placements require stable grap Opening a resource detail also reads its exact subject with the existing prefix filter, including subjects beyond the catalog windows. + +## Isolated native test client and independent observer + +`APP_VARIANT=test` selects **Fractal Test**, bundle `com.compoundingtech.fractal.test`, +app group `group.com.compoundingtech.fractal.test`, and sole keychain access group +`$(AppIdentifierPrefix)com.compoundingtech.fractal.test`. It keeps the same Expo +project/workspace and release application source as daily. The readable build-time +flag is `Constants.expoConfig.extra.testClientMode` in JavaScript and +`FractalTestClientMode` in the app's Info.plist; both are false for dev/daily. +This flag describes the binary, not enrollment or authorization. No command handlers +or automatic enrollment are enabled by this preparation. + +State isolation relies on the separate application sandbox and sole test keychain +access group, not on renaming existing state keys. `FractalDeviceKeyModule.swift` +stores P-256 key material as a generic password under service +`com.compoundingtech.fractal.device-key`; `FractalFabricModule.swift` similarly +stores its independent transport secret under `com.compoundingtech.fractal.fabric-proof`. +Neither specifies `kSecAttrAccessGroup`, so Keychain uses the application's default +access group. SecureStore profile calls in `store.tsx` also omit access-group options. +The local native modules do not use app-group containers or shared UserDefaults +suites. Normal modules and normal dev/daily entitlements are unchanged. The test app +must generate its own key and pair manually with a pinned fingerprint; never copy +normal-app credentials. The app-group entitlement reserves the isolated namespace; +the observer does not share that container or the application's keychain. + +`plugins/withTestClient.cjs` durably generates `FractalTestControl` (UI-testing +target and shared Release scheme) and copies `plugins/ClientControlUITests.swift` +into the disposable native project. The UI runner's base product bundle identifier +is `com.compoundingtech.fractal.test`; Xcode appends `.xctrunner`, giving +`com.compoundingtech.fractal.test.xctrunner`. Signing is manual, with app profile +`$(FRACTAL_IOS_TEST_APP_PROFILE)` and runner profile +`$(FRACTAL_IOS_TEST_RUNNER_PROFILE)`. Johannes performs provisioning/signing setup; +these commands do not create profiles or allow provisioning updates. + +After the applicable consent/signing gates, the command interface from `apps/ios` +is below. Use the daily delivery lane to enforce its guards and regenerate native +source with `APP_VARIANT=test`; do not install using an unguarded alternate path. +The commands illustrate the native build/observer interface, not permission to act +on a device. Destination, profile names and signing identity are supplied locally. + +```sh +APP_VARIANT=test pnpm exec expo prebuild --platform ios --no-install +xcodebuild build-for-testing \ + -workspace ios/fractal.xcworkspace -scheme FractalTestControl \ + -configuration Release -destination "$TEST_DESTINATION" \ + -derivedDataPath "$TEST_DERIVED_DATA" \ + FRACTAL_IOS_TEST_APP_PROFILE="$FRACTAL_IOS_TEST_APP_PROFILE" \ + FRACTAL_IOS_TEST_RUNNER_PROFILE="$FRACTAL_IOS_TEST_RUNNER_PROFILE" +xcodebuild test-without-building -xctestrun "$TEST_XCTESTRUN" \ + -destination "$TEST_DESTINATION" -resultBundlePath "$SMOKE_RESULT" \ + -only-testing:FractalTestControl/ClientControlUITests/testLaunchAndReadAccessibility +``` + +The smoke launches only the test bundle in a separate process and reads the existing +native **Now** tab. It never unlocks, dismisses system dialogs or launches the daily +bundle. Run it separately from a content observation because launching may reset the +route. Failed launches/locked devices are failures, not permission to unlock. + +For D-9, first have the authorized journey open the seeded item. In a copy of the +generated `.xctestrun`, set the test target's **runner** `EnvironmentVariables` +(inside `TestConfigurations[].TestTargets[]` for format 2; target dictionary for +format 1) from the independent seed, not from `device.result`: + +- `FRACTAL_D9_TITLE`: exact rendered seed title. +- `FRACTAL_D9_BODY`: exact plain-text seed body, rendered as one static-text + accessibility element. +- `FRACTAL_D9_BODY_SHA256`: lowercase SHA-256 hex of the seed body's exact UTF-8 + bytes, with no whitespace normalization. + +```sh +xcodebuild test-without-building -xctestrun "$SEEDED_TEST_XCTESTRUN" \ + -destination "$TEST_DESTINATION" -resultBundlePath "$D9_RESULT" \ + -only-testing:FractalTestControl/ClientControlUITests/testSeededTitleAndBodyDigest +``` + +This test does not relaunch, activate, navigate, send commands or read a client +report. It reads visible accessibility labels and hashes the observed body itself. +Missing expectations, background state, missing/split/truncated text or an invisible +element fail closed. Use a simple seed whose full body fits the visible viewport; +rich-text segmentation is not reconstructed by this observer. The accessibility +tree and screenshot are XCTest attachments; screenshots are not OCR proof. +The observer alone does not prove enrollment, grants, receipts, no read-state +mutation, or unchanged normal pairing: those remain separate journey evidence. + diff --git a/apps/ios/app.config.js b/apps/ios/app.config.js index c98723ea550..4ee47f7301e 100644 --- a/apps/ios/app.config.js +++ b/apps/ios/app.config.js @@ -1,30 +1,39 @@ const base = require('./app.json').expo; -// APP_VARIANT selects the installed identity. dev (default) is the Debug/Metro starter app; -// daily is the Release app used every day. Both coexist on one phone with separate Keychain profiles. -// The home-screen name is set through CFBundleDisplayName so Expo's `name`, and with it the -// generated Xcode project, workspace and scheme (`ios/fractal.xcworkspace`), stay the same for both. +// APP_VARIANT selects the installed identity: dev (default), daily, or the isolated +// Release test client. Keep Expo's name (and the generated fractal workspace) stable. module.exports = () => { const variant = process.env.APP_VARIANT ?? 'dev'; - if (variant !== 'dev' && variant !== 'daily') throw new Error('APP_VARIANT must be dev or daily'); + if (!['dev', 'daily', 'test'].includes(variant)) throw new Error('APP_VARIANT must be dev, daily or test'); const daily = variant === 'daily'; + const testClient = variant === 'test'; // LIVE-MIGRATION BRIDGE arn:lmig:smalltalk:2026-10-16-env-fractal-dual-read — DELETE at contraction — https://github.com/compoundingtech/fractal/issues/3032 const push = (process.env.FRACTAL_IOS_PUSH ?? process.env.ST_IOS_PUSH) === '1'; // LIVE-MIGRATION END arn:lmig:smalltalk:2026-10-16-env-fractal-dual-read return { ...base, - ...(push ? { extra: { ...base.extra, stIosPush: true } } : {}), + extra: { ...base.extra, ...(push ? { stIosPush: true } : {}), testClientMode: testClient }, plugins: [ ...(base.plugins ?? []), push ? ['expo-notifications', { mode: 'development' }] : './plugins/withoutPush', + ...(testClient ? ['./plugins/withTestClient.cjs'] : []), ], - icon: daily ? './assets/icon.png' : './assets/icon-dev.png', + icon: daily || testClient ? './assets/icon.png' : './assets/icon-dev.png', ios: { ...base.ios, - bundleIdentifier: daily ? 'com.compoundingtech.fractal' : 'com.compoundingtech.fractal.starter', - infoPlist: { ...base.ios.infoPlist, CFBundleDisplayName: daily ? 'Fractal' : 'Fractal Dev' }, + bundleIdentifier: testClient ? 'com.compoundingtech.fractal.test' : daily ? 'com.compoundingtech.fractal' : 'com.compoundingtech.fractal.starter', + ...(testClient ? { entitlements: { + ...base.ios.entitlements, + 'com.apple.security.application-groups': ['group.com.compoundingtech.fractal.test'], + 'keychain-access-groups': ['$(AppIdentifierPrefix)com.compoundingtech.fractal.test'], + } } : {}), + infoPlist: { + ...base.ios.infoPlist, + CFBundleDisplayName: testClient ? 'Fractal Test' : daily ? 'Fractal' : 'Fractal Dev', + FractalTestClientMode: testClient, + }, }, }; }; diff --git a/apps/ios/appConfig.test.mjs b/apps/ios/appConfig.test.mjs index 438ff46a266..49c9cf754b8 100644 --- a/apps/ios/appConfig.test.mjs +++ b/apps/ios/appConfig.test.mjs @@ -23,6 +23,10 @@ test('daily installs as Fractal with the ordinary icon', () => { assert.equal(daily.ios.bundleIdentifier, 'com.compoundingtech.fractal'); assert.equal(daily.ios.supportsTablet, true); assert.equal(daily.updates, undefined); + assert.equal(daily.extra.testClientMode, false); + assert.equal(daily.ios.infoPlist.FractalTestClientMode, false); + assert.equal(daily.plugins.includes('./plugins/withTestClient.cjs'), false); + assert.equal(daily.ios.entitlements, undefined); }); test('default and dev keep the starter identity with the DEV-badged icon', () => { @@ -33,6 +37,26 @@ test('default and dev keep the starter identity with the DEV-badged icon', () => assert.equal(dev.icon, './assets/icon-dev.png'); assert.equal(dev.ios.bundleIdentifier, 'com.compoundingtech.fractal.starter'); assert.deepEqual(withVariant('dev', configure), dev); + assert.equal(dev.extra.testClientMode, false); + assert.equal(dev.ios.infoPlist.FractalTestClientMode, false); + assert.equal(dev.plugins.includes('./plugins/withTestClient.cjs'), false); + assert.equal(dev.ios.entitlements, undefined); +}); + +test('test is a separately named, isolated Release-source client with a native observer', () => { + const isolated = withVariant('test', configure); + assert.equal(isolated.name, 'fractal'); + assert.equal(isolated.slug, 'fractal-starter'); + assert.equal(isolated.ios.bundleIdentifier, 'com.compoundingtech.fractal.test'); + assert.equal(isolated.ios.infoPlist.CFBundleDisplayName, 'Fractal Test'); + assert.equal(isolated.icon, './assets/icon.png'); + assert.equal(isolated.extra.testClientMode, true); + assert.equal(isolated.ios.infoPlist.FractalTestClientMode, true); + assert.deepEqual(isolated.ios.entitlements['com.apple.security.application-groups'], ['group.com.compoundingtech.fractal.test']); + assert.deepEqual(isolated.ios.entitlements['keychain-access-groups'], ['$(AppIdentifierPrefix)com.compoundingtech.fractal.test']); + assert.equal(isolated.plugins.filter(plugin => plugin === './plugins/withTestClient.cjs').length, 1); + assert.equal(isolated.ios.supportsTablet, true); + assert.equal(isolated.updates, undefined); }); test('an unknown variant fails closed', () => { diff --git a/apps/ios/plugins/ClientControlUITests.swift b/apps/ios/plugins/ClientControlUITests.swift new file mode 100644 index 00000000000..590c64f179a --- /dev/null +++ b/apps/ios/plugins/ClientControlUITests.swift @@ -0,0 +1,70 @@ +import CryptoKit +import XCTest + +// Runs in com.compoundingtech.fractal.test.xctrunner, not the app process. +// No device.result, app export, networking, pairing, navigation or command handler +// participates in this observation. Expectations come from the independent seed. +final class ClientControlUITests: XCTestCase { + private let app = XCUIApplication(bundleIdentifier: "com.compoundingtech.fractal.test") + + override func setUpWithError() throws { + continueAfterFailure = false + } + + // Authorized test-instance bootstrap only. Never launches the everyday app, + // dismisses system dialogs, enters a passcode or tries to unlock the device. + func testLaunchAndReadAccessibility() { + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15), "Test app did not reach foreground") + // Existing native root tab, not an artificial test-only view or client report. + let root = app.tabBars.buttons["Now"] + XCTAssertTrue(root.waitForExistence(timeout: 30), "Now tab is absent from the accessibility tree") + XCTAssertEqual(root.label, "Now") + attachObservation() + } + + // Run separately, after the granted journey has opened the seeded item. This + // deliberately does not relaunch/activate or navigate the app under observation. + // Supply these via the generated .xctestrun Test Runner EnvironmentVariables, + // not the app's launch environment. Omission fails, never silently skips D-9. + // Seed a plain-text body rendered as a single static-text accessibility label; + // a split/truncated/absent body fails closed (screenshots are not OCR proof). + func testSeededTitleAndBodyDigest() throws { + let title = try expected("FRACTAL_D9_TITLE") + let body = try expected("FRACTAL_D9_BODY") + let digest = try expected("FRACTAL_D9_BODY_SHA256") + XCTAssertNotNil(digest.range(of: "^[0-9a-f]{64}$", options: .regularExpression), "Expected lowercase SHA-256 hex") + XCTAssertEqual(sha256(body), digest, "Independent seed body and digest disagree") + XCTAssertEqual(app.state, .runningForeground, "Observation requires an already foreground test app") + let renderedTitle = app.staticTexts.matching(NSPredicate(format: "label == %@", title)).firstMatch + XCTAssertTrue(renderedTitle.waitForExistence(timeout: 15), "Seeded title is not rendered") + XCTAssertTrue(renderedTitle.isHittable, "Seeded title is not visible") + XCTAssertEqual(renderedTitle.label, title) + let renderedBody = app.staticTexts.matching(NSPredicate(format: "label == %@", body)).firstMatch + XCTAssertTrue(renderedBody.waitForExistence(timeout: 15), "Seeded body is not rendered as one accessible text element") + XCTAssertTrue(renderedBody.isHittable, "Seeded body is not visible") + // Hash the independently read accessibility label, never a digest the app reports. + XCTAssertEqual(sha256(renderedBody.label), digest) + attachObservation() + } + + private func expected(_ name: String) throws -> String { + let value = ProcessInfo.processInfo.environment[name] + return try XCTUnwrap(value.flatMap { $0.isEmpty ? nil : $0 }, "Missing independent seed expectation: \(name)") + } + + private func sha256(_ text: String) -> String { + SHA256.hash(data: Data(text.utf8)).map { String(format: "%02x", $0) }.joined() + } + + private func attachObservation() { + let tree = XCTAttachment(string: app.debugDescription) + tree.name = "Independent test-app accessibility tree" + tree.lifetime = .keepAlways + add(tree) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Test-app screenshot (attachment only; not OCR proof)" + screenshot.lifetime = .keepAlways + add(screenshot) + } +} diff --git a/apps/ios/plugins/withTestClient.cjs b/apps/ios/plugins/withTestClient.cjs new file mode 100644 index 00000000000..4d8b7ec45f8 --- /dev/null +++ b/apps/ios/plugins/withTestClient.cjs @@ -0,0 +1,102 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { withXcodeProject } = require('expo/config-plugins'); + +const bundle = 'com.compoundingtech.fractal.test'; +const targetName = 'FractalTestControl'; +const unquote = value => value?.replace(/^"|"$/g, ''); + +// This plugin is registered only by APP_VARIANT=test. Generated ios/ is disposable; +// the Swift observer is copied from release source, never patched after prebuild. +module.exports = function withTestClient(config) { + if (config.ios?.bundleIdentifier !== bundle || config.extra?.testClientMode !== true) { + throw new Error('withTestClient requires the isolated test identity'); + } + return withXcodeProject(config, config => { + const project = config.modResults; + const objects = project.hash.project.objects; + const app = project.getFirstTarget(); + const appName = unquote(app.firstTarget.name); + const configurations = target => objects.XCConfigurationList[target.buildConfigurationList] + .buildConfigurations.map(ref => objects.XCBuildConfiguration[ref.value].buildSettings); + for (const settings of configurations(app.firstTarget)) { + settings.CODE_SIGN_STYLE = 'Manual'; + settings.PROVISIONING_PROFILE_SPECIFIER = '"$(FRACTAL_IOS_TEST_APP_PROFILE)"'; + } + + let targetId = Object.keys(objects.PBXNativeTarget).find(id => + !id.endsWith('_comment') && unquote(objects.PBXNativeTarget[id].name) === targetName); + if (!targetId) { + targetId = project.generateUuid(); + const settings = { + PRODUCT_BUNDLE_IDENTIFIER: bundle, + PRODUCT_NAME: '"$(TARGET_NAME)"', + GENERATE_INFOPLIST_FILE: 'YES', + SWIFT_VERSION: '5.0', + IPHONEOS_DEPLOYMENT_TARGET: configurations(app.firstTarget)[0].IPHONEOS_DEPLOYMENT_TARGET ?? '15.1', + TARGETED_DEVICE_FAMILY: '"1,2"', + SDKROOT: 'iphoneos', + CODE_SIGN_STYLE: 'Manual', + PROVISIONING_PROFILE_SPECIFIER: '"$(FRACTAL_IOS_TEST_RUNNER_PROFILE)"', + // Xcode appends .xctrunner to PRODUCT_BUNDLE_IDENTIFIER for the UI runner. + // Do not give the runner any app-group or keychain-sharing entitlements. + TEST_TARGET_NAME: `"${appName}"`, + SKIP_INSTALL: 'YES', + LD_RUNPATH_SEARCH_PATHS: '"$(inherited) @executable_path/Frameworks @loader_path/Frameworks"', + }; + const list = project.addXCConfigurationList(['Debug', 'Release'].map(name => ({ + isa: 'XCBuildConfiguration', name, buildSettings: { ...settings }, + })), 'Release', `Build configuration list for PBXNativeTarget "${targetName}"`); + const product = project.addProductFile(`${targetName}.xctest`, { + target: targetId, explicitFileType: 'wrapper.cfbundle', + }); + const nativeTarget = { + isa: 'PBXNativeTarget', name: targetName, productName: targetName, + productReference: product.fileRef, + productType: '"com.apple.product-type.bundle.ui-testing"', + buildConfigurationList: list.uuid, buildPhases: [], buildRules: [], dependencies: [], + }; + const target = { uuid: targetId, pbxNativeTarget: nativeTarget }; + project.addToPbxNativeTargetSection(target); + project.addToPbxProjectSection(target); + project.addTargetDependency(targetId, [app.uuid]); + project.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', targetId); + project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetId); + project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', targetId); + const group = project.addPbxGroup([], targetName, targetName); + const root = project.getFirstProject().firstProject; + project.addToPbxGroup(group.uuid, root.mainGroup); + project.addSourceFile('ClientControlUITests.swift', { target: targetId }, group.uuid); + } + const attributes = project.getFirstProject().firstProject.attributes; + attributes.TargetAttributes ??= {}; + attributes.TargetAttributes[targetId] = { TestTargetID: app.uuid, ProvisioningStyle: 'Manual' }; + attributes.TargetAttributes[app.uuid] = { + ...attributes.TargetAttributes[app.uuid], ProvisioningStyle: 'Manual', + }; + + const nativeRoot = config.modRequest.platformProjectRoot; + fs.mkdirSync(path.join(nativeRoot, targetName), { recursive: true }); + fs.copyFileSync(path.join(__dirname, 'ClientControlUITests.swift'), + path.join(nativeRoot, targetName, 'ClientControlUITests.swift')); + const schemeRoot = path.join(nativeRoot, `${appName}.xcodeproj`, 'xcshareddata', 'xcschemes'); + fs.mkdirSync(schemeRoot, { recursive: true }); + const reference = (id, name, product) => ``; + const appRef = reference(app.uuid, appName, `${appName}.app`); + const testRef = reference(targetId, targetName, `${targetName}.xctest`); + fs.writeFileSync(path.join(schemeRoot, `${targetName}.xcscheme`), ` + + + ${appRef} + ${testRef} + + ${testRef} + ${appRef} + ${appRef} + + + +`); + return config; + }); +}; diff --git a/apps/ios/scripts/release_delivery.py b/apps/ios/scripts/release_delivery.py index bf634721fd3..c6e276b8176 100755 --- a/apps/ios/scripts/release_delivery.py +++ b/apps/ios/scripts/release_delivery.py @@ -14,6 +14,19 @@ BUNDLE_ID = "com.compoundingtech.fractal" + +def bundle_id(variant): + return BUNDLE_ID + (".test" if variant == "test" else "") + + +def variant_state(state, variant): + """The normal lane retains its exact path; test never shares mutable state.""" + return state / "ios-test-variant" if variant == "test" else state + + +def build_environment(variant): + return {**DAILY_BUILD_ENV, "APP_VARIANT": "test" if variant == "test" else "daily"} + # LIVE-MIGRATION BRIDGE arn:lmig:smalltalk:2026-10-16-env-fractal-dual-read — DELETE at contraction — https://github.com/compoundingtech/fractal/issues/3032 def ios_env(suffix, default=None, environment=os.environ): """Prefer Fractal; an explicitly empty value masks the legacy spelling.""" @@ -172,10 +185,12 @@ def build_identity(args, *, fabric_hash=None): "env": {key: value for key, value in sorted(os.environ.items()) if key.startswith("EXPO_PUBLIC_") and key not in ("EXPO_PUBLIC_FRACTAL_BUILD", "EXPO_PUBLIC_ST3_BUILD")}, - "pinned": DAILY_BUILD_ENV, + "pinned": build_environment(args.variant), "fabric_framework": (fabric_hash if fabric_hash is not None else framework_hash(args.state / "checkout", required=False)), } + if args.variant == "test": + identity["test_profiles"] = [args.test_app_profile, args.test_runner_profile] return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() @@ -187,7 +202,7 @@ def build_release(args): inputs_file = cache / "inputs.json" inputs = json.loads(inputs_file.read_text()) if inputs_file.exists() else {} env = os.environ.copy() - env.update(DAILY_BUILD_ENV, DEVELOPER_DIR=args.developer_dir, + env.update(build_environment(args.variant), DEVELOPER_DIR=args.developer_dir, EXPO_PUBLIC_FRACTAL_BUILD=args.commit[:12], EXPO_PUBLIC_ST3_BUILD=args.commit[:12], CI="1", LANG="en_US.UTF-8", LC_ALL="en_US.UTF-8") @@ -229,8 +244,8 @@ def build_release(args): print("Reusing locked pnpm workspace dependencies", flush=True) config = json.loads(run(["pnpm", "exec", "expo", "config", "--type", "public", "--json"], cwd=app, env=env, capture=True)) - if config["ios"]["bundleIdentifier"] != BUNDLE_ID: - raise ValueError("Tracked ref lacks the daily app variant; refusing to replace Debug") + if config["ios"]["bundleIdentifier"] != bundle_id(args.variant): + raise ValueError("Tracked ref lacks the selected app variant; refusing another identity") # Reuse the documented builder inside the same gated worker as Xcode. run(["sh", "modules/fractal-fabric/build.sh"], cwd=app, env=env) fabric_hash = framework_hash(repo) @@ -241,7 +256,7 @@ def build_release(args): "packages": identities, "config": config, "source": git(repo, "ls-tree", "-r", args.commit, "--", *native_paths), "developer_dir": args.developer_dir, "team": args.team, - "pod": shutil.which("pod"), "env": DAILY_BUILD_ENV, + "pod": shutil.which("pod"), "env": build_environment(args.variant), "fabric_framework": fabric_hash, }, sort_keys=True).encode()).hexdigest() regenerate = (inputs.get("native") != native_identity @@ -280,11 +295,16 @@ def build_release(args): raise ValueError("FractalFabricRust.xcframework changed before linking") derived = cache / "derived" run(["pnpm", "run", "export:ios"], cwd=app, env=env) + signing = (["CODE_SIGN_STYLE=Manual", f"DEVELOPMENT_TEAM={args.team}", + "CODE_SIGN_IDENTITY=Apple Development", + f"FRACTAL_IOS_TEST_APP_PROFILE={args.test_app_profile}", + f"FRACTAL_IOS_TEST_RUNNER_PROFILE={args.test_runner_profile}"] + if args.variant == "test" else + ["CODE_SIGN_STYLE=Automatic", f"DEVELOPMENT_TEAM={args.team}", + "CODE_SIGN_IDENTITY=Apple Development", "-allowProvisioningUpdates"]) run(["xcodebuild", "-workspace", str(workspace), "-scheme", workspace.stem, "-configuration", "Release", "-sdk", "iphoneos", "-destination", "generic/platform=iOS", - "-derivedDataPath", str(derived), "-jobs", "4", - "CODE_SIGN_STYLE=Automatic", f"DEVELOPMENT_TEAM={args.team}", - "CODE_SIGN_IDENTITY=Apple Development", "-allowProvisioningUpdates", "build"], + "-derivedDataPath", str(derived), "-jobs", "4", *signing, "build"], cwd=app, env=env) # DerivedData survives native identity changes. Select only the current Expo # product; unrelated products from earlier generated workspaces may remain. @@ -294,8 +314,8 @@ def build_release(args): raise ValueError("Expected one successful Release app") with (apps[0] / "Info.plist").open("rb") as file: info = plistlib.load(file) - if info.get("CFBundleIdentifier") != BUNDLE_ID: - raise ValueError("Built app is not the daily bundle") + if info.get("CFBundleIdentifier") != bundle_id(args.variant): + raise ValueError("Built app is not the selected bundle") if any(info.get(key) != value for key, value in stamp.items()): raise ValueError("Built app has stale source identity") if args.commit[:12].encode() not in (apps[0] / "main.jsbundle").read_bytes(): @@ -384,10 +404,12 @@ def read_installed(self, pending=None): if any(not isinstance(app, dict) or not isinstance(app.get("bundleIdentifier"), str) for app in apps): raise ValueError("Invalid installed-app entry") - matches = [app for app in apps if app["bundleIdentifier"] == BUNDLE_ID] - if len(matches) != 1: - raise ValueError("Expected one installed daily app") - version = matches[0].get("bundleVersion") if matches else None + matches = [app for app in apps if app["bundleIdentifier"] == bundle_id(self.args.variant)] + if len(matches) != 1 and not (self.args.variant == "test" and not matches): + raise ValueError("Expected one installed selected app") + # A new isolated test app has no previous install. The normal lane + # still requires its authoritative readback exactly as before. + version = matches[0].get("bundleVersion") if matches else "0" if matches and (not isinstance(version, str) or not version.isascii() or not version.isdigit()): raise ValueError("Invalid installed daily build version") except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError): @@ -417,6 +439,10 @@ def verify_install_artifact(self, artifact): try: with (app / "Info.plist").open("rb") as file: info = plistlib.load(file) + if info.get("CFBundleIdentifier") != bundle_id(self.args.variant): + self.record("install_refused", commit=artifact["commit"], reason="bundle_mismatch", + build_version=artifact["build_version"]) + return False verify_fabric_linkage(app, info, self.env) except (OSError, ValueError, KeyError, subprocess.SubprocessError): # Invalidate retention and delivery intent, even if it already reached the phone. @@ -604,7 +630,7 @@ def reconcile(self): self.record("installed", commit=pending["commit"]) try: run(["xcrun", "devicectl", "device", "process", "launch", "--device", self.args.device, - BUNDLE_ID, "--timeout", "30"], + bundle_id(self.args.variant), "--timeout", "30"], env=self.env) except (OSError, subprocess.SubprocessError) as error: self.record("launch_failed", commit=pending["commit"], reason=str(error)) @@ -679,7 +705,10 @@ def build(self, repo, commit, key): str(Path(__file__).resolve()), "--build-worker", "--repo", str(repo), "--output", str(output), "--team", self.args.team, "--commit", commit, - "--build-version", version, "--developer-dir", self.args.developer_dir] + "--build-version", version, "--developer-dir", self.args.developer_dir, + "--variant", self.args.variant, + "--test-app-profile", self.args.test_app_profile or "", + "--test-runner-profile", self.args.test_runner_profile or ""] with (output / "build.log").open("a") as log: result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT) if result.returncode or not (output / "success.json").exists(): @@ -711,6 +740,10 @@ def main(): parser.add_argument("--repo", type=Path, required=True) parser.add_argument("--ref", default=ios_env("REF", "origin/main")) parser.add_argument("--state", type=Path) + parser.add_argument("--variant", choices=("normal", "test"), + default=ios_env("VARIANT", "normal")) + parser.add_argument("--test-app-profile", default=ios_env("TEST_APP_PROFILE")) + parser.add_argument("--test-runner-profile", default=ios_env("TEST_RUNNER_PROFILE")) parser.add_argument("--expected-origin", default=ios_env("EXPECTED_ORIGIN"), help="Exact trusted origin URL, supplied by the operator") parser.add_argument("--trusted-commit", default=ios_env("TRUSTED_COMMIT"), @@ -738,6 +771,10 @@ def main(): args.repo = args.repo.expanduser().resolve() if not args.team or not args.developer_dir: parser.error("--team/FRACTAL_IOS_TEAM and --developer-dir/DEVELOPER_DIR are required") + if args.variant not in ("normal", "test"): + parser.error("FRACTAL_IOS_VARIANT must be normal or test") + if args.variant == "test" and not args.test_app_profile: + parser.error("test variant requires --test-app-profile/FRACTAL_IOS_TEST_APP_PROFILE") if args.build_worker: build_release(args) return @@ -754,7 +791,7 @@ def main(): stdout=subprocess.PIPE, stderr=subprocess.PIPE) if validation.returncode or ref.startswith("-"): parser.error("--ref must be a valid branch name or full commit SHA") - args.state = args.state.expanduser().resolve() + args.state = variant_state(args.state.expanduser().resolve(), args.variant) delivery = Delivery(args) with (args.state / "delivery.lock").open("a") as lock: try: diff --git a/apps/ios/scripts/test_release_delivery.py b/apps/ios/scripts/test_release_delivery.py index acbd1bf75e4..32b2233e83a 100644 --- a/apps/ios/scripts/test_release_delivery.py +++ b/apps/ios/scripts/test_release_delivery.py @@ -18,7 +18,8 @@ import time import unittest -from release_delivery import FABRIC_FRAMEWORK, framework_hash, ios_env +from release_delivery import (DAILY_BUILD_ENV, FABRIC_FRAMEWORK, build_environment, + bundle_id, framework_hash, ios_env, variant_state) SCRIPT = Path(__file__).with_name("release_delivery.py") @@ -37,6 +38,7 @@ args = sys.argv[1:] root = Path(os.environ["DELIVERY_FIXTURE_ROOT"]) control = json.loads((root / "control.json").read_text()) +bundle_id = "com.compoundingtech.fractal" + (".test" if os.environ.get("APP_VARIANT") == "test" else "") with (root / "commands.jsonl").open("a") as log: log.write(json.dumps({"command": name, "args": args, "cwd": os.getcwd(), "developer_dir": os.environ.get("DEVELOPER_DIR"), @@ -91,19 +93,19 @@ (Path.cwd() / package / "node_modules").mkdir(parents=True, exist_ok=True) sys.exit(0) if args == ["run", "export:ios"]: - assert os.environ["APP_VARIANT"] == "daily" + assert os.environ["APP_VARIANT"] in ("daily", "test") sys.exit(0) assert args[:2] == ["exec", "expo"], args if "config" in args: print(json.dumps({"name": control.get("expo_name", "Fractal"), "slug": "fractal", "ios": { - "bundleIdentifier": "com.compoundingtech.fractal"}})) + "bundleIdentifier": bundle_id}})) elif "prebuild" in args: (Path.cwd() / "ios" / "fractal.xcworkspace").mkdir(parents=True, exist_ok=True) (Path.cwd() / "ios" / "Podfile").write_text("# Fixture native project\n") native = Path.cwd() / "ios" / "fractal" native.mkdir(parents=True, exist_ok=True) with (native / "Info.plist").open("wb") as info: - plistlib.dump({"CFBundleIdentifier": "com.compoundingtech.fractal", + plistlib.dump({"CFBundleIdentifier": bundle_id, "CFBundleExecutable": "fractal", "CFBundleVersion": "0", "StBuildCommit": "unstamped"}, info) else: @@ -189,7 +191,8 @@ "bundleVersion": bundle["CFBundleVersion"], }])) elif "launch" in args: - assert "com.compoundingtech.fractal" in args, args + assert any(identifier in args for identifier in ( + "com.compoundingtech.fractal", "com.compoundingtech.fractal.test")), args status = control.get("launch_exit", 0) else: raise AssertionError(args) @@ -206,6 +209,17 @@ class IosEnvironmentTests(unittest.TestCase): + def test_normal_identity_and_state_are_unchanged(self): + state = Path("/lane") + self.assertEqual(bundle_id("normal"), BUNDLE_ID) + self.assertEqual(variant_state(state, "normal"), state) + self.assertEqual(build_environment("normal"), DAILY_BUILD_ENV) + + def test_test_identity_state_and_pinned_guard_environment(self): + self.assertEqual(bundle_id("test"), BUNDLE_ID + ".test") + self.assertEqual(variant_state(Path("/lane"), "test"), Path("/lane/ios-test-variant")) + self.assertEqual(build_environment("test"), {**DAILY_BUILD_ENV, "APP_VARIANT": "test"}) + def test_fractal_values_win_and_empty_values_mask_legacy(self): for suffix in ("REF", "EXPECTED_ORIGIN", "TRUSTED_COMMIT", "DEVICE", "TEAM", "NOTIFY_FROM", "NOTIFY_TO", "MAX_DEFERRAL", "BUILD_BACKOFF", "BUILD_ONLY"): @@ -229,6 +243,43 @@ def test_fractal_values_win_and_empty_values_mask_legacy(self): class ReleaseDeliveryTests(unittest.TestCase): + def test_test_variant_has_separate_artifacts_and_same_fabric_guard(self): + self.env.update(FRACTAL_IOS_VARIANT="test", + FRACTAL_IOS_TEST_APP_PROFILE="Fractal Test Development", + FRACTAL_IOS_TEST_RUNNER_PROFILE="Fractal Test Runner Development") + self.configure(fabric_stub=True) + self.reconcile("--build-only") + test_state = self.state / "ios-test-variant" + self.assertFalse((self.state / "state.json").exists()) + data = json.loads((test_state / "state.json").read_text()) + self.assertIn("failed_build", data) + self.assertFalse(list((test_state / "artifacts").glob("*/success.json"))) + self.assertEqual(len(self.commands("nm")), 1) + self.configure(fabric_stub=False) + self.reconcile("--build-only") + receipts = list((test_state / "artifacts").glob("*/success.json")) + self.assertEqual(len(receipts), 1) + app = Path(json.loads(receipts[0].read_text())["app"]) + with (app / "Info.plist").open("rb") as file: + self.assertEqual(plistlib.load(file)["CFBundleIdentifier"], BUNDLE_ID + ".test") + self.assertTrue(app.is_relative_to(test_state)) + for command in self.commands("xcodebuild"): + self.assertIn("CODE_SIGN_STYLE=Manual", command["args"]) + self.assertNotIn("-allowProvisioningUpdates", command["args"]) + self.assertFalse(self.device_commands("install")) + self.reconcile() + self.assertEqual(len(self.device_commands("install")), 1) + self.assertIn(BUNDLE_ID + ".test", self.device_commands("launch")[0]["args"]) + + def test_cli_variant_overrides_environment_and_requires_test_profile(self): + self.env["FRACTAL_IOS_VARIANT"] = "test" + failed = self.invoke("--build-only") + self.assertNotEqual(failed.returncode, 0) + self.assertIn("test-app-profile", failed.stderr) + self.reconcile("--variant", "normal", "--build-only") + self.assertTrue((self.state / "state.json").exists()) + self.assertFalse((self.state / "ios-test-variant").exists()) + def setUp(self): temporary = tempfile.TemporaryDirectory(prefix="release-delivery-tests-") self.addCleanup(temporary.cleanup) diff --git a/release-notes/ios-test-variant-prep.json b/release-notes/ios-test-variant-prep.json new file mode 100644 index 00000000000..abfcde9482b --- /dev/null +++ b/release-notes/ios-test-variant-prep.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Prepare an isolated iOS test app and independent XCUITest observation target through the existing daily delivery lane", + "replay": {"status": "none", "detail": "No claim replay changes."}, + "schema": {"status": "none", "detail": "No database schema changes."}, + "rules": {"status": "none", "detail": "No checkpoint rule changes."}, + "compatibility": {"status": "none", "detail": "Normal delivery remains the default with its existing identifiers and state path. The opt-in test variant uses a separate app, keychain entitlement, app group and delivery state namespace."}, + "downtime": {"status": "none", "detail": "No normal-app replacement or pairing migration."}, + "manual": {"status": "required", "detail": "Johannes must register and provision the isolated test app and XCUITest runner before signed device builds. Initial pairing and enrollment require their own consent; this preparation does neither."}, + "recovery": {"status": "none", "detail": "Normal delivery state is not migrated. Test artifacts and delivery state are scoped under ios-test-variant."} +} From 49684298c50d7f5070188468553f68574428ec33 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:45:50 +0100 Subject: [PATCH 107/132] Report every authority refusal mismatch; match the barrier message --- crates/fractal-missions-base/src/mission.rs | 7 +++++-- crates/fractal-store/src/store/mission_capacity.rs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index 7670d6dad34..33a1fc3b433 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -3362,10 +3362,11 @@ mod tests { #[test] fn mission_authority_refusals() { + let mut mismatches = Vec::new(); for (body, code) in [ (r#"authority"#, "empty-authority"), (r#"authority {}"#, "empty-authority"), - (r#"authority "none""#, "unexpected-value"), + (r#"authority "none""#, "invalid-node"), (r#"authority { write "resource/a"; }; authority { write "resource/b"; }"#, "duplicate-mission-field"), (r#"authority { write "resource/a"; write "resource/a"; }"#, "duplicate-authority"), (r#"authority { move "agent/a"; }"#, "invalid-authority"), @@ -3381,7 +3382,9 @@ mod tests { (r#"authority { publish "/*"; }"#, "invalid-authority-pattern"), (r#"step "outer" { mission "inner" { goal "Inner."; authority { write "resource/a"; }; step "x" { agentless; }; }; }"#, "nested-mission-authority"), ] { - assert_eq!(authority_error(body), code, "{body}"); + let actual = authority_error(body); + if actual != code { mismatches.push(format!("{body}: {actual}, expected {code}")); } } + assert!(mismatches.is_empty(), "{mismatches:#?}"); } } diff --git a/crates/fractal-store/src/store/mission_capacity.rs b/crates/fractal-store/src/store/mission_capacity.rs index 3a9eb7cbde2..92a68550095 100644 --- a/crates/fractal-store/src/store/mission_capacity.rs +++ b/crates/fractal-store/src/store/mission_capacity.rs @@ -260,7 +260,7 @@ mission "authorized" state="ready" { sync(&peer, &store); let refusal = publish(&store, AUTHORIZED, "older").unwrap_err(); assert_eq!(refusal.code, "mission-authority-unavailable"); - assert!(refusal.message.contains("has not advertised mission_authority=1; declare authority on a mission once every member is upgraded"), + assert!(refusal.message.contains("has not advertised mission_authority=1); declare authority on a mission once every member is upgraded"), "{}", refusal.message); assert!(store.mission_spec("authorized", None).unwrap().is_none()); claim(&peer, "daemon/birch", "daemon.started", None, From 89dd29750367632702a74d5b47c9a16762bc8b27 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:47:12 +0100 Subject: [PATCH 108/132] fractal: read worker-turn products by their full resource subject The daemon keys a child run's products by full subject, so the worker turn's product review/verdict is resource/mission-run/CHILD/review/verdict. --- packages/fractal/README.md | 2 +- packages/fractal/src/program.ts | 8 ++++++-- packages/fractal/src/program.unit.test.ts | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/fractal/README.md b/packages/fractal/README.md index d9d56f0788d..604ff6d095b 100644 --- a/packages/fractal/README.md +++ b/packages/fractal/README.md @@ -94,7 +94,7 @@ export default Fractal.mission( |---|---|---| | `Fractal.mission(intent, program)` | a `Workflow` with `intent.input` as payload and `intent.output` as success | the success, encoded with `output`, or the typed failure fails the program step | | `Fractal.step({ id, success?, error? }, effect)` | an `Activity` named by its key: `id`, then `id#2`… for repeats, so `Effect.retry` runs it again under a new key | its encoded value, or its typed failure with `_tag` and fields; defects and interrupts are not journaled | -| `Fractal.step({ id, success }).pipe(Effect.provide(Fractal.Worker.layer({ mission, input, product })))` | a `DurableDeferred` | a worker turn: a linked child run of `mission`; the product `/`, decoded with `success`, is the value | +| `Fractal.step({ id, success }).pipe(Effect.provide(Fractal.Worker.layer({ mission, input, product })))` | a `DurableDeferred` | a worker turn: a linked child run of `mission`; the product `resource//`, decoded with `success`, is the value | | `Fractal.ask({ id, for, title, request, deadline?, default? })` | a `DurableDeferred` | a native person ask on the program step, answered `AskAnswer` or failed `AskCancelled` | | `Fractal.run(mission, input, { id? })` | a `DurableDeferred` | a linked child run, `ChildRun` or `ChildFailed` | | `Fractal.command(argv, { env?, cwd?, priority? })` | — | not journaled: run it inside a step; the host runs it as a process group it ends on cancel | diff --git a/packages/fractal/src/program.ts b/packages/fractal/src/program.ts index 3f8463b1fff..2a1dfcd37fb 100644 --- a/packages/fractal/src/program.ts +++ b/packages/fractal/src/program.ts @@ -157,7 +157,10 @@ export function mission( return definition } -/** The child mission a worker turn runs, and the product below the child run that is its value. */ +/** + * The child mission a worker turn runs, and its product's path below the child run + * (`review/verdict` names `resource/mission-run/CHILD/review/verdict`), whose value is the step's. + */ export class Worker extends Context.Service< Worker, { @@ -244,7 +247,8 @@ export function step( return Effect.flatMap(Effect.service(Worker), (worker) => Effect.flatMap(child(options.id, worker.mission, worker.input ?? {}), (done) => { if (success === undefined) return Effect.void - const subject = `${done.run}/${worker.product}` + // The daemon keys products by full subject: `resource//`. + const subject = `resource/${done.run}/${worker.product}` const value = worker.product === undefined ? undefined : done.products?.[subject] if (value === undefined) return Effect.die( diff --git a/packages/fractal/src/program.unit.test.ts b/packages/fractal/src/program.unit.test.ts index 9806226f161..b5f1740a76e 100644 --- a/packages/fractal/src/program.unit.test.ts +++ b/packages/fractal/src/program.unit.test.ts @@ -389,7 +389,9 @@ describe('asks, child runs and worker turns', () => { value: { run: 'mission-run/child-3', status: 'completed', - products: { 'mission-run/child-3/review/verdict': { verdict: 'GO', head: 'h1' } }, + products: { + 'resource/mission-run/child-3/review/verdict': { verdict: 'GO', head: 'h1' }, + }, }, }, } From 4f5ffcd789341156fc4c00ffaa1c7529e4d54bd6 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:48:49 +0100 Subject: [PATCH 109/132] Watch managed launch inputs before resolution can fail agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-drivers/src/managed_launch.rs | 36 ++++++++------- crates/fractal-reconcile/src/contract.rs | 2 +- crates/fractal-reconcile/src/reconcile.rs | 45 +++++++++++++++++++ crates/fractal/src/driver_cli.rs | 2 +- release-notes/interactive-managed-launch.json | 2 +- 5 files changed, 69 insertions(+), 18 deletions(-) diff --git a/crates/fractal-drivers/src/managed_launch.rs b/crates/fractal-drivers/src/managed_launch.rs index cc2bb53a4b5..ad46db01055 100644 --- a/crates/fractal-drivers/src/managed_launch.rs +++ b/crates/fractal-drivers/src/managed_launch.rs @@ -21,7 +21,7 @@ pub fn resolve(harness: &str, argv: &mut [String], spec: &serde_json::Value, sub return Ok(()); } ensure!(subject.starts_with("agent/"), "managed harness launch requires a fenced agent subject"); - let path = resolve_from_source(harness, argv, spec, &|name| std::env::var_os(name))?; + let path = resolve_from_source(harness, argv, spec, &|name| std::env::var_os(name), &|_| Ok(()))?; if let Some(existing) = PROVIDER_ENVIRONMENT.get() { ensure!(existing.path == path && existing.subject == subject, "driver already selected a different managed provider environment"); @@ -33,13 +33,15 @@ pub fn resolve(harness: &str, argv: &mut [String], spec: &serde_json::Value, sub } /// Resolve a daemon seat using the effective captured launch environment, without global state. +/// Register profile and wrapper inputs before reading or validating them, including on failure. pub fn resolve_with_environment( harness: &str, argv: &mut [String], spec: &serde_json::Value, environment: &std::collections::BTreeMap, + input: &dyn Fn(&Path) -> Result<()>, ) -> Result { - resolve_from_source(harness, argv, spec, &|name| environment.get(name).map(std::ffi::OsString::from)) + resolve_from_source(harness, argv, spec, &|name| environment.get(name).map(std::ffi::OsString::from), input) } /// Person-owned terminal bindings retain their native harness launch contract. @@ -62,6 +64,7 @@ fn resolve_from_source( argv: &mut [String], spec: &serde_json::Value, env: &impl Fn(&str) -> Option, + input: &dyn Fn(&Path) -> Result<()>, ) -> Result { ensure!(matches!(harness, "omp" | "claude" | "codex"), "unsupported managed harness {harness}"); if is_person_bound(spec)? { @@ -72,7 +75,7 @@ fn resolve_from_source( .map(|value| value.into_string().map_err(|_| anyhow::anyhow!("AGENT_PERSONA is not UTF-8"))) .transpose()?; let persona = environment_persona.as_deref().unwrap_or("generalist"); - resolve_for_persona(harness, argv, &profile, persona)?; + resolve_for_persona(harness, argv, &profile, persona, input)?; let bin = Path::new(&argv[0]).parent().context("managed wrapper has no parent")?; prefixed_path(bin, env("PATH").as_deref()) } @@ -95,8 +98,10 @@ fn resolve_for_persona( argv: &mut [String], profile: &Path, persona: &str, + input: &dyn Fn(&Path) -> Result<()>, ) -> Result<()> { let program = argv.first_mut().context("managed provider argv is empty")?; + input(profile)?; let manifest: serde_json::Value = serde_json::from_slice( &std::fs::read(profile) .with_context(|| format!("read managed runtime profile {}", profile.display()))?, @@ -114,6 +119,7 @@ fn resolve_for_persona( ))?; ensure!(bin.is_absolute(), "managed wrapper directory must be absolute: {}", bin.display()); let wrapper = bin.join(harness); + input(&wrapper)?; let metadata = std::fs::metadata(&wrapper) .with_context(|| format!("managed {harness} wrapper is missing: {}; refusing raw provider", wrapper.display()))?; ensure!(metadata.is_file(), "managed {harness} wrapper is not a file: {}", wrapper.display()); @@ -206,7 +212,7 @@ mod tests { } for program in [harness.to_owned(), format!("/raw/bin/{harness}")] { let mut argv = vec![program, "--model".into(), "example".into()]; - resolve_for_persona(harness, &mut argv, &profile, "generalist").unwrap(); + resolve_for_persona(harness, &mut argv, &profile, "generalist", &|_| Ok(())).unwrap(); assert_eq!(Path::new(&argv[0]), wrapper); assert_eq!(&argv[1..], ["--model", "example"]); } @@ -218,7 +224,7 @@ mod tests { let (_root, bin, profile) = fixture(); for program in ["omp", "/raw/bin/omp"] { let mut argv = vec![program.to_owned()]; - let error = resolve_for_persona("omp", &mut argv, &profile, "generalist").unwrap_err(); + let error = resolve_for_persona("omp", &mut argv, &profile, "generalist", &|_| Ok(())).unwrap_err(); assert!(format!("{error:#}").contains(bin.join("omp").to_str().unwrap())); assert_eq!(argv[0], program); } @@ -228,7 +234,7 @@ mod tests { fn missing_wrapper_directory_refuses() { let (_root, bin, profile) = fixture(); std::fs::remove_dir(&bin).unwrap(); - let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist").unwrap_err(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist", &|_| Ok(())).unwrap_err(); assert!(format!("{error:#}").contains(bin.join("omp").to_str().unwrap())); } @@ -236,14 +242,14 @@ mod tests { fn missing_profile_key_refuses_raw_provider() { let (_root, _bin, profile) = fixture(); std::fs::write(&profile, "{}").unwrap(); - let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist").unwrap_err(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist", &|_| Ok(())).unwrap_err(); assert!(format!("{error:#}").contains(WRAPPER_DIRECTORY_KEY)); } #[test] fn missing_persona_does_not_fall_back_to_generalist() { let (_root, _bin, profile) = fixture(); - let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "unknown").unwrap_err(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "unknown", &|_| Ok(())).unwrap_err(); assert!(format!("{error:#}").contains("personas.launch.unknown.harnessBin")); } @@ -281,7 +287,7 @@ mod tests { ("AGENT_PERSONA".into(), "steward".into()), ]); let mut argv = ["omp".into()]; - resolve_with_environment("omp", &mut argv, &serde_json::json!({}), &environment).unwrap(); + resolve_with_environment("omp", &mut argv, &serde_json::json!({}), &environment, &|_| Ok(())).unwrap(); assert_eq!(Path::new(&argv[0]), wrapper); } @@ -309,10 +315,10 @@ mod tests { fn malformed_profile_and_relative_wrapper_directory_refuse() { let (_root, _bin, profile) = fixture(); std::fs::write(&profile, "{").unwrap(); - let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist").unwrap_err(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist", &|_| Ok(())).unwrap_err(); assert!(format!("{error:#}").contains("parse managed runtime profile")); std::fs::write(&profile, r#"{"personas":{"launch":{"generalist":{"harnessBin":"relative/bin"}}}}"#).unwrap(); - let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist").unwrap_err(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist", &|_| Ok(())).unwrap_err(); assert!(format!("{error:#}").contains("managed wrapper directory must be absolute")); } @@ -340,7 +346,7 @@ mod tests { for (profile, bin) in [(&first_profile, &first_bin), (&second_profile, &second_bin)] { symlink(profile, &link).unwrap(); let mut argv = ["codex".into(), "--version".into()]; - let path = resolve_with_environment("codex", &mut argv, &serde_json::json!({}), &environment).unwrap(); + let path = resolve_with_environment("codex", &mut argv, &serde_json::json!({}), &environment, &|_| Ok(())).unwrap(); assert_eq!(Path::new(&argv[0]), bin.join("codex")); assert_eq!(std::env::split_paths(&path).next().unwrap(), *bin); let output = std::process::Command::new(&argv[0]).arg(&argv[1]).env("PATH", path).output().unwrap(); @@ -348,7 +354,7 @@ mod tests { std::fs::remove_file(&link).unwrap(); } symlink(root.path().join("missing-profile.json"), &link).unwrap(); - let error = resolve_with_environment("codex", &mut ["codex".into()], &serde_json::json!({}), &environment).unwrap_err(); + let error = resolve_with_environment("codex", &mut ["codex".into()], &serde_json::json!({}), &environment, &|_| Ok(())).unwrap_err(); assert!(format!("{error:#}").contains("read managed runtime profile")); } @@ -361,7 +367,7 @@ mod tests { let environment = std::collections::BTreeMap::from([("PATH".into(), "/native/bin".into())]); for harness in ["omp", "claude", "codex"] { let mut argv = [format!("/native/{harness}")]; - let path = resolve_with_environment(harness, &mut argv, &spec, &environment).unwrap(); + let path = resolve_with_environment(harness, &mut argv, &spec, &environment, &|_| Ok(())).unwrap(); assert_eq!(path, std::ffi::OsStr::new("/native/bin")); assert_eq!(argv[0], format!("/native/{harness}")); } @@ -370,7 +376,7 @@ mod tests { "arguments": ["pty/agent/worker/019a0000-0000-7000-8000-000000000001"], }]}); assert!(!is_person_bound(&agent_binding).unwrap()); - assert!(resolve_with_environment("omp", &mut ["omp".into()], &agent_binding, &environment).is_err()); + assert!(resolve_with_environment("omp", &mut ["omp".into()], &agent_binding, &environment, &|_| Ok(())).is_err()); } #[test] diff --git a/crates/fractal-reconcile/src/contract.rs b/crates/fractal-reconcile/src/contract.rs index 632a4423d44..eb8efd19dd7 100644 --- a/crates/fractal-reconcile/src/contract.rs +++ b/crates/fractal-reconcile/src/contract.rs @@ -85,7 +85,7 @@ fn capture_with(store: &Store, desired: &DesiredSubject, member: &MemberSpec, ru // changing the daemon's environment or selecting process-global driver state. let mut argv = [provider.into_owned()]; let path = fractal_drivers::managed_launch::resolve_with_environment( - harness, &mut argv, &desired.desired, &environment, + harness, &mut argv, &desired.desired, &environment, input, )?; environment.insert("PATH".into(), path.into_string().map_err(|_| anyhow::anyhow!("managed provider PATH is not UTF-8"))?); let [program] = argv; diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index 32b4957c249..659745ec2bc 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -27239,6 +27239,51 @@ agent "import/omp/fixture" {{ .expect("a target mutation must still wake reconciliation"); } + #[test] + fn managed_launch_inputs_remain_watched_when_profile_or_wrapper_is_missing() { + use std::os::unix::fs::PermissionsExt as _; + let root = tempfile::tempdir().unwrap(); + let profile = root.path().join("profile.json"); + let desired = crate::graph::parse_test_intent(&format!("version 2\nagent \"seat\" {{ workspace {:?}; harness \"claude\" {{ model \"model-a\"; }} }}", root.path().to_str().unwrap()), "node").unwrap().subjects.into_values().next().unwrap(); + let store = Arc::new(Store::open_memory("node").unwrap()); + let reconciler = Reconciler::new(store.clone(), Arc::new(FakeRuntime::default()), "node".into(), Arc::new(Notify::new())); + let mut member = desired.member.clone().unwrap(); + member.environment.insert("PATH".into(), root.path().display().to_string()); + member.environment.insert("AGENT_PERSONA".into(), "generalist".into()); + member.environment.insert("AGENT_RUNTIME_PROFILE".into(), profile.display().to_string()); + crate::contract::install(&store, Box::new(move |_| Ok(member.clone()))); + assert!(crate::contract::declared(&store, &desired).is_err()); + reconciler.watch_launch_inputs(&desired).unwrap(); + assert!(crate::contract::declared_ready(&store, &desired).is_err()); + let profile_key = format!("launch-input/{}", profile.display()); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&profile_key)); + assert!(!reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", root.path().join("claude").display())), + "a missing managed profile must not fall back to raw claude"); + + let bin = root.path().join("managed/bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"generalist": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); + reconciler.incremental.touch(&profile_key); + reconciler.watch_launch_inputs(&desired).unwrap(); + assert!(crate::contract::declared_ready(&store, &desired).is_err()); + let wrapper = bin.join("claude"); + let wrapper_key = format!("launch-input/{}", wrapper.display()); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&wrapper_key)); + + std::fs::write(&wrapper, "#!/bin/sh\nexit 0\n").unwrap(); + reconciler.incremental.touch(&wrapper_key); + reconciler.watch_launch_inputs(&desired).unwrap(); + assert!(crate::contract::declared_ready(&store, &desired).is_err(), + "a non-executable wrapper must remain refused and watched"); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&wrapper_key)); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + reconciler.incremental.touch(&wrapper_key); + reconciler.watch_launch_inputs(&desired).unwrap(); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&wrapper_key)); + } + #[test] fn launch_input_measurement_failure_is_unknown_and_subscription_faults_target_daemon() { use std::os::unix::fs::PermissionsExt as _; diff --git a/crates/fractal/src/driver_cli.rs b/crates/fractal/src/driver_cli.rs index 3a48666c36e..87b0b45098a 100644 --- a/crates/fractal/src/driver_cli.rs +++ b/crates/fractal/src/driver_cli.rs @@ -85,7 +85,7 @@ mod managed_declaration_tests { ]).unwrap().command else { panic!("driver expected") }; let declaration = managed_declaration_result(Err(api_error(ClientErrorCode::NotFound))).unwrap(); fractal_drivers::managed_launch::resolve_with_environment( - &args.driver, &mut args.argv, &declaration, &environment, + &args.driver, &mut args.argv, &declaration, &environment, &|_| Ok(()), ).unwrap(); assert_eq!(Path::new(&args.argv[0]), wrapper); } diff --git a/release-notes/interactive-managed-launch.json b/release-notes/interactive-managed-launch.json index 0f6a3a3abd9..ac8d9d48acc 100644 --- a/release-notes/interactive-managed-launch.json +++ b/release-notes/interactive-managed-launch.json @@ -7,5 +7,5 @@ "compatibility": {"status": "changed", "detail": "OMP, Claude, and Codex agent-seat driver launches, including flagless legacy re-exec helpers, use personas.launch..harnessBin. Person-owned terminal bindings retain native execution; one authenticated declaration read selects this path, while agent-owned or absent bindings remain managed. Prelaunch capture follows the same rule. Select AGENT_PERSONA when set, otherwise generalist as an internal default without exporting AGENT_PERSONA. Only confirmed declaration not-found permits an absent declaration; classified daemon outages wait without spawning, while other read errors refuse. Provider PATH is prefixed. Before spawn, any present ST_AGENT/FRACTAL_AGENT must equal the fenced subject (including rejecting empty values); both child aliases are then set to that subject. Driver environment and person-owned native sessions are unchanged."}, "downtime": {"status": "none", "detail": "Existing provider processes are unchanged."}, "manual": {"status": "changed", "detail": "Rollout dependency: do not upgrade a host to a Fractal release containing this change until that host has activated a runtime profile with personas.launch..harnessBin for its managed seats. Read an absolute AGENT_RUNTIME_PROFILE when set, otherwise the stable HOME-relative coding-agent-runtime Nix profile; missing profile, persona key, or wrapper refuses new managed launches."}, - "recovery": {"status": "none", "detail": "Correct the runtime profile or install the named wrapper before retrying a refused launch."} + "recovery": {"status": "none", "detail": "Correct the runtime profile or install the named wrapper before retrying a refused launch. Prelaunch subscriptions watch the profile and selected wrapper even when resolution fails, so repairing either input re-triggers reconciliation without falling back to a raw provider."} } From de1fa2c85f24ca61d4ab84526a27d9793cc761b2 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:55:27 +0100 Subject: [PATCH 110/132] fix(ios): preserve normal config and generate actual UI test product agent-identity: agent/compoundingtech/st-ios agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/ios/README.md | 9 +++++++- apps/ios/app.config.js | 4 ++-- apps/ios/appConfig.test.mjs | 28 +++++++++++++++++++---- apps/ios/plugins/withTestClient.cjs | 5 +++- apps/ios/scripts/assert_test_project.mjs | 26 +++++++++++++++++++++ apps/ios/scripts/test_release_delivery.py | 13 ++++++++++- 6 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 apps/ios/scripts/assert_test_project.mjs diff --git a/apps/ios/README.md b/apps/ios/README.md index 3b356df1377..55b6ec66d20 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -556,7 +556,8 @@ app group `group.com.compoundingtech.fractal.test`, and sole keychain access gro `$(AppIdentifierPrefix)com.compoundingtech.fractal.test`. It keeps the same Expo project/workspace and release application source as daily. The readable build-time flag is `Constants.expoConfig.extra.testClientMode` in JavaScript and -`FractalTestClientMode` in the app's Info.plist; both are false for dev/daily. +`FractalTestClientMode` in the app's Info.plist; both are absent for dev/daily +(absence means normal mode, preserving the normal configuration unchanged). This flag describes the binary, not enrollment or authorization. No command handlers or automatic enrollment are enabled by this preparation. @@ -582,6 +583,12 @@ is `com.compoundingtech.fractal.test`; Xcode appends `.xctrunner`, giving `$(FRACTAL_IOS_TEST_RUNNER_PROFILE)`. Johannes performs provisioning/signing setup; these commands do not create profiles or allow provisioning updates. +After unsigned test prebuild, run `node scripts/assert_test_project.mjs` from +`apps/ios` to assert the generated product reference is exactly +`FractalTestControl.xctest` (not a Spotlight `.mdimporter`) and uses the +UI-testing product type and expected runner base identity. This checks project +generation only; it is not a signed/compiled runner receipt. + After the applicable consent/signing gates, the command interface from `apps/ios` is below. Use the daily delivery lane to enforce its guards and regenerate native source with `APP_VARIANT=test`; do not install using an unguarded alternate path. diff --git a/apps/ios/app.config.js b/apps/ios/app.config.js index 4ee47f7301e..8af66b4c84f 100644 --- a/apps/ios/app.config.js +++ b/apps/ios/app.config.js @@ -12,7 +12,7 @@ module.exports = () => { // LIVE-MIGRATION END arn:lmig:smalltalk:2026-10-16-env-fractal-dual-read return { ...base, - extra: { ...base.extra, ...(push ? { stIosPush: true } : {}), testClientMode: testClient }, + ...((push || testClient) ? { extra: { ...base.extra, ...(push ? { stIosPush: true } : {}), ...(testClient ? { testClientMode: true } : {}) } } : {}), plugins: [ ...(base.plugins ?? []), push @@ -32,7 +32,7 @@ module.exports = () => { infoPlist: { ...base.ios.infoPlist, CFBundleDisplayName: testClient ? 'Fractal Test' : daily ? 'Fractal' : 'Fractal Dev', - FractalTestClientMode: testClient, + ...(testClient ? { FractalTestClientMode: true } : {}), }, }, }; diff --git a/apps/ios/appConfig.test.mjs b/apps/ios/appConfig.test.mjs index 49c9cf754b8..bb8ff9dfd1d 100644 --- a/apps/ios/appConfig.test.mjs +++ b/apps/ios/appConfig.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createRequire } from 'node:module'; const configure = createRequire(import.meta.url)('./app.config.js'); +const base = createRequire(import.meta.url)('./app.json').expo; const withVariant = (value, run) => { const saved = process.env.APP_VARIANT; @@ -23,8 +24,8 @@ test('daily installs as Fractal with the ordinary icon', () => { assert.equal(daily.ios.bundleIdentifier, 'com.compoundingtech.fractal'); assert.equal(daily.ios.supportsTablet, true); assert.equal(daily.updates, undefined); - assert.equal(daily.extra.testClientMode, false); - assert.equal(daily.ios.infoPlist.FractalTestClientMode, false); + assert.equal(daily.extra?.testClientMode, undefined); + assert.equal(daily.ios.infoPlist.FractalTestClientMode, undefined); assert.equal(daily.plugins.includes('./plugins/withTestClient.cjs'), false); assert.equal(daily.ios.entitlements, undefined); }); @@ -37,8 +38,8 @@ test('default and dev keep the starter identity with the DEV-badged icon', () => assert.equal(dev.icon, './assets/icon-dev.png'); assert.equal(dev.ios.bundleIdentifier, 'com.compoundingtech.fractal.starter'); assert.deepEqual(withVariant('dev', configure), dev); - assert.equal(dev.extra.testClientMode, false); - assert.equal(dev.ios.infoPlist.FractalTestClientMode, false); + assert.equal(dev.extra?.testClientMode, undefined); + assert.equal(dev.ios.infoPlist.FractalTestClientMode, undefined); assert.equal(dev.plugins.includes('./plugins/withTestClient.cjs'), false); assert.equal(dev.ios.entitlements, undefined); }); @@ -62,3 +63,22 @@ test('test is a separately named, isolated Release-source client with a native o test('an unknown variant fails closed', () => { assert.throws(() => withVariant('typo', configure), /APP_VARIANT/); }); + +test('normal variants preserve the complete origin/main configuration', () => { + for (const variant of ['dev', 'daily']) { + const daily = variant === 'daily'; + const push = (process.env.FRACTAL_IOS_PUSH ?? process.env.ST_IOS_PUSH) === '1'; + const original = { + ...base, + ...(push ? { extra: { ...base.extra, stIosPush: true } } : {}), + plugins: [...(base.plugins ?? []), push ? ['expo-notifications', { mode: 'development' }] : './plugins/withoutPush'], + icon: daily ? './assets/icon.png' : './assets/icon-dev.png', + ios: { + ...base.ios, + bundleIdentifier: daily ? 'com.compoundingtech.fractal' : 'com.compoundingtech.fractal.starter', + infoPlist: { ...base.ios.infoPlist, CFBundleDisplayName: daily ? 'Fractal' : 'Fractal Dev' }, + }, + }; + assert.deepEqual(withVariant(variant, configure), original); + } +}); diff --git a/apps/ios/plugins/withTestClient.cjs b/apps/ios/plugins/withTestClient.cjs index 4d8b7ec45f8..a4d4d1f7c7c 100644 --- a/apps/ios/plugins/withTestClient.cjs +++ b/apps/ios/plugins/withTestClient.cjs @@ -48,8 +48,11 @@ module.exports = function withTestClient(config) { isa: 'XCBuildConfiguration', name, buildSettings: { ...settings }, })), 'Release', `Build configuration list for PBXNativeTarget "${targetName}"`); const product = project.addProductFile(`${targetName}.xctest`, { - target: targetId, explicitFileType: 'wrapper.cfbundle', + target: targetId, sourceTree: 'BUILT_PRODUCTS_DIR', }); + // Supplying explicitFileType to addProductFile appends the library's first + // cfbundle extension (.mdimporter). Set the type after the exact path exists. + objects.PBXFileReference[product.fileRef].explicitFileType = '"wrapper.cfbundle"'; const nativeTarget = { isa: 'PBXNativeTarget', name: targetName, productName: targetName, productReference: product.fileRef, diff --git a/apps/ios/scripts/assert_test_project.mjs b/apps/ios/scripts/assert_test_project.mjs new file mode 100644 index 00000000000..08c3e0ae8bc --- /dev/null +++ b/apps/ios/scripts/assert_test_project.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const expoRequire = createRequire(require.resolve('expo/package.json')); +const xcode = expoRequire('xcode'); +const projectPath = new URL('../ios/fractal.xcodeproj/project.pbxproj', import.meta.url); +const project = xcode.project(projectPath.pathname); +project.parseSync(); +const objects = project.hash.project.objects; +const unquote = value => value?.replace(/^"|"$/g, ''); +const target = Object.values(objects.PBXNativeTarget).find(value => + typeof value === 'object' && unquote(value.name) === 'FractalTestControl'); +assert.ok(target, 'Generated test target is missing'); +assert.equal(unquote(target.productType), 'com.apple.product-type.bundle.ui-testing'); +const product = objects.PBXFileReference[target.productReference]; +assert.equal(unquote(product.path), 'FractalTestControl.xctest'); +assert.equal(unquote(product.sourceTree), 'BUILT_PRODUCTS_DIR'); +assert.equal(unquote(product.explicitFileType ?? product.lastKnownFileType), 'wrapper.cfbundle'); +assert.equal(readFileSync(projectPath, 'utf8').includes('.xctest.mdimporter'), false); +for (const ref of objects.XCConfigurationList[target.buildConfigurationList].buildConfigurations) { + assert.equal(objects.XCBuildConfiguration[ref.value].buildSettings.PRODUCT_BUNDLE_IDENTIFIER, + 'com.compoundingtech.fractal.test'); +} +console.log('PASS generated UI-test product is FractalTestControl.xctest; UI-testing host identity base is com.compoundingtech.fractal.test'); diff --git a/apps/ios/scripts/test_release_delivery.py b/apps/ios/scripts/test_release_delivery.py index 32b2233e83a..f186c850b09 100644 --- a/apps/ios/scripts/test_release_delivery.py +++ b/apps/ios/scripts/test_release_delivery.py @@ -186,7 +186,12 @@ app = Path(next(arg for arg in args if arg.endswith(".app"))) with (app / "Info.plist").open("rb") as file: bundle = plistlib.load(file) - (root / "device-apps.json").write_text(json.dumps([{ + installed = root / "device-apps.json" + existing = json.loads(installed.read_text()) if installed.exists() else [ + {"bundleIdentifier": "com.compoundingtech.fractal", "bundleVersion": "0"}] + others = [record for record in existing + if record["bundleIdentifier"] != bundle["CFBundleIdentifier"]] + installed.write_text(json.dumps([*others, { "bundleIdentifier": bundle["CFBundleIdentifier"], "bundleVersion": bundle["CFBundleVersion"], }])) @@ -267,9 +272,15 @@ def test_test_variant_has_separate_artifacts_and_same_fabric_guard(self): self.assertIn("CODE_SIGN_STYLE=Manual", command["args"]) self.assertNotIn("-allowProvisioningUpdates", command["args"]) self.assertFalse(self.device_commands("install")) + normal_record = {"bundleIdentifier": BUNDLE_ID, "bundleVersion": "18528"} + (self.root / "device-apps.json").write_text(json.dumps([normal_record])) self.reconcile() self.assertEqual(len(self.device_commands("install")), 1) self.assertIn(BUNDLE_ID + ".test", self.device_commands("launch")[0]["args"]) + installed = json.loads((self.root / "device-apps.json").read_text()) + self.assertEqual(len(installed), 2) + self.assertEqual([record for record in installed if record["bundleIdentifier"] == BUNDLE_ID], + [normal_record]) def test_cli_variant_overrides_environment_and_requires_test_profile(self): self.env["FRACTAL_IOS_VARIANT"] = "test" From 1a115137ebdef5c1bdfc39db42640937f3612654 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:07:05 +0100 Subject: [PATCH 111/132] fix: isolate native profiles without changing driver HOME --- crates/fractal-kernel/src/instance.rs | 61 +++++++------------ crates/fractal-seats/src/external_sessions.rs | 46 ++++++++++---- crates/fractal/src/main.rs | 7 --- docs/st3/instances.md | 26 ++++---- .../instance-native-session-isolation.json | 8 +-- 5 files changed, 73 insertions(+), 75 deletions(-) diff --git a/crates/fractal-kernel/src/instance.rs b/crates/fractal-kernel/src/instance.rs index ae2ecfa3c34..c5fa8f68cd7 100644 --- a/crates/fractal-kernel/src/instance.rs +++ b/crates/fractal-kernel/src/instance.rs @@ -16,7 +16,7 @@ //! bin/ the st3, st and pty the installer puts there //! claude/ the instance's Claude account (CLAUDE_CONFIG_DIR) //! codex/ the instance's Codex account (CODEX_HOME) -//! home/ native harness HOME and pi/omp profiles; aliases existing provider and data dirs +//! pi/ the instance's shared pi/omp profile (PI_CODING_AGENT_DIR) //! work/ the default work root: workspaces of new agents, in work/agents/NAME //! app/ where a macOS app bundle for the instance goes //! ``` @@ -141,27 +141,33 @@ impl Instance { self.root.join("codex") } - /// HOME-shaped session view, shared by discovery and native harness processes. - /// Keep installed generic harness skills in the same home. + /// The shared pi/omp profile; HOME remains the login home for ordinary shell tools. + pub fn pi_agent_dir(&self) -> PathBuf { + self.root.join("pi") + } + + /// HOME-shaped view for discovery only, never exported as a process HOME. pub fn native_session_home(&self) -> PathBuf { - self.root.join("home") + self.state_home().join("native-sessions") } - /// Preserve the existing provider account and XDG data locations without copying sessions. + /// Keep readers on the same provider directories as launched harnesses without copying data. pub fn prepare_native_session_home(&self) -> Result { let home = self.native_session_home(); - std::fs::create_dir_all(home.join(".local"))?; + std::fs::create_dir_all(home.join(".omp"))?; for (alias, target) in [ (home.join(".claude"), self.claude_config_dir()), (home.join(".codex"), self.codex_home()), - (home.join(".local/share"), self.data_home()), + // Both tools share PI_CODING_AGENT_DIR. Expose this profile once, as omp. + (home.join(".omp/agent"), self.pi_agent_dir()), ] { if let Err(error) = std::os::unix::fs::symlink(&target, &alias) { if error.kind() != std::io::ErrorKind::AlreadyExists { return Err(error).with_context(|| format!("create native session alias {}", alias.display())); } anyhow::ensure!( - std::fs::read_link(&alias)? == target, + std::fs::read_link(&alias) + .with_context(|| format!("read native session alias {}", alias.display()))? == target, "native session alias {} does not point to its selected instance directory", alias.display() ); @@ -213,6 +219,7 @@ impl Instance { ("XDG_RUNTIME_DIR", self.run_dir()), ("CLAUDE_CONFIG_DIR", self.claude_config_dir()), ("CODEX_HOME", self.codex_home()), + ("PI_CODING_AGENT_DIR", self.pi_agent_dir()), ] } } @@ -221,7 +228,7 @@ impl Instance { /// process: guessing would run the default install when a person asked for a separate one. pub fn current() -> Option { let name = environment_name()?; - let home = match instance_host_home() { + let home = match env::var_os("HOME").map(PathBuf::from) { Some(home) => home, None => fail("HOME is not set, so the instance directory cannot be found"), }; @@ -231,14 +238,6 @@ pub fn current() -> Option { } } -/// A native driver changes HOME, but nested CLI invocations still resolve the same instance. -fn instance_host_home() -> Option { - environment_name() - .and_then(|_| env::var_os("ST_INSTANCE_HOST_HOME")) - .or_else(|| env::var_os("HOME")) - .map(PathBuf::from) -} - /// Select the discovery root without ever falling back to the login home inside an instance. pub fn native_session_home() -> Result> { match current() { @@ -247,26 +246,6 @@ pub fn native_session_home() -> Result> { } } -/// Apply only at the native driver boundary; the CLI and service manager retain the login HOME. -/// -/// # Safety -/// -/// Changes the process environment, so no other thread may be running. -pub unsafe fn activate_native_session_home() -> Result<()> { - let Some(instance) = current() else { - return Ok(()); - }; - let home = instance.prepare_native_session_home()?; - let host = instance_host_home().context("HOME is not set")?; - unsafe { - env::set_var("ST_INSTANCE_HOST_HOME", host); - env::set_var("HOME", home); - // An ambient profile override must not send pi/omp back to the login session store. - env::remove_var("PI_CODING_AGENT_DIR"); - } - Ok(()) -} - fn fail(message: &str) -> ! { eprintln!("st: {message}"); std::process::exit(2); @@ -306,11 +285,10 @@ pub unsafe fn activate(name: &str) -> Result { { anyhow::bail!("this process already runs in instance `{active}`, not `{name}`"); } - let home = instance_host_home() + let home = env::var_os("HOME") + .map(PathBuf::from) .context("HOME is not set, so the instance directory cannot be found")?; let instance = Instance::new(&home, name)?; - // A nested non-driver CLI restores the login HOME before resolving service-manager paths. - unsafe { env::set_var("HOME", &home) }; let already_inside = active.as_deref() == Some(name); for (variable, remembered) in XDG { // The host's own value is kept once, on the way in; a child started from inside the @@ -445,6 +423,7 @@ mod tests { one.bin_dir(), one.claude_config_dir(), one.codex_home(), + one.pi_agent_dir(), one.work_dir(), one.app_dir(), ] { @@ -454,6 +433,8 @@ mod tests { for (variable, value) in one.environment() { assert!(value.starts_with(one.root()), "{variable}"); } + assert!(one.native_session_home().starts_with(one.state_home())); + assert!(!one.environment().iter().any(|(variable, _)| *variable == "HOME")); } #[test] diff --git a/crates/fractal-seats/src/external_sessions.rs b/crates/fractal-seats/src/external_sessions.rs index dcc95b9b7a4..ebe2aeb9fed 100644 --- a/crates/fractal-seats/src/external_sessions.rs +++ b/crates/fractal-seats/src/external_sessions.rs @@ -2832,6 +2832,13 @@ mod tests { ).unwrap(); }; write_session(&login_home, "login-only"); + let write_omp = |profile: &Path, id: &str| { + let directory = profile.join("sessions/project"); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join(format!("{id}.jsonl")), + format!("{}\n", json!({"type":"session", "id":id, "cwd":login_home}))).unwrap(); + }; + write_omp(&login_home.join(".omp/agent"), "login-omp"); if case == "instance" { // SAFETY: isolated single-test subprocess; no runtime or other readers exist. let selected = unsafe { instance::activate("trial") }.unwrap(); @@ -2840,15 +2847,17 @@ mod tests { fs::create_dir_all(selected.codex_home()).unwrap(); write_session(&native, "instance-only"); let home = Some(native.as_path()); - unsafe { instance::activate_native_session_home() }.unwrap(); - assert_eq!(std::env::var_os("HOME").map(PathBuf::from), Some(native.clone())); - assert!(std::env::var_os("PI_CODING_AGENT_DIR").is_none()); + assert_eq!(std::env::var_os("HOME").map(PathBuf::from), Some(login_home.clone())); + let profile = PathBuf::from(std::env::var_os("PI_CODING_AGENT_DIR").unwrap()); + assert_eq!(profile, selected.pi_agent_dir()); + write_omp(&profile, "instance-omp"); assert_eq!(instance::current(), Some(selected.clone())); assert_eq!(PathBuf::from(std::env::var_os("CODEX_HOME").unwrap()), selected.codex_home()); assert_eq!(fs::canonicalize(native.join(".codex")).unwrap(), fs::canonicalize(selected.codex_home()).unwrap()); assert_eq!(fs::read_link(native.join(".claude")).unwrap(), selected.claude_config_dir()); - assert_eq!(fs::read_link(native.join(".local/share")).unwrap(), selected.data_home()); - // Re-exec and nested CLI selection must not nest instances beneath the synthetic HOME. + assert_eq!(fs::read_link(native.join(".omp/agent")).unwrap(), selected.pi_agent_dir()); + assert!(!native.join(".local/share").exists()); + // Repeated activation leaves shell-tool HOME and profile selection unchanged. unsafe { instance::activate("trial") }.unwrap(); assert_eq!(instance::current(), Some(selected)); assert_eq!(std::env::var_os("HOME").map(PathBuf::from), Some(login_home)); @@ -2857,8 +2866,10 @@ mod tests { crate::external_sessions::discover_fresh(home, true).unwrap(), crate::external_sessions::discover(home, true).unwrap(), ] { - assert_eq!(discovery.sessions.len(), 1); - assert_eq!(discovery.sessions[0].native_id, "instance-only"); + assert_eq!(discovery.sessions.len(), 2); + assert!(discovery.sessions.iter().any(|session| session.native_id == "instance-only")); + assert!(discovery.sessions.iter().any(|session| session.native_id == "instance-omp" + && session.driver == ExternalDriver::Omp)); } let login_id = crate::external_sessions::external_session_id( crate::external_sessions::ExternalDriver::Codex, "login-only", @@ -2868,12 +2879,12 @@ mod tests { assert!(instance::current().is_none()); let native = instance::native_session_home().unwrap().unwrap(); assert_eq!(native, login_home); - let before: Vec<_> = std::env::vars_os().collect(); - unsafe { instance::activate_native_session_home() }.unwrap(); - assert_eq!(std::env::vars_os().collect::>(), before); + assert_eq!(std::env::var_os("PI_CODING_AGENT_DIR"), + Some(login_home.join("ambient-profile").into_os_string())); let discovery = crate::external_sessions::discover_fresh(Some(&native), true).unwrap(); - assert_eq!(discovery.sessions.len(), 1); - assert_eq!(discovery.sessions[0].native_id, "login-only"); + assert_eq!(discovery.sessions.len(), 2); + assert!(discovery.sessions.iter().any(|session| session.native_id == "login-only")); + assert!(discovery.sessions.iter().any(|session| session.native_id == "login-omp")); } return; } @@ -2886,12 +2897,21 @@ mod tests { .env("PI_CODING_AGENT_DIR", home.path().join("ambient-profile")) .env_remove("ST_INSTANCE") .env_remove("FRACTAL_INSTANCE") - .env_remove("ST_INSTANCE_HOST_HOME") .output().unwrap(); assert!(output.status.success(), "{case}: {}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); } } + #[test] + fn instance_native_discovery_rejects_conflicting_alias() { + let home = tempfile::tempdir().unwrap(); + let instance = fractal_kernel::instance::Instance::new(home.path(), "trial").unwrap(); + let conflicting = instance.native_session_home().join(".claude"); + fs::create_dir_all(&conflicting).unwrap(); + let error = instance.prepare_native_session_home().unwrap_err(); + assert!(format!("{error:#}").contains(&format!("read native session alias {}", conflicting.display()))); + } + #[cfg(target_os = "linux")] #[test] fn omp_old_hook_recovery_requires_exact_live_lineage_account_and_header() { diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index a62df1438d4..03f46e33405 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -5255,13 +5255,6 @@ fn cli_main() -> ExitCode { return ExitCode::from(2); } } - if native_driver_requested(arguments.iter().skip(1).cloned()) { - // SAFETY: native harness HOME is selected before any runtime or worker thread starts. - if let Err(error) = unsafe { fractal_daemon::instance::activate_native_session_home() } { - stderr_message("st", format_args!("native session home: {error:#}")); - return ExitCode::from(2); - } - } // A shell stub from `st completions` calls back with `COMPLETE=` on each TAB. Answer // before any config, runtime, or daemon work; this exits when the variable is set. clap_complete::env::CompleteEnv::with_factory(cli_command).complete(); diff --git a/docs/st3/instances.md b/docs/st3/instances.md index bf29b3a5ec0..d49f62e000b 100644 --- a/docs/st3/instances.md +++ b/docs/st3/instances.md @@ -65,7 +65,8 @@ an existing migrated root, except that `work/` stays at its legacy location): | `bin/` | the `st3`, `st` and `pty` that the installer puts there | | | `claude/` | the instance's Claude account, plugins and `st` skill | `CLAUDE_CONFIG_DIR` | | `codex/` | the instance's Codex account and `st` skill | `CODEX_HOME` | -| `home/` | native harness HOME, pi/omp session stores and shared skills (`home/.agents/skills`) | `HOME` for native drivers and hooks | +| `pi/` | the instance's shared pi/omp profile, login and native sessions | `PI_CODING_AGENT_DIR` | +| `home/` | shared skills (`home/.agents/skills`) | | | `work/` | the instance's work root: default workspaces for new agents in `work/agents/NAME`, including the Fractal Assistant | | | `app/` | the macOS app bundle | | @@ -74,16 +75,19 @@ and everything it starts (seats, harnesses, hooks) inherit it, so code that read itself lands in the instance without knowing about instances. Seats of an instance sign in to Claude and Codex separately from your own; that is what makes the first run clean. -Native-session inventory, search, session lists and conversation timelines discover only the -instance's `home/`, never the login user's HOME. This existing instance-owned home also holds -generic harness skills, so native drivers and their children use it as HOME. Its `.claude`, -`.codex` and `.local/share` are checked aliases to the instance's existing `claude/`, `codex/` -and `data/` directories; provider logins and OpenCode sessions stay in their existing locations. -An inherited `PI_CODING_AGENT_DIR` is cleared at the native-driver boundary so pi/omp use the -instance profile. Managed sessions with explicit driver-owned transcript paths retain those paths. -The default instance keeps the login HOME and its existing discovery behavior unchanged. -Ordinary CLI and service-manager operations retain the login HOME, including nested CLI calls -from an instance's harness. +Native-session inventory, search, session lists and conversation timelines use a discovery-only +view at `state/native-sessions/`, never the login user's HOME. Checked `.claude`, `.codex` and +`.omp/agent` aliases point to this instance's `claude/`, `codex/` and `pi/` directories. No other +login-home native stores are scanned. The shared pi/omp profile is exposed once as omp; +distinct saved-session labels for pi and omp within a named instance are not supported. +`PI_CODING_AGENT_DIR` selects that same `pi/` profile for launched agents. Managed sessions with +explicit driver-owned transcript paths retain those paths. + +HOME is unchanged for the daemon, drivers, hooks and their children: git, ssh, gh and shell rc +files retain the login environment. The default instance keeps its existing discovery behavior. +Before upgrading a named instance that uses omp, sign in to the instance's `pi/` profile or +explicitly migrate its existing pi/omp credentials and configuration there while agents are +stopped. Login-home profiles and sessions are not copied or deleted automatically. Beyond directories, an instance has its own names: diff --git a/release-notes/instance-native-session-isolation.json b/release-notes/instance-native-session-isolation.json index df47827285d..56a51e01635 100644 --- a/release-notes/instance-native-session-isolation.json +++ b/release-notes/instance-native-session-isolation.json @@ -1,6 +1,6 @@ { "version": 1, - "summary": "Isolate native session discovery and harness HOME in named instances", + "summary": "Isolate named-instance native discovery and pi/omp profiles without changing HOME", "replay": { "status": "none", "detail": "No replay or projection changes." @@ -11,11 +11,11 @@ }, "rules": { "status": "changed", - "detail": "Native session readers use the selected instance home; native drivers and hooks use the same home." + "detail": "Named-instance native readers use a discovery-only root under the instance state directory, with aliases only to the instance Claude, Codex and shared pi/omp stores." }, "compatibility": { "status": "changed", - "detail": "The default instance keeps its existing HOME and native discovery. Named instances use their existing home directory, with checked aliases to existing provider accounts and XDG data." + "detail": "HOME remains the login HOME for drivers, hooks and shell tools. Named instances set PI_CODING_AGENT_DIR to their own pi/ directory, overriding an inherited profile path. The shared pi/omp native profile is exposed once as omp; distinct pi and omp saved-session labels within a named instance are out of scope. The default instance keeps its existing environment and discovery behavior." }, "downtime": { "status": "changed", @@ -23,7 +23,7 @@ }, "manual": { "status": "changed", - "detail": "Named-instance pi/omp profiles are separate from the login profile; sign in or configure the instance profile as needed. Existing provider account directories are unchanged." + "detail": "Before upgrading a named instance with omp agents, stop its agents and sign in to the instance pi/ profile, or explicitly migrate the required existing pi/omp login credentials and configuration to that directory before restarting. The upgrade does not copy the login-home profile, credentials or sessions. Existing Claude and Codex account directories are unchanged." }, "recovery": { "status": "changed", From acb54e0f44e86e9a71616f53116d1fafb482e40f Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:17:03 +0100 Subject: [PATCH 112/132] fix(ios): declare provisioning as changed manual release impact agent-identity: agent/compoundingtech/st-ios agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- release-notes/ios-test-variant-prep.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/ios-test-variant-prep.json b/release-notes/ios-test-variant-prep.json index abfcde9482b..31b38defca9 100644 --- a/release-notes/ios-test-variant-prep.json +++ b/release-notes/ios-test-variant-prep.json @@ -6,6 +6,6 @@ "rules": {"status": "none", "detail": "No checkpoint rule changes."}, "compatibility": {"status": "none", "detail": "Normal delivery remains the default with its existing identifiers and state path. The opt-in test variant uses a separate app, keychain entitlement, app group and delivery state namespace."}, "downtime": {"status": "none", "detail": "No normal-app replacement or pairing migration."}, - "manual": {"status": "required", "detail": "Johannes must register and provision the isolated test app and XCUITest runner before signed device builds. Initial pairing and enrollment require their own consent; this preparation does neither."}, + "manual": {"status": "changed", "detail": "Before the first signed device build of the test variant, Johannes registers and provisions the test app com.compoundingtech.fractal.test and the XCUITest runner com.compoundingtech.fractal.test.xctrunner (app group, keychain group, both profiles). Initial pairing and enrollment need their own consent; this change does neither. The normal app needs no action."}, "recovery": {"status": "none", "detail": "Normal delivery state is not migrated. Test artifacts and delivery state are scoped under ios-test-variant."} } From 76665ee920aac22e85c966c35a20dbb895f44b46 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:36:30 +0100 Subject: [PATCH 113/132] =?UTF-8?q?docs:=20run=20authority=20in=20program?= =?UTF-8?q?=20engine=20=C2=A73?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/st3/program-engine.md | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md index a7e2526b377..1a1a7856ebf 100644 --- a/docs/st3/program-engine.md +++ b/docs/st3/program-engine.md @@ -184,6 +184,56 @@ the authority of whoever started the run, and nothing else. The daemon writes jo that actor. Changing `authority` is a mission revision, so factory classifies it as an authority-class change. +#### Run authority + +**Grammar.** A top-level mission may declare one `authority` block in the shape of the agent +`*-authority` blocks, one `VERB "PATTERN"` rule per line. `publish`, `start`, `revise` and `cancel` +take mission IDs exactly as `mission-authority` does (no `mission/` prefix); `write` takes a +subject, or a registered family alone, before `/*`. A pattern is exact or a terminal `/*` +namespace covering everything under it. Parsing refuses the block in a nested or loop body, a +repeated block or rule, an empty block, an unknown verb and any other `*`. There is no `"none"` +form: a mission without the block grants its runs nothing. The block is a field of the mission +spec, kept in canonical order and absent when undeclared, so existing revisions hash unchanged and +changing it is a mission revision. Publication holds a revision that declares it until every +member advertises `mission_authority=1`, as for `capacity`. + +**Starter.** A run's starter is the actor of its `mission-run.created`: its requester, or, for a +child a program starts (§6), the parent run, `mission-run/PARENT`. The requester stays the human. +The starter's authority is evaluated when the run writes, not copied at start, so a starter that +loses authority takes it from its running runs. + +**Effective authority** of `mission-run/RUN` is the `authority` of its current generation's +revision intersected with its starter's. A run starter contributes its own effective authority, +recursively, so a child never exceeds its parent. A person, agent or daemon starter contributes all +authority in free mode, and the rules that restrict it (`rule.set` in enforce mode) decide each of +the run's writes as well. With exact-or-namespace patterns the intersection is exact: for each +pair with one verb, the narrower pattern if one covers the other, nothing otherwise. + +**Credential.** For each start or resume of an attempt the engine mints a store capability of kind +`program-run` whose subject is the step run and whose incarnation is the attempt (the secret is +hashed at rest and never journaled), and passes it as `ST_RUN_CREDENTIAL` in the `start` +message's `env`. The host spreads the run env last, so an effect cannot replace it. + +**Binding.** The ancestry walk reads `ST_RUN_CREDENTIAL` from each ancestor's exec-time environment +beside `ST_AGENT`, nearest ancestor first; one environment carrying both binds neither. The daemon +binds a request to `mission-run/RUN` only while the credential's attempt is its step run's current, +live attempt, so the credential dies with its attempt and needs no revocation; one that no longer +binds leaves the caller unverifiable. A run-bound request follows the bound-agent rules: no internal +routes, no person headers, and `actor`/`requester` must be the run. The CLI defaults its actor to +the run when the credential is present. The same-UID boundary is cooperative, as for agents. + +**Journal.** The engine writes `program.step` claims as `mission-run/RUN`; a run may always journal +on its own step runs, whatever it declares. + +**Enforcement** is one decision at the store's write admission, beside the rules gate, for every +claim whose actor is a run. The claim names the grant it needs: `mission.published` on `mission/ID` +needs `publish ID`; a run's creation and its initial generation need `start ID`; a later generation +needs `revise ID`; any other claim needs `write SUBJECT`. Setting another run's outcome takes a +person or agent actor, so a run cannot cancel one. The daemon's `[programs] authority` says what +a write outside the authority does: `enforce` refuses it with `run-authority-denied`; `audit` +lets it through and records `rule.audited` on `rule/run-authority`, so a new program can discover +the grants it needs. + ### 4. Versioning A program's revision is its bundle hash: `sha256` is part of the step definition, so it enters the From 9680d400fa56dff1c9c14039b379dde5d76a3f53 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:37:46 +0100 Subject: [PATCH 114/132] Objectives: one objective kind; shipped objectives read from slo/objectives.kdl The `objective` reader (fractal_kernel::objective) parses an objective from canonical JSON or KDL; the daemon builds its shipped objectives from slo/objectives.kdl through it, and slo/targets.toml is deleted. Design in docs/st3/objectives.md. Squash of schickling/2026-11-09-native-objectives at b78ae1c644 onto main: the original pull request (#6165, then #6282) could not be queued because GitHub refused to evaluate its merge state ("too many files changed"). The tree equals the merge of that head with main. --- .github/workflows/perf-load.ts | 2 +- .st/evidence-skip-policy.json | 4 +- .st/required-lanes.json | 4 +- .st/rust-test-inputs.json | 3 +- Cargo.lock | 2 +- crates/fractal-api-core/src/api.rs | 8 +- .../src/api/request_latency.rs | 6 +- .../fractal-api-core/src/objective_faults.rs | 2 +- crates/fractal-api-core/src/objectives.rs | 4 +- crates/fractal-delivery/src/maintenance.rs | 10 +- .../src/resource/github_workflows.rs | 2 +- crates/fractal-kernel/Cargo.toml | 1 + crates/fractal-kernel/src/declaration_kdl.rs | 109 +++ crates/fractal-kernel/src/lib.rs | 4 + crates/fractal-kernel/src/objective.rs | 732 ++++++++++++++++++ crates/fractal-kernel/src/objective_tests.rs | 94 +++ .../src/declaration_syntax.rs | 51 +- crates/fractal-store/src/graph.rs | 51 +- .../fractal-store/src/store/database_size.rs | 4 +- crates/fractal-telemetry/Cargo.toml | 1 - crates/fractal-telemetry/src/otlp/slo.rs | 4 +- crates/fractal-telemetry/src/slo.rs | 342 +++++--- crates/fractal/tests/daemon_load.rs | 16 +- crates/smallclaims/src/windows/cpu_minutes.rs | 2 +- docs/ci.md | 6 +- docs/st3/README.md | 2 + docs/st3/client-v0/observations.md | 4 +- docs/st3/gate-evidence.md | 2 +- docs/st3/large-value-sites.json | 1 + docs/st3/objectives.md | 164 ++++ docs/st3/profiling.md | 4 +- docs/st3/replication.md | 2 +- release-notes/shipped-objectives-kdl.json | 11 + scripts/ci-perf | 4 +- scripts/ci-perf-slot | 2 +- slo/objectives.kdl | 235 ++++++ slo/targets.toml | 206 ----- 37 files changed, 1660 insertions(+), 441 deletions(-) create mode 100644 crates/fractal-kernel/src/declaration_kdl.rs create mode 100644 crates/fractal-kernel/src/objective.rs create mode 100644 crates/fractal-kernel/src/objective_tests.rs create mode 100644 docs/st3/objectives.md create mode 100644 release-notes/shipped-objectives-kdl.json create mode 100644 slo/objectives.kdl delete mode 100644 slo/targets.toml diff --git a/.github/workflows/perf-load.ts b/.github/workflows/perf-load.ts index 5e72dc48a6b..e74b29fc350 100644 --- a/.github/workflows/perf-load.ts +++ b/.github/workflows/perf-load.ts @@ -106,7 +106,7 @@ python3 scripts/ci-perf-cache pack`, }, ], }, - // The same daemon beside a CPU and disk hog at seat priority, reported against slo/targets.toml. A missed + // The same daemon beside a CPU and disk hog at seat priority, reported against slo/objectives.kdl. A missed // target only reports (until ST_LOAD_HOG_STRICT=1); a failed request or a hog that never loaded the // machine fails the job, and a red daily lane alerts. It takes a second performance runner for up to 45 // minutes, so it runs once a day, not on pull requests. diff --git a/.st/evidence-skip-policy.json b/.st/evidence-skip-policy.json index 75244fe17d3..1b5e25c3928 100644 --- a/.st/evidence-skip-policy.json +++ b/.st/evidence-skip-policy.json @@ -264,7 +264,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ], @@ -500,7 +500,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ], diff --git a/.st/required-lanes.json b/.st/required-lanes.json index 8c038a42302..f3784896b86 100644 --- a/.st/required-lanes.json +++ b/.st/required-lanes.json @@ -638,7 +638,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ] @@ -845,7 +845,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ] diff --git a/.st/rust-test-inputs.json b/.st/rust-test-inputs.json index 1ab26178049..04e700374f2 100644 --- a/.st/rust-test-inputs.json +++ b/.st/rust-test-inputs.json @@ -237,7 +237,8 @@ "crates/fractal-daemon/hooks/claude-observe.sh", "crates/fractal-daemon/hooks/claude-statusline.sh", "crates/fractal-daemon/hooks/omp-channel.ts", - "crates/fractal-daemon/hooks/pi-channel.ts" + "crates/fractal-daemon/hooks/pi-channel.ts", + "slo/objectives.kdl" ] }, "fractal-migrate": { diff --git a/Cargo.lock b/Cargo.lock index d8927e4bd48..69958628352 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2153,6 +2153,7 @@ dependencies = [ "fractal-terminal-direct", "getrandom 0.3.4", "hex", + "kdl", "libc", "reqwest", "rusqlite", @@ -2590,7 +2591,6 @@ dependencies = [ "smallclaims", "tempfile", "tokio", - "toml 0.9.12+spec-1.1.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index bc24089dc22..404cb5042b7 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -1621,12 +1621,12 @@ pub fn request_latency_snapshot() -> Vec { request_latency().lock().unwrap().snapshot() } -/// Only the targets' live windows, for the collector: see `slo/targets.toml`. +/// Only the targets' live windows, for the collector: see `slo/objectives.kdl`. pub fn request_latency_target_windows() -> Value { request_latency().lock().unwrap().targets_only(Instant::now()) } -/// Every target's live windows, and every path's: see `slo/targets.toml`. +/// Every target's live windows, and every path's: see `slo/objectives.kdl`. pub fn request_latency_windows() -> Value { request_latency().lock().unwrap().windows(Instant::now()) } @@ -1636,8 +1636,8 @@ fn note_cpu_busy(busy: smallclaims::windows::cpu_minutes::Busy) { request_latency().lock().unwrap_or_else(std::sync::PoisonError::into_inner).cpu_minutes().note(busy); } -/// Close each UTC minute of the member's CPU as it ends, for the CPU targets in -/// `slo/targets.toml`: read the daemon's CPU at the boundary beside the newest reading each of +/// Close each UTC minute of the member's CPU as it ends, for the `cpu` objective in +/// `slo/objectives.kdl`: read the daemon's CPU at the boundary beside the newest reading each of /// the `workers` replication workers this member runs has sent, note a replication catch-up, and /// check the seats of the minute closed one boundary earlier, once every harness observation /// stamped inside it has committed. The first boundary is this daemon's start. diff --git a/crates/fractal-api-core/src/api/request_latency.rs b/crates/fractal-api-core/src/api/request_latency.rs index 9de20cfc29d..33c4cf44174 100644 --- a/crates/fractal-api-core/src/api/request_latency.rs +++ b/crates/fractal-api-core/src/api/request_latency.rs @@ -1,7 +1,7 @@ //! Completed response-envelope timings. Action buckets are subsets of route totals. //! -//! Each path also keeps rolling 1-minute, 5-minute and 1-hour windows, and each target in -//! `slo/targets.toml` keeps the same windows over all its paths. +//! Each path also keeps rolling 1-minute, 5-minute and 1-hour windows, and each latency objective +//! in `slo/objectives.kdl` keeps the same windows over all its paths. use std::collections::{BTreeMap, VecDeque}; use std::time::{Duration, Instant}; @@ -246,7 +246,7 @@ pub struct Meter { work_stages: BTreeMap, /// Windows by path: `GET /route`, `stream COLLECTION`, or a long poll's route. paths: BTreeMap, - /// Windows by target position in `slo/targets.toml`: served here, then from another machine. + /// Windows by target position in `slo/objectives.kdl`: served here, then from another machine. targets: Vec<[smallclaims::windows::Series; 2]>, /// The member's CPU by closed minute: the daemon's and its replication workers'. cpu: smallclaims::windows::cpu_minutes::CpuMinutes, diff --git a/crates/fractal-api-core/src/objective_faults.rs b/crates/fractal-api-core/src/objective_faults.rs index b82bea81ff6..dab1e41529d 100644 --- a/crates/fractal-api-core/src/objective_faults.rs +++ b/crates/fractal-api-core/src/objective_faults.rs @@ -1,6 +1,6 @@ //! A missed CI objective is a fault for the fleet's fault agent. //! -//! The objectives `slo/targets.toml` declares for CI (each `[[duration]]` and `[[count]]`) are +//! The objectives `slo/objectives.kdl` ships for CI (each `ci-duration` and `ci-count`) are //! read from the rows the SLO history samples once a minute. An objective whose row is missed //! opens one `operational.failure` on this member's `daemon/NODE` subject, addressed to the fleet //! fault agent and never to a person; it stays one episode until the row is met again, when an diff --git a/crates/fractal-api-core/src/objectives.rs b/crates/fractal-api-core/src/objectives.rs index ec7fb92c018..f3b8d846f93 100644 --- a/crates/fractal-api-core/src/objectives.rs +++ b/crates/fractal-api-core/src/objectives.rs @@ -2,8 +2,8 @@ //! //! An objective is a target with windows and an error budget: the daemon's latency targets, its //! store and CPU targets, the database's size and the paired phone's reported targets, all from -//! `slo/targets.toml` and the same report as `GET /v1/client/request-latency`, and CI's durations -//! and counts, which `slo/targets.toml` declares and the Workspace CI runs a repository observer +//! `slo/objectives.kdl` and the same report as `GET /v1/client/request-latency`, and CI's durations +//! and counts, which `slo/objectives.kdl` ships and the Workspace CI runs a repository observer //! read feed, held in memory on the member that observes. A budget is an //! allowance that resets: each account's weekly and 5-hour limits, from the readings `st usage` //! shows. Nothing here measures anything. It reads those sources and says, for each row, its diff --git a/crates/fractal-delivery/src/maintenance.rs b/crates/fractal-delivery/src/maintenance.rs index 3056390198b..9df0c1a79d4 100644 --- a/crates/fractal-delivery/src/maintenance.rs +++ b/crates/fractal-delivery/src/maintenance.rs @@ -18,14 +18,14 @@ const DIVERGENCE_BUSY_BACKOFF: Duration = Duration::from_secs(60); pub fn daemon_busy(store: &Store) -> bool { use smallclaims::windows::{StoreWork, store_series}; let now = Instant::now(); - let max_ms = crate::slo::targets().transaction.max_ms as f64; - let over = |kind| { + let targets = crate::slo::targets(); + let over = |kind, max_ms: u64| { let minute = &store_series(kind).snapshot(now)["1m"]; minute["over_target"].as_u64().unwrap_or(0) > 0 - || minute["max_ms"].as_f64().unwrap_or(0.0) > max_ms + || minute["max_ms"].as_f64().unwrap_or(0.0) > max_ms as f64 }; - over(StoreWork::WriterHold) - || over(StoreWork::WriteTransaction) + over(StoreWork::WriterHold, targets.writer_hold.max_ms) + || over(StoreWork::WriteTransaction, targets.transaction.max_ms) || store.replication_catching_up() || store.replication_projection_deferred() } diff --git a/crates/fractal-github-base/src/resource/github_workflows.rs b/crates/fractal-github-base/src/resource/github_workflows.rs index a01c4ae8a85..15f08b532e3 100644 --- a/crates/fractal-github-base/src/resource/github_workflows.rs +++ b/crates/fractal-github-base/src/resource/github_workflows.rs @@ -157,7 +157,7 @@ const WORKSPACE_WORKFLOW: &str = "Workspace CI"; const WORKSPACE_WORKFLOW_PATH: &str = ".github/workflows/fleet.yml"; /// A merge group's head branch: `gh-readonly-queue/BASE/pr-N-SHA`. const MERGE_GROUP_BRANCH_PREFIX: &str = "gh-readonly-queue/"; -/// The series these runs feed, as `slo/targets.toml` names them. +/// The series these runs feed, as `slo/objectives.kdl` names them. pub const CRITICAL_PATH_SERIES: &str = "ci/workspace-critical-path"; pub const MERGE_QUEUE_SERIES: &str = "ci/merge-queue"; pub const FLAKES_SERIES: &str = "ci/flakes"; diff --git a/crates/fractal-kernel/Cargo.toml b/crates/fractal-kernel/Cargo.toml index cd590be037f..a6b9d0c94bf 100644 --- a/crates/fractal-kernel/Cargo.toml +++ b/crates/fractal-kernel/Cargo.toml @@ -12,6 +12,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std" clap = { version = "4", features = ["derive", "env"] } getrandom = "0.3" hex = "0.4" +kdl = "6" libc = "0.2" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } rusqlite = { version = "0.32", features = ["bundled", "trace", "functions"] } diff --git a/crates/fractal-kernel/src/declaration_kdl.rs b/crates/fractal-kernel/src/declaration_kdl.rs new file mode 100644 index 00000000000..f2746f86175 --- /dev/null +++ b/crates/fractal-kernel/src/declaration_kdl.rs @@ -0,0 +1,109 @@ +//! The values of declaration KDL every declaration kind shares: a node as the canonical JSON +//! each declaration is stored, hashed and read as (`{name, arguments?, properties?, children?}`, +//! properties sorted by name), and durations (`300ms`, `5m`, `7d`). + +use std::collections::BTreeMap; + +use kdl::{KdlNode, KdlValue}; +use serde_json::{Map, Value}; + +use crate::model::FractalError; + +pub fn json_value(value: &KdlValue) -> Result { + Ok(match value { + KdlValue::String(value) => Value::String(value.clone()), + KdlValue::Integer(value) => { + let integer = i64::try_from(*value).map_err(|_| { + FractalError::new( + "integer-range", + "a KDL integer must fit a signed 64-bit value", + ) + })?; + Value::Number(integer.into()) + } + KdlValue::Float(value) => Value::Number( + serde_json::Number::from_f64(*value) + .ok_or_else(|| FractalError::new("invalid-number", "a float must be finite"))?, + ), + KdlValue::Bool(value) => Value::Bool(*value), + KdlValue::Null => Value::Null, + }) +} + +pub fn canonical_node(node: &KdlNode) -> Result { + let mut properties = BTreeMap::::new(); + let mut arguments = Vec::new(); + for entry in node.entries() { + let value = json_value(entry.value())?; + if let Some(name) = entry.name() { + if properties.insert(name.value().into(), value).is_some() { + return Err(FractalError::new( + "duplicate-property", + format!( + "node `{}` repeats property `{}`", + node.name().value(), + name.value() + ), + )); + } + } else { + arguments.push(value); + } + } + let children = node + .children() + .map(|document| { + document + .nodes() + .iter() + .map(canonical_node) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default(); + let mut output = Map::new(); + output.insert("name".into(), Value::String(node.name().value().into())); + if !arguments.is_empty() { + output.insert("arguments".into(), Value::Array(arguments)); + } + if !properties.is_empty() { + output.insert( + "properties".into(), + serde_json::to_value(properties).expect("BTreeMap serializes"), + ); + } + if !children.is_empty() { + output.insert("children".into(), Value::Array(children)); + } + Ok(Value::Object(output)) +} + +/// A duration in milliseconds: an integer with `ms`, `s`, `m`, `h` or `d`, seconds when bare. +pub fn parse_duration(value: &str, positive: bool) -> Result { + let (number, multiplier) = if let Some(value) = value.strip_suffix("ms") { + (value, 1) + } else if let Some(value) = value.strip_suffix('s') { + (value, 1_000) + } else if let Some(value) = value.strip_suffix('m') { + (value, 60_000) + } else if let Some(value) = value.strip_suffix('h') { + (value, 3_600_000) + } else if let Some(value) = value.strip_suffix('d') { + (value, 86_400_000) + } else { + (value, 1_000) + }; + let number = number + .parse::() + .map_err(|_| FractalError::new("invalid-duration", format!("invalid duration `{value}`")))?; + let duration = number + .checked_mul(multiplier) + .ok_or_else(|| FractalError::new("duration-range", "duration is too large"))?; + if positive && duration == 0 { + return Err(FractalError::new( + "invalid-duration", + "a required duration must be positive", + )); + } + Ok(duration) +} diff --git a/crates/fractal-kernel/src/lib.rs b/crates/fractal-kernel/src/lib.rs index eaf1517928a..0673fba7e1c 100644 --- a/crates/fractal-kernel/src/lib.rs +++ b/crates/fractal-kernel/src/lib.rs @@ -3,6 +3,10 @@ pub mod cli_output; pub mod accounts; +/// The values every declaration kind's KDL shares: canonical nodes and durations. +pub mod declaration_kdl; +/// An `objective` declaration: an indicator, its targets and the response to a miss. +pub mod objective; pub mod archive; pub mod boot; pub mod config; diff --git a/crates/fractal-kernel/src/objective.rs b/crates/fractal-kernel/src/objective.rs new file mode 100644 index 00000000000..db233efa125 --- /dev/null +++ b/crates/fractal-kernel/src/objective.rs @@ -0,0 +1,732 @@ +//! What an `objective` declaration says (docs/st3/objectives.md): an indicator that measures +//! samples, the targets that bound them, the window an interval is judged over, and who a miss +//! is delivered to. One strict reader serves the objectives Fractal ships (`slo/objectives.kdl`) +//! and the ones a fleet declares: a missing, unknown, repeated or nonsensical child is an error, +//! never a default. + +use std::collections::BTreeSet; + +use kdl::KdlDocument; +use serde_json::Value; + +use crate::declaration_kdl::{canonical_node, parse_duration}; +use crate::model::FractalError; + +/// The declaration kind and subject family. +pub const KIND: &str = "objective"; + +#[derive(Clone, Debug, PartialEq)] +pub struct Objective { + /// The objective's id, the name after `objective/`. + pub name: String, + pub about: String, + pub indicator: Indicator, + /// The span an interval's budget is measured over; native telemetry keeps its own windows. + pub window: Option, + pub targets: Vec, + pub on_breach: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Indicator { + /// Request, stream, publication or paired-phone latency over the native 1m/5m/1h windows. + Latency { paths: Vec }, + /// A phone's live foreground time over its foreground time. + Share { path: String }, + /// One SQL statement. + Statement, + /// Read and write transactions and writer holds. + Transaction, + /// Each writer hold and long read, by holder. + WriterHold, + /// The member's daemon and replication workers over closed UTC minutes. + Cpu { idle_min_minutes: u64, load_max_cores: f64 }, + /// The store's pages in use and their daily growth. + Database, + /// Open projection divergence faults. + Divergence, + /// A percentile of a CI series' durations. + CiDuration { series: String }, + /// Events in a CI series per hour. + CiCount { series: String }, + /// Work that starts and stops in claims, one sample per subject. + Interval(Box), +} + +impl Indicator { + pub fn kind(&self) -> &'static str { + match self { + Self::Latency { .. } => "latency", + Self::Share { .. } => "share", + Self::Statement => "statement", + Self::Transaction => "transaction", + Self::WriterHold => "writer-hold", + Self::Cpu { .. } => "cpu", + Self::Database => "database", + Self::Divergence => "divergence", + Self::CiDuration { .. } => "ci-duration", + Self::CiCount { .. } => "ci-count", + Self::Interval(_) => "interval", + } + } +} + +/// A claim that marks a moment of a sample, and the field its time is read from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Event { + pub kind: String, + /// A field holding an RFC 3339 time or Unix milliseconds; without one, the claim's accepted time. + pub at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Interval { + pub subject_prefix: String, + pub start: Event, + /// The members a sample must stop on, a list of strings in field `set` of the newest claim. + pub expect: Option<(Event, String)>, + /// One member stopping, named by field `member` (required with `expect`). + pub stop: (Event, Option), + pub cancel: Option, + /// The pause kind and the kind that resumes it. + pub pause: Option<(String, String)>, + /// Fields of the start or expect claim, or `member`, that split the rows. + pub labels: Vec, + pub excludes: Vec, +} + +/// Leave out a sample whose start or expect claim has one of `values` in `field`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Exclude { + pub field: String, + pub values: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Window { + pub ms: u64, + /// The samples the window needs before it is met or missed. + pub min_samples: u64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Target { + /// What the indicator reports: `p99`, `max`, `cores`, `live-gb`, ... + pub stat: String, + pub bound: Bound, + /// A latency target for reads served through this member from another that owns them. + pub remote: bool, +} + +impl Target { + /// The percentile a `pNN` target bounds. + pub fn percentile(&self) -> Option { + percentile(&self.stat) + } + + pub fn within_ms(&self) -> Option { + match self.bound { + Bound::Within(ms) => Some(ms), + _ => None, + } + } + + pub fn max(&self) -> Option { + match self.bound { + Bound::Max(max) => Some(max), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Bound { + Within(u64), + Max(f64), + MinPercent(f64), +} + +/// A miss opens a fault for `owner`; `backup` is held, recorded and never the reviewer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Breach { + pub owner: String, + pub backup: Option, + pub condition: String, +} + +impl Objective { + pub fn subject(&self) -> String { + format!("{KIND}/{}", self.name) + } + + /// The target with `stat` (and `remote`). + pub fn target(&self, stat: &str, remote: bool) -> Option<&Target> { + self.targets.iter().find(|target| target.stat == stat && target.remote == remote) + } + + /// Whether a fleet may declare it: an interval over claims, named `NAMESPACE/NAME` or deeper so + /// it never takes the name of one Fractal ships, which are one word each. Native telemetry + /// counts each path, series and reading toward the one objective the daemon was built with. + pub fn declarable(&self) -> bool { + matches!(self.indicator, Indicator::Interval(_)) && self.name.contains('/') + } +} + +/// The row of percentile `p` of objective `name`: `merge-latency/p95`. +pub fn percentile_row(name: &str, p: u64) -> String { + format!("{name}/p{p}") +} + +fn percentile(stat: &str) -> Option { + let digits = stat.strip_prefix('p')?; + (matches!(digits.len(), 1 | 2) && !digits.starts_with('0') && digits.bytes().all(|b| b.is_ascii_digit())) + .then(|| digits.parse().ok()) + .flatten() + .filter(|p| (1..=99).contains(p)) +} + +/// Lowercase words joined by `-`, as a name segment and a fault condition are. +pub fn valid_word(word: &str) -> bool { + word.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) + && word.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !word.ends_with('-') + && !word.contains("--") +} + +/// One or more words joined by `/`: `cpu`, `ops/change-rollout`. +pub fn valid_name(name: &str) -> bool { + name.len() <= 128 && name.split('/').all(valid_word) +} + +fn error(name: &str, message: impl std::fmt::Display) -> FractalError { + FractalError::new("invalid-objective", format!("objective `{name}`: {message}")) +} + +/// The objectives in a KDL document of `objective` nodes, after an optional `version 2`. +pub fn parse_document(text: &str) -> Result, FractalError> { + let document: KdlDocument = text + .parse() + .map_err(|error| FractalError::new("invalid-kdl", format!("{error}")))?; + let mut names = BTreeSet::new(); + let mut objectives = Vec::new(); + for (index, node) in document.nodes().iter().enumerate() { + let canonical = canonical_node(node)?; + if index == 0 && node.name().value() == "version" { + if canonical.get("arguments") != Some(&serde_json::json!([2])) || canonical.get("children").is_some() { + return Err(FractalError::new("invalid-version", "objectives are `version 2` KDL")); + } + continue; + } + let objective = parse(&canonical)?; + if !names.insert(objective.name.clone()) { + return Err(error(&objective.name, "is declared twice")); + } + objectives.push(objective); + } + Ok(objectives) +} + +/// An `objective` node, in its canonical JSON. +pub fn parse(node: &Value) -> Result { + if node["name"] != KIND { + return Err(FractalError::new("invalid-objective", format!("`{}` is not an objective", node["name"]))); + } + let name = match arguments(node) { + [Value::String(name)] if valid_name(name) => name.clone(), + _ => return Err(FractalError::new( + "invalid-objective", + "an objective has one name: lowercase words joined by `-`, segments by `/`", + )), + }; + let fail = |message: String| error(&name, message); + if properties(node).next().is_some() { + return Err(fail("takes no properties".into())); + } + let mut about = None; + let mut indicator = None; + let mut window = None; + let mut targets = Vec::new(); + let mut on_breach = None; + for child in children(node) { + let child_name = child["name"].as_str().unwrap_or_default(); + let once = |slot: bool| if slot { Err(fail(format!("repeats `{child_name}`"))) } else { Ok(()) }; + match child_name { + "about" => { + once(about.is_some())?; + no_children(child, &fail)?; + only_properties(child, &[], &fail)?; + about = Some(one_string(child, &fail)?.to_owned()).filter(|about| !about.trim().is_empty()); + if about.is_none() { + return Err(fail("`about` is empty".into())); + } + } + "indicator" => { + once(indicator.is_some())?; + indicator = Some(parse_indicator(child, &fail)?); + } + "window" => { + once(window.is_some())?; + no_children(child, &fail)?; + only_properties(child, &["min-samples"], &fail)?; + let ms = duration(one_string(child, &fail)?, &fail)?; + let min_samples = property(child, "min-samples") + .map(|value| positive_integer(value, "min-samples", &fail)) + .transpose()? + .unwrap_or(1); + window = Some(Window { ms, min_samples }); + } + "target" => { + no_children(child, &fail)?; + targets.push(parse_target(child, &fail)?); + } + "on-breach" => { + once(on_breach.is_some())?; + on_breach = Some(parse_breach(child, &fail)?); + } + other => return Err(fail(format!("has an unknown child `{other}`"))), + } + } + let objective = Objective { + about: about.ok_or_else(|| fail("has no `about`".into()))?, + indicator: indicator.ok_or_else(|| fail("has no `indicator`".into()))?, + name: name.clone(), + window, + targets, + on_breach, + }; + check_targets(&objective, &fail)?; + Ok(objective) +} + +fn parse_indicator(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result { + only_properties(node, &[], fail)?; + let kind = one_string(node, fail)?; + let kids = children(node).collect::>(); + let allow = |names: &[&str]| -> Result<(), FractalError> { + match kids.iter().find(|kid| !names.contains(&kid["name"].as_str().unwrap_or_default())) { + Some(kid) => Err(fail(format!("indicator `{kind}` has an unknown child `{}`", kid["name"].as_str().unwrap_or_default()))), + None => Ok(()), + } + }; + let strings = |child: &str| -> Result, FractalError> { + kids.iter() + .filter(|kid| kid["name"] == child) + .map(|kid| { + no_children(kid, fail)?; + only_properties(kid, &[], fail)?; + Ok(one_string(kid, fail)?.to_owned()) + }) + .collect() + }; + let single = |child: &str| -> Result { + match strings(child)?.as_slice() { + [value] => Ok(value.clone()), + _ => Err(fail(format!("indicator `{kind}` needs one `{child}`"))), + } + }; + Ok(match kind { + "latency" => { + allow(&["path"])?; + let paths = strings("path")?; + if paths.is_empty() { + return Err(fail("a latency indicator needs a `path`".into())); + } + Indicator::Latency { paths } + } + "share" => { + allow(&["path"])?; + Indicator::Share { path: single("path")? } + } + "statement" | "transaction" | "writer-hold" | "database" | "divergence" => { + allow(&[])?; + match kind { + "statement" => Indicator::Statement, + "transaction" => Indicator::Transaction, + "writer-hold" => Indicator::WriterHold, + "database" => Indicator::Database, + _ => Indicator::Divergence, + } + } + "cpu" => { + allow(&["idle-min-minutes", "load-max-cores"])?; + let number = |child: &str| -> Result<&Value, FractalError> { + let mut found = kids.iter().filter(|kid| kid["name"] == child); + match (found.next(), found.next()) { + (Some(kid), None) => { + no_children(kid, fail)?; + only_properties(kid, &[], fail)?; + match arguments(kid) { + [value] => Ok(value), + _ => Err(fail(format!("`{child}` needs one number"))), + } + } + _ => Err(fail(format!("a cpu indicator needs one `{child}`"))), + } + }; + Indicator::Cpu { + idle_min_minutes: positive_integer(number("idle-min-minutes")?, "idle-min-minutes", fail)?, + load_max_cores: positive_number(number("load-max-cores")?, "load-max-cores", fail)?, + } + } + "ci-duration" | "ci-count" => { + allow(&["series"])?; + let series = single("series")?; + if !is_ci_series(&series) { + return Err(fail(format!("series {series:?} is not `ci/` and lowercase words"))); + } + if kind == "ci-duration" { Indicator::CiDuration { series } } else { Indicator::CiCount { series } } + } + "interval" => Indicator::Interval(Box::new(parse_interval(&kids, fail)?)), + other => return Err(fail(format!("has an unknown indicator `{other}`"))), + }) +} + +fn parse_interval(kids: &[&Value], fail: &dyn Fn(String) -> FractalError) -> Result { + let mut prefix = None; + let mut start = None; + let mut expect = None; + let mut stop = None; + let mut cancel = None; + let mut pause = None; + let mut labels = Vec::new(); + let mut excludes = Vec::new(); + for kid in kids { + let name = kid["name"].as_str().unwrap_or_default(); + no_children(kid, fail)?; + let repeated = || fail(format!("the interval repeats `{name}`")); + let event = |extra: &[&str]| -> Result { + let mut allowed = vec!["at"]; + allowed.extend_from_slice(extra); + only_properties(kid, &allowed, fail)?; + let kind = one_string(kid, fail)?; + if kind.is_empty() { + return Err(fail(format!("`{name}` names no claim kind"))); + } + Ok(Event { kind: kind.to_owned(), at: field_property(kid, "at", fail)? }) + }; + match name { + "subject-prefix" => { + only_properties(kid, &[], fail)?; + let value = one_string(kid, fail)?; + if !value.ends_with('/') || value.len() < 2 || value.starts_with('/') { + return Err(fail(format!("subject-prefix {value:?} must name subjects and end with `/`"))); + } + if prefix.replace(value.to_owned()).is_some() { + return Err(repeated()); + } + } + "start" => { + if start.replace(event(&[])?).is_some() { + return Err(repeated()); + } + } + "expect" => { + let event = event(&["set"])?; + let set = field_property(kid, "set", fail)?.ok_or_else(|| fail("`expect` needs `set=FIELD`".into()))?; + if expect.replace((event, set)).is_some() { + return Err(repeated()); + } + } + "stop" => { + let event = event(&["member"])?; + if stop.replace((event, field_property(kid, "member", fail)?)).is_some() { + return Err(repeated()); + } + } + "cancel" => { + only_properties(kid, &[], fail)?; + if cancel.replace(one_string(kid, fail)?.to_owned()).is_some() { + return Err(repeated()); + } + } + "pause" => { + only_properties(kid, &["resume"], fail)?; + let resume = property(kid, "resume") + .and_then(Value::as_str) + .filter(|resume| !resume.is_empty()) + .ok_or_else(|| fail("`pause` needs `resume=KIND`".into()))?; + if pause.replace((one_string(kid, fail)?.to_owned(), resume.to_owned())).is_some() { + return Err(repeated()); + } + } + "label" => { + only_properties(kid, &[], fail)?; + let label = one_string(kid, fail)?; + if !is_field(label) || labels.contains(&label.to_owned()) { + return Err(fail(format!("label {label:?} is not a field, or is repeated"))); + } + labels.push(label.to_owned()); + } + "exclude" => { + only_properties(kid, &[], fail)?; + let values = arguments(kid) + .iter() + .map(|value| value.as_str().filter(|value| !value.is_empty()).map(str::to_owned)) + .collect::>>() + .filter(|values| values.len() >= 2 && is_field(&values[0])) + .ok_or_else(|| fail("`exclude` needs a field and at least one value".into()))?; + excludes.push(Exclude { field: values[0].clone(), values: values[1..].to_vec() }); + } + other => return Err(fail(format!("indicator `interval` has an unknown child `{other}`"))), + } + } + let stop = stop.ok_or_else(|| fail("an interval needs `stop`".into()))?; + if expect.is_some() && stop.1.is_none() { + return Err(fail("with `expect`, `stop` needs `member=FIELD`".into())); + } + if labels.iter().any(|label| label == "member") && stop.1.is_none() { + return Err(fail("`label \"member\"` needs `stop member=FIELD`".into())); + } + let interval = Interval { + subject_prefix: prefix.ok_or_else(|| fail("an interval needs `subject-prefix`".into()))?, + start: start.ok_or_else(|| fail("an interval needs `start`".into()))?, + expect, + stop, + cancel, + pause, + labels, + excludes, + }; + let mut kinds = BTreeSet::new(); + for kind in interval.kinds() { + if !kinds.insert(kind) { + return Err(fail(format!("claim kind {kind:?} has two roles"))); + } + } + Ok(interval) +} + +impl Interval { + /// Every claim kind the interval reads, each with one role. + pub fn kinds(&self) -> Vec<&str> { + let mut kinds = vec![self.start.kind.as_str()]; + kinds.extend(self.expect.as_ref().map(|(event, _)| event.kind.as_str())); + kinds.push(self.stop.0.kind.as_str()); + kinds.extend(self.cancel.as_deref()); + if let Some((pause, resume)) = &self.pause { + kinds.extend([pause.as_str(), resume.as_str()]); + } + kinds + } +} + +fn parse_target(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result { + only_properties(node, &["within", "max", "min-percent", "scope"], fail)?; + let stat = one_string(node, fail)?.to_owned(); + let bounds = ["within", "max", "min-percent"] + .into_iter() + .filter_map(|name| property(node, name).map(|value| (name, value))) + .collect::>(); + let bound = match bounds.as_slice() { + [("within", value)] => Bound::Within(duration( + value.as_str().ok_or_else(|| fail(format!("target `{stat}`'s `within` is not a duration")))?, + fail, + )?), + [("max", value)] => Bound::Max(number(value, &format!("target `{stat}`'s `max`"), fail)?), + [("min-percent", value)] => { + let min = number(value, &format!("target `{stat}`'s `min-percent`"), fail)?; + if !(min > 0.0 && min <= 100.0) { + return Err(fail(format!("target `{stat}`'s `min-percent` is not between 0 and 100"))); + } + Bound::MinPercent(min) + } + _ => return Err(fail(format!("target `{stat}` needs one bound: `within`, `max` or `min-percent`"))), + }; + let remote = match property(node, "scope") { + None => false, + Some(Value::String(scope)) if scope == "remote" => true, + Some(other) => return Err(fail(format!("target `{stat}` has an unknown scope {other}"))), + }; + Ok(Target { stat, bound, remote }) +} + +fn parse_breach(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result { + no_children(node, fail)?; + only_properties(node, &["owner", "backup", "condition"], fail)?; + if one_string(node, fail)? != "fault" { + return Err(fail("`on-breach` is `fault`, the only response".into())); + } + let string = |name: &str| property(node, name).map(|value| { + value.as_str().map(str::to_owned).ok_or_else(|| fail(format!("`on-breach`'s `{name}` is not a string"))) + }).transpose(); + let agent = |value: &str| value.strip_prefix("agent/").is_some_and(|id| !id.is_empty()); + let owner = string("owner")?.filter(|owner| agent(owner)).ok_or_else(|| fail("`on-breach` needs `owner=\"agent/...\"`".into()))?; + let backup = string("backup")?; + if backup.as_deref().is_some_and(|backup| !agent(backup) || backup == owner) { + return Err(fail("`on-breach`'s `backup` is an agent other than the owner".into())); + } + let condition = string("condition")? + .filter(|condition| valid_word(condition)) + .ok_or_else(|| fail("`on-breach` needs `condition=NAME`, lowercase words joined by `-`".into()))?; + Ok(Breach { owner, backup, condition }) +} + +/// Each indicator's targets: which stats it reports, which bounds they take and how many. +fn check_targets(objective: &Objective, fail: &dyn Fn(String) -> FractalError) -> Result<(), FractalError> { + let mut seen = BTreeSet::new(); + for target in &objective.targets { + if !seen.insert((target.stat.as_str(), target.remote)) { + return Err(fail(format!("repeats target `{}`", target.stat))); + } + if target.remote && !matches!(objective.indicator, Indicator::Latency { .. }) { + return Err(fail("only a latency target has a remote scope".into())); + } + } + let kind = objective.indicator.kind(); + let stat_bound = |target: &Target| -> bool { + let within = matches!(target.bound, Bound::Within(ms) if ms > 0); + let max = |integer: bool, zero: bool| match target.bound { + Bound::Max(max) => max.is_finite() && (max > 0.0 || (zero && max == 0.0)) && (!integer || max.fract() == 0.0), + _ => false, + }; + match (&objective.indicator, target.stat.as_str()) { + (Indicator::Latency { .. }, "p99") => within, + (Indicator::Share { .. }, "live-share") => matches!(target.bound, Bound::MinPercent(_)), + (Indicator::Statement, "p99" | "max") => within, + (Indicator::Transaction | Indicator::WriterHold, "max") => within, + (Indicator::Cpu { .. }, "cores" | "idle-cores") => max(false, false), + (Indicator::Database, "live-gb" | "growth-mb-per-day") => max(false, false), + (Indicator::Divergence, "open") => max(true, true), + (Indicator::CiCount { .. }, "per-hour") => max(true, true), + (Indicator::CiDuration { .. } | Indicator::Interval(_), stat) => percentile(stat).is_some() && within, + _ => false, + } + }; + if let Some(target) = objective.targets.iter().find(|target| !stat_bound(target)) { + return Err(fail(format!("a `{kind}` indicator has no target `{}` with that bound", target.stat))); + } + let required: &[&str] = match objective.indicator { + Indicator::Latency { .. } => &["p99"], + Indicator::Statement => &["p99", "max"], + Indicator::Share { .. } => &["live-share"], + Indicator::Transaction | Indicator::WriterHold => &["max"], + Indicator::Cpu { .. } => &["cores", "idle-cores"], + Indicator::Database => &["live-gb", "growth-mb-per-day"], + Indicator::Divergence => &["open"], + Indicator::CiCount { .. } => &["per-hour"], + _ => &[], + }; + if let Some(missing) = required.iter().find(|stat| objective.target(stat, false).is_none()) { + return Err(fail(format!("a `{kind}` indicator needs target `{missing}`"))); + } + if objective.targets.is_empty() { + return Err(fail("has no `target`".into())); + } + if let (Some(local), Some(remote)) = (objective.target("p99", false), objective.target("p99", true)) + && remote.within_ms() < local.within_ms() + { + return Err(fail("its remote latency is below its own".into())); + } + if let (Some(p99), Some(max)) = (objective.target("p99", false), objective.target("max", false)) + && matches!(objective.indicator, Indicator::Statement) + && max.within_ms() < p99.within_ms() + { + return Err(fail("its max is below its p99".into())); + } + if let (Some(cores), Some(idle)) = (objective.target("cores", false), objective.target("idle-cores", false)) + && idle.max() > cores.max() + { + return Err(fail("its idle CPU target is above the member's".into())); + } + if let Indicator::Cpu { idle_min_minutes, .. } = objective.indicator + && idle_min_minutes > 60 + { + return Err(fail("its idle minimum is not between 1 and 60 minutes".into())); + } + match (&objective.indicator, objective.window) { + (Indicator::Interval(_), None) => return Err(fail("an interval needs a `window`".into())), + (Indicator::Interval(_), Some(_)) => {} + (_, Some(_)) => return Err(fail("only an interval takes a `window`; native telemetry keeps its own".into())), + (_, None) => {} + } + if objective.on_breach.is_some() && !matches!(objective.indicator, Indicator::Interval(_)) { + return Err(fail("only an interval takes `on-breach`; native objectives respond as they are built".into())); + } + Ok(()) +} + +fn is_ci_series(path: &str) -> bool { + path.strip_prefix("ci/").is_some_and(|rest| { + rest.split('/').all(|part| { + !part.is_empty() && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }) + }) +} + +fn is_field(name: &str) -> bool { + !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +fn arguments(node: &Value) -> &[Value] { + node.get("arguments").and_then(Value::as_array).map_or(&[], Vec::as_slice) +} + +fn properties(node: &Value) -> impl Iterator { + node.get("properties").and_then(Value::as_object).into_iter().flatten() +} + +fn property<'a>(node: &'a Value, name: &str) -> Option<&'a Value> { + node.get("properties")?.get(name) +} + +fn children(node: &Value) -> impl Iterator { + node.get("children").and_then(Value::as_array).into_iter().flatten() +} + +fn no_children(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result<(), FractalError> { + match node.get("children") { + None => Ok(()), + Some(_) => Err(fail(format!("`{}` takes no children", node["name"].as_str().unwrap_or_default()))), + } +} + +fn only_properties(node: &Value, allowed: &[&str], fail: &dyn Fn(String) -> FractalError) -> Result<(), FractalError> { + match properties(node).find(|(name, _)| !allowed.contains(&name.as_str())) { + Some((name, _)) => Err(fail(format!( + "`{}` has an unknown property `{name}`", + node["name"].as_str().unwrap_or_default() + ))), + None => Ok(()), + } +} + +fn one_string<'a>(node: &'a Value, fail: &dyn Fn(String) -> FractalError) -> Result<&'a str, FractalError> { + match arguments(node) { + [Value::String(value)] => Ok(value), + _ => Err(fail(format!("`{}` needs one string", node["name"].as_str().unwrap_or_default()))), + } +} + +fn field_property(node: &Value, name: &str, fail: &dyn Fn(String) -> FractalError) -> Result, FractalError> { + property(node, name) + .map(|value| { + value + .as_str() + .filter(|field| is_field(field)) + .map(str::to_owned) + .ok_or_else(|| fail(format!("`{name}` is not a field name"))) + }) + .transpose() +} + +fn duration(value: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + parse_duration(value, true).map_err(|error| fail(error.message)) +} + +fn number(value: &Value, what: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + value.as_f64().filter(|number| number.is_finite()).ok_or_else(|| fail(format!("{what} is not a number"))) +} + +fn positive_number(value: &Value, what: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + number(value, what, fail).and_then(|number| { + if number > 0.0 { Ok(number) } else { Err(fail(format!("{what} is not positive"))) } + }) +} + +fn positive_integer(value: &Value, what: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + value.as_u64().filter(|n| *n > 0).ok_or_else(|| fail(format!("{what} is not a positive integer"))) +} + +#[cfg(test)] +#[path = "objective_tests.rs"] +mod tests; diff --git a/crates/fractal-kernel/src/objective_tests.rs b/crates/fractal-kernel/src/objective_tests.rs new file mode 100644 index 00000000000..6bb2504565f --- /dev/null +++ b/crates/fractal-kernel/src/objective_tests.rs @@ -0,0 +1,94 @@ +use super::*; + +const SHIPPED: &str = /* sqlite-large-value: allow-for-now lv-ha01213f3c4b5 owner=fractal_kernel::objective_tests TODO=trace-statement-and-value-representation */ include_str!("../../../slo/objectives.kdl"); + +fn one(text: &str) -> Result { + parse_document(&format!("version 2\n{text}")).map(|mut objectives| objectives.remove(0)) +} + +const INTERVAL: &str = r#"objective "ops/change-rollout" { + about "A change, from ready to served by every member" + indicator "interval" { + subject-prefix "custom/acme/change/" + start "custom.acme.change-ready" at="ready_at" + expect "custom.acme.change-merged" set="hosts" at="merged_at" + stop "custom.acme.change-applied" member="host" at="applied_at" + cancel "custom.acme.change-abandoned" + pause "custom.acme.change-paused" resume="custom.acme.change-resumed" + label "class" + label "member" + exclude "exclusion" "deferred-launch" "deferred-runtime" + } + window "7d" min-samples=20 + target "p50" within="60s" + target "p95" within="5m" + on-breach "fault" owner="agent/ops/release-manager" backup="agent/ops/observability" condition="change-rollout-slo-missed" +}"#; + +#[test] +fn the_shipped_objectives_parse_and_none_is_declarable() { + let objectives = parse_document(SHIPPED).unwrap(); + assert_eq!(objectives.len(), 21); + assert!(objectives.iter().all(|objective| !objective.declarable())); + let merge = objectives.iter().find(|objective| objective.name == "merge-latency").unwrap(); + assert_eq!(merge.indicator, Indicator::CiDuration { series: "ci/merge-queue".into() }); + assert_eq!( + merge.targets.iter().map(|target| (target.percentile(), target.within_ms())).collect::>(), + [(Some(50), Some(600_000)), (Some(95), Some(600_000))], + ); + let read = objectives.iter().find(|objective| objective.name == "person-read").unwrap(); + assert_eq!(read.target("p99", true).and_then(Target::within_ms), Some(300)); +} + +#[test] +fn an_interval_reads_every_role() { + let objective = one(INTERVAL).unwrap(); + assert!(objective.declarable()); + assert_eq!(objective.window, Some(Window { ms: 7 * 86_400_000, min_samples: 20 })); + let Indicator::Interval(interval) = &objective.indicator else { panic!() }; + assert_eq!(interval.subject_prefix, "custom/acme/change/"); + assert_eq!(interval.start, Event { kind: "custom.acme.change-ready".into(), at: Some("ready_at".into()) }); + assert_eq!(interval.expect.as_ref().map(|(_, set)| set.as_str()), Some("hosts")); + assert_eq!(interval.stop.1.as_deref(), Some("host")); + assert_eq!(interval.labels, ["class", "member"]); + assert_eq!(interval.excludes, [Exclude { field: "exclusion".into(), values: vec!["deferred-launch".into(), "deferred-runtime".into()] }]); + assert_eq!(interval.kinds().len(), 6); + assert_eq!( + objective.on_breach, + Some(Breach { + owner: "agent/ops/release-manager".into(), + backup: Some("agent/ops/observability".into()), + condition: "change-rollout-slo-missed".into(), + }) + ); + assert_eq!(percentile_row(&objective.name, 95), "ops/change-rollout/p95"); +} + +#[test] +fn a_nonsensical_objective_is_refused_never_defaulted() { + let refused = |text: &str| one(text).unwrap_err().message; + let interval = |replace: &str, with: &str| INTERVAL.replace(replace, with); + for (text, says) in [ + (interval(" window \"7d\" min-samples=20\n", ""), "needs a `window`"), + (interval("target \"p95\" within=\"5m\"", "target \"p95\" within=\"5m\"\n target \"p95\" within=\"9m\""), "repeats target"), + (interval("target \"p95\"", "target \"p100\""), "no target `p100`"), + (interval("target \"p95\"", "target \"p05\""), "no target `p05`"), + (interval(" member=\"host\"", ""), "needs `member=FIELD`"), + (interval("cancel \"custom.acme.change-abandoned\"", "cancel \"custom.acme.change-ready\""), "two roles"), + (interval("backup=\"agent/ops/observability\"", "backup=\"agent/ops/release-manager\""), "other than the owner"), + (interval("owner=\"agent/ops/release-manager\"", "owner=\"person/ada\""), "needs `owner="), + (interval("subject-prefix \"custom/acme/change/\"", "subject-prefix \"custom/acme/change\""), "end with `/`"), + (interval(" label \"class\"\n", " label \"class\"\n color \"red\"\n"), "unknown child `color`"), + (interval("about \"A change, from ready to served by every member\"", "about \" \""), "`about` is empty"), + ("objective \"cpu\" { about \"x\"; indicator \"cpu\" { idle-min-minutes 10; load-max-cores 2.0 }; target \"cores\" max=1.0 }".into(), "needs target `idle-cores`"), + ("objective \"db\" { about \"x\"; indicator \"database\"; target \"live-gb\" max=1.0; target \"growth-mb-per-day\" max=1; window \"1d\" }".into(), "only an interval takes a `window`"), + ("objective \"r\" { about \"x\"; indicator \"latency\" { path \"GET /v1/x\" }; target \"p99\" within=\"300ms\"; target \"p99\" within=\"200ms\" scope=\"remote\" }".into(), "remote latency is below"), + ("objective \"Bad\" { about \"x\" }".into(), "lowercase words"), + ("objective \"ops//x\" { about \"x\" }".into(), "lowercase words"), + ] { + let message = refused(&text); + assert!(message.contains(says), "{says:?} not in {message:?}"); + } + assert!(parse_document(&format!("version 2\n{INTERVAL}\n{INTERVAL}")).unwrap_err().message.contains("declared twice")); + assert!(!one(&INTERVAL.replace("ops/change-rollout", "change-rollout")).unwrap().declarable()); +} diff --git a/crates/fractal-missions-base/src/declaration_syntax.rs b/crates/fractal-missions-base/src/declaration_syntax.rs index 48d5140cd7c..39592e50f44 100644 --- a/crates/fractal-missions-base/src/declaration_syntax.rs +++ b/crates/fractal-missions-base/src/declaration_syntax.rs @@ -4,7 +4,6 @@ use std::collections::{BTreeMap, HashSet}; use kdl::{KdlDocument, KdlNode, KdlValue}; -use serde_json::Value; use sha2::{Digest as _, Sha256}; use crate::model::FractalError; @@ -204,26 +203,7 @@ pub fn first_string(node: &KdlNode) -> Result { .and_then(|value| value_string(value)) } -pub fn json_value(value: &KdlValue) -> Result { - Ok(match value { - KdlValue::String(value) => Value::String(value.clone()), - KdlValue::Integer(value) => { - let integer = i64::try_from(*value).map_err(|_| { - FractalError::new( - "integer-range", - "a KDL integer must fit a signed 64-bit value", - ) - })?; - Value::Number(integer.into()) - } - KdlValue::Float(value) => Value::Number( - serde_json::Number::from_f64(*value) - .ok_or_else(|| FractalError::new("invalid-number", "a float must be finite"))?, - ), - KdlValue::Bool(value) => Value::Bool(*value), - KdlValue::Null => Value::Null, - }) -} +pub use crate::declaration_kdl::{json_value, parse_duration}; pub fn one_string(node: &KdlNode) -> Result { ensure_no_children(node)?; @@ -247,35 +227,6 @@ pub fn one_string_with_children(node: &KdlNode) -> Result }) } -pub fn parse_duration(value: &str, positive: bool) -> Result { - let (number, multiplier) = if let Some(value) = value.strip_suffix("ms") { - (value, 1) - } else if let Some(value) = value.strip_suffix('s') { - (value, 1_000) - } else if let Some(value) = value.strip_suffix('m') { - (value, 60_000) - } else if let Some(value) = value.strip_suffix('h') { - (value, 3_600_000) - } else if let Some(value) = value.strip_suffix('d') { - (value, 86_400_000) - } else { - (value, 1_000) - }; - let number = number - .parse::() - .map_err(|_| FractalError::new("invalid-duration", format!("invalid duration `{value}`")))?; - let duration = number - .checked_mul(multiplier) - .ok_or_else(|| FractalError::new("duration-range", "duration is too large"))?; - if positive && duration == 0 { - return Err(FractalError::new( - "invalid-duration", - "a required duration must be positive", - )); - } - Ok(duration) -} - pub fn parse_map_child( document: &KdlDocument, name: &str, diff --git a/crates/fractal-store/src/graph.rs b/crates/fractal-store/src/graph.rs index 1d96f20fc65..65f5d1d97bb 100644 --- a/crates/fractal-store/src/graph.rs +++ b/crates/fractal-store/src/graph.rs @@ -1,10 +1,11 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use kdl::{KdlDocument, KdlNode, KdlValue}; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; use crate::declaration_syntax::*; +use crate::declaration_kdl::canonical_node; pub use crate::declaration_syntax::parse_duration; #[cfg(test)] use crate::model::GateSpec; @@ -4428,54 +4429,6 @@ fn insert_subject(context: &mut ParseContext, subject: DesiredSubject) -> Result Ok(()) } -fn canonical_node(node: &KdlNode) -> Result { - let mut properties = BTreeMap::::new(); - let mut arguments = Vec::new(); - for entry in node.entries() { - let value = json_value(entry.value())?; - if let Some(name) = entry.name() { - if properties.insert(name.value().into(), value).is_some() { - return Err(FractalError::new( - "duplicate-property", - format!( - "node `{}` repeats property `{}`", - node.name().value(), - name.value() - ), - )); - } - } else { - arguments.push(value); - } - } - let children = node - .children() - .map(|document| { - document - .nodes() - .iter() - .map(canonical_node) - .collect::, _>>() - }) - .transpose()? - .unwrap_or_default(); - let mut output = Map::new(); - output.insert("name".into(), Value::String(node.name().value().into())); - if !arguments.is_empty() { - output.insert("arguments".into(), Value::Array(arguments)); - } - if !properties.is_empty() { - output.insert( - "properties".into(), - serde_json::to_value(properties).expect("BTreeMap serializes"), - ); - } - if !children.is_empty() { - output.insert("children".into(), Value::Array(children)); - } - Ok(Value::Object(output)) -} - fn hash_json(value: &Value) -> String { let bytes = serde_json::to_vec(value).expect("normalized JSON serializes"); hex::encode(Sha256::digest(bytes)) diff --git a/crates/fractal-store/src/store/database_size.rs b/crates/fractal-store/src/store/database_size.rs index a976bbe6f6f..9146b173646 100644 --- a/crates/fractal-store/src/store/database_size.rs +++ b/crates/fractal-store/src/store/database_size.rs @@ -1,5 +1,5 @@ -//! How big this member's store is, and how much it grows a day: the database target in -//! `slo/targets.toml`, and the physical growth that disk-filling conditions read. The daemon +//! How big this member's store is, and how much it grows a day: the database objective in +//! `slo/objectives.kdl`, and the physical growth that disk-filling conditions read. The daemon //! samples the store hourly and keeps two days of samples in `meta`, so the daily growth survives //! restarts. A sample reads three pragmas and the WAL file's length. It is the one sampler: the //! newest sample is cached for every reader (`fractal_daemon::slo::database_size`). diff --git a/crates/fractal-telemetry/Cargo.toml b/crates/fractal-telemetry/Cargo.toml index 5ff2eff06ac..b8ca4a498aa 100644 --- a/crates/fractal-telemetry/Cargo.toml +++ b/crates/fractal-telemetry/Cargo.toml @@ -30,7 +30,6 @@ fractal-kernel = { path = "../fractal-kernel" } fractal-runtime = { path = "../fractal-runtime" } tempfile = "3" tokio = { version = "1", features = ["full"] } -toml = "0.9" tracing = "0.1" tracing-opentelemetry = "0.31" tracing-subscriber = { version = "0.3", default-features = false, features = ["ansi", "env-filter", "fmt", "registry", "std"] } diff --git a/crates/fractal-telemetry/src/otlp/slo.rs b/crates/fractal-telemetry/src/otlp/slo.rs index 2cdf09ff9cf..87a312e942d 100644 --- a/crates/fractal-telemetry/src/otlp/slo.rs +++ b/crates/fractal-telemetry/src/otlp/slo.rs @@ -5,7 +5,7 @@ //! collector holds the history. Nothing here runs on a request path; once a minute a task reads //! the same report as `GET /v1/client/request-latency` and sends it to `[observations.otlp]`. //! -//! Every point carries bounded labels, all from `slo/targets.toml` or fixed words: `target`, the +//! Every point carries bounded labels, all from `slo/objectives.kdl` or fixed words: `target`, the //! `series` inside it (`owner` or `remote` for a read target, `reported` for a latency a paired //! phone reported, `statement`, `read`, `write` or `writer_hold` for the store), the `window` //! (`1m`, `5m`, `1h`) and, for the latency gauge, the `stat` (`p50`, `p99`, `max`). The target @@ -154,7 +154,7 @@ pub fn metrics(node: &str, report: &Value, now_unix_ms: u64) -> Option { gauge( "st_slo_target_ms", "ms", - "The target in slo/targets.toml for a series: its 99th percentile, or its maximum", + "The target in slo/objectives.kdl for a series: its 99th percentile, or its maximum", targets, ), gauge( diff --git a/crates/fractal-telemetry/src/slo.rs b/crates/fractal-telemetry/src/slo.rs index c11ed2e4894..40d6f9887f8 100644 --- a/crates/fractal-telemetry/src/slo.rs +++ b/crates/fractal-telemetry/src/slo.rs @@ -1,24 +1,27 @@ -//! The daemon's targets, from `slo/targets.toml`, which is built in. The same file is what -//! `st doctor` and CI's daemon_load read. A target describes what we aim for; it gates nothing. +//! The daemon's targets, from the objectives it ships in `slo/objectives.kdl`, which is built in +//! and read by the same reader as a declared objective (`fractal_kernel::objective`). The same +//! file is what `st doctor` and CI's daemon_load read. A target describes what we aim for; it +//! gates nothing. -use std::sync::OnceLock; +use std::sync::{LazyLock, OnceLock}; use std::time::Duration; -use anyhow::{Context, Result, ensure}; -use serde::{Deserialize, Serialize}; +use anyhow::{Context, Result, anyhow, bail, ensure}; +use fractal_kernel::objective::{self, Bound, Indicator, Objective}; +use serde::Serialize; use serde_json::{Value, json}; use smallclaims::windows::StoreWork; -/// The targets file this daemon was built with. -pub const SOURCE: &str = include_str!("../../../slo/targets.toml"); +/// The objectives file this daemon was built with. +pub const SOURCE: &str = include_str!("../../../slo/objectives.kdl"); -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Targets { pub latency: Vec, pub share: Vec, pub statement: Statement, pub transaction: Transaction, + pub writer_hold: WriterHold, pub cpu: Cpu, pub divergence: Divergence, pub database: Database, @@ -26,8 +29,7 @@ pub struct Targets { pub count: Vec, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Latency { pub name: String, pub about: String, @@ -37,8 +39,7 @@ pub struct Latency { pub paths: Vec, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Share { pub name: String, pub about: String, @@ -46,25 +47,30 @@ pub struct Share { pub path: String, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Statement { pub about: String, pub p99_ms: u64, pub max_ms: u64, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Transaction { pub about: String, pub max_ms: u64, } +/// Each hold of the store's single writer, and each read transaction past the same bound, by who +/// held it. +#[derive(Debug)] +pub struct WriterHold { + pub about: String, + pub max_ms: u64, +} + /// The member's CPU: its daemon process and every replication worker process beside it, over /// closed UTC minutes. See `smallclaims::windows::cpu_minutes` for what makes a minute idle. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Cpu { pub about: String, /// The most cores the member may use over the hour. @@ -79,15 +85,13 @@ pub struct Cpu { /// Projection divergences, counted fleet-wide from their open faults. A projection is a pure /// function of the claims, so each one is a bug. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Divergence { pub about: String, pub max_open: u64, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Database { pub about: String, /// The pages in use, not counting free pages SQLite reuses before the file grows. @@ -98,8 +102,7 @@ pub struct Database { /// One percentile of a duration observed outside the daemon, such as a CI run: the objective /// `NAME/pPERCENTILE`. A name may declare several percentiles, each its own objective. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct DurationTarget { pub name: String, pub about: String, @@ -119,8 +122,7 @@ impl DurationTarget { /// Events observed outside the daemon, such as flaky CI results, counted per hour: the /// objective `NAME`. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct CountTarget { pub name: String, pub about: String, @@ -150,10 +152,142 @@ impl Latency { } } -/// Read and check a targets file. A missing, unknown or nonsensical field is an error, never a -/// default. +/// Read and check an objectives file of the objectives Fractal ships. A missing, unknown or +/// nonsensical child is an error, never a default. pub fn parse(text: &str) -> Result { - let targets: Targets = toml::from_str(text).context("the targets file is not valid")?; + read(text).map(|(_, targets)| targets) +} + +fn read(text: &str) -> Result<(Vec, Targets)> { + let objectives = + objective::parse_document(text).map_err(|error| anyhow!("the objectives file is not valid: {error}"))?; + let targets = build(&objectives)?; + check(&targets)?; + Ok((objectives, targets)) +} + +/// Put `value` in the one slot its indicator has, named `expected`: `fractal doctor` and the OTLP +/// export read these by name. +fn one(slot: &mut Option, indicator: &str, expected: &str, name: &str, value: T) -> Result<()> { + ensure!(name == expected, "the {indicator} objective is named {name}, not {expected}"); + ensure!(slot.is_none(), "there are two {indicator} objectives"); + *slot = Some(value); + Ok(()) +} + +/// The targets the shipped objectives say. Every one is a single segment; an interval is only +/// ever declared. +fn build(objectives: &[Objective]) -> Result { + let mut latency = Vec::new(); + let mut share = Vec::new(); + let mut statement = None; + let mut transaction = None; + let mut writer_hold = None; + let mut cpu = None; + let mut divergence = None; + let mut database = None; + let mut duration = Vec::new(); + let mut count = Vec::new(); + for objective in objectives { + let name = objective.name.as_str(); + ensure!(!name.contains('/'), "objective {name} is not one segment: only a declared objective has a namespace"); + let about = objective.about.clone(); + let within = |stat: &str, remote: bool| objective.target(stat, remote).and_then(objective::Target::within_ms); + let required = |stat: &str| within(stat, false).with_context(|| format!("objective {name} has no {stat} duration")); + let max = |stat: &str| { + objective.target(stat, false).and_then(objective::Target::max).with_context(|| format!("objective {name} has no {stat} maximum")) + }; + let whole = |value: f64, what: &str| -> Result { + ensure!(value >= 0.0 && value.fract() == 0.0, "objective {name}'s {what} is not a whole number"); + Ok(value as u64) + }; + match &objective.indicator { + Indicator::Latency { paths } => latency.push(Latency { + name: name.to_owned(), + about, + p99_ms: required("p99")?, + remote_p99_ms: within("p99", true), + paths: paths.clone(), + }), + Indicator::Share { path } => { + let min_percent = match objective.target("live-share", false).map(|target| target.bound) { + Some(Bound::MinPercent(min)) => whole(min, "minimum percent")?, + _ => bail!("objective {name} has no live-share minimum"), + }; + share.push(Share { name: name.to_owned(), about, min_percent, path: path.clone() }); + } + Indicator::Statement => { + let value = Statement { about, p99_ms: required("p99")?, max_ms: required("max")? }; + one(&mut statement, "statement", "sql-statement", name, value)?; + } + Indicator::Transaction => { + let value = Transaction { about, max_ms: required("max")? }; + one(&mut transaction, "transaction", "transaction", name, value)?; + } + Indicator::WriterHold => { + let value = WriterHold { about, max_ms: required("max")? }; + one(&mut writer_hold, "writer-hold", "writer-hold", name, value)?; + } + Indicator::Cpu { idle_min_minutes, load_max_cores } => { + let value = Cpu { + about, + max_cores: max("cores")?, + idle_max_cores: max("idle-cores")?, + idle_min_minutes: *idle_min_minutes, + load_max_cores: *load_max_cores, + }; + one(&mut cpu, "cpu", "cpu", name, value)?; + } + Indicator::Database => { + let value = Database { + about, + max_live_gb: max("live-gb")?, + max_growth_mb_per_day: max("growth-mb-per-day")?, + }; + one(&mut database, "database", "database", name, value)?; + } + Indicator::Divergence => { + let value = Divergence { about, max_open: whole(max("open")?, "open maximum")? }; + one(&mut divergence, "divergence", "projection-divergence", name, value)?; + } + Indicator::CiDuration { series } => { + for target in &objective.targets { + duration.push(DurationTarget { + name: name.to_owned(), + about: about.clone(), + percentile: target.percentile().with_context(|| format!("objective {name}'s target {} is no percentile", target.stat))?, + target_ms: target.within_ms().with_context(|| format!("objective {name}'s target {} has no duration", target.stat))?, + path: series.clone(), + }); + } + } + Indicator::CiCount { series } => count.push(CountTarget { + name: name.to_owned(), + about, + max_per_hour: whole(max("per-hour")?, "per-hour maximum")?, + path: series.clone(), + }), + Indicator::Interval(_) => { + bail!("objective {name} is an interval: Fractal ships none; a fleet declares one with `fractal apply`") + } + } + } + let missing = |indicator: &str| format!("there is no {indicator} objective"); + Ok(Targets { + latency, + share, + statement: statement.with_context(|| missing("statement"))?, + transaction: transaction.with_context(|| missing("transaction"))?, + writer_hold: writer_hold.with_context(|| missing("writer-hold"))?, + cpu: cpu.with_context(|| missing("cpu"))?, + divergence: divergence.with_context(|| missing("divergence"))?, + database: database.with_context(|| missing("database"))?, + duration, + count, + }) +} + +fn check(targets: &Targets) -> Result<()> { let mut names = std::collections::BTreeSet::new(); let mut paths = std::collections::BTreeSet::new(); for latency in &targets.latency { @@ -201,6 +335,7 @@ pub fn parse(text: &str) -> Result { "the statement targets are inconsistent" ); ensure!(targets.transaction.max_ms > 0, "the transaction target is zero"); + ensure!(targets.writer_hold.max_ms > 0, "the writer-hold target is zero"); let cpu = &targets.cpu; ensure!( [cpu.max_cores, cpu.idle_max_cores, cpu.load_max_cores] @@ -245,13 +380,22 @@ pub fn parse(text: &str) -> Result { count.path ); } - Ok(targets) + Ok(()) +} + +/// The shipped objectives and the targets they say. The file is checked by a test, so this cannot +/// fail in a release. +static BUILT_IN: LazyLock<(Vec, Targets)> = + LazyLock::new(|| read(SOURCE).expect("the built-in objectives file is valid")); + +/// The objectives Fractal ships, as the objective reader reads them. +pub fn shipped() -> &'static [Objective] { + &BUILT_IN.0 } -/// The built-in targets. The file is checked by a test, so this cannot fail in a release. +/// The built-in targets. pub fn targets() -> &'static Targets { - static TARGETS: OnceLock = OnceLock::new(); - TARGETS.get_or_init(|| parse(SOURCE).expect("the built-in targets file is valid")) + &BUILT_IN.1 } impl Targets { @@ -274,6 +418,10 @@ impl Targets { pub fn transaction_target(&self) -> Duration { Duration::from_millis(self.transaction.max_ms) } + + pub fn writer_hold_target(&self) -> Duration { + Duration::from_millis(self.writer_hold.max_ms) + } } /// Give the store's windows their targets, once per process. @@ -285,13 +433,10 @@ pub fn install() { StoreWork::Statement, Some(targets.statement_target()), ); - for work in [ - StoreWork::ReadTransaction, - StoreWork::WriteTransaction, - StoreWork::WriterHold, - ] { + for work in [StoreWork::ReadTransaction, StoreWork::WriteTransaction] { smallclaims::windows::set_store_target(work, Some(targets.transaction_target())); } + smallclaims::windows::set_store_target(StoreWork::WriterHold, Some(targets.writer_hold_target())); }); } @@ -486,18 +631,18 @@ const OPEN_READS: usize = 8; /// Routes whose read loans are reported beside the writer's holds; the rest are summed. const LOAN_ROUTES: usize = 8; -/// The writer-hold objective: each hold of the store's single writer against the transaction -/// target, and who held it; each read transaction past that target, and who kept it open. +/// The writer-hold objective: each hold of the store's single writer against its target, and who +/// held it; each read transaction past that target, and who kept it open. fn writer_hold_row() -> Value { let targets = targets(); let series = |work| smallclaims::windows::store_series(work).snapshot(std::time::Instant::now()); let mut row = json!({ "name": "writer-hold", - "about": "Each hold of the store's single writer, and each read transaction past the same target, by who held it: a request route or background task, its client, and the code that asked", - "max_ms": targets.transaction.max_ms, + "about": targets.writer_hold.about, + "max_ms": targets.writer_hold.max_ms, "windows": series(StoreWork::WriterHold), "read_windows": series(StoreWork::ReadTransaction), - "open_reads": smallclaims::sqlite::open_snapshots_over(targets.transaction_target(), OPEN_READS), + "open_reads": smallclaims::sqlite::open_snapshots_over(targets.writer_hold_target(), OPEN_READS), // Every read loan by the route it works for, checked out or with a confirmed snapshot: // a loan held past its budget keeps the WAL from recycling even before it reads. "read_loans": smallclaims::sqlite::loans_by_route(LOAN_ROUTES), @@ -777,8 +922,10 @@ mod tests { use super::*; #[test] - fn the_built_in_targets_file_is_valid_and_names_the_mission_targets() { + fn the_built_in_objectives_file_is_valid_and_names_the_mission_targets() { let targets = parse(SOURCE).unwrap(); + assert_eq!(shipped().len(), 21); + assert!(shipped().iter().all(|objective| !objective.declarable())); let read = targets.for_path("GET /v1/client/agents").unwrap(); assert_eq!((read.p99_ms, read.remote_p99_ms), (300, Some(300))); assert_eq!(read.target(true), Duration::from_millis(300)); @@ -819,7 +966,7 @@ mod tests { let attach = targets.for_path("stream terminal").unwrap(); assert_eq!(attach.p99_ms, 200); assert_eq!((targets.statement.p99_ms, targets.statement.max_ms), (10, 100)); - assert_eq!(targets.transaction.max_ms, 300); + assert_eq!((targets.transaction.max_ms, targets.writer_hold.max_ms), (300, 300)); assert_eq!( (targets.cpu.max_cores, targets.cpu.idle_max_cores, targets.cpu.idle_min_minutes, targets.cpu.load_max_cores), (1.0, 0.5, 10, 2.0) @@ -846,7 +993,7 @@ mod tests { } #[test] - fn every_route_path_in_the_targets_file_is_a_route_the_daemon_serves() { + fn every_route_path_in_the_objectives_file_is_a_route_the_daemon_serves() { // api.rs's route table and the route families the daemon adds to it (fractal-daemon's `api::router`). let source = [ include_str!("../../fractal-api-core/src/api.rs"), @@ -929,7 +1076,7 @@ mod tests { install(); let rows = store_report(json!({})); let row = rows.iter().find(|row| row["name"] == "writer-hold").unwrap(); - assert_eq!(row["max_ms"], targets().transaction.max_ms); + assert_eq!(row["max_ms"], targets().writer_hold.max_ms); for field in ["windows", "read_windows", "open_reads", "holders", "long_holds", "long_reads", "in_progress", "waiting"] { assert!(row.get(field).is_some(), "{field} missing from {row}"); } @@ -942,61 +1089,82 @@ mod tests { assert!(row["windows"]["1h"].is_object()); } + /// The shipped file without objective `name`. + fn without(name: &str) -> String { + let start = SOURCE.find(&format!("\nobjective \"{name}\" {{")).unwrap() + 1; + let end = start + SOURCE[start..].find("\n}\n").unwrap() + 3; + format!("{}{}", &SOURCE[..start], &SOURCE[end..]) + } + + fn error(text: &str) -> String { + parse(text).unwrap_err().to_string() + } + #[test] - fn a_broken_targets_file_is_refused_not_defaulted() { - let without_cpu = SOURCE.split("[cpu]").next().unwrap(); - assert!(parse(without_cpu).is_err()); + fn a_broken_objectives_file_is_refused_not_defaulted() { + for name in ["sql-statement", "transaction", "writer-hold", "cpu", "database", "projection-divergence"] { + assert!(error(&without(name)).contains("there is no"), "{name}"); + } for (from, to) in [ - ("\nmax_cores = 1.0", "\nmax_cores = -1.0"), - ("\nmax_cores = 1.0", "\nmax_cores = 0.4"), - ("idle_max_cores = 0.5", "idle_max_cores = 0.0"), - ("idle_max_cores = 0.5", "idle_max_cores = nan"), - ("load_max_cores = 2.0", "load_max_cores = inf"), - ("idle_min_minutes = 10", "idle_min_minutes = 0"), - ("idle_min_minutes = 10", "idle_min_minutes = 61"), - ("idle_min_minutes = 10", "idle_min_minutes = 10.5"), - ("load_max_cores = 2.0\n", ""), - ("idle_max_cores = 0.5\n", ""), + ("target \"cores\" max=1", "target \"cores\" max=-1.0"), + ("target \"cores\" max=1", "target \"cores\" max=0.4"), + ("target \"idle-cores\" max=0.5", "target \"idle-cores\" max=0.0"), + ("target \"idle-cores\" max=0.5", "target \"idle-cores\" max=#nan"), + ("load-max-cores 2", "load-max-cores #inf"), + ("idle-min-minutes 10", "idle-min-minutes 0"), + ("idle-min-minutes 10", "idle-min-minutes 61"), + ("idle-min-minutes 10", "idle-min-minutes 10.5"), + (" load-max-cores 2\n", ""), + (" target \"idle-cores\" max=0.5\n", ""), + ("target \"live-gb\" max=8", "target \"live-gb\" max=0.0"), + (" indicator \"statement\"\n", " indicator \"statement\"\n typo 1\n"), + ("target \"live-share\" min-percent=99", "target \"live-share\" min-percent=99.5"), + ("target \"open\" max=0", "target \"open\" max=0.5"), ] { assert!(SOURCE.contains(from), "{from}"); assert!(parse(&SOURCE.replace(from, to)).is_err(), "{to}"); } - assert!(parse(SOURCE.split("[database]").next().unwrap()).is_err()); - assert!(parse(&SOURCE.replace("max_live_gb = 8.0", "max_live_gb = 0.0")).is_err()); - assert!(parse(&SOURCE.replace("p99_ms = 10\n", "p99_ms = 10\ntypo = 1\n")).is_err()); let twice = format!( - "{SOURCE}\n[[latency]]\nname = \"again\"\nabout = \"\"\np99_ms = 1\npaths = [\"GET /v1/client/now\"]\n" + "{SOURCE}\nobjective \"again\" {{\n about \"x\"\n indicator \"latency\" {{\n path \"GET /v1/client/now\"\n }}\n target \"p99\" within=\"1ms\"\n}}\n" ); - assert!(parse(&twice).unwrap_err().to_string().contains("two targets")); + assert!(error(&twice).contains("two targets")); + // Each statement, transaction, writer-hold, CPU, database and divergence objective is + // the one of its name; the shipped ones are one segment, and none is an interval. + assert!(error(&SOURCE.replace("objective \"database\"", "objective \"store-size\"")).contains("named store-size, not database")); + assert!(error(&SOURCE.replace("objective \"flakes\"", "objective \"ci/flakes\"")).contains("not one segment")); + let interval = format!( + "{SOURCE}\nobjective \"change-rollout\" {{\n about \"x\"\n indicator \"interval\" {{\n subject-prefix \"custom/acme/change/\"\n start \"custom.acme.change-ready\"\n stop \"custom.acme.change-applied\"\n }}\n window \"7d\"\n target \"p95\" within=\"5m\"\n}}\n" + ); + assert!(error(&interval).contains("is an interval"), "{}", error(&interval)); } #[test] fn ci_durations_and_counts_are_checked() { - let error = |text: &str| parse(text).unwrap_err().to_string(); - let p95 = "percentile = 95\ntarget_ms = 600000\npath = \"ci/merge-queue\""; - assert!(parse(SOURCE.split("[[count]]").next().unwrap()).is_err(), "the count kind is required"); - for percentile in ["0", "100", "250"] { - let text = SOURCE.replacen("percentile = 95", &format!("percentile = {percentile}"), 1); - assert!(error(&text).contains("between 1 and 99"), "{percentile}"); + for percentile in ["p0", "p100", "p250", "p-5"] { + let text = SOURCE.replacen("target \"p95\" within=\"13m\"", &format!("target \"{percentile}\" within=\"13m\""), 1); + assert!(error(&text).contains("no target"), "{percentile}"); + } + assert!(parse(&SOURCE.replacen("target \"p95\" within=\"13m\"", "target \"p95\" within=\"0ms\"", 1)).is_err()); + for to in ["max=-1", "max=0.5", "max=0 min-per-hour=0"] { + assert!(parse(&SOURCE.replace("target \"per-hour\" max=0", &format!("target \"per-hour\" {to}"))).is_err(), "{to}"); + } + for series in ["GET /v1/flakes", "ci/Flakes", "ci/"] { + assert!(error(&SOURCE.replace("\"ci/flakes\"", &format!("\"{series}\""))).contains("is not `ci/`"), "{series}"); } - assert!(parse(&SOURCE.replacen("percentile = 95", "percentile = -5", 1)).is_err()); - assert!(error(&SOURCE.replacen("target_ms = 780000", "target_ms = 0", 1)).contains("no duration")); - assert!(parse(&SOURCE.replace("max_per_hour = 0", "max_per_hour = -1")).is_err()); - assert!(parse(&SOURCE.replace("max_per_hour = 0", "max_per_hour = 0.5")).is_err()); - assert!(parse(&SOURCE.replace("max_per_hour = 0", "max_per_hour = 0\nmin_per_hour = 0")).is_err()); - assert!(error(&SOURCE.replace("\"ci/flakes\"", "\"GET /v1/flakes\"")).contains("not a CI series")); - assert!(error(&SOURCE.replace("\"ci/flakes\"", "\"ci/Flakes\"")).contains("not a CI series")); - assert!(error(&SOURCE.replace("\"ci/flakes\"", "\"ci/\"")).contains("not a CI series")); - // The same percentile twice is one objective named twice. - let again = format!("{SOURCE}\n[[duration]]\nname = \"merge-latency\"\nabout = \"\"\n{p95}\n"); - assert!(error(&again).contains("named twice")); + // The same percentile twice is one target repeated. + let again = SOURCE.replace(" target \"p95\" within=\"10m\"\n", " target \"p95\" within=\"10m\"\n target \"p95\" within=\"9m\"\n"); + assert!(error(&again).contains("repeats target")); // Another name may not take a series that feeds one already. - let other = format!("{SOURCE}\n[[duration]]\nname = \"other\"\nabout = \"\"\n{p95}\n"); + let other = format!( + "{SOURCE}\nobjective \"other\" {{\n about \"x\"\n indicator \"ci-duration\" {{\n series \"ci/merge-queue\"\n }}\n target \"p95\" within=\"10m\"\n}}\n" + ); assert!(error(&other).contains("two targets")); - let count = format!("{SOURCE}\n[[count]]\nname = \"other\"\nabout = \"\"\nmax_per_hour = 1\npath = \"ci/merge-queue\"\n"); + let count = format!( + "{SOURCE}\nobjective \"other\" {{\n about \"x\"\n indicator \"ci-count\" {{\n series \"ci/merge-queue\"\n }}\n target \"per-hour\" max=1\n}}\n" + ); assert!(error(&count).contains("two targets")); - let named = SOURCE.replace("name = \"flakes\"", "name = \"write-ack\""); - assert!(error(&named).contains("named twice")); + let named = SOURCE.replace("objective \"flakes\"", "objective \"write-ack\""); + assert!(error(&named).contains("declared twice")); } #[test] diff --git a/crates/fractal/tests/daemon_load.rs b/crates/fractal/tests/daemon_load.rs index 97cba643bf7..1af65c48215 100644 --- a/crates/fractal/tests/daemon_load.rs +++ b/crates/fractal/tests/daemon_load.rs @@ -78,8 +78,8 @@ const BASELINE_RUNS: usize = 5; /// The daemon's CPU, in cores, may differ from the baseline by this much whatever the ratio. const CPU_SLACK: f64 = 0.05; -/// The daemon's average CPU over the run may not pass this many cores: `load_max_cores` in -/// `slo/targets.toml`, built into st3, the bound under this synthetic load rather than the +/// The daemon's average CPU over the run may not pass this many cores: the `cpu` objective's +/// `load-max-cores` in `slo/objectives.kdl`, built into st3, the bound under this synthetic load rather than the /// member's production target. A missing file fails the build and an invalid one fails the run; /// there is no default. fn cpu_budget() -> f64 { @@ -348,7 +348,7 @@ struct PathReport { p99_ms: f64, max_ms: f64, budget_ms: f64, - /// The path's p99 target in `slo/targets.toml`, when it has one. It describes; the run's + /// The path's p99 target in `slo/objectives.kdl`, when it has one. It describes; the run's /// gates are `budget_ms` and main's baseline, not this. #[serde(default, skip_serializing_if = "Option::is_none")] descriptive_target_ms: Option, @@ -439,7 +439,7 @@ const HOG_MEASURES: &[&str] = &["person-read", "person-read-fresh", "write-ack", /// Runs the daemon twice on the same generated store, once alone and once beside a CPU and disk /// hog at the priority seats get by default, with the load average held above the core count. /// It reports person-read, person-read-fresh, write-ack and message send against the targets in -/// `slo/targets.toml` (what `GET /v1/client/request-latency` reports) for each run. +/// `slo/objectives.kdl` (what `GET /v1/client/request-latency` reports) for each run. /// /// ```sh /// ST_LOAD_GATE=1 ST_LOAD_HOG=1 ST_LOAD_HOG_REPORT=out TMPDIR=/var/tmp cargo test --release \ @@ -1273,7 +1273,7 @@ fn targets_come_from_the_targets_file_and_describe_without_gating() { ] { let target = targets .for_path(path) - .unwrap_or_else(|| panic!("{path} has no target in slo/targets.toml")); + .unwrap_or_else(|| panic!("{path} has no target in slo/objectives.kdl")); assert_eq!(descriptive_target(name), Some(target.target(false)), "{name}"); } for name in ["claim", "replication exchange", LONG_POLL] { @@ -1479,7 +1479,7 @@ fn print(report: &Report) { name, path.count, path.p50_ms, path.p99_ms, path.max_ms, path.budget_ms, target ); } - println!("target: slo/targets.toml's p99, described and never gated"); + println!("target: slo/objectives.kdl's p99, described and never gated"); if !report.targets.is_empty() { println!("{:<20} {:>7} {:>8} {:>8} {:>8} {:>8}", "target", "n", "p50 ms", "p99 ms", "max ms", "target"); for (name, target) in &report.targets { @@ -2948,7 +2948,7 @@ fn runtime_of(seat: &str) -> String { format!("load-{}-runtime", seat.rsplit('/').next().unwrap_or(seat)) } -/// The path in `slo/targets.toml` a request stands for, when it stands for one. Lease renewal +/// The path in `slo/objectives.kdl` a request stands for, when it stands for one. Lease renewal /// calls the store directly; it is timed as the work action it is. fn target_path(name: &str) -> Option { match name { @@ -3003,7 +3003,7 @@ fn target_rows(timings: &BTreeMap>) -> BTreeMap Option { let path = target_path(name)?; Some(fractal_daemon::slo::targets().for_path(&path)?.target(false)) diff --git a/crates/smallclaims/src/windows/cpu_minutes.rs b/crates/smallclaims/src/windows/cpu_minutes.rs index 2af590f86db..f8779b95c36 100644 --- a/crates/smallclaims/src/windows/cpu_minutes.rs +++ b/crates/smallclaims/src/windows/cpu_minutes.rs @@ -1,4 +1,4 @@ -//! The member's CPU by closed UTC minute, for the CPU targets in `slo/targets.toml`: the daemon's +//! The member's CPU by closed UTC minute, for the `cpu` objective in `slo/objectives.kdl`: the daemon's //! own process CPU and each replication worker's, with what kept each minute from being idle. //! //! The daemon reads its own CPU at each minute's boundary. A replication worker sends its diff --git a/docs/ci.md b/docs/ci.md index c8f09ded951..a5569288b77 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1294,8 +1294,8 @@ those sparse p99s are effectively observed maxima. This bounded tolerance still regressions on rare paths. CPU tolerates 0.05 cores. A PR without a main baseline fails as P0; a main bootstrap may check only absolute budgets and errors. -The CPU budget (2 cores, `load_max_cores`) comes from [`slo/targets.toml`](../slo/targets.toml), which Fractal builds in. It is -the bound under this synthetic load, not the member's production CPU target (`max_cores`). The file is built in: +The CPU budget (2 cores, the `cpu` objective's `load-max-cores`) comes from [`slo/objectives.kdl`](../slo/objectives.kdl), which Fractal builds in. It is +the bound under this synthetic load, not the member's production CPU target (`cores`). The file is built in: a missing file fails the build and an invalid one fails the run, with no default. The report and step summary also show each path's p99 target from that file (`descriptive_target_ms`, `over_descriptive_target`) when the path stands for one. Targets describe and never gate: the budgets and main's baseline remain the gates. @@ -1362,7 +1362,7 @@ not past it at the start, at its lowest 5-second sample or at the end. Both runs add a faster measured mix: person reads and message sends at 2 a second and a fresh agents list at 1 a second, so each of the four measures has a p99 worth reading. The report pools -every path a target in [`slo/targets.toml`](../slo/targets.toml) covers, the targets that +every path a target in [`slo/objectives.kdl`](../slo/objectives.kdl) covers, the targets that `GET /v1/client/request-latency` serves: `person-read` (300 ms p99), `person-read-fresh` (300 ms), `write-ack` (300 ms) and `message-send` (a send against the write-ack target), each with and without the hog, and prints them as `summary.md`. A request that fails fails the run; a missed target does diff --git a/docs/st3/README.md b/docs/st3/README.md index c80ea7a625e..3201ceb28fd 100644 --- a/docs/st3/README.md +++ b/docs/st3/README.md @@ -97,6 +97,8 @@ Use these documents for implementation details: - [Gate evidence](gate-evidence.md) explains signed test and review records, local checks and CI verification. - [Profiling the daemon](profiling.md) explains how the daemon accounts for its own time: waits for the writer and read connections, SQLite statements, CPU, and callers. +- [Objectives](objectives.md) explains the `objective` kind: the objectives Fractal ships in + `slo/objectives.kdl`, the ones a fleet declares, their indicators, targets and breach response. - [What a run waits on](waiting-on.md) explains `fractal missions waiting-on RUN` and `fractal missions blocked`: each open step's wait, a run's stalled verdict, and the fleet's blocked runs by cause, read in milliseconds from published state. diff --git a/docs/st3/client-v0/observations.md b/docs/st3/client-v0/observations.md index 8a6719c15e9..f5d3936523b 100644 --- a/docs/st3/client-v0/observations.md +++ b/docs/st3/client-v0/observations.md @@ -62,10 +62,10 @@ Live share has no histogram, max or latency count. Foreground is 1–60,000 ms a (`foreground_ms - live_ms`) forms over-target. It aims for at least 99% live foreground time. Neither background nor unreported/disconnected phone time is inferred. -The six latency targets in `slo/targets.toml` are `ios-open-to-live` (3000 ms), +The six latency objectives in `slo/objectives.kdl` are `ios-open-to-live` (3000 ms), `ios-connect` (1500), `ios-message-ack` (500), `ios-conversation-open` (1000), `ios-terminal-open` (1500) and `ios-recover` (10000). `ios-live-share` is a separate share -entry at 99%. Client paths are `client/ios/NAME`, with carrier/path populations reported +objective at 99%. Client paths are `client/ios/NAME`, with carrier/path populations reported separately from daemon response envelopes. The existing request-latency reader and `slo/NAME` doctor lines show them; no new reader is required. diff --git a/docs/st3/gate-evidence.md b/docs/st3/gate-evidence.md index 914e6356b39..0c905dc8313 100644 --- a/docs/st3/gate-evidence.md +++ b/docs/st3/gate-evidence.md @@ -1254,7 +1254,7 @@ done, the pull requests still refused on the old key can be lifted in either of members of the aggregate read: the whole of `.github` (workflows, the clippy and fleet-compat baselines) and `scripts` (the stage scripts and their tests), `fixtures` and `templates`. A pull request that changes none of those paths (documentation outside `docs/st3` and the retained `docs/vrs` migration reference, `apps`, `packages`, `release-notes`, `AGENTS.md`, `README*`) has the same key as a tree -on which the whole gate passed. `slo` (`include_str!` of `slo/targets.toml` into the Fractal crate), `evals` (the example and graph tests read `evals/fractal`), +on which the whole gate passed. `slo` (`include_str!` of `slo/objectives.kdl` into the Fractal crate), `evals` (the example and graph tests read `evals/fractal`), `claude-channel` and `plugins` (the plugin files `include_bytes!`d into fractal-drivers) and `docs/st3` (`crates/fractal/tests/invariants.rs` includes `docs/st3/invariants.md`) are declared because Rust sources open them; a committed-policy test fails when any literal `include_str!`, `include_bytes!` or `include!` path in the Rust sources resolves outside the declared inputs. `release-notes` is deliberately not declared: the Rust sources only name `release-notes` as an diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 1f14b74d731..50f927d33a6 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -3501,6 +3501,7 @@ {"id": "lv-h9fe75ab6969f", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "5650a756ab06b458f8cec93155e560e26236256f1a42304a160aaee0e9f35d99", "owner": "smallclaims::store::receive_writer_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 438, "excerpt": "INSERT INTO checkpoint_envelopes VALUES(?1,?2,?3,?4,'checkpoint/test')"}, {"id": "lv-h9fe9d50b5675", "path": "crates/smallclaims/src/store.rs", "kind": "sql-template", "fingerprint": "c2b3267891be1c5288ab67ef77b10dea76d97ea534255953cccd90889ec18aa8", "owner": "smallclaims::store", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 4501, "excerpt": "INSERT INTO replica_records( record_ref, writer, sequence, envelope_hash, position, raw, state, error_code, error_message, updated_at_unix_ms ) VALUES (?1, ?2, ?3, ?4, 0, ?5, 'invalid', ?6, ?7, ?8) ON"}, {"id": "lv-ha00b1a8a0c42", "path": "crates/fractal-store/src/store/retirements.rs", "kind": "unresolved-sql-sink", "fingerprint": "77abb748296f8d6588bb377e43060276c4b87198064f076635a1ebdf01ae0d6a", "owner": "fractal_store::store::retirements", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 153, "excerpt": "params ! [ recipient , claim . id ] , | row | row . get ( 0 )"}, + {"id": "lv-ha01213f3c4b5", "path": "crates/fractal-kernel/src/objective_tests.rs", "kind": "included-source", "fingerprint": "ffb22acfbbe448358e6dc69c839f176fdf8f9f010e44552da1930ecef54322c4", "owner": "fractal_kernel::objective_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 3, "excerpt": "include_str ! ( \"../../../slo/objectives.kdl\" )"}, {"id": "lv-ha013271629ac", "path": "crates/fractal-github/src/changes/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "bb3beb90db6d03b3fab4ef5029cccc3de43c7be1d9cde0dbf6e2e2ec075bb0af", "owner": "fractal_github::changes::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 288, "excerpt": "fixture . record . clone ( )"}, {"id": "lv-ha017db4fd2e3", "path": "crates/smallclaims/src/store/inventory_generation.rs", "kind": "sql-template", "fingerprint": "2ef160e13f1bd806c0e0cad2a0756638563f1a687c249c8b9725c9c753fa2e4c", "owner": "smallclaims::store::inventory_generation", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 96, "excerpt": "INSERT INTO meta(key,value) VALUES(?1,'1') ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER)+1"}, {"id": "lv-ha019ff5d6de8", "path": "crates/smallclaims/src/ivm/install.rs", "kind": "sql-template", "fingerprint": "89c588db4d094aa63643b605232e1c67071c36b68dc2df42d02c962c1bbb387a", "owner": "smallclaims::ivm::install", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 322, "excerpt": "UPDATE ivm_install_jobs SET phase='stopped',error=?2 WHERE source=?1 AND phase IN ('scan','catchup')"}, diff --git a/docs/st3/objectives.md b/docs/st3/objectives.md new file mode 100644 index 00000000000..db2da559a46 --- /dev/null +++ b/docs/st3/objectives.md @@ -0,0 +1,164 @@ +# Objectives + +An objective is a target for something Fractal measures, with an error budget and an optional +response when it is missed. There is one concept and one KDL kind, `objective`. Fractal ships its +own objectives (request latency, the store, CPU, CI) and a fleet declares more with `fractal apply`, +like agents, missions and accounts. Both are read the same way: `fractal slo ls`, `fractal slo +explain ID` and `fractal slo chart ID`. + +## The `objective` kind + +```kdl +version 2 + +objective "ops/change-rollout" { + about "A change, from its author marking it ready to every member that serves it" + indicator "interval" { + subject-prefix "custom/acme/change/" + start "custom.acme.change-ready" at="ready_at" + expect "custom.acme.change-merged" set="hosts" at="merged_at" + stop "custom.acme.change-applied" member="host" at="applied_at" + cancel "custom.acme.change-abandoned" + label "class" + exclude "exclusion" "deferred-launch" "deferred-runtime" "deferred-workspace" + } + window "7d" min-samples=20 + target "p50" within="60s" + target "p95" within="5m" + on-breach "fault" owner="agent/ops/release-manager" backup="agent/ops/observability" condition="change-rollout-slo-missed" +} +``` + +- The name is the objective's id and its subject is `objective/NAME`. It is lowercase words joined + by `-`, in segments joined by `/`. A declared objective is named `NAMESPACE/NAME` or deeper, like + an agent; the objectives Fractal ships are one segment each, so the two never collide. +- `about` says what is measured, in one sentence. +- `indicator` says how a sample is measured. Each kind is below. +- `target STAT BOUND` bounds one statistic. `STAT` is what the indicator reports (`p50`, `p99`, + `max`, `cores`, ...) and the bound is `within=DURATION`, `max=NUMBER` or `min-percent=NUMBER`. + Each indicator names its rows (table below). A percentile over claims or CI is the row + `NAME/pNN` (`ops/change-rollout/p95`), one per target. +- `window` is the span the budget is measured over, and `min-samples` the samples it needs before it + is met or missed; with fewer it is `no-data`. Only an indicator over claims takes a window. + Native telemetry keeps its own windows, below. +- `on-breach` is what happens when a row is missed. It is optional; without it a miss is shown and + nothing is delivered. + +A missing, unknown, repeated or nonsensical child is refused, never defaulted. An objective is a root +declaration; it cannot sit inside a mission. + +## Indicators + +### Native telemetry + +Fractal measures these itself, in memory, on every member. They are the objectives Fractal ships, in +[`slo/objectives.kdl`](../../slo/objectives.kdl), which is generated from +[`slo/objectives.kdl.genie.ts`](../../slo/objectives.kdl.genie.ts). A fleet cannot declare them: +each path, store series and CPU reading counts toward exactly one objective, and that is the one +the daemon was built with. + +| Indicator | Measures | Targets | Rows | Windows | +|---|---|---|---|---| +| `latency` | Each `path`: a daemon route (`GET /v1/client/agents`), a stream's first snapshot (`stream agents`), a refresher's publication (`publish missions`) or a paired phone's report (`client/ios/connect`) | `p99 within=`; again with `scope="remote"` for the same reads served from another member | `NAME`, `NAME/remote` | 1m, 5m, 1h; budget over 1h | +| `share` | A phone's live foreground time over its foreground time, at `path` | `live-share min-percent=` | `NAME` | 1m, 5m, 1h | +| `statement` | One SQL statement | `p99 within=`, `max within=` | `NAME` (the p99) | 1m, 5m, 1h | +| `transaction` | Read and write transactions and writer holds | `max within=` | `NAME/read`, `NAME/write`, `NAME/writer_hold` | 1m, 5m, 1h | +| `writer-hold` | Each writer hold and long read, by holder | `max within=` | `NAME` | 1m, 5m, 1h | +| `cpu` | The daemon and its replication workers over closed UTC minutes; children `idle-min-minutes` and `load-max-cores` | `cores max=`, `idle-cores max=` | `NAME`, `NAME/idle` | the hour | +| `database` | The store's pages in use and their daily growth | `live-gb max=`, `growth-mb-per-day max=` | `NAME` | the newest hourly measurement | +| `divergence` | Open projection divergence faults | `open max=` | `NAME`, once a divergence check has run | now | +| `ci-duration` | A CI `series` (`ci/merge-queue`) read by the repository observer | `pNN within=` | `NAME/pNN` | 1d | +| `ci-count` | Events in a CI `series` | `per-hour max=` | `NAME` | 1h | + +The statement, transaction, writer-hold, CPU, database and divergence objectives are one each, +named as the table's rows say (`sql-statement`, `transaction`, `writer-hold`, `cpu`, `database`, +`projection-divergence`): `fractal doctor` and the OTLP export read them by these names. + +These keep the meanings they had as built-in targets: a p99 is missed in a window when more than +1% of its samples are over, a max when any one is, and CI rows are no-data on a member that reads no +Workspace CI runs. + +### Interval over claims + +`interval` measures work that starts and stops in different processes and on different members, +from claims those processes write. Each subject under `subject-prefix` is one sample. + +| Child | Meaning | +|---|---| +| `subject-prefix "P"` | The sample subjects: every subject that starts with `P`. Custom claims (`custom/NS/...`) need no registration and replicate to every member. | +| `start KIND at=FIELD` | The sample starts at the earliest such claim. | +| `expect KIND set=FIELD` | The members a sample must stop on, a list of strings in the newest such claim. An empty list stops the sample at the expect claim's time. Without `expect`, one stop claim ends the sample. | +| `stop KIND member=FIELD` | One member has stopped: the earliest such claim per value of `FIELD`. The sample stops when every expected member has, at the latest of their times. | +| `cancel KIND` | An open sample with such a claim is cancelled. A sample that stopped first keeps its value. | +| `pause KIND resume=KIND` | The clock stops from each pause to the next resume, and a sample still paused is not aging. | +| `label "FIELD"` | Also one row per value of `FIELD` on the start or expect claim: `ops/change-rollout/p95{class=routine}`. `label "member"` splits by stop member, each sample's value being that member's stop. | +| `exclude "FIELD" "A" "B"` | A sample whose start or expect claim has one of these values is excluded. | + +`at=FIELD` reads a time from the claim's fields: an RFC 3339 string or Unix milliseconds. Without +`at`, the time is when the claim's origin accepted it, which every member sees the same. + +A sample is **closed** when it stopped, **open** while it has started and not stopped or been +cancelled, **cancelled**, or **excluded**. A closed sample belongs to the window by its stop time. + +**Open samples are censored, not dropped.** For each target, an open sample older than the target's +`within` enters that target's percentile at its current age: it is certain to be over, so a stuck +change spends budget instead of hiding. A younger open sample is left out of the percentile, since +it may still finish within. The row counts every class, so nothing is silently dropped: + +``` +ops/change-rollout/p95 missed p95 ≤ 5 min p95 41 min over 23 samples (1 open over) 7d 0% +``` + +The value is the nearest-rank percentile. The budget is the share over `within` against the share +the percentile allows: p95 allows 5 in 100. + +### Ratio over Prometheus metrics + +There is none: Fractal does not read Prometheus. It exports its native readings as OTLP gauges +(`st_slo_*`), and Grafana computes ratios from them and from other exporters. An objective whose good +and total events exist only as Prometheus series stays in Grafana until its events reach Fractal as +claims or native telemetry. + +## Breach response + +```kdl +on-breach "fault" owner="agent/NS/NAME" backup="agent/NS/OTHER" condition="NAME-slo-missed" +``` + +A missed row opens one `operational.failure` on `daemon/MEMBER` addressed to `owner`, with the +`condition`. It stays one episode until the row is met again, when `operational.recovered` ends it. +A row with no data neither opens nor ends one. The member that delivers it is the one the owner +agent is declared on: every member evaluates the objective from the same replicated claims, and one +delivers. An owner that is not a live declaration gets nothing, and the explanation says so. + +`backup` is held: it is recorded and shown, and never becomes the fault's reviewer. It differs from +the owner. + +Objectives Fractal ships respond as they always have: a missed CI row is a fault for the fleet fault +agent. + +## Publishing, revisions and replication + +An objective is published with `fractal apply`, like an account. It is stored as the subject's +`intent.desired` claim, and its revision is the hash of the canonical declaration, which `fractal +apply --dry-run` prints as the old and new revision. The claim replicates to every member, so every +member evaluates the same declaration. A revision restarts the objective's evaluation from the claims; +nothing about past samples is stored apart from them. Retiring the subject removes the objective and +its rows. + +A daemon that does not know the `objective` family refuses the replicated claim, so publish an +objective only after every member runs a build that knows it. + +## Reading objectives + +`fractal slo ls` lists every row, shipped and declared alike, from `GET /v1/slo`. `fractal slo explain +ID` adds, for a declared objective, its subject and revision, the sample counts by class, each open +sample over target with its age, and its response. `fractal slo chart ID` draws the budget left a +minute at a time from the local seven-day history, which records declared rows like any other. + +## Listing claims by kind or subject prefix + +`GET /v1/claims` filters by `kind`, by `subject_prefix` or by an exact `subject`, and pages by store +index. `fractal claims ls --kind KIND --subject-prefix PREFIX` prints them. Both read through the +claims' kind and subject indexes. The interval indicator reads its claims the same way, keeping each +objective's samples in memory and reading only claims newer than the last it saw. diff --git a/docs/st3/profiling.md b/docs/st3/profiling.md index 2a47b88b483..1fc93e82fcd 100644 --- a/docs/st3/profiling.md +++ b/docs/st3/profiling.md @@ -152,7 +152,7 @@ move observers after acknowledgements. ## Targets and live windows -[`slo/targets.toml`](../../slo/targets.toml) holds the daemon's targets: reads a person waits on, +[`slo/objectives.kdl`](../../slo/objectives.kdl) holds the objectives Fractal ships ([objectives](objectives.md)): reads a person waits on, writes and acks, terminal attach to first screen, one SQL statement, any transaction or hold of the writer, daemon CPU, this member's database size, and CI's objectives. Every daemon builds the file in. CI's daemon_load reads the same file. A target describes what we aim for and gates nothing by itself. @@ -306,7 +306,7 @@ holds the history. Each point carries `target`, `series` (`owner` or `remote` fo the store), `window` (`1m`, `5m`, `1h`) and, on latency, `stat` (`p50`, `p99`, `max`), under the resource's `host.name`. The gauges are `st_slo_latency_ms`, `st_slo_over_target_ratio`, `st_slo_samples`, `st_slo_target_ms` (the -figure from `slo/targets.toml`, so one query sets a host's number against its target), +figure from `slo/objectives.kdl`, so one query sets a host's number against its target), `st_slo_cpu_cores` (the member's cores by `window`, sent only once known) and `st_slo_cpu_max_cores`. A window with no samples sends its count only. A phone's live share is a minimum over foreground milliseconds, so it is not in those: it goes out as `st_slo_live_share`, `st_slo_foreground_ms` and `st_slo_min_share_percent`. The task reads only diff --git a/docs/st3/replication.md b/docs/st3/replication.md index 4fc44c95694..6f06ddb8c3f 100644 --- a/docs/st3/replication.md +++ b/docs/st3/replication.md @@ -1296,7 +1296,7 @@ episode stays open; that projection opens its own episode. The same pair and pro diverging again reopens the same episode. Comparisons are held in memory, so after a restart an open fault stays open until the next comparison that agrees. -The `projection-divergence` objective in `slo/targets.toml` counts open divergence faults +The `projection-divergence` objective in `slo/objectives.kdl` counts open divergence faults fleet-wide, with a target of none. A daemon that runs the check keeps the count its last check read, so `fractal doctor` (as `slo/projection-divergence`), `fractal slo` and the SLO history read no claims for it; a daemon that never checks shows neither the line nor the row. A miss is `info`, like any diff --git a/release-notes/shipped-objectives-kdl.json b/release-notes/shipped-objectives-kdl.json new file mode 100644 index 00000000000..be668617d61 --- /dev/null +++ b/release-notes/shipped-objectives-kdl.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "The daemon's built-in targets are the objectives it ships in slo/objectives.kdl, read by the objective reader; slo/targets.toml is gone", + "replay": {"status": "none", "detail": "No replay or rebuild."}, + "schema": {"status": "none", "detail": "No tables, columns, indexes or triggers."}, + "rules": {"status": "none", "detail": "Checkpoint rules and digests are unchanged."}, + "compatibility": {"status": "none", "detail": "Behavior is unchanged: the same 25 rows with the same ids, abouts, targets and windows in st slo, GET /v1/slo, the request-latency read, st doctor and the OTLP gauges. The file is built into each daemon, so no running config changes. writer-hold reads its own objective's 300 ms bound, equal to the transaction bound it used before; st_slo_target_ms's description names slo/objectives.kdl."}, + "downtime": {"status": "none", "detail": "Standard daemon restart only."}, + "manual": {"status": "none", "detail": "No manual step."}, + "recovery": {"status": "none", "detail": "Binary rollback restores the TOML-built targets, which are the same numbers."} +} diff --git a/scripts/ci-perf b/scripts/ci-perf index 83df95431b5..f21be80d856 100755 --- a/scripts/ci-perf +++ b/scripts/ci-perf @@ -56,7 +56,7 @@ summarize_load() { printf '| Request | n | p50 ms | p99 ms | max ms | budget ms | target ms |\n| --- | ---: | ---: | ---: | ---: | ---: | ---: |\n' jq -r '.paths | to_entries[] | "| \(.key) | \(.value.count) | \(.value.p50_ms | floor) | \(.value.p99_ms | floor) | \(.value.max_ms | floor) | \(.value.budget_ms | floor) | \(if .value.descriptive_target_ms == null then "-" else "\(.value.descriptive_target_ms | floor)\(if .value.over_descriptive_target then " over" else "" end)" end) |"' "$report" - printf '\nTarget: the p99 in slo/targets.toml, described and never gated. Budgets and main'"'"'s baseline are the gates.\n' + printf '\nTarget: the p99 in slo/objectives.kdl, described and never gated. Budgets and main'"'"'s baseline are the gates.\n' printf 'Runner reference: %s ms to sort 2^21 values on one core (the same work on every host).\n' \ "$(jq -r '.runner_reference_ms // "-"' "$report")" jq -r 'if (.unavailable // {}) == {} then empty else @@ -169,7 +169,7 @@ PY if [ -s "$RUNNER_TEMP/perf-source-state.json" ]; then export AGENT_SPEC_REVISION="$(git rev-parse HEAD)" fi - # The daemon beside a CPU and disk hog at seat priority; reported against slo/targets.toml, + # The daemon beside a CPU and disk hog at seat priority; reported against slo/objectives.kdl, # never a required check (docs/ci.md). # arn:lmig:smalltalk:2026-10-16-env-fractal-dual-read FRACTAL_LOAD_GATE=1 ST_LOAD_GATE=1 FRACTAL_LOAD_HOG=1 ST_LOAD_HOG=1 FRACTAL_LOAD_HOG_REPORT="$out/hog" ST_LOAD_HOG_REPORT="$out/hog" \ diff --git a/scripts/ci-perf-slot b/scripts/ci-perf-slot index 78d83a290dd..7db3eb6e598 100755 --- a/scripts/ci-perf-slot +++ b/scripts/ci-perf-slot @@ -47,7 +47,7 @@ Measuring a change (docs/ci.md, "Performance gate") Run the load test above on your branch alone: the slot makes sure nothing else of ours is measuring at the same time. Its report is a table of each request's p50/p99/max against its budget, and the daemon's CPU in cores. The budgets that apply are the absolute p99 budget of - every request and the 2-core CPU ceiling (slo/targets.toml); locally there is no baseline, so + every request and the 2-core CPU ceiling (slo/objectives.kdl); locally there is no baseline, so those are the whole gate. CI adds the relative gate, twice the worst of main's last five reports, when you dispatch Performance on your branch. A run that misses a budget fails; do not retry it into passing, find the cause. diff --git a/slo/objectives.kdl b/slo/objectives.kdl new file mode 100644 index 00000000000..5c983834a9d --- /dev/null +++ b/slo/objectives.kdl @@ -0,0 +1,235 @@ +// The objectives every Fractal daemon is built with (docs/st3/objectives.md). Each daemon shows +// each latency objective's live 1-minute, 5-minute and 1-hour windows on +// `GET /v1/client/request-latency` and in `fractal doctor`, and CI's daemon_load reads them too. +// An objective describes; it gates nothing by itself. daemon_load's budgets stay its gates. +// +// A latency path is `METHOD /route` as the daemon routes it, or `stream COLLECTION` for the time +// from a subscription on the client collections socket to its first snapshot or screen. A request +// that asks to wait (`wait_ms` above zero, or `wait=true`) is a long poll and counts toward no +// objective. An agents list asked to be fresh (`fresh=true`) waits for one roster refresh by +// design: it is the path `GET /v1/client/agents (fresh)` and counts toward its own objective, not +// the first page's. `publish COLLECTION` is a refresher's publication: the time from the first +// claim this node committed after the previous publication to the publication that carries it. +version 2 + +objective "person-read" { + about "Reads a person waits on: agents list, missions list, open a conversation, conversation changes, work list, Now, what a run waits on, documents" + indicator "latency" { + // Agents list + path "GET /v1/client/agents" + path "GET /v1/client/agents/{*id}" + path "stream agents" + // Missions list + path "GET /v1/client/missions" + path "GET /v1/client/missions/{*id}" + path "stream missions" + // Open a conversation, and its changes + path "GET /v1/client/sessions/{*id}" + path "stream conversation" + path "GET /v1/client/conversations/{id}/changes" + // Work list + path "GET /v1/client/work" + path "GET /v1/client/work/{*id}" + path "GET /v1/work" + path "stream work" + // Now + path "GET /v1/client/now" + path "GET /v1/client/attention" + path "stream attention" + // Why a run is stuck, and every blocked run + path "GET /v1/mission-runs/{run}/waiting-on" + path "GET /v1/blocked-runs" + // Documents + path "GET /v1/client/documents/content" + path "GET /v1/documents" + path "GET /v1/documents/content" + } + target "p99" within="300ms" + // The same reads through this daemon from another machine that owns them. + target "p99" within="300ms" scope="remote" +} + +objective "person-read-fresh" { + about "An agents list with legacy fresh=true or an explicit min_cut asks to wait for publication; ordinary st agents ls and tree do not. Explicit waits have their own target and count toward no other" + indicator "latency" { + path "GET /v1/client/agents (fresh)" + } + target "p99" within="300ms" +} + +objective "projection-fresh" { + about "From a commit to the published missions, agents or attention view that carries it, timed by each refresher; refreshers fold at most once a second" + indicator "latency" { + path "publish missions" + path "publish agents" + path "publish attention" + } + target "p99" within="1500ms" +} + +objective "write-ack" { + about "Writes and acks: send, work actions, harness events" + indicator "latency" { + path "POST /v1/messages" + path "POST /v1/client/actions" + path "POST /v1/work/{action}/{*subject}" + path "POST /v1/work/extend/{*subject}" + path "POST /v1/work/done" + path "POST /v1/work/ask" + path "POST /v1/work/cancel-ask" + path "POST /v1/work/start" + path "POST /v1/work/handoff/{*subject}" + path "POST /v1/work/acknowledge/{*subject}" + path "POST /v1/harness-events" + path "POST /v1/mailbox/receipts" + path "POST /v1/messages/read/{*subject}" + } + target "p99" within="300ms" +} + +objective "terminal-attach" { + about "Terminal attach to first screen" + indicator "latency" { + path "GET /v1/client/terminals/{id}/screen" + path "stream terminal" + } + target "p99" within="200ms" +} + +objective "sql-statement" { + about "One SQL statement" + indicator "statement" + target "p99" within="10ms" + target "max" within="100ms" +} + +objective "transaction" { + about "Any transaction, read or write, and any hold of the store's writer" + indicator "transaction" + target "max" within="300ms" +} + +objective "writer-hold" { + about "Each hold of the store's single writer, and each read transaction past the same target, by who held it: a request route or background task, its client, and the code that asked" + indicator "writer-hold" + target "max" within="300ms" +} + +// The member's CPU is its daemon process and every replication worker process beside it, all +// their work included (maintenance, heartbeat, anti-entropy, replication), over closed UTC +// minutes. An idle minute is one known to be idle: no seat on this member working, no daemon +// start, deploy or replication catch-up in it, and every figure known. An hour with fewer than +// `idle-min-minutes` idle minutes, or that the daemon was not up for all of, is unknown. +objective "cpu" { + about "Member CPU, the daemon and its replication workers: at most 1 core over the hour, and 0.5 core over the hour's idle minutes" + indicator "cpu" { + idle-min-minutes 10 + // daemon_load's bound in CI under its synthetic load, which is not the production target. + load-max-cores 2 + } + target "cores" max=1 + target "idle-cores" max=0.5 +} + +// Closed UTC minutes reported by paired clients; distinct from daemon response timing. + +objective "ios-open-to-live" { + about "Foreground to first live Home snapshot; reported phone population only" + indicator "latency" { + path "client/ios/open-to-live" + } + target "p99" within="3000ms" +} + +objective "ios-connect" { + about "Client connection; reported phone population only" + indicator "latency" { + path "client/ios/connect" + } + target "p99" within="1500ms" +} + +objective "ios-message-ack" { + about "Client message acknowledgement; reported phone population only" + indicator "latency" { + path "client/ios/message-ack" + } + target "p99" within="500ms" +} + +objective "ios-conversation-open" { + about "Client conversation opening; reported phone population only" + indicator "latency" { + path "client/ios/conversation-open" + } + target "p99" within="1000ms" +} + +objective "ios-terminal-open" { + about "Client terminal opening; reported phone population only" + indicator "latency" { + path "client/ios/terminal-open" + } + target "p99" within="1500ms" +} + +objective "ios-recover" { + about "Client feed recovery; reported phone population only" + indicator "latency" { + path "client/ios/recover" + } + target "p99" within="10000ms" +} + +objective "ios-live-share" { + about "Live foreground time / reported foreground time; disconnected foreground is over-target" + indicator "share" { + path "client/ios/live-share" + } + target "live-share" min-percent=99 +} + +objective "projection-divergence" { + about "Open P1 projection bugs: two members that hold the same envelopes and project a table differently. Each is a fault for the fleet's fault agent with what a reproduction needs; nothing repairs it from a peer" + indicator "divergence" + target "open" max=0 +} + +objective "database" { + about "This member's store: the pages in use, and how much they grow a day once checkpoints and retention have run" + indicator "database" + target "live-gb" max=8 + target "growth-mb-per-day" max=300 +} + +// CI, observed outside the daemon: the merge queue and the workspace's checks. A `ci-duration` +// target is one percentile of a duration, the row `NAME/pPERCENTILE`. A `ci-count` is the most +// events an hour, the row `NAME`. `series` names what feeds it, `ci/NAME`. They are objectives in +// `fractal slo` only, not in the request-latency read or `fractal doctor`. Workspace CI's runs, +// read by the repository observer that reads main_performance_failures and held in memory, feed +// them; other members show no-data. + +objective "merge-latency" { + about "A pull request's time in the merge queue, from entering it to merging" + indicator "ci-duration" { + series "ci/merge-queue" + } + target "p50" within="10m" + target "p95" within="10m" +} + +objective "ci-critical-path" { + about "The workspace CI's critical path, from a change to all checks green" + indicator "ci-duration" { + series "ci/workspace-critical-path" + } + target "p95" within="13m" +} + +objective "flakes" { + about "Flaky results: tests nextest passed only on a retry, and merge groups ejected from the queue" + indicator "ci-count" { + series "ci/flakes" + } + target "per-hour" max=0 +} diff --git a/slo/targets.toml b/slo/targets.toml deleted file mode 100644 index 559aa61931d..00000000000 --- a/slo/targets.toml +++ /dev/null @@ -1,206 +0,0 @@ -# What the st daemon aims for. Every daemon builds this file in, shows each target's live -# 1-minute, 5-minute and 1-hour windows on `GET /v1/client/request-latency` and in `st doctor`, -# and CI's daemon_load reads it too. A latency is a 99th percentile unless it says max. -# -# A target describes; it gates nothing by itself. daemon_load's budgets stay its gates. -# -# A path is `METHOD /route` as the daemon routes it, or `stream COLLECTION` for the time from a -# subscription on the client collections socket to its first snapshot or screen. A request that -# asks to wait (`wait_ms` above zero, or `wait=true`) is a long poll and counts toward no target. -# An agents list asked to be fresh (`fresh=true`) waits for one roster refresh by design: it is -# the path `GET /v1/client/agents (fresh)` and counts toward its own target, not the first page's. -# `publish COLLECTION` is a refresher's publication: the time from the first claim this node -# committed after the previous publication to the publication that carries it. - -[[latency]] -name = "person-read" -about = "Reads a person waits on: agents list, missions list, open a conversation, conversation changes, work list, Now, what a run waits on, documents" -p99_ms = 300 -# The same reads through this daemon from another machine that owns them. -remote_p99_ms = 300 -paths = [ - # Agents list - "GET /v1/client/agents", - "GET /v1/client/agents/{*id}", - "stream agents", - # Missions list - "GET /v1/client/missions", - "GET /v1/client/missions/{*id}", - "stream missions", - # Open a conversation, and its changes - "GET /v1/client/sessions/{*id}", - "stream conversation", - "GET /v1/client/conversations/{id}/changes", - # Work list - "GET /v1/client/work", - "GET /v1/client/work/{*id}", - "GET /v1/work", - "stream work", - # Now - "GET /v1/client/now", - "GET /v1/client/attention", - "stream attention", - # Why a run is stuck, and every blocked run - "GET /v1/mission-runs/{run}/waiting-on", - "GET /v1/blocked-runs", - # Documents - "GET /v1/client/documents/content", - "GET /v1/documents", - "GET /v1/documents/content", -] - -[[latency]] -name = "person-read-fresh" -about = "An agents list with legacy fresh=true or an explicit min_cut asks to wait for publication; ordinary st agents ls and tree do not. Explicit waits have their own target and count toward no other" -p99_ms = 300 -paths = [ - "GET /v1/client/agents (fresh)", -] - -[[latency]] -name = "projection-fresh" -about = "From a commit to the published missions, agents or attention view that carries it, timed by each refresher; refreshers fold at most once a second" -p99_ms = 1500 -paths = [ - "publish missions", - "publish agents", - "publish attention", -] - -[[latency]] -name = "write-ack" -about = "Writes and acks: send, work actions, harness events" -p99_ms = 300 -paths = [ - "POST /v1/messages", - "POST /v1/client/actions", - "POST /v1/work/{action}/{*subject}", - "POST /v1/work/extend/{*subject}", - "POST /v1/work/done", - "POST /v1/work/ask", - "POST /v1/work/cancel-ask", - "POST /v1/work/start", - "POST /v1/work/handoff/{*subject}", - "POST /v1/work/acknowledge/{*subject}", - "POST /v1/harness-events", - "POST /v1/mailbox/receipts", - "POST /v1/messages/read/{*subject}", -] - -[[latency]] -name = "terminal-attach" -about = "Terminal attach to first screen" -p99_ms = 200 -paths = [ - "GET /v1/client/terminals/{id}/screen", - "stream terminal", -] - -[statement] -about = "One SQL statement" -p99_ms = 10 -max_ms = 100 - -[transaction] -about = "Any transaction, read or write, and any hold of the store's writer" -max_ms = 300 - -# The member's CPU is its daemon process and every replication worker process beside it, all -# their work included (maintenance, heartbeat, anti-entropy, replication), over closed UTC -# minutes. An idle minute is one known to be idle: no seat on this member working, no daemon -# start, deploy or replication catch-up in it, and every figure known. An hour with fewer than -# `idle_min_minutes` idle minutes, or that the daemon was not up for all of, is unknown. -[cpu] -about = "Member CPU, the daemon and its replication workers: at most 1 core over the hour, and 0.5 core over the hour's idle minutes" -max_cores = 1.0 -idle_max_cores = 0.5 -idle_min_minutes = 10 -# daemon_load's bound in CI under its synthetic load, which is not the production target. -load_max_cores = 2.0 - -# Closed UTC minutes reported by paired clients; distinct from daemon response timing. - -[[latency]] -name = "ios-open-to-live" -about = "Foreground to first live Home snapshot; reported phone population only" -p99_ms = 3000 -paths = ["client/ios/open-to-live"] - -[[latency]] -name = "ios-connect" -about = "Client connection; reported phone population only" -p99_ms = 1500 -paths = ["client/ios/connect"] - -[[latency]] -name = "ios-message-ack" -about = "Client message acknowledgement; reported phone population only" -p99_ms = 500 -paths = ["client/ios/message-ack"] - -[[latency]] -name = "ios-conversation-open" -about = "Client conversation opening; reported phone population only" -p99_ms = 1000 -paths = ["client/ios/conversation-open"] - -[[latency]] -name = "ios-terminal-open" -about = "Client terminal opening; reported phone population only" -p99_ms = 1500 -paths = ["client/ios/terminal-open"] - -[[latency]] -name = "ios-recover" -about = "Client feed recovery; reported phone population only" -p99_ms = 10000 -paths = ["client/ios/recover"] - -[[share]] -name = "ios-live-share" -about = "Live foreground time / reported foreground time; disconnected foreground is over-target" -min_percent = 99 -path = "client/ios/live-share" - -[divergence] -about = "Open P1 projection bugs: two members that hold the same envelopes and project a table differently. Each is a fault for the fleet's fault agent with what a reproduction needs; nothing repairs it from a peer" -max_open = 0 - -[database] -about = "This member's store: the pages in use, and how much they grow a day once checkpoints and retention have run" -max_live_gb = 8.0 -max_growth_mb_per_day = 300 - -# CI, observed outside the daemon: the merge queue and the workspace's checks. A `[[duration]]` is -# one percentile of a duration, the objective `NAME/pPERCENTILE`; a name may declare several. A -# `[[count]]` is the most events an hour, the objective `NAME`. `path` names the series that feeds -# it, `ci/NAME`. They are objectives in `st slo` only, not in the request-latency read or `st -# doctor`. Workspace CI's runs, read by the repository observer that reads -# main_performance_failures and held in memory, feed them; other members show no-data. - -[[duration]] -name = "merge-latency" -about = "A pull request's time in the merge queue, from entering it to merging" -percentile = 50 -target_ms = 600000 -path = "ci/merge-queue" - -[[duration]] -name = "merge-latency" -about = "A pull request's time in the merge queue, from entering it to merging" -percentile = 95 -target_ms = 600000 -path = "ci/merge-queue" - -[[duration]] -name = "ci-critical-path" -about = "The workspace CI's critical path, from a change to all checks green" -percentile = 95 -target_ms = 780000 -path = "ci/workspace-critical-path" - -[[count]] -name = "flakes" -about = "Flaky results: tests nextest passed only on a retry, and merge groups ejected from the queue" -max_per_hour = 0 -path = "ci/flakes" From 97e5ebf003c148285c12afb403a3bd037d0c4ed7 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:11:26 +0100 Subject: [PATCH 115/132] Keep .github/workflows/perf-load.ts unchanged; its slo/targets.toml comment is updated separately --- .github/workflows/perf-load.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/perf-load.ts b/.github/workflows/perf-load.ts index e74b29fc350..5e72dc48a6b 100644 --- a/.github/workflows/perf-load.ts +++ b/.github/workflows/perf-load.ts @@ -106,7 +106,7 @@ python3 scripts/ci-perf-cache pack`, }, ], }, - // The same daemon beside a CPU and disk hog at seat priority, reported against slo/objectives.kdl. A missed + // The same daemon beside a CPU and disk hog at seat priority, reported against slo/targets.toml. A missed // target only reports (until ST_LOAD_HOG_STRICT=1); a failed request or a hog that never loaded the // machine fails the job, and a red daily lane alerts. It takes a second performance runner for up to 45 // minutes, so it runs once a day, not on pull requests. From 08bbc16b9214e7e4746c82efc1d9e5b04b4162c2 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:27:35 +0100 Subject: [PATCH 116/132] 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 a85054985d7c7351297f7611d1d508307dbb8e08 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:29:41 +0100 Subject: [PATCH 117/132] Define isolated Fabric pairing admission and signed result contract agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- docs/st3/client-v0/README.md | 75 +++++++++++++++ .../client-v0/schemas/client-v0.schema.json | 92 +++++++++++++++++++ release-notes/isolated-fabric-pairing.json | 1 + 3 files changed, 168 insertions(+) create mode 100644 release-notes/isolated-fabric-pairing.json diff --git a/docs/st3/client-v0/README.md b/docs/st3/client-v0/README.md index 6ba8363af67..bfb47bf4127 100644 --- a/docs/st3/client-v0/README.md +++ b/docs/st3/client-v0/README.md @@ -3610,3 +3610,78 @@ null means unknown, not zero. Null reset means unknown. Freshness expires after the positive horizon, both for snapshot generation and each account observation; consumers must label expired readings stale. Unsupported versions are unavailable. + +## Fabric cold-bootstrap pairing client contract + +The isolated pairing receiver uses the canonical `PairingQrPayload`, +`PairingExchangeRequest`, `PairingExchangeReply`, `PairingResultRequest` and +`PairingAdmissionRecord` schema definitions. Production receivers are unchanged. +Readers must support this contract before an issuer enables QR version 2. +Version 1 retains its existing six fields and behavior. + +QR version 2 adds exactly `fabric: {node_id, attempt_id, secret}`. The NodeID is +64 lowercase hexadecimal characters and pins the gateway. Dial `fabric/pair/1` +on that NodeID; ignore `gateway` on this cold route. The secret exists only in +memory, never logs or persistent storage, and is cleared when the attempt ends. +The permit grants exactly the isolated client service, inbound-only, and expires +at the earlier of the challenge expiry and ten minutes after issue. + +Each exchange message is one UTF-8 JSON envelope: +`{v:1, request_id, op, body}`. Request IDs cannot repeat within an attempt. +Allowed operations are `capabilities`, `claim`, `complete`, and `ack`. +Other operations return `pairing-route-not-allowed`. Complete before a successful +claim returns `pairing-claim-invalid`. Claim and complete retain their signed HTTP +request bodies. Success is `{v:1, request_id, ok:true, body}`; refusal is +`{v:1, request_id, ok:false, refusal:{code,message}}`. The completion body is +`{state:"admission-pending",pairing_id}`: it contains no credential or grant. +After queuing a refusal, the server waits for `ack` with body +`{request_id: REFUSED_REQUEST_ID}` or the permit deadline before rejecting. +Ack has no authority effect. + +The server stores the exact confirm request in S1 before calling Fabric confirm. +The advertised completion NodeID must equal Fabric's authenticated bound NodeID. +After confirm the server persists the retained receipt as S2, then atomically +finalizes exactly one grant and stores the completion body as S3. Fabric confirm +and the grant are separate durable transactions; S1 and S2 never return credentials. +The client reads restricted Fabric status after disconnect or completion, clears +the secret on a terminal state, and on confirmed dials the isolated client service. +It verifies the remote NodeID against the QR pin before sending any request. + +POST `/v1/client/pairings/{id}/result` takes +`{api_version,attempt_id,device_public_key,proof}`. Sign exactly four UTF-8 lines: +`pairing.result.v1`, full pairing ID, attempt ID, bound NodeID. Separate lines +with one LF byte and omit a trailing LF. Key and signature encodings are the +same as claim. The key must be the approved key. Transport NodeID comes only +from the trusted Fabric preface on the dedicated isolated Unix receiver, +never a body, query or HTTP header. The ordinary receiver refuses this route. +Wrong key or preface NodeID returns `pairing-key-mismatch`; missing preface is +refused. This receiver also serves credential-authenticated client routes, using +the same trusted preface identity rather than a caller-supplied header. + +Result returns the identical stored completion body in S3 without creating a +grant. In S1 or S2 it waits reactively at most 30 seconds, then returns +`pairing-result-pending`, `retryable:true`, and RFC3339 `deadline`. +The deadline is the permit deadline in S1, or retained Fabric `committed_at` +plus 600 seconds after confirm. Only one hold is open per pairing: a new hold +replaces the previous hold, which returns pending. The client immediately +reissues pending requests and stops at the deadline. A transition wakes a hold, +including reconciliation after restart. S4 and S5 return terminal +`pairing-result-expired`. A usable session requires result fetch followed by one +credential-authenticated client request. + +Reconcile at startup and before every transition using current Fabric owner +access provenance, never a historical confirmed receipt alone. Retry S1 pending +with the identical persisted confirm request; S1 confirmed moves to S2 first. +Expired, rejected or exchange-lost S1 attempts close refused with no grant. +S2 with current owner access and before the retained deadline finalizes once; +missing access in S2 or S3 enters S4 and never re-adds the peer. If no successful +result fetch has occurred by the retained deadline, enter S4. Revoke uses the +same path. Persist S4 before idempotent peer removal, then revoke any grant, +then persist S5. S4 and S5 cannot resurrect access. + +Sweep orphan peers only after reconciling S1. A confirmed S1 attempt first moves +to S2 and must not be swept. Unknown pairing IDs and peers whose pairing record +is S4, S5 or refused enter cleanup. A finalized grant with no owner record also +enters cleanup. No static add-peer, HTTP, pre-grant, or saved-gateway fallback +is permitted. + diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index b723adeaf1f..6d168e6ca43 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -637,6 +637,9 @@ "pairing-key-binding-required", "pairing-already-claimed", "pairing-claim-invalid", + "pairing-route-not-allowed", + "pairing-result-pending", + "pairing-result-expired", "suspend-authored-session-selection", "stale-fence", "cursor-gap", @@ -4353,6 +4356,95 @@ "expires_at": { "$ref": "#/$defs/Timestamp" } } }, + "PairingFabricPermit": { + "description": "Cold-bootstrap permit. Secret stays in memory only, is never logged or persisted, and is cleared when the attempt ends. Node ID pins the gateway dial target.", + "type": "object", "additionalProperties": false, "required": ["node_id", "attempt_id", "secret"], + "properties": { + "node_id": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "attempt_id": { "type": "string", "minLength": 1 }, + "secret": { "type": "string", "minLength": 1, "x-st-codec": "redacted" } + } + }, + "PairingQrPayload": { + "description": "Version 1 retains its six fields. Version 2 adds exactly fabric. A cold client ignores gateway and dials the pinned fabric.node_id; there is no HTTP fallback.", + "type": "object", "additionalProperties": false, + "required": ["_tag", "version", "gateway", "id", "code", "expires_at_unix_ms"], + "properties": { + "_tag": { "const": "fractal.pairing" }, "version": { "enum": [1, 2] }, + "gateway": { "type": "string", "format": "uri" }, "id": { "$ref": "#/$defs/Id" }, + "code": { "type": "string", "pattern": "^[A-Z2-9]{8}$" }, + "expires_at_unix_ms": { "type": "integer" }, "fabric": { "$ref": "#/$defs/PairingFabricPermit" } + }, + "oneOf": [ + { "properties": { "version": { "const": 1 }, "fabric": false } }, + { "required": ["fabric"], "properties": { "version": { "const": 2 } } } + ] + }, + "PairingAdmissionPending": { + "description": "Exchange completion acknowledges durable S1 only, never a credential or a grant.", + "type": "object", "additionalProperties": false, "required": ["state", "pairing_id"], + "properties": { "state": { "const": "admission-pending" }, "pairing_id": { "$ref": "#/$defs/Id" } } + }, + "PairingExchangeRequest": { + "description": "One UTF-8 JSON envelope per Fabric exchange message. Request IDs are unique per attempt. Only capabilities, claim, complete and ack are allowed; complete requires a successful claim.", + "type": "object", "additionalProperties": false, "required": ["v", "request_id", "op", "body"], + "properties": { "v": { "const": 1 }, "request_id": { "type": "string", "minLength": 1 }, "op": { "enum": ["capabilities", "claim", "complete", "ack"] }, "body": {} }, + "oneOf": [ + { "properties": { "op": { "const": "capabilities" }, "body": { "type": "object", "additionalProperties": false } } }, + { "properties": { "op": { "const": "claim" }, "body": { "$ref": "#/$defs/PairingKeyClaim" } } }, + { "properties": { "op": { "const": "complete" }, "body": { "$ref": "#/$defs/PairingComplete" } } }, + { "properties": { "op": { "const": "ack" }, "body": { "type": "object", "additionalProperties": false, "required": ["request_id"], "properties": { "request_id": { "type": "string", "minLength": 1 } } } } } + ] + }, + "PairingExchangeRefusal": { + "type": "object", "additionalProperties": false, "required": ["code", "message"], + "properties": { "code": { "$ref": "#/$defs/ErrorCode" }, "message": { "type": "string" } } + }, + "PairingExchangeReply": { + "description": "Completion returns admission-pending, never a usable completion body. A typed refusal is queued until ack or the permit deadline, then the attempt is rejected. Ack has no authority effect.", + "type": "object", "additionalProperties": false, "required": ["v", "request_id", "ok"], + "properties": { "v": { "const": 1 }, "request_id": { "type": "string" }, "ok": { "type": "boolean" }, "body": { "oneOf": [{ "$ref": "#/$defs/PairingAdvertisement" }, { "$ref": "#/$defs/PairingKeyStatus" }, { "$ref": "#/$defs/PairingAdmissionPending" }, { "type": "object", "additionalProperties": false }] }, "refusal": { "$ref": "#/$defs/PairingExchangeRefusal" } }, + "oneOf": [ + { "required": ["body"], "properties": { "ok": { "const": true }, "refusal": false } }, + { "required": ["refusal"], "properties": { "ok": { "const": false }, "body": false } } + ] + }, + "PairingResultRequest": { + "description": "POST /v1/client/pairings/{id}/result, only on the isolated Fabric-preface receiver. Sign four UTF-8 lines: pairing.result.v1, full pairing ID, attempt_id, bound NodeID; single LF separators, no trailing LF. Same key/signature encoding as claim. Transport NodeID comes only from the trusted Fabric preface, never this body, a query or an HTTP header.", + "type": "object", "additionalProperties": false, "required": ["api_version", "attempt_id", "device_public_key", "proof"], + "properties": { + "api_version": { "enum": ["st3.client.v0", "fractal.client.v0"] }, "attempt_id": { "type": "string", "minLength": 1 }, + "device_public_key": { "type": "string", "minLength": 32 }, + "proof": { "type": "string", "pattern": "^[A-Za-z0-9_-]{85}[AQgw]$" } + } + }, + "PairingResultPending": { + "description": "Reactive hold lasts at most 30 seconds. A second hold replaces the first, returning pending to the first. Reissue immediately, stop at deadline. Deadline is retained committed_at + 600 seconds in S2/S3 or the permit deadline in S1.", + "type": "object", "additionalProperties": false, "required": ["code", "retryable", "deadline"], + "properties": { "code": { "const": "pairing-result-pending" }, "retryable": { "const": true }, "deadline": { "$ref": "#/$defs/Timestamp" } } + }, + "PairingFabricConfirmRequest": { + "description": "Exact request persisted before calling Fabric confirm; retries use the identical body.", + "type": "object", "additionalProperties": false, + "required": ["operation", "owner", "attempt_id", "expected_node_id", "expected_attempt_revision", "expected_policy_revision", "request_id"], + "properties": { "operation": { "const": "confirm" }, "owner": { "type": "string" }, "attempt_id": { "type": "string" }, "expected_node_id": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "expected_attempt_revision": { "type": "integer", "minimum": 0 }, "expected_policy_revision": { "type": "integer", "minimum": 0 }, "request_id": { "type": "string" } } + }, + "PairingAdmissionRecord": { + "description": "Durable state only moves forward S1 admission-pending, S2 confirmed-unfinalized, S3 finalized, S4 cleanup tombstone, S5 closed. S4 is persisted before remove_peer, then grant revoke, then S5. Reconcile current owner access at startup and before transitions; absent access never resurrects. Sweep orphans only after S1 reconciliation.", + "type": "object", "additionalProperties": false, + "required": ["state", "pairing_id", "device_public_key", "bound_node_id", "creation_id", "attempt_id", "permit_deadline", "confirm_request"], + "properties": { + "state": { "enum": ["S1", "S2", "S3", "S4", "S5"] }, "pairing_id": { "$ref": "#/$defs/Id" }, + "device_public_key": { "type": "string" }, "bound_node_id": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "creation_id": { "type": "string" }, "attempt_id": { "type": "string" }, "permit_deadline": { "$ref": "#/$defs/Timestamp" }, + "confirm_request": { "$ref": "#/$defs/PairingFabricConfirmRequest" }, + "confirm_receipt": { "type": "object", "description": "Full retained Fabric confirm receipt, including committed_at and policy_revision." }, + "committed_at": { "$ref": "#/$defs/Timestamp" }, "policy_revision": { "type": "integer", "minimum": 0 }, + "completion": { "$ref": "#/$defs/PairedSession" }, "grant_id": { "$ref": "#/$defs/Id" }, + "result_fetched_at": { "$ref": "#/$defs/Timestamp" }, "remove_peer_receipt": { "type": "object" }, "revoke_receipt": { "type": "object" }, + "refused": { "type": "boolean" } + } + }, "PairingChallenge": { "oneOf": [ { "properties": { "grant_kind": { "const": "person" }, "principal": false, "grant_id": false, "grant_expires_at": false, "policy_revision": false, "minimal_read_mode": false } }, diff --git a/release-notes/isolated-fabric-pairing.json b/release-notes/isolated-fabric-pairing.json new file mode 100644 index 00000000000..9b568803b40 --- /dev/null +++ b/release-notes/isolated-fabric-pairing.json @@ -0,0 +1 @@ +{"type":"security","description":"Define isolated Fabric cold-bootstrap pairing with pinned QR permits, signed result recovery, and fail-closed durable admission reconciliation."} From cd71fda0a400cd29a7c6889d82aa47ce50533428 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:34:18 +0100 Subject: [PATCH 118/132] Constrain admission states and retain typed confirm receipts agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../templates/lib.rs.in | 30 ++++++++++++++++ .../fixtures/pairing-admission-states.json | 35 +++++++++++++++++++ .../client-v0/schemas/client-v0.schema.json | 16 +++++++-- 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 docs/st3/client-v0/fixtures/pairing-admission-states.json diff --git a/crates/fractal-client-codegen/templates/lib.rs.in b/crates/fractal-client-codegen/templates/lib.rs.in index 8cf3dc2e6c5..5c20e1f2f50 100644 --- a/crates/fractal-client-codegen/templates/lib.rs.in +++ b/crates/fractal-client-codegen/templates/lib.rs.in @@ -3495,6 +3495,36 @@ fn percent_encode_segment(value: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn pairing_admission_state_schema_validates_every_durable_branch() { + let contract: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/schemas/client-v0.schema.json" + )).unwrap(); + let schema = serde_json::json!({"$ref": "#/$defs/PairingAdmissionRecord", "$defs": contract["$defs"]}); + let validator = jsonschema::options().should_validate_formats(true).build(&schema).unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-admission-states.json" + )).unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut record = fixture["base"].as_object().unwrap().clone(); + record.insert("state".into(), case["state"].clone()); + for group in case["groups"].as_array().unwrap() { + record.extend(fixture[group.as_str().unwrap()].as_object().unwrap().clone()); + } + if let Some(fields) = case["set"].as_object() { + record.extend(fields.clone()); + } + if let Some(fields) = case["remove"].as_array() { + for field in fields { record.remove(field.as_str().unwrap()); } + } + if let Some(fields) = case["remove_receipt"].as_array() { + let receipt = record.get_mut("confirm_receipt").unwrap().as_object_mut().unwrap(); + for field in fields { receipt.remove(field.as_str().unwrap()); } + } + assert_eq!(validator.is_valid(&serde_json::Value::Object(record)), case["valid"].as_bool().unwrap(), "{}", case["name"]); + } + } + #[test] fn phone_documents_without_params_matches_the_schema() { let value = serde_json::to_value(PhoneNavigateArgs::Documents(None)).unwrap(); diff --git a/docs/st3/client-v0/fixtures/pairing-admission-states.json b/docs/st3/client-v0/fixtures/pairing-admission-states.json new file mode 100644 index 00000000000..64ce80c8d20 --- /dev/null +++ b/docs/st3/client-v0/fixtures/pairing-admission-states.json @@ -0,0 +1,35 @@ +{ + "base": {"state":"S1","pairing_id":"pairing/example","device_public_key":"example-key","bound_node_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","creation_id":"pairing/example","attempt_id":"attempt-example","permit_deadline":"2026-01-01T00:10:00Z","confirm_request":{"operation":"confirm","owner":"fractal/pairing/example","attempt_id":"attempt-example","expected_node_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","expected_attempt_revision":1,"expected_policy_revision":1,"request_id":"confirm-example"}}, + "confirmed": {"confirm_receipt":{"committed_at":"2026-01-01T00:01:00Z","policy_revision":2,"node_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","future_field":"retained"},"committed_at":"2026-01-01T00:01:00Z","policy_revision":2}, + "finalized": {"grant_id":"grant/example","completion":{"kind":"paired-session","device_id":"device/example","person_id":"person/example","session_actor":"session/example","credential":"01234567890123456789012345678901","scopes":[],"expires_at":"2026-01-02T00:00:00Z"}}, + "cleanup": {"cleanup_reason":"expired","cleanup_started_at":"2026-01-01T00:11:00Z"}, + "cases": [ + {"name":"S1 pending valid","state":"S1","groups":[],"valid":true}, + {"name":"S1 missing exact confirm request","state":"S1","groups":[],"remove":["confirm_request"],"valid":false}, + {"name":"S2 confirmed valid including unknown receipt field","state":"S2","groups":["confirmed"],"valid":true}, + {"name":"S2 missing receipt","state":"S2","groups":["confirmed"],"remove":["confirm_receipt"],"valid":false}, + {"name":"S2 missing committed time","state":"S2","groups":["confirmed"],"remove":["committed_at"],"valid":false}, + {"name":"S2 missing policy revision","state":"S2","groups":["confirmed"],"remove":["policy_revision"],"valid":false}, + {"name":"receipt missing committed_at","state":"S2","groups":["confirmed"],"remove_receipt":["committed_at"],"valid":false}, + {"name":"receipt missing policy_revision","state":"S2","groups":["confirmed"],"remove_receipt":["policy_revision"],"valid":false}, + {"name":"receipt missing node_id","state":"S2","groups":["confirmed"],"remove_receipt":["node_id"],"valid":false}, + {"name":"S3 finalized valid","state":"S3","groups":["confirmed","finalized"],"valid":true}, + {"name":"S3 missing completion","state":"S3","groups":["confirmed","finalized"],"remove":["completion"],"valid":false}, + {"name":"S3 missing grant","state":"S3","groups":["confirmed","finalized"],"remove":["grant_id"],"valid":false}, + {"name":"S4 unconfirmed cleanup valid","state":"S4","groups":["cleanup"],"valid":true}, + {"name":"S4 confirmed cleanup valid","state":"S4","groups":["confirmed","cleanup"],"valid":true}, + {"name":"S4 missing reason","state":"S4","groups":["cleanup"],"remove":["cleanup_reason"],"valid":false}, + {"name":"S4 missing tombstone timestamp","state":"S4","groups":["cleanup"],"remove":["cleanup_started_at"],"valid":false}, + {"name":"S4 grant requires receipt","state":"S4","groups":["finalized","cleanup"],"valid":false}, + {"name":"S5 refused without confirmation valid","state":"S5","groups":["cleanup"],"set":{"refused":true},"valid":true}, + {"name":"S5 refused with confirmed fields invalid","state":"S5","groups":["confirmed","cleanup"],"set":{"refused":true,"remove_peer_receipt":{}},"valid":false}, + {"name":"S5 refused with grant invalid","state":"S5","groups":["confirmed","finalized","cleanup"],"set":{"refused":true,"remove_peer_receipt":{},"revoke_receipt":{}},"valid":false}, + {"name":"S1 cannot be refused terminal","state":"S1","groups":[],"set":{"refused":true},"valid":false}, + {"name":"S4 cannot be refused terminal","state":"S4","groups":["cleanup"],"set":{"refused":true},"valid":false}, + {"name":"S5 confirmed removal valid","state":"S5","groups":["confirmed","cleanup"],"set":{"remove_peer_receipt":{}},"valid":true}, + {"name":"S5 confirmed missing removal invalid","state":"S5","groups":["confirmed","cleanup"],"valid":false}, + {"name":"S5 granted revoke valid","state":"S5","groups":["confirmed","finalized","cleanup"],"set":{"remove_peer_receipt":{},"revoke_receipt":{}},"valid":true}, + {"name":"S5 granted missing revoke invalid","state":"S5","groups":["confirmed","finalized","cleanup"],"set":{"remove_peer_receipt":{}},"valid":false}, + {"name":"S5 missing cleanup invalid","state":"S5","groups":["confirmed"],"set":{"remove_peer_receipt":{}},"valid":false} + ] +} diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index 6d168e6ca43..94053232a68 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -4438,12 +4438,22 @@ "device_public_key": { "type": "string" }, "bound_node_id": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "creation_id": { "type": "string" }, "attempt_id": { "type": "string" }, "permit_deadline": { "$ref": "#/$defs/Timestamp" }, "confirm_request": { "$ref": "#/$defs/PairingFabricConfirmRequest" }, - "confirm_receipt": { "type": "object", "description": "Full retained Fabric confirm receipt, including committed_at and policy_revision." }, + "confirm_receipt": { "type": "object", "additionalProperties": true, "required": ["committed_at", "policy_revision", "node_id"], "properties": { "committed_at": { "$ref": "#/$defs/Timestamp" }, "policy_revision": { "type": "integer", "minimum": 0 }, "node_id": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, "description": "Full retained Fabric confirm receipt. Unknown fields are retained, while the reconciliation inputs remain typed." }, "committed_at": { "$ref": "#/$defs/Timestamp" }, "policy_revision": { "type": "integer", "minimum": 0 }, "completion": { "$ref": "#/$defs/PairedSession" }, "grant_id": { "$ref": "#/$defs/Id" }, "result_fetched_at": { "$ref": "#/$defs/Timestamp" }, "remove_peer_receipt": { "type": "object" }, "revoke_receipt": { "type": "object" }, - "refused": { "type": "boolean" } - } + "cleanup_reason": { "type": "string", "minLength": 1 }, "cleanup_started_at": { "$ref": "#/$defs/Timestamp" }, + "refused": { "const": true, "description": "Present only for terminal refused S5 closures, which have no confirmation or grant." } + }, + "allOf": [ + { "if": { "properties": { "state": { "enum": ["S2", "S3"] } } }, "then": { "required": ["confirm_receipt", "committed_at", "policy_revision"] } }, + { "if": { "properties": { "state": { "const": "S3" } } }, "then": { "required": ["completion", "grant_id"] } }, + { "if": { "properties": { "state": { "enum": ["S4", "S5"] } } }, "then": { "required": ["cleanup_reason", "cleanup_started_at"] } }, + { "if": { "properties": { "state": { "enum": ["S4", "S5"] } }, "anyOf": [{ "required": ["confirm_receipt"] }, { "required": ["grant_id"] }] }, "then": { "required": ["confirm_receipt", "committed_at", "policy_revision"] } }, + { "if": { "properties": { "state": { "const": "S5" } }, "required": ["confirm_receipt"] }, "then": { "required": ["remove_peer_receipt"] } }, + { "if": { "properties": { "state": { "const": "S5" } }, "required": ["grant_id"] }, "then": { "required": ["revoke_receipt"] } }, + { "if": { "required": ["refused"] }, "then": { "properties": { "state": { "const": "S5" }, "confirm_receipt": false, "committed_at": false, "policy_revision": false, "grant_id": false, "completion": false } } } + ] }, "PairingChallenge": { "oneOf": [ From f111843b98301b7e18a7dcbeceed7a33ee4ac8cb Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:00:20 +0100 Subject: [PATCH 119/132] Specify held approval wait and digest-safe pairing request replay agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- docs/st3/client-v0/README.md | 37 ++++++++++++++++++- .../fixtures/pairing-approval-wait.json | 29 +++++++++++++++ .../client-v0/schemas/client-v0.schema.json | 33 +++++++++++++++-- 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 docs/st3/client-v0/fixtures/pairing-approval-wait.json diff --git a/docs/st3/client-v0/README.md b/docs/st3/client-v0/README.md index bfb47bf4127..ec9fdd5fdeb 100644 --- a/docs/st3/client-v0/README.md +++ b/docs/st3/client-v0/README.md @@ -3627,8 +3627,12 @@ The permit grants exactly the isolated client service, inbound-only, and expires at the earlier of the challenge expiry and ten minutes after issue. Each exchange message is one UTF-8 JSON envelope: -`{v:1, request_id, op, body}`. Request IDs cannot repeat within an attempt. -Allowed operations are `capabilities`, `claim`, `complete`, and `ack`. +`{v:1, request_id, op, body}`. A request ID is single-use per attempt and per op. +An identical body digest gets the retained reply or rejoins the in-flight wait; +the same ID with a different digest returns `pairing-request-conflict`. +A new ID for an already successful complete returns `pairing-claim-invalid`, +without a second grant. +Allowed operations are `capabilities`, `claim`, `approval-wait`, `complete`, and `ack`. Other operations return `pairing-route-not-allowed`. Complete before a successful claim returns `pairing-claim-invalid`. Claim and complete retain their signed HTTP request bodies. Success is `{v:1, request_id, ok:true, body}`; refusal is @@ -3638,6 +3642,35 @@ After queuing a refusal, the server waits for `ack` with body `{request_id: REFUSED_REQUEST_ID}` or the permit deadline before rejecting. Ack has no authority effect. +`approval-wait` uses body `{pairing_id,key_fingerprint}` in that envelope. +The fingerprint must equal the claimed key. The op carries no authority: +only the initiating issuer's local-person approval can approve. Subscribe to +Fractal changes before reading durable approval and expiry state. If already +approved, reply immediately; otherwise hold until approval, refusal or the absolute +permit deadline (at most ten minutes). There is no continuation token. +Return exactly one reply, whose success body is +`{status:"approved",key_fingerprint,approved_at}` (RFC3339 timestamp), or whose +typed refusal has code `pairing-rejected`, `pairing-expired`, +`pairing-key-mismatch`, or `pairing-wait-busy`; refusal message is optional. +Only one approval wait may be outstanding per attempt. A concurrent wait with a +new request ID returns `pairing-wait-busy`. An identical request rejoins the wait +or replays the reply; after restart, derive its answer from durable approval state. +Never push an unsolicited approved reply on the claim's request ID. + +The client sequence is: +1. Send `claim`, then use Fabric's bounded held receive for `awaiting-approval` + (or already `approved`). +2. Send `approval-wait`, then use held receive. Re-arm receive only after the + previous hold ends. No immediate receive loops or status polling are allowed. + Fabric registers before checking the queue, holds at most the earlier of + 30 seconds and permit deadline, and permits one held receive per attempt; + ordinary receive is unchanged. +3. On approved, send complete once and receive admission-pending, then send + application ack BEFORE waiting for Fabric confirm, which erases the queues. + After confirmation, follow the S1–S5 result flow below. +4. On refusal, send application ack; the owner rejects only after ack or deadline. + + The server stores the exact confirm request in S1 before calling Fabric confirm. The advertised completion NodeID must equal Fabric's authenticated bound NodeID. After confirm the server persists the retained receipt as S2, then atomically diff --git a/docs/st3/client-v0/fixtures/pairing-approval-wait.json b/docs/st3/client-v0/fixtures/pairing-approval-wait.json new file mode 100644 index 00000000000..d9ee4226a86 --- /dev/null +++ b/docs/st3/client-v0/fixtures/pairing-approval-wait.json @@ -0,0 +1,29 @@ +{ + "description": "Schema fixtures plus normative replay scenarios. Request ID is single-use per attempt and op: identical body digest replays retained reply or rejoins in-flight wait; changed digest refuses pairing-request-conflict. A new complete ID after successful completion refuses pairing-claim-invalid without another grant.", + "cases": [ + {"name":"valid wait request","schema":"PairingExchangeRequest","valid":true,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, + {"name":"missing fingerprint","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"pairing_id":"pairing/example"}}}, + {"name":"missing pairing ID","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, + {"name":"malformed fingerprint","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:short"}}}, + {"name":"authority field not permitted","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approve":true}}}, + {"name":"missing request ID","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, + {"name":"valid durable approved reply","schema":"PairingApprovalWaitReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approved_at":"2026-01-01T00:01:00Z"}}}, + {"name":"approved missing timestamp","schema":"PairingApprovalWaitReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, + {"name":"approved must not contain credential","schema":"PairingApprovalWaitReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approved_at":"2026-01-01T00:01:00Z","credential":"never-before-S3"}}}, + {"name":"rejected refusal","schema":"PairingApprovalWaitReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-rejected"}}}, + {"name":"expired refusal","schema":"PairingApprovalWaitReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-expired"}}}, + {"name":"mismatched key refusal","schema":"PairingApprovalWaitReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-key-mismatch"}}}, + {"name":"concurrent wait refusal","schema":"PairingApprovalWaitReply","valid":true,"value":{"v":1,"request_id":"wait-2","ok":false,"refusal":{"code":"pairing-wait-busy"}}}, + {"name":"unrecognized wait refusal","schema":"PairingApprovalWaitReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"not-a-wait-refusal"}}}, + {"name":"contradictory approved refusal","schema":"PairingApprovalWaitReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"refusal":{"code":"pairing-rejected"}}}, + {"name":"changed digest conflict envelope","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-request-conflict","message":"Request ID reused with a different body digest"}}} + ], + "replay_scenarios": [ + {"name":"identical wait while held","same_attempt":true,"same_op":true,"same_request_id":true,"same_body_digest":true,"expected":"rejoin-in-flight-wait","new_reply_count":1}, + {"name":"identical wait after reply","same_attempt":true,"same_op":true,"same_request_id":true,"same_body_digest":true,"expected":"retained-reply","new_authority":false}, + {"name":"identical wait after daemon restart","same_attempt":true,"same_op":true,"same_request_id":true,"same_body_digest":true,"expected":"reply-from-durable-approval-state","in_memory_wait_authoritative":false}, + {"name":"same request ID different digest","same_attempt":true,"same_op":true,"same_request_id":true,"same_body_digest":false,"expected_code":"pairing-request-conflict"}, + {"name":"new concurrent approval wait","same_attempt":true,"same_op":true,"same_request_id":false,"wait_in_flight":true,"expected_code":"pairing-wait-busy"}, + {"name":"new complete after successful complete","op":"complete","same_request_id":false,"previous_complete_succeeded":true,"expected_code":"pairing-claim-invalid","extra_grants":0} + ] +} diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index 94053232a68..a1d2d24ea38 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -640,6 +640,10 @@ "pairing-route-not-allowed", "pairing-result-pending", "pairing-result-expired", + "pairing-request-conflict", + "pairing-wait-busy", + "pairing-rejected", + "pairing-expired", "suspend-authored-session-selection", "stale-fence", "cursor-gap", @@ -4386,16 +4390,39 @@ "properties": { "state": { "const": "admission-pending" }, "pairing_id": { "$ref": "#/$defs/Id" } } }, "PairingExchangeRequest": { - "description": "One UTF-8 JSON envelope per Fabric exchange message. Request IDs are unique per attempt. Only capabilities, claim, complete and ack are allowed; complete requires a successful claim.", + "description": "One UTF-8 JSON envelope per Fabric exchange message. A request_id is single-use per attempt and op: identical body digest replays the retained reply or rejoins the in-flight wait; different digest refuses pairing-request-conflict. A new request_id for an already successful complete refuses pairing-claim-invalid without another grant. Only capabilities, claim, approval-wait, complete and ack are allowed; complete requires a successful claim.", "type": "object", "additionalProperties": false, "required": ["v", "request_id", "op", "body"], - "properties": { "v": { "const": 1 }, "request_id": { "type": "string", "minLength": 1 }, "op": { "enum": ["capabilities", "claim", "complete", "ack"] }, "body": {} }, + "properties": { "v": { "const": 1 }, "request_id": { "type": "string", "minLength": 1 }, "op": { "enum": ["capabilities", "claim", "approval-wait", "complete", "ack"] }, "body": {} }, "oneOf": [ { "properties": { "op": { "const": "capabilities" }, "body": { "type": "object", "additionalProperties": false } } }, { "properties": { "op": { "const": "claim" }, "body": { "$ref": "#/$defs/PairingKeyClaim" } } }, + { "properties": { "op": { "const": "approval-wait" }, "body": { "$ref": "#/$defs/PairingApprovalWaitRequest" } } }, { "properties": { "op": { "const": "complete" }, "body": { "$ref": "#/$defs/PairingComplete" } } }, { "properties": { "op": { "const": "ack" }, "body": { "type": "object", "additionalProperties": false, "required": ["request_id"], "properties": { "request_id": { "type": "string", "minLength": 1 } } } } } ] }, + "PairingApprovalWaitRequest": { + "description": "Envelope body for approval-wait. Fingerprint must equal the claimed key. No authority is conveyed. Subscribe before reading durable approval/expiry. Reply once at approval, refusal, or absolute permit deadline; no continuation token. At most one outstanding wait per attempt; a new concurrent request ID refuses pairing-wait-busy. After restart an identical request replays from durable state.", + "type": "object", "additionalProperties": false, "required": ["pairing_id", "key_fingerprint"], + "properties": { "pairing_id": { "$ref": "#/$defs/Id" }, "key_fingerprint": { "type": "string", "pattern": "^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$" } } + }, + "PairingApprovalWaitApproved": { + "type": "object", "additionalProperties": false, "required": ["status", "key_fingerprint", "approved_at"], + "properties": { "status": { "const": "approved" }, "key_fingerprint": { "type": "string", "pattern": "^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$" }, "approved_at": { "$ref": "#/$defs/Timestamp" } } + }, + "PairingApprovalWaitRefusal": { + "type": "object", "additionalProperties": false, "required": ["code"], + "properties": { "code": { "enum": ["pairing-rejected", "pairing-expired", "pairing-key-mismatch", "pairing-wait-busy"] }, "message": { "type": "string" } } + }, + "PairingApprovalWaitReply": { + "description": "Exactly one reply derived from durable local-person approval, never an unsolicited push on the claim request ID.", + "type": "object", "additionalProperties": false, "required": ["v", "request_id", "ok"], + "properties": { "v": { "const": 1 }, "request_id": { "type": "string", "minLength": 1 }, "ok": { "type": "boolean" }, "body": { "$ref": "#/$defs/PairingApprovalWaitApproved" }, "refusal": { "$ref": "#/$defs/PairingApprovalWaitRefusal" } }, + "oneOf": [ + { "required": ["body"], "properties": { "ok": { "const": true }, "refusal": false } }, + { "required": ["refusal"], "properties": { "ok": { "const": false }, "body": false } } + ] + }, "PairingExchangeRefusal": { "type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": { "code": { "$ref": "#/$defs/ErrorCode" }, "message": { "type": "string" } } @@ -4403,7 +4430,7 @@ "PairingExchangeReply": { "description": "Completion returns admission-pending, never a usable completion body. A typed refusal is queued until ack or the permit deadline, then the attempt is rejected. Ack has no authority effect.", "type": "object", "additionalProperties": false, "required": ["v", "request_id", "ok"], - "properties": { "v": { "const": 1 }, "request_id": { "type": "string" }, "ok": { "type": "boolean" }, "body": { "oneOf": [{ "$ref": "#/$defs/PairingAdvertisement" }, { "$ref": "#/$defs/PairingKeyStatus" }, { "$ref": "#/$defs/PairingAdmissionPending" }, { "type": "object", "additionalProperties": false }] }, "refusal": { "$ref": "#/$defs/PairingExchangeRefusal" } }, + "properties": { "v": { "const": 1 }, "request_id": { "type": "string", "minLength": 1 }, "ok": { "type": "boolean" }, "body": { "oneOf": [{ "$ref": "#/$defs/PairingAdvertisement" }, { "$ref": "#/$defs/PairingKeyStatus" }, { "$ref": "#/$defs/PairingApprovalWaitApproved" }, { "$ref": "#/$defs/PairingAdmissionPending" }, { "type": "object", "additionalProperties": false }] }, "refusal": { "anyOf": [{ "$ref": "#/$defs/PairingExchangeRefusal" }, { "$ref": "#/$defs/PairingApprovalWaitRefusal" }] } }, "oneOf": [ { "required": ["body"], "properties": { "ok": { "const": true }, "refusal": false } }, { "required": ["refusal"], "properties": { "ok": { "const": false }, "body": false } } From 56f1563b5f94bfd57193ecad57ef4e35d344f26e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:04:52 +0100 Subject: [PATCH 120/132] Unify pairing exchange replies and required refusal messages agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- docs/st3/client-v0/README.md | 4 +++- .../fixtures/pairing-approval-wait.json | 20 +++++++++---------- .../client-v0/schemas/client-v0.schema.json | 17 ++-------------- 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/docs/st3/client-v0/README.md b/docs/st3/client-v0/README.md index ec9fdd5fdeb..a1ccfcd05ef 100644 --- a/docs/st3/client-v0/README.md +++ b/docs/st3/client-v0/README.md @@ -3651,7 +3651,9 @@ permit deadline (at most ten minutes). There is no continuation token. Return exactly one reply, whose success body is `{status:"approved",key_fingerprint,approved_at}` (RFC3339 timestamp), or whose typed refusal has code `pairing-rejected`, `pairing-expired`, -`pairing-key-mismatch`, or `pairing-wait-busy`; refusal message is optional. +`pairing-key-mismatch`, `pairing-wait-busy`, or `pairing-request-conflict`. +Every refusal uses the single `PairingExchangeRefusal` type: both code and +message are required. `PairingExchangeReply` is the only reply envelope type. Only one approval wait may be outstanding per attempt. A concurrent wait with a new request ID returns `pairing-wait-busy`. An identical request rejoins the wait or replays the reply; after restart, derive its answer from durable approval state. diff --git a/docs/st3/client-v0/fixtures/pairing-approval-wait.json b/docs/st3/client-v0/fixtures/pairing-approval-wait.json index d9ee4226a86..dc997836fba 100644 --- a/docs/st3/client-v0/fixtures/pairing-approval-wait.json +++ b/docs/st3/client-v0/fixtures/pairing-approval-wait.json @@ -7,16 +7,16 @@ {"name":"malformed fingerprint","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:short"}}}, {"name":"authority field not permitted","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approve":true}}}, {"name":"missing request ID","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, - {"name":"valid durable approved reply","schema":"PairingApprovalWaitReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approved_at":"2026-01-01T00:01:00Z"}}}, - {"name":"approved missing timestamp","schema":"PairingApprovalWaitReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, - {"name":"approved must not contain credential","schema":"PairingApprovalWaitReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approved_at":"2026-01-01T00:01:00Z","credential":"never-before-S3"}}}, - {"name":"rejected refusal","schema":"PairingApprovalWaitReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-rejected"}}}, - {"name":"expired refusal","schema":"PairingApprovalWaitReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-expired"}}}, - {"name":"mismatched key refusal","schema":"PairingApprovalWaitReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-key-mismatch"}}}, - {"name":"concurrent wait refusal","schema":"PairingApprovalWaitReply","valid":true,"value":{"v":1,"request_id":"wait-2","ok":false,"refusal":{"code":"pairing-wait-busy"}}}, - {"name":"unrecognized wait refusal","schema":"PairingApprovalWaitReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"not-a-wait-refusal"}}}, - {"name":"contradictory approved refusal","schema":"PairingApprovalWaitReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"refusal":{"code":"pairing-rejected"}}}, - {"name":"changed digest conflict envelope","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-request-conflict","message":"Request ID reused with a different body digest"}}} + {"name":"valid durable approved reply","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approved_at":"2026-01-01T00:01:00Z"}}}, + {"name":"approved missing timestamp","schema":"PairingExchangeReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, + {"name":"approved must not contain credential","schema":"PairingExchangeReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approved_at":"2026-01-01T00:01:00Z","credential":"never-before-S3"}}}, + {"name":"rejected refusal","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-rejected","message":"Pairing rejected"}}}, + {"name":"expired refusal","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-expired","message":"Permit deadline reached"}}}, + {"name":"mismatched key refusal","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-key-mismatch","message":"Claimed key does not match"}}}, + {"name":"concurrent wait refusal","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-2","ok":false,"refusal":{"code":"pairing-wait-busy","message":"Approval wait already outstanding"}}}, + {"name":"refusal missing required message","schema":"PairingExchangeReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-rejected"}}}, + {"name":"contradictory approved refusal","schema":"PairingExchangeReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"refusal":{"code":"pairing-rejected","message":"Pairing rejected"}}}, + {"name":"changed digest conflict is valid approval-wait refusal","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-request-conflict","message":"Request ID reused with a different body digest"}}} ], "replay_scenarios": [ {"name":"identical wait while held","same_attempt":true,"same_op":true,"same_request_id":true,"same_body_digest":true,"expected":"rejoin-in-flight-wait","new_reply_count":1}, diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index a1d2d24ea38..db2c41ab81f 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -4402,7 +4402,7 @@ ] }, "PairingApprovalWaitRequest": { - "description": "Envelope body for approval-wait. Fingerprint must equal the claimed key. No authority is conveyed. Subscribe before reading durable approval/expiry. Reply once at approval, refusal, or absolute permit deadline; no continuation token. At most one outstanding wait per attempt; a new concurrent request ID refuses pairing-wait-busy. After restart an identical request replays from durable state.", + "description": "Envelope body for approval-wait. Fingerprint must equal the claimed key. No authority is conveyed. Subscribe before reading durable approval/expiry. Exactly one reply derived from durable local-person approval, never an unsolicited push on the claim request_id. Reply at approval, refusal, or absolute permit deadline; no continuation token. Allowed refusal codes: pairing-rejected, pairing-expired, pairing-key-mismatch, pairing-wait-busy, pairing-request-conflict. Every refusal uses PairingExchangeRefusal with code and message required. At most one outstanding wait per attempt; a new concurrent request ID refuses pairing-wait-busy. After restart an identical request replays from durable state.", "type": "object", "additionalProperties": false, "required": ["pairing_id", "key_fingerprint"], "properties": { "pairing_id": { "$ref": "#/$defs/Id" }, "key_fingerprint": { "type": "string", "pattern": "^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$" } } }, @@ -4410,19 +4410,6 @@ "type": "object", "additionalProperties": false, "required": ["status", "key_fingerprint", "approved_at"], "properties": { "status": { "const": "approved" }, "key_fingerprint": { "type": "string", "pattern": "^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$" }, "approved_at": { "$ref": "#/$defs/Timestamp" } } }, - "PairingApprovalWaitRefusal": { - "type": "object", "additionalProperties": false, "required": ["code"], - "properties": { "code": { "enum": ["pairing-rejected", "pairing-expired", "pairing-key-mismatch", "pairing-wait-busy"] }, "message": { "type": "string" } } - }, - "PairingApprovalWaitReply": { - "description": "Exactly one reply derived from durable local-person approval, never an unsolicited push on the claim request ID.", - "type": "object", "additionalProperties": false, "required": ["v", "request_id", "ok"], - "properties": { "v": { "const": 1 }, "request_id": { "type": "string", "minLength": 1 }, "ok": { "type": "boolean" }, "body": { "$ref": "#/$defs/PairingApprovalWaitApproved" }, "refusal": { "$ref": "#/$defs/PairingApprovalWaitRefusal" } }, - "oneOf": [ - { "required": ["body"], "properties": { "ok": { "const": true }, "refusal": false } }, - { "required": ["refusal"], "properties": { "ok": { "const": false }, "body": false } } - ] - }, "PairingExchangeRefusal": { "type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": { "code": { "$ref": "#/$defs/ErrorCode" }, "message": { "type": "string" } } @@ -4430,7 +4417,7 @@ "PairingExchangeReply": { "description": "Completion returns admission-pending, never a usable completion body. A typed refusal is queued until ack or the permit deadline, then the attempt is rejected. Ack has no authority effect.", "type": "object", "additionalProperties": false, "required": ["v", "request_id", "ok"], - "properties": { "v": { "const": 1 }, "request_id": { "type": "string", "minLength": 1 }, "ok": { "type": "boolean" }, "body": { "oneOf": [{ "$ref": "#/$defs/PairingAdvertisement" }, { "$ref": "#/$defs/PairingKeyStatus" }, { "$ref": "#/$defs/PairingApprovalWaitApproved" }, { "$ref": "#/$defs/PairingAdmissionPending" }, { "type": "object", "additionalProperties": false }] }, "refusal": { "anyOf": [{ "$ref": "#/$defs/PairingExchangeRefusal" }, { "$ref": "#/$defs/PairingApprovalWaitRefusal" }] } }, + "properties": { "v": { "const": 1 }, "request_id": { "type": "string", "minLength": 1 }, "ok": { "type": "boolean" }, "body": { "oneOf": [{ "$ref": "#/$defs/PairingAdvertisement" }, { "$ref": "#/$defs/PairingKeyStatus" }, { "$ref": "#/$defs/PairingApprovalWaitApproved" }, { "$ref": "#/$defs/PairingAdmissionPending" }, { "type": "object", "additionalProperties": false }] }, "refusal": { "$ref": "#/$defs/PairingExchangeRefusal" } }, "oneOf": [ { "required": ["body"], "properties": { "ok": { "const": true }, "refusal": false } }, { "required": ["refusal"], "properties": { "ok": { "const": false }, "body": false } } From 34288d9f4b558ae9763b2311b6161b26150d5fab Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:40:59 +0100 Subject: [PATCH 121/132] Make refused admission predicate explicit for schema generation agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- docs/st3/client-v0/schemas/client-v0.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index db2c41ab81f..95b618b5d98 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -4466,7 +4466,7 @@ { "if": { "properties": { "state": { "enum": ["S4", "S5"] } }, "anyOf": [{ "required": ["confirm_receipt"] }, { "required": ["grant_id"] }] }, "then": { "required": ["confirm_receipt", "committed_at", "policy_revision"] } }, { "if": { "properties": { "state": { "const": "S5" } }, "required": ["confirm_receipt"] }, "then": { "required": ["remove_peer_receipt"] } }, { "if": { "properties": { "state": { "const": "S5" } }, "required": ["grant_id"] }, "then": { "required": ["revoke_receipt"] } }, - { "if": { "required": ["refused"] }, "then": { "properties": { "state": { "const": "S5" }, "confirm_receipt": false, "committed_at": false, "policy_revision": false, "grant_id": false, "completion": false } } } + { "if": { "required": ["refused"], "properties": { "refused": { "const": true } } }, "then": { "properties": { "state": { "const": "S5" }, "confirm_receipt": false, "committed_at": false, "policy_revision": false, "grant_id": false, "completion": false } } } ] }, "PairingChallenge": { From e9771f638a35c2e39a8ca7ade13b1509b2b934a5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:54:47 +0100 Subject: [PATCH 122/132] Support object presence predicates in conditional client schemas agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-client-codegen/src/rich.rs | 42 ++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/fractal-client-codegen/src/rich.rs b/crates/fractal-client-codegen/src/rich.rs index 1f2d47788d9..48b4568aecf 100644 --- a/crates/fractal-client-codegen/src/rich.rs +++ b/crates/fractal-client-codegen/src/rich.rs @@ -876,6 +876,15 @@ fn validate_condition_schema(value: &Value, cx: &Cx, depth: usize) -> Result<()> validate_condition_schema(schema, cx, depth + 1)?; } } + "anyOf" => { + for schema in part.as_array().context("conditional presence alternatives")? { + let object = schema.as_object().context("conditional presence alternative")?; + if object.len() != 1 || !object.contains_key("required") { + bail!("conditional anyOf only supports presence predicates"); + } + validate_condition_schema(schema, cx, depth + 1)?; + } + } "items" => validate_condition_schema(part, cx, depth + 1)?, "oneOf" | "allOf" => { for schema in part.as_array().context("conditional alternatives")? { @@ -907,7 +916,6 @@ fn validate_condition_fields( let name = name.as_str().context("conditional required name")?; let field = fields.get(name) .with_context(|| format!("conditional field `{name}` is undeclared"))?; - validate_condition_schema(field, cx, depth + 1)?; if nullable_inner(field).is_some() { bail!("conditional presence of nullable field `{name}` is not translated"); } @@ -923,7 +931,7 @@ fn validate_condition_fields( validate_condition_value(assertion, field, cx, depth + 1)?; } } - "allOf" | "oneOf" => { + "allOf" | "oneOf" | "anyOf" => { for part in value.as_array().context("conditional alternatives")? { validate_condition_fields(part, fields, cx, depth + 1)?; } @@ -944,6 +952,10 @@ fn validate_condition_value(assertion: &Value, field: &Value, cx: &Cx, depth: us if depth > 64 { bail!("recursive conditional fields are not translated"); } + // Boolean field assertions inspect only presence, not the decoded field value. + if assertion.is_boolean() { + return Ok(()); + } validate_condition_schema(field, cx, depth)?; if let Some(reference) = field.get("$ref").and_then(Value::as_str) { let name = reference.strip_prefix("#/$defs/").context("only #/$defs/ refs")?; @@ -1034,7 +1046,7 @@ fn condition_predicate(value: &Value, target: &str, cx: &mut Cx) -> Result=" } )); } - "allOf" | "oneOf" => { + "allOf" | "oneOf" | "anyOf" => { let parts = value .as_array() .context("conditional alternatives")? @@ -1051,7 +1063,7 @@ fn condition_predicate(value: &Value, target: &str, cx: &mut Cx) -> Result>() - .join(" && ") + .join(if key == "anyOf" { " || " } else { " && " }) }); } _ => bail!("unsupported conditional assertion `{key}`"), @@ -1810,6 +1822,28 @@ fn emit_models( mod tests { use super::*; + #[test] + fn object_presence_conditions_do_not_validate_or_transform_field_values() { + let schema = json!({"x-st-integers":"json-safe", "$defs":{"Entry":{ + "type":"object", "additionalProperties":false, + "properties":{"state":{"type":"string"},"refused":{"const":true},"receipt":{"type":"object","additionalProperties":true},"grant":{"type":"string"}}, + "allOf":[ + {"if":{"properties":{"state":{"const":"closed"}},"anyOf":[{"required":["receipt"]},{"required":["grant"]}]},"then":{"required":["receipt"]}}, + {"if":{"required":["refused"],"properties":{"refused":{"const":true}}},"then":{"properties":{"receipt":false}}} + ] + }}}); + let generated = models(&schema).unwrap(); + assert!(generated.contains("Object.prototype.hasOwnProperty.call(o, \"receipt\")")); + assert!(generated.contains(" || ")); + assert_eq!(generated.matches("conditional object relationship").count(), 2); + let mut invalid = schema.clone(); + invalid["$defs"]["Entry"]["allOf"][0]["if"]["anyOf"][0] = json!({"properties":{"grant":{"const":"x"}}}); + assert!(format!("{:#}", models(&invalid).unwrap_err()).contains("only supports presence predicates")); + let mut undeclared = schema; + undeclared["$defs"]["Entry"]["allOf"][0]["if"]["anyOf"][0] = json!({"required":["unknown"]}); + assert!(format!("{:#}", models(&undeclared).unwrap_err()).contains("undeclared")); + } + #[test] fn subject_references_keep_string_length_constraints() -> Result<()> { let schema = json!({ From c322092eceb1797f628f87cecbe15d49b7e2c2f9 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:56:15 +0100 Subject: [PATCH 123/132] Validate approval wait request and reply schema fixtures agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-client-codegen/templates/lib.rs.in | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/fractal-client-codegen/templates/lib.rs.in b/crates/fractal-client-codegen/templates/lib.rs.in index 5c20e1f2f50..9d845f86f7c 100644 --- a/crates/fractal-client-codegen/templates/lib.rs.in +++ b/crates/fractal-client-codegen/templates/lib.rs.in @@ -3495,6 +3495,21 @@ fn percent_encode_segment(value: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn pairing_approval_wait_schema_validates_all_reply_and_request_fixtures() { + let contract: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/schemas/client-v0.schema.json" + )).unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-approval-wait.json" + )).unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let schema = serde_json::json!({"$ref": format!("#/$defs/{}", case["schema"].as_str().unwrap()), "$defs": contract["$defs"]}); + let validator = jsonschema::options().should_validate_formats(true).build(&schema).unwrap(); + assert_eq!(validator.is_valid(&case["value"]), case["valid"].as_bool().unwrap(), "{}", case["name"]); + } + } + #[test] fn pairing_admission_state_schema_validates_every_durable_branch() { let contract: serde_json::Value = serde_json::from_str(include_str!( From 4cf1c6b52e8fe590c886ee43ba68f7fc608a2d26 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:58:06 +0100 Subject: [PATCH 124/132] Express absolute QR gateway URI with supported schema format agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- docs/st3/client-v0/schemas/client-v0.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index 95b618b5d98..7a7b1dc92dc 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -4375,7 +4375,7 @@ "required": ["_tag", "version", "gateway", "id", "code", "expires_at_unix_ms"], "properties": { "_tag": { "const": "fractal.pairing" }, "version": { "enum": [1, 2] }, - "gateway": { "type": "string", "format": "uri" }, "id": { "$ref": "#/$defs/Id" }, + "gateway": { "type": "string", "format": "uri-reference", "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:" }, "id": { "$ref": "#/$defs/Id" }, "code": { "type": "string", "pattern": "^[A-Z2-9]{8}$" }, "expires_at_unix_ms": { "type": "integer" }, "fabric": { "$ref": "#/$defs/PairingFabricPermit" } }, From acd673da44b1f17db834263392333f6e0a2d4de4 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:59:48 +0100 Subject: [PATCH 125/132] Expose cold pairing refusal codes in native client templates agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../templates/Models.swift.in | 21 +++++++++++++++++++ .../templates/generated.rs.in | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/crates/fractal-client-codegen/templates/Models.swift.in b/crates/fractal-client-codegen/templates/Models.swift.in index bd57ca15bca..cd9f805ad05 100644 --- a/crates/fractal-client-codegen/templates/Models.swift.in +++ b/crates/fractal-client-codegen/templates/Models.swift.in @@ -163,6 +163,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case pairingKeyBindingRequired case pairingAlreadyClaimed case pairingClaimInvalid + case pairingRouteNotAllowed + case pairingResultPending + case pairingResultExpired + case pairingRequestConflict + case pairingWaitBusy + case pairingRejected + case pairingExpired case suspendAuthoredSessionSelection case legacyActionReceiptUntrusted case arrangementExists @@ -257,6 +264,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case "pairing-key-binding-required": .pairingKeyBindingRequired case "pairing-already-claimed": .pairingAlreadyClaimed case "pairing-claim-invalid": .pairingClaimInvalid + case "pairing-route-not-allowed": .pairingRouteNotAllowed + case "pairing-result-pending": .pairingResultPending + case "pairing-result-expired": .pairingResultExpired + case "pairing-request-conflict": .pairingRequestConflict + case "pairing-wait-busy": .pairingWaitBusy + case "pairing-rejected": .pairingRejected + case "pairing-expired": .pairingExpired case "invalid-answer": .invalidAnswer; case "not-answerable": .notAnswerable case "suspend-authored-session-selection": .suspendAuthoredSessionSelection case "legacy-action-receipt-untrusted": .legacyActionReceiptUntrusted @@ -353,6 +367,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case .pairingKeyBindingRequired: "pairing-key-binding-required" case .pairingAlreadyClaimed: "pairing-already-claimed" case .pairingClaimInvalid: "pairing-claim-invalid" + case .pairingRouteNotAllowed: "pairing-route-not-allowed" + case .pairingResultPending: "pairing-result-pending" + case .pairingResultExpired: "pairing-result-expired" + case .pairingRequestConflict: "pairing-request-conflict" + case .pairingWaitBusy: "pairing-wait-busy" + case .pairingRejected: "pairing-rejected" + case .pairingExpired: "pairing-expired" case .invalidAnswer: "invalid-answer"; case .notAnswerable: "not-answerable" case .suspendAuthoredSessionSelection: "suspend-authored-session-selection" case .legacyActionReceiptUntrusted: "legacy-action-receipt-untrusted" diff --git a/crates/fractal-client-codegen/templates/generated.rs.in b/crates/fractal-client-codegen/templates/generated.rs.in index e326b5845fb..57e9727782d 100644 --- a/crates/fractal-client-codegen/templates/generated.rs.in +++ b/crates/fractal-client-codegen/templates/generated.rs.in @@ -142,6 +142,13 @@ pub enum ErrorCode { PairingKeyBindingRequired, PairingAlreadyClaimed, PairingClaimInvalid, + PairingRouteNotAllowed, + PairingResultPending, + PairingResultExpired, + PairingRequestConflict, + PairingWaitBusy, + PairingRejected, + PairingExpired, SuspendAuthoredSessionSelection, StaleFence, CursorGap, From 928532f243394e50cddb26a390748ff13fd86f7a Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:02:42 +0100 Subject: [PATCH 126/132] Regenerate pairing schemas from acd673da44b1f17db834263392333f6e0a2d4de4 agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../Sources/FractalClient/Models.swift | 21 +++ .../fractal-client/Models.generated.ts | 140 +++++++++++++++ .../fractal-client/ReadSchema.generated.ts | 7 + .../fractal-client/Schema.generated.ts | 160 ++++++++++++++++-- crates/fractal-client/src/generated.rs | 7 + crates/fractal-client/src/lib.rs | 78 +++++++++ 6 files changed, 398 insertions(+), 15 deletions(-) diff --git a/clients/swift/FractalClient/Sources/FractalClient/Models.swift b/clients/swift/FractalClient/Sources/FractalClient/Models.swift index 4176a4c342c..b2a711fdddf 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Models.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Models.swift @@ -163,6 +163,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case pairingKeyBindingRequired case pairingAlreadyClaimed case pairingClaimInvalid + case pairingRouteNotAllowed + case pairingResultPending + case pairingResultExpired + case pairingRequestConflict + case pairingWaitBusy + case pairingRejected + case pairingExpired case suspendAuthoredSessionSelection case legacyActionReceiptUntrusted case arrangementExists @@ -257,6 +264,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case "pairing-key-binding-required": .pairingKeyBindingRequired case "pairing-already-claimed": .pairingAlreadyClaimed case "pairing-claim-invalid": .pairingClaimInvalid + case "pairing-route-not-allowed": .pairingRouteNotAllowed + case "pairing-result-pending": .pairingResultPending + case "pairing-result-expired": .pairingResultExpired + case "pairing-request-conflict": .pairingRequestConflict + case "pairing-wait-busy": .pairingWaitBusy + case "pairing-rejected": .pairingRejected + case "pairing-expired": .pairingExpired case "invalid-answer": .invalidAnswer; case "not-answerable": .notAnswerable case "suspend-authored-session-selection": .suspendAuthoredSessionSelection case "legacy-action-receipt-untrusted": .legacyActionReceiptUntrusted @@ -353,6 +367,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case .pairingKeyBindingRequired: "pairing-key-binding-required" case .pairingAlreadyClaimed: "pairing-already-claimed" case .pairingClaimInvalid: "pairing-claim-invalid" + case .pairingRouteNotAllowed: "pairing-route-not-allowed" + case .pairingResultPending: "pairing-result-pending" + case .pairingResultExpired: "pairing-result-expired" + case .pairingRequestConflict: "pairing-request-conflict" + case .pairingWaitBusy: "pairing-wait-busy" + case .pairingRejected: "pairing-rejected" + case .pairingExpired: "pairing-expired" case .invalidAnswer: "invalid-answer"; case .notAnswerable: "not-answerable" case .suspendAuthoredSessionSelection: "suspend-authored-session-selection" case .legacyActionReceiptUntrusted: "legacy-action-receipt-untrusted" diff --git a/clients/typescript/fractal-client/Models.generated.ts b/clients/typescript/fractal-client/Models.generated.ts index 54f9400e90d..2c73216140a 100644 --- a/clients/typescript/fractal-client/Models.generated.ts +++ b/clients/typescript/fractal-client/Models.generated.ts @@ -1895,6 +1895,13 @@ export type ErrorCode = ( | "pairing-key-binding-required" | "pairing-already-claimed" | "pairing-claim-invalid" + | "pairing-route-not-allowed" + | "pairing-result-pending" + | "pairing-result-expired" + | "pairing-request-conflict" + | "pairing-wait-busy" + | "pairing-rejected" + | "pairing-expired" | "suspend-authored-session-selection" | "stale-fence" | "cursor-gap" @@ -2983,6 +2990,41 @@ export type PageInfo = { export type PairedSession = (PersonPairedSession | AssistantPairedSession); +export type PairingAdmissionPending = { + pairing_id: Id; + state: "admission-pending"; +}; + +export type PairingAdmissionRecord = { + attempt_id: string; + bound_node_id: string; + cleanup_reason?: string; + cleanup_started_at?: Timestamp; + committed_at?: Timestamp; + completion?: PairedSession; + confirm_receipt?: { + committed_at: Timestamp; + node_id: string; + policy_revision: number; +}; + confirm_request: PairingFabricConfirmRequest; + creation_id: string; + device_public_key: string; + grant_id?: Id; + pairing_id: Id; + permit_deadline: Timestamp; + policy_revision?: number; + refused?: true; + remove_peer_receipt?: { + +}; + result_fetched_at?: Timestamp; + revoke_receipt?: { + +}; + state: "S1" | "S2" | "S3" | "S4" | "S5"; +}; + export type PairingAdvertisement = { api_version: "st3.client.v0" | "fractal.client.v0"; capabilities: Array<{ @@ -2992,6 +3034,17 @@ export type PairingAdvertisement = { }>; }; +export type PairingApprovalWaitApproved = { + approved_at: Timestamp; + key_fingerprint: string; + status: "approved"; +}; + +export type PairingApprovalWaitRequest = { + key_fingerprint: string; + pairing_id: Id; +}; + export type PairingBegin = (PersonPairingBegin | AssistantPairingBegin); export type PairingChallenge = { @@ -3032,6 +3085,65 @@ export type PairingComplete = { proof?: string; }; +export type PairingExchangeRefusal = { + code: ErrorCode; + message: string; +}; + +export type PairingExchangeReply = { + body?: (PairingAdvertisement | PairingKeyStatus | PairingApprovalWaitApproved | PairingAdmissionPending | Record); + ok: boolean; + refusal?: PairingExchangeRefusal; + request_id: string; + v: 1; +} & ({ + ok?: true; + refusal?: never; +} | { + body?: never; + ok?: false; +}); + +export type PairingExchangeRequest = { + body: unknown; + op: "capabilities" | "claim" | "approval-wait" | "complete" | "ack"; + request_id: string; + v: 1; +} & ({ + body?: Record; + op?: "capabilities"; +} | { + body?: PairingKeyClaim; + op?: "claim"; +} | { + body?: PairingApprovalWaitRequest; + op?: "approval-wait"; +} | { + body?: PairingComplete; + op?: "complete"; +} | { + body?: { + request_id: string; +}; + op?: "ack"; +}); + +export type PairingFabricConfirmRequest = { + attempt_id: string; + expected_attempt_revision: number; + expected_node_id: string; + expected_policy_revision: number; + operation: "confirm"; + owner: string; + request_id: string; +}; + +export type PairingFabricPermit = { + attempt_id: string; + node_id: string; + secret: string; +}; + export type PairingKeyApproval = { api_version: "st3.client.v0" | "fractal.client.v0"; device_key_fingerprint: string; @@ -3051,6 +3163,34 @@ export type PairingKeyStatus = { status: "awaiting-key" | "awaiting-approval" | "approved" | "completed" | "expired"; }; +export type PairingQrPayload = { + _tag: "fractal.pairing"; + code: string; + expires_at_unix_ms: number; + fabric?: PairingFabricPermit; + gateway: string; + id: Id; + version: 1 | 2; +} & ({ + fabric?: never; + version?: 1; +} | { + version?: 2; +}); + +export type PairingResultPending = { + code: "pairing-result-pending"; + deadline: Timestamp; + retryable: true; +}; + +export type PairingResultRequest = { + api_version: "st3.client.v0" | "fractal.client.v0"; + attempt_id: string; + device_public_key: string; + proof: string; +}; + export type ParticipantRef = string; export type PersonAnswer = { diff --git a/clients/typescript/fractal-client/ReadSchema.generated.ts b/clients/typescript/fractal-client/ReadSchema.generated.ts index d346b54a0d5..e7c97307f78 100644 --- a/clients/typescript/fractal-client/ReadSchema.generated.ts +++ b/clients/typescript/fractal-client/ReadSchema.generated.ts @@ -1755,6 +1755,13 @@ export const ErrorCode = /*#__PURE__*/ (() => openEnum([ "pairing-key-binding-required", "pairing-already-claimed", "pairing-claim-invalid", + "pairing-route-not-allowed", + "pairing-result-pending", + "pairing-result-expired", + "pairing-request-conflict", + "pairing-wait-busy", + "pairing-rejected", + "pairing-expired", "suspend-authored-session-selection", "stale-fence", "cursor-gap", diff --git a/clients/typescript/fractal-client/Schema.generated.ts b/clients/typescript/fractal-client/Schema.generated.ts index 4c77a8f999b..b4c1fb707b2 100644 --- a/clients/typescript/fractal-client/Schema.generated.ts +++ b/clients/typescript/fractal-client/Schema.generated.ts @@ -1012,6 +1012,13 @@ export const ErrorEnvelope = /*#__PURE__*/ (() => Schema.Union([ Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-key-binding-required"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-already-claimed"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-claim-invalid"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-route-not-allowed"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-result-pending"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-result-expired"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-request-conflict"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-wait-busy"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-rejected"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-expired"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("suspend-authored-session-selection"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("stale-fence"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("cursor-gap"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), @@ -1052,7 +1059,7 @@ export const ErrorEnvelope = /*#__PURE__*/ (() => Schema.Union([ Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("invalid-answer"), "details": HarnessInvalidAnswerDetails, "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("not-answerable"), "details": HarnessNotAnswerableDetails, "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("internal"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), - Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": unknownCase(["recipient-retired","agents-publication-row-too-large","identity-cycle","identity-occupied","identity-retired-source","identity-cross-kind","identity-unknown","lifecycle-unavailable","retirement-upgrade-required","retire-revision-mismatch","retire-incarnation-mismatch","invalid-custody-successor","custody-pending","attention-migrated","arrangement-exists","arrangement-folder-exists","arrangement-retired","arrangement-limit","arrangement-folder-deleted","arrangement-cycle","arrangement-body-too-large","arrangement-owner-forbidden","invalid-arrangement-subject","invalid-arrangement-action","invalid-arrangement-operations","invalid-arrangement-folder","invalid-arrangement-name","invalid-arrangement-key","invalid-arrangement-version","invalid-arrangement-body","unsupported-membership-container","membership-owner-forbidden","invalid-membership-member","invalid-membership-bucket","invalid-membership-key","invalid-membership-operations","membership-edit-too-large","invalid-subject-reference","not-found","forbidden","unsupported-capability","peer-too-old","validation-failed","idempotency-conflict","legacy-action-receipt-untrusted","issuer-required","pairing-origin-required","pairing-restart-required","pairing-key-mismatch","pairing-key-binding-required","pairing-already-claimed","pairing-claim-invalid","suspend-authored-session-selection","stale-fence","cursor-gap","page-cursor-expired","agent-roster-not-ready","agent-roster-cut-not-ready","message-selectors-not-ready","rate-limited","runtime-not-local","runtime-authority-indeterminate","runtime-list-not-ready","runtime-list-ended","remote-unavailable","terminal-unavailable","inventory-unavailable","terminal-ended","timeline-history-incomplete","projection-detail-too-large","projection-detail-invalid-source","conversation-content-invalidated","transcript-unavailable","blob-too-large","unsupported-media-type","blob-content-mismatch","blob-quota-exceeded","blob-not-found","blob-expired","message-too-large","message-store-full","long-message-signature-unsupported","response-budget-exceeded","content-not-text","invalid-document-reference","document-not-synced","private-notes-unreachable","private-notes-carrier-conflict","private-notes-indeterminate","invalid-answer","not-answerable","internal"]), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }) + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": unknownCase(["recipient-retired","agents-publication-row-too-large","identity-cycle","identity-occupied","identity-retired-source","identity-cross-kind","identity-unknown","lifecycle-unavailable","retirement-upgrade-required","retire-revision-mismatch","retire-incarnation-mismatch","invalid-custody-successor","custody-pending","attention-migrated","arrangement-exists","arrangement-folder-exists","arrangement-retired","arrangement-limit","arrangement-folder-deleted","arrangement-cycle","arrangement-body-too-large","arrangement-owner-forbidden","invalid-arrangement-subject","invalid-arrangement-action","invalid-arrangement-operations","invalid-arrangement-folder","invalid-arrangement-name","invalid-arrangement-key","invalid-arrangement-version","invalid-arrangement-body","unsupported-membership-container","membership-owner-forbidden","invalid-membership-member","invalid-membership-bucket","invalid-membership-key","invalid-membership-operations","membership-edit-too-large","invalid-subject-reference","not-found","forbidden","unsupported-capability","peer-too-old","validation-failed","idempotency-conflict","legacy-action-receipt-untrusted","issuer-required","pairing-origin-required","pairing-restart-required","pairing-key-mismatch","pairing-key-binding-required","pairing-already-claimed","pairing-claim-invalid","pairing-route-not-allowed","pairing-result-pending","pairing-result-expired","pairing-request-conflict","pairing-wait-busy","pairing-rejected","pairing-expired","suspend-authored-session-selection","stale-fence","cursor-gap","page-cursor-expired","agent-roster-not-ready","agent-roster-cut-not-ready","message-selectors-not-ready","rate-limited","runtime-not-local","runtime-authority-indeterminate","runtime-list-not-ready","runtime-list-ended","remote-unavailable","terminal-unavailable","inventory-unavailable","terminal-ended","timeline-history-incomplete","projection-detail-too-large","projection-detail-invalid-source","conversation-content-invalidated","transcript-unavailable","blob-too-large","unsupported-media-type","blob-content-mismatch","blob-quota-exceeded","blob-not-found","blob-expired","message-too-large","message-store-full","long-message-signature-unsupported","response-budget-exceeded","content-not-text","invalid-document-reference","document-not-synced","private-notes-unreachable","private-notes-carrier-conflict","private-notes-indeterminate","invalid-answer","not-answerable","internal"]), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }) ], { mode: "oneOf" }).annotate({ identifier: "ErrorEnvelope" }))() export type ErrorEnvelope = typeof ErrorEnvelope.Type export type ErrorEnvelopeEncoded = typeof ErrorEnvelope.Encoded @@ -1292,6 +1299,70 @@ export const ObservationResponse = /*#__PURE__*/ (() => Schema.Struct({ export type ObservationResponse = typeof ObservationResponse.Type export type ObservationResponseEncoded = typeof ObservationResponse.Encoded +/** Exchange completion acknowledges durable S1 only, never a credential or a grant. */ +export const PairingAdmissionPending = /*#__PURE__*/ (() => Schema.Struct({ + "pairing_id": Id, + "state": Schema.Literal("admission-pending") +}).annotate({ identifier: "PairingAdmissionPending", description: "Exchange completion acknowledges durable S1 only, never a credential or a grant." }))() +export type PairingAdmissionPending = typeof PairingAdmissionPending.Type +export type PairingAdmissionPendingEncoded = typeof PairingAdmissionPending.Encoded + +/** Exact request persisted before calling Fabric confirm; retries use the identical body. */ +export const PairingFabricConfirmRequest = /*#__PURE__*/ (() => Schema.Struct({ + "attempt_id": Schema.String, + "expected_attempt_revision": Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + "expected_node_id": Schema.String.check(Schema.isPattern(new RegExp("^[0-9a-f]{64}$", "u"))), + "expected_policy_revision": Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + "operation": Schema.Literal("confirm"), + "owner": Schema.String, + "request_id": Schema.String +}).annotate({ identifier: "PairingFabricConfirmRequest", description: "Exact request persisted before calling Fabric confirm; retries use the identical body." }))() +export type PairingFabricConfirmRequest = typeof PairingFabricConfirmRequest.Type +export type PairingFabricConfirmRequestEncoded = typeof PairingFabricConfirmRequest.Encoded + +/** Durable state only moves forward S1 admission-pending, S2 confirmed-unfinalized, S3 finalized, S4 cleanup tombstone, S5 closed. S4 is persisted before remove_peer, then grant revoke, then S5. Reconcile current owner access at startup and before transitions; absent access never resurrects. Sweep orphans only after S1 reconciliation. */ +export const PairingAdmissionRecord = /*#__PURE__*/ (() => Schema.Struct({ + "attempt_id": Schema.String, + "bound_node_id": Schema.String.check(Schema.isPattern(new RegExp("^[0-9a-f]{64}$", "u"))), + "cleanup_reason": optionalKey(Schema.String.check(Schema.isMinCodePoints(1))), + "cleanup_started_at": optionalKey(Timestamp), + "committed_at": optionalKey(Timestamp), + "completion": optionalKey(PairedSession), + /** Full retained Fabric confirm receipt. Unknown fields are retained, while the reconciliation inputs remain typed. */ + "confirm_receipt": optionalKey(Schema.Struct({ "committed_at": Timestamp, "node_id": Schema.String.check(Schema.isPattern(new RegExp("^[0-9a-f]{64}$", "u"))), "policy_revision": Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })).annotate({ description: "Full retained Fabric confirm receipt. Unknown fields are retained, while the reconciliation inputs remain typed." }), + "confirm_request": PairingFabricConfirmRequest, + "creation_id": Schema.String, + "device_public_key": Schema.String, + "grant_id": optionalKey(Id), + "pairing_id": Id, + "permit_deadline": Timestamp, + "policy_revision": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + /** Present only for terminal refused S5 closures, which have no confirmation or grant. */ + "refused": optionalKey(Schema.Literal(true)).annotate({ description: "Present only for terminal refused S5 closures, which have no confirmation or grant." }), + "remove_peer_receipt": optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + "result_fetched_at": optionalKey(Timestamp), + "revoke_receipt": optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + "state": Schema.Literals(["S1","S2","S3","S4","S5"]) +}).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((["S2","S3"] as ReadonlyArray).includes((o as Record)["state"])))))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "confirm_receipt"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "committed_at"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "policy_revision")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((o as Record)["state"] === "S3"))))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "completion"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "grant_id")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((["S4","S5"] as ReadonlyArray).includes((o as Record)["state"])))))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "cleanup_reason"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "cleanup_started_at")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "confirm_receipt")))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "grant_id"))))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((["S4","S5"] as ReadonlyArray).includes((o as Record)["state"])))))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "confirm_receipt"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "committed_at"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "policy_revision")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((o as Record)["state"] === "S5")))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "confirm_receipt")))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "remove_peer_receipt")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((o as Record)["state"] === "S5")))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "grant_id")))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "revoke_receipt")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "refused") || (((o as Record)["refused"] === true)))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "refused")))) || (((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "committed_at") || (false))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "completion") || (false))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "confirm_receipt") || (false))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "grant_id") || (false))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "policy_revision") || (false))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((o as Record)["state"] === "S5"))))), { expected: "conditional object relationship" })).annotate({ identifier: "PairingAdmissionRecord", description: "Durable state only moves forward S1 admission-pending, S2 confirmed-unfinalized, S3 finalized, S4 cleanup tombstone, S5 closed. S4 is persisted before remove_peer, then grant revoke, then S5. Reconcile current owner access at startup and before transitions; absent access never resurrects. Sweep orphans only after S1 reconciliation." }))() +export type PairingAdmissionRecord = typeof PairingAdmissionRecord.Type +export type PairingAdmissionRecordEncoded = typeof PairingAdmissionRecord.Encoded + +export const PairingApprovalWaitApproved = /*#__PURE__*/ (() => Schema.Struct({ + "approved_at": Timestamp, + "key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))), + "status": Schema.Literal("approved") +}).annotate({ identifier: "PairingApprovalWaitApproved" }))() +export type PairingApprovalWaitApproved = typeof PairingApprovalWaitApproved.Type +export type PairingApprovalWaitApprovedEncoded = typeof PairingApprovalWaitApproved.Encoded + +/** Envelope body for approval-wait. Fingerprint must equal the claimed key. No authority is conveyed. Subscribe before reading durable approval/expiry. Exactly one reply derived from durable local-person approval, never an unsolicited push on the claim request_id. Reply at approval, refusal, or absolute permit deadline; no continuation token. Allowed refusal codes: pairing-rejected, pairing-expired, pairing-key-mismatch, pairing-wait-busy, pairing-request-conflict. Every refusal uses PairingExchangeRefusal with code and message required. At most one outstanding wait per attempt; a new concurrent request ID refuses pairing-wait-busy. After restart an identical request replays from durable state. */ +export const PairingApprovalWaitRequest = /*#__PURE__*/ (() => Schema.Struct({ + "key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))), + "pairing_id": Id +}).annotate({ identifier: "PairingApprovalWaitRequest", description: "Envelope body for approval-wait. Fingerprint must equal the claimed key. No authority is conveyed. Subscribe before reading durable approval/expiry. Exactly one reply derived from durable local-person approval, never an unsolicited push on the claim request_id. Reply at approval, refusal, or absolute permit deadline; no continuation token. Allowed refusal codes: pairing-rejected, pairing-expired, pairing-key-mismatch, pairing-wait-busy, pairing-request-conflict. Every refusal uses PairingExchangeRefusal with code and message required. At most one outstanding wait per attempt; a new concurrent request ID refuses pairing-wait-busy. After restart an identical request replays from durable state." }))() +export type PairingApprovalWaitRequest = typeof PairingApprovalWaitRequest.Type +export type PairingApprovalWaitRequestEncoded = typeof PairingApprovalWaitRequest.Encoded + export const PersonPairingBegin = /*#__PURE__*/ (() => Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), /** Absent means optional. Required seals the challenge before QR display and requires a signed scanning-key claim followed by trusted local approval. */ @@ -1333,12 +1404,28 @@ export const PairingComplete = /*#__PURE__*/ (() => Schema.Struct({ export type PairingComplete = typeof PairingComplete.Type export type PairingCompleteEncoded = typeof PairingComplete.Encoded -export const PairingKeyApproval = /*#__PURE__*/ (() => Schema.Struct({ - "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), - "device_key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))) -}).annotate({ identifier: "PairingKeyApproval" }))() -export type PairingKeyApproval = typeof PairingKeyApproval.Type -export type PairingKeyApprovalEncoded = typeof PairingKeyApproval.Encoded +export const PairingExchangeRefusal = /*#__PURE__*/ (() => Schema.Struct({ + "code": ErrorCode, + "message": Schema.String +}).annotate({ identifier: "PairingExchangeRefusal" }))() +export type PairingExchangeRefusal = typeof PairingExchangeRefusal.Type +export type PairingExchangeRefusalEncoded = typeof PairingExchangeRefusal.Encoded + +export const PairingKeyStatus = /*#__PURE__*/ (() => Schema.Struct({ + "device_key_fingerprint": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u")))), + "expires_at": Timestamp, + "kind": Schema.Literal("pairing-key-status"), + "status": Schema.Literals(["awaiting-key","awaiting-approval","approved","completed","expired"]) +}).annotate({ identifier: "PairingKeyStatus" }))() +export type PairingKeyStatus = typeof PairingKeyStatus.Type +export type PairingKeyStatusEncoded = typeof PairingKeyStatus.Encoded + +export const PairingExchangeReply = /*#__PURE__*/ (() => Schema.Union([ + Schema.Struct({ "body": Schema.Union([Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "capabilities": Schema.Array(Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(2)), "approved_at": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "device_key_fingerprint": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u")))), "expires_at": Timestamp, "kind": Schema.Literal("pairing-key-status"), "status": Schema.Literals(["awaiting-key","awaiting-approval","approved","completed","expired"]), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "approved_at": Timestamp, "key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))), "status": Schema.Literal("approved"), "api_version": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "pairing_id": Id, "state": Schema.Literal("admission-pending"), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Record(Schema.String, Schema.Never)], { mode: "oneOf" }), "ok": Schema.Literal(true), "refusal": optionalKey(Schema.Never), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": optionalKey(Schema.Never), "ok": Schema.Literal(false), "refusal": PairingExchangeRefusal, "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }) +], { mode: "oneOf" }).annotate({ identifier: "PairingExchangeReply" }))() +export type PairingExchangeReply = typeof PairingExchangeReply.Type +export type PairingExchangeReplyEncoded = typeof PairingExchangeReply.Encoded export const PairingKeyClaim = /*#__PURE__*/ (() => Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), @@ -1351,14 +1438,57 @@ export const PairingKeyClaim = /*#__PURE__*/ (() => Schema.Struct({ export type PairingKeyClaim = typeof PairingKeyClaim.Type export type PairingKeyClaimEncoded = typeof PairingKeyClaim.Encoded -export const PairingKeyStatus = /*#__PURE__*/ (() => Schema.Struct({ - "device_key_fingerprint": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u")))), - "expires_at": Timestamp, - "kind": Schema.Literal("pairing-key-status"), - "status": Schema.Literals(["awaiting-key","awaiting-approval","approved","completed","expired"]) -}).annotate({ identifier: "PairingKeyStatus" }))() -export type PairingKeyStatus = typeof PairingKeyStatus.Type -export type PairingKeyStatusEncoded = typeof PairingKeyStatus.Encoded +export const PairingExchangeRequest = /*#__PURE__*/ (() => Schema.Union([ + Schema.Struct({ "body": Schema.Record(Schema.String, Schema.Never), "op": Schema.Literal("capabilities"), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": PairingKeyClaim, "op": Schema.Literal("claim"), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": PairingApprovalWaitRequest, "op": Schema.Literal("approval-wait"), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": PairingComplete, "op": Schema.Literal("complete"), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": Schema.Struct({ "request_id": Schema.String.check(Schema.isMinCodePoints(1)) }), "op": Schema.Literal("ack"), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }) +], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("op")).annotate({ identifier: "PairingExchangeRequest" }))() +export type PairingExchangeRequest = typeof PairingExchangeRequest.Type +export type PairingExchangeRequestEncoded = typeof PairingExchangeRequest.Encoded + +/** Cold-bootstrap permit. Secret stays in memory only, is never logged or persisted, and is cleared when the attempt ends. Node ID pins the gateway dial target. */ +export const PairingFabricPermit = /*#__PURE__*/ (() => Schema.Struct({ + "attempt_id": Schema.String.check(Schema.isMinCodePoints(1)), + "node_id": Schema.String.check(Schema.isPattern(new RegExp("^[0-9a-f]{64}$", "u"))), + "secret": Schema.RedactedFromValue(Schema.String.check(Schema.isMinCodePoints(1))) +}).annotate({ identifier: "PairingFabricPermit", description: "Cold-bootstrap permit. Secret stays in memory only, is never logged or persisted, and is cleared when the attempt ends. Node ID pins the gateway dial target." }))() +export type PairingFabricPermit = typeof PairingFabricPermit.Type +export type PairingFabricPermitEncoded = typeof PairingFabricPermit.Encoded + +export const PairingKeyApproval = /*#__PURE__*/ (() => Schema.Struct({ + "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), + "device_key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))) +}).annotate({ identifier: "PairingKeyApproval" }))() +export type PairingKeyApproval = typeof PairingKeyApproval.Type +export type PairingKeyApprovalEncoded = typeof PairingKeyApproval.Encoded + +export const PairingQrPayload = /*#__PURE__*/ (() => Schema.Union([ + Schema.Struct({ "_tag": Schema.Literal("fractal.pairing"), "code": Schema.String.check(Schema.isPattern(new RegExp("^[A-Z2-9]{8}$", "u"))), "expires_at_unix_ms": Schema.Int, "fabric": optionalKey(Schema.Never), "gateway": Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z][A-Za-z0-9+.-]*:", "u"))).check(Schema.isPattern(URI_REFERENCE, { expected: "an RFC 3986 URI reference" })), "id": Id, "version": Schema.Literal(1) }), + Schema.Struct({ "_tag": Schema.Literal("fractal.pairing"), "code": Schema.String.check(Schema.isPattern(new RegExp("^[A-Z2-9]{8}$", "u"))), "expires_at_unix_ms": Schema.Int, "fabric": PairingFabricPermit, "gateway": Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z][A-Za-z0-9+.-]*:", "u"))).check(Schema.isPattern(URI_REFERENCE, { expected: "an RFC 3986 URI reference" })), "id": Id, "version": Schema.Literal(2) }) +], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("version")).annotate({ identifier: "PairingQrPayload" }))() +export type PairingQrPayload = typeof PairingQrPayload.Type +export type PairingQrPayloadEncoded = typeof PairingQrPayload.Encoded + +/** Reactive hold lasts at most 30 seconds. A second hold replaces the first, returning pending to the first. Reissue immediately, stop at deadline. Deadline is retained committed_at + 600 seconds in S2/S3 or the permit deadline in S1. */ +export const PairingResultPending = /*#__PURE__*/ (() => Schema.Struct({ + "code": Schema.Literal("pairing-result-pending"), + "deadline": Timestamp, + "retryable": Schema.Literal(true) +}).annotate({ identifier: "PairingResultPending", description: "Reactive hold lasts at most 30 seconds. A second hold replaces the first, returning pending to the first. Reissue immediately, stop at deadline. Deadline is retained committed_at + 600 seconds in S2/S3 or the permit deadline in S1." }))() +export type PairingResultPending = typeof PairingResultPending.Type +export type PairingResultPendingEncoded = typeof PairingResultPending.Encoded + +/** POST /v1/client/pairings/{id}/result, only on the isolated Fabric-preface receiver. Sign four UTF-8 lines: pairing.result.v1, full pairing ID, attempt_id, bound NodeID; single LF separators, no trailing LF. Same key/signature encoding as claim. Transport NodeID comes only from the trusted Fabric preface, never this body, a query or an HTTP header. */ +export const PairingResultRequest = /*#__PURE__*/ (() => Schema.Struct({ + "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), + "attempt_id": Schema.String.check(Schema.isMinCodePoints(1)), + "device_public_key": Schema.String.check(Schema.isMinCodePoints(32)), + "proof": Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_-]{85}[AQgw]$", "u"))) +}).annotate({ identifier: "PairingResultRequest", description: "POST /v1/client/pairings/{id}/result, only on the isolated Fabric-preface receiver. Sign four UTF-8 lines: pairing.result.v1, full pairing ID, attempt_id, bound NodeID; single LF separators, no trailing LF. Same key/signature encoding as claim. Transport NodeID comes only from the trusted Fabric preface, never this body, a query or an HTTP header." }))() +export type PairingResultRequest = typeof PairingResultRequest.Type +export type PairingResultRequestEncoded = typeof PairingResultRequest.Encoded /** A paired phone's APNs device token. The person and device come only from the paired session; any other field, including a person or device identity, is refused. The token is a secret: it is stored only on the member that issued the pairing and never appears in claims, replication, resources, logs or error messages. */ export const PushRegistration = /*#__PURE__*/ (() => Schema.Struct({ diff --git a/crates/fractal-client/src/generated.rs b/crates/fractal-client/src/generated.rs index 3a7b5e5e870..95ada7e4805 100644 --- a/crates/fractal-client/src/generated.rs +++ b/crates/fractal-client/src/generated.rs @@ -142,6 +142,13 @@ pub enum ErrorCode { PairingKeyBindingRequired, PairingAlreadyClaimed, PairingClaimInvalid, + PairingRouteNotAllowed, + PairingResultPending, + PairingResultExpired, + PairingRequestConflict, + PairingWaitBusy, + PairingRejected, + PairingExpired, SuspendAuthoredSessionSelection, StaleFence, CursorGap, diff --git a/crates/fractal-client/src/lib.rs b/crates/fractal-client/src/lib.rs index 16ce1966558..44c86feb8d9 100644 --- a/crates/fractal-client/src/lib.rs +++ b/crates/fractal-client/src/lib.rs @@ -5370,6 +5370,84 @@ fn percent_encode_segment(value: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn pairing_approval_wait_schema_validates_all_reply_and_request_fixtures() { + let contract: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/schemas/client-v0.schema.json" + )) + .unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-approval-wait.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let schema = serde_json::json!({"$ref": format!("#/$defs/{}", case["schema"].as_str().unwrap()), "$defs": contract["$defs"]}); + let validator = jsonschema::options() + .should_validate_formats(true) + .build(&schema) + .unwrap(); + assert_eq!( + validator.is_valid(&case["value"]), + case["valid"].as_bool().unwrap(), + "{}", + case["name"] + ); + } + } + + #[test] + fn pairing_admission_state_schema_validates_every_durable_branch() { + let contract: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/schemas/client-v0.schema.json" + )) + .unwrap(); + let schema = serde_json::json!({"$ref": "#/$defs/PairingAdmissionRecord", "$defs": contract["$defs"]}); + let validator = jsonschema::options() + .should_validate_formats(true) + .build(&schema) + .unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-admission-states.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut record = fixture["base"].as_object().unwrap().clone(); + record.insert("state".into(), case["state"].clone()); + for group in case["groups"].as_array().unwrap() { + record.extend( + fixture[group.as_str().unwrap()] + .as_object() + .unwrap() + .clone(), + ); + } + if let Some(fields) = case["set"].as_object() { + record.extend(fields.clone()); + } + if let Some(fields) = case["remove"].as_array() { + for field in fields { + record.remove(field.as_str().unwrap()); + } + } + if let Some(fields) = case["remove_receipt"].as_array() { + let receipt = record + .get_mut("confirm_receipt") + .unwrap() + .as_object_mut() + .unwrap(); + for field in fields { + receipt.remove(field.as_str().unwrap()); + } + } + assert_eq!( + validator.is_valid(&serde_json::Value::Object(record)), + case["valid"].as_bool().unwrap(), + "{}", + case["name"] + ); + } + } + #[test] fn phone_documents_without_params_matches_the_schema() { let value = serde_json::to_value(PhoneNavigateArgs::Documents(None)).unwrap(); From 14f05b4dea1c8f399874fe1a93693d014ee875e9 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:43:50 +0100 Subject: [PATCH 127/132] Match opt-in pairing advertisement schema to gateway discovery agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-core/src/api/tests.rs | 32 +++++++++++++++++++ .../client-v0/schemas/client-v0.schema.json | 8 +++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/fractal-api-core/src/api/tests.rs b/crates/fractal-api-core/src/api/tests.rs index a2c1b5bc70f..d5b0b816221 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -15,6 +15,38 @@ use axum::body::to_bytes; use axum::http::Request; use std::path::PathBuf; +#[tokio::test] +async fn opted_in_pairing_advertisement_matches_the_canonical_schema() { + let root = tempfile::tempdir().unwrap(); + let state = super::tests_shared::state(root.path()); + let app = Router::new() + .fallback(|| async { StatusCode::IM_A_TEAPOT }) + .layer(axum::middleware::from_fn_with_state( + (state, ClientTransportBoundary::FabricLoopback), response_envelope, + )); + let contract: Value = serde_json::from_str(include_str!( + "../../../../docs/st3/client-v0/schemas/client-v0.schema.json" + )).unwrap(); + let schema = json!({"$ref":"#/$defs/PairingAdvertisement","$defs":contract["$defs"]}); + let validator = jsonschema::options().should_validate_formats(true).build(&schema).unwrap(); + for query in [ + "?accepts=pairing-key-binding", + "?accepts=pairing-key-binding&accepts=fabric-gateway-grant", + ] { + let response = app.clone().oneshot(Request::builder() + .uri(format!("/v1/client/capabilities{query}")) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body: Value = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX).await.unwrap() + ).unwrap(); + assert!(body["capabilities"].as_array().unwrap().iter().any(|capability| { + capability["id"] == "pairing-key-binding" && capability["version"] == 2 + })); + assert!(validator.is_valid(&body), "{body}"); + } +} + #[cfg(target_os = "linux")] #[test] fn incomplete_linux_environment_is_unavailable() { diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index 7a7b1dc92dc..485d1110866 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -495,9 +495,13 @@ "required": ["api_version", "capabilities"], "properties": { "api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, - "capabilities": {"type": "array", "minItems": 1, "maxItems": 2, "items": { + "capabilities": {"type": "array", "minItems": 1, "maxItems": 3, "items": { "type": "object", "additionalProperties": false, "required": ["id", "version", "state"], - "properties": {"id": {"enum": ["device-key-proofs", "fabric-gateway-grant"]}, "version": {"const": 1}, "state": {"const": "granted"}} + "properties": {"id": {"enum": ["device-key-proofs", "fabric-gateway-grant", "pairing-key-binding"]}, "version": {"enum": [1, 2]}, "state": {"const": "granted"}}, + "oneOf": [ + {"properties": {"id": {"enum": ["device-key-proofs", "fabric-gateway-grant"]}, "version": {"const": 1}}}, + {"properties": {"id": {"const": "pairing-key-binding"}, "version": {"const": 2}}} + ] }} } }, From 8ea253cd0a49e75959de6f2d4791e32f41433d7a Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:46:25 +0100 Subject: [PATCH 128/132] Expose typed native QR exchange approval and result models agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../templates/Models.swift.in | 88 ++++++++++++++ .../templates/generated.rs.in | 112 ++++++++++++++++++ .../templates/lib.rs.in | 18 +++ 3 files changed, 218 insertions(+) diff --git a/crates/fractal-client-codegen/templates/Models.swift.in b/crates/fractal-client-codegen/templates/Models.swift.in index cd9f805ad05..04c6ffdc66b 100644 --- a/crates/fractal-client-codegen/templates/Models.swift.in +++ b/crates/fractal-client-codegen/templates/Models.swift.in @@ -1516,6 +1516,94 @@ public struct PairingKeyStatus: Codable, Sendable { public let deviceKeyFingerprint: String? enum CodingKeys: String, CodingKey { case kind, status, expiresAt = "expires_at", deviceKeyFingerprint = "device_key_fingerprint" } } +public struct PairingExchangeEmpty: Codable, Sendable { public init() {} } +/// Keep secret in memory only; do not log or persist a permit or its QR payload. +public struct PairingFabricPermit: Codable, Sendable { + public let nodeID, attemptID, secret: String + public init(nodeID: String, attemptID: String, secret: String) { self.nodeID = nodeID; self.attemptID = attemptID; self.secret = secret } + enum CodingKeys: String, CodingKey { case nodeID = "node_id", attemptID = "attempt_id", secret } +} +public struct PairingQrPayload: Codable, Sendable { + public let tag: String + public let version: UInt8 + public let gateway, id, code: String + public let expiresAtUnixMS: Int64 + public let fabric: PairingFabricPermit? + public init(tag: String = "fractal.pairing", version: UInt8, gateway: String, id: String, code: String, expiresAtUnixMS: Int64, fabric: PairingFabricPermit? = nil) { + self.tag = tag; self.version = version; self.gateway = gateway; self.id = id; self.code = code; self.expiresAtUnixMS = expiresAtUnixMS; self.fabric = fabric + } + enum CodingKeys: String, CodingKey { case tag = "_tag", version, gateway, id, code, expiresAtUnixMS = "expires_at_unix_ms", fabric } +} +public struct PairingAdvertisement: Codable, Sendable { + public let apiVersion: String + public let capabilities: [PairingAdvertisedCapability] + public init(apiVersion: String, capabilities: [PairingAdvertisedCapability]) { self.apiVersion = apiVersion; self.capabilities = capabilities } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", capabilities } +} +public struct PairingAdvertisedCapability: Codable, Sendable { + public let id: String + public let version: UInt32 + public let state: String + public init(id: String, version: UInt32, state: String = "granted") { self.id = id; self.version = version; self.state = state } +} +public enum PairingExchangeOperation: String, Codable, Sendable { + case capabilities, claim, approvalWait = "approval-wait", complete, ack +} +/// Body is the canonical request model for op, not a handwritten JSON copy. +public struct PairingExchangeRequest: Codable, Sendable { + public let v: UInt8 + public let requestID: String + public let op: PairingExchangeOperation + public let body: Body + public init(requestID: String, op: PairingExchangeOperation, body: Body) { self.v = 1; self.requestID = requestID; self.op = op; self.body = body } + enum CodingKeys: String, CodingKey { case v, requestID = "request_id", op, body } +} +public struct PairingExchangeRefusal: Codable, Sendable { + public let code: ErrorCode + public let message: String + public init(code: ErrorCode, message: String) { self.code = code; self.message = message } +} +/// One reply envelope for every operation, with its typed success body. +public struct PairingExchangeReply: Codable, Sendable { + public let v: UInt8 + public let requestID: String + public let ok: Bool + public let body: Body? + public let refusal: PairingExchangeRefusal? + public init(requestID: String, ok: Bool, body: Body? = nil, refusal: PairingExchangeRefusal? = nil) { self.v = 1; self.requestID = requestID; self.ok = ok; self.body = body; self.refusal = refusal } + enum CodingKeys: String, CodingKey { case v, requestID = "request_id", ok, body, refusal } +} +public struct PairingApprovalWaitRequest: Codable, Sendable { + public let pairingID, keyFingerprint: String + public init(pairingID: String, keyFingerprint: String) { self.pairingID = pairingID; self.keyFingerprint = keyFingerprint } + enum CodingKeys: String, CodingKey { case pairingID = "pairing_id", keyFingerprint = "key_fingerprint" } +} +public struct PairingApprovalWaitApproved: Codable, Sendable { + public let status, keyFingerprint, approvedAt: String + public init(keyFingerprint: String, approvedAt: String) { self.status = "approved"; self.keyFingerprint = keyFingerprint; self.approvedAt = approvedAt } + enum CodingKeys: String, CodingKey { case status, keyFingerprint = "key_fingerprint", approvedAt = "approved_at" } +} +public struct PairingExchangeAck: Codable, Sendable { + public let requestID: String + public init(requestID: String) { self.requestID = requestID } + enum CodingKeys: String, CodingKey { case requestID = "request_id" } +} +public struct PairingAdmissionPending: Codable, Sendable { + public let state, pairingID: String + public init(pairingID: String) { self.state = "admission-pending"; self.pairingID = pairingID } + enum CodingKeys: String, CodingKey { case state, pairingID = "pairing_id" } +} +public struct PairingResultRequest: Codable, Sendable { + public let apiVersion, attemptID, devicePublicKey, proof: String + public init(apiVersion: String, attemptID: String, devicePublicKey: String, proof: String) { self.apiVersion = apiVersion; self.attemptID = attemptID; self.devicePublicKey = devicePublicKey; self.proof = proof } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", attemptID = "attempt_id", devicePublicKey = "device_public_key", proof } +} +public struct PairingResultPending: Codable, Sendable { + public let code: ErrorCode + public let retryable: Bool + public let deadline: String + public init(deadline: String) { self.code = .pairingResultPending; self.retryable = true; self.deadline = deadline } +} public struct PairingChallenge: Codable, Sendable { public let kind, pairingID, code, expiresAt: String; public let personRootFingerprint: String? public let principal: AssistantPrincipal?; public let grantKind, grantID, grantExpiresAt: String?; public let policyRevision: UInt64?; public let minimalReadMode: AssistantMinimalReadMode? diff --git a/crates/fractal-client-codegen/templates/generated.rs.in b/crates/fractal-client-codegen/templates/generated.rs.in index 57e9727782d..a7475022449 100644 --- a/crates/fractal-client-codegen/templates/generated.rs.in +++ b/crates/fractal-client-codegen/templates/generated.rs.in @@ -4328,6 +4328,118 @@ pub struct PairingKeyStatus { pub device_key_fingerprint: Option, pub expires_at: String, } +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeEmpty {} +/// The permit secret is deliberately excluded from Debug output. +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingFabricPermit { + pub node_id: String, + pub attempt_id: String, + pub secret: String, +} +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingQrPayload { + #[serde(rename = "_tag")] + pub tag: String, + pub version: u8, + pub gateway: String, + pub id: String, + pub code: String, + pub expires_at_unix_ms: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub fabric: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingAdvertisement { + pub api_version: String, + pub capabilities: Vec, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingAdvertisedCapability { + pub id: String, + pub version: u32, + pub state: String, +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum PairingExchangeOperation { + Capabilities, + Claim, + ApprovalWait, + Complete, + Ack, +} +/// Body is the canonical request model for op, not a handwritten JSON copy. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeRequest { + pub v: u8, + pub request_id: String, + pub op: PairingExchangeOperation, + pub body: T, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeRefusal { + pub code: ErrorCode, + pub message: String, +} +/// One reply envelope for every operation, with its typed success body. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeReply { + pub v: u8, + pub request_id: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refusal: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingApprovalWaitRequest { + pub pairing_id: String, + pub key_fingerprint: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingApprovalWaitApproved { + pub status: String, + pub key_fingerprint: String, + pub approved_at: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeAck { + pub request_id: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingAdmissionPending { + pub state: String, + pub pairing_id: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingResultRequest { + pub api_version: String, + pub attempt_id: String, + pub device_public_key: String, + pub proof: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingResultPending { + pub code: ErrorCode, + pub retryable: bool, + pub deadline: String, +} #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct PairingChallenge { pub kind: String, diff --git a/crates/fractal-client-codegen/templates/lib.rs.in b/crates/fractal-client-codegen/templates/lib.rs.in index 9d845f86f7c..699018077e7 100644 --- a/crates/fractal-client-codegen/templates/lib.rs.in +++ b/crates/fractal-client-codegen/templates/lib.rs.in @@ -3495,6 +3495,24 @@ fn percent_encode_segment(value: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn pairing_native_exchange_models_match_approval_wait_wire_fields() { + let request = PairingExchangeRequest { + v: 1, request_id: "wait-1".into(), op: PairingExchangeOperation::ApprovalWait, + body: PairingApprovalWaitRequest { pairing_id: "pairing/example".into(), key_fingerprint: format!("sha256:{}", "A".repeat(43)) }, + }; + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-approval-wait.json" + )).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), fixture["cases"][0]["value"]); + let approved: PairingExchangeReply = + serde_json::from_value(fixture["cases"][6]["value"].clone()).unwrap(); + assert_eq!(approved.body.unwrap().status, "approved"); + let refused: PairingExchangeReply = + serde_json::from_value(fixture["cases"][9]["value"].clone()).unwrap(); + assert_eq!(refused.refusal.unwrap().code, ErrorCode::PairingRejected); + } + #[test] fn pairing_approval_wait_schema_validates_all_reply_and_request_fixtures() { let contract: serde_json::Value = serde_json::from_str(include_str!( From 5dd88ca6caeaa5036ae88846ea49580c8a1579b4 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:47:28 +0100 Subject: [PATCH 129/132] Regenerate native pairing models from 8ea253cd0a49e75959de6f2d4791e32f41433d7a agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../Sources/FractalClient/Models.swift | 88 ++++++++++++++ .../fractal-client/Models.generated.ts | 12 +- .../fractal-client/ReadSchema.generated.ts | 2 +- .../fractal-client/Schema.generated.ts | 2 +- crates/fractal-client/src/generated.rs | 112 ++++++++++++++++++ crates/fractal-client/src/lib.rs | 27 +++++ 6 files changed, 238 insertions(+), 5 deletions(-) diff --git a/clients/swift/FractalClient/Sources/FractalClient/Models.swift b/clients/swift/FractalClient/Sources/FractalClient/Models.swift index b2a711fdddf..863d674c906 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Models.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Models.swift @@ -1574,6 +1574,94 @@ public struct PairingKeyStatus: Codable, Sendable { public let deviceKeyFingerprint: String? enum CodingKeys: String, CodingKey { case kind, status, expiresAt = "expires_at", deviceKeyFingerprint = "device_key_fingerprint" } } +public struct PairingExchangeEmpty: Codable, Sendable { public init() {} } +/// Keep secret in memory only; do not log or persist a permit or its QR payload. +public struct PairingFabricPermit: Codable, Sendable { + public let nodeID, attemptID, secret: String + public init(nodeID: String, attemptID: String, secret: String) { self.nodeID = nodeID; self.attemptID = attemptID; self.secret = secret } + enum CodingKeys: String, CodingKey { case nodeID = "node_id", attemptID = "attempt_id", secret } +} +public struct PairingQrPayload: Codable, Sendable { + public let tag: String + public let version: UInt8 + public let gateway, id, code: String + public let expiresAtUnixMS: Int64 + public let fabric: PairingFabricPermit? + public init(tag: String = "fractal.pairing", version: UInt8, gateway: String, id: String, code: String, expiresAtUnixMS: Int64, fabric: PairingFabricPermit? = nil) { + self.tag = tag; self.version = version; self.gateway = gateway; self.id = id; self.code = code; self.expiresAtUnixMS = expiresAtUnixMS; self.fabric = fabric + } + enum CodingKeys: String, CodingKey { case tag = "_tag", version, gateway, id, code, expiresAtUnixMS = "expires_at_unix_ms", fabric } +} +public struct PairingAdvertisement: Codable, Sendable { + public let apiVersion: String + public let capabilities: [PairingAdvertisedCapability] + public init(apiVersion: String, capabilities: [PairingAdvertisedCapability]) { self.apiVersion = apiVersion; self.capabilities = capabilities } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", capabilities } +} +public struct PairingAdvertisedCapability: Codable, Sendable { + public let id: String + public let version: UInt32 + public let state: String + public init(id: String, version: UInt32, state: String = "granted") { self.id = id; self.version = version; self.state = state } +} +public enum PairingExchangeOperation: String, Codable, Sendable { + case capabilities, claim, approvalWait = "approval-wait", complete, ack +} +/// Body is the canonical request model for op, not a handwritten JSON copy. +public struct PairingExchangeRequest: Codable, Sendable { + public let v: UInt8 + public let requestID: String + public let op: PairingExchangeOperation + public let body: Body + public init(requestID: String, op: PairingExchangeOperation, body: Body) { self.v = 1; self.requestID = requestID; self.op = op; self.body = body } + enum CodingKeys: String, CodingKey { case v, requestID = "request_id", op, body } +} +public struct PairingExchangeRefusal: Codable, Sendable { + public let code: ErrorCode + public let message: String + public init(code: ErrorCode, message: String) { self.code = code; self.message = message } +} +/// One reply envelope for every operation, with its typed success body. +public struct PairingExchangeReply: Codable, Sendable { + public let v: UInt8 + public let requestID: String + public let ok: Bool + public let body: Body? + public let refusal: PairingExchangeRefusal? + public init(requestID: String, ok: Bool, body: Body? = nil, refusal: PairingExchangeRefusal? = nil) { self.v = 1; self.requestID = requestID; self.ok = ok; self.body = body; self.refusal = refusal } + enum CodingKeys: String, CodingKey { case v, requestID = "request_id", ok, body, refusal } +} +public struct PairingApprovalWaitRequest: Codable, Sendable { + public let pairingID, keyFingerprint: String + public init(pairingID: String, keyFingerprint: String) { self.pairingID = pairingID; self.keyFingerprint = keyFingerprint } + enum CodingKeys: String, CodingKey { case pairingID = "pairing_id", keyFingerprint = "key_fingerprint" } +} +public struct PairingApprovalWaitApproved: Codable, Sendable { + public let status, keyFingerprint, approvedAt: String + public init(keyFingerprint: String, approvedAt: String) { self.status = "approved"; self.keyFingerprint = keyFingerprint; self.approvedAt = approvedAt } + enum CodingKeys: String, CodingKey { case status, keyFingerprint = "key_fingerprint", approvedAt = "approved_at" } +} +public struct PairingExchangeAck: Codable, Sendable { + public let requestID: String + public init(requestID: String) { self.requestID = requestID } + enum CodingKeys: String, CodingKey { case requestID = "request_id" } +} +public struct PairingAdmissionPending: Codable, Sendable { + public let state, pairingID: String + public init(pairingID: String) { self.state = "admission-pending"; self.pairingID = pairingID } + enum CodingKeys: String, CodingKey { case state, pairingID = "pairing_id" } +} +public struct PairingResultRequest: Codable, Sendable { + public let apiVersion, attemptID, devicePublicKey, proof: String + public init(apiVersion: String, attemptID: String, devicePublicKey: String, proof: String) { self.apiVersion = apiVersion; self.attemptID = attemptID; self.devicePublicKey = devicePublicKey; self.proof = proof } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", attemptID = "attempt_id", devicePublicKey = "device_public_key", proof } +} +public struct PairingResultPending: Codable, Sendable { + public let code: ErrorCode + public let retryable: Bool + public let deadline: String + public init(deadline: String) { self.code = .pairingResultPending; self.retryable = true; self.deadline = deadline } +} public struct PairingChallenge: Codable, Sendable { public let kind, pairingID, code, expiresAt: String; public let personRootFingerprint: String? public let principal: AssistantPrincipal?; public let grantKind, grantID, grantExpiresAt: String?; public let policyRevision: UInt64?; public let minimalReadMode: AssistantMinimalReadMode? diff --git a/clients/typescript/fractal-client/Models.generated.ts b/clients/typescript/fractal-client/Models.generated.ts index 2c73216140a..57818eef7f0 100644 --- a/clients/typescript/fractal-client/Models.generated.ts +++ b/clients/typescript/fractal-client/Models.generated.ts @@ -3028,10 +3028,16 @@ export type PairingAdmissionRecord = { export type PairingAdvertisement = { api_version: "st3.client.v0" | "fractal.client.v0"; capabilities: Array<{ - id: "device-key-proofs" | "fabric-gateway-grant"; + id: "device-key-proofs" | "fabric-gateway-grant" | "pairing-key-binding"; state: "granted"; - version: 1; -}>; + version: 1 | 2; +} & ({ + id?: "device-key-proofs" | "fabric-gateway-grant"; + version?: 1; +} | { + id?: "pairing-key-binding"; + version?: 2; +})>; }; export type PairingApprovalWaitApproved = { diff --git a/clients/typescript/fractal-client/ReadSchema.generated.ts b/clients/typescript/fractal-client/ReadSchema.generated.ts index e7c97307f78..837ae58c626 100644 --- a/clients/typescript/fractal-client/ReadSchema.generated.ts +++ b/clients/typescript/fractal-client/ReadSchema.generated.ts @@ -4203,7 +4203,7 @@ export type MessageBodyEncoded = typeof MessageBody.Encoded /** Static unauthenticated pairing preflight; no envelope, presence update or graph information. */ export const PairingAdvertisement = /*#__PURE__*/ (() => Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), - "capabilities": Schema.Array(Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(2)) + "capabilities": Schema.Array(Schema.Union([Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) }), Schema.Struct({ "id": Schema.Literal("pairing-key-binding"), "state": Schema.Literal("granted"), "version": Schema.Literal(2) })], { mode: "oneOf" })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)) }).annotate({ identifier: "PairingAdvertisement", description: "Static unauthenticated pairing preflight; no envelope, presence update or graph information." }))() export type PairingAdvertisement = typeof PairingAdvertisement.Type export type PairingAdvertisementEncoded = typeof PairingAdvertisement.Encoded diff --git a/clients/typescript/fractal-client/Schema.generated.ts b/clients/typescript/fractal-client/Schema.generated.ts index b4c1fb707b2..597d0344de1 100644 --- a/clients/typescript/fractal-client/Schema.generated.ts +++ b/clients/typescript/fractal-client/Schema.generated.ts @@ -1421,7 +1421,7 @@ export type PairingKeyStatus = typeof PairingKeyStatus.Type export type PairingKeyStatusEncoded = typeof PairingKeyStatus.Encoded export const PairingExchangeReply = /*#__PURE__*/ (() => Schema.Union([ - Schema.Struct({ "body": Schema.Union([Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "capabilities": Schema.Array(Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(2)), "approved_at": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "device_key_fingerprint": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u")))), "expires_at": Timestamp, "kind": Schema.Literal("pairing-key-status"), "status": Schema.Literals(["awaiting-key","awaiting-approval","approved","completed","expired"]), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "approved_at": Timestamp, "key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))), "status": Schema.Literal("approved"), "api_version": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "pairing_id": Id, "state": Schema.Literal("admission-pending"), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Record(Schema.String, Schema.Never)], { mode: "oneOf" }), "ok": Schema.Literal(true), "refusal": optionalKey(Schema.Never), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": Schema.Union([Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "capabilities": Schema.Array(Schema.Union([Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) }), Schema.Struct({ "id": Schema.Literal("pairing-key-binding"), "state": Schema.Literal("granted"), "version": Schema.Literal(2) })], { mode: "oneOf" })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)), "approved_at": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "device_key_fingerprint": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u")))), "expires_at": Timestamp, "kind": Schema.Literal("pairing-key-status"), "status": Schema.Literals(["awaiting-key","awaiting-approval","approved","completed","expired"]), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "approved_at": Timestamp, "key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))), "status": Schema.Literal("approved"), "api_version": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "pairing_id": Id, "state": Schema.Literal("admission-pending"), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Record(Schema.String, Schema.Never)], { mode: "oneOf" }), "ok": Schema.Literal(true), "refusal": optionalKey(Schema.Never), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), Schema.Struct({ "body": optionalKey(Schema.Never), "ok": Schema.Literal(false), "refusal": PairingExchangeRefusal, "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }) ], { mode: "oneOf" }).annotate({ identifier: "PairingExchangeReply" }))() export type PairingExchangeReply = typeof PairingExchangeReply.Type diff --git a/crates/fractal-client/src/generated.rs b/crates/fractal-client/src/generated.rs index 95ada7e4805..02828499b0d 100644 --- a/crates/fractal-client/src/generated.rs +++ b/crates/fractal-client/src/generated.rs @@ -5159,6 +5159,118 @@ pub struct PairingKeyStatus { pub device_key_fingerprint: Option, pub expires_at: String, } +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeEmpty {} +/// The permit secret is deliberately excluded from Debug output. +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingFabricPermit { + pub node_id: String, + pub attempt_id: String, + pub secret: String, +} +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingQrPayload { + #[serde(rename = "_tag")] + pub tag: String, + pub version: u8, + pub gateway: String, + pub id: String, + pub code: String, + pub expires_at_unix_ms: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub fabric: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingAdvertisement { + pub api_version: String, + pub capabilities: Vec, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingAdvertisedCapability { + pub id: String, + pub version: u32, + pub state: String, +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum PairingExchangeOperation { + Capabilities, + Claim, + ApprovalWait, + Complete, + Ack, +} +/// Body is the canonical request model for op, not a handwritten JSON copy. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeRequest { + pub v: u8, + pub request_id: String, + pub op: PairingExchangeOperation, + pub body: T, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeRefusal { + pub code: ErrorCode, + pub message: String, +} +/// One reply envelope for every operation, with its typed success body. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeReply { + pub v: u8, + pub request_id: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refusal: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingApprovalWaitRequest { + pub pairing_id: String, + pub key_fingerprint: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingApprovalWaitApproved { + pub status: String, + pub key_fingerprint: String, + pub approved_at: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeAck { + pub request_id: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingAdmissionPending { + pub state: String, + pub pairing_id: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingResultRequest { + pub api_version: String, + pub attempt_id: String, + pub device_public_key: String, + pub proof: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingResultPending { + pub code: ErrorCode, + pub retryable: bool, + pub deadline: String, +} #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct PairingChallenge { pub kind: String, diff --git a/crates/fractal-client/src/lib.rs b/crates/fractal-client/src/lib.rs index 44c86feb8d9..4b98d609cdd 100644 --- a/crates/fractal-client/src/lib.rs +++ b/crates/fractal-client/src/lib.rs @@ -5370,6 +5370,33 @@ fn percent_encode_segment(value: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn pairing_native_exchange_models_match_approval_wait_wire_fields() { + let request = PairingExchangeRequest { + v: 1, + request_id: "wait-1".into(), + op: PairingExchangeOperation::ApprovalWait, + body: PairingApprovalWaitRequest { + pairing_id: "pairing/example".into(), + key_fingerprint: format!("sha256:{}", "A".repeat(43)), + }, + }; + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-approval-wait.json" + )) + .unwrap(); + assert_eq!( + serde_json::to_value(request).unwrap(), + fixture["cases"][0]["value"] + ); + let approved: PairingExchangeReply = + serde_json::from_value(fixture["cases"][6]["value"].clone()).unwrap(); + assert_eq!(approved.body.unwrap().status, "approved"); + let refused: PairingExchangeReply = + serde_json::from_value(fixture["cases"][9]["value"].clone()).unwrap(); + assert_eq!(refused.refusal.unwrap().code, ErrorCode::PairingRejected); + } + #[test] fn pairing_approval_wait_schema_validates_all_reply_and_request_fixtures() { let contract: serde_json::Value = serde_json::from_str(include_str!( From 2cf3867b0516bd592650e7038fa0a668579567ab Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:54:39 +0100 Subject: [PATCH 130/132] Require unique pairing capabilities and validate default discovery agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-core/src/api/tests.rs | 9 +++++++-- docs/st3/client-v0/schemas/client-v0.schema.json | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/fractal-api-core/src/api/tests.rs b/crates/fractal-api-core/src/api/tests.rs index d5b0b816221..187df9c5c98 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -30,6 +30,7 @@ async fn opted_in_pairing_advertisement_matches_the_canonical_schema() { let schema = json!({"$ref":"#/$defs/PairingAdvertisement","$defs":contract["$defs"]}); let validator = jsonschema::options().should_validate_formats(true).build(&schema).unwrap(); for query in [ + "", "?accepts=pairing-key-binding", "?accepts=pairing-key-binding&accepts=fabric-gateway-grant", ] { @@ -40,10 +41,14 @@ async fn opted_in_pairing_advertisement_matches_the_canonical_schema() { let body: Value = serde_json::from_slice( &to_bytes(response.into_body(), usize::MAX).await.unwrap() ).unwrap(); - assert!(body["capabilities"].as_array().unwrap().iter().any(|capability| { + assert_eq!(body["capabilities"].as_array().unwrap().iter().any(|capability| { capability["id"] == "pairing-key-binding" && capability["version"] == 2 - })); + }), !query.is_empty()); assert!(validator.is_valid(&body), "{body}"); + let mut duplicated = body.clone(); + let capabilities = duplicated["capabilities"].as_array_mut().unwrap(); + capabilities.push(capabilities[0].clone()); + assert!(!validator.is_valid(&duplicated), "duplicate capability IDs must be refused"); } } diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index 485d1110866..3cafca04064 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -495,7 +495,7 @@ "required": ["api_version", "capabilities"], "properties": { "api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, - "capabilities": {"type": "array", "minItems": 1, "maxItems": 3, "items": { + "capabilities": {"type": "array", "minItems": 1, "maxItems": 3, "uniqueItems": true, "items": { "type": "object", "additionalProperties": false, "required": ["id", "version", "state"], "properties": {"id": {"enum": ["device-key-proofs", "fabric-gateway-grant", "pairing-key-binding"]}, "version": {"enum": [1, 2]}, "state": {"const": "granted"}}, "oneOf": [ From 63b7564886170320263f04c04006df6632c6c683 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:58:47 +0100 Subject: [PATCH 131/132] Reject inconsistent native pairing envelopes and wire constants agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../PairingModelsTests.swift | 235 ++++++++++++++ .../templates/Models.swift.in | 267 ++++++++++++++-- .../templates/generated.rs.in | 293 ++++++++++++++++-- .../templates/lib.rs.in | 169 +++++++++- 4 files changed, 896 insertions(+), 68 deletions(-) create mode 100644 clients/swift/FractalClient/Tests/FractalClientTests/PairingModelsTests.swift diff --git a/clients/swift/FractalClient/Tests/FractalClientTests/PairingModelsTests.swift b/clients/swift/FractalClient/Tests/FractalClientTests/PairingModelsTests.swift new file mode 100644 index 00000000000..e84deed73c6 --- /dev/null +++ b/clients/swift/FractalClient/Tests/FractalClientTests/PairingModelsTests.swift @@ -0,0 +1,235 @@ +import XCTest +import Foundation +import FractalClient + +final class PairingModelsTests: XCTestCase { + private let apiVersion = "st3.client.v0" + private let refusal: [String: Any] = ["code": "pairing-rejected", "message": "Pairing rejected"] + private let approved: [String: Any] = ["status": "approved", "key_fingerprint": "fingerprint", "approved_at": "2026-11-10T12:00:00Z"] + private let permit: [String: Any] = ["node_id": "node/example", "attempt_id": "attempt/example", "secret": "secret"] + + private func decode(_ type: T.Type, _ object: [String: Any]) throws -> T { + try JSONDecoder().decode(type, from: JSONSerialization.data(withJSONObject: object)) + } + + private func object(_ value: T) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) as? [String: Any]) + } + + private func roundTrip(_ type: T.Type, _ wire: [String: Any], file: StaticString = #filePath, line: UInt = #line) throws { + let value = try decode(type, wire) + XCTAssertEqual(try object(value) as NSDictionary, wire as NSDictionary, file: file, line: line) + } + + func testTypedRequestConstructorsRoundTripAllOperations() throws { + let requests: [PairingExchangeRequest] = [ + .capabilities(requestID: "request/capabilities"), + .claim(requestID: "request/claim", body: PairingKeyClaim(apiVersion: apiVersion, code: "code", devicePublicKey: "key", proof: "proof")), + .approvalWait(requestID: "request/wait", body: PairingApprovalWaitRequest(pairingID: "pairing/example", keyFingerprint: "fingerprint")), + .complete(requestID: "request/complete", body: PairingComplete(apiVersion: apiVersion, code: "code", devicePublicKey: "key", keyStorage: "secure-enclave")), + .ack(requestID: "request/ack", body: PairingExchangeAck(requestID: "request/complete")), + ] + let operations: [PairingExchangeOperation] = [.capabilities, .claim, .approvalWait, .complete, .ack] + for (request, operation) in zip(requests, operations) { + XCTAssertEqual(request.op, operation) + let wire = try object(request) + XCTAssertEqual(Set(wire.keys), ["v", "request_id", "op", "body"]) + XCTAssertEqual(wire["v"] as? Int, 1) + XCTAssertEqual(wire["op"] as? String, operation.rawValue) + try roundTrip(PairingExchangeRequest.self, wire) + } + } + + func testRequestRejectsOperationBodyDisagreementAndWrongVersion() throws { + let bodies: [(String, [String: Any])] = [ + ("capabilities", [:]), + ("claim", ["api_version": apiVersion, "code": "code", "device_public_key": "key", "proof": "proof"]), + ("approval-wait", ["pairing_id": "pairing/example", "key_fingerprint": "fingerprint"]), + ("complete", ["api_version": apiVersion, "code": "code", "device_public_key": "key", "key_storage": "secure-enclave"]), + ("ack", ["request_id": "request/complete"]), + ] + for (operation, _) in bodies { + for (bodyOperation, body) in bodies where operation != bodyOperation { + // A claim also satisfies complete's schema: proof is optional there. + if operation == "complete" && bodyOperation == "claim" { continue } + let wire: [String: Any] = ["v": 1, "request_id": "request/example", "op": operation, "body": body] + XCTAssertThrowsError(try decode(PairingExchangeRequest.self, wire), "\(operation) accepted \(bodyOperation) body") + } + } + let valid: [String: Any] = ["v": 1, "request_id": "request/example", "op": "capabilities", "body": [:]] + for version in [0, 2, -1, 256, "1", NSNull()] as [Any] { + var wire = valid + wire["v"] = version + XCTAssertThrowsError(try decode(PairingExchangeRequest.self, wire)) + } + for field in ["v", "request_id", "op", "body"] { + var wire = valid + wire.removeValue(forKey: field) + XCTAssertThrowsError(try decode(PairingExchangeRequest.self, wire)) + } + var unknown = valid + unknown["op"] = "unknown" + XCTAssertThrowsError(try decode(PairingExchangeRequest.self, unknown)) + unknown = valid + unknown["body"] = NSNull() + XCTAssertThrowsError(try decode(PairingExchangeRequest.self, unknown)) + } + + func testReplyOutcomeRoundTripsSuccessAndCommonRefusal() throws { + let success = PairingExchangeReply(requestID: "request/wait", outcome: .ok(PairingApprovalWaitApproved(keyFingerprint: "fingerprint", approvedAt: "2026-11-10T12:00:00Z"))) + let successWire: [String: Any] = ["v": 1, "request_id": "request/wait", "ok": true, "body": approved] + XCTAssertEqual(try object(success) as NSDictionary, successWire as NSDictionary) + let decoded = try decode(PairingExchangeReply.self, successWire) + guard case .ok(let approval) = decoded.outcome else { return XCTFail("Expected approval success") } + XCTAssertEqual(approval.status, .approved) + try roundTrip(PairingExchangeReply.self, successWire) + + let rejected = PairingExchangeReply(requestID: "request/wait", outcome: .refused(PairingExchangeRefusal(code: .pairingRejected, message: "Pairing rejected"))) + let refusalWire: [String: Any] = ["v": 1, "request_id": "request/wait", "ok": false, "refusal": refusal] + XCTAssertEqual(try object(rejected) as NSDictionary, refusalWire as NSDictionary) + guard case .refused(let reason) = try decode(PairingExchangeReply.self, refusalWire).outcome else { + return XCTFail("Expected common refusal") + } + XCTAssertEqual(reason.code, .pairingRejected) + try roundTrip(PairingExchangeReply.self, refusalWire) + // The same envelope/refusal works for a different success-body type. + try roundTrip(PairingExchangeReply.self, refusalWire) + try roundTrip(PairingExchangeReply.self, + ["v": 1, "request_id": "request/ack", "ok": true, "body": [:]]) + } + + func testReplyRejectsEveryInconsistentOutcomeCombination() throws { + // Each field is absent, null, or populated. Only the two canonical combinations are valid. + let bodyValues: [Any?] = [nil, NSNull(), approved] + let refusalValues: [Any?] = [nil, NSNull(), refusal] + for ok in [false, true] { + for (bodyIndex, body) in bodyValues.enumerated() { + for (refusalIndex, reason) in refusalValues.enumerated() { + if (ok && bodyIndex == 2 && refusalIndex == 0) || (!ok && bodyIndex == 0 && refusalIndex == 2) { continue } + var wire: [String: Any] = ["v": 1, "request_id": "request/example", "ok": ok] + wire["body"] = body + wire["refusal"] = reason + XCTAssertThrowsError(try decode(PairingExchangeReply.self, wire), "ok=\(ok), body=\(bodyIndex), refusal=\(refusalIndex)") + } + } + } + let valid: [String: Any] = ["v": 1, "request_id": "request/example", "ok": true, "body": approved] + for version in [0, 2, -1, 256, "1", NSNull()] as [Any] { + var wire = valid + wire["v"] = version + XCTAssertThrowsError(try decode(PairingExchangeReply.self, wire)) + } + for field in ["v", "request_id", "ok", "body"] { + var wire = valid + wire.removeValue(forKey: field) + XCTAssertThrowsError(try decode(PairingExchangeReply.self, wire)) + } + for reason in [[:], ["code": "pairing-rejected"], ["message": "rejected"], "rejected", NSNull()] as [Any] { + XCTAssertThrowsError(try decode(PairingExchangeReply.self, + ["v": 1, "request_id": "request/example", "ok": false, "refusal": reason])) + } + } + + func testQrVersionsRoundTripWithVersionBoundFabric() throws { + let v1 = PairingQrPayload(version: .v1, gateway: "https://gateway.example", id: "pairing/example", code: "code", expiresAtUnixMS: 123) + let v2 = PairingQrPayload(version: .v2(PairingFabricPermit(nodeID: "node/example", attemptID: "attempt/example", secret: "secret")), gateway: "https://gateway.example", id: "pairing/example", code: "code", expiresAtUnixMS: 123) + for (payload, version) in [(v1, 1), (v2, 2)] { + let wire = try object(payload) + XCTAssertEqual(wire["_tag"] as? String, "fractal.pairing") + XCTAssertEqual(wire["version"] as? Int, version) + XCTAssertEqual(wire.keys.contains("fabric"), version == 2) + try roundTrip(PairingQrPayload.self, wire) + } + } + + func testQrRejectsWrongTagVersionAndFabricCombinations() throws { + let valid: [String: Any] = ["_tag": "fractal.pairing", "version": 1, "gateway": "https://gateway.example", "id": "pairing/example", "code": "code", "expires_at_unix_ms": 123] + for tag in ["pairing", "", 1, NSNull()] as [Any] { + var wire = valid + wire["_tag"] = tag + XCTAssertThrowsError(try decode(PairingQrPayload.self, wire)) + } + for version in [0, 3, -1, 256, "1", NSNull()] as [Any] { + var wire = valid + wire["version"] = version + XCTAssertThrowsError(try decode(PairingQrPayload.self, wire)) + } + for fabric in [permit, NSNull()] as [Any] { + var wire = valid + wire["fabric"] = fabric + XCTAssertThrowsError(try decode(PairingQrPayload.self, wire)) + } + var v2 = valid + v2["version"] = 2 + XCTAssertThrowsError(try decode(PairingQrPayload.self, v2)) + v2["fabric"] = NSNull() + XCTAssertThrowsError(try decode(PairingQrPayload.self, v2)) + for field in ["_tag", "version"] { + var wire = valid + wire.removeValue(forKey: field) + XCTAssertThrowsError(try decode(PairingQrPayload.self, wire)) + } + } + + func testApprovalAndAdmissionConstantsAreValidatedAndRoundTrip() throws { + try roundTrip(PairingApprovalWaitApproved.self, approved) + let pending = PairingAdmissionPending(pairingID: "pairing/example") + let pendingWire: [String: Any] = ["state": "admission-pending", "pairing_id": "pairing/example"] + XCTAssertEqual(try object(pending) as NSDictionary, pendingWire as NSDictionary) + try roundTrip(PairingAdmissionPending.self, pendingWire) + for invalid in ["pending", "", 1, NSNull()] as [Any] { + var approval = approved + approval["status"] = invalid + XCTAssertThrowsError(try decode(PairingApprovalWaitApproved.self, approval)) + var admission = pendingWire + admission["state"] = invalid + XCTAssertThrowsError(try decode(PairingAdmissionPending.self, admission)) + } + var approval = approved + approval.removeValue(forKey: "status") + XCTAssertThrowsError(try decode(PairingApprovalWaitApproved.self, approval)) + XCTAssertThrowsError(try decode(PairingAdmissionPending.self, ["pairing_id": "pairing/example"])) + } + + func testAdvertisementCapabilitiesRoundTripAndValidateConstants() throws { + let advertisement = try PairingAdvertisement(apiVersion: apiVersion, capabilities: PairingAdvertisedCapability.allCases) + let wire = try object(advertisement) + try roundTrip(PairingAdvertisement.self, wire) + for capability in PairingAdvertisedCapability.allCases { + let valid = try object(capability) + XCTAssertEqual(valid["id"] as? String, capability.rawValue) + XCTAssertEqual(valid["version"] as? UInt32, capability == .pairingKeyBinding ? 2 : 1) + XCTAssertEqual(valid["state"] as? String, "granted") + try roundTrip(PairingAdvertisedCapability.self, valid) + for version in [0, capability == .pairingKeyBinding ? 1 : 2, 3, -1, "1", NSNull()] as [Any] { + var invalid = valid + invalid["version"] = version + XCTAssertThrowsError(try decode(PairingAdvertisedCapability.self, invalid)) + } + for state in ["denied", "", 1, NSNull()] as [Any] { + var invalid = valid + invalid["state"] = state + XCTAssertThrowsError(try decode(PairingAdvertisedCapability.self, invalid)) + } + for id in ["unknown", "", 1, NSNull()] as [Any] { + var invalid = valid + invalid["id"] = id + XCTAssertThrowsError(try decode(PairingAdvertisedCapability.self, invalid)) + } + for field in ["id", "version", "state"] { + var invalid = valid + invalid.removeValue(forKey: field) + XCTAssertThrowsError(try decode(PairingAdvertisedCapability.self, invalid)) + } + } + let proof = try object(PairingAdvertisedCapability.deviceKeyProofs) + let grant = try object(PairingAdvertisedCapability.fabricGatewayGrant) + let binding = try object(PairingAdvertisedCapability.pairingKeyBinding) + for capabilities in [[], [proof, proof], [proof, grant, binding, proof]] { + XCTAssertThrowsError(try decode(PairingAdvertisement.self, ["api_version": apiVersion, "capabilities": capabilities])) + } + XCTAssertThrowsError(try PairingAdvertisement(apiVersion: apiVersion, capabilities: [])) + XCTAssertThrowsError(try PairingAdvertisement(apiVersion: apiVersion, capabilities: [.deviceKeyProofs, .deviceKeyProofs])) + XCTAssertThrowsError(try PairingAdvertisement(apiVersion: apiVersion, capabilities: [.deviceKeyProofs, .fabricGatewayGrant, .pairingKeyBinding, .deviceKeyProofs])) + } +} diff --git a/crates/fractal-client-codegen/templates/Models.swift.in b/crates/fractal-client-codegen/templates/Models.swift.in index 04c6ffdc66b..1e9eefb115a 100644 --- a/crates/fractal-client-codegen/templates/Models.swift.in +++ b/crates/fractal-client-codegen/templates/Models.swift.in @@ -1516,71 +1516,266 @@ public struct PairingKeyStatus: Codable, Sendable { public let deviceKeyFingerprint: String? enum CodingKeys: String, CodingKey { case kind, status, expiresAt = "expires_at", deviceKeyFingerprint = "device_key_fingerprint" } } -public struct PairingExchangeEmpty: Codable, Sendable { public init() {} } +public struct PairingExchangeEmpty: Codable, Sendable { + public init() {} + public init(from decoder: Decoder) throws { + let fields = try decoder.container(keyedBy: PairingWireKey.self) + guard fields.allKeys.isEmpty else { throw pairingWireError(decoder, "Expected an empty pairing body") } + } + public func encode(to encoder: Encoder) throws { _ = encoder.container(keyedBy: PairingWireKey.self) } +} + +private struct PairingWireKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } +} + +private func pairingWireError(_ decoder: Decoder, _ message: String) -> DecodingError { + .dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: message)) +} + +private func pairingWireFields(_ decoder: Decoder, allowed: Set) throws { + let fields = try decoder.container(keyedBy: PairingWireKey.self) + guard fields.allKeys.allSatisfy({ allowed.contains($0.stringValue) }) else { + throw pairingWireError(decoder, "Unexpected pairing field") + } +} /// Keep secret in memory only; do not log or persist a permit or its QR payload. public struct PairingFabricPermit: Codable, Sendable { public let nodeID, attemptID, secret: String public init(nodeID: String, attemptID: String, secret: String) { self.nodeID = nodeID; self.attemptID = attemptID; self.secret = secret } enum CodingKeys: String, CodingKey { case nodeID = "node_id", attemptID = "attempt_id", secret } } +public enum PairingQrTag: String, Codable, Sendable { case pairing = "fractal.pairing" } +public enum PairingQrVersion: Sendable { + case v1 + case v2(PairingFabricPermit) +} + +/// Version two carries a fabric permit; version one cannot carry one. public struct PairingQrPayload: Codable, Sendable { - public let tag: String - public let version: UInt8 + public var tag: PairingQrTag { .pairing } + public let version: PairingQrVersion public let gateway, id, code: String public let expiresAtUnixMS: Int64 - public let fabric: PairingFabricPermit? - public init(tag: String = "fractal.pairing", version: UInt8, gateway: String, id: String, code: String, expiresAtUnixMS: Int64, fabric: PairingFabricPermit? = nil) { - self.tag = tag; self.version = version; self.gateway = gateway; self.id = id; self.code = code; self.expiresAtUnixMS = expiresAtUnixMS; self.fabric = fabric + public init(version: PairingQrVersion, gateway: String, id: String, code: String, expiresAtUnixMS: Int64) { + self.version = version; self.gateway = gateway; self.id = id; self.code = code; self.expiresAtUnixMS = expiresAtUnixMS } enum CodingKeys: String, CodingKey { case tag = "_tag", version, gateway, id, code, expiresAtUnixMS = "expires_at_unix_ms", fabric } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["_tag", "version", "gateway", "id", "code", "expires_at_unix_ms", "fabric"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + _ = try fields.decode(PairingQrTag.self, forKey: .tag) + switch try fields.decode(UInt8.self, forKey: .version) { + case 1: + guard !fields.contains(.fabric) else { throw pairingWireError(decoder, "Version one must not carry fabric") } + version = .v1 + case 2: version = .v2(try fields.decode(PairingFabricPermit.self, forKey: .fabric)) + default: throw pairingWireError(decoder, "Unsupported pairing QR version") + } + gateway = try fields.decode(String.self, forKey: .gateway) + id = try fields.decode(String.self, forKey: .id) + code = try fields.decode(String.self, forKey: .code) + expiresAtUnixMS = try fields.decode(Int64.self, forKey: .expiresAtUnixMS) + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(tag, forKey: .tag) + switch version { + case .v1: try fields.encode(1, forKey: .version) + case .v2(let permit): + try fields.encode(2, forKey: .version) + try fields.encode(permit, forKey: .fabric) + } + try fields.encode(gateway, forKey: .gateway) + try fields.encode(id, forKey: .id) + try fields.encode(code, forKey: .code) + try fields.encode(expiresAtUnixMS, forKey: .expiresAtUnixMS) + } +} + +public enum PairingCapabilityState: String, Codable, Sendable { case granted } +public enum PairingAdvertisedCapability: String, Sendable, CaseIterable, Hashable, Codable { + case deviceKeyProofs = "device-key-proofs" + case fabricGatewayGrant = "fabric-gateway-grant" + case pairingKeyBinding = "pairing-key-binding" + public var id: String { rawValue } + public var version: UInt32 { self == .pairingKeyBinding ? 2 : 1 } + public var state: PairingCapabilityState { .granted } + enum CodingKeys: String, CodingKey { case id, version, state } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["id", "version", "state"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + let id = try fields.decode(String.self, forKey: .id) + guard let capability = Self(rawValue: id), + try fields.decode(UInt32.self, forKey: .version) == capability.version else { + throw pairingWireError(decoder, "Unsupported pairing capability or version") + } + _ = try fields.decode(PairingCapabilityState.self, forKey: .state) + self = capability + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(id, forKey: .id) + try fields.encode(version, forKey: .version) + try fields.encode(state, forKey: .state) + } } + public struct PairingAdvertisement: Codable, Sendable { public let apiVersion: String public let capabilities: [PairingAdvertisedCapability] - public init(apiVersion: String, capabilities: [PairingAdvertisedCapability]) { self.apiVersion = apiVersion; self.capabilities = capabilities } + public init(apiVersion: String, capabilities: [PairingAdvertisedCapability]) throws { + guard !capabilities.isEmpty, capabilities.count <= 3, Set(capabilities).count == capabilities.count else { + throw EncodingError.invalidValue(capabilities, .init(codingPath: [], debugDescription: "Expected one to three unique pairing capabilities")) + } + self.apiVersion = apiVersion; self.capabilities = capabilities + } enum CodingKeys: String, CodingKey { case apiVersion = "api_version", capabilities } + public init(from decoder: Decoder) throws { + let fields = try decoder.container(keyedBy: CodingKeys.self) + let capabilities = try fields.decode([PairingAdvertisedCapability].self, forKey: .capabilities) + guard !capabilities.isEmpty, capabilities.count <= 3, Set(capabilities).count == capabilities.count else { + throw pairingWireError(decoder, "Expected one to three unique pairing capabilities") + } + self.apiVersion = try fields.decode(String.self, forKey: .apiVersion) + self.capabilities = capabilities + } } -public struct PairingAdvertisedCapability: Codable, Sendable { - public let id: String - public let version: UInt32 - public let state: String - public init(id: String, version: UInt32, state: String = "granted") { self.id = id; self.version = version; self.state = state } -} + public enum PairingExchangeOperation: String, Codable, Sendable { case capabilities, claim, approvalWait = "approval-wait", complete, ack } -/// Body is the canonical request model for op, not a handwritten JSON copy. -public struct PairingExchangeRequest: Codable, Sendable { - public let v: UInt8 + +/// The operation is derived from the canonical typed body, never independently supplied. +public enum PairingExchangeRequestBody: Sendable { + case capabilities + case claim(PairingKeyClaim) + case approvalWait(PairingApprovalWaitRequest) + case complete(PairingComplete) + case ack(PairingExchangeAck) + public var op: PairingExchangeOperation { + switch self { + case .capabilities: .capabilities + case .claim: .claim + case .approvalWait: .approvalWait + case .complete: .complete + case .ack: .ack + } + } +} + +public struct PairingExchangeRequest: Codable, Sendable { + public var v: UInt8 { 1 } public let requestID: String - public let op: PairingExchangeOperation - public let body: Body - public init(requestID: String, op: PairingExchangeOperation, body: Body) { self.v = 1; self.requestID = requestID; self.op = op; self.body = body } + public var op: PairingExchangeOperation { body.op } + public let body: PairingExchangeRequestBody + public init(requestID: String, body: PairingExchangeRequestBody) { self.requestID = requestID; self.body = body } + public static func capabilities(requestID: String) -> Self { .init(requestID: requestID, body: .capabilities) } + public static func claim(requestID: String, body: PairingKeyClaim) -> Self { .init(requestID: requestID, body: .claim(body)) } + public static func approvalWait(requestID: String, body: PairingApprovalWaitRequest) -> Self { .init(requestID: requestID, body: .approvalWait(body)) } + public static func complete(requestID: String, body: PairingComplete) -> Self { .init(requestID: requestID, body: .complete(body)) } + public static func ack(requestID: String, body: PairingExchangeAck) -> Self { .init(requestID: requestID, body: .ack(body)) } enum CodingKeys: String, CodingKey { case v, requestID = "request_id", op, body } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["v", "request_id", "op", "body"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + guard try fields.decode(UInt8.self, forKey: .v) == 1 else { throw pairingWireError(decoder, "Unsupported pairing exchange version") } + requestID = try fields.decode(String.self, forKey: .requestID) + let bodyDecoder = try fields.superDecoder(forKey: .body) + switch try fields.decode(PairingExchangeOperation.self, forKey: .op) { + case .capabilities: + _ = try PairingExchangeEmpty(from: bodyDecoder) + body = .capabilities + case .claim: + try pairingWireFields(bodyDecoder, allowed: ["api_version", "code", "device_public_key", "proof"]) + body = .claim(try PairingKeyClaim(from: bodyDecoder)) + case .approvalWait: + try pairingWireFields(bodyDecoder, allowed: ["pairing_id", "key_fingerprint"]) + body = .approvalWait(try PairingApprovalWaitRequest(from: bodyDecoder)) + case .complete: + try pairingWireFields(bodyDecoder, allowed: ["api_version", "code", "device_public_key", "proof", "key_storage", "fabric_node_id"]) + body = .complete(try PairingComplete(from: bodyDecoder)) + case .ack: + try pairingWireFields(bodyDecoder, allowed: ["request_id"]) + body = .ack(try PairingExchangeAck(from: bodyDecoder)) + } + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(v, forKey: .v) + try fields.encode(requestID, forKey: .requestID) + try fields.encode(op, forKey: .op) + switch body { + case .capabilities: try fields.encode(PairingExchangeEmpty(), forKey: .body) + case .claim(let body): try fields.encode(body, forKey: .body) + case .approvalWait(let body): try fields.encode(body, forKey: .body) + case .complete(let body): try fields.encode(body, forKey: .body) + case .ack(let body): try fields.encode(body, forKey: .body) + } + } } public struct PairingExchangeRefusal: Codable, Sendable { public let code: ErrorCode public let message: String public init(code: ErrorCode, message: String) { self.code = code; self.message = message } } -/// One reply envelope for every operation, with its typed success body. +/// A success and a refusal cannot coexist or both be absent. +public enum PairingExchangeOutcome: Sendable { + case ok(Body) + case refused(PairingExchangeRefusal) +} + +/// One reply envelope and one common refusal for every operation. public struct PairingExchangeReply: Codable, Sendable { - public let v: UInt8 + public var v: UInt8 { 1 } public let requestID: String - public let ok: Bool - public let body: Body? - public let refusal: PairingExchangeRefusal? - public init(requestID: String, ok: Bool, body: Body? = nil, refusal: PairingExchangeRefusal? = nil) { self.v = 1; self.requestID = requestID; self.ok = ok; self.body = body; self.refusal = refusal } + public let outcome: PairingExchangeOutcome + public init(requestID: String, outcome: PairingExchangeOutcome) { self.requestID = requestID; self.outcome = outcome } enum CodingKeys: String, CodingKey { case v, requestID = "request_id", ok, body, refusal } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["v", "request_id", "ok", "body", "refusal"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + guard try fields.decode(UInt8.self, forKey: .v) == 1 else { throw pairingWireError(decoder, "Unsupported pairing exchange version") } + requestID = try fields.decode(String.self, forKey: .requestID) + if try fields.decode(Bool.self, forKey: .ok) { + guard fields.contains(.body), !fields.contains(.refusal), !(try fields.decodeNil(forKey: .body)) else { + throw pairingWireError(decoder, "Successful pairing reply requires only a body") + } + outcome = .ok(try fields.decode(Body.self, forKey: .body)) + } else { + guard fields.contains(.refusal), !fields.contains(.body) else { + throw pairingWireError(decoder, "Refused pairing reply requires only a refusal") + } + outcome = .refused(try fields.decode(PairingExchangeRefusal.self, forKey: .refusal)) + } + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(v, forKey: .v) + try fields.encode(requestID, forKey: .requestID) + switch outcome { + case .ok(let body): + try fields.encode(true, forKey: .ok) + try fields.encode(body, forKey: .body) + case .refused(let refusal): + try fields.encode(false, forKey: .ok) + try fields.encode(refusal, forKey: .refusal) + } + } } public struct PairingApprovalWaitRequest: Codable, Sendable { public let pairingID, keyFingerprint: String public init(pairingID: String, keyFingerprint: String) { self.pairingID = pairingID; self.keyFingerprint = keyFingerprint } enum CodingKeys: String, CodingKey { case pairingID = "pairing_id", keyFingerprint = "key_fingerprint" } } +public enum PairingApprovalStatus: String, Codable, Sendable { case approved } public struct PairingApprovalWaitApproved: Codable, Sendable { - public let status, keyFingerprint, approvedAt: String - public init(keyFingerprint: String, approvedAt: String) { self.status = "approved"; self.keyFingerprint = keyFingerprint; self.approvedAt = approvedAt } + public let status: PairingApprovalStatus + public let keyFingerprint, approvedAt: String + public init(keyFingerprint: String, approvedAt: String) { self.status = .approved; self.keyFingerprint = keyFingerprint; self.approvedAt = approvedAt } enum CodingKeys: String, CodingKey { case status, keyFingerprint = "key_fingerprint", approvedAt = "approved_at" } } public struct PairingExchangeAck: Codable, Sendable { @@ -1588,9 +1783,11 @@ public struct PairingExchangeAck: Codable, Sendable { public init(requestID: String) { self.requestID = requestID } enum CodingKeys: String, CodingKey { case requestID = "request_id" } } +public enum PairingAdmissionState: String, Codable, Sendable { case pending = "admission-pending" } public struct PairingAdmissionPending: Codable, Sendable { - public let state, pairingID: String - public init(pairingID: String) { self.state = "admission-pending"; self.pairingID = pairingID } + public let state: PairingAdmissionState + public let pairingID: String + public init(pairingID: String) { self.state = .pending; self.pairingID = pairingID } enum CodingKeys: String, CodingKey { case state, pairingID = "pairing_id" } } public struct PairingResultRequest: Codable, Sendable { @@ -1609,7 +1806,17 @@ public struct PairingChallenge: Codable, Sendable { public let principal: AssistantPrincipal?; public let grantKind, grantID, grantExpiresAt: String?; public let policyRevision: UInt64?; public let minimalReadMode: AssistantMinimalReadMode? enum CodingKeys: String, CodingKey { case kind, pairingID = "pairing_id", code, expiresAt = "expires_at", personRootFingerprint = "person_root_fingerprint", principal, grantKind = "grant_kind", grantID = "grant_id", grantExpiresAt = "grant_expires_at", policyRevision = "policy_revision", minimalReadMode = "minimal_read_mode" } } -public struct PairingComplete: Codable, Sendable { public let apiVersion: String; public let code: String; public let devicePublicKey: String; public var proof: String? = nil; public var keyStorage: String? = nil; public var fabricNodeID: String? = nil; enum CodingKeys: String, CodingKey { case apiVersion = "api_version", code, devicePublicKey = "device_public_key", proof, keyStorage = "key_storage", fabricNodeID = "fabric_node_id" } } +public struct PairingComplete: Codable, Sendable { + public let apiVersion, code, devicePublicKey: String + public var proof: String? + public var keyStorage: String? + public var fabricNodeID: String? + public init(apiVersion: String, code: String, devicePublicKey: String, proof: String? = nil, keyStorage: String? = nil, fabricNodeID: String? = nil) { + self.apiVersion = apiVersion; self.code = code; self.devicePublicKey = devicePublicKey + self.proof = proof; self.keyStorage = keyStorage; self.fabricNodeID = fabricNodeID + } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", code, devicePublicKey = "device_public_key", proof, keyStorage = "key_storage", fabricNodeID = "fabric_node_id" } +} public enum PairedSession: Codable, Sendable { case person(PersonPairedSession), assistant(AssistantPairedSession) public init(from decoder: Decoder) throws { diff --git a/crates/fractal-client-codegen/templates/generated.rs.in b/crates/fractal-client-codegen/templates/generated.rs.in index a7475022449..29e7081ec2c 100644 --- a/crates/fractal-client-codegen/templates/generated.rs.in +++ b/crates/fractal-client-codegen/templates/generated.rs.in @@ -4339,31 +4339,145 @@ pub struct PairingFabricPermit { pub attempt_id: String, pub secret: String, } +/// A numeric wire constant: callers cannot construct a different value. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PairingWireVersion; +impl Serialize for PairingWireVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u8(V) + } +} +impl<'de, const V: u8> Deserialize<'de> for PairingWireVersion { + fn deserialize>(deserializer: D) -> Result { + if u8::deserialize(deserializer)? == V { + Ok(Self) + } else { + Err(serde::de::Error::custom("incorrect pairing wire version")) + } + } +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingQrTag { + #[serde(rename = "fractal.pairing")] + Pairing, +} +/// Each variant enforces its version and presence (or absence) of a Fabric permit. +#[derive(Clone, Serialize, PartialEq)] +#[serde(untagged)] +pub enum PairingQrPayload { + V1(PairingQrV1), + V2(PairingQrV2), +} +impl<'de> Deserialize<'de> for PairingQrPayload { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + #[serde(rename = "_tag")] + tag: PairingQrTag, + version: u8, + gateway: String, + id: String, + code: String, + expires_at_unix_ms: i64, + #[serde(default)] + fabric: PairingExchangeField, + } + let wire = Wire::deserialize(deserializer)?; + match (wire.version, wire.fabric) { + (1, PairingExchangeField::Missing) => Ok(Self::V1(PairingQrV1 { + tag: wire.tag, version: PairingWireVersion, gateway: wire.gateway, + id: wire.id, code: wire.code, expires_at_unix_ms: wire.expires_at_unix_ms, + })), + (2, PairingExchangeField::Present(fabric)) => Ok(Self::V2(PairingQrV2 { + tag: wire.tag, version: PairingWireVersion, gateway: wire.gateway, + id: wire.id, code: wire.code, expires_at_unix_ms: wire.expires_at_unix_ms, fabric, + })), + _ => Err(serde::de::Error::custom("pairing QR version does not match Fabric permit presence")), + } + } +} #[derive(Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] -pub struct PairingQrPayload { +pub struct PairingQrV1 { #[serde(rename = "_tag")] - pub tag: String, - pub version: u8, + pub tag: PairingQrTag, + pub version: PairingWireVersion<1>, pub gateway: String, pub id: String, pub code: String, pub expires_at_unix_ms: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub fabric: Option, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] +pub struct PairingQrV2 { + #[serde(rename = "_tag")] + pub tag: PairingQrTag, + pub version: PairingWireVersion<2>, + pub gateway: String, + pub id: String, + pub code: String, + pub expires_at_unix_ms: i64, + pub fabric: PairingFabricPermit, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(try_from = "PairingAdvertisementWire")] pub struct PairingAdvertisement { pub api_version: String, - pub capabilities: Vec, + capabilities: Vec, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +impl PairingAdvertisement { + pub fn new(api_version: String, capabilities: Vec) -> Result { + if capabilities.is_empty() || capabilities.len() > 3 { + return Err("pairing advertisement requires one to three capabilities"); + } + for (index, capability) in capabilities.iter().enumerate() { + if capabilities[..index].iter().any(|other| other.id() == capability.id()) { + return Err("duplicate pairing capability ID"); + } + } + Ok(Self { api_version, capabilities }) + } + + pub fn capabilities(&self) -> &[PairingAdvertisedCapability] { + &self.capabilities + } +} +#[derive(Deserialize)] #[serde(deny_unknown_fields)] -pub struct PairingAdvertisedCapability { - pub id: String, - pub version: u32, - pub state: String, +struct PairingAdvertisementWire { + api_version: String, + capabilities: Vec, +} +impl TryFrom for PairingAdvertisement { + type Error = &'static str; + fn try_from(wire: PairingAdvertisementWire) -> Result { + Self::new(wire.api_version, wire.capabilities) + } +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingCapabilityState { + #[serde(rename = "granted")] + Granted, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(tag = "id", deny_unknown_fields)] +pub enum PairingAdvertisedCapability { + #[serde(rename = "device-key-proofs")] + DeviceKeyProofs { version: PairingWireVersion<1>, state: PairingCapabilityState }, + #[serde(rename = "fabric-gateway-grant")] + FabricGatewayGrant { version: PairingWireVersion<1>, state: PairingCapabilityState }, + #[serde(rename = "pairing-key-binding")] + PairingKeyBinding { version: PairingWireVersion<2>, state: PairingCapabilityState }, +} +impl PairingAdvertisedCapability { + pub fn id(&self) -> &'static str { + match self { + Self::DeviceKeyProofs { .. } => "device-key-proofs", + Self::FabricGatewayGrant { .. } => "fabric-gateway-grant", + Self::PairingKeyBinding { .. } => "pairing-key-binding", + } + } } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "kebab-case")] @@ -4374,32 +4488,143 @@ pub enum PairingExchangeOperation { Complete, Ack, } -/// Body is the canonical request model for op, not a handwritten JSON copy. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct PairingExchangeRequest { - pub v: u8, +mod pairing_request_body { + pub trait Sealed {} +} +/// Sealed to the five canonical operation bodies; downstream types cannot choose an opcode. +pub trait PairingExchangeRequestBody: pairing_request_body::Sealed { + const OP: PairingExchangeOperation; +} +#[derive(Clone, Debug, PartialEq)] +pub struct PairingExchangeRequest { pub request_id: String, - pub op: PairingExchangeOperation, pub body: T, } +macro_rules! pairing_request_body { + ($body:ty, $op:ident, $constructor:ident) => { + impl pairing_request_body::Sealed for $body {} + impl PairingExchangeRequestBody for $body { + const OP: PairingExchangeOperation = PairingExchangeOperation::$op; + } + impl PairingExchangeRequest<$body> { + pub fn $constructor(request_id: String, body: $body) -> Self { + Self { request_id, body } + } + } + }; +} +pairing_request_body!(PairingExchangeEmpty, Capabilities, capabilities); +pairing_request_body!(PairingKeyClaim, Claim, claim); +pairing_request_body!(PairingApprovalWaitRequest, ApprovalWait, approval_wait); +pairing_request_body!(PairingComplete, Complete, complete); +pairing_request_body!(PairingExchangeAck, Ack, ack); +impl PairingExchangeRequest { + pub fn op(&self) -> PairingExchangeOperation { + T::OP + } +} +impl Serialize for PairingExchangeRequest { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut wire = serializer.serialize_struct("PairingExchangeRequest", 4)?; + wire.serialize_field("v", &1_u8)?; + wire.serialize_field("request_id", &self.request_id)?; + wire.serialize_field("op", &T::OP)?; + wire.serialize_field("body", &self.body)?; + wire.end() + } +} +impl<'de, T: PairingExchangeRequestBody + Deserialize<'de>> Deserialize<'de> for PairingExchangeRequest { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + v: PairingWireVersion<1>, + request_id: String, + op: PairingExchangeOperation, + body: T, + } + let wire = Wire::::deserialize(deserializer)?; + let _ = wire.v; + if wire.op != T::OP { + return Err(serde::de::Error::custom("pairing operation does not match body type")); + } + Ok(Self { request_id: wire.request_id, body: wire.body }) + } +} #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PairingExchangeRefusal { pub code: ErrorCode, pub message: String, } -/// One reply envelope for every operation, with its typed success body. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] +/// Native outcomes cannot represent contradictory success and refusal fields. +#[derive(Clone, Debug, PartialEq)] +pub enum PairingExchangeOutcome { + Ok(T), + Refused(PairingExchangeRefusal), +} +/// One reply envelope and refusal type for every operation. +#[derive(Clone, Debug, PartialEq)] pub struct PairingExchangeReply { - pub v: u8, pub request_id: String, - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub body: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub refusal: Option, + pub outcome: PairingExchangeOutcome, +} +impl Serialize for PairingExchangeReply { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut wire = serializer.serialize_struct("PairingExchangeReply", 4)?; + wire.serialize_field("v", &1_u8)?; + wire.serialize_field("request_id", &self.request_id)?; + match &self.outcome { + PairingExchangeOutcome::Ok(body) => { + wire.serialize_field("ok", &true)?; + wire.serialize_field("body", body)?; + } + PairingExchangeOutcome::Refused(refusal) => { + wire.serialize_field("ok", &false)?; + wire.serialize_field("refusal", refusal)?; + } + } + wire.end() + } +} +// Unlike Option, this retains the distinction between a missing field and an +// explicitly present null. Forbidden fields are rejected even when null. +enum PairingExchangeField { + Missing, + Present(T), +} +impl Default for PairingExchangeField { + fn default() -> Self { Self::Missing } +} +impl<'de, T: Deserialize<'de>> Deserialize<'de> for PairingExchangeField { + fn deserialize>(deserializer: D) -> Result { + T::deserialize(deserializer).map(Self::Present) + } +} +impl<'de, T: Deserialize<'de>> Deserialize<'de> for PairingExchangeReply { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, bound(deserialize = "T: Deserialize<'de>"))] + struct Wire { + v: PairingWireVersion<1>, + request_id: String, + ok: bool, + #[serde(default)] + body: PairingExchangeField, + #[serde(default)] + refusal: PairingExchangeField, + } + let wire = Wire::::deserialize(deserializer)?; + let _ = wire.v; + let outcome = match (wire.ok, wire.body, wire.refusal) { + (true, PairingExchangeField::Present(body), PairingExchangeField::Missing) => PairingExchangeOutcome::Ok(body), + (false, PairingExchangeField::Missing, PairingExchangeField::Present(refusal)) => PairingExchangeOutcome::Refused(refusal), + _ => return Err(serde::de::Error::custom("inconsistent pairing reply outcome")), + }; + Ok(Self { request_id: wire.request_id, outcome }) + } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -4410,10 +4635,15 @@ pub struct PairingApprovalWaitRequest { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PairingApprovalWaitApproved { - pub status: String, + pub status: PairingApprovalStatus, pub key_fingerprint: String, pub approved_at: String, } +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingApprovalStatus { + #[serde(rename = "approved")] + Approved, +} #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PairingExchangeAck { @@ -4422,9 +4652,14 @@ pub struct PairingExchangeAck { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PairingAdmissionPending { - pub state: String, + pub state: PairingAdmissionState, pub pairing_id: String, } +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingAdmissionState { + #[serde(rename = "admission-pending")] + AdmissionPending, +} #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PairingResultRequest { diff --git a/crates/fractal-client-codegen/templates/lib.rs.in b/crates/fractal-client-codegen/templates/lib.rs.in index 699018077e7..8c9657ab854 100644 --- a/crates/fractal-client-codegen/templates/lib.rs.in +++ b/crates/fractal-client-codegen/templates/lib.rs.in @@ -3497,20 +3497,171 @@ fn percent_encode_segment(value: &str) -> String { mod tests { #[test] fn pairing_native_exchange_models_match_approval_wait_wire_fields() { - let request = PairingExchangeRequest { - v: 1, request_id: "wait-1".into(), op: PairingExchangeOperation::ApprovalWait, - body: PairingApprovalWaitRequest { pairing_id: "pairing/example".into(), key_fingerprint: format!("sha256:{}", "A".repeat(43)) }, - }; + use serde::de::DeserializeOwned; + use serde::Serialize; + + fn roundtrip(value: &serde_json::Value) { + let decoded: T = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), *value); + } + fn rejects(value: serde_json::Value) { + assert!(serde_json::from_value::(value.clone()).is_err(), "{value}"); + } + fn request_roundtrip(request: PairingExchangeRequest) + where + T: PairingExchangeRequestBody + DeserializeOwned + Serialize, + { + let value = serde_json::to_value(request).unwrap(); + roundtrip::>(&value); + for op in ["capabilities", "claim", "approval-wait", "complete", "ack"] { + if value["op"] != op { + let mut wrong = value.clone(); + wrong["op"] = serde_json::json!(op); + rejects::>(wrong); + } + } + let mut wrong = value; + wrong["v"] = serde_json::json!(2); + rejects::>(wrong); + } + + let request = PairingExchangeRequest::approval_wait( + "wait-1".into(), + PairingApprovalWaitRequest { + pairing_id: "pairing/example".into(), + key_fingerprint: format!("sha256:{}", "A".repeat(43)), + }, + ); let fixture: serde_json::Value = serde_json::from_str(include_str!( "../../../docs/st3/client-v0/fixtures/pairing-approval-wait.json" )).unwrap(); - assert_eq!(serde_json::to_value(request).unwrap(), fixture["cases"][0]["value"]); + assert_eq!(serde_json::to_value(&request).unwrap(), fixture["cases"][0]["value"]); + request_roundtrip(request); + request_roundtrip(PairingExchangeRequest::capabilities("cap-1".into(), PairingExchangeEmpty {})); + request_roundtrip(PairingExchangeRequest::claim("claim-1".into(), PairingKeyClaim { + api_version: "fractal.client.v0".into(), code: "ABCDEFGH".into(), + device_public_key: "key".into(), proof: "proof".into(), + })); + request_roundtrip(PairingExchangeRequest::complete("complete-1".into(), PairingComplete { + api_version: "fractal.client.v0".into(), code: "ABCDEFGH".into(), + device_public_key: "key".into(), proof: None, key_storage: None, fabric_node_id: None, + })); + request_roundtrip(PairingExchangeRequest::ack("ack-1".into(), PairingExchangeAck { request_id: "complete-1".into() })); + rejects::>(serde_json::json!({ + "v": 1, "request_id": "wait-1", "op": "approval-wait", "body": {} + })); + let approved_value = fixture["cases"][6]["value"].clone(); + let refused_value = fixture["cases"][9]["value"].clone(); let approved: PairingExchangeReply = - serde_json::from_value(fixture["cases"][6]["value"].clone()).unwrap(); - assert_eq!(approved.body.unwrap().status, "approved"); + serde_json::from_value(approved_value.clone()).unwrap(); + assert!(matches!(approved.outcome, PairingExchangeOutcome::Ok(PairingApprovalWaitApproved { + status: PairingApprovalStatus::Approved, .. + }))); let refused: PairingExchangeReply = - serde_json::from_value(fixture["cases"][9]["value"].clone()).unwrap(); - assert_eq!(refused.refusal.unwrap().code, ErrorCode::PairingRejected); + serde_json::from_value(refused_value.clone()).unwrap(); + assert!(matches!(refused.outcome, PairingExchangeOutcome::Refused(PairingExchangeRefusal { + code: ErrorCode::PairingRejected, .. + }))); + roundtrip::>(&approved_value); + roundtrip::>(&refused_value); + // Exercise every body/refusal presence combination with both ok values. + for ok in [false, true] { + for has_body in [false, true] { + for has_refusal in [false, true] { + let mut value = serde_json::json!({"v": 1, "request_id": "reply-1", "ok": ok}); + if has_body { value["body"] = approved_value["body"].clone(); } + if has_refusal { value["refusal"] = refused_value["refusal"].clone(); } + if (ok && has_body && !has_refusal) || (!ok && !has_body && has_refusal) { + roundtrip::>(&value); + } else { + rejects::>(value); + } + } + } + } + for field in ["body", "refusal"] { + for base in [&approved_value, &refused_value] { + let mut value = base.clone(); + value[field] = serde_json::Value::Null; + rejects::>(value); + } + } + let mut wrong = approved_value.clone(); + wrong["v"] = serde_json::json!(2); + rejects::>(wrong); + let mut wrong = approved_value["body"].clone(); + wrong["status"] = serde_json::json!("pending"); + rejects::(wrong); + + let pending = serde_json::json!({"state": "admission-pending", "pairing_id": "pairing/example"}); + roundtrip::(&pending); + let mut wrong = pending.clone(); + wrong["state"] = serde_json::json!("granted"); + rejects::(wrong); + roundtrip::>(&serde_json::json!({ + "v": 1, "request_id": "complete-1", "ok": true, "body": pending + })); + let qr = serde_json::json!({ + "_tag": "fractal.pairing", "version": 1, "gateway": "https://example.test", + "id": "pairing/example", "code": "ABCDEFGH", "expires_at_unix_ms": 1000 + }); + let permit = serde_json::json!({"node_id": "node", "attempt_id": "attempt", "secret": "secret"}); + roundtrip::(&qr); + let mut qr_v2 = qr.clone(); + qr_v2["version"] = serde_json::json!(2); + qr_v2["fabric"] = permit.clone(); + roundtrip::(&qr_v2); + for (field, value) in [ + ("_tag", serde_json::json!("other.pairing")), + ("version", serde_json::json!(0)), + ("version", serde_json::json!(3)), + ("fabric", permit), + ("fabric", serde_json::Value::Null), + ] { + let mut wrong = qr.clone(); + wrong[field] = value; + rejects::(wrong); + } + let mut wrong = qr.clone(); + wrong["version"] = serde_json::json!(2); + rejects::(wrong); + let mut wrong = qr_v2; + wrong["fabric"] = serde_json::Value::Null; + rejects::(wrong); + + let capabilities = serde_json::json!([ + {"id": "device-key-proofs", "version": 1, "state": "granted"}, + {"id": "fabric-gateway-grant", "version": 1, "state": "granted"}, + {"id": "pairing-key-binding", "version": 2, "state": "granted"} + ]); + let advertisement = serde_json::json!({"api_version": "fractal.client.v0", "capabilities": capabilities}); + roundtrip::(&advertisement); + roundtrip::>(&serde_json::json!({ + "v": 1, "request_id": "cap-1", "ok": true, "body": advertisement + })); + for capability in capabilities.as_array().unwrap() { + roundtrip::(capability); + for (field, value) in [ + ("id", serde_json::json!("unknown")), + ("state", serde_json::json!("pending")), + ("version", serde_json::json!(0)), + ("version", serde_json::json!(3)), + ("version", serde_json::json!(if capability["version"] == 1 { 2 } else { 1 })), + ] { + let mut wrong = capability.clone(); + wrong[field] = value; + rejects::(wrong); + } + } + for invalid in [ + serde_json::json!([]), + serde_json::json!([capabilities[0], capabilities[0]]), + serde_json::json!([capabilities[0], capabilities[1], capabilities[2], capabilities[0]]), + ] { + rejects::(serde_json::json!({ + "api_version": "fractal.client.v0", "capabilities": invalid + })); + } } #[test] From 35cb2b19c80923461f668ade976b85a7b3b0ac57 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:04:20 +0100 Subject: [PATCH 132/132] Regenerate strict pairing codecs from 63b7564886170320263f04c04006df6632c6c683 agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../Sources/FractalClient/Models.swift | 267 ++++++++++++-- .../fractal-client/ReadSchema.generated.ts | 2 +- .../fractal-client/Schema.generated.ts | 2 +- crates/fractal-client/src/generated.rs | 342 ++++++++++++++++-- crates/fractal-client/src/lib.rs | 214 ++++++++++- 5 files changed, 755 insertions(+), 72 deletions(-) diff --git a/clients/swift/FractalClient/Sources/FractalClient/Models.swift b/clients/swift/FractalClient/Sources/FractalClient/Models.swift index 863d674c906..f10d869b99a 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Models.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Models.swift @@ -1574,71 +1574,266 @@ public struct PairingKeyStatus: Codable, Sendable { public let deviceKeyFingerprint: String? enum CodingKeys: String, CodingKey { case kind, status, expiresAt = "expires_at", deviceKeyFingerprint = "device_key_fingerprint" } } -public struct PairingExchangeEmpty: Codable, Sendable { public init() {} } +public struct PairingExchangeEmpty: Codable, Sendable { + public init() {} + public init(from decoder: Decoder) throws { + let fields = try decoder.container(keyedBy: PairingWireKey.self) + guard fields.allKeys.isEmpty else { throw pairingWireError(decoder, "Expected an empty pairing body") } + } + public func encode(to encoder: Encoder) throws { _ = encoder.container(keyedBy: PairingWireKey.self) } +} + +private struct PairingWireKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } +} + +private func pairingWireError(_ decoder: Decoder, _ message: String) -> DecodingError { + .dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: message)) +} + +private func pairingWireFields(_ decoder: Decoder, allowed: Set) throws { + let fields = try decoder.container(keyedBy: PairingWireKey.self) + guard fields.allKeys.allSatisfy({ allowed.contains($0.stringValue) }) else { + throw pairingWireError(decoder, "Unexpected pairing field") + } +} /// Keep secret in memory only; do not log or persist a permit or its QR payload. public struct PairingFabricPermit: Codable, Sendable { public let nodeID, attemptID, secret: String public init(nodeID: String, attemptID: String, secret: String) { self.nodeID = nodeID; self.attemptID = attemptID; self.secret = secret } enum CodingKeys: String, CodingKey { case nodeID = "node_id", attemptID = "attempt_id", secret } } +public enum PairingQrTag: String, Codable, Sendable { case pairing = "fractal.pairing" } +public enum PairingQrVersion: Sendable { + case v1 + case v2(PairingFabricPermit) +} + +/// Version two carries a fabric permit; version one cannot carry one. public struct PairingQrPayload: Codable, Sendable { - public let tag: String - public let version: UInt8 + public var tag: PairingQrTag { .pairing } + public let version: PairingQrVersion public let gateway, id, code: String public let expiresAtUnixMS: Int64 - public let fabric: PairingFabricPermit? - public init(tag: String = "fractal.pairing", version: UInt8, gateway: String, id: String, code: String, expiresAtUnixMS: Int64, fabric: PairingFabricPermit? = nil) { - self.tag = tag; self.version = version; self.gateway = gateway; self.id = id; self.code = code; self.expiresAtUnixMS = expiresAtUnixMS; self.fabric = fabric + public init(version: PairingQrVersion, gateway: String, id: String, code: String, expiresAtUnixMS: Int64) { + self.version = version; self.gateway = gateway; self.id = id; self.code = code; self.expiresAtUnixMS = expiresAtUnixMS } enum CodingKeys: String, CodingKey { case tag = "_tag", version, gateway, id, code, expiresAtUnixMS = "expires_at_unix_ms", fabric } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["_tag", "version", "gateway", "id", "code", "expires_at_unix_ms", "fabric"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + _ = try fields.decode(PairingQrTag.self, forKey: .tag) + switch try fields.decode(UInt8.self, forKey: .version) { + case 1: + guard !fields.contains(.fabric) else { throw pairingWireError(decoder, "Version one must not carry fabric") } + version = .v1 + case 2: version = .v2(try fields.decode(PairingFabricPermit.self, forKey: .fabric)) + default: throw pairingWireError(decoder, "Unsupported pairing QR version") + } + gateway = try fields.decode(String.self, forKey: .gateway) + id = try fields.decode(String.self, forKey: .id) + code = try fields.decode(String.self, forKey: .code) + expiresAtUnixMS = try fields.decode(Int64.self, forKey: .expiresAtUnixMS) + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(tag, forKey: .tag) + switch version { + case .v1: try fields.encode(1, forKey: .version) + case .v2(let permit): + try fields.encode(2, forKey: .version) + try fields.encode(permit, forKey: .fabric) + } + try fields.encode(gateway, forKey: .gateway) + try fields.encode(id, forKey: .id) + try fields.encode(code, forKey: .code) + try fields.encode(expiresAtUnixMS, forKey: .expiresAtUnixMS) + } +} + +public enum PairingCapabilityState: String, Codable, Sendable { case granted } +public enum PairingAdvertisedCapability: String, Sendable, CaseIterable, Hashable, Codable { + case deviceKeyProofs = "device-key-proofs" + case fabricGatewayGrant = "fabric-gateway-grant" + case pairingKeyBinding = "pairing-key-binding" + public var id: String { rawValue } + public var version: UInt32 { self == .pairingKeyBinding ? 2 : 1 } + public var state: PairingCapabilityState { .granted } + enum CodingKeys: String, CodingKey { case id, version, state } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["id", "version", "state"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + let id = try fields.decode(String.self, forKey: .id) + guard let capability = Self(rawValue: id), + try fields.decode(UInt32.self, forKey: .version) == capability.version else { + throw pairingWireError(decoder, "Unsupported pairing capability or version") + } + _ = try fields.decode(PairingCapabilityState.self, forKey: .state) + self = capability + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(id, forKey: .id) + try fields.encode(version, forKey: .version) + try fields.encode(state, forKey: .state) + } } + public struct PairingAdvertisement: Codable, Sendable { public let apiVersion: String public let capabilities: [PairingAdvertisedCapability] - public init(apiVersion: String, capabilities: [PairingAdvertisedCapability]) { self.apiVersion = apiVersion; self.capabilities = capabilities } + public init(apiVersion: String, capabilities: [PairingAdvertisedCapability]) throws { + guard !capabilities.isEmpty, capabilities.count <= 3, Set(capabilities).count == capabilities.count else { + throw EncodingError.invalidValue(capabilities, .init(codingPath: [], debugDescription: "Expected one to three unique pairing capabilities")) + } + self.apiVersion = apiVersion; self.capabilities = capabilities + } enum CodingKeys: String, CodingKey { case apiVersion = "api_version", capabilities } + public init(from decoder: Decoder) throws { + let fields = try decoder.container(keyedBy: CodingKeys.self) + let capabilities = try fields.decode([PairingAdvertisedCapability].self, forKey: .capabilities) + guard !capabilities.isEmpty, capabilities.count <= 3, Set(capabilities).count == capabilities.count else { + throw pairingWireError(decoder, "Expected one to three unique pairing capabilities") + } + self.apiVersion = try fields.decode(String.self, forKey: .apiVersion) + self.capabilities = capabilities + } } -public struct PairingAdvertisedCapability: Codable, Sendable { - public let id: String - public let version: UInt32 - public let state: String - public init(id: String, version: UInt32, state: String = "granted") { self.id = id; self.version = version; self.state = state } -} + public enum PairingExchangeOperation: String, Codable, Sendable { case capabilities, claim, approvalWait = "approval-wait", complete, ack } -/// Body is the canonical request model for op, not a handwritten JSON copy. -public struct PairingExchangeRequest: Codable, Sendable { - public let v: UInt8 + +/// The operation is derived from the canonical typed body, never independently supplied. +public enum PairingExchangeRequestBody: Sendable { + case capabilities + case claim(PairingKeyClaim) + case approvalWait(PairingApprovalWaitRequest) + case complete(PairingComplete) + case ack(PairingExchangeAck) + public var op: PairingExchangeOperation { + switch self { + case .capabilities: .capabilities + case .claim: .claim + case .approvalWait: .approvalWait + case .complete: .complete + case .ack: .ack + } + } +} + +public struct PairingExchangeRequest: Codable, Sendable { + public var v: UInt8 { 1 } public let requestID: String - public let op: PairingExchangeOperation - public let body: Body - public init(requestID: String, op: PairingExchangeOperation, body: Body) { self.v = 1; self.requestID = requestID; self.op = op; self.body = body } + public var op: PairingExchangeOperation { body.op } + public let body: PairingExchangeRequestBody + public init(requestID: String, body: PairingExchangeRequestBody) { self.requestID = requestID; self.body = body } + public static func capabilities(requestID: String) -> Self { .init(requestID: requestID, body: .capabilities) } + public static func claim(requestID: String, body: PairingKeyClaim) -> Self { .init(requestID: requestID, body: .claim(body)) } + public static func approvalWait(requestID: String, body: PairingApprovalWaitRequest) -> Self { .init(requestID: requestID, body: .approvalWait(body)) } + public static func complete(requestID: String, body: PairingComplete) -> Self { .init(requestID: requestID, body: .complete(body)) } + public static func ack(requestID: String, body: PairingExchangeAck) -> Self { .init(requestID: requestID, body: .ack(body)) } enum CodingKeys: String, CodingKey { case v, requestID = "request_id", op, body } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["v", "request_id", "op", "body"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + guard try fields.decode(UInt8.self, forKey: .v) == 1 else { throw pairingWireError(decoder, "Unsupported pairing exchange version") } + requestID = try fields.decode(String.self, forKey: .requestID) + let bodyDecoder = try fields.superDecoder(forKey: .body) + switch try fields.decode(PairingExchangeOperation.self, forKey: .op) { + case .capabilities: + _ = try PairingExchangeEmpty(from: bodyDecoder) + body = .capabilities + case .claim: + try pairingWireFields(bodyDecoder, allowed: ["api_version", "code", "device_public_key", "proof"]) + body = .claim(try PairingKeyClaim(from: bodyDecoder)) + case .approvalWait: + try pairingWireFields(bodyDecoder, allowed: ["pairing_id", "key_fingerprint"]) + body = .approvalWait(try PairingApprovalWaitRequest(from: bodyDecoder)) + case .complete: + try pairingWireFields(bodyDecoder, allowed: ["api_version", "code", "device_public_key", "proof", "key_storage", "fabric_node_id"]) + body = .complete(try PairingComplete(from: bodyDecoder)) + case .ack: + try pairingWireFields(bodyDecoder, allowed: ["request_id"]) + body = .ack(try PairingExchangeAck(from: bodyDecoder)) + } + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(v, forKey: .v) + try fields.encode(requestID, forKey: .requestID) + try fields.encode(op, forKey: .op) + switch body { + case .capabilities: try fields.encode(PairingExchangeEmpty(), forKey: .body) + case .claim(let body): try fields.encode(body, forKey: .body) + case .approvalWait(let body): try fields.encode(body, forKey: .body) + case .complete(let body): try fields.encode(body, forKey: .body) + case .ack(let body): try fields.encode(body, forKey: .body) + } + } } public struct PairingExchangeRefusal: Codable, Sendable { public let code: ErrorCode public let message: String public init(code: ErrorCode, message: String) { self.code = code; self.message = message } } -/// One reply envelope for every operation, with its typed success body. +/// A success and a refusal cannot coexist or both be absent. +public enum PairingExchangeOutcome: Sendable { + case ok(Body) + case refused(PairingExchangeRefusal) +} + +/// One reply envelope and one common refusal for every operation. public struct PairingExchangeReply: Codable, Sendable { - public let v: UInt8 + public var v: UInt8 { 1 } public let requestID: String - public let ok: Bool - public let body: Body? - public let refusal: PairingExchangeRefusal? - public init(requestID: String, ok: Bool, body: Body? = nil, refusal: PairingExchangeRefusal? = nil) { self.v = 1; self.requestID = requestID; self.ok = ok; self.body = body; self.refusal = refusal } + public let outcome: PairingExchangeOutcome + public init(requestID: String, outcome: PairingExchangeOutcome) { self.requestID = requestID; self.outcome = outcome } enum CodingKeys: String, CodingKey { case v, requestID = "request_id", ok, body, refusal } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["v", "request_id", "ok", "body", "refusal"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + guard try fields.decode(UInt8.self, forKey: .v) == 1 else { throw pairingWireError(decoder, "Unsupported pairing exchange version") } + requestID = try fields.decode(String.self, forKey: .requestID) + if try fields.decode(Bool.self, forKey: .ok) { + guard fields.contains(.body), !fields.contains(.refusal), !(try fields.decodeNil(forKey: .body)) else { + throw pairingWireError(decoder, "Successful pairing reply requires only a body") + } + outcome = .ok(try fields.decode(Body.self, forKey: .body)) + } else { + guard fields.contains(.refusal), !fields.contains(.body) else { + throw pairingWireError(decoder, "Refused pairing reply requires only a refusal") + } + outcome = .refused(try fields.decode(PairingExchangeRefusal.self, forKey: .refusal)) + } + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(v, forKey: .v) + try fields.encode(requestID, forKey: .requestID) + switch outcome { + case .ok(let body): + try fields.encode(true, forKey: .ok) + try fields.encode(body, forKey: .body) + case .refused(let refusal): + try fields.encode(false, forKey: .ok) + try fields.encode(refusal, forKey: .refusal) + } + } } public struct PairingApprovalWaitRequest: Codable, Sendable { public let pairingID, keyFingerprint: String public init(pairingID: String, keyFingerprint: String) { self.pairingID = pairingID; self.keyFingerprint = keyFingerprint } enum CodingKeys: String, CodingKey { case pairingID = "pairing_id", keyFingerprint = "key_fingerprint" } } +public enum PairingApprovalStatus: String, Codable, Sendable { case approved } public struct PairingApprovalWaitApproved: Codable, Sendable { - public let status, keyFingerprint, approvedAt: String - public init(keyFingerprint: String, approvedAt: String) { self.status = "approved"; self.keyFingerprint = keyFingerprint; self.approvedAt = approvedAt } + public let status: PairingApprovalStatus + public let keyFingerprint, approvedAt: String + public init(keyFingerprint: String, approvedAt: String) { self.status = .approved; self.keyFingerprint = keyFingerprint; self.approvedAt = approvedAt } enum CodingKeys: String, CodingKey { case status, keyFingerprint = "key_fingerprint", approvedAt = "approved_at" } } public struct PairingExchangeAck: Codable, Sendable { @@ -1646,9 +1841,11 @@ public struct PairingExchangeAck: Codable, Sendable { public init(requestID: String) { self.requestID = requestID } enum CodingKeys: String, CodingKey { case requestID = "request_id" } } +public enum PairingAdmissionState: String, Codable, Sendable { case pending = "admission-pending" } public struct PairingAdmissionPending: Codable, Sendable { - public let state, pairingID: String - public init(pairingID: String) { self.state = "admission-pending"; self.pairingID = pairingID } + public let state: PairingAdmissionState + public let pairingID: String + public init(pairingID: String) { self.state = .pending; self.pairingID = pairingID } enum CodingKeys: String, CodingKey { case state, pairingID = "pairing_id" } } public struct PairingResultRequest: Codable, Sendable { @@ -1667,7 +1864,17 @@ public struct PairingChallenge: Codable, Sendable { public let principal: AssistantPrincipal?; public let grantKind, grantID, grantExpiresAt: String?; public let policyRevision: UInt64?; public let minimalReadMode: AssistantMinimalReadMode? enum CodingKeys: String, CodingKey { case kind, pairingID = "pairing_id", code, expiresAt = "expires_at", personRootFingerprint = "person_root_fingerprint", principal, grantKind = "grant_kind", grantID = "grant_id", grantExpiresAt = "grant_expires_at", policyRevision = "policy_revision", minimalReadMode = "minimal_read_mode" } } -public struct PairingComplete: Codable, Sendable { public let apiVersion: String; public let code: String; public let devicePublicKey: String; public var proof: String? = nil; public var keyStorage: String? = nil; public var fabricNodeID: String? = nil; enum CodingKeys: String, CodingKey { case apiVersion = "api_version", code, devicePublicKey = "device_public_key", proof, keyStorage = "key_storage", fabricNodeID = "fabric_node_id" } } +public struct PairingComplete: Codable, Sendable { + public let apiVersion, code, devicePublicKey: String + public var proof: String? + public var keyStorage: String? + public var fabricNodeID: String? + public init(apiVersion: String, code: String, devicePublicKey: String, proof: String? = nil, keyStorage: String? = nil, fabricNodeID: String? = nil) { + self.apiVersion = apiVersion; self.code = code; self.devicePublicKey = devicePublicKey + self.proof = proof; self.keyStorage = keyStorage; self.fabricNodeID = fabricNodeID + } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", code, devicePublicKey = "device_public_key", proof, keyStorage = "key_storage", fabricNodeID = "fabric_node_id" } +} public enum PairedSession: Codable, Sendable { case person(PersonPairedSession), assistant(AssistantPairedSession) public init(from decoder: Decoder) throws { diff --git a/clients/typescript/fractal-client/ReadSchema.generated.ts b/clients/typescript/fractal-client/ReadSchema.generated.ts index 837ae58c626..74c52294d4f 100644 --- a/clients/typescript/fractal-client/ReadSchema.generated.ts +++ b/clients/typescript/fractal-client/ReadSchema.generated.ts @@ -4203,7 +4203,7 @@ export type MessageBodyEncoded = typeof MessageBody.Encoded /** Static unauthenticated pairing preflight; no envelope, presence update or graph information. */ export const PairingAdvertisement = /*#__PURE__*/ (() => Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), - "capabilities": Schema.Array(Schema.Union([Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) }), Schema.Struct({ "id": Schema.Literal("pairing-key-binding"), "state": Schema.Literal("granted"), "version": Schema.Literal(2) })], { mode: "oneOf" })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)) + "capabilities": Schema.Array(Schema.Union([Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) }), Schema.Struct({ "id": Schema.Literal("pairing-key-binding"), "state": Schema.Literal("granted"), "version": Schema.Literal(2) })], { mode: "oneOf" })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)).check(Schema.isUnique()) }).annotate({ identifier: "PairingAdvertisement", description: "Static unauthenticated pairing preflight; no envelope, presence update or graph information." }))() export type PairingAdvertisement = typeof PairingAdvertisement.Type export type PairingAdvertisementEncoded = typeof PairingAdvertisement.Encoded diff --git a/clients/typescript/fractal-client/Schema.generated.ts b/clients/typescript/fractal-client/Schema.generated.ts index 597d0344de1..e2d40c843f5 100644 --- a/clients/typescript/fractal-client/Schema.generated.ts +++ b/clients/typescript/fractal-client/Schema.generated.ts @@ -1421,7 +1421,7 @@ export type PairingKeyStatus = typeof PairingKeyStatus.Type export type PairingKeyStatusEncoded = typeof PairingKeyStatus.Encoded export const PairingExchangeReply = /*#__PURE__*/ (() => Schema.Union([ - Schema.Struct({ "body": Schema.Union([Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "capabilities": Schema.Array(Schema.Union([Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) }), Schema.Struct({ "id": Schema.Literal("pairing-key-binding"), "state": Schema.Literal("granted"), "version": Schema.Literal(2) })], { mode: "oneOf" })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)), "approved_at": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "device_key_fingerprint": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u")))), "expires_at": Timestamp, "kind": Schema.Literal("pairing-key-status"), "status": Schema.Literals(["awaiting-key","awaiting-approval","approved","completed","expired"]), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "approved_at": Timestamp, "key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))), "status": Schema.Literal("approved"), "api_version": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "pairing_id": Id, "state": Schema.Literal("admission-pending"), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Record(Schema.String, Schema.Never)], { mode: "oneOf" }), "ok": Schema.Literal(true), "refusal": optionalKey(Schema.Never), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": Schema.Union([Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "capabilities": Schema.Array(Schema.Union([Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) }), Schema.Struct({ "id": Schema.Literal("pairing-key-binding"), "state": Schema.Literal("granted"), "version": Schema.Literal(2) })], { mode: "oneOf" })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)).check(Schema.isUnique()), "approved_at": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "device_key_fingerprint": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u")))), "expires_at": Timestamp, "kind": Schema.Literal("pairing-key-status"), "status": Schema.Literals(["awaiting-key","awaiting-approval","approved","completed","expired"]), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "approved_at": Timestamp, "key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))), "status": Schema.Literal("approved"), "api_version": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "pairing_id": Id, "state": Schema.Literal("admission-pending"), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Record(Schema.String, Schema.Never)], { mode: "oneOf" }), "ok": Schema.Literal(true), "refusal": optionalKey(Schema.Never), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), Schema.Struct({ "body": optionalKey(Schema.Never), "ok": Schema.Literal(false), "refusal": PairingExchangeRefusal, "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }) ], { mode: "oneOf" }).annotate({ identifier: "PairingExchangeReply" }))() export type PairingExchangeReply = typeof PairingExchangeReply.Type diff --git a/crates/fractal-client/src/generated.rs b/crates/fractal-client/src/generated.rs index 02828499b0d..a4a3aafbe1a 100644 --- a/crates/fractal-client/src/generated.rs +++ b/crates/fractal-client/src/generated.rs @@ -5170,31 +5170,174 @@ pub struct PairingFabricPermit { pub attempt_id: String, pub secret: String, } +/// A numeric wire constant: callers cannot construct a different value. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PairingWireVersion; +impl Serialize for PairingWireVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u8(V) + } +} +impl<'de, const V: u8> Deserialize<'de> for PairingWireVersion { + fn deserialize>(deserializer: D) -> Result { + if u8::deserialize(deserializer)? == V { + Ok(Self) + } else { + Err(serde::de::Error::custom("incorrect pairing wire version")) + } + } +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingQrTag { + #[serde(rename = "fractal.pairing")] + Pairing, +} +/// Each variant enforces its version and presence (or absence) of a Fabric permit. +#[derive(Clone, Serialize, PartialEq)] +#[serde(untagged)] +pub enum PairingQrPayload { + V1(PairingQrV1), + V2(PairingQrV2), +} +impl<'de> Deserialize<'de> for PairingQrPayload { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + #[serde(rename = "_tag")] + tag: PairingQrTag, + version: u8, + gateway: String, + id: String, + code: String, + expires_at_unix_ms: i64, + #[serde(default)] + fabric: PairingExchangeField, + } + let wire = Wire::deserialize(deserializer)?; + match (wire.version, wire.fabric) { + (1, PairingExchangeField::Missing) => Ok(Self::V1(PairingQrV1 { + tag: wire.tag, + version: PairingWireVersion, + gateway: wire.gateway, + id: wire.id, + code: wire.code, + expires_at_unix_ms: wire.expires_at_unix_ms, + })), + (2, PairingExchangeField::Present(fabric)) => Ok(Self::V2(PairingQrV2 { + tag: wire.tag, + version: PairingWireVersion, + gateway: wire.gateway, + id: wire.id, + code: wire.code, + expires_at_unix_ms: wire.expires_at_unix_ms, + fabric, + })), + _ => Err(serde::de::Error::custom( + "pairing QR version does not match Fabric permit presence", + )), + } + } +} #[derive(Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] -pub struct PairingQrPayload { +pub struct PairingQrV1 { #[serde(rename = "_tag")] - pub tag: String, - pub version: u8, + pub tag: PairingQrTag, + pub version: PairingWireVersion<1>, pub gateway: String, pub id: String, pub code: String, pub expires_at_unix_ms: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub fabric: Option, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] +pub struct PairingQrV2 { + #[serde(rename = "_tag")] + pub tag: PairingQrTag, + pub version: PairingWireVersion<2>, + pub gateway: String, + pub id: String, + pub code: String, + pub expires_at_unix_ms: i64, + pub fabric: PairingFabricPermit, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(try_from = "PairingAdvertisementWire")] pub struct PairingAdvertisement { pub api_version: String, - pub capabilities: Vec, + capabilities: Vec, +} +impl PairingAdvertisement { + pub fn new( + api_version: String, + capabilities: Vec, + ) -> Result { + if capabilities.is_empty() || capabilities.len() > 3 { + return Err("pairing advertisement requires one to three capabilities"); + } + for (index, capability) in capabilities.iter().enumerate() { + if capabilities[..index] + .iter() + .any(|other| other.id() == capability.id()) + { + return Err("duplicate pairing capability ID"); + } + } + Ok(Self { + api_version, + capabilities, + }) + } + + pub fn capabilities(&self) -> &[PairingAdvertisedCapability] { + &self.capabilities + } } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Deserialize)] #[serde(deny_unknown_fields)] -pub struct PairingAdvertisedCapability { - pub id: String, - pub version: u32, - pub state: String, +struct PairingAdvertisementWire { + api_version: String, + capabilities: Vec, +} +impl TryFrom for PairingAdvertisement { + type Error = &'static str; + fn try_from(wire: PairingAdvertisementWire) -> Result { + Self::new(wire.api_version, wire.capabilities) + } +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingCapabilityState { + #[serde(rename = "granted")] + Granted, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(tag = "id", deny_unknown_fields)] +pub enum PairingAdvertisedCapability { + #[serde(rename = "device-key-proofs")] + DeviceKeyProofs { + version: PairingWireVersion<1>, + state: PairingCapabilityState, + }, + #[serde(rename = "fabric-gateway-grant")] + FabricGatewayGrant { + version: PairingWireVersion<1>, + state: PairingCapabilityState, + }, + #[serde(rename = "pairing-key-binding")] + PairingKeyBinding { + version: PairingWireVersion<2>, + state: PairingCapabilityState, + }, +} +impl PairingAdvertisedCapability { + pub fn id(&self) -> &'static str { + match self { + Self::DeviceKeyProofs { .. } => "device-key-proofs", + Self::FabricGatewayGrant { .. } => "fabric-gateway-grant", + Self::PairingKeyBinding { .. } => "pairing-key-binding", + } + } } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "kebab-case")] @@ -5205,32 +5348,163 @@ pub enum PairingExchangeOperation { Complete, Ack, } -/// Body is the canonical request model for op, not a handwritten JSON copy. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct PairingExchangeRequest { - pub v: u8, +mod pairing_request_body { + pub trait Sealed {} +} +/// Sealed to the five canonical operation bodies; downstream types cannot choose an opcode. +pub trait PairingExchangeRequestBody: pairing_request_body::Sealed { + const OP: PairingExchangeOperation; +} +#[derive(Clone, Debug, PartialEq)] +pub struct PairingExchangeRequest { pub request_id: String, - pub op: PairingExchangeOperation, pub body: T, } +macro_rules! pairing_request_body { + ($body:ty, $op:ident, $constructor:ident) => { + impl pairing_request_body::Sealed for $body {} + impl PairingExchangeRequestBody for $body { + const OP: PairingExchangeOperation = PairingExchangeOperation::$op; + } + impl PairingExchangeRequest<$body> { + pub fn $constructor(request_id: String, body: $body) -> Self { + Self { request_id, body } + } + } + }; +} +pairing_request_body!(PairingExchangeEmpty, Capabilities, capabilities); +pairing_request_body!(PairingKeyClaim, Claim, claim); +pairing_request_body!(PairingApprovalWaitRequest, ApprovalWait, approval_wait); +pairing_request_body!(PairingComplete, Complete, complete); +pairing_request_body!(PairingExchangeAck, Ack, ack); +impl PairingExchangeRequest { + pub fn op(&self) -> PairingExchangeOperation { + T::OP + } +} +impl Serialize for PairingExchangeRequest { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut wire = serializer.serialize_struct("PairingExchangeRequest", 4)?; + wire.serialize_field("v", &1_u8)?; + wire.serialize_field("request_id", &self.request_id)?; + wire.serialize_field("op", &T::OP)?; + wire.serialize_field("body", &self.body)?; + wire.end() + } +} +impl<'de, T: PairingExchangeRequestBody + Deserialize<'de>> Deserialize<'de> + for PairingExchangeRequest +{ + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + v: PairingWireVersion<1>, + request_id: String, + op: PairingExchangeOperation, + body: T, + } + let wire = Wire::::deserialize(deserializer)?; + let _ = wire.v; + if wire.op != T::OP { + return Err(serde::de::Error::custom( + "pairing operation does not match body type", + )); + } + Ok(Self { + request_id: wire.request_id, + body: wire.body, + }) + } +} #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PairingExchangeRefusal { pub code: ErrorCode, pub message: String, } -/// One reply envelope for every operation, with its typed success body. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] +/// Native outcomes cannot represent contradictory success and refusal fields. +#[derive(Clone, Debug, PartialEq)] +pub enum PairingExchangeOutcome { + Ok(T), + Refused(PairingExchangeRefusal), +} +/// One reply envelope and refusal type for every operation. +#[derive(Clone, Debug, PartialEq)] pub struct PairingExchangeReply { - pub v: u8, pub request_id: String, - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub body: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub refusal: Option, + pub outcome: PairingExchangeOutcome, +} +impl Serialize for PairingExchangeReply { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut wire = serializer.serialize_struct("PairingExchangeReply", 4)?; + wire.serialize_field("v", &1_u8)?; + wire.serialize_field("request_id", &self.request_id)?; + match &self.outcome { + PairingExchangeOutcome::Ok(body) => { + wire.serialize_field("ok", &true)?; + wire.serialize_field("body", body)?; + } + PairingExchangeOutcome::Refused(refusal) => { + wire.serialize_field("ok", &false)?; + wire.serialize_field("refusal", refusal)?; + } + } + wire.end() + } +} +// Unlike Option, this retains the distinction between a missing field and an +// explicitly present null. Forbidden fields are rejected even when null. +enum PairingExchangeField { + Missing, + Present(T), +} +impl Default for PairingExchangeField { + fn default() -> Self { + Self::Missing + } +} +impl<'de, T: Deserialize<'de>> Deserialize<'de> for PairingExchangeField { + fn deserialize>(deserializer: D) -> Result { + T::deserialize(deserializer).map(Self::Present) + } +} +impl<'de, T: Deserialize<'de>> Deserialize<'de> for PairingExchangeReply { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, bound(deserialize = "T: Deserialize<'de>"))] + struct Wire { + v: PairingWireVersion<1>, + request_id: String, + ok: bool, + #[serde(default)] + body: PairingExchangeField, + #[serde(default)] + refusal: PairingExchangeField, + } + let wire = Wire::::deserialize(deserializer)?; + let _ = wire.v; + let outcome = match (wire.ok, wire.body, wire.refusal) { + (true, PairingExchangeField::Present(body), PairingExchangeField::Missing) => { + PairingExchangeOutcome::Ok(body) + } + (false, PairingExchangeField::Missing, PairingExchangeField::Present(refusal)) => { + PairingExchangeOutcome::Refused(refusal) + } + _ => { + return Err(serde::de::Error::custom( + "inconsistent pairing reply outcome", + )); + } + }; + Ok(Self { + request_id: wire.request_id, + outcome, + }) + } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -5241,10 +5515,15 @@ pub struct PairingApprovalWaitRequest { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PairingApprovalWaitApproved { - pub status: String, + pub status: PairingApprovalStatus, pub key_fingerprint: String, pub approved_at: String, } +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingApprovalStatus { + #[serde(rename = "approved")] + Approved, +} #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PairingExchangeAck { @@ -5253,9 +5532,14 @@ pub struct PairingExchangeAck { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PairingAdmissionPending { - pub state: String, + pub state: PairingAdmissionState, pub pairing_id: String, } +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingAdmissionState { + #[serde(rename = "admission-pending")] + AdmissionPending, +} #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PairingResultRequest { diff --git a/crates/fractal-client/src/lib.rs b/crates/fractal-client/src/lib.rs index 4b98d609cdd..ad50a904b62 100644 --- a/crates/fractal-client/src/lib.rs +++ b/crates/fractal-client/src/lib.rs @@ -5372,29 +5372,221 @@ fn percent_encode_segment(value: &str) -> String { mod tests { #[test] fn pairing_native_exchange_models_match_approval_wait_wire_fields() { - let request = PairingExchangeRequest { - v: 1, - request_id: "wait-1".into(), - op: PairingExchangeOperation::ApprovalWait, - body: PairingApprovalWaitRequest { + use serde::Serialize; + use serde::de::DeserializeOwned; + + fn roundtrip(value: &serde_json::Value) { + let decoded: T = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), *value); + } + fn rejects(value: serde_json::Value) { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "{value}" + ); + } + fn request_roundtrip(request: PairingExchangeRequest) + where + T: PairingExchangeRequestBody + DeserializeOwned + Serialize, + { + let value = serde_json::to_value(request).unwrap(); + roundtrip::>(&value); + for op in ["capabilities", "claim", "approval-wait", "complete", "ack"] { + if value["op"] != op { + let mut wrong = value.clone(); + wrong["op"] = serde_json::json!(op); + rejects::>(wrong); + } + } + let mut wrong = value; + wrong["v"] = serde_json::json!(2); + rejects::>(wrong); + } + + let request = PairingExchangeRequest::approval_wait( + "wait-1".into(), + PairingApprovalWaitRequest { pairing_id: "pairing/example".into(), key_fingerprint: format!("sha256:{}", "A".repeat(43)), }, - }; + ); let fixture: serde_json::Value = serde_json::from_str(include_str!( "../../../docs/st3/client-v0/fixtures/pairing-approval-wait.json" )) .unwrap(); assert_eq!( - serde_json::to_value(request).unwrap(), + serde_json::to_value(&request).unwrap(), fixture["cases"][0]["value"] ); + request_roundtrip(request); + request_roundtrip(PairingExchangeRequest::capabilities( + "cap-1".into(), + PairingExchangeEmpty {}, + )); + request_roundtrip(PairingExchangeRequest::claim( + "claim-1".into(), + PairingKeyClaim { + api_version: "fractal.client.v0".into(), + code: "ABCDEFGH".into(), + device_public_key: "key".into(), + proof: "proof".into(), + }, + )); + request_roundtrip(PairingExchangeRequest::complete( + "complete-1".into(), + PairingComplete { + api_version: "fractal.client.v0".into(), + code: "ABCDEFGH".into(), + device_public_key: "key".into(), + proof: None, + key_storage: None, + fabric_node_id: None, + }, + )); + request_roundtrip(PairingExchangeRequest::ack( + "ack-1".into(), + PairingExchangeAck { + request_id: "complete-1".into(), + }, + )); + rejects::>(serde_json::json!({ + "v": 1, "request_id": "wait-1", "op": "approval-wait", "body": {} + })); + let approved_value = fixture["cases"][6]["value"].clone(); + let refused_value = fixture["cases"][9]["value"].clone(); let approved: PairingExchangeReply = - serde_json::from_value(fixture["cases"][6]["value"].clone()).unwrap(); - assert_eq!(approved.body.unwrap().status, "approved"); + serde_json::from_value(approved_value.clone()).unwrap(); + assert!(matches!( + approved.outcome, + PairingExchangeOutcome::Ok(PairingApprovalWaitApproved { + status: PairingApprovalStatus::Approved, + .. + }) + )); let refused: PairingExchangeReply = - serde_json::from_value(fixture["cases"][9]["value"].clone()).unwrap(); - assert_eq!(refused.refusal.unwrap().code, ErrorCode::PairingRejected); + serde_json::from_value(refused_value.clone()).unwrap(); + assert!(matches!( + refused.outcome, + PairingExchangeOutcome::Refused(PairingExchangeRefusal { + code: ErrorCode::PairingRejected, + .. + }) + )); + roundtrip::>(&approved_value); + roundtrip::>(&refused_value); + // Exercise every body/refusal presence combination with both ok values. + for ok in [false, true] { + for has_body in [false, true] { + for has_refusal in [false, true] { + let mut value = serde_json::json!({"v": 1, "request_id": "reply-1", "ok": ok}); + if has_body { + value["body"] = approved_value["body"].clone(); + } + if has_refusal { + value["refusal"] = refused_value["refusal"].clone(); + } + if (ok && has_body && !has_refusal) || (!ok && !has_body && has_refusal) { + roundtrip::>(&value); + } else { + rejects::>(value); + } + } + } + } + for field in ["body", "refusal"] { + for base in [&approved_value, &refused_value] { + let mut value = base.clone(); + value[field] = serde_json::Value::Null; + rejects::>(value); + } + } + let mut wrong = approved_value.clone(); + wrong["v"] = serde_json::json!(2); + rejects::>(wrong); + let mut wrong = approved_value["body"].clone(); + wrong["status"] = serde_json::json!("pending"); + rejects::(wrong); + + let pending = + serde_json::json!({"state": "admission-pending", "pairing_id": "pairing/example"}); + roundtrip::(&pending); + let mut wrong = pending.clone(); + wrong["state"] = serde_json::json!("granted"); + rejects::(wrong); + roundtrip::>(&serde_json::json!({ + "v": 1, "request_id": "complete-1", "ok": true, "body": pending + })); + let qr = serde_json::json!({ + "_tag": "fractal.pairing", "version": 1, "gateway": "https://example.test", + "id": "pairing/example", "code": "ABCDEFGH", "expires_at_unix_ms": 1000 + }); + let permit = + serde_json::json!({"node_id": "node", "attempt_id": "attempt", "secret": "secret"}); + roundtrip::(&qr); + let mut qr_v2 = qr.clone(); + qr_v2["version"] = serde_json::json!(2); + qr_v2["fabric"] = permit.clone(); + roundtrip::(&qr_v2); + for (field, value) in [ + ("_tag", serde_json::json!("other.pairing")), + ("version", serde_json::json!(0)), + ("version", serde_json::json!(3)), + ("fabric", permit), + ("fabric", serde_json::Value::Null), + ] { + let mut wrong = qr.clone(); + wrong[field] = value; + rejects::(wrong); + } + let mut wrong = qr.clone(); + wrong["version"] = serde_json::json!(2); + rejects::(wrong); + let mut wrong = qr_v2; + wrong["fabric"] = serde_json::Value::Null; + rejects::(wrong); + + let capabilities = serde_json::json!([ + {"id": "device-key-proofs", "version": 1, "state": "granted"}, + {"id": "fabric-gateway-grant", "version": 1, "state": "granted"}, + {"id": "pairing-key-binding", "version": 2, "state": "granted"} + ]); + let advertisement = + serde_json::json!({"api_version": "fractal.client.v0", "capabilities": capabilities}); + roundtrip::(&advertisement); + roundtrip::>(&serde_json::json!({ + "v": 1, "request_id": "cap-1", "ok": true, "body": advertisement + })); + for capability in capabilities.as_array().unwrap() { + roundtrip::(capability); + for (field, value) in [ + ("id", serde_json::json!("unknown")), + ("state", serde_json::json!("pending")), + ("version", serde_json::json!(0)), + ("version", serde_json::json!(3)), + ( + "version", + serde_json::json!(if capability["version"] == 1 { 2 } else { 1 }), + ), + ] { + let mut wrong = capability.clone(); + wrong[field] = value; + rejects::(wrong); + } + } + for invalid in [ + serde_json::json!([]), + serde_json::json!([capabilities[0], capabilities[0]]), + serde_json::json!([ + capabilities[0], + capabilities[1], + capabilities[2], + capabilities[0] + ]), + ] { + rejects::(serde_json::json!({ + "api_version": "fractal.client.v0", "capabilities": invalid + })); + } } #[test]