@feltdb/core 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/build-identity.json +4 -4
  2. package/dist/cli/commands.js +8 -2
  3. package/dist/cli/workspace-integration.js +8 -0
  4. package/dist/create/build-identity.json +4 -4
  5. package/dist/create/cli.js +46 -37
  6. package/dist/create/create.js +17 -2
  7. package/dist/create/package-versions.js +1 -1
  8. package/dist/create/server-source/Cargo.lock +4 -4
  9. package/dist/create/server-source/Cargo.toml +1 -1
  10. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +204 -1
  11. package/dist/create/server-source/crates/feltdb/src/workload.rs +685 -53
  12. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
  13. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
  14. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  15. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  16. package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +28 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  19. package/dist/create/server-source/crates/feltdb-server/src/main.rs +844 -135
  20. package/dist/create/setup-summary.js +40 -0
  21. package/dist/db.d.ts +27 -1
  22. package/dist/db.d.ts.map +1 -1
  23. package/dist/db.js +17 -4
  24. package/dist/development-runtime-bridge.d.ts +20 -0
  25. package/dist/development-runtime-bridge.d.ts.map +1 -1
  26. package/dist/development-runtime-bridge.js +61 -12
  27. package/dist/http-db.d.ts +13 -0
  28. package/dist/http-db.d.ts.map +1 -1
  29. package/dist/http-db.js +33 -4
  30. package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
  31. package/dist/studio/app.d.ts +8 -1
  32. package/dist/studio/app.d.ts.map +1 -1
  33. package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
  34. package/dist/studio/components/KeyManagementPanel.js +1 -1
  35. package/dist/studio/components/index.js +2 -2
  36. package/dist/studio/{components-B4eKIUN5.js → components-BSGmHfif.js} +6 -6
  37. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  38. package/dist/studio/control-plane/Instance.d.ts +4 -1
  39. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  40. package/dist/studio/control-plane/Operations.d.ts +4 -1
  41. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  42. package/dist/studio/control-plane/index.js +2 -2
  43. package/dist/studio/control-plane/primitives.d.ts +16 -0
  44. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  45. package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
  46. package/dist/studio/index.d.ts +1 -0
  47. package/dist/studio/index.d.ts.map +1 -1
  48. package/dist/studio/index.js +384 -247
  49. package/dist/studio/navigation.d.ts +57 -0
  50. package/dist/studio/navigation.d.ts.map +1 -0
  51. package/dist/studio/studio.css +1 -1
  52. package/dist/studio/utils/control-plane-api.d.ts +2 -0
  53. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  54. package/dist/studio/utils/control-plane-api.js +5 -1
  55. package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-3hDngl1h.js} +1 -1
  56. package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
  57. package/dist/studio-app/assets/index-Dql1hS5m.js +30 -0
  58. package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
  59. package/dist/studio-app/index.html +2 -2
  60. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  61. package/dist/wasm/package.json +1 -1
  62. package/dist/workspace/local-development-authority.d.ts +19 -0
  63. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  64. package/dist/workspace/local-development-authority.js +335 -15
  65. package/package.json +1 -1
  66. package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
  67. package/dist/studio-app/assets/index-DoVvbIay.js +0 -30
@@ -67,6 +67,7 @@ use feltdb::{
67
67
  PeerAdvertisement, PeerId, RecordPrecondition, StateRevision, StateTriggerStore, StoredRow,
68
68
  };
