@feltdb/core 0.13.0 → 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 (92) 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 +27 -6
  12. package/dist/collection.d.ts +3 -1
  13. package/dist/collection.d.ts.map +1 -1
  14. package/dist/collection.js +30 -8
  15. package/dist/create/build-identity.json +4 -4
  16. package/dist/create/cli.js +1 -1
  17. package/dist/create/create.js +4 -6
  18. package/dist/create/package-versions.js +1 -1
  19. package/dist/create/server-source/Cargo.lock +6 -6
  20. package/dist/create/server-source/Cargo.toml +1 -1
  21. package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
  22. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
  23. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +137 -1
  24. package/dist/create/server-source/crates/feltdb/src/workload.rs +1427 -91
  25. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  26. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  27. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  28. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  29. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
  30. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  31. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  32. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  33. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  34. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  35. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/main.rs +879 -84
  37. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  38. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  39. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  40. package/dist/db.d.ts +64 -9
  41. package/dist/db.d.ts.map +1 -1
  42. package/dist/db.js +153 -27
  43. package/dist/deployment.d.ts +19 -0
  44. package/dist/deployment.d.ts.map +1 -1
  45. package/dist/deployment.js +38 -2
  46. package/dist/embedded-query.d.ts.map +1 -1
  47. package/dist/embedded-query.js +5 -4
  48. package/dist/embedded-transaction.d.ts +8 -4
  49. package/dist/embedded-transaction.d.ts.map +1 -1
  50. package/dist/embedded-transaction.js +55 -19
  51. package/dist/error-codes.d.ts +31 -2
  52. package/dist/error-codes.d.ts.map +1 -1
  53. package/dist/error-codes.js +41 -2
  54. package/dist/feltdb.d.ts +7 -0
  55. package/dist/feltdb.d.ts.map +1 -1
  56. package/dist/file-db.d.ts +27 -0
  57. package/dist/file-db.d.ts.map +1 -1
  58. package/dist/file-db.js +145 -7
  59. package/dist/http-client.d.ts +11 -3
  60. package/dist/http-client.d.ts.map +1 -1
  61. package/dist/http-client.js +27 -14
  62. package/dist/http-db.d.ts +27 -1
  63. package/dist/http-db.d.ts.map +1 -1
  64. package/dist/http-db.js +252 -54
  65. package/dist/index.js +1 -1
  66. package/dist/local-state-lock.d.ts +34 -0
  67. package/dist/local-state-lock.d.ts.map +1 -0
  68. package/dist/local-state-lock.js +225 -0
  69. package/dist/reactive-graph.d.ts +0 -3
  70. package/dist/reactive-graph.d.ts.map +1 -1
  71. package/dist/reactive-graph.js +11 -1
  72. package/dist/runtime-registry.d.ts +7 -2
  73. package/dist/runtime-registry.d.ts.map +1 -1
  74. package/dist/runtime-registry.js +2 -2
  75. package/dist/studio/components/index.js +1 -1
  76. package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
  77. package/dist/studio/index.js +1 -1
  78. package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-BjM1ylL3.js} +1 -1
  79. package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
  80. package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
  81. package/dist/studio-app/index.html +1 -1
  82. package/dist/transaction.d.ts +27 -11
  83. package/dist/transaction.d.ts.map +1 -1
  84. package/dist/transaction.js +20 -14
  85. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  86. package/dist/wasm/package.json +1 -1
  87. package/dist/workload.d.ts +31 -3
  88. package/dist/workload.d.ts.map +1 -1
  89. package/dist/workload.js +28 -3
  90. package/package.json +1 -1
  91. package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
  92. package/dist/studio-app/assets/index-Dql1hS5m.js +0 -30
