@feltdb/core 0.13.0 → 0.16.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 (300) 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 +68 -0
  5. package/dist/agent-state.d.ts.map +1 -1
  6. package/dist/agent-state.js +210 -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 +12 -4
  11. package/dist/cli/commands.js +214 -10
  12. package/dist/cli/publish-engine.js +116 -16
  13. package/dist/collection.d.ts +3 -1
  14. package/dist/collection.d.ts.map +1 -1
  15. package/dist/collection.js +33 -8
  16. package/dist/create/build-identity.json +12 -4
  17. package/dist/create/cli.js +1 -1
  18. package/dist/create/create.js +4 -6
  19. package/dist/create/managed-account.js +93 -35
  20. package/dist/create/package-versions.js +1 -1
  21. package/dist/create/server-source/Cargo.lock +8 -511
  22. package/dist/create/server-source/Cargo.toml +2 -13
  23. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  24. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  25. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  26. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  27. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
  28. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  29. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  30. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  31. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  32. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  33. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
  34. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1134 -96
  35. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  37. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  38. package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +1 -1
  39. package/dist/db.d.ts +75 -10
  40. package/dist/db.d.ts.map +1 -1
  41. package/dist/db.js +168 -29
  42. package/dist/deployment.d.ts +19 -0
  43. package/dist/deployment.d.ts.map +1 -1
  44. package/dist/deployment.js +38 -2
  45. package/dist/embedded-query.d.ts.map +1 -1
  46. package/dist/embedded-query.js +5 -4
  47. package/dist/embedded-transaction.d.ts +8 -4
  48. package/dist/embedded-transaction.d.ts.map +1 -1
  49. package/dist/embedded-transaction.js +55 -19
  50. package/dist/error-codes.d.ts +36 -2
  51. package/dist/error-codes.d.ts.map +1 -1
  52. package/dist/error-codes.js +50 -3
  53. package/dist/feltdb.d.ts +7 -0
  54. package/dist/feltdb.d.ts.map +1 -1
  55. package/dist/file-db.d.ts +27 -0
  56. package/dist/file-db.d.ts.map +1 -1
  57. package/dist/file-db.js +145 -7
  58. package/dist/http-client.d.ts +11 -3
  59. package/dist/http-client.d.ts.map +1 -1
  60. package/dist/http-client.js +27 -14
  61. package/dist/http-db.d.ts +43 -1
  62. package/dist/http-db.d.ts.map +1 -1
  63. package/dist/http-db.js +287 -59
  64. package/dist/index.js +1 -1
  65. package/dist/local-state-lock.d.ts +34 -0
  66. package/dist/local-state-lock.d.ts.map +1 -0
  67. package/dist/local-state-lock.js +225 -0
  68. package/dist/managed-recovery.d.ts +6 -0
  69. package/dist/managed-recovery.d.ts.map +1 -1
  70. package/dist/managed-recovery.js +9 -0
  71. package/dist/reactive-graph.d.ts +0 -3
  72. package/dist/reactive-graph.d.ts.map +1 -1
  73. package/dist/reactive-graph.js +11 -1
  74. package/dist/runtime-registry.d.ts +7 -2
  75. package/dist/runtime-registry.d.ts.map +1 -1
  76. package/dist/runtime-registry.js +2 -2
  77. package/dist/studio/components/index.js +1 -1
  78. package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
  79. package/dist/studio/index.js +1 -1
  80. package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-DtybUY0q.js} +1 -1
  81. package/dist/studio-app/assets/feltdb_wasm_bg-DsgIUwaB.wasm +0 -0
  82. package/dist/studio-app/assets/index-C4OffsZl.js +37 -0
  83. package/dist/studio-app/index.html +1 -1
  84. package/dist/transaction.d.ts +27 -11
  85. package/dist/transaction.d.ts.map +1 -1
  86. package/dist/transaction.js +20 -14
  87. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  88. package/dist/wasm/package.json +1 -1
  89. package/dist/workload.d.ts +86 -6
  90. package/dist/workload.d.ts.map +1 -1
  91. package/dist/workload.js +51 -3
  92. package/dist/workspace/workspace-connection.d.ts.map +1 -1
  93. package/dist/workspace/workspace-connection.js +14 -3
  94. package/package.json +1 -1
  95. package/dist/create/server-source/crates/feltdb/Cargo.lock +0 -175
  96. package/dist/create/server-source/crates/feltdb/Cargo.toml +0 -102
  97. package/dist/create/server-source/crates/feltdb/LICENSE +0 -21
  98. package/dist/create/server-source/crates/feltdb/README.md +0 -75
  99. package/dist/create/server-source/crates/feltdb/benches/baselines/gate-13-redux.json +0 -370
  100. package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +0 -589
  101. package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +0 -247
  102. package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +0 -440
  103. package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +0 -275
  104. package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +0 -381
  105. package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +0 -467
  106. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +0 -489
  107. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +0 -281
  108. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +0 -606
  109. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +0 -531
  110. package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +0 -462
  111. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +0 -505
  112. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +0 -484
  113. package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +0 -758
  114. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +0 -421
  115. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +0 -329
  116. package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +0 -1705
  117. package/dist/create/server-source/crates/feltdb/src/acquisition.rs +0 -286
  118. package/dist/create/server-source/crates/feltdb/src/admission.rs +0 -185
  119. package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +0 -507
  120. package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +0 -539
  121. package/dist/create/server-source/crates/feltdb/src/agent_state.rs +0 -320
  122. package/dist/create/server-source/crates/feltdb/src/agent_transition_contract.rs +0 -1545
  123. package/dist/create/server-source/crates/feltdb/src/analytics.rs +0 -521
  124. package/dist/create/server-source/crates/feltdb/src/application.rs +0 -2674
  125. package/dist/create/server-source/crates/feltdb/src/application_runtime.rs +0 -1070
  126. package/dist/create/server-source/crates/feltdb/src/authority.rs +0 -479
  127. package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +0 -847
  128. package/dist/create/server-source/crates/feltdb/src/authorization.rs +0 -1030
  129. package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +0 -1122
  130. package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +0 -32
  131. package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority.rs +0 -19
  132. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +0 -1474
  133. package/dist/create/server-source/crates/feltdb/src/capabilities/mod.rs +0 -8
  134. package/dist/create/server-source/crates/feltdb/src/capabilities/search.rs +0 -418
  135. package/dist/create/server-source/crates/feltdb/src/capabilities/vector.rs +0 -482
  136. package/dist/create/server-source/crates/feltdb/src/capability.rs +0 -903
  137. package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +0 -263
  138. package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -77
  139. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +0 -496
  140. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +0 -2574
  141. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +0 -212
  142. package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +0 -422
  143. package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +0 -445
  144. package/dist/create/server-source/crates/feltdb/src/content_distribution.rs +0 -465
  145. package/dist/create/server-source/crates/feltdb/src/convergence.rs +0 -634
  146. package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +0 -427
  147. package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +0 -376
  148. package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +0 -365
  149. package/dist/create/server-source/crates/feltdb/src/cron.rs +0 -294
  150. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +0 -483
  151. package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +0 -477
  152. package/dist/create/server-source/crates/feltdb/src/distributed_tests.rs +0 -1003
  153. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +0 -1334
  154. package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +0 -368
  155. package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +0 -231
  156. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +0 -482
  157. package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +0 -203
  158. package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +0 -317
  159. package/dist/create/server-source/crates/feltdb/src/equality_index.rs +0 -595
  160. package/dist/create/server-source/crates/feltdb/src/execution.rs +0 -426
  161. package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +0 -214
  162. package/dist/create/server-source/crates/feltdb/src/indexing.rs +0 -776
  163. package/dist/create/server-source/crates/feltdb/src/lib.rs +0 -7274
  164. package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +0 -234
  165. package/dist/create/server-source/crates/feltdb/src/materialization.rs +0 -184
  166. package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -266
  167. package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +0 -310
  168. package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +0 -736
  169. package/dist/create/server-source/crates/feltdb/src/observability.rs +0 -379
  170. package/dist/create/server-source/crates/feltdb/src/operation.rs +0 -290
  171. package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +0 -439
  172. package/dist/create/server-source/crates/feltdb/src/operation_log.rs +0 -349
  173. package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +0 -332
  174. package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +0 -718
  175. package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +0 -487
  176. package/dist/create/server-source/crates/feltdb/src/peer_registry.rs +0 -166
  177. package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +0 -289
  178. package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +0 -566
  179. package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +0 -3391
  180. package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +0 -203
  181. package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +0 -261
  182. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +0 -502
  183. package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +0 -216
  184. package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +0 -303
  185. package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +0 -323
  186. package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +0 -531
  187. package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +0 -406
  188. package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +0 -436
  189. package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +0 -387
  190. package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +0 -378
  191. package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +0 -359
  192. package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +0 -372
  193. package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +0 -404
  194. package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +0 -376
  195. package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +0 -461
  196. package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +0 -2125
  197. package/dist/create/server-source/crates/feltdb/src/production_api.rs +0 -418
  198. package/dist/create/server-source/crates/feltdb/src/provenance.rs +0 -207
  199. package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +0 -126
  200. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +0 -218
  201. package/dist/create/server-source/crates/feltdb/src/read_basis.rs +0 -257
  202. package/dist/create/server-source/crates/feltdb/src/references.rs +0 -404
  203. package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +0 -401
  204. package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +0 -501
  205. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +0 -680
  206. package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +0 -163
  207. package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +0 -133
  208. package/dist/create/server-source/crates/feltdb/src/routing.rs +0 -409
  209. package/dist/create/server-source/crates/feltdb/src/semantic_decision.rs +0 -2029
  210. package/dist/create/server-source/crates/feltdb/src/semantic_query.rs +0 -599
  211. package/dist/create/server-source/crates/feltdb/src/sharding.rs +0 -549
  212. package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +0 -516
  213. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +0 -5894
  214. package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +0 -222
  215. package/dist/create/server-source/crates/feltdb/src/state_facade.rs +0 -320
  216. package/dist/create/server-source/crates/feltdb/src/state_hash.rs +0 -291
  217. package/dist/create/server-source/crates/feltdb/src/state_model.rs +0 -4158
  218. package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +0 -379
  219. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +0 -673
  220. package/dist/create/server-source/crates/feltdb/src/storage.rs +0 -273
  221. package/dist/create/server-source/crates/feltdb/src/submission.rs +0 -471
  222. package/dist/create/server-source/crates/feltdb/src/sync.rs +0 -495
  223. package/dist/create/server-source/crates/feltdb/src/sync_contract.rs +0 -822
  224. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +0 -433
  225. package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +0 -462
  226. package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +0 -965
  227. package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +0 -1120
  228. package/dist/create/server-source/crates/feltdb/src/transactions.rs +0 -643
  229. package/dist/create/server-source/crates/feltdb/src/trigger.rs +0 -390
  230. package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +0 -749
  231. package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +0 -708
  232. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +0 -1133
  233. package/dist/create/server-source/crates/feltdb/src/workflow.rs +0 -713
  234. package/dist/create/server-source/crates/feltdb/src/workflow_acceptance_tests.rs +0 -510
  235. package/dist/create/server-source/crates/feltdb/src/workflow_integration.rs +0 -354
  236. package/dist/create/server-source/crates/feltdb/src/workflow_runtime.rs +0 -594
  237. package/dist/create/server-source/crates/feltdb/src/workload.rs +0 -2886
  238. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +0 -456
  239. package/dist/create/server-source/crates/feltdb/tests/agent_state_native.rs +0 -1188
  240. package/dist/create/server-source/crates/feltdb/tests/agent_transition_contract_conformance.rs +0 -2884
  241. package/dist/create/server-source/crates/feltdb/tests/authority_process.rs +0 -297
  242. package/dist/create/server-source/crates/feltdb/tests/authority_transaction_identity.rs +0 -534
  243. package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +0 -132
  244. package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +0 -299
  245. package/dist/create/server-source/crates/feltdb/tests/common/mod.rs +0 -96
  246. package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +0 -272
  247. package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +0 -467
  248. package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +0 -309
  249. package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +0 -445
  250. package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +0 -518
  251. package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +0 -392
  252. package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +0 -850
  253. package/dist/create/server-source/crates/feltdb/tests/fixtures/agent_transition_corpus.json +0 -23340
  254. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +0 -1916
  255. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +0 -1878
  256. package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +0 -1862
  257. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +0 -123
  258. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +0 -299
  259. package/dist/create/server-source/crates/feltdb/tests/local_process_ownership.rs +0 -69
  260. package/dist/create/server-source/crates/feltdb/tests/managed_incident_regression.rs +0 -191
  261. package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +0 -278
  262. package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +0 -234
  263. package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +0 -947
  264. package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +0 -404
  265. package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +0 -916
  266. package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +0 -1045
  267. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +0 -347
  268. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +0 -361
  269. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +0 -330
  270. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +0 -93
  271. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +0 -622
  272. package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +0 -517
  273. package/dist/create/server-source/crates/feltdb/tests/production_certification.rs +0 -1153
  274. package/dist/create/server-source/crates/feltdb/tests/production_contract.rs +0 -771
  275. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +0 -1383
  276. package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +0 -868
  277. package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +0 -221
  278. package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +0 -417
  279. package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +0 -338
  280. package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +0 -1039
  281. package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +0 -739
  282. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +0 -452
  283. package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +0 -81
  284. package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +0 -438
  285. package/dist/create/server-source/crates/feltdb/tests/semantic_decision.rs +0 -1165
  286. package/dist/create/server-source/crates/feltdb/tests/semantic_query.rs +0 -767
  287. package/dist/create/server-source/crates/feltdb/tests/semantic_query_contract.rs +0 -363
  288. package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +0 -1799
  289. package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +0 -1316
  290. package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +0 -358
  291. package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +0 -365
  292. package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +0 -918
  293. package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +0 -299
  294. package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +0 -328
  295. package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +0 -255
  296. package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +0 -249
  297. package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +0 -994
  298. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +0 -484
  299. package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
  300. 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
 