69
69
  use feltdb_server::{
70
+ agent_execution::{self, CompletionCriteria, RunFailure, RunKind, RunPrincipal},
70
71
  app_state::{AppState, BoundedQueryCursor},
71
72
  application_contract::{ApplicationContractStore, ContractPatch},
72
73
  artifacts::{ArtifactKind, ArtifactLifecycle, ArtifactMetadata, ArtifactStore, ProducerRef},
@@ -1476,6 +1477,9 @@ struct WorkloadContextRequest {
1476
1477
  #[derive(Deserialize)]
1477
1478
  struct WorkloadControlRequest {
1478
1479
  grant: feltdb::authorization::SignedGrant,
1480
+ /// Why an operator retried the workload; recorded in its history.
1481
+ #[serde(default)]
1482
+ reason: Option<String>,
1479
1483
  }
1480
1484
  #[derive(Deserialize)]
1481
1485
  struct WorkerHeartbeatRequest {
@@ -1498,7 +1502,10 @@ fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
1498
1502
  "WORKLOAD_STALE_FENCE"
1499
1503
  | "WORKLOAD_TRANSITION_DENIED"
1500
1504
  | "WORKLOAD_NOT_READY"
1501
- | "WORKLOAD_NOT_YET_ELIGIBLE" => StatusCode::CONFLICT,
1505
+ | "WORKLOAD_NOT_YET_ELIGIBLE"
1506
+ | "WORKLOAD_ATTEMPTS_EXHAUSTED"
1507
+ | "WORKLOAD_RETRY_EXHAUSTED"
1508
+ | "WORKLOAD_CONFLICT" => StatusCode::CONFLICT,
1502
1509
  "WORKLOAD_UNAUTHORIZED" | "WORKLOAD_CAPABILITY_DENIED" => StatusCode::FORBIDDEN,
1503
1510
  "WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
1504
1511
  _ => StatusCode::UNPROCESSABLE_ENTITY,
@@ -1589,7 +1596,7 @@ async fn create_workload(
1589
1596
  State(state): State<AppState>,
1590
1597
  Extension(principal): Extension<Principal>,
1591
1598
  Json(mut input): Json<CreateWorkload>,
1592
- ) -> Result<(StatusCode, Json<Value>), ApiError> {
1599
+ ) -> Result<(StatusCode, HeaderMap, Json<Value>), ApiError> {
1593
1600
  let subject = grant_subject(&principal);
1594
1601
  input.requested_by = subject.clone();
1595
1602
  input.session_id = principal.session_id.clone();
@@ -1603,7 +1610,7 @@ async fn create_workload(
1603
1610
  "workload:create",
1604
1611
  &input.authorization_snapshot,
1605
1612
  )?;
1606
- let workload = state
1613
+ let (workload, created) = state
1607
1614
  .workloads
1608
1615
  .lock()
1609
1616
  .map_err(|_| {
@@ -1612,17 +1619,34 @@ async fn create_workload(
1612
1619
  "workload store unavailable".into(),
1613
1620
  )
1614
1621
  })?
1615
- .create(input)
1616
- .map_err(workload_error)?;
1622
+ .admit(input)
1623
+ .map_err(|error| {
1624
+ if error.code == "IDEMPOTENCY_KEY_REUSED" {
1625
+ // AppPort's contract for a reused key (§13.1): CONFLICT, with
1626
+ // the reason, not retryable.
1627
+ ApiError::structured(
1628
+ StatusCode::CONFLICT,
1629
+ json!({"code": "CONFLICT", "message": error.message,
1630
+ "details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false}}),
1631
+ )
1632
+ } else {
1633
+ workload_error(error)
1634
+ }
1635
+ })?;
1617
1636
  record_authorization_audit(
1618
1637
  &state,
1619
1638
  &principal.key_id,
1620
- "workload.created",
1639
+ if created { "workload.created" } else { "workload.replayed" },
1621
1640
  &workload.workload_id,
1622
1641
  "ready",
1623
1642
  201,
1624
1643
  );
1625
- Ok((StatusCode::CREATED, Json(json!(workload))))
1644
+ // A replay answers with the stored response, marked as a replay.
1645
+ let mut headers = HeaderMap::new();
1646
+ if !created {
1647
+ headers.insert("idempotent-replay", axum::http::HeaderValue::from_static("true"));
1648
+ }
1649
+ Ok((StatusCode::CREATED, headers, Json(json!(workload))))
1626
1650
  }
1627
1651
  async fn list_workloads(
1628
1652
  State(state): State<AppState>,
@@ -1664,14 +1688,27 @@ async fn get_workload(
1664
1688
  .get(&id)
1665
1689
  .map_err(workload_error)?
1666
1690
  .clone();
1667
- application_scope(
1668
- &state,
1669
- &principal.key_id,
1670
- &workload.application_id,
1671
- "workload:read",
1672
- )?;
1691
+ workload_read_scope(&state, &principal, &workload.application_id)?;
1673
1692
  Ok(Json(json!(workload)))
1674
1693
  }
1694
+ /// Authorize reading one workload. A caller who may not read the
1695
+ /// application's work gets the same answer as for a workload that does not
1696
+ /// exist, so workload ids cannot be probed.
1697
+ fn workload_read_scope(
1698
+ state: &AppState,
1699
+ principal: &Principal,
1700
+ application_id: &str,
1701
+ ) -> Result<(), ApiError> {
1702
+ application_scope(state, &principal.key_id, application_id, "workload:read")
1703
+ .map(|_| ())
1704
+ .map_err(|_| {
1705
+ // Exactly the body `workload_error` gives a missing workload.
1706
+ ApiError::structured(
1707
+ StatusCode::NOT_FOUND,
1708
+ json!({"code": "WORKLOAD_NOT_FOUND", "message": "workload not found"}),
1709
+ )
1710
+ })
1711
+ }
1675
1712
  async fn claim_workload(
1676
1713
  State(state): State<AppState>,
1677
1714
  Extension(principal): Extension<Principal>,
@@ -1971,7 +2008,7 @@ async fn retry_workload(
1971
2008
  .workloads
1972
2009
  .lock()
1973
2010
  .unwrap()
1974
- .retry(&id, &principal.key_id, unix_seconds_i64())
2011
+ .retry_with_reason(&id, &principal.key_id, unix_seconds_i64(), input.reason.as_deref())
1975
2012
  .map_err(workload_error)?;
1976
2013
  Ok(StatusCode::NO_CONTENT)
1977
2014
  }
@@ -1987,12 +2024,7 @@ async fn workload_history(
1987
2024
  .get(&id)
1988
2025
  .map_err(workload_error)?
1989
2026
  .clone();
1990
- application_scope(
1991
- &state,
1992
- &principal.key_id,
1993
- &workload.application_id,
1994
- "workload:read",
1995
- )?;
2027
+ workload_read_scope(&state, &principal, &workload.application_id)?;
1996
2028
  Ok(Json(
1997
2029
  json!({"events":workload.execution_history,"attempts":workload.attempts,"checkpoints":workload.checkpoints}),
1998
2030
  ))
@@ -2009,12 +2041,7 @@ async fn workload_result(
2009
2041
  .get(&id)
2010
2042
  .map_err(workload_error)?
2011
2043
  .clone();
2012
- application_scope(
2013
- &state,
2014
- &principal.key_id,
2015
- &workload.application_id,
2016
- "workload:read",
2017
- )?;
2044
+ workload_read_scope(&state, &principal, &workload.application_id)?;
2018
2045
  Ok(Json(json!(workload.result)))
2019
2046
  }
2020
2047
 
@@ -2290,12 +2317,7 @@ async fn eligible_workers(
2290
2317
  .get(&id)
2291
2318
  .map_err(workload_error)?
2292
2319
  .clone();
2293
- application_scope(
2294
- &state,
2295
- &principal.key_id,
2296
- &workload.application_id,
2297
- "workload:read",
2298
- )?;
2320
+ workload_read_scope(&state, &principal, &workload.application_id)?;
2299
2321
  let workers = state
2300
2322
  .mesh
2301
2323
  .lock()
@@ -8418,6 +8440,10 @@ struct StartAgentRun {
8418
8440
  goal: String,
8419
8441
  #[serde(default)]
8420
8442
  input: Value,
8443
+ /// What the runtime must observe for the run to complete. Without it the
8444
+ /// run can execute but never completes: the goal text is not evaluated.
8445
+ #[serde(default)]
8446
+ completion: Option<CompletionCriteria>,
8421
8447
  }
8422
8448
 
8423
8449
  #[derive(Deserialize)]
@@ -8846,6 +8872,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8846
8872
  };
8847
8873
  start_peer_sessions(&state, &config)?;
8848
8874
  start_membership_recovery(&state);
8875
+ start_workload_recovery(&state);
8849
8876
  start_autonomous_worker(&state);
8850
8877
 
8851
8878
  let protected = Router::new()
@@ -11198,6 +11225,37 @@ fn start_autonomous_worker(state: &AppState) {
11198
11225
  });
11199
11226
  }
11200
11227
 
11228
+ /// Recover Durable Work whose lease expired, on every node, whether or not it
11229
+ /// runs the autonomous worker: external workers depend on it too.
11230
+ ///
11231
+ /// It is the mesh's claim recovery (`recover_expired_work`, the part of
11232
+ /// `detect_failures` that recovers work): expired work goes to
11233
+ /// `WorkloadStore::recover_expired`, bounded by each workload's attempt budget,
11234
+ /// and its claim is released from the worker that held it. Worker liveness —
11235
+ /// marking silent workers offline — is deliberately not run here; it would lock
11236
+ /// out registered workers that do not heartbeat on the mesh's schedule. It takes
11237
+ /// the mesh lock before the workload lock, the order the claim route uses.
11238
+ fn start_workload_recovery(state: &AppState) {
11239
+ let state = state.clone();
11240
+ tokio::spawn(async move {
11241
+ loop {
11242
+ if state.cluster.get().is_member {
11243
+ let outcome = match (state.mesh.lock(), state.workloads.lock()) {
11244
+ (Ok(mut mesh), Ok(mut workloads)) => mesh
11245
+ .recover_expired_work(unix_seconds_i64(), &mut workloads)
11246
+ .map(|_| ())
11247
+ .map_err(|error| error.message),
11248
+ _ => Err("worker mesh or workload store unavailable".to_string()),
11249
+ };
11250
+ if let Err(error) = outcome {
11251
+ tracing::warn!(%error, "durable work recovery pass incomplete");
11252
+ }
11253
+ }
11254
+ tokio::time::sleep(Duration::from_secs(1)).await;
11255
+ }
11256
+ });
11257
+ }
11258
+
11201
11259
  fn start_membership_recovery(state: &AppState) {
11202
11260
  let state = state.clone();
11203
11261
  tokio::spawn(async move {
@@ -11218,13 +11276,18 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
11218
11276
  return Ok(());
11219
11277
  }
11220
11278
  let worker = state.db.instance_id()?;
11279
+ // One run's failure never stops the pass: each is recorded on its own run,
11280
+ // and an infrastructure fault is logged and retried on the next pass.
11221
11281
  for row in state.db.list_collection("_flow_workflow_runs")? {
11282
+ if row.value["status"] != "running" {
11283
+ continue;
11284
+ }
11222
11285
  let Some(steps) = row.value["steps"].as_array() else {
11223
11286
  continue;
11224
11287
  };
11225
11288
  let now = lease_now_ms(state)?;
11226
11289
  let Some(step) = steps.iter().find(|step| {
11227
- step["status"] == "ready"
11290
+ (step["status"] == "ready" && step["not_before_ms"].as_u64().unwrap_or(0) <= now)
11228
11291
  || (step["status"] == "running"
11229
11292
  && step["lease_expires_ms"].as_u64().unwrap_or(0) <= now)
11230
11293
  }) else {
@@ -11233,42 +11296,33 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
11233
11296
  let Some(step_id) = step["id"].as_str() else {
11234
11297
  continue;
11235
11298
  };
11299
+ // A step that names no capability here is an external step, claimed
11300
+ // by an external worker through the claim route.
11236
11301
  if !is_executable_capability(state, step_id)? {
11237
11302
  continue;
11238
11303
  }
11239
11304
  let run_id = row.value["id"].as_str().unwrap_or_default().to_string();
11240
- let input = row.value["input"].clone();
11241
- let claimed = transition_workflow_step(
11242
- state,
11243
- &run_id,
11244
- step_id,
11245
- Some((worker.clone(), 30_000)),
11246
- None,
11247
- )
11248
- .await?;
11249
- let claim_id = claimed.0.value["steps"]
11250
- .as_array()
11251
- .and_then(|steps| steps.iter().find(|step| step["id"] == step_id))
11252
- .and_then(|step| step["claim_id"].as_str())
11253
- .ok_or_else(|| {
11254
- ApiError(
11255
- StatusCode::CONFLICT,
11256
- "worker claim was not materialized".to_string(),
11257
- )
11258
- })?
11259
- .to_string();
11260
- let result = execute_named_capability(state, step_id, &input)?;
11261
- let _ = transition_workflow_step(state, &run_id, step_id, None, Some((claim_id, result)))
11262
- .await?;
11305
+ let attempts = step["attempt"].as_u64().unwrap_or(0);
11306
+ if let Err(error) =
11307
+ execute_workflow_step(state, &row.value, &run_id, step_id, attempts, &worker).await
11308
+ {
11309
+ tracing::warn!(run = %run_id, step = %step_id, status = error.0.as_u16(), "workflow step attempt did not finish");
11310
+ }
11263
11311
  }
11264
11312
 
11265
11313
  for row in state.db.list_collection("_flow_agent_runs")? {
11266
- let reclaimable = row.value["status"] == "running"
11267
- && row.value["lease_expires_ms"].as_u64().unwrap_or(0) <= lease_now_ms(state)?;
11268
- if row.value["status"] != "ready" && !reclaimable {
11314
+ let now = lease_now_ms(state)?;
11315
+ let status = row.value["status"].as_str().unwrap_or_default();
11316
+ let lease_expired = row.value["lease_expires_ms"].as_u64().unwrap_or(0) <= now;
11317
+ if !agent_execution::agent_run_claimable(status, lease_expired)
11318
+ || row.value["not_before_ms"].as_u64().unwrap_or(0) > now
11319
+ {
11269
11320
  continue;
11270
11321
  }
11271
- execute_agent_run(state, &row.value, &worker).await?;
11322
+ if let Err(error) = execute_agent_run(state, &row.value, &worker).await {
11323
+ let run = row.value["id"].as_str().unwrap_or_default();
11324
+ tracing::warn!(%run, status = error.0.as_u16(), "agent run attempt did not finish");
11325
+ }
11272
11326
  }
11273
11327
 
11274
11328
  evaluate_state_triggers(state)?;
@@ -11372,7 +11426,7 @@ fn worker_capabilities(state: &AppState) -> Result<BTreeSet<String>, ApiError> {
11372
11426
  fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiError> {
11373
11427
  let capabilities = worker_capabilities(state)?;
11374
11428
  let now = unix_seconds_i64();
11375
- let candidates: Vec<(String, String, Value)> = {
11429
+ let candidates: Vec<(String, String, Value, feltdb::workload::Workload)> = {
11376
11430
  let store = workload_store(state)?;
11377
11431
  store
11378
11432
  .workloads
@@ -11391,12 +11445,13 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
11391
11445
  workload.workload_id.clone(),
11392
11446
  workload.definition_id.clone(),
11393
11447
  workload.input.clone(),
11448
+ workload.clone(),
11394
11449
  )
11395
11450
  })
11396
11451
  .collect()
11397
11452
  };
11398
11453
 
11399
- for (id, definition, input) in candidates {
11454
+ for (id, definition, input, workload) in candidates {
11400
11455
  // Unlike the filter above, this one is enforcement: nothing downstream
11401
11456
  // knows whether `definition_id` names something runnable. Claiming a
11402
11457
  // workload only to dead-letter it would consume work another node could
@@ -11427,7 +11482,13 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
11427
11482
  )
11428
11483
  .map_err(workload_error)?;
11429
11484
 
11430
- match execute_named_capability(state, &definition, &input) {
11485
+ let access = CapabilityAccess::Workload {
11486
+ requested_by: &workload.requested_by,
11487
+ tenant_id: &workload.tenant_id,
11488
+ application_id: &workload.application_id,
11489
+ grant: &workload.authorization_snapshot,
11490
+ };
11491
+ match execute_named_capability(state, &definition, &input, &access) {
11431
11492
  Ok(output) => {
11432
11493
  workload_store(state)?
11433
11494
  .complete(
@@ -11464,6 +11525,157 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
11464
11525
  Ok(())
11465
11526
  }
11466
11527
 
11528
+ /// Debug builds only: `FELTDB_TEST_TRANSIENT_CAPABILITY=<name>[:<count>]` makes
11529
+ /// executing `<name>` fail with a server error — every time, or for its first
11530
+ /// `<count>` executions — so retry bounds can be exercised at the HTTP
11531
+ /// boundary. Compiled out of release builds, like `FELTDB_TEST_CLOCK_OFFSET_MS`.
11532
+ #[cfg(debug_assertions)]
11533
+ fn injected_capability_fault(name: &str) -> Option<ApiError> {
11534
+ static INJECTED: AtomicU64 = AtomicU64::new(0);
11535
+ let setting = std::env::var("FELTDB_TEST_TRANSIENT_CAPABILITY").ok()?;
11536
+ let (target, limit) = match setting.split_once(':') {
11537
+ Some((target, count)) => (target.to_string(), count.parse::<u64>().ok()),
11538
+ None => (setting, None),
11539
+ };
11540
+ if target != name {
11541
+ return None;
11542
+ }
11543
+ let injected = INJECTED.fetch_add(1, Ordering::SeqCst);
11544
+ if limit.is_some_and(|limit| injected >= limit) {
11545
+ return None;
11546
+ }
11547
+ Some(ApiError(
11548
+ StatusCode::SERVICE_UNAVAILABLE,
11549
+ "injected transient capability failure".into(),
11550
+ ))
11551
+ }
11552
+
11553
+ /// The authority a capability reads under.
11554
+ ///
11555
+ /// Every collection a capability touches is authorized against it in
11556
+ /// [`authorize_capability_read`], the single point all capability reads pass
11557
+ /// through, so no route, program step, workflow, or agent reaches state with
11558
+ /// more authority than the principal it acts for.
11559
+ enum CapabilityAccess<'a> {
11560
+ /// The request's own principal, on the direct capability routes.
11561
+ Principal(&'a Principal),
11562
+ /// An application actor on the `/v1` Service API.
11563
+ Application {
11564
+ actor: &'a str,
11565
+ application_id: &'a str,
11566
+ },
11567
+ /// A run's verified delegated authority, on the autonomous worker.
11568
+ Delegated(&'a RunPrincipal),
11569
+ /// A durable workload's signed authorization snapshot. Reads are allowed
11570
+ /// only when that grant, re-verified at execution, authorizes `state:read`
11571
+ /// for the workload's requester — so a workload never reads with the
11572
+ /// node's authority, and a row injected without a server-signed grant
11573
+ /// reads nothing.
11574
+ Workload {
11575
+ requested_by: &'a GrantSubject,
11576
+ tenant_id: &'a str,
11577
+ application_id: &'a str,
11578
+ grant: &'a feltdb::authorization::SignedGrant,
11579
+ },
11580
+ }
11581
+
11582
+ /// Whether a principal, described the way the authenticator describes it, holds
11583
+ /// `capability`. The same decision the request middleware makes.
11584
+ fn principal_grants(
11585
+ state: &AppState,
11586
+ kind: &str,
11587
+ id: &str,
11588
+ scopes: &[String],
11589
+ capability: &str,
11590
+ ) -> bool {
11591
+ let subject = if kind == "human" {
11592
+ Subject::User(id.to_string())
11593
+ } else {
11594
+ Subject::Service(id.to_string())
11595
+ };
11596
+ authorize(&AuthorizationRequest {
11597
+ subject: &subject,
11598
+ tenant_id: &state.namespace,
11599
+ application_id: Some(&state.namespace),
11600
+ capability,
11601
+ direct_grants: scopes,
11602
+ memberships: &[],
11603
+ roles: &[],
11604
+ })
11605
+ .allowed
11606
+ }
11607
+
11608
+ fn request_principal_grants(state: &AppState, principal: &Principal, capability: &str) -> bool {
11609
+ principal_grants(
11610
+ state,
11611
+ &principal.subject_type,
11612
+ &principal.key_id,
11613
+ &principal.scopes,
11614
+ capability,
11615
+ )
11616
+ }
11617
+
11618
+ fn delegated_grants(state: &AppState, principal: &RunPrincipal, capability: &str) -> bool {
11619
+ let scopes: Vec<String> = principal.scopes.iter().cloned().collect();
11620
+ principal_grants(state, &principal.kind, &principal.id, &scopes, capability)
11621
+ }
11622
+
11623
+ fn capability_read_denied(message: String) -> ApiError {
11624
+ ApiError::structured(
11625
+ StatusCode::FORBIDDEN,
11626
+ json!({"code": agent_execution::AUTHORIZATION_DENIED, "message": message}),
11627
+ )
11628
+ }
11629
+
11630
+ /// Authorize one collection read made by a capability.
11631
+ ///
11632
+ /// Reading records through a capability is reading state, so it needs the
11633
+ /// `state:read` a direct read needs. System collections (`_`-prefixed) hold
11634
+ /// runs, definitions and other server records and are never readable this way.
11635
+ fn authorize_capability_read(
11636
+ state: &AppState,
11637
+ access: &CapabilityAccess<'_>,
11638
+ collection: &str,
11639
+ ) -> Result<(), ApiError> {
11640
+ if agent_execution::is_system_collection(collection) {
11641
+ return Err(capability_read_denied(format!(
11642
+ "capabilities cannot read system collection {collection}"
11643
+ )));
11644
+ }
11645
+ let allowed = match access {
11646
+ CapabilityAccess::Principal(principal) => {
11647
+ request_principal_grants(state, principal, "state:read")
11648
+ }
11649
+ CapabilityAccess::Application {
11650
+ actor,
11651
+ application_id,
11652
+ } => application_scope(state, actor, application_id, "state:read").is_ok(),
11653
+ CapabilityAccess::Delegated(principal) => delegated_grants(state, principal, "state:read"),
11654
+ CapabilityAccess::Workload {
11655
+ requested_by,
11656
+ tenant_id,
11657
+ application_id,
11658
+ grant,
11659
+ } => workload_decision(
11660
+ state,
11661
+ (*requested_by).clone(),
11662
+ tenant_id,
11663
+ application_id,
11664
+ format!("flow://{application_id}/state/{collection}"),
11665
+ "state:read",
11666
+ grant,
11667
+ )
11668
+ .is_ok(),
11669
+ };
11670
+ if allowed {
11671
+ Ok(())
11672
+ } else {
11673
+ Err(capability_read_denied(format!(
11674
+ "the caller is not authorized to read {collection}"
11675
+ )))
11676
+ }
11677
+ }
11678
+
11467
11679
  fn is_builtin_capability(name: &str) -> bool {
11468
11680
  matches!(name, "search" | "identity")
11469
11681
  }
@@ -11480,6 +11692,7 @@ fn execute_builtin_capability(
11480
11692
  state: &AppState,
11481
11693
  name: &str,
11482
11694
  input: &Value,
11695
+ access: &CapabilityAccess<'_>,
11483
11696
  ) -> Result<Value, ApiError> {
11484
11697
  match name {
11485
11698
  "identity" => Ok(input.clone()),
@@ -11491,6 +11704,7 @@ fn execute_builtin_capability(
11491
11704
  )
11492
11705
  })?;
11493
11706
  validate_segment(collection)?;
11707
+ authorize_capability_read(state, access, collection)?;
11494
11708
  let needle = input["query"].as_str().unwrap_or_default().to_lowercase();
11495
11709
  let limit = input["limit"].as_u64().unwrap_or(50).min(500) as usize;
11496
11710
  Ok(Value::Array(
@@ -11511,80 +11725,369 @@ fn execute_builtin_capability(
11511
11725
  }
11512
11726
  }
11513
11727
 
11728
+ /// The text of an [`ApiError`] without its wire envelope: the `message` of a
11729
+ /// structured error, or the plain message.
11730
+ fn api_error_message(error: &ApiError) -> String {
11731
+ serde_json::from_str::<Value>(&error.1)
11732
+ .ok()
11733
+ .and_then(|value| value["message"].as_str().map(str::to_string))
11734
+ .unwrap_or_else(|| error.1.clone())
11735
+ }
11736
+
11737
+ /// Everything an agent run needs before the worker may lease it.
11738
+ ///
11739
+ /// Checked on every attempt, so authority revoked after the run was created,
11740
+ /// a definition removed, or a capability unregistered ends the run here,
11741
+ /// before anything executes.
11742
+ fn prepare_agent_run(
11743
+ state: &AppState,
11744
+ run: &Value,
11745
+ id: &str,
11746
+ attempt: u64,
11747
+ ) -> Result<(String, RunPrincipal, Option<CompletionCriteria>), RunFailure> {
11748
+ if attempt > agent_execution::max_run_attempts() {
11749
+ return Err(RunFailure::permanent(
11750
+ agent_execution::RETRY_EXHAUSTED,
11751
+ format!(
11752
+ "run was claimed {} times without finishing",
11753
+ attempt - 1
11754
+ ),
11755
+ ));
11756
+ }
11757
+ let principal = resolve_run_principal(state, run, RunKind::Agent, id)?;
11758
+ if !delegated_grants(state, &principal, "agents:run") {
11759
+ return Err(RunFailure::permanent(
11760
+ agent_execution::AUTHORIZATION_DENIED,
11761
+ "the run's principal may not run agents",
11762
+ ));
11763
+ }
11764
+ let agent = run["agent"].as_str().unwrap_or_default();
11765
+ let definition = match validate_segment(agent)
11766
+ .and_then(|_| required_value(state, "_flow_agents", agent, "agent not found"))
11767
+ {
11768
+ Ok(definition) => definition,
11769
+ Err(_) => {
11770
+ return Err(RunFailure::permanent(
11771
+ agent_execution::AGENT_NOT_FOUND,
11772
+ "the run's agent is not defined",
11773
+ ))
11774
+ }
11775
+ };
11776
+ let capability = agent_capability(state, &definition)?;
11777
+ let criteria = match &run["completion"] {
11778
+ Value::Null => None,
11779
+ value => Some(
11780
+ serde_json::from_value::<CompletionCriteria>(value.clone())
11781
+ .ok()
11782
+ .filter(|criteria| criteria.validate().is_ok())
11783
+ .ok_or_else(|| {
11784
+ RunFailure::permanent(
11785
+ agent_execution::RUN_INVALID,
11786
+ "the run's completion criteria are invalid",
11787
+ )
11788
+ })?,
11789
+ ),
11790
+ };
11791
+ Ok((capability, principal, criteria))
11792
+ }
11793
+
11794
+ fn mark_agent_run_failed(run: &mut Value, failure: &RunFailure, attempt: u64) {
11795
+ run["status"] = json!(agent_execution::STATUS_FAILED);
11796
+ run["failure"] = failure.to_record(attempt);
11797
+ run["attempt"] = json!(attempt);
11798
+ }
11799
+
11800
+ /// Execute one attempt of an agent run.
11801
+ ///
11802
+ /// The run completes only when its declared criteria hold for the result this
11803
+ /// worker observed. Every other outcome is explicit: a permanent error fails
11804
+ /// the run, a transient one returns it to `ready` after a backoff until the
11805
+ /// attempt bound, and a run without criteria fails as unverifiable. An `Err`
11806
+ /// is an infrastructure fault that left the run untouched.
11514
11807
  async fn execute_agent_run(state: &AppState, run: &Value, worker: &str) -> Result<(), ApiError> {
11515
11808
  let id = run["id"]
11516
11809
  .as_str()
11517
11810
  .ok_or_else(|| ApiError(StatusCode::CONFLICT, "agent run has no id".to_string()))?;
11518
- let agent_name = run["agent"].as_str().ok_or_else(|| {
11519
- ApiError(
11520
- StatusCode::CONFLICT,
11521
- "agent run has no definition".to_string(),
11522
- )
11523
- })?;
11524
- let definition = required_value(state, "_flow_agents", agent_name, "agent not found")?;
11525
- let capability = definition["capabilities"].as_array().and_then(|values| {
11526
- values
11527
- .iter()
11528
- .filter_map(Value::as_str)
11529
- .find(|name| is_executable_capability(state, name).unwrap_or(false))
11530
- });
11531
- let Some(capability) = capability else {
11532
- return Ok(());
11533
- };
11534
- let claim_id = next_id(state, "claim");
11535
- let expires_ms = lease_now_ms(state)?.saturating_add(30_000);
11536
- let lease = LeaseRequest {
11537
- claim_id: claim_id.clone(),
11538
- owner: worker.to_string(),
11539
- expires_ms,
11540
- membership_epoch: state.cluster.get().epoch,
11541
- };
11542
- {
11811
+ let (capability, principal, criteria, claim_id, attempt, input) = {
11543
11812
  let _guard = state.lifecycle_lock.lock().await;
11544
- reserve_quorum_lease(state, &format!("agent-{id}"), &lease).await?;
11545
11813
  let mut current = required_value(state, "_flow_agent_runs", id, "agent run not found")?;
11546
- let reclaimable = current["status"] == "running"
11547
- && current["lease_expires_ms"].as_u64().unwrap_or(0) <= lease_now_ms(state)?;
11548
- if current["status"] != "ready" && !reclaimable {
11814
+ let now = lease_now_ms(state)?;
11815
+ let status = current["status"].as_str().unwrap_or_default();
11816
+ let lease_expired = current["lease_expires_ms"].as_u64().unwrap_or(0) <= now;
11817
+ if !agent_execution::agent_run_claimable(status, lease_expired)
11818
+ || current["not_before_ms"].as_u64().unwrap_or(0) > now
11819
+ {
11549
11820
  return Ok(());
11550
11821
  }
11551
- current["status"] = json!("running");
11822
+ let attempt = current["attempt"].as_u64().unwrap_or(0) + 1;
11823
+ let (capability, principal, criteria) =
11824
+ match prepare_agent_run(state, &current, id, attempt) {
11825
+ Ok(prepared) => prepared,
11826
+ Err(failure) => {
11827
+ mark_agent_run_failed(&mut current, &failure, attempt);
11828
+ put_canonical(state, "_flow_agent_runs", id, current)?;
11829
+ return Ok(());
11830
+ }
11831
+ };
11832
+ let claim_id = next_id(state, "claim");
11833
+ let expires_ms = now.saturating_add(30_000);
11834
+ let lease = LeaseRequest {
11835
+ claim_id: claim_id.clone(),
11836
+ owner: worker.to_string(),
11837
+ expires_ms,
11838
+ membership_epoch: state.cluster.get().epoch,
11839
+ };
11840
+ reserve_quorum_lease(state, &format!("agent-{id}"), &lease).await?;
11841
+ current["status"] = json!(agent_execution::STATUS_RUNNING);
11552
11842
  current["worker"] = json!(worker);
11553
11843
  current["claim_id"] = json!(claim_id);
11554
11844
  current["lease_expires_ms"] = json!(expires_ms);
11555
- let _ = put_canonical(state, "_flow_agent_runs", id, current)?;
11556
- }
11557
- let result = execute_named_capability(state, capability, &run["input"])?;
11845
+ current["attempt"] = json!(attempt);
11846
+ let input = current["input"].clone();
11847
+ put_canonical(state, "_flow_agent_runs", id, current)?;
11848
+ (capability, principal, criteria, claim_id, attempt, input)
11849
+ };
11850
+
11851
+ let outcome = execute_named_capability(
11852
+ state,
11853
+ &capability,
11854
+ &input,
11855
+ &CapabilityAccess::Delegated(&principal),
11856
+ );
11857
+
11558
11858
  let _guard = state.lifecycle_lock.lock().await;
11559
11859
  let mut current = required_value(state, "_flow_agent_runs", id, "agent run not found")?;
11560
- if current["claim_id"] != claim_id
11561
- || current["lease_expires_ms"].as_u64().unwrap_or(0) <= lease_now_ms(state)?
11860
+ let now = lease_now_ms(state)?;
11861
+ // Publish only while this claim still owns the run. A claim that lost its
11862
+ // lease publishes nothing; the next claim counts as another attempt.
11863
+ if current["claim_id"] != claim_id.as_str()
11864
+ || current["status"] != agent_execution::STATUS_RUNNING
11865
+ || current["lease_expires_ms"].as_u64().unwrap_or(0) <= now
11562
11866
  {
11563
- return Err(ApiError(
11564
- StatusCode::CONFLICT,
11565
- "agent ownership expired before result publication".to_string(),
11566
- ));
11867
+ return Ok(());
11567
11868
  }
11568
- current["observations"]
11869
+ match outcome {
11870
+ Ok(result) => {
11871
+ let satisfied = criteria
11872
+ .as_ref()
11873
+ .map(|criteria| criteria.satisfied_by(&result));
11874
+ let digest = format!(
11875
+ "sha256:{:x}",
11876
+ Sha256::digest(serde_json::to_vec(&result).unwrap_or_default())
11877
+ );
11878
+ let evidence = json!({
11879
+ "source": "runtime", "attempt": attempt, "claim_id": claim_id,
11880
+ "capability": capability, "result_items": result.as_array().map(Vec::len),
11881
+ "result_digest": digest, "criteria": criteria, "satisfied": satisfied,
11882
+ "observed_at_ms": now,
11883
+ });
11884
+ for (field, value) in [
11885
+ ("observations", result.clone()),
11886
+ (
11887
+ "decisions",
11888
+ json!({"capability": capability, "result": result, "source": "runtime"}),
11889
+ ),
11890
+ ("evidence", evidence),
11891
+ ] {
11892
+ if !current[field].is_array() {
11893
+ current[field] = json!([]);
11894
+ }
11895
+ current[field]
11896
+ .as_array_mut()
11897
+ .expect("normalized to an array")
11898
+ .push(value);
11899
+ }
11900
+ match satisfied {
11901
+ Some(true) => {
11902
+ current["status"] = json!(agent_execution::STATUS_COMPLETED);
11903
+ current["completed_at_ms"] = json!(now);
11904
+ }
11905
+ Some(false) => mark_agent_run_failed(
11906
+ &mut current,
11907
+ &RunFailure::permanent(
11908
+ agent_execution::COMPLETION_CRITERIA_UNMET,
11909
+ "the observed result does not satisfy the run's completion criteria",
11910
+ ),
11911
+ attempt,
11912
+ ),
11913
+ None => mark_agent_run_failed(
11914
+ &mut current,
11915
+ &RunFailure::permanent(
11916
+ agent_execution::COMPLETION_UNVERIFIABLE,
11917
+ "the run declares no completion criteria the runtime can verify; its goal text is not evaluated",
11918
+ ),
11919
+ attempt,
11920
+ ),
11921
+ }
11922
+ }
11923
+ Err(error) => {
11924
+ let failure = agent_execution::classify_capability_error(
11925
+ error.0.as_u16(),
11926
+ &api_error_message(&error),
11927
+ );
11928
+ if failure.retryable && attempt < agent_execution::max_run_attempts() {
11929
+ // The failed attempt's lease stays reserved until it lapses, so
11930
+ // the retry waits for that as well as for the backoff: a new
11931
+ // claim before then would be refused by the lease quorum.
11932
+ let lease_expires = current["lease_expires_ms"].as_u64().unwrap_or(0);
11933
+ current["status"] = json!(agent_execution::STATUS_READY);
11934
+ current["not_before_ms"] = json!(now
11935
+ .saturating_add(agent_execution::retry_delay_ms(attempt + 1))
11936
+ .max(lease_expires));
11937
+ current["last_failure"] = failure.to_record(attempt);
11938
+ } else if failure.retryable {
11939
+ mark_agent_run_failed(
11940
+ &mut current,
11941
+ &RunFailure::permanent(
11942
+ agent_execution::RETRY_EXHAUSTED,
11943
+ format!("{} after {attempt} attempts", failure.message),
11944
+ ),
11945
+ attempt,
11946
+ );
11947
+ } else {
11948
+ mark_agent_run_failed(&mut current, &failure, attempt);
11949
+ }
11950
+ }
11951
+ }
11952
+ put_canonical(state, "_flow_agent_runs", id, current)?;
11953
+ Ok(())
11954
+ }
11955
+
11956
+ /// Fail a workflow step and its run. `claim_id`, when given, must still own
11957
+ /// the step; otherwise another claim has taken over and nothing changes.
11958
+ async fn fail_workflow_step(
11959
+ state: &AppState,
11960
+ run_id: &str,
11961
+ step_id: &str,
11962
+ claim_id: Option<&str>,
11963
+ failure: &RunFailure,
11964
+ ) -> Result<(), ApiError> {
11965
+ let _guard = state.lifecycle_lock.lock().await;
11966
+ let mut run = required_value(state, "_flow_workflow_runs", run_id, "workflow run not found")?;
11967
+ if run["status"] != "running" {
11968
+ return Ok(());
11969
+ }
11970
+ let Some(step) = run["steps"]
11569
11971
  .as_array_mut()
11570
- .ok_or_else(|| {
11571
- ApiError(
11572
- StatusCode::CONFLICT,
11573
- "agent observations are invalid".to_string(),
11574
- )
11575
- })?
11576
- .push(result.clone());
11577
- current["decisions"]
11972
+ .and_then(|steps| steps.iter_mut().find(|step| step["id"] == step_id))
11973
+ else {
11974
+ return Ok(());
11975
+ };
11976
+ if claim_id.is_some_and(|claim| step["claim_id"] != claim) {
11977
+ return Ok(());
11978
+ }
11979
+ let attempt = step["attempt"].as_u64().unwrap_or(0);
11980
+ step["status"] = json!(agent_execution::STATUS_FAILED);
11981
+ step["failure"] = failure.to_record(attempt);
11982
+ run["status"] = json!(agent_execution::STATUS_FAILED);
11983
+ run["failure"] = json!({"step": step_id, "code": failure.code, "message": failure.message});
11984
+ put_canonical(state, "_flow_workflow_runs", run_id, run)?;
11985
+ Ok(())
11986
+ }
11987
+
11988
+ /// Return a step that failed transiently to `ready` after a backoff.
11989
+ async fn release_workflow_step(
11990
+ state: &AppState,
11991
+ run_id: &str,
11992
+ step_id: &str,
11993
+ claim_id: &str,
11994
+ failure: &RunFailure,
11995
+ ) -> Result<(), ApiError> {
11996
+ let _guard = state.lifecycle_lock.lock().await;
11997
+ let mut run = required_value(state, "_flow_workflow_runs", run_id, "workflow run not found")?;
11998
+ let now = lease_now_ms(state)?;
11999
+ let Some(step) = run["steps"]
11578
12000
  .as_array_mut()
12001
+ .and_then(|steps| steps.iter_mut().find(|step| step["id"] == step_id))
12002
+ else {
12003
+ return Ok(());
12004
+ };
12005
+ if step["claim_id"] != claim_id || step["status"] != "running" {
12006
+ return Ok(());
12007
+ }
12008
+ let attempt = step["attempt"].as_u64().unwrap_or(0);
12009
+ let lease_expires = step["lease_expires_ms"].as_u64().unwrap_or(0);
12010
+ step["status"] = json!(agent_execution::STATUS_READY);
12011
+ // Not before the failed attempt's lease lapses: until then the lease
12012
+ // quorum refuses any other claim.
12013
+ step["not_before_ms"] = json!(now
12014
+ .saturating_add(agent_execution::retry_delay_ms(attempt + 1))
12015
+ .max(lease_expires));
12016
+ step["last_failure"] = failure.to_record(attempt);
12017
+ put_canonical(state, "_flow_workflow_runs", run_id, run)?;
12018
+ Ok(())
12019
+ }
12020
+
12021
+ /// Execute one autonomous workflow step under the run's delegated authority.
12022
+ async fn execute_workflow_step(
12023
+ state: &AppState,
12024
+ run: &Value,
12025
+ run_id: &str,
12026
+ step_id: &str,
12027
+ attempts: u64,
12028
+ worker: &str,
12029
+ ) -> Result<(), ApiError> {
12030
+ if attempts >= agent_execution::max_run_attempts() {
12031
+ let failure = RunFailure::permanent(
12032
+ agent_execution::RETRY_EXHAUSTED,
12033
+ format!("step was claimed {attempts} times without finishing"),
12034
+ );
12035
+ return fail_workflow_step(state, run_id, step_id, None, &failure).await;
12036
+ }
12037
+ let principal = match resolve_run_principal(state, run, RunKind::Workflow, run_id) {
12038
+ Ok(principal) if delegated_grants(state, &principal, "workflows:run") => principal,
12039
+ Ok(_) => {
12040
+ let failure = RunFailure::permanent(
12041
+ agent_execution::AUTHORIZATION_DENIED,
12042
+ "the run's principal may not run workflows",
12043
+ );
12044
+ return fail_workflow_step(state, run_id, step_id, None, &failure).await;
12045
+ }
12046
+ Err(failure) => return fail_workflow_step(state, run_id, step_id, None, &failure).await,
12047
+ };
12048
+ let claimed = transition_workflow_step(
12049
+ state,
12050
+ run_id,
12051
+ step_id,
12052
+ Some((worker.to_string(), 30_000)),
12053
+ None,
12054
+ )
12055
+ .await?;
12056
+ let claim_id = claimed.0.value["steps"]
12057
+ .as_array()
12058
+ .and_then(|steps| steps.iter().find(|step| step["id"] == step_id))
12059
+ .and_then(|step| step["claim_id"].as_str())
11579
12060
  .ok_or_else(|| {
11580
12061
  ApiError(
11581
12062
  StatusCode::CONFLICT,
11582
- "agent decisions are invalid".to_string(),
12063
+ "worker claim was not materialized".to_string(),
11583
12064
  )
11584
12065
  })?
11585
- .push(json!({"capability": capability, "result": result}));
11586
- current["status"] = json!("completed");
11587
- let _ = put_canonical(state, "_flow_agent_runs", id, current)?;
12066
+ .to_string();
12067
+ let input = run["input"].clone();
12068
+ match execute_named_capability(state, step_id, &input, &CapabilityAccess::Delegated(&principal))
12069
+ {
12070
+ Ok(result) => {
12071
+ transition_workflow_step(state, run_id, step_id, None, Some((claim_id, result))).await?;
12072
+ }
12073
+ Err(error) => {
12074
+ let failure = agent_execution::classify_capability_error(
12075
+ error.0.as_u16(),
12076
+ &api_error_message(&error),
12077
+ );
12078
+ if failure.retryable && attempts + 1 < agent_execution::max_run_attempts() {
12079
+ release_workflow_step(state, run_id, step_id, &claim_id, &failure).await?;
12080
+ } else if failure.retryable {
12081
+ let exhausted = RunFailure::permanent(
12082
+ agent_execution::RETRY_EXHAUSTED,
12083
+ format!("{} after {} attempts", failure.message, attempts + 1),
12084
+ );
12085
+ fail_workflow_step(state, run_id, step_id, Some(&claim_id), &exhausted).await?;
12086
+ } else {
12087
+ fail_workflow_step(state, run_id, step_id, Some(&claim_id), &failure).await?;
12088
+ }
12089
+ }
12090
+ }
11588
12091
  Ok(())
11589
12092
  }
11590
12093
 
@@ -12563,10 +13066,12 @@ async fn get_provenance(
12563
13066
 
12564
13067
  async fn search_collection(
12565
13068
  State(state): State<AppState>,
13069
+ Extension(principal): Extension<Principal>,
12566
13070
  Path(collection): Path<String>,
12567
13071
  Json(request): Json<SearchRequest>,
12568
13072
  ) -> Result<Json<Vec<RecordResponse>>, ApiError> {
12569
13073
  validate_segment(&collection)?;
13074
+ authorize_capability_read(&state, &CapabilityAccess::Principal(&principal), &collection)?;
12570
13075
  if request.query.len() > 512 || request.limit == 0 || request.limit > 500 {
12571
13076
  return Err(ApiError(
12572
13077
  StatusCode::BAD_REQUEST,
@@ -12616,7 +13121,12 @@ async fn execute_capability(
12616
13121
  ));
12617
13122
  }
12618
13123
  validate_segment(&name)?;
12619
- Ok(Json(execute_named_capability(&state, &name, &input)?))
13124
+ Ok(Json(execute_named_capability(
13125
+ &state,
13126
+ &name,
13127
+ &input,
13128
+ &CapabilityAccess::Principal(&principal),
13129
+ )?))
12620
13130
  }
12621
13131
 
12622
13132
  async fn execute_application_capability(
@@ -12663,7 +13173,15 @@ async fn execute_application_capability(
12663
13173
  "capability is not externally invocable".into(),
12664
13174
  ));
12665
13175
  }
12666
- Ok(Json(execute_named_capability(&state, &name, &payload)?))
13176
+ Ok(Json(execute_named_capability(
13177
+ &state,
13178
+ &name,
13179
+ &payload,
13180
+ &CapabilityAccess::Application {
13181
+ actor: &principal.key_id,
13182
+ application_id: &input.application_id,
13183
+ },
13184
+ )?))
12667
13185
  }
12668
13186
 
12669
13187
  fn validate_capability_program(program: &CapabilityProgram) -> Result<(), ApiError> {
@@ -12722,9 +13240,14 @@ fn execute_named_capability(
12722
13240
  state: &AppState,
12723
13241
  name: &str,
12724
13242
  input: &Value,
13243
+ access: &CapabilityAccess<'_>,
12725
13244
  ) -> Result<Value, ApiError> {
13245
+ #[cfg(debug_assertions)]
13246
+ if let Some(fault) = injected_capability_fault(name) {
13247
+ return Err(fault);
13248
+ }
12726
13249
  if is_builtin_capability(name) {
12727
- return execute_builtin_capability(state, name, input);
13250
+ return execute_builtin_capability(state, name, input, access);
12728
13251
  }
12729
13252
  let value = required_value(state, "_flow_capabilities", name, "capability not found")?;
12730
13253
  let program: CapabilityProgram = serde_json::from_value(value).map_err(|error| {
@@ -12758,6 +13281,7 @@ fn execute_named_capability(
12758
13281
  state,
12759
13282
  "search",
12760
13283
  &json!({"collection": collection, "query": query, "limit": limit.unwrap_or(50)}),
13284
+ access,
12761
13285
  )?
12762
13286
  }
12763
13287
  CapabilityInstruction::FilterEq {
@@ -12873,12 +13397,14 @@ async fn start_workflow_run(
12873
13397
  let step_states: Vec<Value> = steps.iter().enumerate().map(|(index, step)| json!({
12874
13398
  "id": step, "status": if index == 0 { "ready" } else { "pending" }, "worker": null, "result": null
12875
13399
  })).collect();
13400
+ let (authority, attribution) = run_authority(&state, RunKind::Workflow, &id, &principal)?;
12876
13401
  let response = insert_canonical(
12877
13402
  &state,
12878
13403
  "_flow_workflow_runs",
12879
13404
  &id,
12880
13405
  json!({
12881
- "id": id, "workflow": name, "status": "running", "input": request.input, "steps": step_states
13406
+ "id": id, "workflow": name, "status": "running", "input": request.input, "steps": step_states,
13407
+ "authority": authority, "principal": attribution,
12882
13408
  }),
12883
13409
  )?;
12884
13410
  Ok((StatusCode::CREATED, Json(response)))
@@ -12886,9 +13412,12 @@ async fn start_workflow_run(
12886
13412
 
12887
13413
  async fn get_workflow_run(
12888
13414
  State(state): State<AppState>,
13415
+ Extension(principal): Extension<Principal>,
12889
13416
  Path(id): Path<String>,
12890
13417
  ) -> Result<Json<RecordResponse>, ApiError> {
12891
- get_canonical(&state, "_flow_workflow_runs", &id, "workflow run not found")
13418
+ let mut response = get_canonical(&state, "_flow_workflow_runs", &id, "workflow run not found")?;
13419
+ redact_run_results(&state, &principal, &mut response.0.value);
13420
+ Ok(response)
12892
13421
  }
12893
13422
 
12894
13423
  async fn claim_workflow_step(
@@ -12940,6 +13469,12 @@ async fn transition_workflow_step(
12940
13469
  validate_segment(step)?;
12941
13470
  let _guard = state.lifecycle_lock.lock().await;
12942
13471
  let mut run = required_value(state, "_flow_workflow_runs", id, "workflow run not found")?;
13472
+ if run["status"] == agent_execution::STATUS_FAILED {
13473
+ return Err(ApiError::structured(
13474
+ StatusCode::CONFLICT,
13475
+ json!({"code": "WORKFLOW_RUN_TERMINAL", "message": "workflow run has failed"}),
13476
+ ));
13477
+ }
12943
13478
  let steps = run["steps"]
12944
13479
  .as_array_mut()
12945
13480
  .ok_or_else(|| ApiError(StatusCode::CONFLICT, "workflow run is invalid".to_string()))?;
@@ -12968,10 +13503,12 @@ async fn transition_workflow_step(
12968
13503
  membership_epoch: state.cluster.get().epoch,
12969
13504
  };
12970
13505
  reserve_quorum_lease(state, &resource, &lease).await?;
13506
+ let attempt = steps[index]["attempt"].as_u64().unwrap_or(0) + 1;
12971
13507
  steps[index]["status"] = json!("running");
12972
13508
  steps[index]["worker"] = json!(worker);
12973
13509
  steps[index]["claim_id"] = json!(claim_id);
12974
13510
  steps[index]["lease_expires_ms"] = json!(expires_ms);
13511
+ steps[index]["attempt"] = json!(attempt);
12975
13512
  } else {
12976
13513
  if status != "running" {
12977
13514
  return Err(ApiError(
@@ -13029,31 +13566,169 @@ async fn start_agent_run(
13029
13566
  ));
13030
13567
  }
13031
13568
  validate_segment(&name)?;
13032
- required_value(&state, "_flow_agents", &name, "agent not found")?;
13569
+ let definition = required_value(&state, "_flow_agents", &name, "agent not found")?;
13033
13570
  if request.goal.trim().is_empty() {
13034
13571
  return Err(ApiError(
13035
13572
  StatusCode::BAD_REQUEST,
13036
13573
  "agent goal is required".to_string(),
13037
13574
  ));
13038
13575
  }
13576
+ if let Some(criteria) = &request.completion {
13577
+ criteria.validate().map_err(|message| {
13578
+ ApiError::structured(
13579
+ StatusCode::BAD_REQUEST,
13580
+ json!({"code": "COMPLETION_CRITERIA_INVALID", "message": message}),
13581
+ )
13582
+ })?;
13583
+ }
13584
+ // Refuse a run that could only ever fail, instead of creating one.
13585
+ agent_capability(&state, &definition).map_err(|failure| {
13586
+ ApiError::structured(
13587
+ StatusCode::UNPROCESSABLE_ENTITY,
13588
+ json!({"code": failure.code, "message": failure.message}),
13589
+ )
13590
+ })?;
13039
13591
  let id = next_id(&state, "agent");
13592
+ let (authority, attribution) = run_authority(&state, RunKind::Agent, &id, &principal)?;
13040
13593
  let response = insert_canonical(
13041
13594
  &state,
13042
13595
  "_flow_agent_runs",
13043
13596
  &id,
13044
13597
  json!({
13045
13598
  "id": id, "agent": name, "goal": request.goal, "input": request.input,
13046
- "status": "ready", "observations": [], "decisions": []
13599
+ "status": agent_execution::STATUS_READY, "observations": [], "decisions": [],
13600
+ "completion": request.completion, "evidence": [], "attempt": 0,
13601
+ "authority": authority, "principal": attribution,
13047
13602
  }),
13048
13603
  )?;
13049
13604
  Ok((StatusCode::CREATED, Json(response)))
13050
13605
  }
13051
13606
 
13607
+ /// Sign the requesting principal's authority into a new run, and the
13608
+ /// attribution recorded beside it.
13609
+ fn run_authority(
13610
+ state: &AppState,
13611
+ kind: RunKind,
13612
+ run_id: &str,
13613
+ principal: &Principal,
13614
+ ) -> Result<(Value, Value), ApiError> {
13615
+ let run_principal = RunPrincipal {
13616
+ id: principal.key_id.clone(),
13617
+ kind: principal.subject_type.clone(),
13618
+ scopes: principal.scopes.iter().cloned().collect(),
13619
+ };
13620
+ let grant = agent_execution::issue_run_authority(
13621
+ &state.grant_signer,
13622
+ kind,
13623
+ run_id,
13624
+ &state.namespace,
13625
+ &run_principal,
13626
+ unix_seconds_i64(),
13627
+ )
13628
+ .map_err(|message| ApiError(StatusCode::INTERNAL_SERVER_ERROR, message))?;
13629
+ let authority = serde_json::to_value(grant)
13630
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
13631
+ Ok((
13632
+ authority,
13633
+ json!({"id": principal.key_id, "kind": principal.subject_type}),
13634
+ ))
13635
+ }
13636
+
13637
+ /// The capability an agent run executes: the first one its definition
13638
+ /// declares. It must be executable now; one that is not is a permanent error,
13639
+ /// never a run left waiting for a capability that does not exist.
13640
+ fn agent_capability(state: &AppState, definition: &Value) -> Result<String, RunFailure> {
13641
+ let Some(name) = definition["capabilities"]
13642
+ .as_array()
13643
+ .and_then(|values| values.first())
13644
+ .and_then(Value::as_str)
13645
+ else {
13646
+ return Err(RunFailure::permanent(
13647
+ agent_execution::CAPABILITY_UNKNOWN,
13648
+ "agent declares no capability",
13649
+ ));
13650
+ };
13651
+ match is_executable_capability(state, name) {
13652
+ Ok(true) => Ok(name.to_string()),
13653
+ _ => Err(RunFailure::permanent(
13654
+ agent_execution::CAPABILITY_UNKNOWN,
13655
+ format!("capability {name} is not defined on this node"),
13656
+ )),
13657
+ }
13658
+ }
13659
+
13660
+ /// The principal a run may act for at execution time.
13661
+ ///
13662
+ /// The signed authority binds scopes to the run. When authentication is on,
13663
+ /// the key is also re-read, and only scopes it still holds remain: a revoked
13664
+ /// key acts for nothing, and a narrowed key never acts with its old breadth.
13665
+ fn resolve_run_principal(
13666
+ state: &AppState,
13667
+ run: &Value,
13668
+ kind: RunKind,
13669
+ run_id: &str,
13670
+ ) -> Result<RunPrincipal, RunFailure> {
13671
+ let mut principal = agent_execution::verify_run_authority(
13672
+ &state.grant_signer,
13673
+ &run["authority"],
13674
+ kind,
13675
+ run_id,
13676
+ &state.namespace,
13677
+ unix_seconds_i64(),
13678
+ )?;
13679
+ if state.auth_enabled && principal.kind != "human" {
13680
+ let Some((current, current_kind)) = state.keys.active_scopes(&principal.id, &state.namespace)
13681
+ else {
13682
+ return Err(RunFailure::permanent(
13683
+ agent_execution::AUTHORIZATION_DENIED,
13684
+ "the run's principal is no longer active",
13685
+ ));
13686
+ };
13687
+ if current_kind != principal.kind {
13688
+ return Err(RunFailure::permanent(
13689
+ agent_execution::AUTHORIZATION_DENIED,
13690
+ "the run's principal changed type",
13691
+ ));
13692
+ }
13693
+ principal.scopes =
13694
+ agent_execution::effective_scopes(&principal.scopes, &current.into_iter().collect());
13695
+ }
13696
+ Ok(principal)
13697
+ }
13698
+
13052
13699
  async fn get_agent_run(
13053
13700
  State(state): State<AppState>,
13701
+ Extension(principal): Extension<Principal>,
13054
13702
  Path(id): Path<String>,
13055
13703
  ) -> Result<Json<RecordResponse>, ApiError> {
13056
- get_canonical(&state, "_flow_agent_runs", &id, "agent run not found")
13704
+ let mut response = get_canonical(&state, "_flow_agent_runs", &id, "agent run not found")?;
13705
+ redact_run_results(&state, &principal, &mut response.0.value);
13706
+ Ok(response)
13707
+ }
13708
+
13709
+ /// Remove the state a run read from a response to a reader who may not read
13710
+ /// state. A run's observations hold records read under its caller's
13711
+ /// authority; `agents:read` alone must not disclose them.
13712
+ fn redact_run_results(state: &AppState, principal: &Principal, run: &mut Value) {
13713
+ if request_principal_grants(state, principal, "state:read") {
13714
+ return;
13715
+ }
13716
+ let Some(object) = run.as_object_mut() else {
13717
+ return;
13718
+ };
13719
+ for field in ["observations", "decisions"] {
13720
+ if object.contains_key(field) {
13721
+ object.insert(field.into(), json!([]));
13722
+ }
13723
+ }
13724
+ if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) {
13725
+ for step in steps {
13726
+ if step.get("result").is_some_and(|result| !result.is_null()) {
13727
+ step["result"] = Value::Null;
13728
+ }
13729
+ }
13730
+ }
13731
+ object.insert("results_redacted".into(), json!(true));
13057
13732
  }
13058
13733
 
13059
13734
  async fn add_agent_observation(
@@ -13061,12 +13736,11 @@ async fn add_agent_observation(
13061
13736
  Path(id): Path<String>,
13062
13737
  Json(request): Json<Observation>,
13063
13738
  ) -> Result<Json<RecordResponse>, ApiError> {
13064
- mutate_agent_run(&state, &id, |run| {
13739
+ mutate_agent_run(&state, &id, agent_execution::STATUS_DELIBERATING, |run| {
13065
13740
  run["observations"]
13066
13741
  .as_array_mut()
13067
13742
  .expect("validated agent run")
13068
13743
  .push(request.observation);
13069
- run["status"] = json!("deliberating");
13070
13744
  })
13071
13745
  .await
13072
13746
  }
@@ -13076,32 +13750,44 @@ async fn add_agent_decision(
13076
13750
  Path(id): Path<String>,
13077
13751
  Json(request): Json<Decision>,
13078
13752
  ) -> Result<Json<RecordResponse>, ApiError> {
13079
- mutate_agent_run(&state, &id, |run| {
13753
+ // Completion is decided by the runtime from criteria it verifies, never by
13754
+ // a participant saying so — whatever evidence the request carries.
13755
+ if request.completed {
13756
+ return Err(ApiError::structured(
13757
+ StatusCode::CONFLICT,
13758
+ json!({
13759
+ "code": "AGENT_COMPLETION_NOT_CLIENT_CONTROLLED",
13760
+ "message": "a run completes only when the runtime verifies its declared completion criteria",
13761
+ }),
13762
+ ));
13763
+ }
13764
+ mutate_agent_run(&state, &id, agent_execution::STATUS_ACTING, |run| {
13080
13765
  run["decisions"]
13081
13766
  .as_array_mut()
13082
13767
  .expect("validated agent run")
13083
- .push(json!({"decision": request.decision, "action": request.action}));
13084
- run["status"] = json!(if request.completed {
13085
- "completed"
13086
- } else {
13087
- "acting"
13088
- });
13768
+ .push(json!({"decision": request.decision, "action": request.action, "source": "client"}));
13089
13769
  })
13090
13770
  .await
13091
13771
  }
13092
13772
 
13773
+ /// Append a participant's annotation to a run that is still open.
13774
+ ///
13775
+ /// Terminal outcomes are never rewritten. While the worker holds the run, the
13776
+ /// annotation is kept but the status is the worker's.
13093
13777
  async fn mutate_agent_run<F: FnOnce(&mut Value)>(
13094
13778
  state: &AppState,
13095
13779
  id: &str,
13780
+ annotation: &str,
13096
13781
  change: F,
13097
13782
  ) -> Result<Json<RecordResponse>, ApiError> {
13098
13783
  validate_segment(id)?;
13099
13784
  let _guard = state.lifecycle_lock.lock().await;
13100
13785
  let mut run = required_value(state, "_flow_agent_runs", id, "agent run not found")?;
13101
- if run["status"] == "completed" {
13102
- return Err(ApiError(
13786
+ let status = run["status"].as_str().unwrap_or_default().to_string();
13787
+ if agent_execution::is_terminal(&status) {
13788
+ return Err(ApiError::structured(
13103
13789
  StatusCode::CONFLICT,
13104
- "agent run is already completed".to_string(),
13790
+ json!({"code": "AGENT_RUN_TERMINAL", "message": format!("agent run is already {status}")}),
13105
13791
  ));
13106
13792
  }
13107
13793
  if !run["observations"].is_array() || !run["decisions"].is_array() {
@@ -13111,6 +13797,9 @@ async fn mutate_agent_run<F: FnOnce(&mut Value)>(
13111
13797
  ));
13112
13798
  }
13113
13799
  change(&mut run);
13800
+ if status != agent_execution::STATUS_RUNNING {
13801
+ run["status"] = json!(annotation);
13802
+ }
13114
13803
  put_canonical(state, "_flow_agent_runs", id, run)
13115
13804
  }
13116
13805
 
@@ -14214,6 +14903,7 @@ async fn commit_transaction(
14214
14903
  record_preconditions.push(precondition);
14215
14904
  }
14216
14905
  for operation in request.operations {
14906
+ ensure_not_runtime_owned(&operation.collection)?;
14217
14907
  let key = record_key(&operation.collection, &operation.id)?;
14218
14908
  if !seen.insert(key.clone()) {
14219
14909
  return Err(ApiError(
@@ -14334,6 +15024,21 @@ async fn commit_transaction(
14334
15024
  ))
14335
15025
  }
14336
15026
 
15027
+ /// Refuse a raw write to a collection only the runtime writes.
15028
+ ///
15029
+ /// A run's status, retry state, evidence and authority decide what the worker
15030
+ /// does and what the run reports. Letting `/collections` or `/transactions`
15031
+ /// write them would let any `state:write` caller declare a run completed.
15032
+ fn ensure_not_runtime_owned(collection: &str) -> Result<(), ApiError> {
15033
+ if agent_execution::is_runtime_owned_collection(collection) {
15034
+ return Err(ApiError::structured(
15035
+ StatusCode::FORBIDDEN,
15036
+ json!({"code": "RUNTIME_OWNED_COLLECTION", "message": "this collection is written only by the runtime"}),
15037
+ ));
15038
+ }
15039
+ Ok(())
15040
+ }
15041
+
14337
15042
  async fn create_record(
14338
15043
  State(state): State<AppState>,
14339
15044
  Path(collection): Path<String>,
@@ -14341,6 +15046,7 @@ async fn create_record(
14341
15046
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
14342
15047
  let _handler =
14343
15048
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
15049
+ ensure_not_runtime_owned(&collection)?;
14344
15050
  if collection == PROPOSAL_COLLECTION
14345
15051
  || collection == PROPOSAL_EVENT_COLLECTION
14346
15052
  || collection == CONVERGENCE_COLLECTION
@@ -14389,6 +15095,7 @@ async fn update_record(
14389
15095
  ) -> Result<Json<RecordResponse>, ApiError> {
14390
15096
  let _handler =
14391
15097
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
15098
+ ensure_not_runtime_owned(&collection)?;
14392
15099
  if collection == PROPOSAL_COLLECTION
14393
15100
  || collection == PROPOSAL_EVENT_COLLECTION
14394
15101
  || collection == CONVERGENCE_COLLECTION
@@ -14428,6 +15135,7 @@ async fn compare_and_set_record(
14428
15135
  ) -> Result<Response, ApiError> {
14429
15136
  let _handler =
14430
15137
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
15138
+ ensure_not_runtime_owned(&collection)?;
14431
15139
  let key = record_key(&collection, &id)?;
14432
15140
  let result = state.db.compare_and_set_json(
14433
15141
  &key,
@@ -14519,6 +15227,7 @@ async fn delete_record(
14519
15227
  ) -> Result<StatusCode, ApiError> {
14520
15228
  let _handler =
14521
15229
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
15230
+ ensure_not_runtime_owned(&collection)?;
14522
15231
  if collection == PROPOSAL_COLLECTION
14523
15232
  || collection == PROPOSAL_EVENT_COLLECTION
14524
15233
  || collection == CONVERGENCE_COLLECTION