@@ -88,9 +88,11 @@ use feltdb_server::{
88
88
  cluster::{ClusterConfig, ClusterStore, MembershipProposal, ProposalPhase},
89
89
  connections::{ConnectionProvider, ConnectionStatus, ConnectionStore, SecretReference},
90
90
  content::ContentStore,
91
+ control_authorization::{self, PrincipalClass},
92
+ console_accounts::{self, ConsoleAccountStore},
91
93
  control_plane,
92
94
  identity::IdentityStore,
93
- key_management::{create_key, list_keys, revoke_key},
95
+ key_management::{create_key, list_keys, revoke_key, rotate_key},
94
96
  key_provider::provider_from_environment,
95
97
  leases::LeaseStore,
96
98
  metrics::Metrics,
@@ -200,11 +202,13 @@ struct HealthResponse<'a> {
200
202
  public_contract: &'a str,
201
203
  capability_fingerprint: String,
202
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`.
203
208
  storage: &'a str,
204
- fabric: &'a str,
209
+ /// Configuration, not a probe: whether this process runs the autonomous
210
+ /// worker.
205
211
  execution: &'a str,
206
- workflows: &'a str,
207
- agents: &'a str,
208
212
  membership: &'a str,
209
213
  }
210
214
 
@@ -1466,6 +1470,10 @@ struct WorkloadFailRequest {
1466
1470
  fence: WorkloadFenceRequest,
1467
1471
  kind: String,
1468
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>,
1469
1477
  }
1470
1478
  #[derive(Deserialize)]
1471
1479
  struct WorkloadContextRequest {
@@ -1496,6 +1504,45 @@ struct WorkerReconcileRequest {
1496
1504
  reconciliation: WorkerReconciliation,
1497
1505
  grant: feltdb::authorization::SignedGrant,
1498
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
+ }
1499
1546
  fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
1500
1547
  let status = match error.code.as_str() {
1501
1548
  "WORKLOAD_NOT_FOUND" => StatusCode::NOT_FOUND,
@@ -1505,6 +1552,8 @@ fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
1505
1552
  | "WORKLOAD_NOT_YET_ELIGIBLE"
1506
1553
  | "WORKLOAD_ATTEMPTS_EXHAUSTED"
1507
1554
  | "WORKLOAD_RETRY_EXHAUSTED"
1555
+ | "WORKLOAD_RESULT_CONFLICT"
1556
+ | "WORKLOAD_TIMED_OUT"
1508
1557
  | "WORKLOAD_CONFLICT" => StatusCode::CONFLICT,
1509
1558
  "WORKLOAD_UNAUTHORIZED" | "WORKLOAD_CAPABILITY_DENIED" => StatusCode::FORBIDDEN,
1510
1559
  "WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
@@ -1597,6 +1646,7 @@ async fn create_workload(
1597
1646
  Extension(principal): Extension<Principal>,
1598
1647
  Json(mut input): Json<CreateWorkload>,
1599
1648
  ) -> Result<(StatusCode, HeaderMap, Json<Value>), ApiError> {
1649
+ require_work_authority(&state)?;
1600
1650
  let subject = grant_subject(&principal);
1601
1651
  input.requested_by = subject.clone();
1602
1652
  input.session_id = principal.session_id.clone();
@@ -1624,10 +1674,18 @@ async fn create_workload(
1624
1674
  if error.code == "IDEMPOTENCY_KEY_REUSED" {
1625
1675
  // AppPort's contract for a reused key (§13.1): CONFLICT, with
1626
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!([]));
1627
1685
  ApiError::structured(
1628
1686
  StatusCode::CONFLICT,
1629
1687
  json!({"code": "CONFLICT", "message": error.message,
1630
- "details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false}}),
1688
+ "details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false, "fields": fields}}),
1631
1689
  )
1632
1690
  } else {
1633
1691
  workload_error(error)
@@ -1668,7 +1726,8 @@ async fn list_workloads(
1668
1726
  "workload store unavailable".into(),
1669
1727
  )
1670
1728
  })?
1671
- .list(&tenant, &target.application_id);
1729
+ .fresh_list(&tenant, &target.application_id)
1730
+ .map_err(workload_error)?;
1672
1731
  Ok(Json(json!({"workloads":values,"grant_id":target.grant_id})))
1673
1732
  }
1674
1733
  async fn get_workload(
@@ -1685,7 +1744,7 @@ async fn get_workload(
1685
1744
  "workload store unavailable".into(),
1686
1745
  )
1687
1746
  })?
1688
- .get(&id)
1747
+ .fresh(&id)
1689
1748
  .map_err(workload_error)?
1690
1749
  .clone();
1691
1750
  workload_read_scope(&state, &principal, &workload.application_id)?;
@@ -1715,11 +1774,12 @@ async fn claim_workload(
1715
1774
  Path(id): Path<String>,
1716
1775
  Json(input): Json<WorkloadClaimRequest>,
1717
1776
  ) -> Result<Json<Value>, ApiError> {
1777
+ require_work_authority(&state)?;
1718
1778
  let workload = state
1719
1779
  .workloads
1720
1780
  .lock()
1721
1781
  .unwrap()
1722
- .get(&id)
1782
+ .fresh(&id)
1723
1783
  .map_err(workload_error)?
1724
1784
  .clone();
1725
1785
  let subject = grant_subject(&principal);
@@ -1773,7 +1833,7 @@ fn authorize_fence(
1773
1833
  .workloads
1774
1834
  .lock()
1775
1835
  .unwrap()
1776
- .get(id)
1836
+ .fresh(id)
1777
1837
  .map_err(workload_error)?
1778
1838
  .clone();
1779
1839
  let subject = grant_subject(principal);
@@ -1800,6 +1860,7 @@ async fn start_workload(
1800
1860
  Path(id): Path<String>,
1801
1861
  Json(input): Json<WorkloadFenceRequest>,
1802
1862
  ) -> Result<StatusCode, ApiError> {
1863
+ require_work_authority(&state)?;
1803
1864
  authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
1804
1865
  state
1805
1866
  .workloads
@@ -1821,6 +1882,7 @@ async fn heartbeat_workload(
1821
1882
  Path(id): Path<String>,
1822
1883
  Json(input): Json<WorkloadHeartbeatRequest>,
1823
1884
  ) -> Result<Json<Value>, ApiError> {
1885
+ require_work_authority(&state)?;
1824
1886
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1825
1887
  let claim = state
1826
1888
  .workloads
@@ -1843,6 +1905,7 @@ async fn checkpoint_workload(
1843
1905
  Path(id): Path<String>,
1844
1906
  Json(input): Json<WorkloadCheckpointRequest>,
1845
1907
  ) -> Result<Json<Value>, ApiError> {
1908
+ require_work_authority(&state)?;
1846
1909
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1847
1910
  let checkpoint = state
1848
1911
  .workloads
@@ -1865,6 +1928,7 @@ async fn complete_workload(
1865
1928
  Path(id): Path<String>,
1866
1929
  Json(input): Json<WorkloadCompleteRequest>,
1867
1930
  ) -> Result<Json<Value>, ApiError> {
1931
+ require_work_authority(&state)?;
1868
1932
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1869
1933
  let result = state
1870
1934
  .workloads
@@ -1891,18 +1955,20 @@ async fn fail_workload(
1891
1955
  Path(id): Path<String>,
1892
1956
  Json(input): Json<WorkloadFailRequest>,
1893
1957
  ) -> Result<Json<Value>, ApiError> {
1958
+ require_work_authority(&state)?;
1894
1959
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1895
1960
  let status = state
1896
1961
  .workloads
1897
1962
  .lock()
1898
1963
  .unwrap()
1899
- .fail(
1964
+ .fail_with_effect(
1900
1965
  &id,
1901
1966
  &input.fence.claim_id,
1902
1967
  &input.fence.worker_id,
1903
1968
  input.fence.fencing_token,
1904
1969
  &input.kind,
1905
1970
  &input.error,
1971
+ input.effect.as_deref(),
1906
1972
  unix_seconds_i64(),
1907
1973
  )
1908
1974
  .map_err(workload_error)?;
@@ -1915,6 +1981,7 @@ async fn confirm_workload_cancelled(
1915
1981
  Path(id): Path<String>,
1916
1982
  Json(input): Json<WorkloadFenceRequest>,
1917
1983
  ) -> Result<StatusCode, ApiError> {
1984
+ require_work_authority(&state)?;
1918
1985
  authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
1919
1986
  state
1920
1987
  .workloads
@@ -1936,6 +2003,7 @@ async fn workload_execution_context(
1936
2003
  Path(id): Path<String>,
1937
2004
  Json(input): Json<WorkloadContextRequest>,
1938
2005
  ) -> Result<Json<Value>, ApiError> {
2006
+ require_work_authority(&state)?;
1939
2007
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1940
2008
  let context = state
1941
2009
  .workloads
@@ -1958,11 +2026,12 @@ async fn cancel_workload(
1958
2026
  Path(id): Path<String>,
1959
2027
  Json(input): Json<WorkloadControlRequest>,
1960
2028
  ) -> Result<Json<Value>, ApiError> {
2029
+ require_work_authority(&state)?;
1961
2030
  let workload = state
1962
2031
  .workloads
1963
2032
  .lock()
1964
2033
  .unwrap()
1965
- .get(&id)
2034
+ .fresh(&id)
1966
2035
  .map_err(workload_error)?
1967
2036
  .clone();
1968
2037
  workload_decision(
@@ -1988,11 +2057,12 @@ async fn retry_workload(
1988
2057
  Path(id): Path<String>,
1989
2058
  Json(input): Json<WorkloadControlRequest>,
1990
2059
  ) -> Result<StatusCode, ApiError> {
2060
+ require_work_authority(&state)?;
1991
2061
  let workload = state
1992
2062
  .workloads
1993
2063
  .lock()
1994
2064
  .unwrap()
1995
- .get(&id)
2065
+ .fresh(&id)
1996
2066
  .map_err(workload_error)?
1997
2067
  .clone();
1998
2068
  workload_decision(
@@ -2021,7 +2091,7 @@ async fn workload_history(
2021
2091
  .workloads
2022
2092
  .lock()
2023
2093
  .unwrap()
2024
- .get(&id)
2094
+ .fresh(&id)
2025
2095
  .map_err(workload_error)?
2026
2096
  .clone();
2027
2097
  workload_read_scope(&state, &principal, &workload.application_id)?;
@@ -2038,7 +2108,7 @@ async fn workload_result(
2038
2108
  .workloads
2039
2109
  .lock()
2040
2110
  .unwrap()
2041
- .get(&id)
2111
+ .fresh(&id)
2042
2112
  .map_err(workload_error)?
2043
2113
  .clone();
2044
2114
  workload_read_scope(&state, &principal, &workload.application_id)?;
@@ -2223,7 +2293,8 @@ async fn reconcile_worker(
2223
2293
  ) -> Result<Json<Value>, ApiError> {
2224
2294
  authorize_worker_action(&state, &principal, &id, &input.grant, "worker:heartbeat")?;
2225
2295
  input.reconciliation.worker_id = id;
2226
- let workloads = state.workloads.lock().unwrap();
2296
+ let mut workloads = state.workloads.lock().unwrap();
2297
+ workloads.refresh().map_err(workload_error)?;
2227
2298
  let result = state
2228
2299
  .mesh
2229
2300
  .lock()
@@ -2314,7 +2385,7 @@ async fn eligible_workers(
2314
2385
  .workloads
2315
2386
  .lock()
2316
2387
  .unwrap()
2317
- .get(&id)
2388
+ .fresh(&id)
2318
2389
  .map_err(workload_error)?
2319
2390
  .clone();
2320
2391
  workload_read_scope(&state, &principal, &workload.application_id)?;
@@ -6957,6 +7028,139 @@ async fn auth_sign_up(
6957
7028
  .map_err(control_error)?;
6958
7029
  Ok((StatusCode::CREATED, auth_response(identity, session, token)))
6959
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
+
6960
7164
  async fn auth_sign_in(
6961
7165
  State(state): State<AppState>,
6962
7166
  Json(input): Json<SignInRequest>,
@@ -7595,7 +7799,9 @@ fn state_contract_error(error: feltdb::state_contract::StateFailure) -> ApiError
7595
7799
  "PRECONDITION_FAILED" | "CONFLICT" | "IDEMPOTENCY_CONFLICT" => StatusCode::CONFLICT,
7596
7800
  "UNKNOWN_COLLECTION" => StatusCode::NOT_FOUND,
7597
7801
  "SEMANTIC_UNSUPPORTED" => StatusCode::NOT_IMPLEMENTED,
7598
- "STORAGE_FAILURE" | "STATE_UNAVAILABLE" => StatusCode::SERVICE_UNAVAILABLE,
7802
+ "STORAGE_FAILURE" | "STATE_UNAVAILABLE" | "DURABILITY_FAILURE" => {
7803
+ StatusCode::SERVICE_UNAVAILABLE
7804
+ }
7599
7805
  _ => StatusCode::UNPROCESSABLE_ENTITY,
7600
7806
  };
7601
7807
  ApiError(
@@ -8232,6 +8438,10 @@ struct SyncPullResponse {
8232
8438
  operations: Vec<Operation>,
8233
8439
  versions: HashMap<String, u64>,
8234
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,
8235
8445
  }
8236
8446
 
8237
8447
  #[derive(Serialize, Deserialize)]
@@ -8480,6 +8690,7 @@ struct Config {
8480
8690
  audit: PathBuf,
8481
8691
  allowed_origins: Vec<axum::http::HeaderValue>,
8482
8692
  worker_enabled: bool,
8693
+ work_authority: bool,
8483
8694
  advertise_url: Option<String>,
8484
8695
  maximum_clock_drift_ms: u64,
8485
8696
  }
@@ -8528,6 +8739,7 @@ impl Config {
8528
8739
  .transpose()?
8529
8740
  .unwrap_or_default();
8530
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");
8531
8743
  let mut advertise_url = std::env::var("FELTDB_ADVERTISE_URL")
8532
8744
  .ok()
8533
8745
  .map(|value| value.trim_end_matches('/').to_string());
@@ -8580,6 +8792,7 @@ impl Config {
8580
8792
  .map_err(|_| "invalid --allow-origin".to_string())?,
8581
8793
  ),
8582
8794
  "--worker" => worker_enabled = true,
8795
+ "--work-authority" => work_authority = true,
8583
8796
  "--advertise-url" => {
8584
8797
  advertise_url = Some(value()?.trim_end_matches('/').to_string())
8585
8798
  }
@@ -8594,7 +8807,7 @@ impl Config {
8594
8807
  }
8595
8808
  }
8596
8809
  "--help" | "-h" => {
8597
- 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]");
8598
8811
  std::process::exit(0);
8599
8812
  }
8600
8813
  _ => return Err(format!("unknown argument: {argument}")),
@@ -8617,6 +8830,7 @@ impl Config {
8617
8830
  audit,
8618
8831
  allowed_origins,
8619
8832
  worker_enabled,
8833
+ work_authority,
8620
8834
  advertise_url,
8621
8835
  maximum_clock_drift_ms,
8622
8836
  })
@@ -8687,10 +8901,28 @@ fn validate_production_configuration(config: &Config) -> Result<(), Box<dyn std:
8687
8901
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
8688
8902
  tracing_subscriber::fmt()
8689
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)
8690
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
+ }
8691
8920
  if manage_keys()? {
8692
8921
  return Ok(());
8693
8922
  }
8923
+ if manage_console_accounts()? {
8924
+ return Ok(());
8925
+ }
8694
8926
  if manage_authorization()? {
8695
8927
  return Ok(());
8696
8928
  }
@@ -8827,6 +9059,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8827
9059
  lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
8828
9060
  state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(2)),
8829
9061
  worker_enabled: config.worker_enabled,
9062
+ work_authority: config.work_authority,
9063
+ work_authority_conflict: Arc::new(std::sync::atomic::AtomicBool::new(false)),
8830
9064
  leases: LeaseStore::load(config.data.with_extension("leases.json"))?,
8831
9065
  content: content_store,
8832
9066
  artifacts: artifact_store,
@@ -8848,6 +9082,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8848
9082
  .into_bytes(),
8849
9083
  config.data.with_extension("revoked-sessions.json"),
8850
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
+ )?,
8851
9089
  principals: principal_authority,
8852
9090
  connections: ConnectionStore::load_with_provider(
8853
9091
  config.data.with_extension("connections.json"),
@@ -8936,6 +9174,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8936
9174
  .route("/api/tenants/{tenant_id}", get(get_tenant))
8937
9175
  .route("/api/keys", get(list_keys).post(create_key))
8938
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))
8939
9181
  .route(
8940
9182
  "/api/tenants/{tenant_id}/applications",
8941
9183
  get(list_applications).post(create_application_control),
@@ -9596,23 +9838,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9596
9838
  .route("/v1/openapi.json", get(application_openapi))
9597
9839
  .route("/v1/auth/signup", axum::routing::post(auth_sign_up))
9598
9840
  .route("/v1/auth/signin", axum::routing::post(auth_sign_in))
9599
- .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).
9600
9843
  .route(
9601
- "/internal/intents",
9602
- axum::routing::post(internal_create_intent),
9844
+ "/v1/console/accounts/password/signup",
9845
+ axum::routing::post(console_password_signup),
9603
9846
  )
9604
- .route("/internal/intents/{id}", put(internal_replace_intent))
9605
9847
  .route(
9606
- "/internal/intent-events",
9607
- axum::routing::post(internal_create_intent_event),
9848
+ "/v1/console/accounts/password/signin",
9849
+ axum::routing::post(console_password_signin),
9608
9850
  )
9609
- .route("/ready", get(readiness))
9610
- .route("/runtime", get(runtime))
9611
- .route("/metrics", get(network_metrics))
9612
9851
  .route(
9613
- "/api/certification/fixtures/{application_id}",
9614
- axum::routing::delete(delete_certification_fixture),
9852
+ "/v1/console/accounts/github/resolve",
9853
+ axum::routing::post(console_github_resolve),
9615
9854
  )
9855
+ .route("/ready", get(readiness))
9856
+ .route("/runtime", get(runtime))
9857
+ .merge(service_secret_routes())
9616
9858
  .merge(protected)
9617
9859
  .merge(query_execution_diagnostics_router())
9618
9860
  .layer(middleware::from_fn_with_state(state.clone(), admit_request))
@@ -9620,7 +9862,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9620
9862
  .layer(middleware::from_fn(attribute_request))
9621
9863
  .layer(middleware::from_fn_with_state(state.clone(), count_request))
9622
9864
  .layer(RequestBodyLimitLayer::new(1024 * 1024))
9623
- .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
+ ))
9624
9876
  .with_state(state);
9625
9877
  if !config.allowed_origins.is_empty() {
9626
9878
  app = app.layer(
@@ -9664,6 +9916,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9664
9916
  Ok(())
9665
9917
  }
9666
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
+
9667
9945
  async fn count_request(
9668
9946
  State(state): State<AppState>,
9669
9947
  request: Request<axum::body::Body>,
@@ -9696,7 +9974,7 @@ async fn authenticate(
9696
9974
  // an answer to PR36's question — which part of the request does is.
9697
9975
  let authorization =
9698
9976
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Authorization);
9699
- let principal = if state.auth_enabled {
9977
+ let (principal, class) = if state.auth_enabled {
9700
9978
  let token = request
9701
9979
  .headers()
9702
9980
  .get(AUTHORIZATION)
@@ -9759,8 +10037,8 @@ async fn authenticate(
9759
10037
  })
9760
10038
  .and_then(|token| state.sessions.verify(token));
9761
10039
  match (machine, actor, human) {
9762
- (Some(principal), _, _) => principal,
9763
- (None, Some(session), _) => Principal {
10040
+ (Some(principal), _, _) => (principal, PrincipalClass::Machine),
10041
+ (None, Some(session), _) => (Principal {
9764
10042
  key_id: session.subject_id,
9765
10043
  scopes: vec![
9766
10044
  "application:read".into(),
@@ -9775,24 +10053,55 @@ async fn authenticate(
9775
10053
  subject_type: "human".into(),
9776
10054
  identity_id: Some(session.identity_id),
9777
10055
  session_id: Some(session.session_id),
9778
- },
10056
+ }, PrincipalClass::EndUser),
9779
10057
  (None, None, Some(session)) => {
9780
- if state
9781
- .tenancy
9782
- .register_platform_user(&session.user_id, &session.identity)
9783
- .is_err()
9784
- || !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)
9785
10091
  {
9786
10092
  return ApiError(StatusCode::FORBIDDEN, "platform user is suspended".into())
9787
10093
  .into_response();
9788
10094
  }
9789
- Principal {
9790
- key_id: session.user_id,
9791
- scopes: vec![],
9792
- subject_type: "human".into(),
9793
- identity_id: Some(session.identity),
9794
- session_id: Some(session.id),
9795
- }
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
+ )
9796
10105
  }
9797
10106
  (None, None, None) => {
9798
10107
  state.metrics.authentication_failure();
@@ -9812,17 +10121,43 @@ async fn authenticate(
9812
10121
  }
9813
10122
  }
9814
10123
  } else {
9815
- Principal {
9816
- key_id: "development".to_string(),
9817
- scopes: vec!["*".to_string()],
9818
- subject_type: "service".into(),
9819
- identity_id: None,
9820
- session_id: None,
9821
- }
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
+ )
9822
10134
  };
9823
10135
 
9824
10136
  let path = request.uri().path();
9825
- 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" {
9826
10161
  "events:read"
9827
10162
  } else if path == "/v1/state/diff"
9828
10163
  || path == "/v1/state/conflicts"
@@ -9882,6 +10217,24 @@ async fn authenticate(
9882
10217
  } else {
9883
10218
  "state:write"
9884
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
+ }
9885
10238
  let subject = if principal.subject_type == "human" {
9886
10239
  Subject::User(principal.key_id.clone())
9887
10240
  } else {
@@ -9899,16 +10252,26 @@ async fn authenticate(
9899
10252
  let human_contract = principal.subject_type == "human"
9900
10253
  && path.starts_with("/v1/applications/")
9901
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.
9902
10258
  let authenticated_diagnostics = path == "/v1/diagnostics";
9903
- if !path.starts_with("/api/")
9904
- && !human_contract
9905
- && !authenticated_diagnostics
9906
- && !decision.allowed
9907
- {
10259
+ let decided_by_route = control.is_some() || human_contract || authenticated_diagnostics;
10260
+ if !decided_by_route && !decision.allowed {
9908
10261
  state.metrics.authentication_failure();
9909
10262
  audit(&state, &principal.key_id, required, path, "denied", 403);
9910
- return ApiError(StatusCode::FORBIDDEN, format!("missing scope: {required}"))
9911
- .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();
9912
10275
  }
9913
10276
  if path == "/events" {
9914
10277
  state.metrics.event_connection();
@@ -9927,6 +10290,7 @@ async fn authenticate(
9927
10290
 
9928
10291
  request.extensions_mut().insert(principal.clone());
9929
10292
  request.extensions_mut().insert(authenticated.clone());
10293
+ request.extensions_mut().insert(class);
9930
10294
 
9931
10295
  let principal_id = request
9932
10296
  .extensions()
@@ -10272,14 +10636,14 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
10272
10636
  capability_fingerprint: control_plane::public_contract_capability_fingerprint(),
10273
10637
  runtime: "self-hosted",
10274
10638
  storage: database.storage.label(),
10275
- 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.
10276
10642
  execution: if state.worker_enabled {
10277
10643
  "autonomous"
10278
10644
  } else {
10279
10645
  "state-coordinated"
10280
10646
  },
10281
- workflows: "available",
10282
- agents: "available",
10283
10647
  membership: if clock {
10284
10648
  membership
10285
10649
  } else {
@@ -10417,10 +10781,19 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
10417
10781
  values("--namespace", "default"),
10418
10782
  option("--worker"),
10419
10783
  )?;
10420
- println!(
10421
- "API key created (id: {}). This secret will not be shown again:\n{}",
10422
- key.id, key.secret
10423
- );
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
+ }
10424
10797
  }
10425
10798
  "list" => println!("{}", serde_json::to_string_pretty(&store.list()?)?),
10426
10799
  "revoke" => {
@@ -10430,11 +10803,93 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
10430
10803
  }
10431
10804
  println!("API key revoked: {id}");
10432
10805
  }
10433
- _ => 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()),
10434
10873
  }
10435
10874
  Ok(true)
10436
10875
  }
10437
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
+
10438
10893
  fn manage_authorization() -> Result<bool, Box<dyn std::error::Error>> {
10439
10894
  let arguments: Vec<String> = std::env::args().collect();
10440
10895
  let family = arguments.get(1).map(String::as_str).unwrap_or("");
@@ -10961,6 +11416,37 @@ fn manage_application_runtime() -> Result<bool, Box<dyn std::error::Error>> {
10961
11416
  Ok(true)
10962
11417
  }
10963
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
+
10964
11450
  async fn create_online_backup(
10965
11451
  State(state): State<AppState>,
10966
11452
  Json(request): Json<OnlineBackupRequest>,
@@ -10990,7 +11476,7 @@ async fn create_online_backup(
10990
11476
  })
10991
11477
  .cloned()
10992
11478
  .collect();
10993
- let output = PathBuf::from(request.output);
11479
+ let output = backup_output_path(&request.output, std::env::var_os("FELTDB_BACKUP_DIR"))?;
10994
11480
  let source = feltdb_server::backup::BackupSource {
10995
11481
  data: state
10996
11482
  .backup_artifacts
@@ -11002,14 +11488,22 @@ async fn create_online_backup(
11002
11488
  server_version: Some(env!("CARGO_PKG_VERSION").into()),
11003
11489
  durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
11004
11490
  };
11491
+ let records = operator_record_path(&state, BACKUP_RECORDS);
11005
11492
  let manifest = tokio::task::spawn_blocking(move || {
11006
- let _guard = guard;
11007
- feltdb_server::backup::create_bundle_with_source(
11008
- &output,
11009
- &artifacts,
11010
- &[("database.snapshot.json".to_string(), snapshot_bytes)],
11011
- source,
11012
- )
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)
11013
11507
  })
11014
11508
  .await
11015
11509
  .map_err(|error| {
@@ -11033,6 +11527,236 @@ async fn create_online_backup(
11033
11527
  /// finding was that losing security evidence was invisible, and a signal an
11034
11528
  /// operator has to know to go and look for is not much better than no signal.
11035
11529
  /// It rides with the metrics they already read.
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)
11536
+ }
11537
+
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
+
11036
11760
  async fn network_metrics(State(state): State<AppState>) -> Json<Value> {
11037
11761
  let mut body = serde_json::to_value(state.metrics.snapshot()).unwrap_or_else(|_| json!({}));
11038
11762
  if let Some(fields) = body.as_object_mut() {
@@ -11075,6 +11799,7 @@ async fn sync_pull(
11075
11799
  operations,
11076
11800
  versions: authority_position,
11077
11801
  catchup,
11802
+ durable_work_authority: state.work_authority,
11078
11803
  }))
11079
11804
  }
11080
11805
 
@@ -11239,10 +11964,13 @@ fn start_workload_recovery(state: &AppState) {
11239
11964
  let state = state.clone();
11240
11965
  tokio::spawn(async move {
11241
11966
  loop {
11242
- if state.cluster.get().is_member {
11967
+ if state.cluster.get().is_member && is_work_authority(&state) {
11243
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.
11244
11971
  (Ok(mut mesh), Ok(mut workloads)) => mesh
11245
- .recover_expired_work(unix_seconds_i64(), &mut workloads)
11972
+ .enforce_deadlines(unix_seconds_i64(), &mut workloads)
11973
+ .and_then(|_| mesh.recover_expired_work(unix_seconds_i64(), &mut workloads))
11246
11974
  .map(|_| ())
11247
11975
  .map_err(|error| error.message),
11248
11976
  _ => Err("worker mesh or workload store unavailable".to_string()),
@@ -11325,8 +12053,12 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
11325
12053
  }
11326
12054
  }
11327
12055
 
11328
- evaluate_state_triggers(state)?;
11329
- 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
+ }
11330
12062
  Ok(())
11331
12063
  }
11332
12064
 
@@ -12141,6 +12873,17 @@ async fn sync_with_peer(
12141
12873
  .json::<SyncPullResponse>()
12142
12874
  .await?;
12143
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
+ }
12144
12887
  let mut incoming = pull.operations;
12145
12888
  incoming.sort_by(|left, right| {
12146
12889
  left.instance_id
@@ -14977,7 +15720,7 @@ async fn commit_transaction(
14977
15720
  "committed": false,
14978
15721
  "code": "PRECONDITION_FAILED",
14979
15722
  "error": failure.to_string(),
14980
- "failure": *failure,
15723
+ "failure": failure.public_json(&feltdb::ReadLayout::Direct),
14981
15724
  }),
14982
15725
  ),
14983
15726
  FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
@@ -14994,7 +15737,29 @@ async fn commit_transaction(
14994
15737
  }),
14995
15738
  )
14996
15739
  }
14997
- 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
+ ),
14998
15763
  }
14999
15764
  })?;
15000
15765
  let operation_ids = state
@@ -16437,7 +17202,8 @@ async fn control_plane_agent(
16437
17202
  "workload store unavailable".into(),
16438
17203
  )
16439
17204
  })?
16440
- .list(&tenant, &application_id)
17205
+ .fresh_list(&tenant, &application_id)
17206
+ .map_err(workload_error)?
16441
17207
  .into_iter()
16442
17208
  .filter(|workload| {
16443
17209
  serde_json::to_value(&workload.requested_by)
@@ -17032,3 +17798,32 @@ mod authority_gate_tests {
17032
17798
  }
17033
17799
  }
17034
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
+ }