@@ -1090,12 +1094,22 @@ async fn push_sync(
1090
1094
  });
1091
1095
  continue;
1092
1096
  }
1093
- let target_revision = state
1097
+ // The production contract, never one the client names: with nothing promoted there is
1098
+ // no contract to write under.
1099
+ let Some(target_revision) = state
1094
1100
  .applications
1095
1101
  .pointers(&operation.application_id)
1096
1102
  .get("production")
1097
1103
  .cloned()
1098
- .unwrap_or_else(|| operation.application_revision.clone());
1104
+ else {
1105
+ rejections.push(SyncRejection {
1106
+ operation_id: operation.operation_id,
1107
+ code: "SYNC_SCHEMA_MISMATCH".into(),
1108
+ message: "application environment has no active contract".into(),
1109
+ retryable: false,
1110
+ });
1111
+ continue;
1112
+ };
1099
1113
  let Some(revision) = state.applications.revision(
1100
1114
  &operation.tenant_id,
1101
1115
  &operation.application_id,
@@ -1224,6 +1238,24 @@ async fn push_sync(
1224
1238
  .acquire()
1225
1239
  .await
1226
1240
  .expect("transaction admission is never closed");
1241
+ // A promotion between resolving the contract and taking the permit: refuse, write nothing.
1242
+ if ensure_revision_current(
1243
+ &state,
1244
+ &operation.application_id,
1245
+ "production",
1246
+ &revision.revision_id,
1247
+ true,
1248
+ )
1249
+ .is_err()
1250
+ {
1251
+ rejections.push(SyncRejection {
1252
+ operation_id: operation.operation_id,
1253
+ code: "SYNC_SCHEMA_MISMATCH".into(),
1254
+ message: "the production contract changed; nothing was written".into(),
1255
+ retryable: false,
1256
+ });
1257
+ continue;
1258
+ }
1227
1259
  match execute_transaction(&state.db, &schema, &transaction, None) {
1228
1260
  Ok(committed) => {
1229
1261
  let position = state
@@ -1466,6 +1498,10 @@ struct WorkloadFailRequest {
1466
1498
  fence: WorkloadFenceRequest,
1467
1499
  kind: String,
1468
1500
  error: String,
1501
+ /// `none` (failed before any external effect) or `unknown` (an effect may
1502
+ /// have happened). Omitted: the policy decides by `kind`.
1503
+ #[serde(default)]
1504
+ effect: Option<String>,
1469
1505
  }
1470
1506
  #[derive(Deserialize)]
1471
1507
  struct WorkloadContextRequest {
@@ -1482,6 +1518,16 @@ struct WorkloadControlRequest {
1482
1518
  reason: Option<String>,
1483
1519
  }
1484
1520
  #[derive(Deserialize)]
1521
+ struct WorkloadDecisionRequest {
1522
+ /// A `workload:approve` grant whose subject is the authenticated caller.
1523
+ grant: feltdb::authorization::SignedGrant,
1524
+ /// The `input_hash` of the work the decider reviewed.
1525
+ input_hash: String,
1526
+ /// Required for a rejection; recorded with the decision.
1527
+ #[serde(default)]
1528
+ reason: Option<String>,
1529
+ }
1530
+ #[derive(Deserialize)]
1485
1531
  struct WorkerHeartbeatRequest {
1486
1532
  heartbeat: WorkerHeartbeat,
1487
1533
  grant: feltdb::authorization::SignedGrant,
@@ -1496,6 +1542,45 @@ struct WorkerReconcileRequest {
1496
1542
  reconciliation: WorkerReconciliation,
1497
1543
  grant: feltdb::authorization::SignedGrant,
1498
1544
  }
1545
+ /// Whether this node commits Durable Work.
1546
+ ///
1547
+ /// Durable Work's guarantees rest on one serialization point for each
1548
+ /// workload: the guarded commit of one store. Peers replicate by causal
1549
+ /// order with last-writer-wins for concurrent writes, so two nodes that both
1550
+ /// committed work would each pass their own guard and silently diverge. A node
1551
+ /// without peers is therefore always the authority (the single-node default);
1552
+ /// in a peered cluster exactly one node, started with `--work-authority`,
1553
+ /// commits Durable Work, and every other node serves it read-only from its
1554
+ /// replicated store. A node that learns a peer is also the authority stops
1555
+ /// being one.
1556
+ fn is_work_authority(state: &AppState) -> bool {
1557
+ if state
1558
+ .work_authority_conflict
1559
+ .load(std::sync::atomic::Ordering::SeqCst)
1560
+ {
1561
+ return false;
1562
+ }
1563
+ state.work_authority || state.cluster.get().peers.is_empty()
1564
+ }
1565
+ /// Refuse a Durable Work write on a node that is not the authority.
1566
+ fn require_work_authority(state: &AppState) -> Result<(), ApiError> {
1567
+ if is_work_authority(state) {
1568
+ return Ok(());
1569
+ }
1570
+ let conflict = state
1571
+ .work_authority_conflict
1572
+ .load(std::sync::atomic::Ordering::SeqCst);
1573
+ Err(ApiError::structured(
1574
+ StatusCode::MISDIRECTED_REQUEST,
1575
+ json!({"code": "WORKLOAD_NOT_AUTHORITY",
1576
+ "message": if conflict {
1577
+ "more than one node is configured as the Durable Work authority; Durable Work writes are refused until that is corrected"
1578
+ } else {
1579
+ "this node replicates Durable Work read-only; send the request to the node started with --work-authority"
1580
+ },
1581
+ "details": {"reason": if conflict { "authority_conflict" } else { "replica" }, "retryable": false}}),
1582
+ ))
1583
+ }
1499
1584
  fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
1500
1585
  let status = match error.code.as_str() {
1501
1586
  "WORKLOAD_NOT_FOUND" => StatusCode::NOT_FOUND,
@@ -1505,8 +1590,18 @@ fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
1505
1590
  | "WORKLOAD_NOT_YET_ELIGIBLE"
1506
1591
  | "WORKLOAD_ATTEMPTS_EXHAUSTED"
1507
1592
  | "WORKLOAD_RETRY_EXHAUSTED"
1593
+ | "WORKLOAD_RESULT_CONFLICT"
1594
+ | "WORKLOAD_TIMED_OUT"
1595
+ | "WORKLOAD_APPROVAL_PENDING"
1596
+ | "WORKLOAD_REJECTED"
1597
+ | "WORKLOAD_APPROVAL_DECIDED"
1598
+ | "WORKLOAD_APPROVAL_STALE"
1599
+ | "WORKLOAD_APPROVAL_NOT_ACTIONABLE"
1600
+ | "WORKLOAD_APPROVAL_NOT_REQUIRED"
1508
1601
  | "WORKLOAD_CONFLICT" => StatusCode::CONFLICT,
1509
- "WORKLOAD_UNAUTHORIZED" | "WORKLOAD_CAPABILITY_DENIED" => StatusCode::FORBIDDEN,
1602
+ "WORKLOAD_UNAUTHORIZED"
1603
+ | "WORKLOAD_CAPABILITY_DENIED"
1604
+ | "WORKLOAD_APPROVER_NOT_PERMITTED" => StatusCode::FORBIDDEN,
1510
1605
  "WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
1511
1606
  _ => StatusCode::UNPROCESSABLE_ENTITY,
1512
1607
  };
@@ -1597,6 +1692,7 @@ async fn create_workload(
1597
1692
  Extension(principal): Extension<Principal>,
1598
1693
  Json(mut input): Json<CreateWorkload>,
1599
1694
  ) -> Result<(StatusCode, HeaderMap, Json<Value>), ApiError> {
1695
+ require_work_authority(&state)?;
1600
1696
  let subject = grant_subject(&principal);
1601
1697
  input.requested_by = subject.clone();
1602
1698
  input.session_id = principal.session_id.clone();
@@ -1624,10 +1720,18 @@ async fn create_workload(
1624
1720
  if error.code == "IDEMPOTENCY_KEY_REUSED" {
1625
1721
  // AppPort's contract for a reused key (§13.1): CONFLICT, with
1626
1722
  // the reason, not retryable.
1723
+ // `fields` names the execution-affecting request fields that
1724
+ // differ from the admitted workload.
1725
+ let fields = error
1726
+ .details
1727
+ .as_ref()
1728
+ .and_then(|details| details.get("fields"))
1729
+ .cloned()
1730
+ .unwrap_or_else(|| json!([]));
1627
1731
  ApiError::structured(
1628
1732
  StatusCode::CONFLICT,
1629
1733
  json!({"code": "CONFLICT", "message": error.message,
1630
- "details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false}}),
1734
+ "details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false, "fields": fields}}),
1631
1735
  )
1632
1736
  } else {
1633
1737
  workload_error(error)
@@ -1668,7 +1772,8 @@ async fn list_workloads(
1668
1772
  "workload store unavailable".into(),
1669
1773
  )
1670
1774
  })?
1671
- .list(&tenant, &target.application_id);
1775
+ .fresh_list(&tenant, &target.application_id)
1776
+ .map_err(workload_error)?;
1672
1777
  Ok(Json(json!({"workloads":values,"grant_id":target.grant_id})))
1673
1778
  }
1674
1779
  async fn get_workload(
@@ -1685,7 +1790,7 @@ async fn get_workload(
1685
1790
  "workload store unavailable".into(),
1686
1791
  )
1687
1792
  })?
1688
- .get(&id)
1793
+ .fresh(&id)
1689
1794
  .map_err(workload_error)?
1690
1795
  .clone();
1691
1796
  workload_read_scope(&state, &principal, &workload.application_id)?;
@@ -1715,11 +1820,12 @@ async fn claim_workload(
1715
1820
  Path(id): Path<String>,
1716
1821
  Json(input): Json<WorkloadClaimRequest>,
1717
1822
  ) -> Result<Json<Value>, ApiError> {
1823
+ require_work_authority(&state)?;
1718
1824
  let workload = state
1719
1825
  .workloads
1720
1826
  .lock()
1721
1827
  .unwrap()
1722
- .get(&id)
1828
+ .fresh(&id)
1723
1829
  .map_err(workload_error)?
1724
1830
  .clone();
1725
1831
  let subject = grant_subject(&principal);
@@ -1773,7 +1879,7 @@ fn authorize_fence(
1773
1879
  .workloads
1774
1880
  .lock()
1775
1881
  .unwrap()
1776
- .get(id)
1882
+ .fresh(id)
1777
1883
  .map_err(workload_error)?
1778
1884
  .clone();
1779
1885
  let subject = grant_subject(principal);
@@ -1800,6 +1906,7 @@ async fn start_workload(
1800
1906
  Path(id): Path<String>,
1801
1907
  Json(input): Json<WorkloadFenceRequest>,
1802
1908
  ) -> Result<StatusCode, ApiError> {
1909
+ require_work_authority(&state)?;
1803
1910
  authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
1804
1911
  state
1805
1912
  .workloads
@@ -1821,6 +1928,7 @@ async fn heartbeat_workload(
1821
1928
  Path(id): Path<String>,
1822
1929
  Json(input): Json<WorkloadHeartbeatRequest>,
1823
1930
  ) -> Result<Json<Value>, ApiError> {
1931
+ require_work_authority(&state)?;
1824
1932
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1825
1933
  let claim = state
1826
1934
  .workloads
@@ -1843,6 +1951,7 @@ async fn checkpoint_workload(
1843
1951
  Path(id): Path<String>,
1844
1952
  Json(input): Json<WorkloadCheckpointRequest>,
1845
1953
  ) -> Result<Json<Value>, ApiError> {
1954
+ require_work_authority(&state)?;
1846
1955
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1847
1956
  let checkpoint = state
1848
1957
  .workloads
@@ -1865,6 +1974,7 @@ async fn complete_workload(
1865
1974
  Path(id): Path<String>,
1866
1975
  Json(input): Json<WorkloadCompleteRequest>,
1867
1976
  ) -> Result<Json<Value>, ApiError> {
1977
+ require_work_authority(&state)?;
1868
1978
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1869
1979
  let result = state
1870
1980
  .workloads
@@ -1891,18 +2001,20 @@ async fn fail_workload(
1891
2001
  Path(id): Path<String>,
1892
2002
  Json(input): Json<WorkloadFailRequest>,
1893
2003
  ) -> Result<Json<Value>, ApiError> {
2004
+ require_work_authority(&state)?;
1894
2005
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1895
2006
  let status = state
1896
2007
  .workloads
1897
2008
  .lock()
1898
2009
  .unwrap()
1899
- .fail(
2010
+ .fail_with_effect(
1900
2011
  &id,
1901
2012
  &input.fence.claim_id,
1902
2013
  &input.fence.worker_id,
1903
2014
  input.fence.fencing_token,
1904
2015
  &input.kind,
1905
2016
  &input.error,
2017
+ input.effect.as_deref(),
1906
2018
  unix_seconds_i64(),
1907
2019
  )
1908
2020
  .map_err(workload_error)?;
@@ -1915,6 +2027,7 @@ async fn confirm_workload_cancelled(
1915
2027
  Path(id): Path<String>,
1916
2028
  Json(input): Json<WorkloadFenceRequest>,
1917
2029
  ) -> Result<StatusCode, ApiError> {
2030
+ require_work_authority(&state)?;
1918
2031
  authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
1919
2032
  state
1920
2033
  .workloads
@@ -1936,6 +2049,7 @@ async fn workload_execution_context(
1936
2049
  Path(id): Path<String>,
1937
2050
  Json(input): Json<WorkloadContextRequest>,
1938
2051
  ) -> Result<Json<Value>, ApiError> {
2052
+ require_work_authority(&state)?;
1939
2053
  authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
1940
2054
  let context = state
1941
2055
  .workloads
@@ -1958,11 +2072,12 @@ async fn cancel_workload(
1958
2072
  Path(id): Path<String>,
1959
2073
  Json(input): Json<WorkloadControlRequest>,
1960
2074
  ) -> Result<Json<Value>, ApiError> {
2075
+ require_work_authority(&state)?;
1961
2076
  let workload = state
1962
2077
  .workloads
1963
2078
  .lock()
1964
2079
  .unwrap()
1965
- .get(&id)
2080
+ .fresh(&id)
1966
2081
  .map_err(workload_error)?
1967
2082
  .clone();
1968
2083
  workload_decision(
@@ -1988,11 +2103,12 @@ async fn retry_workload(
1988
2103
  Path(id): Path<String>,
1989
2104
  Json(input): Json<WorkloadControlRequest>,
1990
2105
  ) -> Result<StatusCode, ApiError> {
2106
+ require_work_authority(&state)?;
1991
2107
  let workload = state
1992
2108
  .workloads
1993
2109
  .lock()
1994
2110
  .unwrap()
1995
- .get(&id)
2111
+ .fresh(&id)
1996
2112
  .map_err(workload_error)?
1997
2113
  .clone();
1998
2114
  workload_decision(
@@ -2012,6 +2128,99 @@ async fn retry_workload(
2012
2128
  .map_err(workload_error)?;
2013
2129
  Ok(StatusCode::NO_CONTENT)
2014
2130
  }
2131
+ async fn approve_workload(
2132
+ State(state): State<AppState>,
2133
+ Extension(principal): Extension<Principal>,
2134
+ Path(id): Path<String>,
2135
+ Json(input): Json<WorkloadDecisionRequest>,
2136
+ ) -> Result<(HeaderMap, Json<Value>), ApiError> {
2137
+ decide_workload(
2138
+ &state,
2139
+ &principal,
2140
+ &id,
2141
+ input,
2142
+ feltdb::workload::ApprovalOutcome::Approved,
2143
+ )
2144
+ }
2145
+ async fn reject_workload(
2146
+ State(state): State<AppState>,
2147
+ Extension(principal): Extension<Principal>,
2148
+ Path(id): Path<String>,
2149
+ Json(input): Json<WorkloadDecisionRequest>,
2150
+ ) -> Result<(HeaderMap, Json<Value>), ApiError> {
2151
+ decide_workload(
2152
+ &state,
2153
+ &principal,
2154
+ &id,
2155
+ input,
2156
+ feltdb::workload::ApprovalOutcome::Rejected,
2157
+ )
2158
+ }
2159
+ /// Commit an approval decision. The caller must be the Durable Work
2160
+ /// authority's client, authenticate as the grant's subject, and hold
2161
+ /// `workload:approve` for this workload; the store then applies the
2162
+ /// workload's approval policy (permitted kinds and approvers, never the
2163
+ /// requester) and binds the decision to the reviewed input.
2164
+ fn decide_workload(
2165
+ state: &AppState,
2166
+ principal: &Principal,
2167
+ id: &str,
2168
+ input: WorkloadDecisionRequest,
2169
+ outcome: feltdb::workload::ApprovalOutcome,
2170
+ ) -> Result<(HeaderMap, Json<Value>), ApiError> {
2171
+ require_work_authority(state)?;
2172
+ let workload = state
2173
+ .workloads
2174
+ .lock()
2175
+ .map_err(|_| {
2176
+ ApiError(
2177
+ StatusCode::INTERNAL_SERVER_ERROR,
2178
+ "workload store unavailable".into(),
2179
+ )
2180
+ })?
2181
+ .fresh(id)
2182
+ .map_err(workload_error)?
2183
+ .clone();
2184
+ let decider = grant_subject(principal);
2185
+ workload_decision(
2186
+ state,
2187
+ decider.clone(),
2188
+ &workload.tenant_id,
2189
+ &workload.application_id,
2190
+ format!("flow://{}/workloads/{id}", workload.application_id),
2191
+ "workload:approve",
2192
+ &input.grant,
2193
+ )?;
2194
+ let mut store = state.workloads.lock().map_err(|_| {
2195
+ ApiError(
2196
+ StatusCode::INTERNAL_SERVER_ERROR,
2197
+ "workload store unavailable".into(),
2198
+ )
2199
+ })?;
2200
+ let (decision, replayed) = store
2201
+ .decide(
2202
+ id,
2203
+ &decider,
2204
+ outcome,
2205
+ input.reason.as_deref(),
2206
+ &input.grant.grant.id,
2207
+ &input.input_hash,
2208
+ unix_seconds_i64(),
2209
+ )
2210
+ .map_err(workload_error)?;
2211
+ let state_now = store.get(id).map_err(workload_error)?.state;
2212
+ let mut headers = HeaderMap::new();
2213
+ if replayed {
2214
+ headers.insert(
2215
+ "idempotent-replay",
2216
+ axum::http::HeaderValue::from_static("true"),
2217
+ );
2218
+ }
2219
+ Ok((
2220
+ headers,
2221
+ Json(json!({"decision": decision, "state": state_now})),
2222
+ ))
2223
+ }
2015
2224
  async fn workload_history(
2016
2225
  State(state): State<AppState>,
2017
2226
  Extension(principal): Extension<Principal>,
@@ -2021,7 +2230,7 @@ async fn workload_history(
2021
2230
  .workloads
2022
2231
  .lock()
2023
2232
  .unwrap()
2024
- .get(&id)
2233
+ .fresh(&id)
2025
2234
  .map_err(workload_error)?
2026
2235
  .clone();
2027
2236
  workload_read_scope(&state, &principal, &workload.application_id)?;
@@ -2038,7 +2247,7 @@ async fn workload_result(
2038
2247
  .workloads
2039
2248
  .lock()
2040
2249
  .unwrap()
2041
- .get(&id)
2250
+ .fresh(&id)
2042
2251
  .map_err(workload_error)?
2043
2252
  .clone();
2044
2253
  workload_read_scope(&state, &principal, &workload.application_id)?;
@@ -2223,7 +2432,8 @@ async fn reconcile_worker(
2223
2432
  ) -> Result<Json<Value>, ApiError> {
2224
2433
  authorize_worker_action(&state, &principal, &id, &input.grant, "worker:heartbeat")?;
2225
2434
  input.reconciliation.worker_id = id;
2226
- let workloads = state.workloads.lock().unwrap();
2435
+ let mut workloads = state.workloads.lock().unwrap();
2436
+ workloads.refresh().map_err(workload_error)?;
2227
2437
  let result = state
2228
2438
  .mesh
2229
2439
  .lock()
@@ -2314,7 +2524,7 @@ async fn eligible_workers(
2314
2524
  .workloads
2315
2525
  .lock()
2316
2526
  .unwrap()
2317
- .get(&id)
2527
+ .fresh(&id)
2318
2528
  .map_err(workload_error)?
2319
2529
  .clone();
2320
2530
  workload_read_scope(&state, &principal, &workload.application_id)?;
@@ -4539,6 +4749,7 @@ async fn promote_environment(
4539
4749
  Path((application_id, environment)): Path<(String, String)>,
4540
4750
  Json(input): Json<MoveEnvironmentRequest>,
4541
4751
  ) -> Result<Json<Value>, ApiError> {
4752
+ let _drain = drain_state_commits(&state).await;
4542
4753
  Ok(Json(move_environment(
4543
4754
  &state,
4544
4755
  &principal,
@@ -4557,6 +4768,7 @@ async fn rollback_environment(
4557
4768
  Path((application_id, environment)): Path<(String, String)>,
4558
4769
  Json(input): Json<RollbackEnvironmentRequest>,
4559
4770
  ) -> Result<Json<Value>, ApiError> {
4771
+ let _drain = drain_state_commits(&state).await;
4560
4772
  Ok(Json(move_environment(
4561
4773
  &state,
4562
4774
  &principal,
@@ -4575,6 +4787,7 @@ async fn promote_application_revision(
4575
4787
  Path((application_id, revision_id)): Path<(String, String)>,
4576
4788
  Json(input): Json<PromoteRevisionRequest>,
4577
4789
  ) -> Result<Json<Value>, ApiError> {
4790
+ let _drain = drain_state_commits(&state).await;
4578
4791
  Ok(Json(move_environment(
4579
4792
  &state,
4580
4793
  &principal,
@@ -4681,6 +4894,97 @@ fn runtime_inventory(
4681
4894
  enforce_availability: true,
4682
4895
  })
4683
4896
  }
4897
+ /// The contract a state request runs under: the revision promoted to `environment`, and
4898
+ /// only that one.
4899
+ ///
4900
+ /// A client names the revision it discovered. Any other revision (one a later promotion
4901
+ /// superseded, or one committed but never promoted) is refused with `REVISION_NOT_ACTIVE`,
4902
+ /// naming the active one, before anything is read or written. Accepting it would apply a
4903
+ /// contract that is not the environment's: fields a newer contract removed, policies it
4904
+ /// tightened, or a revision whose promotion was never approved. The client re-discovers and
4905
+ /// sends its next request under the active revision; nothing is retried under another contract.
4906
+ fn active_revision_contract(
4907
+ state: &AppState,
4908
+ actor: &str,
4909
+ application_id: &str,
4910
+ revision_id: &str,
4911
+ environment: &str,
4912
+ ) -> Result<ApplicationRuntimeContract, ApiError> {
4913
+ application_scope(state, actor, application_id, "application:revision:read")?;
4914
+ let active = state
4915
+ .applications
4916
+ .pointers(application_id)
4917
+ .get(environment)
4918
+ .cloned()
4919
+ .ok_or(ApiError(
4920
+ StatusCode::NOT_FOUND,
4921
+ "application environment has no active contract".into(),
4922
+ ))?;
4923
+ if active != revision_id {
4924
+ return Err(revision_not_active(revision_id, &active, environment));
4925
+ }
4926
+ revision_contract(state, actor, application_id, revision_id, environment)
4927
+ }
4928
+ /// A revision runtime runs in an environment that has a promoted contract only as that
4929
+ /// contract. An environment nothing is promoted to (a preview) runs any committed revision.
4930
+ fn runtime_revision_contract(
4931
+ state: &AppState,
4932
+ actor: &str,
4933
+ application_id: &str,
4934
+ revision_id: &str,
4935
+ environment: &str,
4936
+ ) -> Result<ApplicationRuntimeContract, ApiError> {
4937
+ application_scope(state, actor, application_id, "application:revision:read")?;
4938
+ if let Some(active) = state.applications.pointers(application_id).get(environment) {
4939
+ if active != revision_id {
4940
+ return Err(revision_not_active(revision_id, active, environment));
4941
+ }
4942
+ }
4943
+ revision_contract(state, actor, application_id, revision_id, environment)
4944
+ }
4945
+ /// Commits that write application state each hold one of these permits.
4946
+ const STATE_TRANSACTION_PERMITS: u32 = 2;
4947
+ /// Hold every commit permit while an environment's contract pointer moves: the move waits for
4948
+ /// commits in flight, and no commit starts until it is done. With `ensure_revision_current`
4949
+ /// checked under a permit, a commit and a promotion are ordered: a commit never lands under a
4950
+ /// contract a promotion has already replaced.
4951
+ async fn drain_state_commits(state: &AppState) -> tokio::sync::SemaphorePermit<'_> {
4952
+ state
4953
+ .state_transaction_admission
4954
+ .acquire_many(STATE_TRANSACTION_PERMITS)
4955
+ .await
4956
+ .expect("transaction admission is never closed")
4957
+ }
4958
+ /// Re-check, while holding a commit permit, that `revision_id` is still the environment's
4959
+ /// contract (`require_promoted`: an environment with nothing promoted refuses too).
4960
+ fn ensure_revision_current(
4961
+ state: &AppState,
4962
+ application_id: &str,
4963
+ environment: &str,
4964
+ revision_id: &str,
4965
+ require_promoted: bool,
4966
+ ) -> Result<(), ApiError> {
4967
+ match state.applications.pointers(application_id).get(environment) {
4968
+ Some(active) if active != revision_id => Err(revision_not_active(revision_id, active, environment)),
4969
+ None if require_promoted => Err(ApiError(
4970
+ StatusCode::NOT_FOUND,
4971
+ "application environment has no active contract".into(),
4972
+ )),
4973
+ _ => Ok(()),
4974
+ }
4975
+ }
4976
+ fn revision_not_active(requested: &str, active: &str, environment: &str) -> ApiError {
4977
+ ApiError::structured(
4978
+ StatusCode::CONFLICT,
4979
+ json!({
4980
+ "code": "REVISION_NOT_ACTIVE",
4981
+ "message": format!("revision {requested} is not the contract active in {environment}; {active} is. Nothing was read or written. Whether to send the request again under the active revision is the caller's decision."),
4982
+ "requested_revision_id": requested,
4983
+ "active_revision_id": active,
4984
+ "environment": environment,
4985
+ }),
4986
+ )
4987
+ }
4684
4988
  fn revision_contract(
4685
4989
  state: &AppState,
4686
4990
  actor: &str,
@@ -6957,6 +7261,139 @@ async fn auth_sign_up(
6957
7261
  .map_err(control_error)?;
6958
7262
  Ok((StatusCode::CREATED, auth_response(identity, session, token)))
6959
7263
  }
7264
+ #[derive(Deserialize)]
7265
+ struct ConsolePasswordRequest {
7266
+ email: String,
7267
+ password: String,
7268
+ }
7269
+ #[derive(Deserialize)]
7270
+ struct ConsoleGithubRequest {
7271
+ github_id: Value,
7272
+ login: String,
7273
+ }
7274
+
7275
+ fn console_account_error(error: console_accounts::AccountError) -> ApiError {
7276
+ ApiError::structured(
7277
+ StatusCode::from_u16(error.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
7278
+ json!({ "code": error.code, "message": error.message }),
7279
+ )
7280
+ }
7281
+
7282
+ /// Authenticate a website call to `/v1/console/accounts/*` and parse its body.
7283
+ fn console_request<T: serde::de::DeserializeOwned>(
7284
+ state: &AppState,
7285
+ method: &Method,
7286
+ uri: &axum::http::Uri,
7287
+ headers: &HeaderMap,
7288
+ body: &[u8],
7289
+ ) -> Result<T, ApiError> {
7290
+ let Some(secret) = state.console_accounts.secret() else {
7291
+ return Err(ApiError::structured(
7292
+ StatusCode::SERVICE_UNAVAILABLE,
7293
+ json!({
7294
+ "code": "CONSOLE_ACCOUNTS_UNCONFIGURED",
7295
+ "message": "console accounts require FELTDB_SESSION_SECRET (at least 32 bytes) on the control plane",
7296
+ }),
7297
+ ));
7298
+ };
7299
+ let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
7300
+ let now = std::time::SystemTime::now()
7301
+ .duration_since(std::time::UNIX_EPOCH)
7302
+ .unwrap_or_default()
7303
+ .as_secs();
7304
+ if let Err(error) = console_accounts::verify_console_request(
7305
+ secret,
7306
+ method.as_str(),
7307
+ uri.path(),
7308
+ header(console_accounts::TIMESTAMP_HEADER),
7309
+ header(console_accounts::SIGNATURE_HEADER),
7310
+ body,
7311
+ now,
7312
+ ) {
7313
+ state.metrics.authentication_failure();
7314
+ audit(state, "anonymous", "console.accounts", uri.path(), "denied", 401);
7315
+ return Err(console_account_error(error));
7316
+ }
7317
+ serde_json::from_slice(body)
7318
+ .map_err(|error| ApiError(StatusCode::BAD_REQUEST, format!("invalid request body: {error}")))
7319
+ }
7320
+
7321
+ /// Argon2 work and the account file write run off the request worker.
7322
+ async fn console_blocking<F>(work: F) -> Result<Json<Value>, ApiError>
7323
+ where
7324
+ F: FnOnce() -> Result<console_accounts::ResolvedAccount, console_accounts::AccountError>
7325
+ + Send
7326
+ + 'static,
7327
+ {
7328
+ match tokio::task::spawn_blocking(work).await {
7329
+ Ok(Ok(account)) => Ok(Json(json!({
7330
+ "user_id": account.user_id,
7331
+ "identity": account.identity,
7332
+ "display_identity": account.display_identity,
7333
+ }))),
7334
+ Ok(Err(error)) => Err(console_account_error(error)),
7335
+ Err(_) => Err(ApiError(
7336
+ StatusCode::INTERNAL_SERVER_ERROR,
7337
+ "console account operation failed".into(),
7338
+ )),
7339
+ }
7340
+ }
7341
+
7342
+ async fn console_password_signup(
7343
+ State(state): State<AppState>,
7344
+ method: Method,
7345
+ uri: axum::http::Uri,
7346
+ headers: HeaderMap,
7347
+ body: axum::body::Bytes,
7348
+ ) -> Result<(StatusCode, Json<Value>), ApiError> {
7349
+ let input: ConsolePasswordRequest = console_request(&state, &method, &uri, &headers, &body)?;
7350
+ let created = console_blocking(move || {
7351
+ state
7352
+ .console_accounts
7353
+ .password_signup(&state.tenancy, &input.email, &input.password)
7354
+ })
7355
+ .await?;
7356
+ Ok((StatusCode::CREATED, created))
7357
+ }
7358
+
7359
+ async fn console_password_signin(
7360
+ State(state): State<AppState>,
7361
+ method: Method,
7362
+ uri: axum::http::Uri,
7363
+ headers: HeaderMap,
7364
+ body: axum::body::Bytes,
7365
+ ) -> Result<Json<Value>, ApiError> {
7366
+ let input: ConsolePasswordRequest = console_request(&state, &method, &uri, &headers, &body)?;
7367
+ console_blocking(move || {
7368
+ state
7369
+ .console_accounts
7370
+ .password_signin(&input.email, &input.password)
7371
+ })
7372
+ .await
7373
+ }
7374
+
7375
+ async fn console_github_resolve(
7376
+ State(state): State<AppState>,
7377
+ method: Method,
7378
+ uri: axum::http::Uri,
7379
+ headers: HeaderMap,
7380
+ body: axum::body::Bytes,
7381
+ ) -> Result<Json<Value>, ApiError> {
7382
+ let input: ConsoleGithubRequest = console_request(&state, &method, &uri, &headers, &body)?;
7383
+ // GitHub user ids are numbers; accept the number or its decimal string.
7384
+ let github_id = match &input.github_id {
7385
+ Value::Number(number) if number.is_u64() => number.to_string(),
7386
+ Value::String(text) => text.clone(),
7387
+ _ => String::new(),
7388
+ };
7389
+ console_blocking(move || {
7390
+ state
7391
+ .console_accounts
7392
+ .github_resolve(&state.tenancy, &github_id, &input.login)
7393
+ })
7394
+ .await
7395
+ }
7396
+
6960
7397
  async fn auth_sign_in(
6961
7398
  State(state): State<AppState>,
6962
7399
  Json(input): Json<SignInRequest>,
@@ -7095,7 +7532,7 @@ async fn run_revision_runtime(
7095
7532
  &application_id,
7096
7533
  "application:runtime:run",
7097
7534
  )?;
7098
- let contract = revision_contract(
7535
+ let contract = runtime_revision_contract(
7099
7536
  &state,
7100
7537
  &principal.key_id,
7101
7538
  &application_id,
@@ -7160,7 +7597,7 @@ async fn run_runtime_action(
7160
7597
  Json(input): Json<Value>,
7161
7598
  ) -> Result<Json<Value>, ApiError> {
7162
7599
  application_scope(&state, &principal.key_id, &application_id, "state:write")?;
7163
- let contract = revision_contract(
7600
+ let contract = runtime_revision_contract(
7164
7601
  &state,
7165
7602
  &principal.key_id,
7166
7603
  &application_id,
@@ -7259,6 +7696,7 @@ async fn run_runtime_action(
7259
7696
  .acquire()
7260
7697
  .await
7261
7698
  .expect("transaction admission is never closed");
7699
+ ensure_revision_current(&state, &application_id, &environment.environment, &contract.revision_id, false)?;
7262
7700
  Ok(Json(json!(execute_transaction(
7263
7701
  &state.db,
7264
7702
  &contract.state.schema,
@@ -7290,7 +7728,7 @@ async fn run_runtime_query(
7290
7728
  Query(environment): Query<RuntimeEnvironmentRequest>,
7291
7729
  ) -> Result<Json<Value>, ApiError> {
7292
7730
  application_scope(&state, &principal.key_id, &application_id, "state:read")?;
7293
- let contract = revision_contract(
7731
+ let contract = runtime_revision_contract(
7294
7732
  &state,
7295
7733
  &principal.key_id,
7296
7734
  &application_id,
@@ -7392,7 +7830,7 @@ async fn run_runtime_workflow(
7392
7830
  Json(input): Json<StartRun>,
7393
7831
  ) -> Result<(StatusCode, Json<Value>), ApiError> {
7394
7832
  application_scope(&state, &principal.key_id, &application_id, "workflows:run")?;
7395
- let contract = revision_contract(
7833
+ let contract = runtime_revision_contract(
7396
7834
  &state,
7397
7835
  &principal.key_id,
7398
7836
  &application_id,
@@ -7595,7 +8033,9 @@ fn state_contract_error(error: feltdb::state_contract::StateFailure) -> ApiError
7595
8033
  "PRECONDITION_FAILED" | "CONFLICT" | "IDEMPOTENCY_CONFLICT" => StatusCode::CONFLICT,
7596
8034
  "UNKNOWN_COLLECTION" => StatusCode::NOT_FOUND,
7597
8035
  "SEMANTIC_UNSUPPORTED" => StatusCode::NOT_IMPLEMENTED,
7598
- "STORAGE_FAILURE" | "STATE_UNAVAILABLE" => StatusCode::SERVICE_UNAVAILABLE,
8036
+ "STORAGE_FAILURE" | "STATE_UNAVAILABLE" | "DURABILITY_FAILURE" => {
8037
+ StatusCode::SERVICE_UNAVAILABLE
8038
+ }
7599
8039
  _ => StatusCode::UNPROCESSABLE_ENTITY,
7600
8040
  };
7601
8041
  ApiError(
@@ -7647,7 +8087,7 @@ async fn get_state_version(
7647
8087
  &target.application_id,
7648
8088
  "state:read",
7649
8089
  )?;
7650
- let contract = revision_contract(
8090
+ let contract = active_revision_contract(
7651
8091
  &state,
7652
8092
  &principal.key_id,
7653
8093
  &target.application_id,
@@ -7689,7 +8129,7 @@ async fn execute_canonical_query(
7689
8129
  "state:read",
7690
8130
  )?;
7691
8131
  require_bound_tenant(&headers, &tenant)?;
7692
- let contract = revision_contract(
8132
+ let contract = active_revision_contract(
7693
8133
  &state,
7694
8134
  &principal.key_id,
7695
8135
  &input.application_id,
@@ -7998,7 +8438,7 @@ async fn capture_canonical_read_basis(
7998
8438
  "state:read",
7999
8439
  )?;
8000
8440
  require_bound_tenant(&headers, &tenant)?;
8001
- let contract = revision_contract(
8441
+ let contract = active_revision_contract(
8002
8442
  &state,
8003
8443
  &principal.key_id,
8004
8444
  &input.application_id,
@@ -8029,7 +8469,7 @@ async fn execute_canonical_transaction(
8029
8469
  "state:write",
8030
8470
  )?;
8031
8471
  require_bound_tenant(&headers, &tenant)?;
8032
- let contract = revision_contract(
8472
+ let contract = active_revision_contract(
8033
8473
  &state,
8034
8474
  &principal.key_id,
8035
8475
  &input.application_id,
@@ -8118,6 +8558,7 @@ async fn execute_canonical_transaction(
8118
8558
  .acquire()
8119
8559
  .await
8120
8560
  .expect("transaction admission is never closed");
8561
+ ensure_revision_current(&state, &input.application_id, &input.environment, &contract.revision_id, true)?;
8121
8562
  let result = execute_transaction_with_collection_policies(
8122
8563
  &state.db,
8123
8564
  &contract.state.schema,
@@ -8232,6 +8673,10 @@ struct SyncPullResponse {
8232
8673
  operations: Vec<Operation>,
8233
8674
  versions: HashMap<String, u64>,
8234
8675
  catchup: CatchupStatus,
8676
+ /// Whether the answering node is configured as the Durable Work authority
8677
+ /// (`--work-authority`). Absent from older peers.
8678
+ #[serde(default)]
8679
+ durable_work_authority: bool,
8235
8680
  }
8236
8681
 
8237
8682
  #[derive(Serialize, Deserialize)]
@@ -8480,6 +8925,7 @@ struct Config {
8480
8925
  audit: PathBuf,
8481
8926
  allowed_origins: Vec<axum::http::HeaderValue>,
8482
8927
  worker_enabled: bool,
8928
+ work_authority: bool,
8483
8929
  advertise_url: Option<String>,
8484
8930
  maximum_clock_drift_ms: u64,
8485
8931
  }
@@ -8528,6 +8974,7 @@ impl Config {
8528
8974
  .transpose()?
8529
8975
  .unwrap_or_default();
8530
8976
  let mut worker_enabled = std::env::var("FELTDB_WORKER_ENABLED").as_deref() == Ok("true");
8977
+ let mut work_authority = std::env::var("FELTDB_WORK_AUTHORITY").as_deref() == Ok("true");
8531
8978
  let mut advertise_url = std::env::var("FELTDB_ADVERTISE_URL")
8532
8979
  .ok()
8533
8980
  .map(|value| value.trim_end_matches('/').to_string());
@@ -8580,6 +9027,7 @@ impl Config {
8580
9027
  .map_err(|_| "invalid --allow-origin".to_string())?,
8581
9028
  ),
8582
9029
  "--worker" => worker_enabled = true,
9030
+ "--work-authority" => work_authority = true,
8583
9031
  "--advertise-url" => {
8584
9032
  advertise_url = Some(value()?.trim_end_matches('/').to_string())
8585
9033
  }
@@ -8594,7 +9042,7 @@ impl Config {
8594
9042
  }
8595
9043
  }
8596
9044
  "--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]");
9045
+ 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
9046
  std::process::exit(0);
8599
9047
  }
8600
9048
  _ => return Err(format!("unknown argument: {argument}")),
@@ -8617,6 +9065,7 @@ impl Config {
8617
9065
  audit,
8618
9066
  allowed_origins,
8619
9067
  worker_enabled,
9068
+ work_authority,
8620
9069
  advertise_url,
8621
9070
  maximum_clock_drift_ms,
8622
9071
  })
@@ -8687,10 +9136,28 @@ fn validate_production_configuration(config: &Config) -> Result<(), Box<dyn std:
8687
9136
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
8688
9137
  tracing_subscriber::fmt()
8689
9138
  .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
9139
+ // Every log line passes through credential redaction, whatever wrote it.
9140
+ .with_writer(feltdb_server::redaction::RedactingStdout)
8690
9141
  .init();
9142
+ if matches!(
9143
+ std::env::args().nth(1).as_deref(),
9144
+ Some("version" | "--version" | "-V")
9145
+ ) {
9146
+ // One line an operator script can print instead of a hard-coded banner.
9147
+ println!(
9148
+ "feltdb-server {} (release {}, commit {})",
9149
+ env!("CARGO_PKG_VERSION"),
9150
+ option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
9151
+ option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown")
9152
+ );
9153
+ return Ok(());
9154
+ }
8691
9155
  if manage_keys()? {
8692
9156
  return Ok(());
8693
9157
  }
9158
+ if manage_console_accounts()? {
9159
+ return Ok(());
9160
+ }
8694
9161
  if manage_authorization()? {
8695
9162
  return Ok(());
8696
9163
  }
@@ -8825,8 +9292,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8825
9292
  peer_client,
8826
9293
  peer_token,
8827
9294
  lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
8828
- state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(2)),
9295
+ state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(STATE_TRANSACTION_PERMITS as usize)),
8829
9296
  worker_enabled: config.worker_enabled,
9297
+ work_authority: config.work_authority,
9298
+ work_authority_conflict: Arc::new(std::sync::atomic::AtomicBool::new(false)),
8830
9299
  leases: LeaseStore::load(config.data.with_extension("leases.json"))?,
8831
9300
  content: content_store,
8832
9301
  artifacts: artifact_store,
@@ -8848,6 +9317,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8848
9317
  .into_bytes(),
8849
9318
  config.data.with_extension("revoked-sessions.json"),
8850
9319
  )?,
9320
+ console_accounts: ConsoleAccountStore::load(
9321
+ config.data.with_extension("console-accounts.json"),
9322
+ console_accounts::configured_secret(std::env::var("FELTDB_SESSION_SECRET").ok()),
9323
+ )?,
8851
9324
  principals: principal_authority,
8852
9325
  connections: ConnectionStore::load_with_provider(
8853
9326
  config.data.with_extension("connections.json"),
@@ -8936,6 +9409,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8936
9409
  .route("/api/tenants/{tenant_id}", get(get_tenant))
8937
9410
  .route("/api/keys", get(list_keys).post(create_key))
8938
9411
  .route("/api/keys/{id}", axum::routing::delete(revoke_key))
9412
+ .route("/api/keys/{id}/rotate", axum::routing::post(rotate_key))
9413
+ // Operator observability: authenticated, `operator:read`.
9414
+ .route("/metrics", get(network_metrics))
9415
+ .route("/v1/status", get(operator_status))
8939
9416
  .route(
8940
9417
  "/api/tenants/{tenant_id}/applications",
8941
9418
  get(list_applications).post(create_application_control),
@@ -9293,6 +9770,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9293
9770
  "/v1/workloads/{id}/retry",
9294
9771
  axum::routing::post(retry_workload),
9295
9772
  )
9773
+ .route(
9774
+ "/v1/workloads/{id}/approve",
9775
+ axum::routing::post(approve_workload),
9776
+ )
9777
+ .route(
9778
+ "/v1/workloads/{id}/reject",
9779
+ axum::routing::post(reject_workload),
9780
+ )
9296
9781
  .route("/v1/workloads/{id}/history", get(workload_history))
9297
9782
  .route("/v1/workloads/{id}/result", get(workload_result))
9298
9783
  .route("/v1/workloads/{id}/eligible-workers", get(eligible_workers))
@@ -9596,23 +10081,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9596
10081
  .route("/v1/openapi.json", get(application_openapi))
9597
10082
  .route("/v1/auth/signup", axum::routing::post(auth_sign_up))
9598
10083
  .route("/v1/auth/signin", axum::routing::post(auth_sign_in))
9599
- .route("/internal/intents/by-hash", get(internal_find_intent))
10084
+ // Console accounts: callable only by the website, which signs each
10085
+ // request with FELTDB_SESSION_SECRET (see console_accounts.rs).
9600
10086
  .route(
9601
- "/internal/intents",
9602
- axum::routing::post(internal_create_intent),
10087
+ "/v1/console/accounts/password/signup",
10088
+ axum::routing::post(console_password_signup),
9603
10089
  )
9604
- .route("/internal/intents/{id}", put(internal_replace_intent))
9605
10090
  .route(
9606
- "/internal/intent-events",
9607
- axum::routing::post(internal_create_intent_event),
10091
+ "/v1/console/accounts/password/signin",
10092
+ axum::routing::post(console_password_signin),
9608
10093
  )
9609
- .route("/ready", get(readiness))
9610
- .route("/runtime", get(runtime))
9611
- .route("/metrics", get(network_metrics))
9612
10094
  .route(
9613
- "/api/certification/fixtures/{application_id}",
9614
- axum::routing::delete(delete_certification_fixture),
10095
+ "/v1/console/accounts/github/resolve",
10096
+ axum::routing::post(console_github_resolve),
9615
10097
  )
10098
+ .route("/ready", get(readiness))
10099
+ .route("/runtime", get(runtime))
10100
+ .merge(service_secret_routes())
9616
10101
  .merge(protected)
9617
10102
  .merge(query_execution_diagnostics_router())
9618
10103
  .layer(middleware::from_fn_with_state(state.clone(), admit_request))
@@ -9620,7 +10105,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9620
10105
  .layer(middleware::from_fn(attribute_request))
9621
10106
  .layer(middleware::from_fn_with_state(state.clone(), count_request))
9622
10107
  .layer(RequestBodyLimitLayer::new(1024 * 1024))
9623
- .layer(TraceLayer::new_for_http())
10108
+ .layer(middleware::from_fn(redact_error_responses))
10109
+ // Spans carry the path, never the query string, where clients put tokens.
10110
+ .layer(TraceLayer::new_for_http().make_span_with(
10111
+ |request: &Request<axum::body::Body>| {
10112
+ tracing::debug_span!(
10113
+ "request",
10114
+ method = %request.method(),
10115
+ path = %request.uri().path(),
10116
+ )
10117
+ },
10118
+ ))
9624
10119
  .with_state(state);
9625
10120
  if !config.allowed_origins.is_empty() {
9626
10121
  app = app.layer(
@@ -9664,6 +10159,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9664
10159
  Ok(())
9665
10160
  }
9666
10161
 
10162
+ /// Error bodies can quote client input (axum's JSON rejections echo the
10163
+ /// offending value); a credential sent by mistake must not come back out, or
10164
+ /// land in whatever logs the response. Success bodies are untouched: a key's
10165
+ /// secret is returned exactly once, at creation or rotation.
10166
+ async fn redact_error_responses(request: Request<axum::body::Body>, next: Next) -> Response {
10167
+ let response = next.run(request).await;
10168
+ if response.status().as_u16() < 400 {
10169
+ return response;
10170
+ }
10171
+ let (parts, body) = response.into_parts();
10172
+ let bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
10173
+ Ok(bytes) => bytes,
10174
+ Err(_) => return Response::from_parts(parts, axum::body::Body::empty()),
10175
+ };
10176
+ let body = match std::str::from_utf8(&bytes) {
10177
+ Ok(text) => match feltdb_server::redaction::redact(text) {
10178
+ std::borrow::Cow::Owned(redacted) => axum::body::Body::from(redacted),
10179
+ std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
10180
+ },
10181
+ Err(_) => axum::body::Body::from(bytes),
10182
+ };
10183
+ let mut parts = parts;
10184
+ parts.headers.remove(axum::http::header::CONTENT_LENGTH);
10185
+ Response::from_parts(parts, body)
10186
+ }
10187
+
9667
10188
  async fn count_request(
9668
10189
  State(state): State<AppState>,
9669
10190
  request: Request<axum::body::Body>,
@@ -9696,7 +10217,7 @@ async fn authenticate(
9696
10217
  // an answer to PR36's question — which part of the request does is.
9697
10218
  let authorization =
9698
10219
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Authorization);
9699
- let principal = if state.auth_enabled {
10220
+ let (principal, class) = if state.auth_enabled {
9700
10221
  let token = request
9701
10222
  .headers()
9702
10223
  .get(AUTHORIZATION)
@@ -9759,8 +10280,8 @@ async fn authenticate(
9759
10280
  })
9760
10281
  .and_then(|token| state.sessions.verify(token));
9761
10282
  match (machine, actor, human) {
9762
- (Some(principal), _, _) => principal,
9763
- (None, Some(session), _) => Principal {
10283
+ (Some(principal), _, _) => (principal, PrincipalClass::Machine),
10284
+ (None, Some(session), _) => (Principal {
9764
10285
  key_id: session.subject_id,
9765
10286
  scopes: vec![
9766
10287
  "application:read".into(),
@@ -9775,24 +10296,55 @@ async fn authenticate(
9775
10296
  subject_type: "human".into(),
9776
10297
  identity_id: Some(session.identity_id),
9777
10298
  session_id: Some(session.session_id),
9778
- },
10299
+ }, PrincipalClass::EndUser),
9779
10300
  (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)
10301
+ // A signed cookie is not enough: its user id must belong to a
10302
+ // console account (created through the verified account
10303
+ // routes, or bound/claimed for a pre-0.15.0 user), and its
10304
+ // identity must be that account's. Sessions the website
10305
+ // minted before accounts existed are refused here.
10306
+ let account = match state.console_accounts.account_for_user(&session.user_id) {
10307
+ Some(account) if account.display_identity == session.identity.trim().to_lowercase() => account,
10308
+ _ => {
10309
+ state.metrics.authentication_failure();
10310
+ audit(
10311
+ &state,
10312
+ &session.user_id,
10313
+ "authenticate",
10314
+ request.uri().path(),
10315
+ "denied",
10316
+ 401,
10317
+ );
10318
+ return ApiError::structured(
10319
+ StatusCode::UNAUTHORIZED,
10320
+ json!({
10321
+ "code": "CONSOLE_ACCOUNT_REQUIRED",
10322
+ "message": "this console session is not bound to a console account; sign in again",
10323
+ }),
10324
+ )
10325
+ .into_response();
10326
+ }
10327
+ };
10328
+ if (!state.tenancy.platform_user_exists(&account.user_id)
10329
+ && state
10330
+ .tenancy
10331
+ .register_platform_user(&account.user_id, &account.display_identity)
10332
+ .is_err())
10333
+ || !state.tenancy.platform_user_active(&account.user_id)
9785
10334
  {
9786
10335
  return ApiError(StatusCode::FORBIDDEN, "platform user is suspended".into())
9787
10336
  .into_response();
9788
10337
  }
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
- }
10338
+ (
10339
+ Principal {
10340
+ key_id: account.user_id,
10341
+ scopes: vec![],
10342
+ subject_type: "human".into(),
10343
+ identity_id: Some(account.display_identity),
10344
+ session_id: Some(session.id),
10345
+ },
10346
+ PrincipalClass::ConsoleUser,
10347
+ )
9796
10348
  }
9797
10349
  (None, None, None) => {
9798
10350
  state.metrics.authentication_failure();
@@ -9812,17 +10364,43 @@ async fn authenticate(
9812
10364
  }
9813
10365
  }
9814
10366
  } 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
- }
10367
+ (
10368
+ Principal {
10369
+ key_id: "development".to_string(),
10370
+ scopes: vec!["*".to_string()],
10371
+ subject_type: "service".into(),
10372
+ identity_id: None,
10373
+ session_id: None,
10374
+ },
10375
+ PrincipalClass::Development,
10376
+ )
9822
10377
  };
9823
10378
 
9824
10379
  let path = request.uri().path();
9825
- let required = if path == "/events" {
10380
+ // The control plane (`/api/*`) is decided per route, before any handler:
10381
+ // machine keys need the route's capability, console users are held to
10382
+ // tenant/application membership by the handler, and application end-user
10383
+ // sessions are refused. There is no path family without a decision.
10384
+ let control = control_authorization::control_plane_requirement(request.method(), path);
10385
+ if let Some(requirement) = control {
10386
+ if let Err(denial) = control_authorization::decide(class, &principal.scopes, requirement) {
10387
+ state.metrics.authentication_failure();
10388
+ let action = match requirement {
10389
+ control_authorization::Requirement::Capability(scope) => scope,
10390
+ _ => "control-plane",
10391
+ };
10392
+ audit(&state, &principal.key_id, action, path, "denied", 403);
10393
+ return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
10394
+ }
10395
+ }
10396
+ let required = if let Some(requirement) = control {
10397
+ match requirement {
10398
+ control_authorization::Requirement::Capability(scope) => scope,
10399
+ _ => "session:self",
10400
+ }
10401
+ } else if path == "/metrics" || path == "/v1/status" {
10402
+ control_authorization::OPERATOR_READ
10403
+ } else if path == "/events" {
9826
10404
  "events:read"
9827
10405
  } else if path == "/v1/state/diff"
9828
10406
  || path == "/v1/state/conflicts"
@@ -9882,6 +10460,24 @@ async fn authenticate(
9882
10460
  } else {
9883
10461
  "state:write"
9884
10462
  };
10463
+ // An end-user session belongs to one application (self-serve sign-up
10464
+ // registers it there). Its state scopes are meaningful only on the
10465
+ // application API, which checks that membership per request. The legacy
10466
+ // routes (`/collections`, `/transactions`, `/cas`, `/events`, ...) are
10467
+ // scoped to the whole server namespace, so a session there would read and
10468
+ // write every application's records.
10469
+ if class == PrincipalClass::EndUser && !path.starts_with("/v1/") {
10470
+ state.metrics.authentication_failure();
10471
+ audit(&state, &principal.key_id, required, path, "denied", 403);
10472
+ let denial = control_authorization::Denial::new(
10473
+ control_authorization::END_USER_SESSION_FORBIDDEN,
10474
+ format!(
10475
+ "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"
10476
+ ),
10477
+ )
10478
+ .with_scope(required);
10479
+ return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
10480
+ }
9885
10481
  let subject = if principal.subject_type == "human" {
9886
10482
  Subject::User(principal.key_id.clone())
9887
10483
  } else {
@@ -9899,16 +10495,26 @@ async fn authenticate(
9899
10495
  let human_contract = principal.subject_type == "human"
9900
10496
  && path.starts_with("/v1/applications/")
9901
10497
  && path.split('/').any(|segment| segment == "contract");
10498
+ // Two non-control routes are decided by who is asking rather than by a
10499
+ // scope, and are listed here rather than exempted: a user's own contract
10500
+ // drafts (keyed by that user) and the authenticated diagnostics summary.
9902
10501
  let authenticated_diagnostics = path == "/v1/diagnostics";
9903
- if !path.starts_with("/api/")
9904
- && !human_contract
9905
- && !authenticated_diagnostics
9906
- && !decision.allowed
9907
- {
10502
+ let decided_by_route = control.is_some() || human_contract || authenticated_diagnostics;
10503
+ if !decided_by_route && !decision.allowed {
9908
10504
  state.metrics.authentication_failure();
9909
10505
  audit(&state, &principal.key_id, required, path, "denied", 403);
9910
- return ApiError(StatusCode::FORBIDDEN, format!("missing scope: {required}"))
9911
- .into_response();
10506
+ let denial = if class == PrincipalClass::EndUser
10507
+ && (path == "/metrics" || path == "/v1/status" || path.starts_with("/admin/"))
10508
+ {
10509
+ control_authorization::Denial::new(
10510
+ control_authorization::END_USER_SESSION_FORBIDDEN,
10511
+ format!("missing scope {required} — end-user sessions cannot use operator routes; use an operator key"),
10512
+ )
10513
+ .with_scope(required)
10514
+ } else {
10515
+ control_authorization::Denial::missing_scope(required)
10516
+ };
10517
+ return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
9912
10518
  }
9913
10519
  if path == "/events" {
9914
10520
  state.metrics.event_connection();
@@ -9927,6 +10533,7 @@ async fn authenticate(
9927
10533
 
9928
10534
  request.extensions_mut().insert(principal.clone());
9929
10535
  request.extensions_mut().insert(authenticated.clone());
10536
+ request.extensions_mut().insert(class);
9930
10537
 
9931
10538
  let principal_id = request
9932
10539
  .extensions()
@@ -10272,14 +10879,14 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
10272
10879
  capability_fingerprint: control_plane::public_contract_capability_fingerprint(),
10273
10880
  runtime: "self-hosted",
10274
10881
  storage: database.storage.label(),
10275
- fabric: "healthy",
10882
+ // `fabric`, `workflows` and `agents` were constant "healthy" /
10883
+ // "available" here whatever their state (GAP-10). Nothing checks them,
10884
+ // so nothing reports them.
10276
10885
  execution: if state.worker_enabled {
10277
10886
  "autonomous"
10278
10887
  } else {
10279
10888
  "state-coordinated"
10280
10889
  },
10281
- workflows: "available",
10282
- agents: "available",
10283
10890
  membership: if clock {
10284
10891
  membership
10285
10892
  } else {
@@ -10417,10 +11024,19 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
10417
11024
  values("--namespace", "default"),
10418
11025
  option("--worker"),
10419
11026
  )?;
10420
- println!(
10421
- "API key created (id: {}). This secret will not be shown again:\n{}",
10422
- key.id, key.secret
10423
- );
11027
+ // `--secret-file <path>` writes the secret to an owner-only file
11028
+ // and prints only the id, so a bootstrap script never has to put a
11029
+ // credential on stdout (and from there into platform logs).
11030
+ match option("--secret-file") {
11031
+ Some(file) => {
11032
+ write_secret_file(std::path::Path::new(&file), &key.secret)?;
11033
+ println!("API key created (id: {}). Secret written to {file} (mode 0600).", key.id);
11034
+ }
11035
+ None => println!(
11036
+ "API key created (id: {}). This secret will not be shown again:\n{}",
11037
+ key.id, key.secret
11038
+ ),
11039
+ }
10424
11040
  }
10425
11041
  "list" => println!("{}", serde_json::to_string_pretty(&store.list()?)?),
10426
11042
  "revoke" => {
@@ -10430,11 +11046,93 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
10430
11046
  }
10431
11047
  println!("API key revoked: {id}");
10432
11048
  }
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()),
11049
+ _ => 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()),
10434
11050
  }
10435
11051
  Ok(true)
10436
11052
  }
10437
11053
 
11054
+ /// `feltdb-server console-accounts <claim|list> --data <state.log> ...`
11055
+ ///
11056
+ /// `claim --email <email> --password-file <file>` binds a console password
11057
+ /// account to the existing pre-0.15.0 platform user registered under that
11058
+ /// e-mail, so its owner keeps their tenants. Before 0.15.0 the website did not
11059
+ /// verify passwords, so such a user cannot be claimed by self-service sign-up;
11060
+ /// the operator verifies the person out of band and runs this. The password
11061
+ /// is read from a file (first line) so it never appears in the process list.
11062
+ fn manage_console_accounts() -> Result<bool, Box<dyn std::error::Error>> {
11063
+ let arguments: Vec<String> = std::env::args().collect();
11064
+ if arguments.get(1).map(String::as_str) != Some("console-accounts") {
11065
+ return Ok(false);
11066
+ }
11067
+ let usage = "usage: feltdb-server console-accounts <claim|list> --data <state.log path> [--email email --password-file path]";
11068
+ let option = |name: &str| {
11069
+ arguments
11070
+ .iter()
11071
+ .position(|argument| argument == name)
11072
+ .and_then(|index| arguments.get(index + 1))
11073
+ .cloned()
11074
+ };
11075
+ let data = PathBuf::from(
11076
+ option("--data").unwrap_or_else(|| "./data/feltdb.log".to_string()),
11077
+ );
11078
+ let accounts = ConsoleAccountStore::load(data.with_extension("console-accounts.json"), None)?;
11079
+ let tenancy = TenancyStore::load(data.with_extension("platform.json"))?;
11080
+ match arguments.get(2).map(String::as_str).unwrap_or("") {
11081
+ "claim" => {
11082
+ let email = option("--email").ok_or("console-accounts claim requires --email")?;
11083
+ let file = option("--password-file")
11084
+ .ok_or("console-accounts claim requires --password-file")?;
11085
+ let password = std::fs::read_to_string(&file)?
11086
+ .lines()
11087
+ .next()
11088
+ .unwrap_or_default()
11089
+ .to_string();
11090
+ let claimed = accounts
11091
+ .claim_password(&tenancy, &email, &password)
11092
+ .map_err(|error| format!("{}: {}", error.code, error.message))?;
11093
+ println!(
11094
+ "Console account claimed: {} is bound to existing platform user {}.",
11095
+ claimed.identity, claimed.user_id
11096
+ );
11097
+ }
11098
+ "list" => {
11099
+ let listed: Vec<Value> = accounts
11100
+ .list()
11101
+ .into_iter()
11102
+ .map(|account| {
11103
+ json!({
11104
+ "identity": account.identity,
11105
+ "user_id": account.user_id,
11106
+ "provider": account.provider,
11107
+ "display_identity": account.display_identity,
11108
+ "origin": account.origin,
11109
+ "created_at": account.created_at,
11110
+ })
11111
+ })
11112
+ .collect();
11113
+ println!("{}", serde_json::to_string_pretty(&listed)?);
11114
+ }
11115
+ _ => return Err(usage.into()),
11116
+ }
11117
+ Ok(true)
11118
+ }
11119
+
11120
+ /// Create `path` owner-only (0600) and write `secret` to it; refuses to
11121
+ /// overwrite, so a second bootstrap never silently replaces a delivered secret.
11122
+ fn write_secret_file(path: &std::path::Path, secret: &str) -> std::io::Result<()> {
11123
+ let mut options = OpenOptions::new();
11124
+ options.write(true).create_new(true);
11125
+ #[cfg(unix)]
11126
+ {
11127
+ use std::os::unix::fs::OpenOptionsExt;
11128
+ options.mode(0o600);
11129
+ }
11130
+ let mut file = options.open(path)?;
11131
+ file.write_all(secret.as_bytes())?;
11132
+ file.write_all(b"\n")?;
11133
+ file.sync_all()
11134
+ }
11135
+
10438
11136
  fn manage_authorization() -> Result<bool, Box<dyn std::error::Error>> {
10439
11137
  let arguments: Vec<String> = std::env::args().collect();
10440
11138
  let family = arguments.get(1).map(String::as_str).unwrap_or("");
@@ -10961,6 +11659,37 @@ fn manage_application_runtime() -> Result<bool, Box<dyn std::error::Error>> {
10961
11659
  Ok(true)
10962
11660
  }
10963
11661
 
11662
+ /// Where an online backup bundle may be written.
11663
+ ///
11664
+ /// With `FELTDB_BACKUP_DIR` set (the managed deployments set it), the request
11665
+ /// names a bundle, not a path: a single relative component placed inside that
11666
+ /// directory. Without it the server is self-hosted and the operator's path is
11667
+ /// used as given, as before.
11668
+ fn backup_output_path(
11669
+ requested: &str,
11670
+ backup_dir: Option<std::ffi::OsString>,
11671
+ ) -> Result<PathBuf, ApiError> {
11672
+ let Some(root) = backup_dir.filter(|value| !value.is_empty()) else {
11673
+ return Ok(PathBuf::from(requested));
11674
+ };
11675
+ let name = std::path::Path::new(requested);
11676
+ let mut components = name.components();
11677
+ let single = matches!(
11678
+ (components.next(), components.next()),
11679
+ (Some(std::path::Component::Normal(_)), None)
11680
+ );
11681
+ if !single {
11682
+ return Err(ApiError(
11683
+ StatusCode::BAD_REQUEST,
11684
+ format!(
11685
+ "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\"}}",
11686
+ std::path::Path::new(&root).display()
11687
+ ),
11688
+ ));
11689
+ }
11690
+ Ok(PathBuf::from(root).join(name))
11691
+ }
11692
+
10964
11693
  async fn create_online_backup(
10965
11694
  State(state): State<AppState>,
10966
11695
  Json(request): Json<OnlineBackupRequest>,
@@ -10990,7 +11719,7 @@ async fn create_online_backup(
10990
11719
  })
10991
11720
  .cloned()
10992
11721
  .collect();
10993
- let output = PathBuf::from(request.output);
11722
+ let output = backup_output_path(&request.output, std::env::var_os("FELTDB_BACKUP_DIR"))?;
10994
11723
  let source = feltdb_server::backup::BackupSource {
10995
11724
  data: state
10996
11725
  .backup_artifacts
@@ -11002,14 +11731,22 @@ async fn create_online_backup(
11002
11731
  server_version: Some(env!("CARGO_PKG_VERSION").into()),
11003
11732
  durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
11004
11733
  };
11734
+ let records = operator_record_path(&state, BACKUP_RECORDS);
11005
11735
  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
- )
11736
+ let manifest = {
11737
+ let _guard = guard;
11738
+ feltdb_server::backup::create_bundle_with_source(
11739
+ &output,
11740
+ &artifacts,
11741
+ &[("database.snapshot.json".to_string(), snapshot_bytes)],
11742
+ source,
11743
+ )?
11744
+ };
11745
+ // Verified after the barrier is released: restoring into scratch and
11746
+ // opening the result is what turns "a bundle exists" into "a bundle
11747
+ // restores", and the record says which of the two is true.
11748
+ record_online_backup(&records, &output, &manifest);
11749
+ Ok::<_, std::io::Error>(manifest)
11013
11750
  })
11014
11751
  .await
11015
11752
  .map_err(|error| {
@@ -11033,6 +11770,236 @@ async fn create_online_backup(
11033
11770
  /// finding was that losing security evidence was invisible, and a signal an
11034
11771
  /// operator has to know to go and look for is not much better than no signal.
11035
11772
  /// It rides with the metrics they already read.
11773
+ const BACKUP_RECORDS: &str = "backups.json";
11774
+
11775
+ /// Operator records live beside the durable log (`<data>.<name>`), on the
11776
+ /// same volume, like the readiness probe they are derived from.
11777
+ fn operator_record_path(state: &AppState, name: &str) -> PathBuf {
11778
+ state.readiness_probe.with_extension(name)
11779
+ }
11780
+
11781
+ fn read_records(path: &std::path::Path) -> Vec<Value> {
11782
+ std::fs::read(path)
11783
+ .ok()
11784
+ .and_then(|bytes| serde_json::from_slice::<Vec<Value>>(&bytes).ok())
11785
+ .unwrap_or_default()
11786
+ }
11787
+
11788
+ /// Append one record; bounded so the file cannot grow without limit.
11789
+ fn append_record(path: &std::path::Path, record: Value) -> std::io::Result<()> {
11790
+ let mut records = read_records(path);
11791
+ records.push(record);
11792
+ let excess = records.len().saturating_sub(100);
11793
+ records.drain(..excess);
11794
+ let temporary = path.with_extension("json.tmp");
11795
+ let mut file = std::fs::File::create(&temporary)?;
11796
+ file.write_all(&serde_json::to_vec_pretty(&records).map_err(std::io::Error::other)?)?;
11797
+ file.sync_all()?;
11798
+ std::fs::rename(&temporary, path)
11799
+ }
11800
+
11801
+ fn record_online_backup(
11802
+ records: &std::path::Path,
11803
+ output: &std::path::Path,
11804
+ manifest: &feltdb_server::backup::BackupManifest,
11805
+ ) {
11806
+ let started = Instant::now();
11807
+ let restore = verify_restorable(output).map_err(|error| error.to_string());
11808
+ let record = json!({
11809
+ "kind": "online_backup",
11810
+ "bundle_id": feltdb_server::backup::backup_id(output).ok(),
11811
+ "output": output.display().to_string(),
11812
+ "created_ms": manifest.created_ms,
11813
+ "files": manifest.entries.len(),
11814
+ "verified": restore.is_ok(),
11815
+ "restore_verification": match &restore {
11816
+ Ok(detail) => json!({
11817
+ "method": "bundle verified, restored into a scratch directory on this host and opened",
11818
+ "verified_at_ms": unix_millis(),
11819
+ "duration_ms": started.elapsed().as_millis() as u64,
11820
+ "detail": detail,
11821
+ }),
11822
+ Err(error) => json!({ "method": "scratch restore", "error": error }),
11823
+ },
11824
+ });
11825
+ if let Err(error) = append_record(records, record) {
11826
+ tracing::error!(%error, "failed to record online backup");
11827
+ }
11828
+ }
11829
+
11830
+ fn unix_millis() -> u64 {
11831
+ std::time::SystemTime::now()
11832
+ .duration_since(std::time::UNIX_EPOCH)
11833
+ .unwrap_or_default()
11834
+ .as_millis() as u64
11835
+ }
11836
+
11837
+ /// The routes authenticated by a shared service secret rather than an API
11838
+ /// key. They are routed only when deliberately configured, so an authority
11839
+ /// that is not part of a managed Intent Engine deployment or a certification
11840
+ /// run has no such surface at all.
11841
+ fn service_secret_routes() -> Router<AppState> {
11842
+ let mut routes = Router::new();
11843
+ let intent_secret = std::env::var("FELTDB_INTENT_SERVICE_SECRET").unwrap_or_default();
11844
+ if intent_secret.len() >= 32 {
11845
+ routes = routes
11846
+ .route("/internal/intents/by-hash", get(internal_find_intent))
11847
+ .route(
11848
+ "/internal/intents",
11849
+ axum::routing::post(internal_create_intent),
11850
+ )
11851
+ .route("/internal/intents/{id}", put(internal_replace_intent))
11852
+ .route(
11853
+ "/internal/intent-events",
11854
+ axum::routing::post(internal_create_intent_event),
11855
+ );
11856
+ } else if !intent_secret.is_empty() {
11857
+ tracing::warn!("FELTDB_INTENT_SERVICE_SECRET is shorter than 32 bytes; /internal/intents* is not routed");
11858
+ }
11859
+ let certification = matches!(
11860
+ std::env::var("FELTDB_CERTIFICATION_MODE").as_deref(),
11861
+ Ok("1" | "true")
11862
+ );
11863
+ if certification {
11864
+ tracing::warn!("FELTDB_CERTIFICATION_MODE is set: certification fixture deletion is routed");
11865
+ routes = routes.route(
11866
+ "/api/certification/fixtures/{application_id}",
11867
+ axum::routing::delete(delete_certification_fixture),
11868
+ );
11869
+ }
11870
+ routes
11871
+ }
11872
+
11873
+ /// A real write → fsync → read-back probe of the storage volume.
11874
+ fn storage_probe(path: &std::path::Path) -> Value {
11875
+ let started = Instant::now();
11876
+ let token = format!("feltdb-status-probe-{}\n", uuid::Uuid::new_v4().simple());
11877
+ let mut wrote = false;
11878
+ let mut synced = false;
11879
+ let result = (|| -> std::io::Result<bool> {
11880
+ if let Some(parent) = path.parent() {
11881
+ std::fs::create_dir_all(parent)?;
11882
+ }
11883
+ let mut file = OpenOptions::new()
11884
+ .create(true)
11885
+ .write(true)
11886
+ .truncate(true)
11887
+ .open(path)?;
11888
+ file.write_all(token.as_bytes())?;
11889
+ wrote = true;
11890
+ file.sync_all()?;
11891
+ synced = true;
11892
+ Ok(std::fs::read(path)? == token.as_bytes())
11893
+ })();
11894
+ let read_back = matches!(result, Ok(true));
11895
+ json!({
11896
+ "ok": read_back,
11897
+ "write": wrote,
11898
+ "fsync": synced,
11899
+ "read_back": read_back,
11900
+ "checked_at_ms": unix_millis(),
11901
+ "latency_us": started.elapsed().as_micros() as u64,
11902
+ "error": result.err().map(|error| error.to_string()),
11903
+ })
11904
+ }
11905
+
11906
+ /// `GET /v1/status` — the operator's view, needs `operator:read`.
11907
+ ///
11908
+ /// Every field is observed by this request or read from a record this server
11909
+ /// wrote when the event happened; nothing is asserted. Availability (this
11910
+ /// process answers and its volume accepts a synced write) is reported apart
11911
+ /// from verified durability (a backup of this state was restored and opened).
11912
+ async fn operator_status(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
11913
+ let probe_path = operator_record_path(&state, "status-probe");
11914
+ let backups_path = operator_record_path(&state, BACKUP_RECORDS);
11915
+ let (probe, backups) = tokio::task::spawn_blocking(move || {
11916
+ (storage_probe(&probe_path), read_records(&backups_path))
11917
+ })
11918
+ .await
11919
+ .map_err(|error| {
11920
+ tracing::error!(%error, "status worker failed");
11921
+ ApiError(StatusCode::SERVICE_UNAVAILABLE, "status check temporarily unavailable".into())
11922
+ })?;
11923
+ let database = state.db.health();
11924
+ let last_backup = backups
11925
+ .iter()
11926
+ .rev()
11927
+ .find(|record| record["kind"] == "online_backup")
11928
+ .map(|record| {
11929
+ json!({
11930
+ "bundle_id": record["bundle_id"],
11931
+ "created_ms": record["created_ms"],
11932
+ "output": record["output"],
11933
+ "verified": record["verified"],
11934
+ })
11935
+ });
11936
+ let last_verified_restore = backups
11937
+ .iter()
11938
+ .rev()
11939
+ .find(|record| record["verified"] == true)
11940
+ .map(|record| {
11941
+ json!({
11942
+ "bundle_id": record["bundle_id"],
11943
+ "verified_at_ms": record["restore_verification"]["verified_at_ms"],
11944
+ "method": record["restore_verification"]["method"],
11945
+ })
11946
+ });
11947
+ let clock = state.lease_clock.is_healthy();
11948
+ let membership_healthy = !matches!(
11949
+ state.cluster.proposal().map(|value| value.phase),
11950
+ Some(ProposalPhase::Preparing | ProposalPhase::Prepared | ProposalPhase::Aborted)
11951
+ );
11952
+ let available = probe["ok"] == true;
11953
+ Ok(Json(json!({
11954
+ "status": if available && overall_status(membership_healthy, clock, &database.storage) == "healthy" { "available" } else { "degraded" },
11955
+ "release": {
11956
+ "version": env!("CARGO_PKG_VERSION"),
11957
+ "release_id": option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
11958
+ "git_commit": option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
11959
+ "build_timestamp": option_env!("FELTDB_BUILD_TIMESTAMP").unwrap_or("unknown"),
11960
+ "deployment_id": std::env::var("FELTDB_DEPLOYMENT_ID").or_else(|_| std::env::var("FLY_IMAGE_REF")).ok(),
11961
+ "promoted_revision": std::env::var("FELTDB_PROMOTED_REVISION").ok(),
11962
+ },
11963
+ "capabilities": {
11964
+ "public_contract": "feltdb.public.substrate@1.0",
11965
+ "capability_fingerprint": control_plane::public_contract_capability_fingerprint(),
11966
+ "protocol_version": PROTOCOL_VERSION,
11967
+ "authentication": state.auth_enabled,
11968
+ "autonomous_workers": state.worker_enabled,
11969
+ "configured_peers": state.cluster.get().peers.len(),
11970
+ "durable_work_authority": is_work_authority(&state),
11971
+ },
11972
+ "availability": {
11973
+ "serving": true,
11974
+ "storage_writable": available,
11975
+ "clock": if clock { "healthy" } else { "unhealthy" },
11976
+ "membership": if membership_healthy { "healthy" } else { "recovering-or-attention-required" },
11977
+ },
11978
+ "storage": {
11979
+ "replay": database.storage.label(),
11980
+ "durable_format": database.durable_format.to_string(),
11981
+ "probe": probe,
11982
+ },
11983
+ "backups": {
11984
+ "last": last_backup,
11985
+ "recorded": backups.len(),
11986
+ },
11987
+ "restore": {
11988
+ "last_verified": last_verified_restore,
11989
+ "status": if last_verified_restore.is_some() { "verified" } else { "never_verified" },
11990
+ "off_host": "not observable by this server",
11991
+ },
11992
+ "durability": {
11993
+ "verified": last_verified_restore.is_some(),
11994
+ "basis": if last_verified_restore.is_some() {
11995
+ "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"
11996
+ } else {
11997
+ "no backup of this authority has been restored and verified; availability is not durability"
11998
+ },
11999
+ },
12000
+ })))
12001
+ }
12002
+
11036
12003
  async fn network_metrics(State(state): State<AppState>) -> Json<Value> {
11037
12004
  let mut body = serde_json::to_value(state.metrics.snapshot()).unwrap_or_else(|_| json!({}));
11038
12005
  if let Some(fields) = body.as_object_mut() {
@@ -11075,6 +12042,7 @@ async fn sync_pull(
11075
12042
  operations,
11076
12043
  versions: authority_position,
11077
12044
  catchup,
12045
+ durable_work_authority: state.work_authority,
11078
12046
  }))
11079
12047
  }
11080
12048
 
@@ -11239,10 +12207,13 @@ fn start_workload_recovery(state: &AppState) {
11239
12207
  let state = state.clone();
11240
12208
  tokio::spawn(async move {
11241
12209
  loop {
11242
- if state.cluster.get().is_member {
12210
+ if state.cluster.get().is_member && is_work_authority(&state) {
11243
12211
  let outcome = match (state.mesh.lock(), state.workloads.lock()) {
12212
+ // Deadlines first: work past one is timed out under its
12213
+ // own decision, not recovered as a lost worker.
11244
12214
  (Ok(mut mesh), Ok(mut workloads)) => mesh
11245
- .recover_expired_work(unix_seconds_i64(), &mut workloads)
12215
+ .enforce_deadlines(unix_seconds_i64(), &mut workloads)
12216
+ .and_then(|_| mesh.recover_expired_work(unix_seconds_i64(), &mut workloads))
11246
12217
  .map(|_| ())
11247
12218
  .map_err(|error| error.message),
11248
12219
  _ => Err("worker mesh or workload store unavailable".to_string()),
@@ -11325,8 +12296,12 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
11325
12296
  }
11326
12297
  }
11327
12298
 
11328
- evaluate_state_triggers(state)?;
11329
- execute_eligible_workloads(state, &worker)?;
12299
+ // Triggers admit work and the pass claims it: Durable Work writes, so
12300
+ // only on the authority.
12301
+ if is_work_authority(state) {
12302
+ evaluate_state_triggers(state)?;
12303
+ execute_eligible_workloads(state, &worker)?;
12304
+ }
11330
12305
  Ok(())
11331
12306
  }
11332
12307
 
@@ -12141,6 +13116,17 @@ async fn sync_with_peer(
12141
13116
  .json::<SyncPullResponse>()
12142
13117
  .await?;
12143
13118
 
13119
+ // Two nodes configured as the Durable Work authority would each commit
13120
+ // work the other cannot see in time; both refuse until one is restarted
13121
+ // without `--work-authority`.
13122
+ if state.work_authority && pull.durable_work_authority {
13123
+ if !state
13124
+ .work_authority_conflict
13125
+ .swap(true, std::sync::atomic::Ordering::SeqCst)
13126
+ {
13127
+ tracing::error!(%peer, "two nodes are configured as the Durable Work authority; refusing Durable Work writes");
13128
+ }
13129
+ }
12144
13130
  let mut incoming = pull.operations;
12145
13131
  incoming.sort_by(|left, right| {
12146
13132
  left.instance_id
@@ -14977,7 +15963,7 @@ async fn commit_transaction(
14977
15963
  "committed": false,
14978
15964
  "code": "PRECONDITION_FAILED",
14979
15965
  "error": failure.to_string(),
14980
- "failure": *failure,
15966
+ "failure": failure.public_json(&feltdb::ReadLayout::Direct),
14981
15967
  }),
14982
15968
  ),
14983
15969
  FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
@@ -14994,7 +15980,29 @@ async fn commit_transaction(
14994
15980
  }),
14995
15981
  )
14996
15982
  }
14997
- other => ApiError(StatusCode::CONFLICT, other.to_string()),
15983
+ // The commit could not be made durable. It is neither a lost
15984
+ // race nor a success, and answering 409 here once let a caller
15985
+ // read a storage failure as "someone else won". The outcome is
15986
+ // to be treated as unknown and reconciled by retrying with the
15987
+ // same transaction id.
15988
+ storage @ (FlowError::Io(_) | FlowError::Serde(_)) => ApiError::structured(
15989
+ StatusCode::SERVICE_UNAVAILABLE,
15990
+ json!({
15991
+ "committed": "unknown",
15992
+ "code": "DURABILITY_FAILURE",
15993
+ "message": storage.to_string(),
15994
+ "transaction_id": request.transaction_id,
15995
+ }),
15996
+ ),
15997
+ other => ApiError::structured(
15998
+ StatusCode::INTERNAL_SERVER_ERROR,
15999
+ json!({
16000
+ "committed": "unknown",
16001
+ "code": "EXECUTION_FAILED",
16002
+ "message": other.to_string(),
16003
+ "transaction_id": request.transaction_id,
16004
+ }),
16005
+ ),
14998
16006
  }
14999
16007
  })?;
15000
16008
  let operation_ids = state
@@ -16437,7 +17445,8 @@ async fn control_plane_agent(
16437
17445
  "workload store unavailable".into(),
16438
17446
  )
16439
17447
  })?
16440
- .list(&tenant, &application_id)
17448
+ .fresh_list(&tenant, &application_id)
17449
+ .map_err(workload_error)?
16441
17450
  .into_iter()
16442
17451
  .filter(|workload| {
16443
17452
  serde_json::to_value(&workload.requested_by)
@@ -17032,3 +18041,32 @@ mod authority_gate_tests {
17032
18041
  }
17033
18042
  }
17034
18043
  }
18044
+
18045
+ #[cfg(test)]
18046
+ mod backup_output_tests {
18047
+ use super::backup_output_path;
18048
+ use std::path::PathBuf;
18049
+
18050
+ #[test]
18051
+ fn a_confined_server_writes_named_bundles_inside_its_backup_directory() {
18052
+ let root = Some("/data/backups".into());
18053
+ assert_eq!(
18054
+ backup_output_path("nightly-1", root.clone()).unwrap(),
18055
+ PathBuf::from("/data/backups/nightly-1")
18056
+ );
18057
+ for refused in ["/data/state.log", "../state.log", "a/b", "..", ".", "", "/"] {
18058
+ assert!(
18059
+ backup_output_path(refused, root.clone()).is_err(),
18060
+ "{refused:?} must be refused"
18061
+ );
18062
+ }
18063
+ }
18064
+
18065
+ #[test]
18066
+ fn a_self_hosted_server_uses_the_operator_path() {
18067
+ assert_eq!(
18068
+ backup_output_path("/srv/backups/x", None).unwrap(),
18069
+ PathBuf::from("/srv/backups/x")
18070
+ );
18071
+ }
18072
+ }