@feltdb/core 0.12.2 → 0.15.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 (126) hide show
  1. package/dist/agent-runtime.d.ts +64 -2
  2. package/dist/agent-runtime.d.ts.map +1 -1
  3. package/dist/agent-runtime.js +121 -11
  4. package/dist/agent-state.d.ts +48 -0
  5. package/dist/agent-state.d.ts.map +1 -1
  6. package/dist/agent-state.js +174 -12
  7. package/dist/agent.d.ts +10 -1
  8. package/dist/agent.d.ts.map +1 -1
  9. package/dist/agent.js +10 -1
  10. package/dist/build-identity.json +4 -4
  11. package/dist/cli/commands.js +34 -7
  12. package/dist/cli/workspace-integration.js +8 -0
  13. package/dist/collection.d.ts +3 -1
  14. package/dist/collection.d.ts.map +1 -1
  15. package/dist/collection.js +30 -8
  16. package/dist/create/build-identity.json +4 -4
  17. package/dist/create/cli.js +47 -38
  18. package/dist/create/create.js +21 -8
  19. package/dist/create/package-versions.js +1 -1
  20. package/dist/create/server-source/Cargo.lock +6 -6
  21. package/dist/create/server-source/Cargo.toml +1 -1
  22. package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
  23. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
  24. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +341 -2
  25. package/dist/create/server-source/crates/feltdb/src/workload.rs +2094 -126
  26. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
  27. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
  28. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  29. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  30. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  31. package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
  32. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  33. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +113 -25
  34. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  35. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  37. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  38. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  39. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +4 -0
  40. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1740 -236
  41. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  42. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  43. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  44. package/dist/create/setup-summary.js +40 -0
  45. package/dist/db.d.ts +91 -10
  46. package/dist/db.d.ts.map +1 -1
  47. package/dist/db.js +170 -31
  48. package/dist/deployment.d.ts +19 -0
  49. package/dist/deployment.d.ts.map +1 -1
  50. package/dist/deployment.js +38 -2
  51. package/dist/development-runtime-bridge.d.ts +20 -0
  52. package/dist/development-runtime-bridge.d.ts.map +1 -1
  53. package/dist/development-runtime-bridge.js +61 -12
  54. package/dist/embedded-query.d.ts.map +1 -1
  55. package/dist/embedded-query.js +5 -4
  56. package/dist/embedded-transaction.d.ts +8 -4
  57. package/dist/embedded-transaction.d.ts.map +1 -1
  58. package/dist/embedded-transaction.js +55 -19
  59. package/dist/error-codes.d.ts +31 -2
  60. package/dist/error-codes.d.ts.map +1 -1
  61. package/dist/error-codes.js +41 -2
  62. package/dist/feltdb.d.ts +7 -0
  63. package/dist/feltdb.d.ts.map +1 -1
  64. package/dist/file-db.d.ts +27 -0
  65. package/dist/file-db.d.ts.map +1 -1
  66. package/dist/file-db.js +145 -7
  67. package/dist/http-client.d.ts +11 -3
  68. package/dist/http-client.d.ts.map +1 -1
  69. package/dist/http-client.js +27 -14
  70. package/dist/http-db.d.ts +40 -1
  71. package/dist/http-db.d.ts.map +1 -1
  72. package/dist/http-db.js +285 -58
  73. package/dist/index.js +1 -1
  74. package/dist/local-state-lock.d.ts +34 -0
  75. package/dist/local-state-lock.d.ts.map +1 -0
  76. package/dist/local-state-lock.js +225 -0
  77. package/dist/reactive-graph.d.ts +0 -3
  78. package/dist/reactive-graph.d.ts.map +1 -1
  79. package/dist/reactive-graph.js +11 -1
  80. package/dist/runtime-registry.d.ts +7 -2
  81. package/dist/runtime-registry.d.ts.map +1 -1
  82. package/dist/runtime-registry.js +2 -2
  83. package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
  84. package/dist/studio/app.d.ts +8 -1
  85. package/dist/studio/app.d.ts.map +1 -1
  86. package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
  87. package/dist/studio/components/KeyManagementPanel.js +1 -1
  88. package/dist/studio/components/index.js +2 -2
  89. package/dist/studio/{components-B4eKIUN5.js → components-CJmzntBi.js} +8 -7
  90. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  91. package/dist/studio/control-plane/Instance.d.ts +4 -1
  92. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  93. package/dist/studio/control-plane/Operations.d.ts +4 -1
  94. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  95. package/dist/studio/control-plane/index.js +2 -2
  96. package/dist/studio/control-plane/primitives.d.ts +16 -0
  97. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  98. package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
  99. package/dist/studio/index.d.ts +1 -0
  100. package/dist/studio/index.d.ts.map +1 -1
  101. package/dist/studio/index.js +384 -247
  102. package/dist/studio/navigation.d.ts +57 -0
  103. package/dist/studio/navigation.d.ts.map +1 -0
  104. package/dist/studio/studio.css +1 -1
  105. package/dist/studio/utils/control-plane-api.d.ts +2 -0
  106. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  107. package/dist/studio/utils/control-plane-api.js +5 -1
  108. package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-BjM1ylL3.js} +1 -1
  109. package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
  110. package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
  111. package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
  112. package/dist/studio-app/index.html +2 -2
  113. package/dist/transaction.d.ts +27 -11
  114. package/dist/transaction.d.ts.map +1 -1
  115. package/dist/transaction.js +20 -14
  116. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  117. package/dist/wasm/package.json +1 -1
  118. package/dist/workload.d.ts +31 -3
  119. package/dist/workload.d.ts.map +1 -1
  120. package/dist/workload.js +28 -3
  121. package/dist/workspace/local-development-authority.d.ts +19 -0
  122. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  123. package/dist/workspace/local-development-authority.js +335 -15
  124. package/package.json +1 -1
  125. package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
  126. 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},
@@ -87,9 +88,11 @@ use feltdb_server::{
87
88
  cluster::{ClusterConfig, ClusterStore, MembershipProposal, ProposalPhase},
88
89
  connections::{ConnectionProvider, ConnectionStatus, ConnectionStore, SecretReference},
89
90
  content::ContentStore,
91
+ control_authorization::{self, PrincipalClass},
92
+ console_accounts::{self, ConsoleAccountStore},
90
93
  control_plane,
91
94
  identity::IdentityStore,
92
- key_management::{create_key, list_keys, revoke_key},
95
+ key_management::{create_key, list_keys, revoke_key, rotate_key},
93
96
  key_provider::provider_from_environment,
94
97
  leases::LeaseStore,
95
98
  metrics::Metrics,
@@ -199,11 +202,13 @@ struct HealthResponse<'a> {
199
202
  public_contract: &'a str,
200
203
  capability_fingerprint: String,
201
204
  runtime: &'a str,
205
+ /// Replay outcome of the durable log at open (`clean` or
206
+ /// `recovered-incomplete-write`). Liveness only: it is not a probe of the
207
+ /// volume now and not a durability claim — see `/ready` and `/v1/status`.
202
208
  storage: &'a str,
203
- fabric: &'a str,
209
+ /// Configuration, not a probe: whether this process runs the autonomous
210
+ /// worker.
204
211
  execution: &'a str,
205
- workflows: &'a str,
206
- agents: &'a str,
207
212
  membership: &'a str,
208
213
  }
209
214
 
@@ -1465,6 +1470,10 @@ struct WorkloadFailRequest {
1465
1470
  fence: WorkloadFenceRequest,
1466
1471
  kind: String,
1467
1472
  error: String,
1473
+ /// `none` (failed before any external effect) or `unknown` (an effect may
1474
+ /// have happened). Omitted: the policy decides by `kind`.
1475
+ #[serde(default)]
1476
+ effect: Option<String>,
1468
1477
  }
1469
1478
  #[derive(Deserialize)]
1470
1479
  struct WorkloadContextRequest {
@@ -1476,6 +1485,9 @@ struct WorkloadContextRequest {
1476
1485
  #[derive(Deserialize)]
1477
1486
  struct WorkloadControlRequest {
1478
1487
  grant: feltdb::authorization::SignedGrant,
1488
+ /// Why an operator retried the workload; recorded in its history.
1489
+ #[serde(default)]
1490
+ reason: Option<String>,
1479
1491
  }
1480
1492
  #[derive(Deserialize)]
1481
1493
  struct WorkerHeartbeatRequest {
@@ -1492,13 +1504,57 @@ struct WorkerReconcileRequest {
1492
1504
  reconciliation: WorkerReconciliation,
1493
1505
  grant: feltdb::authorization::SignedGrant,
1494
1506
  }
1507
+ /// Whether this node commits Durable Work.
1508
+ ///
1509
+ /// Durable Work's guarantees rest on one serialization point for each
1510
+ /// workload: the guarded commit of one store. Peers replicate by causal
1511
+ /// order with last-writer-wins for concurrent writes, so two nodes that both
1512
+ /// committed work would each pass their own guard and silently diverge. A node
1513
+ /// without peers is therefore always the authority (the single-node default);
1514
+ /// in a peered cluster exactly one node, started with `--work-authority`,
1515
+ /// commits Durable Work, and every other node serves it read-only from its
1516
+ /// replicated store. A node that learns a peer is also the authority stops
1517
+ /// being one.
1518
+ fn is_work_authority(state: &AppState) -> bool {
1519
+ if state
1520
+ .work_authority_conflict
1521
+ .load(std::sync::atomic::Ordering::SeqCst)
1522
+ {
1523
+ return false;
1524
+ }
1525
+ state.work_authority || state.cluster.get().peers.is_empty()
1526
+ }
1527
+ /// Refuse a Durable Work write on a node that is not the authority.
1528
+ fn require_work_authority(state: &AppState) -> Result<(), ApiError> {
1529
+ if is_work_authority(state) {
1530
+ return Ok(());
1531
+ }
1532
+ let conflict = state
1533
+ .work_authority_conflict
1534
+ .load(std::sync::atomic::Ordering::SeqCst);
1535
+ Err(ApiError::structured(
1536
+ StatusCode::MISDIRECTED_REQUEST,
1537
+ json!({"code": "WORKLOAD_NOT_AUTHORITY",
1538
+ "message": if conflict {
1539
+ "more than one node is configured as the Durable Work authority; Durable Work writes are refused until that is corrected"
1540
+ } else {
1541
+ "this node replicates Durable Work read-only; send the request to the node started with --work-authority"
1542
+ },
1543
+ "details": {"reason": if conflict { "authority_conflict" } else { "replica" }, "retryable": false}}),
1544
+ ))
1545
+ }
1495
1546
  fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
1496
1547
  let status = match error.code.as_str() {
1497
1548
  "WORKLOAD_NOT_FOUND" => StatusCode::NOT_FOUND,
1498
1549
  "WORKLOAD_STALE_FENCE"
1499
1550
  | "WORKLOAD_TRANSITION_DENIED"
1500
1551
  | "WORKLOAD_NOT_READY"
1501
- | "WORKLOAD_NOT_YET_ELIGIBLE" => StatusCode::CONFLICT,
1552
+ | "WORKLOAD_NOT_YET_ELIGIBLE"
1553
+ | "WORKLOAD_ATTEMPTS_EXHAUSTED"
1554
+ | "WORKLOAD_RETRY_EXHAUSTED"
1555
+ | "WORKLOAD_RESULT_CONFLICT"
1556
+ | "WORKLOAD_TIMED_OUT"
1557
+ | "WORKLOAD_CONFLICT" => StatusCode::CONFLICT,
1502
1558
  "WORKLOAD_UNAUTHORIZED" | "WORKLOAD_CAPABILITY_DENIED" => StatusCode::FORBIDDEN,
1503
1559
  "WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
1504
1560
  _ => StatusCode::UNPROCESSABLE_ENTITY,
@@ -1589,7 +1645,8 @@ async fn create_workload(
1589
1645
  State(state): State<AppState>,
1590
1646
  Extension(principal): Extension<Principal>,
1591
1647
  Json(mut input): Json<CreateWorkload>,
1592
- ) -> Result<(StatusCode, Json<Value>), ApiError> {
1648
+ ) -> Result<(StatusCode, HeaderMap, Json<Value>), ApiError> {
1649
+ require_work_authority(&state)?;
1593
1650
  let subject = grant_subject(&principal);
1594
1651
  input.requested_by = subject.clone();
1595
1652
  input.session_id = principal.session_id.clone();
@@ -1603,7 +1660,7 @@ async fn create_workload(
1603
1660
  "workload:create",
1604
1661
  &input.authorization_snapshot,
1605
1662
  )?;
1606
- let workload = state
1663
+ let (workload, created) = state
1607
1664
  .workloads
1608
1665
  .lock()
1609
1666
  .map_err(|_| {
@@ -1612,17 +1669,42 @@ async fn create_workload(
1612
1669
  "workload store unavailable".into(),
1613
1670
  )
1614
1671
  })?
1615
- .create(input)
1616
- .map_err(workload_error)?;
1672
+ .admit(input)
1673
+ .map_err(|error| {
1674
+ if error.code == "IDEMPOTENCY_KEY_REUSED" {
1675
+ // AppPort's contract for a reused key (§13.1): CONFLICT, with
1676
+ // the reason, not retryable.
1677
+ // `fields` names the execution-affecting request fields that
1678
+ // differ from the admitted workload.
1679
+ let fields = error
1680
+ .details
1681
+ .as_ref()
1682
+ .and_then(|details| details.get("fields"))
1683
+ .cloned()
1684
+ .unwrap_or_else(|| json!([]));
1685
+ ApiError::structured(
1686
+ StatusCode::CONFLICT,
1687
+ json!({"code": "CONFLICT", "message": error.message,
1688
+ "details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false, "fields": fields}}),
1689
+ )
1690
+ } else {
1691
+ workload_error(error)
1692
+ }
1693
+ })?;
1617
1694
  record_authorization_audit(
1618
1695
  &state,
1619
1696
  &principal.key_id,
1620
- "workload.created",
1697
+ if created { "workload.created" } else { "workload.replayed" },
1621
1698
  &workload.workload_id,
1622
1699
  "ready",
1623
1700
  201,
1624
1701
  );
1625
- Ok((StatusCode::CREATED, Json(json!(workload))))
1702
+ // A replay answers with the stored response, marked as a replay.
1703
+ let mut headers = HeaderMap::new();
1704
+ if !created {
1705
+ headers.insert("idempotent-replay", axum::http::HeaderValue::from_static("true"));
1706
+ }
1707
+ Ok((StatusCode::CREATED, headers, Json(json!(workload))))
1626
1708
  }
1627
1709
  async fn list_workloads(
1628
1710
  State(state): State<AppState>,
@@ -1644,7 +1726,8 @@ async fn list_workloads(
1644
1726
  "workload store unavailable".into(),
1645
1727
  )
1646
1728
  })?
1647
- .list(&tenant, &target.application_id);
1729
+ .fresh_list(&tenant, &target.application_id)
1730
+ .map_err(workload_error)?;
1648
1731
  Ok(Json(json!({"workloads":values,"grant_id":target.grant_id})))
1649
1732
  }
1650
1733
  async fn get_workload(
@@ -1661,28 +1744,42 @@ async fn get_workload(
1661
1744
  "workload store unavailable".into(),
1662
1745
  )
1663
1746
  })?
1664
- .get(&id)
1747
+ .fresh(&id)
1665
1748
  .map_err(workload_error)?
1666
1749
  .clone();
1667
- application_scope(
1668
- &state,
1669
- &principal.key_id,
1670
- &workload.application_id,
1671
- "workload:read",
1672
- )?;
1750
+ workload_read_scope(&state, &principal, &workload.application_id)?;
1673
1751
  Ok(Json(json!(workload)))
1674
1752
  }
1753
+ /// Authorize reading one workload. A caller who may not read the
1754
+ /// application's work gets the same answer as for a workload that does not
1755
+ /// exist, so workload ids cannot be probed.
1756
+ fn workload_read_scope(
1757
+ state: &AppState,
1758
+ principal: &Principal,
1759
+ application_id: &str,
1760
+ ) -> Result<(), ApiError> {
1761
+ application_scope(state, &principal.key_id, application_id, "workload:read")
1762
+ .map(|_| ())
1763
+ .map_err(|_| {
1764
+ // Exactly the body `workload_error` gives a missing workload.
1765
+ ApiError::structured(
1766
+ StatusCode::NOT_FOUND,
1767
+ json!({"code": "WORKLOAD_NOT_FOUND", "message": "workload not found"}),
1768
+ )
1769
+ })
1770
+ }
1675
1771
  async fn claim_workload(
1676
1772
  State(state): State<AppState>,
1677
1773
  Extension(principal): Extension<Principal>,
1678
1774
  Path(id): Path<String>,
1679
1775
  Json(input): Json<WorkloadClaimRequest>,
1680
1776
  ) -> Result<Json<Value>, ApiError> {
1777
+ require_work_authority(&state)?;
1681
1778
  let workload = state
1682
1779
  .workloads
1683
1780
  .lock()
1684
1781
  .unwrap()
1685
- .get(&id)
1782
+ .fresh(&id)
1686
1783
  .map_err(workload_error)?
1687
1784
  .clone();
1688
1785
  let subject = grant_subject(&principal);
@@ -1736,7 +1833,7 @@ fn authorize_fence(
1736
1833
  .workloads
1737
1834
  .lock()
1738
1835
  .unwrap()
1739
- .get(id)
1836
+ .fresh(id)
1740
1837
  .map_err(workload_error)?
1741
1838
  .clone();
1742
1839
  let subject = grant_subject(principal);
@@ -1763,6 +1860,7 @@ async fn start_workload(
1763
1860
  Path(id): Path<String>,
1764
1861
  Json(input): Json<WorkloadFenceRequest>,
1765
1862
  ) -> Result<StatusCode, ApiError> {
1863
+ require_work_authority(&state)?;
1766
1864
  authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
1767
1865
  state
1768
1866
  .workloads
@@ -1784,6 +1882,7 @@ async fn heartbeat_workload(
1784
1882
  Path(id): Path<String>,
1785
1883
  Json(input): Json<WorkloadHeartbeatRequest>,
1786
1884
  ) -> Result<Json<Value>, ApiError> {
1885
+ require_work_authority(&state)?;
1787
1886
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1788
1887
  let claim = state
1789
1888
  .workloads
@@ -1806,6 +1905,7 @@ async fn checkpoint_workload(
1806
1905
  Path(id): Path<String>,
1807
1906
  Json(input): Json<WorkloadCheckpointRequest>,
1808
1907
  ) -> Result<Json<Value>, ApiError> {
1908
+ require_work_authority(&state)?;
1809
1909
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1810
1910
  let checkpoint = state
1811
1911
  .workloads
@@ -1828,6 +1928,7 @@ async fn complete_workload(
1828
1928
  Path(id): Path<String>,
1829
1929
  Json(input): Json<WorkloadCompleteRequest>,
1830
1930
  ) -> Result<Json<Value>, ApiError> {
1931
+ require_work_authority(&state)?;
1831
1932
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1832
1933
  let result = state
1833
1934
  .workloads
@@ -1854,18 +1955,20 @@ async fn fail_workload(
1854
1955
  Path(id): Path<String>,
1855
1956
  Json(input): Json<WorkloadFailRequest>,
1856
1957
  ) -> Result<Json<Value>, ApiError> {
1958
+ require_work_authority(&state)?;
1857
1959
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1858
1960
  let status = state
1859
1961
  .workloads
1860
1962
  .lock()
1861
1963
  .unwrap()
1862
- .fail(
1964
+ .fail_with_effect(
1863
1965
  &id,
1864
1966
  &input.fence.claim_id,
1865
1967
  &input.fence.worker_id,
1866
1968
  input.fence.fencing_token,
1867
1969
  &input.kind,
1868
1970
  &input.error,
1971
+ input.effect.as_deref(),
1869
1972
  unix_seconds_i64(),
1870
1973
  )
1871
1974
  .map_err(workload_error)?;
@@ -1878,6 +1981,7 @@ async fn confirm_workload_cancelled(
1878
1981
  Path(id): Path<String>,
1879
1982
  Json(input): Json<WorkloadFenceRequest>,
1880
1983
  ) -> Result<StatusCode, ApiError> {
1984
+ require_work_authority(&state)?;
1881
1985
  authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
1882
1986
  state
1883
1987
  .workloads
@@ -1899,6 +2003,7 @@ async fn workload_execution_context(
1899
2003
  Path(id): Path<String>,
1900
2004
  Json(input): Json<WorkloadContextRequest>,
1901
2005
  ) -> Result<Json<Value>, ApiError> {
2006
+ require_work_authority(&state)?;
1902
2007
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1903
2008
  let context = state
1904
2009
  .workloads
@@ -1921,11 +2026,12 @@ async fn cancel_workload(
1921
2026
  Path(id): Path<String>,
1922
2027
  Json(input): Json<WorkloadControlRequest>,
1923
2028
  ) -> Result<Json<Value>, ApiError> {
2029
+ require_work_authority(&state)?;
1924
2030
  let workload = state
1925
2031
  .workloads
1926
2032
  .lock()
1927
2033
  .unwrap()
1928
- .get(&id)
2034
+ .fresh(&id)
1929
2035
  .map_err(workload_error)?
1930
2036
  .clone();
1931
2037
  workload_decision(
@@ -1951,11 +2057,12 @@ async fn retry_workload(
1951
2057
  Path(id): Path<String>,
1952
2058
  Json(input): Json<WorkloadControlRequest>,
1953
2059
  ) -> Result<StatusCode, ApiError> {
2060
+ require_work_authority(&state)?;
1954
2061
  let workload = state
1955
2062
  .workloads
1956
2063
  .lock()
1957
2064
  .unwrap()
1958
- .get(&id)
2065
+ .fresh(&id)
1959
2066
  .map_err(workload_error)?
1960
2067
  .clone();
1961
2068
  workload_decision(
@@ -1971,7 +2078,7 @@ async fn retry_workload(
1971
2078
  .workloads
1972
2079
  .lock()
1973
2080
  .unwrap()
1974
- .retry(&id, &principal.key_id, unix_seconds_i64())
2081
+ .retry_with_reason(&id, &principal.key_id, unix_seconds_i64(), input.reason.as_deref())
1975
2082
  .map_err(workload_error)?;
1976
2083
  Ok(StatusCode::NO_CONTENT)
1977
2084
  }
@@ -1984,15 +2091,10 @@ async fn workload_history(
1984
2091
  .workloads
1985
2092
  .lock()
1986
2093
  .unwrap()
1987
- .get(&id)
2094
+ .fresh(&id)
1988
2095
  .map_err(workload_error)?
1989
2096
  .clone();
1990
- application_scope(
1991
- &state,
1992
- &principal.key_id,
1993
- &workload.application_id,
1994
- "workload:read",
1995
- )?;
2097
+ workload_read_scope(&state, &principal, &workload.application_id)?;
1996
2098
  Ok(Json(
1997
2099
  json!({"events":workload.execution_history,"attempts":workload.attempts,"checkpoints":workload.checkpoints}),
1998
2100
  ))
@@ -2006,15 +2108,10 @@ async fn workload_result(
2006
2108
  .workloads
2007
2109
  .lock()
2008
2110
  .unwrap()
2009
- .get(&id)
2111
+ .fresh(&id)
2010
2112
  .map_err(workload_error)?
2011
2113
  .clone();
2012
- application_scope(
2013
- &state,
2014
- &principal.key_id,
2015
- &workload.application_id,
2016
- "workload:read",
2017
- )?;
2114
+ workload_read_scope(&state, &principal, &workload.application_id)?;
2018
2115
  Ok(Json(json!(workload.result)))
2019
2116
  }
2020
2117
 
@@ -2196,7 +2293,8 @@ async fn reconcile_worker(
2196
2293
  ) -> Result<Json<Value>, ApiError> {
2197
2294
  authorize_worker_action(&state, &principal, &id, &input.grant, "worker:heartbeat")?;
2198
2295
  input.reconciliation.worker_id = id;
2199
- let workloads = state.workloads.lock().unwrap();
2296
+ let mut workloads = state.workloads.lock().unwrap();
2297
+ workloads.refresh().map_err(workload_error)?;
2200
2298
  let result = state
2201
2299
  .mesh
2202
2300
  .lock()
@@ -2287,15 +2385,10 @@ async fn eligible_workers(
2287
2385
  .workloads
2288
2386
  .lock()
2289
2387
  .unwrap()
2290
- .get(&id)
2388
+ .fresh(&id)
2291
2389
  .map_err(workload_error)?
2292
2390
  .clone();
2293
- application_scope(
2294
- &state,
2295
- &principal.key_id,
2296
- &workload.application_id,
2297
- "workload:read",
2298
- )?;
2391
+ workload_read_scope(&state, &principal, &workload.application_id)?;
2299
2392
  let workers = state
2300
2393
  .mesh
2301
2394
  .lock()
@@ -6935,6 +7028,139 @@ async fn auth_sign_up(
6935
7028
  .map_err(control_error)?;
6936
7029
  Ok((StatusCode::CREATED, auth_response(identity, session, token)))
6937
7030
  }
7031
+ #[derive(Deserialize)]
7032
+ struct ConsolePasswordRequest {
7033
+ email: String,
7034
+ password: String,
7035
+ }
7036
+ #[derive(Deserialize)]
7037
+ struct ConsoleGithubRequest {
7038
+ github_id: Value,
7039
+ login: String,
7040
+ }
7041
+
7042
+ fn console_account_error(error: console_accounts::AccountError) -> ApiError {
7043
+ ApiError::structured(
7044
+ StatusCode::from_u16(error.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
7045
+ json!({ "code": error.code, "message": error.message }),
7046
+ )
7047
+ }
7048
+
7049
+ /// Authenticate a website call to `/v1/console/accounts/*` and parse its body.
7050
+ fn console_request<T: serde::de::DeserializeOwned>(
7051
+ state: &AppState,
7052
+ method: &Method,
7053
+ uri: &axum::http::Uri,
7054
+ headers: &HeaderMap,
7055
+ body: &[u8],
7056
+ ) -> Result<T, ApiError> {
7057
+ let Some(secret) = state.console_accounts.secret() else {
7058
+ return Err(ApiError::structured(
7059
+ StatusCode::SERVICE_UNAVAILABLE,
7060
+ json!({
7061
+ "code": "CONSOLE_ACCOUNTS_UNCONFIGURED",
7062
+ "message": "console accounts require FELTDB_SESSION_SECRET (at least 32 bytes) on the control plane",
7063
+ }),
7064
+ ));
7065
+ };
7066
+ let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
7067
+ let now = std::time::SystemTime::now()
7068
+ .duration_since(std::time::UNIX_EPOCH)
7069
+ .unwrap_or_default()
7070
+ .as_secs();
7071
+ if let Err(error) = console_accounts::verify_console_request(
7072
+ secret,
7073
+ method.as_str(),
7074
+ uri.path(),
7075
+ header(console_accounts::TIMESTAMP_HEADER),
7076
+ header(console_accounts::SIGNATURE_HEADER),
7077
+ body,
7078
+ now,
7079
+ ) {
7080
+ state.metrics.authentication_failure();
7081
+ audit(state, "anonymous", "console.accounts", uri.path(), "denied", 401);
7082
+ return Err(console_account_error(error));
7083
+ }
7084
+ serde_json::from_slice(body)
7085
+ .map_err(|error| ApiError(StatusCode::BAD_REQUEST, format!("invalid request body: {error}")))
7086
+ }
7087
+
7088
+ /// Argon2 work and the account file write run off the request worker.
7089
+ async fn console_blocking<F>(work: F) -> Result<Json<Value>, ApiError>
7090
+ where
7091
+ F: FnOnce() -> Result<console_accounts::ResolvedAccount, console_accounts::AccountError>
7092
+ + Send
7093
+ + 'static,
7094
+ {
7095
+ match tokio::task::spawn_blocking(work).await {
7096
+ Ok(Ok(account)) => Ok(Json(json!({
7097
+ "user_id": account.user_id,
7098
+ "identity": account.identity,
7099
+ "display_identity": account.display_identity,
7100
+ }))),
7101
+ Ok(Err(error)) => Err(console_account_error(error)),
7102
+ Err(_) => Err(ApiError(
7103
+ StatusCode::INTERNAL_SERVER_ERROR,
7104
+ "console account operation failed".into(),
7105
+ )),
7106
+ }
7107
+ }
7108
+
7109
+ async fn console_password_signup(
7110
+ State(state): State<AppState>,
7111
+ method: Method,
7112
+ uri: axum::http::Uri,
7113
+ headers: HeaderMap,
7114
+ body: axum::body::Bytes,
7115
+ ) -> Result<(StatusCode, Json<Value>), ApiError> {
7116
+ let input: ConsolePasswordRequest = console_request(&state, &method, &uri, &headers, &body)?;
7117
+ let created = console_blocking(move || {
7118
+ state
7119
+ .console_accounts
7120
+ .password_signup(&state.tenancy, &input.email, &input.password)
7121
+ })
7122
+ .await?;
7123
+ Ok((StatusCode::CREATED, created))
7124
+ }
7125
+
7126
+ async fn console_password_signin(
7127
+ State(state): State<AppState>,
7128
+ method: Method,
7129
+ uri: axum::http::Uri,
7130
+ headers: HeaderMap,
7131
+ body: axum::body::Bytes,
7132
+ ) -> Result<Json<Value>, ApiError> {
7133
+ let input: ConsolePasswordRequest = console_request(&state, &method, &uri, &headers, &body)?;
7134
+ console_blocking(move || {
7135
+ state
7136
+ .console_accounts
7137
+ .password_signin(&input.email, &input.password)
7138
+ })
7139
+ .await
7140
+ }
7141
+
7142
+ async fn console_github_resolve(
7143
+ State(state): State<AppState>,
7144
+ method: Method,
7145
+ uri: axum::http::Uri,
7146
+ headers: HeaderMap,
7147
+ body: axum::body::Bytes,
7148
+ ) -> Result<Json<Value>, ApiError> {
7149
+ let input: ConsoleGithubRequest = console_request(&state, &method, &uri, &headers, &body)?;
7150
+ // GitHub user ids are numbers; accept the number or its decimal string.
7151
+ let github_id = match &input.github_id {
7152
+ Value::Number(number) if number.is_u64() => number.to_string(),
7153
+ Value::String(text) => text.clone(),
7154
+ _ => String::new(),
7155
+ };
7156
+ console_blocking(move || {
7157
+ state
7158
+ .console_accounts
7159
+ .github_resolve(&state.tenancy, &github_id, &input.login)
7160
+ })
7161
+ .await
7162
+ }
7163
+
6938
7164
  async fn auth_sign_in(
6939
7165
  State(state): State<AppState>,
6940
7166
  Json(input): Json<SignInRequest>,
@@ -7573,7 +7799,9 @@ fn state_contract_error(error: feltdb::state_contract::StateFailure) -> ApiError
7573
7799
  "PRECONDITION_FAILED" | "CONFLICT" | "IDEMPOTENCY_CONFLICT" => StatusCode::CONFLICT,
7574
7800
  "UNKNOWN_COLLECTION" => StatusCode::NOT_FOUND,
7575
7801
  "SEMANTIC_UNSUPPORTED" => StatusCode::NOT_IMPLEMENTED,
7576
- "STORAGE_FAILURE" | "STATE_UNAVAILABLE" => StatusCode::SERVICE_UNAVAILABLE,
7802
+ "STORAGE_FAILURE" | "STATE_UNAVAILABLE" | "DURABILITY_FAILURE" => {
7803
+ StatusCode::SERVICE_UNAVAILABLE
7804
+ }
7577
7805
  _ => StatusCode::UNPROCESSABLE_ENTITY,
7578
7806
  };
7579
7807
  ApiError(
@@ -8210,6 +8438,10 @@ struct SyncPullResponse {
8210
8438
  operations: Vec<Operation>,
8211
8439
  versions: HashMap<String, u64>,
8212
8440
  catchup: CatchupStatus,
8441
+ /// Whether the answering node is configured as the Durable Work authority
8442
+ /// (`--work-authority`). Absent from older peers.
8443
+ #[serde(default)]
8444
+ durable_work_authority: bool,
8213
8445
  }
8214
8446
 
8215
8447
  #[derive(Serialize, Deserialize)]
@@ -8418,6 +8650,10 @@ struct StartAgentRun {
8418
8650
  goal: String,
8419
8651
  #[serde(default)]
8420
8652
  input: Value,
8653
+ /// What the runtime must observe for the run to complete. Without it the
8654
+ /// run can execute but never completes: the goal text is not evaluated.
8655
+ #[serde(default)]
8656
+ completion: Option<CompletionCriteria>,
8421
8657
  }
8422
8658
 
8423
8659
  #[derive(Deserialize)]
@@ -8454,6 +8690,7 @@ struct Config {
8454
8690
  audit: PathBuf,
8455
8691
  allowed_origins: Vec<axum::http::HeaderValue>,
8456
8692
  worker_enabled: bool,
8693
+ work_authority: bool,
8457
8694
  advertise_url: Option<String>,
8458
8695
  maximum_clock_drift_ms: u64,
8459
8696
  }
@@ -8502,6 +8739,7 @@ impl Config {
8502
8739
  .transpose()?
8503
8740
  .unwrap_or_default();
8504
8741
  let mut worker_enabled = std::env::var("FELTDB_WORKER_ENABLED").as_deref() == Ok("true");
8742
+ let mut work_authority = std::env::var("FELTDB_WORK_AUTHORITY").as_deref() == Ok("true");
8505
8743
  let mut advertise_url = std::env::var("FELTDB_ADVERTISE_URL")
8506
8744
  .ok()
8507
8745
  .map(|value| value.trim_end_matches('/').to_string());
@@ -8554,6 +8792,7 @@ impl Config {
8554
8792
  .map_err(|_| "invalid --allow-origin".to_string())?,
8555
8793
  ),
8556
8794
  "--worker" => worker_enabled = true,
8795
+ "--work-authority" => work_authority = true,
8557
8796
  "--advertise-url" => {
8558
8797
  advertise_url = Some(value()?.trim_end_matches('/').to_string())
8559
8798
  }
@@ -8568,7 +8807,7 @@ impl Config {
8568
8807
  }
8569
8808
  }
8570
8809
  "--help" | "-h" => {
8571
- println!("feltdb-server [--host 127.0.0.1] [--port 7700] [--advertise-url https://node.example] [--namespace default] [--data ./data/feltdb.log] [--auth] [--keys ./data/api-keys.json] [--audit ./data/feltdb.audit.log] [--allow-origin https://app.example] [--peer https://peer] [--peer-token-env FELTDB_PEER_TOKEN] [--sync-interval-ms 2000] [--worker]");
8810
+ println!("feltdb-server [--host 127.0.0.1] [--port 7700] [--advertise-url https://node.example] [--namespace default] [--data ./data/feltdb.log] [--auth] [--keys ./data/api-keys.json] [--audit ./data/feltdb.audit.log] [--allow-origin https://app.example] [--peer https://peer] [--peer-token-env FELTDB_PEER_TOKEN] [--sync-interval-ms 2000] [--worker] [--work-authority]");
8572
8811
  std::process::exit(0);
8573
8812
  }
8574
8813
  _ => return Err(format!("unknown argument: {argument}")),
@@ -8591,6 +8830,7 @@ impl Config {
8591
8830
  audit,
8592
8831
  allowed_origins,
8593
8832
  worker_enabled,
8833
+ work_authority,
8594
8834
  advertise_url,
8595
8835
  maximum_clock_drift_ms,
8596
8836
  })
@@ -8661,10 +8901,28 @@ fn validate_production_configuration(config: &Config) -> Result<(), Box<dyn std:
8661
8901
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
8662
8902
  tracing_subscriber::fmt()
8663
8903
  .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
8904
+ // Every log line passes through credential redaction, whatever wrote it.
8905
+ .with_writer(feltdb_server::redaction::RedactingStdout)
8664
8906
  .init();
8907
+ if matches!(
8908
+ std::env::args().nth(1).as_deref(),
8909
+ Some("version" | "--version" | "-V")
8910
+ ) {
8911
+ // One line an operator script can print instead of a hard-coded banner.
8912
+ println!(
8913
+ "feltdb-server {} (release {}, commit {})",
8914
+ env!("CARGO_PKG_VERSION"),
8915
+ option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
8916
+ option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown")
8917
+ );
8918
+ return Ok(());
8919
+ }
8665
8920
  if manage_keys()? {
8666
8921
  return Ok(());
8667
8922
  }
8923
+ if manage_console_accounts()? {
8924
+ return Ok(());
8925
+ }
8668
8926
  if manage_authorization()? {
8669
8927
  return Ok(());
8670
8928
  }
@@ -8801,6 +9059,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8801
9059
  lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
8802
9060
  state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(2)),
8803
9061
  worker_enabled: config.worker_enabled,
9062
+ work_authority: config.work_authority,
9063
+ work_authority_conflict: Arc::new(std::sync::atomic::AtomicBool::new(false)),
8804
9064
  leases: LeaseStore::load(config.data.with_extension("leases.json"))?,
8805
9065
  content: content_store,
8806
9066
  artifacts: artifact_store,
@@ -8822,6 +9082,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8822
9082
  .into_bytes(),
8823
9083
  config.data.with_extension("revoked-sessions.json"),
8824
9084
  )?,
9085
+ console_accounts: ConsoleAccountStore::load(
9086
+ config.data.with_extension("console-accounts.json"),
9087
+ console_accounts::configured_secret(std::env::var("FELTDB_SESSION_SECRET").ok()),
9088
+ )?,
8825
9089
  principals: principal_authority,
8826
9090
  connections: ConnectionStore::load_with_provider(
8827
9091
  config.data.with_extension("connections.json"),
@@ -8846,6 +9110,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8846
9110
  };
8847
9111
  start_peer_sessions(&state, &config)?;
8848
9112
  start_membership_recovery(&state);
9113
+ start_workload_recovery(&state);
8849
9114
  start_autonomous_worker(&state);
8850
9115
 
8851
9116
  let protected = Router::new()
@@ -8909,6 +9174,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8909
9174
  .route("/api/tenants/{tenant_id}", get(get_tenant))
8910
9175
  .route("/api/keys", get(list_keys).post(create_key))
8911
9176
  .route("/api/keys/{id}", axum::routing::delete(revoke_key))
9177
+ .route("/api/keys/{id}/rotate", axum::routing::post(rotate_key))
9178
+ // Operator observability: authenticated, `operator:read`.
9179
+ .route("/metrics", get(network_metrics))
9180
+ .route("/v1/status", get(operator_status))
8912
9181
  .route(
8913
9182
  "/api/tenants/{tenant_id}/applications",
8914
9183
  get(list_applications).post(create_application_control),
@@ -9569,23 +9838,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9569
9838
  .route("/v1/openapi.json", get(application_openapi))
9570
9839
  .route("/v1/auth/signup", axum::routing::post(auth_sign_up))
9571
9840
  .route("/v1/auth/signin", axum::routing::post(auth_sign_in))
9572
- .route("/internal/intents/by-hash", get(internal_find_intent))
9841
+ // Console accounts: callable only by the website, which signs each
9842
+ // request with FELTDB_SESSION_SECRET (see console_accounts.rs).
9573
9843
  .route(
9574
- "/internal/intents",
9575
- axum::routing::post(internal_create_intent),
9844
+ "/v1/console/accounts/password/signup",
9845
+ axum::routing::post(console_password_signup),
9576
9846
  )
9577
- .route("/internal/intents/{id}", put(internal_replace_intent))
9578
9847
  .route(
9579
- "/internal/intent-events",
9580
- axum::routing::post(internal_create_intent_event),
9848
+ "/v1/console/accounts/password/signin",
9849
+ axum::routing::post(console_password_signin),
9581
9850
  )
9582
- .route("/ready", get(readiness))
9583
- .route("/runtime", get(runtime))
9584
- .route("/metrics", get(network_metrics))
9585
9851
  .route(
9586
- "/api/certification/fixtures/{application_id}",
9587
- axum::routing::delete(delete_certification_fixture),
9852
+ "/v1/console/accounts/github/resolve",
9853
+ axum::routing::post(console_github_resolve),
9588
9854
  )
9855
+ .route("/ready", get(readiness))
9856
+ .route("/runtime", get(runtime))
9857
+ .merge(service_secret_routes())
9589
9858
  .merge(protected)
9590
9859
  .merge(query_execution_diagnostics_router())
9591
9860
  .layer(middleware::from_fn_with_state(state.clone(), admit_request))
@@ -9593,7 +9862,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9593
9862
  .layer(middleware::from_fn(attribute_request))
9594
9863
  .layer(middleware::from_fn_with_state(state.clone(), count_request))
9595
9864
  .layer(RequestBodyLimitLayer::new(1024 * 1024))
9596
- .layer(TraceLayer::new_for_http())
9865
+ .layer(middleware::from_fn(redact_error_responses))
9866
+ // Spans carry the path, never the query string, where clients put tokens.
9867
+ .layer(TraceLayer::new_for_http().make_span_with(
9868
+ |request: &Request<axum::body::Body>| {
9869
+ tracing::debug_span!(
9870
+ "request",
9871
+ method = %request.method(),
9872
+ path = %request.uri().path(),
9873
+ )
9874
+ },
9875
+ ))
9597
9876
  .with_state(state);
9598
9877
  if !config.allowed_origins.is_empty() {
9599
9878
  app = app.layer(
@@ -9637,6 +9916,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9637
9916
  Ok(())
9638
9917
  }
9639
9918
 
9919
+ /// Error bodies can quote client input (axum's JSON rejections echo the
9920
+ /// offending value); a credential sent by mistake must not come back out, or
9921
+ /// land in whatever logs the response. Success bodies are untouched: a key's
9922
+ /// secret is returned exactly once, at creation or rotation.
9923
+ async fn redact_error_responses(request: Request<axum::body::Body>, next: Next) -> Response {
9924
+ let response = next.run(request).await;
9925
+ if response.status().as_u16() < 400 {
9926
+ return response;
9927
+ }
9928
+ let (parts, body) = response.into_parts();
9929
+ let bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
9930
+ Ok(bytes) => bytes,
9931
+ Err(_) => return Response::from_parts(parts, axum::body::Body::empty()),
9932
+ };
9933
+ let body = match std::str::from_utf8(&bytes) {
9934
+ Ok(text) => match feltdb_server::redaction::redact(text) {
9935
+ std::borrow::Cow::Owned(redacted) => axum::body::Body::from(redacted),
9936
+ std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
9937
+ },
9938
+ Err(_) => axum::body::Body::from(bytes),
9939
+ };
9940
+ let mut parts = parts;
9941
+ parts.headers.remove(axum::http::header::CONTENT_LENGTH);
9942
+ Response::from_parts(parts, body)
9943
+ }
9944
+
9640
9945
  async fn count_request(
9641
9946
  State(state): State<AppState>,
9642
9947
  request: Request<axum::body::Body>,
@@ -9669,7 +9974,7 @@ async fn authenticate(
9669
9974
  // an answer to PR36's question — which part of the request does is.
9670
9975
  let authorization =
9671
9976
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Authorization);
9672
- let principal = if state.auth_enabled {
9977
+ let (principal, class) = if state.auth_enabled {
9673
9978
  let token = request
9674
9979
  .headers()
9675
9980
  .get(AUTHORIZATION)
@@ -9732,8 +10037,8 @@ async fn authenticate(
9732
10037
  })
9733
10038
  .and_then(|token| state.sessions.verify(token));
9734
10039
  match (machine, actor, human) {
9735
- (Some(principal), _, _) => principal,
9736
- (None, Some(session), _) => Principal {
10040
+ (Some(principal), _, _) => (principal, PrincipalClass::Machine),
10041
+ (None, Some(session), _) => (Principal {
9737
10042
  key_id: session.subject_id,
9738
10043
  scopes: vec![
9739
10044
  "application:read".into(),
@@ -9748,24 +10053,55 @@ async fn authenticate(
9748
10053
  subject_type: "human".into(),
9749
10054
  identity_id: Some(session.identity_id),
9750
10055
  session_id: Some(session.session_id),
9751
- },
10056
+ }, PrincipalClass::EndUser),
9752
10057
  (None, None, Some(session)) => {
9753
- if state
9754
- .tenancy
9755
- .register_platform_user(&session.user_id, &session.identity)
9756
- .is_err()
9757
- || !state.tenancy.platform_user_active(&session.user_id)
10058
+ // A signed cookie is not enough: its user id must belong to a
10059
+ // console account (created through the verified account
10060
+ // routes, or bound/claimed for a pre-0.15.0 user), and its
10061
+ // identity must be that account's. Sessions the website
10062
+ // minted before accounts existed are refused here.
10063
+ let account = match state.console_accounts.account_for_user(&session.user_id) {
10064
+ Some(account) if account.display_identity == session.identity.trim().to_lowercase() => account,
10065
+ _ => {
10066
+ state.metrics.authentication_failure();
10067
+ audit(
10068
+ &state,
10069
+ &session.user_id,
10070
+ "authenticate",
10071
+ request.uri().path(),
10072
+ "denied",
10073
+ 401,
10074
+ );
10075
+ return ApiError::structured(
10076
+ StatusCode::UNAUTHORIZED,
10077
+ json!({
10078
+ "code": "CONSOLE_ACCOUNT_REQUIRED",
10079
+ "message": "this console session is not bound to a console account; sign in again",
10080
+ }),
10081
+ )
10082
+ .into_response();
10083
+ }
10084
+ };
10085
+ if (!state.tenancy.platform_user_exists(&account.user_id)
10086
+ && state
10087
+ .tenancy
10088
+ .register_platform_user(&account.user_id, &account.display_identity)
10089
+ .is_err())
10090
+ || !state.tenancy.platform_user_active(&account.user_id)
9758
10091
  {
9759
10092
  return ApiError(StatusCode::FORBIDDEN, "platform user is suspended".into())
9760
10093
  .into_response();
9761
10094
  }
9762
- Principal {
9763
- key_id: session.user_id,
9764
- scopes: vec![],
9765
- subject_type: "human".into(),
9766
- identity_id: Some(session.identity),
9767
- session_id: Some(session.id),
9768
- }
10095
+ (
10096
+ Principal {
10097
+ key_id: account.user_id,
10098
+ scopes: vec![],
10099
+ subject_type: "human".into(),
10100
+ identity_id: Some(account.display_identity),
10101
+ session_id: Some(session.id),
10102
+ },
10103
+ PrincipalClass::ConsoleUser,
10104
+ )
9769
10105
  }
9770
10106
  (None, None, None) => {
9771
10107
  state.metrics.authentication_failure();
@@ -9785,17 +10121,43 @@ async fn authenticate(
9785
10121
  }
9786
10122
  }
9787
10123
  } else {
9788
- Principal {
9789
- key_id: "development".to_string(),
9790
- scopes: vec!["*".to_string()],
9791
- subject_type: "service".into(),
9792
- identity_id: None,
9793
- session_id: None,
9794
- }
10124
+ (
10125
+ Principal {
10126
+ key_id: "development".to_string(),
10127
+ scopes: vec!["*".to_string()],
10128
+ subject_type: "service".into(),
10129
+ identity_id: None,
10130
+ session_id: None,
10131
+ },
10132
+ PrincipalClass::Development,
10133
+ )
9795
10134
  };
9796
10135
 
9797
10136
  let path = request.uri().path();
9798
- let required = if path == "/events" {
10137
+ // The control plane (`/api/*`) is decided per route, before any handler:
10138
+ // machine keys need the route's capability, console users are held to
10139
+ // tenant/application membership by the handler, and application end-user
10140
+ // sessions are refused. There is no path family without a decision.
10141
+ let control = control_authorization::control_plane_requirement(request.method(), path);
10142
+ if let Some(requirement) = control {
10143
+ if let Err(denial) = control_authorization::decide(class, &principal.scopes, requirement) {
10144
+ state.metrics.authentication_failure();
10145
+ let action = match requirement {
10146
+ control_authorization::Requirement::Capability(scope) => scope,
10147
+ _ => "control-plane",
10148
+ };
10149
+ audit(&state, &principal.key_id, action, path, "denied", 403);
10150
+ return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
10151
+ }
10152
+ }
10153
+ let required = if let Some(requirement) = control {
10154
+ match requirement {
10155
+ control_authorization::Requirement::Capability(scope) => scope,
10156
+ _ => "session:self",
10157
+ }
10158
+ } else if path == "/metrics" || path == "/v1/status" {
10159
+ control_authorization::OPERATOR_READ
10160
+ } else if path == "/events" {
9799
10161
  "events:read"
9800
10162
  } else if path == "/v1/state/diff"
9801
10163
  || path == "/v1/state/conflicts"
@@ -9855,6 +10217,24 @@ async fn authenticate(
9855
10217
  } else {
9856
10218
  "state:write"
9857
10219
  };
10220
+ // An end-user session belongs to one application (self-serve sign-up
10221
+ // registers it there). Its state scopes are meaningful only on the
10222
+ // application API, which checks that membership per request. The legacy
10223
+ // routes (`/collections`, `/transactions`, `/cas`, `/events`, ...) are
10224
+ // scoped to the whole server namespace, so a session there would read and
10225
+ // write every application's records.
10226
+ if class == PrincipalClass::EndUser && !path.starts_with("/v1/") {
10227
+ state.metrics.authentication_failure();
10228
+ audit(&state, &principal.key_id, required, path, "denied", 403);
10229
+ let denial = control_authorization::Denial::new(
10230
+ control_authorization::END_USER_SESSION_FORBIDDEN,
10231
+ format!(
10232
+ "end-user sessions can only use the application API (/v1/...) of their own application; {path} is a namespace-wide route — use an application-scoped service key on the server side"
10233
+ ),
10234
+ )
10235
+ .with_scope(required);
10236
+ return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
10237
+ }
9858
10238
  let subject = if principal.subject_type == "human" {
9859
10239
  Subject::User(principal.key_id.clone())
9860
10240
  } else {
@@ -9872,16 +10252,26 @@ async fn authenticate(
9872
10252
  let human_contract = principal.subject_type == "human"
9873
10253
  && path.starts_with("/v1/applications/")
9874
10254
  && path.split('/').any(|segment| segment == "contract");
10255
+ // Two non-control routes are decided by who is asking rather than by a
10256
+ // scope, and are listed here rather than exempted: a user's own contract
10257
+ // drafts (keyed by that user) and the authenticated diagnostics summary.
9875
10258
  let authenticated_diagnostics = path == "/v1/diagnostics";
9876
- if !path.starts_with("/api/")
9877
- && !human_contract
9878
- && !authenticated_diagnostics
9879
- && !decision.allowed
9880
- {
10259
+ let decided_by_route = control.is_some() || human_contract || authenticated_diagnostics;
10260
+ if !decided_by_route && !decision.allowed {
9881
10261
  state.metrics.authentication_failure();
9882
10262
  audit(&state, &principal.key_id, required, path, "denied", 403);
9883
- return ApiError(StatusCode::FORBIDDEN, format!("missing scope: {required}"))
9884
- .into_response();
10263
+ let denial = if class == PrincipalClass::EndUser
10264
+ && (path == "/metrics" || path == "/v1/status" || path.starts_with("/admin/"))
10265
+ {
10266
+ control_authorization::Denial::new(
10267
+ control_authorization::END_USER_SESSION_FORBIDDEN,
10268
+ format!("missing scope {required} — end-user sessions cannot use operator routes; use an operator key"),
10269
+ )
10270
+ .with_scope(required)
10271
+ } else {
10272
+ control_authorization::Denial::missing_scope(required)
10273
+ };
10274
+ return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
9885
10275
  }
9886
10276
  if path == "/events" {
9887
10277
  state.metrics.event_connection();
@@ -9900,6 +10290,7 @@ async fn authenticate(
9900
10290
 
9901
10291
  request.extensions_mut().insert(principal.clone());
9902
10292
  request.extensions_mut().insert(authenticated.clone());
10293
+ request.extensions_mut().insert(class);
9903
10294
 
9904
10295
  let principal_id = request
9905
10296
  .extensions()
@@ -10245,14 +10636,14 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
10245
10636
  capability_fingerprint: control_plane::public_contract_capability_fingerprint(),
10246
10637
  runtime: "self-hosted",
10247
10638
  storage: database.storage.label(),
10248
- fabric: "healthy",
10639
+ // `fabric`, `workflows` and `agents` were constant "healthy" /
10640
+ // "available" here whatever their state (GAP-10). Nothing checks them,
10641
+ // so nothing reports them.
10249
10642
  execution: if state.worker_enabled {
10250
10643
  "autonomous"
10251
10644
  } else {
10252
10645
  "state-coordinated"
10253
10646
  },
10254
- workflows: "available",
10255
- agents: "available",
10256
10647
  membership: if clock {
10257
10648
  membership
10258
10649
  } else {
@@ -10390,10 +10781,19 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
10390
10781
  values("--namespace", "default"),
10391
10782
  option("--worker"),
10392
10783
  )?;
10393
- println!(
10394
- "API key created (id: {}). This secret will not be shown again:\n{}",
10395
- key.id, key.secret
10396
- );
10784
+ // `--secret-file <path>` writes the secret to an owner-only file
10785
+ // and prints only the id, so a bootstrap script never has to put a
10786
+ // credential on stdout (and from there into platform logs).
10787
+ match option("--secret-file") {
10788
+ Some(file) => {
10789
+ write_secret_file(std::path::Path::new(&file), &key.secret)?;
10790
+ println!("API key created (id: {}). Secret written to {file} (mode 0600).", key.id);
10791
+ }
10792
+ None => println!(
10793
+ "API key created (id: {}). This secret will not be shown again:\n{}",
10794
+ key.id, key.secret
10795
+ ),
10796
+ }
10397
10797
  }
10398
10798
  "list" => println!("{}", serde_json::to_string_pretty(&store.list()?)?),
10399
10799
  "revoke" => {
@@ -10403,11 +10803,93 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
10403
10803
  }
10404
10804
  println!("API key revoked: {id}");
10405
10805
  }
10406
- _ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--id key-id]".into()),
10806
+ _ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--secret-file path] [--id key-id]".into()),
10807
+ }
10808
+ Ok(true)
10809
+ }
10810
+
10811
+ /// `feltdb-server console-accounts <claim|list> --data <state.log> ...`
10812
+ ///
10813
+ /// `claim --email <email> --password-file <file>` binds a console password
10814
+ /// account to the existing pre-0.15.0 platform user registered under that
10815
+ /// e-mail, so its owner keeps their tenants. Before 0.15.0 the website did not
10816
+ /// verify passwords, so such a user cannot be claimed by self-service sign-up;
10817
+ /// the operator verifies the person out of band and runs this. The password
10818
+ /// is read from a file (first line) so it never appears in the process list.
10819
+ fn manage_console_accounts() -> Result<bool, Box<dyn std::error::Error>> {
10820
+ let arguments: Vec<String> = std::env::args().collect();
10821
+ if arguments.get(1).map(String::as_str) != Some("console-accounts") {
10822
+ return Ok(false);
10823
+ }
10824
+ let usage = "usage: feltdb-server console-accounts <claim|list> --data <state.log path> [--email email --password-file path]";
10825
+ let option = |name: &str| {
10826
+ arguments
10827
+ .iter()
10828
+ .position(|argument| argument == name)
10829
+ .and_then(|index| arguments.get(index + 1))
10830
+ .cloned()
10831
+ };
10832
+ let data = PathBuf::from(
10833
+ option("--data").unwrap_or_else(|| "./data/feltdb.log".to_string()),
10834
+ );
10835
+ let accounts = ConsoleAccountStore::load(data.with_extension("console-accounts.json"), None)?;
10836
+ let tenancy = TenancyStore::load(data.with_extension("platform.json"))?;
10837
+ match arguments.get(2).map(String::as_str).unwrap_or("") {
10838
+ "claim" => {
10839
+ let email = option("--email").ok_or("console-accounts claim requires --email")?;
10840
+ let file = option("--password-file")
10841
+ .ok_or("console-accounts claim requires --password-file")?;
10842
+ let password = std::fs::read_to_string(&file)?
10843
+ .lines()
10844
+ .next()
10845
+ .unwrap_or_default()
10846
+ .to_string();
10847
+ let claimed = accounts
10848
+ .claim_password(&tenancy, &email, &password)
10849
+ .map_err(|error| format!("{}: {}", error.code, error.message))?;
10850
+ println!(
10851
+ "Console account claimed: {} is bound to existing platform user {}.",
10852
+ claimed.identity, claimed.user_id
10853
+ );
10854
+ }
10855
+ "list" => {
10856
+ let listed: Vec<Value> = accounts
10857
+ .list()
10858
+ .into_iter()
10859
+ .map(|account| {
10860
+ json!({
10861
+ "identity": account.identity,
10862
+ "user_id": account.user_id,
10863
+ "provider": account.provider,
10864
+ "display_identity": account.display_identity,
10865
+ "origin": account.origin,
10866
+ "created_at": account.created_at,
10867
+ })
10868
+ })
10869
+ .collect();
10870
+ println!("{}", serde_json::to_string_pretty(&listed)?);
10871
+ }
10872
+ _ => return Err(usage.into()),
10407
10873
  }
10408
10874
  Ok(true)
10409
10875
  }
10410
10876
 
10877
+ /// Create `path` owner-only (0600) and write `secret` to it; refuses to
10878
+ /// overwrite, so a second bootstrap never silently replaces a delivered secret.
10879
+ fn write_secret_file(path: &std::path::Path, secret: &str) -> std::io::Result<()> {
10880
+ let mut options = OpenOptions::new();
10881
+ options.write(true).create_new(true);
10882
+ #[cfg(unix)]
10883
+ {
10884
+ use std::os::unix::fs::OpenOptionsExt;
10885
+ options.mode(0o600);
10886
+ }
10887
+ let mut file = options.open(path)?;
10888
+ file.write_all(secret.as_bytes())?;
10889
+ file.write_all(b"\n")?;
10890
+ file.sync_all()
10891
+ }
10892
+
10411
10893
  fn manage_authorization() -> Result<bool, Box<dyn std::error::Error>> {
10412
10894
  let arguments: Vec<String> = std::env::args().collect();
10413
10895
  let family = arguments.get(1).map(String::as_str).unwrap_or("");
@@ -10934,6 +11416,37 @@ fn manage_application_runtime() -> Result<bool, Box<dyn std::error::Error>> {
10934
11416
  Ok(true)
10935
11417
  }
10936
11418
 
11419
+ /// Where an online backup bundle may be written.
11420
+ ///
11421
+ /// With `FELTDB_BACKUP_DIR` set (the managed deployments set it), the request
11422
+ /// names a bundle, not a path: a single relative component placed inside that
11423
+ /// directory. Without it the server is self-hosted and the operator's path is
11424
+ /// used as given, as before.
11425
+ fn backup_output_path(
11426
+ requested: &str,
11427
+ backup_dir: Option<std::ffi::OsString>,
11428
+ ) -> Result<PathBuf, ApiError> {
11429
+ let Some(root) = backup_dir.filter(|value| !value.is_empty()) else {
11430
+ return Ok(PathBuf::from(requested));
11431
+ };
11432
+ let name = std::path::Path::new(requested);
11433
+ let mut components = name.components();
11434
+ let single = matches!(
11435
+ (components.next(), components.next()),
11436
+ (Some(std::path::Component::Normal(_)), None)
11437
+ );
11438
+ if !single {
11439
+ return Err(ApiError(
11440
+ StatusCode::BAD_REQUEST,
11441
+ format!(
11442
+ "backup output must be a bundle name, not a path: this server writes bundles inside FELTDB_BACKUP_DIR ({}); send e.g. {{\"output\":\"backup-2026-10-28\"}}",
11443
+ std::path::Path::new(&root).display()
11444
+ ),
11445
+ ));
11446
+ }
11447
+ Ok(PathBuf::from(root).join(name))
11448
+ }
11449
+
10937
11450
  async fn create_online_backup(
10938
11451
  State(state): State<AppState>,
10939
11452
  Json(request): Json<OnlineBackupRequest>,
@@ -10963,7 +11476,7 @@ async fn create_online_backup(
10963
11476
  })
10964
11477
  .cloned()
10965
11478
  .collect();
10966
- let output = PathBuf::from(request.output);
11479
+ let output = backup_output_path(&request.output, std::env::var_os("FELTDB_BACKUP_DIR"))?;
10967
11480
  let source = feltdb_server::backup::BackupSource {
10968
11481
  data: state
10969
11482
  .backup_artifacts
@@ -10975,14 +11488,22 @@ async fn create_online_backup(
10975
11488
  server_version: Some(env!("CARGO_PKG_VERSION").into()),
10976
11489
  durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
10977
11490
  };
11491
+ let records = operator_record_path(&state, BACKUP_RECORDS);
10978
11492
  let manifest = tokio::task::spawn_blocking(move || {
10979
- let _guard = guard;
10980
- feltdb_server::backup::create_bundle_with_source(
10981
- &output,
10982
- &artifacts,
10983
- &[("database.snapshot.json".to_string(), snapshot_bytes)],
10984
- source,
10985
- )
11493
+ let manifest = {
11494
+ let _guard = guard;
11495
+ feltdb_server::backup::create_bundle_with_source(
11496
+ &output,
11497
+ &artifacts,
11498
+ &[("database.snapshot.json".to_string(), snapshot_bytes)],
11499
+ source,
11500
+ )?
11501
+ };
11502
+ // Verified after the barrier is released: restoring into scratch and
11503
+ // opening the result is what turns "a bundle exists" into "a bundle
11504
+ // restores", and the record says which of the two is true.
11505
+ record_online_backup(&records, &output, &manifest);
11506
+ Ok::<_, std::io::Error>(manifest)
10986
11507
  })
10987
11508
  .await
10988
11509
  .map_err(|error| {
@@ -11006,28 +11527,258 @@ async fn create_online_backup(
11006
11527
  /// finding was that losing security evidence was invisible, and a signal an
11007
11528
  /// operator has to know to go and look for is not much better than no signal.
11008
11529
  /// It rides with the metrics they already read.
11009
- async fn network_metrics(State(state): State<AppState>) -> Json<Value> {
11010
- let mut body = serde_json::to_value(state.metrics.snapshot()).unwrap_or_else(|_| json!({}));
11011
- if let Some(fields) = body.as_object_mut() {
11012
- fields.insert(
11013
- "audit".to_string(),
11014
- serde_json::to_value(state.audit.health()).unwrap_or(Value::Null),
11015
- );
11016
- }
11017
- Json(body)
11530
+ const BACKUP_RECORDS: &str = "backups.json";
11531
+
11532
+ /// Operator records live beside the durable log (`<data>.<name>`), on the
11533
+ /// same volume, like the readiness probe they are derived from.
11534
+ fn operator_record_path(state: &AppState, name: &str) -> PathBuf {
11535
+ state.readiness_probe.with_extension(name)
11018
11536
  }
11019
11537
 
11020
- async fn sync_pull(
11021
- State(state): State<AppState>,
11022
- Json(request): Json<SyncPullRequest>,
11023
- ) -> Result<Json<SyncPullResponse>, ApiError> {
11024
- authorize_sync_member(&state, &request.requester, request.membership_epoch)?;
11025
- let peer_position = request.versions;
11026
- let authority_position = state.db.operation_versions()?;
11027
- let retained_from = state.db.retained_operation_floors()?;
11028
- let catchup = catchup_status(
11029
- request.requester,
11030
- peer_position.clone(),
11538
+ fn read_records(path: &std::path::Path) -> Vec<Value> {
11539
+ std::fs::read(path)
11540
+ .ok()
11541
+ .and_then(|bytes| serde_json::from_slice::<Vec<Value>>(&bytes).ok())
11542
+ .unwrap_or_default()
11543
+ }
11544
+
11545
+ /// Append one record; bounded so the file cannot grow without limit.
11546
+ fn append_record(path: &std::path::Path, record: Value) -> std::io::Result<()> {
11547
+ let mut records = read_records(path);
11548
+ records.push(record);
11549
+ let excess = records.len().saturating_sub(100);
11550
+ records.drain(..excess);
11551
+ let temporary = path.with_extension("json.tmp");
11552
+ let mut file = std::fs::File::create(&temporary)?;
11553
+ file.write_all(&serde_json::to_vec_pretty(&records).map_err(std::io::Error::other)?)?;
11554
+ file.sync_all()?;
11555
+ std::fs::rename(&temporary, path)
11556
+ }
11557
+
11558
+ fn record_online_backup(
11559
+ records: &std::path::Path,
11560
+ output: &std::path::Path,
11561
+ manifest: &feltdb_server::backup::BackupManifest,
11562
+ ) {
11563
+ let started = Instant::now();
11564
+ let restore = verify_restorable(output).map_err(|error| error.to_string());
11565
+ let record = json!({
11566
+ "kind": "online_backup",
11567
+ "bundle_id": feltdb_server::backup::backup_id(output).ok(),
11568
+ "output": output.display().to_string(),
11569
+ "created_ms": manifest.created_ms,
11570
+ "files": manifest.entries.len(),
11571
+ "verified": restore.is_ok(),
11572
+ "restore_verification": match &restore {
11573
+ Ok(detail) => json!({
11574
+ "method": "bundle verified, restored into a scratch directory on this host and opened",
11575
+ "verified_at_ms": unix_millis(),
11576
+ "duration_ms": started.elapsed().as_millis() as u64,
11577
+ "detail": detail,
11578
+ }),
11579
+ Err(error) => json!({ "method": "scratch restore", "error": error }),
11580
+ },
11581
+ });
11582
+ if let Err(error) = append_record(records, record) {
11583
+ tracing::error!(%error, "failed to record online backup");
11584
+ }
11585
+ }
11586
+
11587
+ fn unix_millis() -> u64 {
11588
+ std::time::SystemTime::now()
11589
+ .duration_since(std::time::UNIX_EPOCH)
11590
+ .unwrap_or_default()
11591
+ .as_millis() as u64
11592
+ }
11593
+
11594
+ /// The routes authenticated by a shared service secret rather than an API
11595
+ /// key. They are routed only when deliberately configured, so an authority
11596
+ /// that is not part of a managed Intent Engine deployment or a certification
11597
+ /// run has no such surface at all.
11598
+ fn service_secret_routes() -> Router<AppState> {
11599
+ let mut routes = Router::new();
11600
+ let intent_secret = std::env::var("FELTDB_INTENT_SERVICE_SECRET").unwrap_or_default();
11601
+ if intent_secret.len() >= 32 {
11602
+ routes = routes
11603
+ .route("/internal/intents/by-hash", get(internal_find_intent))
11604
+ .route(
11605
+ "/internal/intents",
11606
+ axum::routing::post(internal_create_intent),
11607
+ )
11608
+ .route("/internal/intents/{id}", put(internal_replace_intent))
11609
+ .route(
11610
+ "/internal/intent-events",
11611
+ axum::routing::post(internal_create_intent_event),
11612
+ );
11613
+ } else if !intent_secret.is_empty() {
11614
+ tracing::warn!("FELTDB_INTENT_SERVICE_SECRET is shorter than 32 bytes; /internal/intents* is not routed");
11615
+ }
11616
+ let certification = matches!(
11617
+ std::env::var("FELTDB_CERTIFICATION_MODE").as_deref(),
11618
+ Ok("1" | "true")
11619
+ );
11620
+ if certification {
11621
+ tracing::warn!("FELTDB_CERTIFICATION_MODE is set: certification fixture deletion is routed");
11622
+ routes = routes.route(
11623
+ "/api/certification/fixtures/{application_id}",
11624
+ axum::routing::delete(delete_certification_fixture),
11625
+ );
11626
+ }
11627
+ routes
11628
+ }
11629
+
11630
+ /// A real write → fsync → read-back probe of the storage volume.
11631
+ fn storage_probe(path: &std::path::Path) -> Value {
11632
+ let started = Instant::now();
11633
+ let token = format!("feltdb-status-probe-{}\n", uuid::Uuid::new_v4().simple());
11634
+ let mut wrote = false;
11635
+ let mut synced = false;
11636
+ let result = (|| -> std::io::Result<bool> {
11637
+ if let Some(parent) = path.parent() {
11638
+ std::fs::create_dir_all(parent)?;
11639
+ }
11640
+ let mut file = OpenOptions::new()
11641
+ .create(true)
11642
+ .write(true)
11643
+ .truncate(true)
11644
+ .open(path)?;
11645
+ file.write_all(token.as_bytes())?;
11646
+ wrote = true;
11647
+ file.sync_all()?;
11648
+ synced = true;
11649
+ Ok(std::fs::read(path)? == token.as_bytes())
11650
+ })();
11651
+ let read_back = matches!(result, Ok(true));
11652
+ json!({
11653
+ "ok": read_back,
11654
+ "write": wrote,
11655
+ "fsync": synced,
11656
+ "read_back": read_back,
11657
+ "checked_at_ms": unix_millis(),
11658
+ "latency_us": started.elapsed().as_micros() as u64,
11659
+ "error": result.err().map(|error| error.to_string()),
11660
+ })
11661
+ }
11662
+
11663
+ /// `GET /v1/status` — the operator's view, needs `operator:read`.
11664
+ ///
11665
+ /// Every field is observed by this request or read from a record this server
11666
+ /// wrote when the event happened; nothing is asserted. Availability (this
11667
+ /// process answers and its volume accepts a synced write) is reported apart
11668
+ /// from verified durability (a backup of this state was restored and opened).
11669
+ async fn operator_status(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
11670
+ let probe_path = operator_record_path(&state, "status-probe");
11671
+ let backups_path = operator_record_path(&state, BACKUP_RECORDS);
11672
+ let (probe, backups) = tokio::task::spawn_blocking(move || {
11673
+ (storage_probe(&probe_path), read_records(&backups_path))
11674
+ })
11675
+ .await
11676
+ .map_err(|error| {
11677
+ tracing::error!(%error, "status worker failed");
11678
+ ApiError(StatusCode::SERVICE_UNAVAILABLE, "status check temporarily unavailable".into())
11679
+ })?;
11680
+ let database = state.db.health();
11681
+ let last_backup = backups
11682
+ .iter()
11683
+ .rev()
11684
+ .find(|record| record["kind"] == "online_backup")
11685
+ .map(|record| {
11686
+ json!({
11687
+ "bundle_id": record["bundle_id"],
11688
+ "created_ms": record["created_ms"],
11689
+ "output": record["output"],
11690
+ "verified": record["verified"],
11691
+ })
11692
+ });
11693
+ let last_verified_restore = backups
11694
+ .iter()
11695
+ .rev()
11696
+ .find(|record| record["verified"] == true)
11697
+ .map(|record| {
11698
+ json!({
11699
+ "bundle_id": record["bundle_id"],
11700
+ "verified_at_ms": record["restore_verification"]["verified_at_ms"],
11701
+ "method": record["restore_verification"]["method"],
11702
+ })
11703
+ });
11704
+ let clock = state.lease_clock.is_healthy();
11705
+ let membership_healthy = !matches!(
11706
+ state.cluster.proposal().map(|value| value.phase),
11707
+ Some(ProposalPhase::Preparing | ProposalPhase::Prepared | ProposalPhase::Aborted)
11708
+ );
11709
+ let available = probe["ok"] == true;
11710
+ Ok(Json(json!({
11711
+ "status": if available && overall_status(membership_healthy, clock, &database.storage) == "healthy" { "available" } else { "degraded" },
11712
+ "release": {
11713
+ "version": env!("CARGO_PKG_VERSION"),
11714
+ "release_id": option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
11715
+ "git_commit": option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
11716
+ "build_timestamp": option_env!("FELTDB_BUILD_TIMESTAMP").unwrap_or("unknown"),
11717
+ "deployment_id": std::env::var("FELTDB_DEPLOYMENT_ID").or_else(|_| std::env::var("FLY_IMAGE_REF")).ok(),
11718
+ "promoted_revision": std::env::var("FELTDB_PROMOTED_REVISION").ok(),
11719
+ },
11720
+ "capabilities": {
11721
+ "public_contract": "feltdb.public.substrate@1.0",
11722
+ "capability_fingerprint": control_plane::public_contract_capability_fingerprint(),
11723
+ "protocol_version": PROTOCOL_VERSION,
11724
+ "authentication": state.auth_enabled,
11725
+ "autonomous_workers": state.worker_enabled,
11726
+ "configured_peers": state.cluster.get().peers.len(),
11727
+ "durable_work_authority": is_work_authority(&state),
11728
+ },
11729
+ "availability": {
11730
+ "serving": true,
11731
+ "storage_writable": available,
11732
+ "clock": if clock { "healthy" } else { "unhealthy" },
11733
+ "membership": if membership_healthy { "healthy" } else { "recovering-or-attention-required" },
11734
+ },
11735
+ "storage": {
11736
+ "replay": database.storage.label(),
11737
+ "durable_format": database.durable_format.to_string(),
11738
+ "probe": probe,
11739
+ },
11740
+ "backups": {
11741
+ "last": last_backup,
11742
+ "recorded": backups.len(),
11743
+ },
11744
+ "restore": {
11745
+ "last_verified": last_verified_restore,
11746
+ "status": if last_verified_restore.is_some() { "verified" } else { "never_verified" },
11747
+ "off_host": "not observable by this server",
11748
+ },
11749
+ "durability": {
11750
+ "verified": last_verified_restore.is_some(),
11751
+ "basis": if last_verified_restore.is_some() {
11752
+ "a backup of this authority was restored into scratch on this host and opened; off-host recovery and power-loss durability are not proven by this endpoint"
11753
+ } else {
11754
+ "no backup of this authority has been restored and verified; availability is not durability"
11755
+ },
11756
+ },
11757
+ })))
11758
+ }
11759
+
11760
+ async fn network_metrics(State(state): State<AppState>) -> Json<Value> {
11761
+ let mut body = serde_json::to_value(state.metrics.snapshot()).unwrap_or_else(|_| json!({}));
11762
+ if let Some(fields) = body.as_object_mut() {
11763
+ fields.insert(
11764
+ "audit".to_string(),
11765
+ serde_json::to_value(state.audit.health()).unwrap_or(Value::Null),
11766
+ );
11767
+ }
11768
+ Json(body)
11769
+ }
11770
+
11771
+ async fn sync_pull(
11772
+ State(state): State<AppState>,
11773
+ Json(request): Json<SyncPullRequest>,
11774
+ ) -> Result<Json<SyncPullResponse>, ApiError> {
11775
+ authorize_sync_member(&state, &request.requester, request.membership_epoch)?;
11776
+ let peer_position = request.versions;
11777
+ let authority_position = state.db.operation_versions()?;
11778
+ let retained_from = state.db.retained_operation_floors()?;
11779
+ let catchup = catchup_status(
11780
+ request.requester,
11781
+ peer_position.clone(),
11031
11782
  authority_position.clone(),
11032
11783
  retained_from,
11033
11784
  );
@@ -11048,6 +11799,7 @@ async fn sync_pull(
11048
11799
  operations,
11049
11800
  versions: authority_position,
11050
11801
  catchup,
11802
+ durable_work_authority: state.work_authority,
11051
11803
  }))
11052
11804
  }
11053
11805
 
@@ -11198,6 +11950,40 @@ fn start_autonomous_worker(state: &AppState) {
11198
11950
  });
11199
11951
  }
11200
11952
 
11953
+ /// Recover Durable Work whose lease expired, on every node, whether or not it
11954
+ /// runs the autonomous worker: external workers depend on it too.
11955
+ ///
11956
+ /// It is the mesh's claim recovery (`recover_expired_work`, the part of
11957
+ /// `detect_failures` that recovers work): expired work goes to
11958
+ /// `WorkloadStore::recover_expired`, bounded by each workload's attempt budget,
11959
+ /// and its claim is released from the worker that held it. Worker liveness —
11960
+ /// marking silent workers offline — is deliberately not run here; it would lock
11961
+ /// out registered workers that do not heartbeat on the mesh's schedule. It takes
11962
+ /// the mesh lock before the workload lock, the order the claim route uses.
11963
+ fn start_workload_recovery(state: &AppState) {
11964
+ let state = state.clone();
11965
+ tokio::spawn(async move {
11966
+ loop {
11967
+ if state.cluster.get().is_member && is_work_authority(&state) {
11968
+ let outcome = match (state.mesh.lock(), state.workloads.lock()) {
11969
+ // Deadlines first: work past one is timed out under its
11970
+ // own decision, not recovered as a lost worker.
11971
+ (Ok(mut mesh), Ok(mut workloads)) => mesh
11972
+ .enforce_deadlines(unix_seconds_i64(), &mut workloads)
11973
+ .and_then(|_| mesh.recover_expired_work(unix_seconds_i64(), &mut workloads))
11974
+ .map(|_| ())
11975
+ .map_err(|error| error.message),
11976
+ _ => Err("worker mesh or workload store unavailable".to_string()),
11977
+ };
11978
+ if let Err(error) = outcome {
11979
+ tracing::warn!(%error, "durable work recovery pass incomplete");
11980
+ }
11981
+ }
11982
+ tokio::time::sleep(Duration::from_secs(1)).await;
11983
+ }
11984
+ });
11985
+ }
11986
+
11201
11987
  fn start_membership_recovery(state: &AppState) {
11202
11988
  let state = state.clone();
11203
11989
  tokio::spawn(async move {
@@ -11218,13 +12004,18 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
11218
12004
  return Ok(());
11219
12005
  }
11220
12006
  let worker = state.db.instance_id()?;
12007
+ // One run's failure never stops the pass: each is recorded on its own run,
12008
+ // and an infrastructure fault is logged and retried on the next pass.
11221
12009
  for row in state.db.list_collection("_flow_workflow_runs")? {
12010
+ if row.value["status"] != "running" {
12011
+ continue;
12012
+ }
11222
12013
  let Some(steps) = row.value["steps"].as_array() else {
11223
12014
  continue;
11224
12015
  };
11225
12016
  let now = lease_now_ms(state)?;
11226
12017
  let Some(step) = steps.iter().find(|step| {
11227
- step["status"] == "ready"
12018
+ (step["status"] == "ready" && step["not_before_ms"].as_u64().unwrap_or(0) <= now)
11228
12019
  || (step["status"] == "running"
11229
12020
  && step["lease_expires_ms"].as_u64().unwrap_or(0) <= now)
11230
12021
  }) else {
@@ -11233,46 +12024,41 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
11233
12024
  let Some(step_id) = step["id"].as_str() else {
11234
12025
  continue;
11235
12026
  };
12027
+ // A step that names no capability here is an external step, claimed
12028
+ // by an external worker through the claim route.
11236
12029
  if !is_executable_capability(state, step_id)? {
11237
12030
  continue;
11238
12031
  }
11239
12032
  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?;
12033
+ let attempts = step["attempt"].as_u64().unwrap_or(0);
12034
+ if let Err(error) =
12035
+ execute_workflow_step(state, &row.value, &run_id, step_id, attempts, &worker).await
12036
+ {
12037
+ tracing::warn!(run = %run_id, step = %step_id, status = error.0.as_u16(), "workflow step attempt did not finish");
12038
+ }
11263
12039
  }
11264
12040
 
11265
12041
  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 {
12042
+ let now = lease_now_ms(state)?;
12043
+ let status = row.value["status"].as_str().unwrap_or_default();
12044
+ let lease_expired = row.value["lease_expires_ms"].as_u64().unwrap_or(0) <= now;
12045
+ if !agent_execution::agent_run_claimable(status, lease_expired)
12046
+ || row.value["not_before_ms"].as_u64().unwrap_or(0) > now
12047
+ {
11269
12048
  continue;
11270
12049
  }
11271
- execute_agent_run(state, &row.value, &worker).await?;
12050
+ if let Err(error) = execute_agent_run(state, &row.value, &worker).await {
12051
+ let run = row.value["id"].as_str().unwrap_or_default();
12052
+ tracing::warn!(%run, status = error.0.as_u16(), "agent run attempt did not finish");
12053
+ }
11272
12054
  }
11273
12055
 
11274
- evaluate_state_triggers(state)?;
11275
- execute_eligible_workloads(state, &worker)?;
12056
+ // Triggers admit work and the pass claims it: Durable Work writes, so
12057
+ // only on the authority.
12058
+ if is_work_authority(state) {
12059
+ evaluate_state_triggers(state)?;
12060
+ execute_eligible_workloads(state, &worker)?;
12061
+ }
11276
12062
  Ok(())
11277
12063
  }
11278
12064
 
@@ -11372,7 +12158,7 @@ fn worker_capabilities(state: &AppState) -> Result<BTreeSet<String>, ApiError> {
11372
12158
  fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiError> {
11373
12159
  let capabilities = worker_capabilities(state)?;
11374
12160
  let now = unix_seconds_i64();
11375
- let candidates: Vec<(String, String, Value)> = {
12161
+ let candidates: Vec<(String, String, Value, feltdb::workload::Workload)> = {
11376
12162
  let store = workload_store(state)?;
11377
12163
  store
11378
12164
  .workloads
@@ -11391,12 +12177,13 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
11391
12177
  workload.workload_id.clone(),
11392
12178
  workload.definition_id.clone(),
11393
12179
  workload.input.clone(),
12180
+ workload.clone(),
11394
12181
  )
11395
12182
  })
11396
12183
  .collect()
11397
12184
  };
11398
12185
 
11399
- for (id, definition, input) in candidates {
12186
+ for (id, definition, input, workload) in candidates {
11400
12187
  // Unlike the filter above, this one is enforcement: nothing downstream
11401
12188
  // knows whether `definition_id` names something runnable. Claiming a
11402
12189
  // workload only to dead-letter it would consume work another node could
@@ -11427,7 +12214,13 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
11427
12214
  )
11428
12215
  .map_err(workload_error)?;
11429
12216
 
11430
- match execute_named_capability(state, &definition, &input) {
12217
+ let access = CapabilityAccess::Workload {
12218
+ requested_by: &workload.requested_by,
12219
+ tenant_id: &workload.tenant_id,
12220
+ application_id: &workload.application_id,
12221
+ grant: &workload.authorization_snapshot,
12222
+ };
12223
+ match execute_named_capability(state, &definition, &input, &access) {
11431
12224
  Ok(output) => {
11432
12225
  workload_store(state)?
11433
12226
  .complete(
@@ -11464,6 +12257,157 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
11464
12257
  Ok(())
11465
12258
  }
11466
12259
 
12260
+ /// Debug builds only: `FELTDB_TEST_TRANSIENT_CAPABILITY=<name>[:<count>]` makes
12261
+ /// executing `<name>` fail with a server error — every time, or for its first
12262
+ /// `<count>` executions — so retry bounds can be exercised at the HTTP
12263
+ /// boundary. Compiled out of release builds, like `FELTDB_TEST_CLOCK_OFFSET_MS`.
12264
+ #[cfg(debug_assertions)]
12265
+ fn injected_capability_fault(name: &str) -> Option<ApiError> {
12266
+ static INJECTED: AtomicU64 = AtomicU64::new(0);
12267
+ let setting = std::env::var("FELTDB_TEST_TRANSIENT_CAPABILITY").ok()?;
12268
+ let (target, limit) = match setting.split_once(':') {
12269
+ Some((target, count)) => (target.to_string(), count.parse::<u64>().ok()),
12270
+ None => (setting, None),
12271
+ };
12272
+ if target != name {
12273
+ return None;
12274
+ }
12275
+ let injected = INJECTED.fetch_add(1, Ordering::SeqCst);
12276
+ if limit.is_some_and(|limit| injected >= limit) {
12277
+ return None;
12278
+ }
12279
+ Some(ApiError(
12280
+ StatusCode::SERVICE_UNAVAILABLE,
12281
+ "injected transient capability failure".into(),
12282
+ ))
12283
+ }
12284
+
12285
+ /// The authority a capability reads under.
12286
+ ///
12287
+ /// Every collection a capability touches is authorized against it in
12288
+ /// [`authorize_capability_read`], the single point all capability reads pass
12289
+ /// through, so no route, program step, workflow, or agent reaches state with
12290
+ /// more authority than the principal it acts for.
12291
+ enum CapabilityAccess<'a> {
12292
+ /// The request's own principal, on the direct capability routes.
12293
+ Principal(&'a Principal),
12294
+ /// An application actor on the `/v1` Service API.
12295
+ Application {
12296
+ actor: &'a str,
12297
+ application_id: &'a str,
12298
+ },
12299
+ /// A run's verified delegated authority, on the autonomous worker.
12300
+ Delegated(&'a RunPrincipal),
12301
+ /// A durable workload's signed authorization snapshot. Reads are allowed
12302
+ /// only when that grant, re-verified at execution, authorizes `state:read`
12303
+ /// for the workload's requester — so a workload never reads with the
12304
+ /// node's authority, and a row injected without a server-signed grant
12305
+ /// reads nothing.
12306
+ Workload {
12307
+ requested_by: &'a GrantSubject,
12308
+ tenant_id: &'a str,
12309
+ application_id: &'a str,
12310
+ grant: &'a feltdb::authorization::SignedGrant,
12311
+ },
12312
+ }
12313
+
12314
+ /// Whether a principal, described the way the authenticator describes it, holds
12315
+ /// `capability`. The same decision the request middleware makes.
12316
+ fn principal_grants(
12317
+ state: &AppState,
12318
+ kind: &str,
12319
+ id: &str,
12320
+ scopes: &[String],
12321
+ capability: &str,
12322
+ ) -> bool {
12323
+ let subject = if kind == "human" {
12324
+ Subject::User(id.to_string())
12325
+ } else {
12326
+ Subject::Service(id.to_string())
12327
+ };
12328
+ authorize(&AuthorizationRequest {
12329
+ subject: &subject,
12330
+ tenant_id: &state.namespace,
12331
+ application_id: Some(&state.namespace),
12332
+ capability,
12333
+ direct_grants: scopes,
12334
+ memberships: &[],
12335
+ roles: &[],
12336
+ })
12337
+ .allowed
12338
+ }
12339
+
12340
+ fn request_principal_grants(state: &AppState, principal: &Principal, capability: &str) -> bool {
12341
+ principal_grants(
12342
+ state,
12343
+ &principal.subject_type,
12344
+ &principal.key_id,
12345
+ &principal.scopes,
12346
+ capability,
12347
+ )
12348
+ }
12349
+
12350
+ fn delegated_grants(state: &AppState, principal: &RunPrincipal, capability: &str) -> bool {
12351
+ let scopes: Vec<String> = principal.scopes.iter().cloned().collect();
12352
+ principal_grants(state, &principal.kind, &principal.id, &scopes, capability)
12353
+ }
12354
+
12355
+ fn capability_read_denied(message: String) -> ApiError {
12356
+ ApiError::structured(
12357
+ StatusCode::FORBIDDEN,
12358
+ json!({"code": agent_execution::AUTHORIZATION_DENIED, "message": message}),
12359
+ )
12360
+ }
12361
+
12362
+ /// Authorize one collection read made by a capability.
12363
+ ///
12364
+ /// Reading records through a capability is reading state, so it needs the
12365
+ /// `state:read` a direct read needs. System collections (`_`-prefixed) hold
12366
+ /// runs, definitions and other server records and are never readable this way.
12367
+ fn authorize_capability_read(
12368
+ state: &AppState,
12369
+ access: &CapabilityAccess<'_>,
12370
+ collection: &str,
12371
+ ) -> Result<(), ApiError> {
12372
+ if agent_execution::is_system_collection(collection) {
12373
+ return Err(capability_read_denied(format!(
12374
+ "capabilities cannot read system collection {collection}"
12375
+ )));
12376
+ }
12377
+ let allowed = match access {
12378
+ CapabilityAccess::Principal(principal) => {
12379
+ request_principal_grants(state, principal, "state:read")
12380
+ }
12381
+ CapabilityAccess::Application {
12382
+ actor,
12383
+ application_id,
12384
+ } => application_scope(state, actor, application_id, "state:read").is_ok(),
12385
+ CapabilityAccess::Delegated(principal) => delegated_grants(state, principal, "state:read"),
12386
+ CapabilityAccess::Workload {
12387
+ requested_by,
12388
+ tenant_id,
12389
+ application_id,
12390
+ grant,
12391
+ } => workload_decision(
12392
+ state,
12393
+ (*requested_by).clone(),
12394
+ tenant_id,
12395
+ application_id,
12396
+ format!("flow://{application_id}/state/{collection}"),
12397
+ "state:read",
12398
+ grant,
12399
+ )
12400
+ .is_ok(),
12401
+ };
12402
+ if allowed {
12403
+ Ok(())
12404
+ } else {
12405
+ Err(capability_read_denied(format!(
12406
+ "the caller is not authorized to read {collection}"
12407
+ )))
12408
+ }
12409
+ }
12410
+
11467
12411
  fn is_builtin_capability(name: &str) -> bool {
11468
12412
  matches!(name, "search" | "identity")
11469
12413
  }
@@ -11480,6 +12424,7 @@ fn execute_builtin_capability(
11480
12424
  state: &AppState,
11481
12425
  name: &str,
11482
12426
  input: &Value,
12427
+ access: &CapabilityAccess<'_>,
11483
12428
  ) -> Result<Value, ApiError> {
11484
12429
  match name {
11485
12430
  "identity" => Ok(input.clone()),
@@ -11491,6 +12436,7 @@ fn execute_builtin_capability(
11491
12436
  )
11492
12437
  })?;
11493
12438
  validate_segment(collection)?;
12439
+ authorize_capability_read(state, access, collection)?;
11494
12440
  let needle = input["query"].as_str().unwrap_or_default().to_lowercase();
11495
12441
  let limit = input["limit"].as_u64().unwrap_or(50).min(500) as usize;
11496
12442
  Ok(Value::Array(
@@ -11511,80 +12457,369 @@ fn execute_builtin_capability(
11511
12457
  }
11512
12458
  }
11513
12459
 
12460
+ /// The text of an [`ApiError`] without its wire envelope: the `message` of a
12461
+ /// structured error, or the plain message.
12462
+ fn api_error_message(error: &ApiError) -> String {
12463
+ serde_json::from_str::<Value>(&error.1)
12464
+ .ok()
12465
+ .and_then(|value| value["message"].as_str().map(str::to_string))
12466
+ .unwrap_or_else(|| error.1.clone())
12467
+ }
12468
+
12469
+ /// Everything an agent run needs before the worker may lease it.
12470
+ ///
12471
+ /// Checked on every attempt, so authority revoked after the run was created,
12472
+ /// a definition removed, or a capability unregistered ends the run here,
12473
+ /// before anything executes.
12474
+ fn prepare_agent_run(
12475
+ state: &AppState,
12476
+ run: &Value,
12477
+ id: &str,
12478
+ attempt: u64,
12479
+ ) -> Result<(String, RunPrincipal, Option<CompletionCriteria>), RunFailure> {
12480
+ if attempt > agent_execution::max_run_attempts() {
12481
+ return Err(RunFailure::permanent(
12482
+ agent_execution::RETRY_EXHAUSTED,
12483
+ format!(
12484
+ "run was claimed {} times without finishing",
12485
+ attempt - 1
12486
+ ),
12487
+ ));
12488
+ }
12489
+ let principal = resolve_run_principal(state, run, RunKind::Agent, id)?;
12490
+ if !delegated_grants(state, &principal, "agents:run") {
12491
+ return Err(RunFailure::permanent(
12492
+ agent_execution::AUTHORIZATION_DENIED,
12493
+ "the run's principal may not run agents",
12494
+ ));
12495
+ }
12496
+ let agent = run["agent"].as_str().unwrap_or_default();
12497
+ let definition = match validate_segment(agent)
12498
+ .and_then(|_| required_value(state, "_flow_agents", agent, "agent not found"))
12499
+ {
12500
+ Ok(definition) => definition,
12501
+ Err(_) => {
12502
+ return Err(RunFailure::permanent(
12503
+ agent_execution::AGENT_NOT_FOUND,
12504
+ "the run's agent is not defined",
12505
+ ))
12506
+ }
12507
+ };
12508
+ let capability = agent_capability(state, &definition)?;
12509
+ let criteria = match &run["completion"] {
12510
+ Value::Null => None,
12511
+ value => Some(
12512
+ serde_json::from_value::<CompletionCriteria>(value.clone())
12513
+ .ok()
12514
+ .filter(|criteria| criteria.validate().is_ok())
12515
+ .ok_or_else(|| {
12516
+ RunFailure::permanent(
12517
+ agent_execution::RUN_INVALID,
12518
+ "the run's completion criteria are invalid",
12519
+ )
12520
+ })?,
12521
+ ),
12522
+ };
12523
+ Ok((capability, principal, criteria))
12524
+ }
12525
+
12526
+ fn mark_agent_run_failed(run: &mut Value, failure: &RunFailure, attempt: u64) {
12527
+ run["status"] = json!(agent_execution::STATUS_FAILED);
12528
+ run["failure"] = failure.to_record(attempt);
12529
+ run["attempt"] = json!(attempt);
12530
+ }
12531
+
12532
+ /// Execute one attempt of an agent run.
12533
+ ///
12534
+ /// The run completes only when its declared criteria hold for the result this
12535
+ /// worker observed. Every other outcome is explicit: a permanent error fails
12536
+ /// the run, a transient one returns it to `ready` after a backoff until the
12537
+ /// attempt bound, and a run without criteria fails as unverifiable. An `Err`
12538
+ /// is an infrastructure fault that left the run untouched.
11514
12539
  async fn execute_agent_run(state: &AppState, run: &Value, worker: &str) -> Result<(), ApiError> {
11515
12540
  let id = run["id"]
11516
12541
  .as_str()
11517
12542
  .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
- {
12543
+ let (capability, principal, criteria, claim_id, attempt, input) = {
11543
12544
  let _guard = state.lifecycle_lock.lock().await;
11544
- reserve_quorum_lease(state, &format!("agent-{id}"), &lease).await?;
11545
12545
  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 {
12546
+ let now = lease_now_ms(state)?;
12547
+ let status = current["status"].as_str().unwrap_or_default();
12548
+ let lease_expired = current["lease_expires_ms"].as_u64().unwrap_or(0) <= now;
12549
+ if !agent_execution::agent_run_claimable(status, lease_expired)
12550
+ || current["not_before_ms"].as_u64().unwrap_or(0) > now
12551
+ {
11549
12552
  return Ok(());
11550
12553
  }
11551
- current["status"] = json!("running");
12554
+ let attempt = current["attempt"].as_u64().unwrap_or(0) + 1;
12555
+ let (capability, principal, criteria) =
12556
+ match prepare_agent_run(state, &current, id, attempt) {
12557
+ Ok(prepared) => prepared,
12558
+ Err(failure) => {
12559
+ mark_agent_run_failed(&mut current, &failure, attempt);
12560
+ put_canonical(state, "_flow_agent_runs", id, current)?;
12561
+ return Ok(());
12562
+ }
12563
+ };
12564
+ let claim_id = next_id(state, "claim");
12565
+ let expires_ms = now.saturating_add(30_000);
12566
+ let lease = LeaseRequest {
12567
+ claim_id: claim_id.clone(),
12568
+ owner: worker.to_string(),
12569
+ expires_ms,
12570
+ membership_epoch: state.cluster.get().epoch,
12571
+ };
12572
+ reserve_quorum_lease(state, &format!("agent-{id}"), &lease).await?;
12573
+ current["status"] = json!(agent_execution::STATUS_RUNNING);
11552
12574
  current["worker"] = json!(worker);
11553
12575
  current["claim_id"] = json!(claim_id);
11554
12576
  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"])?;
12577
+ current["attempt"] = json!(attempt);
12578
+ let input = current["input"].clone();
12579
+ put_canonical(state, "_flow_agent_runs", id, current)?;
12580
+ (capability, principal, criteria, claim_id, attempt, input)
12581
+ };
12582
+
12583
+ let outcome = execute_named_capability(
12584
+ state,
12585
+ &capability,
12586
+ &input,
12587
+ &CapabilityAccess::Delegated(&principal),
12588
+ );
12589
+
11558
12590
  let _guard = state.lifecycle_lock.lock().await;
11559
12591
  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)?
12592
+ let now = lease_now_ms(state)?;
12593
+ // Publish only while this claim still owns the run. A claim that lost its
12594
+ // lease publishes nothing; the next claim counts as another attempt.
12595
+ if current["claim_id"] != claim_id.as_str()
12596
+ || current["status"] != agent_execution::STATUS_RUNNING
12597
+ || current["lease_expires_ms"].as_u64().unwrap_or(0) <= now
11562
12598
  {
11563
- return Err(ApiError(
11564
- StatusCode::CONFLICT,
11565
- "agent ownership expired before result publication".to_string(),
11566
- ));
12599
+ return Ok(());
12600
+ }
12601
+ match outcome {
12602
+ Ok(result) => {
12603
+ let satisfied = criteria
12604
+ .as_ref()
12605
+ .map(|criteria| criteria.satisfied_by(&result));
12606
+ let digest = format!(
12607
+ "sha256:{:x}",
12608
+ Sha256::digest(serde_json::to_vec(&result).unwrap_or_default())
12609
+ );
12610
+ let evidence = json!({
12611
+ "source": "runtime", "attempt": attempt, "claim_id": claim_id,
12612
+ "capability": capability, "result_items": result.as_array().map(Vec::len),
12613
+ "result_digest": digest, "criteria": criteria, "satisfied": satisfied,
12614
+ "observed_at_ms": now,
12615
+ });
12616
+ for (field, value) in [
12617
+ ("observations", result.clone()),
12618
+ (
12619
+ "decisions",
12620
+ json!({"capability": capability, "result": result, "source": "runtime"}),
12621
+ ),
12622
+ ("evidence", evidence),
12623
+ ] {
12624
+ if !current[field].is_array() {
12625
+ current[field] = json!([]);
12626
+ }
12627
+ current[field]
12628
+ .as_array_mut()
12629
+ .expect("normalized to an array")
12630
+ .push(value);
12631
+ }
12632
+ match satisfied {
12633
+ Some(true) => {
12634
+ current["status"] = json!(agent_execution::STATUS_COMPLETED);
12635
+ current["completed_at_ms"] = json!(now);
12636
+ }
12637
+ Some(false) => mark_agent_run_failed(
12638
+ &mut current,
12639
+ &RunFailure::permanent(
12640
+ agent_execution::COMPLETION_CRITERIA_UNMET,
12641
+ "the observed result does not satisfy the run's completion criteria",
12642
+ ),
12643
+ attempt,
12644
+ ),
12645
+ None => mark_agent_run_failed(
12646
+ &mut current,
12647
+ &RunFailure::permanent(
12648
+ agent_execution::COMPLETION_UNVERIFIABLE,
12649
+ "the run declares no completion criteria the runtime can verify; its goal text is not evaluated",
12650
+ ),
12651
+ attempt,
12652
+ ),
12653
+ }
12654
+ }
12655
+ Err(error) => {
12656
+ let failure = agent_execution::classify_capability_error(
12657
+ error.0.as_u16(),
12658
+ &api_error_message(&error),
12659
+ );
12660
+ if failure.retryable && attempt < agent_execution::max_run_attempts() {
12661
+ // The failed attempt's lease stays reserved until it lapses, so
12662
+ // the retry waits for that as well as for the backoff: a new
12663
+ // claim before then would be refused by the lease quorum.
12664
+ let lease_expires = current["lease_expires_ms"].as_u64().unwrap_or(0);
12665
+ current["status"] = json!(agent_execution::STATUS_READY);
12666
+ current["not_before_ms"] = json!(now
12667
+ .saturating_add(agent_execution::retry_delay_ms(attempt + 1))
12668
+ .max(lease_expires));
12669
+ current["last_failure"] = failure.to_record(attempt);
12670
+ } else if failure.retryable {
12671
+ mark_agent_run_failed(
12672
+ &mut current,
12673
+ &RunFailure::permanent(
12674
+ agent_execution::RETRY_EXHAUSTED,
12675
+ format!("{} after {attempt} attempts", failure.message),
12676
+ ),
12677
+ attempt,
12678
+ );
12679
+ } else {
12680
+ mark_agent_run_failed(&mut current, &failure, attempt);
12681
+ }
12682
+ }
11567
12683
  }
11568
- current["observations"]
12684
+ put_canonical(state, "_flow_agent_runs", id, current)?;
12685
+ Ok(())
12686
+ }
12687
+
12688
+ /// Fail a workflow step and its run. `claim_id`, when given, must still own
12689
+ /// the step; otherwise another claim has taken over and nothing changes.
12690
+ async fn fail_workflow_step(
12691
+ state: &AppState,
12692
+ run_id: &str,
12693
+ step_id: &str,
12694
+ claim_id: Option<&str>,
12695
+ failure: &RunFailure,
12696
+ ) -> Result<(), ApiError> {
12697
+ let _guard = state.lifecycle_lock.lock().await;
12698
+ let mut run = required_value(state, "_flow_workflow_runs", run_id, "workflow run not found")?;
12699
+ if run["status"] != "running" {
12700
+ return Ok(());
12701
+ }
12702
+ let Some(step) = run["steps"]
11569
12703
  .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"]
12704
+ .and_then(|steps| steps.iter_mut().find(|step| step["id"] == step_id))
12705
+ else {
12706
+ return Ok(());
12707
+ };
12708
+ if claim_id.is_some_and(|claim| step["claim_id"] != claim) {
12709
+ return Ok(());
12710
+ }
12711
+ let attempt = step["attempt"].as_u64().unwrap_or(0);
12712
+ step["status"] = json!(agent_execution::STATUS_FAILED);
12713
+ step["failure"] = failure.to_record(attempt);
12714
+ run["status"] = json!(agent_execution::STATUS_FAILED);
12715
+ run["failure"] = json!({"step": step_id, "code": failure.code, "message": failure.message});
12716
+ put_canonical(state, "_flow_workflow_runs", run_id, run)?;
12717
+ Ok(())
12718
+ }
12719
+
12720
+ /// Return a step that failed transiently to `ready` after a backoff.
12721
+ async fn release_workflow_step(
12722
+ state: &AppState,
12723
+ run_id: &str,
12724
+ step_id: &str,
12725
+ claim_id: &str,
12726
+ failure: &RunFailure,
12727
+ ) -> Result<(), ApiError> {
12728
+ let _guard = state.lifecycle_lock.lock().await;
12729
+ let mut run = required_value(state, "_flow_workflow_runs", run_id, "workflow run not found")?;
12730
+ let now = lease_now_ms(state)?;
12731
+ let Some(step) = run["steps"]
11578
12732
  .as_array_mut()
12733
+ .and_then(|steps| steps.iter_mut().find(|step| step["id"] == step_id))
12734
+ else {
12735
+ return Ok(());
12736
+ };
12737
+ if step["claim_id"] != claim_id || step["status"] != "running" {
12738
+ return Ok(());
12739
+ }
12740
+ let attempt = step["attempt"].as_u64().unwrap_or(0);
12741
+ let lease_expires = step["lease_expires_ms"].as_u64().unwrap_or(0);
12742
+ step["status"] = json!(agent_execution::STATUS_READY);
12743
+ // Not before the failed attempt's lease lapses: until then the lease
12744
+ // quorum refuses any other claim.
12745
+ step["not_before_ms"] = json!(now
12746
+ .saturating_add(agent_execution::retry_delay_ms(attempt + 1))
12747
+ .max(lease_expires));
12748
+ step["last_failure"] = failure.to_record(attempt);
12749
+ put_canonical(state, "_flow_workflow_runs", run_id, run)?;
12750
+ Ok(())
12751
+ }
12752
+
12753
+ /// Execute one autonomous workflow step under the run's delegated authority.
12754
+ async fn execute_workflow_step(
12755
+ state: &AppState,
12756
+ run: &Value,
12757
+ run_id: &str,
12758
+ step_id: &str,
12759
+ attempts: u64,
12760
+ worker: &str,
12761
+ ) -> Result<(), ApiError> {
12762
+ if attempts >= agent_execution::max_run_attempts() {
12763
+ let failure = RunFailure::permanent(
12764
+ agent_execution::RETRY_EXHAUSTED,
12765
+ format!("step was claimed {attempts} times without finishing"),
12766
+ );
12767
+ return fail_workflow_step(state, run_id, step_id, None, &failure).await;
12768
+ }
12769
+ let principal = match resolve_run_principal(state, run, RunKind::Workflow, run_id) {
12770
+ Ok(principal) if delegated_grants(state, &principal, "workflows:run") => principal,
12771
+ Ok(_) => {
12772
+ let failure = RunFailure::permanent(
12773
+ agent_execution::AUTHORIZATION_DENIED,
12774
+ "the run's principal may not run workflows",
12775
+ );
12776
+ return fail_workflow_step(state, run_id, step_id, None, &failure).await;
12777
+ }
12778
+ Err(failure) => return fail_workflow_step(state, run_id, step_id, None, &failure).await,
12779
+ };
12780
+ let claimed = transition_workflow_step(
12781
+ state,
12782
+ run_id,
12783
+ step_id,
12784
+ Some((worker.to_string(), 30_000)),
12785
+ None,
12786
+ )
12787
+ .await?;
12788
+ let claim_id = claimed.0.value["steps"]
12789
+ .as_array()
12790
+ .and_then(|steps| steps.iter().find(|step| step["id"] == step_id))
12791
+ .and_then(|step| step["claim_id"].as_str())
11579
12792
  .ok_or_else(|| {
11580
12793
  ApiError(
11581
12794
  StatusCode::CONFLICT,
11582
- "agent decisions are invalid".to_string(),
12795
+ "worker claim was not materialized".to_string(),
11583
12796
  )
11584
12797
  })?
11585
- .push(json!({"capability": capability, "result": result}));
11586
- current["status"] = json!("completed");
11587
- let _ = put_canonical(state, "_flow_agent_runs", id, current)?;
12798
+ .to_string();
12799
+ let input = run["input"].clone();
12800
+ match execute_named_capability(state, step_id, &input, &CapabilityAccess::Delegated(&principal))
12801
+ {
12802
+ Ok(result) => {
12803
+ transition_workflow_step(state, run_id, step_id, None, Some((claim_id, result))).await?;
12804
+ }
12805
+ Err(error) => {
12806
+ let failure = agent_execution::classify_capability_error(
12807
+ error.0.as_u16(),
12808
+ &api_error_message(&error),
12809
+ );
12810
+ if failure.retryable && attempts + 1 < agent_execution::max_run_attempts() {
12811
+ release_workflow_step(state, run_id, step_id, &claim_id, &failure).await?;
12812
+ } else if failure.retryable {
12813
+ let exhausted = RunFailure::permanent(
12814
+ agent_execution::RETRY_EXHAUSTED,
12815
+ format!("{} after {} attempts", failure.message, attempts + 1),
12816
+ );
12817
+ fail_workflow_step(state, run_id, step_id, Some(&claim_id), &exhausted).await?;
12818
+ } else {
12819
+ fail_workflow_step(state, run_id, step_id, Some(&claim_id), &failure).await?;
12820
+ }
12821
+ }
12822
+ }
11588
12823
  Ok(())
11589
12824
  }
11590
12825
 
@@ -11638,6 +12873,17 @@ async fn sync_with_peer(
11638
12873
  .json::<SyncPullResponse>()
11639
12874
  .await?;
11640
12875
 
12876
+ // Two nodes configured as the Durable Work authority would each commit
12877
+ // work the other cannot see in time; both refuse until one is restarted
12878
+ // without `--work-authority`.
12879
+ if state.work_authority && pull.durable_work_authority {
12880
+ if !state
12881
+ .work_authority_conflict
12882
+ .swap(true, std::sync::atomic::Ordering::SeqCst)
12883
+ {
12884
+ tracing::error!(%peer, "two nodes are configured as the Durable Work authority; refusing Durable Work writes");
12885
+ }
12886
+ }
11641
12887
  let mut incoming = pull.operations;
11642
12888
  incoming.sort_by(|left, right| {
11643
12889
  left.instance_id
@@ -12563,10 +13809,12 @@ async fn get_provenance(
12563
13809
 
12564
13810
  async fn search_collection(
12565
13811
  State(state): State<AppState>,
13812
+ Extension(principal): Extension<Principal>,
12566
13813
  Path(collection): Path<String>,
12567
13814
  Json(request): Json<SearchRequest>,
12568
13815
  ) -> Result<Json<Vec<RecordResponse>>, ApiError> {
12569
13816
  validate_segment(&collection)?;
13817
+ authorize_capability_read(&state, &CapabilityAccess::Principal(&principal), &collection)?;
12570
13818
  if request.query.len() > 512 || request.limit == 0 || request.limit > 500 {
12571
13819
  return Err(ApiError(
12572
13820
  StatusCode::BAD_REQUEST,
@@ -12616,7 +13864,12 @@ async fn execute_capability(
12616
13864
  ));
12617
13865
  }
12618
13866
  validate_segment(&name)?;
12619
- Ok(Json(execute_named_capability(&state, &name, &input)?))
13867
+ Ok(Json(execute_named_capability(
13868
+ &state,
13869
+ &name,
13870
+ &input,
13871
+ &CapabilityAccess::Principal(&principal),
13872
+ )?))
12620
13873
  }
12621
13874
 
12622
13875
  async fn execute_application_capability(
@@ -12663,7 +13916,15 @@ async fn execute_application_capability(
12663
13916
  "capability is not externally invocable".into(),
12664
13917
  ));
12665
13918
  }
12666
- Ok(Json(execute_named_capability(&state, &name, &payload)?))
13919
+ Ok(Json(execute_named_capability(
13920
+ &state,
13921
+ &name,
13922
+ &payload,
13923
+ &CapabilityAccess::Application {
13924
+ actor: &principal.key_id,
13925
+ application_id: &input.application_id,
13926
+ },
13927
+ )?))
12667
13928
  }
12668
13929
 
12669
13930
  fn validate_capability_program(program: &CapabilityProgram) -> Result<(), ApiError> {
@@ -12722,9 +13983,14 @@ fn execute_named_capability(
12722
13983
  state: &AppState,
12723
13984
  name: &str,
12724
13985
  input: &Value,
13986
+ access: &CapabilityAccess<'_>,
12725
13987
  ) -> Result<Value, ApiError> {
13988
+ #[cfg(debug_assertions)]
13989
+ if let Some(fault) = injected_capability_fault(name) {
13990
+ return Err(fault);
13991
+ }
12726
13992
  if is_builtin_capability(name) {
12727
- return execute_builtin_capability(state, name, input);
13993
+ return execute_builtin_capability(state, name, input, access);
12728
13994
  }
12729
13995
  let value = required_value(state, "_flow_capabilities", name, "capability not found")?;
12730
13996
  let program: CapabilityProgram = serde_json::from_value(value).map_err(|error| {
@@ -12758,6 +14024,7 @@ fn execute_named_capability(
12758
14024
  state,
12759
14025
  "search",
12760
14026
  &json!({"collection": collection, "query": query, "limit": limit.unwrap_or(50)}),
14027
+ access,
12761
14028
  )?
12762
14029
  }
12763
14030
  CapabilityInstruction::FilterEq {
@@ -12873,12 +14140,14 @@ async fn start_workflow_run(
12873
14140
  let step_states: Vec<Value> = steps.iter().enumerate().map(|(index, step)| json!({
12874
14141
  "id": step, "status": if index == 0 { "ready" } else { "pending" }, "worker": null, "result": null
12875
14142
  })).collect();
14143
+ let (authority, attribution) = run_authority(&state, RunKind::Workflow, &id, &principal)?;
12876
14144
  let response = insert_canonical(
12877
14145
  &state,
12878
14146
  "_flow_workflow_runs",
12879
14147
  &id,
12880
14148
  json!({
12881
- "id": id, "workflow": name, "status": "running", "input": request.input, "steps": step_states
14149
+ "id": id, "workflow": name, "status": "running", "input": request.input, "steps": step_states,
14150
+ "authority": authority, "principal": attribution,
12882
14151
  }),
12883
14152
  )?;
12884
14153
  Ok((StatusCode::CREATED, Json(response)))
@@ -12886,9 +14155,12 @@ async fn start_workflow_run(
12886
14155
 
12887
14156
  async fn get_workflow_run(
12888
14157
  State(state): State<AppState>,
14158
+ Extension(principal): Extension<Principal>,
12889
14159
  Path(id): Path<String>,
12890
14160
  ) -> Result<Json<RecordResponse>, ApiError> {
12891
- get_canonical(&state, "_flow_workflow_runs", &id, "workflow run not found")
14161
+ let mut response = get_canonical(&state, "_flow_workflow_runs", &id, "workflow run not found")?;
14162
+ redact_run_results(&state, &principal, &mut response.0.value);
14163
+ Ok(response)
12892
14164
  }
12893
14165
 
12894
14166
  async fn claim_workflow_step(
@@ -12940,6 +14212,12 @@ async fn transition_workflow_step(
12940
14212
  validate_segment(step)?;
12941
14213
  let _guard = state.lifecycle_lock.lock().await;
12942
14214
  let mut run = required_value(state, "_flow_workflow_runs", id, "workflow run not found")?;
14215
+ if run["status"] == agent_execution::STATUS_FAILED {
14216
+ return Err(ApiError::structured(
14217
+ StatusCode::CONFLICT,
14218
+ json!({"code": "WORKFLOW_RUN_TERMINAL", "message": "workflow run has failed"}),
14219
+ ));
14220
+ }
12943
14221
  let steps = run["steps"]
12944
14222
  .as_array_mut()
12945
14223
  .ok_or_else(|| ApiError(StatusCode::CONFLICT, "workflow run is invalid".to_string()))?;
@@ -12968,10 +14246,12 @@ async fn transition_workflow_step(
12968
14246
  membership_epoch: state.cluster.get().epoch,
12969
14247
  };
12970
14248
  reserve_quorum_lease(state, &resource, &lease).await?;
14249
+ let attempt = steps[index]["attempt"].as_u64().unwrap_or(0) + 1;
12971
14250
  steps[index]["status"] = json!("running");
12972
14251
  steps[index]["worker"] = json!(worker);
12973
14252
  steps[index]["claim_id"] = json!(claim_id);
12974
14253
  steps[index]["lease_expires_ms"] = json!(expires_ms);
14254
+ steps[index]["attempt"] = json!(attempt);
12975
14255
  } else {
12976
14256
  if status != "running" {
12977
14257
  return Err(ApiError(
@@ -13029,31 +14309,169 @@ async fn start_agent_run(
13029
14309
  ));
13030
14310
  }
13031
14311
  validate_segment(&name)?;
13032
- required_value(&state, "_flow_agents", &name, "agent not found")?;
14312
+ let definition = required_value(&state, "_flow_agents", &name, "agent not found")?;
13033
14313
  if request.goal.trim().is_empty() {
13034
14314
  return Err(ApiError(
13035
14315
  StatusCode::BAD_REQUEST,
13036
14316
  "agent goal is required".to_string(),
13037
14317
  ));
13038
14318
  }
14319
+ if let Some(criteria) = &request.completion {
14320
+ criteria.validate().map_err(|message| {
14321
+ ApiError::structured(
14322
+ StatusCode::BAD_REQUEST,
14323
+ json!({"code": "COMPLETION_CRITERIA_INVALID", "message": message}),
14324
+ )
14325
+ })?;
14326
+ }
14327
+ // Refuse a run that could only ever fail, instead of creating one.
14328
+ agent_capability(&state, &definition).map_err(|failure| {
14329
+ ApiError::structured(
14330
+ StatusCode::UNPROCESSABLE_ENTITY,
14331
+ json!({"code": failure.code, "message": failure.message}),
14332
+ )
14333
+ })?;
13039
14334
  let id = next_id(&state, "agent");
14335
+ let (authority, attribution) = run_authority(&state, RunKind::Agent, &id, &principal)?;
13040
14336
  let response = insert_canonical(
13041
14337
  &state,
13042
14338
  "_flow_agent_runs",
13043
14339
  &id,
13044
14340
  json!({
13045
14341
  "id": id, "agent": name, "goal": request.goal, "input": request.input,
13046
- "status": "ready", "observations": [], "decisions": []
14342
+ "status": agent_execution::STATUS_READY, "observations": [], "decisions": [],
14343
+ "completion": request.completion, "evidence": [], "attempt": 0,
14344
+ "authority": authority, "principal": attribution,
13047
14345
  }),
13048
14346
  )?;
13049
14347
  Ok((StatusCode::CREATED, Json(response)))
13050
14348
  }
13051
14349
 
14350
+ /// Sign the requesting principal's authority into a new run, and the
14351
+ /// attribution recorded beside it.
14352
+ fn run_authority(
14353
+ state: &AppState,
14354
+ kind: RunKind,
14355
+ run_id: &str,
14356
+ principal: &Principal,
14357
+ ) -> Result<(Value, Value), ApiError> {
14358
+ let run_principal = RunPrincipal {
14359
+ id: principal.key_id.clone(),
14360
+ kind: principal.subject_type.clone(),
14361
+ scopes: principal.scopes.iter().cloned().collect(),
14362
+ };
14363
+ let grant = agent_execution::issue_run_authority(
14364
+ &state.grant_signer,
14365
+ kind,
14366
+ run_id,
14367
+ &state.namespace,
14368
+ &run_principal,
14369
+ unix_seconds_i64(),
14370
+ )
14371
+ .map_err(|message| ApiError(StatusCode::INTERNAL_SERVER_ERROR, message))?;
14372
+ let authority = serde_json::to_value(grant)
14373
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
14374
+ Ok((
14375
+ authority,
14376
+ json!({"id": principal.key_id, "kind": principal.subject_type}),
14377
+ ))
14378
+ }
14379
+
14380
+ /// The capability an agent run executes: the first one its definition
14381
+ /// declares. It must be executable now; one that is not is a permanent error,
14382
+ /// never a run left waiting for a capability that does not exist.
14383
+ fn agent_capability(state: &AppState, definition: &Value) -> Result<String, RunFailure> {
14384
+ let Some(name) = definition["capabilities"]
14385
+ .as_array()
14386
+ .and_then(|values| values.first())
14387
+ .and_then(Value::as_str)
14388
+ else {
14389
+ return Err(RunFailure::permanent(
14390
+ agent_execution::CAPABILITY_UNKNOWN,
14391
+ "agent declares no capability",
14392
+ ));
14393
+ };
14394
+ match is_executable_capability(state, name) {
14395
+ Ok(true) => Ok(name.to_string()),
14396
+ _ => Err(RunFailure::permanent(
14397
+ agent_execution::CAPABILITY_UNKNOWN,
14398
+ format!("capability {name} is not defined on this node"),
14399
+ )),
14400
+ }
14401
+ }
14402
+
14403
+ /// The principal a run may act for at execution time.
14404
+ ///
14405
+ /// The signed authority binds scopes to the run. When authentication is on,
14406
+ /// the key is also re-read, and only scopes it still holds remain: a revoked
14407
+ /// key acts for nothing, and a narrowed key never acts with its old breadth.
14408
+ fn resolve_run_principal(
14409
+ state: &AppState,
14410
+ run: &Value,
14411
+ kind: RunKind,
14412
+ run_id: &str,
14413
+ ) -> Result<RunPrincipal, RunFailure> {
14414
+ let mut principal = agent_execution::verify_run_authority(
14415
+ &state.grant_signer,
14416
+ &run["authority"],
14417
+ kind,
14418
+ run_id,
14419
+ &state.namespace,
14420
+ unix_seconds_i64(),
14421
+ )?;
14422
+ if state.auth_enabled && principal.kind != "human" {
14423
+ let Some((current, current_kind)) = state.keys.active_scopes(&principal.id, &state.namespace)
14424
+ else {
14425
+ return Err(RunFailure::permanent(
14426
+ agent_execution::AUTHORIZATION_DENIED,
14427
+ "the run's principal is no longer active",
14428
+ ));
14429
+ };
14430
+ if current_kind != principal.kind {
14431
+ return Err(RunFailure::permanent(
14432
+ agent_execution::AUTHORIZATION_DENIED,
14433
+ "the run's principal changed type",
14434
+ ));
14435
+ }
14436
+ principal.scopes =
14437
+ agent_execution::effective_scopes(&principal.scopes, &current.into_iter().collect());
14438
+ }
14439
+ Ok(principal)
14440
+ }
14441
+
13052
14442
  async fn get_agent_run(
13053
14443
  State(state): State<AppState>,
14444
+ Extension(principal): Extension<Principal>,
13054
14445
  Path(id): Path<String>,
13055
14446
  ) -> Result<Json<RecordResponse>, ApiError> {
13056
- get_canonical(&state, "_flow_agent_runs", &id, "agent run not found")
14447
+ let mut response = get_canonical(&state, "_flow_agent_runs", &id, "agent run not found")?;
14448
+ redact_run_results(&state, &principal, &mut response.0.value);
14449
+ Ok(response)
14450
+ }
14451
+
14452
+ /// Remove the state a run read from a response to a reader who may not read
14453
+ /// state. A run's observations hold records read under its caller's
14454
+ /// authority; `agents:read` alone must not disclose them.
14455
+ fn redact_run_results(state: &AppState, principal: &Principal, run: &mut Value) {
14456
+ if request_principal_grants(state, principal, "state:read") {
14457
+ return;
14458
+ }
14459
+ let Some(object) = run.as_object_mut() else {
14460
+ return;
14461
+ };
14462
+ for field in ["observations", "decisions"] {
14463
+ if object.contains_key(field) {
14464
+ object.insert(field.into(), json!([]));
14465
+ }
14466
+ }
14467
+ if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) {
14468
+ for step in steps {
14469
+ if step.get("result").is_some_and(|result| !result.is_null()) {
14470
+ step["result"] = Value::Null;
14471
+ }
14472
+ }
14473
+ }
14474
+ object.insert("results_redacted".into(), json!(true));
13057
14475
  }
13058
14476
 
13059
14477
  async fn add_agent_observation(
@@ -13061,12 +14479,11 @@ async fn add_agent_observation(
13061
14479
  Path(id): Path<String>,
13062
14480
  Json(request): Json<Observation>,
13063
14481
  ) -> Result<Json<RecordResponse>, ApiError> {
13064
- mutate_agent_run(&state, &id, |run| {
14482
+ mutate_agent_run(&state, &id, agent_execution::STATUS_DELIBERATING, |run| {
13065
14483
  run["observations"]
13066
14484
  .as_array_mut()
13067
14485
  .expect("validated agent run")
13068
14486
  .push(request.observation);
13069
- run["status"] = json!("deliberating");
13070
14487
  })
13071
14488
  .await
13072
14489
  }
@@ -13076,32 +14493,44 @@ async fn add_agent_decision(
13076
14493
  Path(id): Path<String>,
13077
14494
  Json(request): Json<Decision>,
13078
14495
  ) -> Result<Json<RecordResponse>, ApiError> {
13079
- mutate_agent_run(&state, &id, |run| {
14496
+ // Completion is decided by the runtime from criteria it verifies, never by
14497
+ // a participant saying so — whatever evidence the request carries.
14498
+ if request.completed {
14499
+ return Err(ApiError::structured(
14500
+ StatusCode::CONFLICT,
14501
+ json!({
14502
+ "code": "AGENT_COMPLETION_NOT_CLIENT_CONTROLLED",
14503
+ "message": "a run completes only when the runtime verifies its declared completion criteria",
14504
+ }),
14505
+ ));
14506
+ }
14507
+ mutate_agent_run(&state, &id, agent_execution::STATUS_ACTING, |run| {
13080
14508
  run["decisions"]
13081
14509
  .as_array_mut()
13082
14510
  .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
- });
14511
+ .push(json!({"decision": request.decision, "action": request.action, "source": "client"}));
13089
14512
  })
13090
14513
  .await
13091
14514
  }
13092
14515
 
14516
+ /// Append a participant's annotation to a run that is still open.
14517
+ ///
14518
+ /// Terminal outcomes are never rewritten. While the worker holds the run, the
14519
+ /// annotation is kept but the status is the worker's.
13093
14520
  async fn mutate_agent_run<F: FnOnce(&mut Value)>(
13094
14521
  state: &AppState,
13095
14522
  id: &str,
14523
+ annotation: &str,
13096
14524
  change: F,
13097
14525
  ) -> Result<Json<RecordResponse>, ApiError> {
13098
14526
  validate_segment(id)?;
13099
14527
  let _guard = state.lifecycle_lock.lock().await;
13100
14528
  let mut run = required_value(state, "_flow_agent_runs", id, "agent run not found")?;
13101
- if run["status"] == "completed" {
13102
- return Err(ApiError(
14529
+ let status = run["status"].as_str().unwrap_or_default().to_string();
14530
+ if agent_execution::is_terminal(&status) {
14531
+ return Err(ApiError::structured(
13103
14532
  StatusCode::CONFLICT,
13104
- "agent run is already completed".to_string(),
14533
+ json!({"code": "AGENT_RUN_TERMINAL", "message": format!("agent run is already {status}")}),
13105
14534
  ));
13106
14535
  }
13107
14536
  if !run["observations"].is_array() || !run["decisions"].is_array() {
@@ -13111,6 +14540,9 @@ async fn mutate_agent_run<F: FnOnce(&mut Value)>(
13111
14540
  ));
13112
14541
  }
13113
14542
  change(&mut run);
14543
+ if status != agent_execution::STATUS_RUNNING {
14544
+ run["status"] = json!(annotation);
14545
+ }
13114
14546
  put_canonical(state, "_flow_agent_runs", id, run)
13115
14547
  }
13116
14548
 
@@ -14214,6 +15646,7 @@ async fn commit_transaction(
14214
15646
  record_preconditions.push(precondition);
14215
15647
  }
14216
15648
  for operation in request.operations {
15649
+ ensure_not_runtime_owned(&operation.collection)?;
14217
15650
  let key = record_key(&operation.collection, &operation.id)?;
14218
15651
  if !seen.insert(key.clone()) {
14219
15652
  return Err(ApiError(
@@ -14287,7 +15720,7 @@ async fn commit_transaction(
14287
15720
  "committed": false,
14288
15721
  "code": "PRECONDITION_FAILED",
14289
15722
  "error": failure.to_string(),
14290
- "failure": *failure,
15723
+ "failure": failure.public_json(&feltdb::ReadLayout::Direct),
14291
15724
  }),
14292
15725
  ),
14293
15726
  FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
@@ -14304,7 +15737,29 @@ async fn commit_transaction(
14304
15737
  }),
14305
15738
  )
14306
15739
  }
14307
- other => ApiError(StatusCode::CONFLICT, other.to_string()),
15740
+ // The commit could not be made durable. It is neither a lost
15741
+ // race nor a success, and answering 409 here once let a caller
15742
+ // read a storage failure as "someone else won". The outcome is
15743
+ // to be treated as unknown and reconciled by retrying with the
15744
+ // same transaction id.
15745
+ storage @ (FlowError::Io(_) | FlowError::Serde(_)) => ApiError::structured(
15746
+ StatusCode::SERVICE_UNAVAILABLE,
15747
+ json!({
15748
+ "committed": "unknown",
15749
+ "code": "DURABILITY_FAILURE",
15750
+ "message": storage.to_string(),
15751
+ "transaction_id": request.transaction_id,
15752
+ }),
15753
+ ),
15754
+ other => ApiError::structured(
15755
+ StatusCode::INTERNAL_SERVER_ERROR,
15756
+ json!({
15757
+ "committed": "unknown",
15758
+ "code": "EXECUTION_FAILED",
15759
+ "message": other.to_string(),
15760
+ "transaction_id": request.transaction_id,
15761
+ }),
15762
+ ),
14308
15763
  }
14309
15764
  })?;
14310
15765
  let operation_ids = state
@@ -14334,6 +15789,21 @@ async fn commit_transaction(
14334
15789
  ))
14335
15790
  }
14336
15791
 
15792
+ /// Refuse a raw write to a collection only the runtime writes.
15793
+ ///
15794
+ /// A run's status, retry state, evidence and authority decide what the worker
15795
+ /// does and what the run reports. Letting `/collections` or `/transactions`
15796
+ /// write them would let any `state:write` caller declare a run completed.
15797
+ fn ensure_not_runtime_owned(collection: &str) -> Result<(), ApiError> {
15798
+ if agent_execution::is_runtime_owned_collection(collection) {
15799
+ return Err(ApiError::structured(
15800
+ StatusCode::FORBIDDEN,
15801
+ json!({"code": "RUNTIME_OWNED_COLLECTION", "message": "this collection is written only by the runtime"}),
15802
+ ));
15803
+ }
15804
+ Ok(())
15805
+ }
15806
+
14337
15807
  async fn create_record(
14338
15808
  State(state): State<AppState>,
14339
15809
  Path(collection): Path<String>,
@@ -14341,6 +15811,7 @@ async fn create_record(
14341
15811
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
14342
15812
  let _handler =
14343
15813
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
15814
+ ensure_not_runtime_owned(&collection)?;
14344
15815
  if collection == PROPOSAL_COLLECTION
14345
15816
  || collection == PROPOSAL_EVENT_COLLECTION
14346
15817
  || collection == CONVERGENCE_COLLECTION
@@ -14389,6 +15860,7 @@ async fn update_record(
14389
15860
  ) -> Result<Json<RecordResponse>, ApiError> {
14390
15861
  let _handler =
14391
15862
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
15863
+ ensure_not_runtime_owned(&collection)?;
14392
15864
  if collection == PROPOSAL_COLLECTION
14393
15865
  || collection == PROPOSAL_EVENT_COLLECTION
14394
15866
  || collection == CONVERGENCE_COLLECTION
@@ -14428,6 +15900,7 @@ async fn compare_and_set_record(
14428
15900
  ) -> Result<Response, ApiError> {
14429
15901
  let _handler =
14430
15902
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
15903
+ ensure_not_runtime_owned(&collection)?;
14431
15904
  let key = record_key(&collection, &id)?;
14432
15905
  let result = state.db.compare_and_set_json(
14433
15906
  &key,
@@ -14519,6 +15992,7 @@ async fn delete_record(
14519
15992
  ) -> Result<StatusCode, ApiError> {
14520
15993
  let _handler =
14521
15994
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
15995
+ ensure_not_runtime_owned(&collection)?;
14522
15996
  if collection == PROPOSAL_COLLECTION
14523
15997
  || collection == PROPOSAL_EVENT_COLLECTION
14524
15998
  || collection == CONVERGENCE_COLLECTION
@@ -15728,7 +17202,8 @@ async fn control_plane_agent(
15728
17202
  "workload store unavailable".into(),
15729
17203
  )
15730
17204
  })?
15731
- .list(&tenant, &application_id)
17205
+ .fresh_list(&tenant, &application_id)
17206
+ .map_err(workload_error)?
15732
17207
  .into_iter()
15733
17208
  .filter(|workload| {
15734
17209
  serde_json::to_value(&workload.requested_by)
@@ -16323,3 +17798,32 @@ mod authority_gate_tests {
16323
17798
  }
16324
17799
  }
16325
17800
  }
17801
+
17802
+ #[cfg(test)]
17803
+ mod backup_output_tests {
17804
+ use super::backup_output_path;
17805
+ use std::path::PathBuf;
17806
+
17807
+ #[test]
17808
+ fn a_confined_server_writes_named_bundles_inside_its_backup_directory() {
17809
+ let root = Some("/data/backups".into());
17810
+ assert_eq!(
17811
+ backup_output_path("nightly-1", root.clone()).unwrap(),
17812
+ PathBuf::from("/data/backups/nightly-1")
17813
+ );
17814
+ for refused in ["/data/state.log", "../state.log", "a/b", "..", ".", "", "/"] {
17815
+ assert!(
17816
+ backup_output_path(refused, root.clone()).is_err(),
17817
+ "{refused:?} must be refused"
17818
+ );
17819
+ }
17820
+ }
17821
+
17822
+ #[test]
17823
+ fn a_self_hosted_server_uses_the_operator_path() {
17824
+ assert_eq!(
17825
+ backup_output_path("/srv/backups/x", None).unwrap(),
17826
+ PathBuf::from("/srv/backups/x")
17827
+ );
17828
+ }
17829
+ }