@feltdb/core 0.8.3 → 0.8.5

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 (193) hide show
  1. package/dist/cli/commands.js +4 -1
  2. package/dist/cli/provisioning-neutrality.js +79 -0
  3. package/dist/collection.d.ts +43 -1
  4. package/dist/collection.d.ts.map +1 -1
  5. package/dist/collection.js +192 -22
  6. package/dist/create/create.js +25 -21
  7. package/dist/create/managed-account.js +11 -0
  8. package/dist/create/package-versions.js +1 -1
  9. package/dist/create/server-source/Cargo.lock +165 -0
  10. package/dist/create/server-source/Cargo.toml +9 -0
  11. package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
  12. package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
  13. package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
  14. package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
  15. package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
  16. package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
  17. package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
  18. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
  19. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
  20. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
  21. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
  22. package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
  23. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
  24. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
  25. package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
  26. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
  27. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
  28. package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
  29. package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
  30. package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
  31. package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
  32. package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
  33. package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
  34. package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
  35. package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
  36. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
  37. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
  38. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
  39. package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
  40. package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
  41. package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
  42. package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
  43. package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
  44. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
  45. package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
  46. package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
  47. package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
  48. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
  49. package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
  50. package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
  51. package/dist/create/server-source/crates/feltdb/src/equality_index.rs +595 -0
  52. package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
  53. package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
  54. package/dist/create/server-source/crates/feltdb/src/lib.rs +1050 -117
  55. package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
  56. package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
  57. package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
  58. package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
  59. package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
  60. package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
  61. package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
  62. package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
  63. package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
  64. package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
  65. package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
  66. package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
  67. package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
  68. package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
  69. package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
  70. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +90 -72
  71. package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
  72. package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
  73. package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
  74. package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
  75. package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
  76. package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
  77. package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
  78. package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
  79. package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
  80. package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
  81. package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
  82. package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
  83. package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
  84. package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
  85. package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
  86. package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +126 -0
  87. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
  88. package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
  89. package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
  90. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
  91. package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
  92. package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
  93. package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
  94. package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
  95. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +305 -6
  96. package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
  97. package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
  98. package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
  99. package/dist/create/server-source/crates/feltdb/src/state_model.rs +1514 -537
  100. package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
  101. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
  102. package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
  103. package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
  104. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
  105. package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
  106. package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
  107. package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
  108. package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
  109. package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
  110. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  111. package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
  112. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +442 -0
  113. package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
  114. package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -0
  115. package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
  116. package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
  117. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
  118. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
  119. package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
  120. package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +234 -0
  121. package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +947 -0
  122. package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
  123. package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
  124. package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
  125. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
  126. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
  127. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
  128. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
  129. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
  130. package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
  131. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1032 -0
  132. package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
  133. package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
  134. package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
  135. package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
  136. package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
  137. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
  138. package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
  139. package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
  140. package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
  141. package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
  142. package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
  143. package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
  144. package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
  145. package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
  146. package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
  147. package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
  148. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +2 -1
  149. package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
  150. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
  151. package/dist/create/server-source/crates/feltdb-server/src/main.rs +800 -34
  152. package/dist/db.d.ts +33 -34
  153. package/dist/db.d.ts.map +1 -1
  154. package/dist/db.js +74 -20
  155. package/dist/deployment.d.ts +30 -0
  156. package/dist/deployment.d.ts.map +1 -0
  157. package/dist/deployment.js +130 -0
  158. package/dist/embedded-transaction.d.ts +22 -4
  159. package/dist/embedded-transaction.d.ts.map +1 -1
  160. package/dist/embedded-transaction.js +51 -5
  161. package/dist/feltdb.d.ts +14 -2
  162. package/dist/feltdb.d.ts.map +1 -1
  163. package/dist/file-db.js +1 -1
  164. package/dist/http-client.d.ts +14 -0
  165. package/dist/http-client.d.ts.map +1 -1
  166. package/dist/http-client.js +23 -5
  167. package/dist/http-db.d.ts +119 -1
  168. package/dist/http-db.d.ts.map +1 -1
  169. package/dist/http-db.js +346 -31
  170. package/dist/index-core.d.ts +2 -0
  171. package/dist/index-core.d.ts.map +1 -1
  172. package/dist/index-core.js +2 -0
  173. package/dist/index.d.ts.map +1 -1
  174. package/dist/index.js +9 -0
  175. package/dist/indexeddb-db.d.ts.map +1 -1
  176. package/dist/indexeddb-db.js +35 -21
  177. package/dist/managed-recovery.d.ts +192 -0
  178. package/dist/managed-recovery.d.ts.map +1 -0
  179. package/dist/managed-recovery.js +242 -0
  180. package/dist/memory-db.js +1 -1
  181. package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-DaNwCLRX.js} +1 -1
  182. package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
  183. package/dist/studio-app/assets/index-j8IlhNqJ.js +29 -0
  184. package/dist/studio-app/index.html +1 -1
  185. package/dist/transaction.d.ts +30 -0
  186. package/dist/transaction.d.ts.map +1 -1
  187. package/dist/transaction.js +41 -0
  188. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  189. package/dist/workload.d.ts +2 -1
  190. package/dist/workload.d.ts.map +1 -1
  191. package/package.json +1 -1
  192. package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
  193. package/dist/studio-app/assets/index-B0k4UAlI.js +0 -29
@@ -62,7 +62,7 @@ use feltdb::{
62
62
  },
63
63
  workload::{CreateWorkload, WorkloadStore},
64
64
  AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
65
- PeerAdvertisement, PeerId, RecordPrecondition, StoredRow,
65
+ PeerAdvertisement, PeerId, RecordPrecondition, StateTriggerStore, StoredRow,
66
66
  };
67
67
  use feltdb_server::{
68
68
  app_state::{AppState, BoundedQueryCursor},
@@ -1421,9 +1421,10 @@ struct WorkerReconcileRequest {
1421
1421
  fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
1422
1422
  let status = match error.code.as_str() {
1423
1423
  "WORKLOAD_NOT_FOUND" => StatusCode::NOT_FOUND,
1424
- "WORKLOAD_STALE_FENCE" | "WORKLOAD_TRANSITION_DENIED" | "WORKLOAD_NOT_READY" => {
1425
- StatusCode::CONFLICT
1426
- }
1424
+ "WORKLOAD_STALE_FENCE"
1425
+ | "WORKLOAD_TRANSITION_DENIED"
1426
+ | "WORKLOAD_NOT_READY"
1427
+ | "WORKLOAD_NOT_YET_ELIGIBLE" => StatusCode::CONFLICT,
1427
1428
  "WORKLOAD_UNAUTHORIZED" | "WORKLOAD_CAPABILITY_DENIED" => StatusCode::FORBIDDEN,
1428
1429
  "WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
1429
1430
  _ => StatusCode::UNPROCESSABLE_ENTITY,
@@ -7861,6 +7862,54 @@ struct SyncPullRequest {
7861
7862
  struct SyncPullResponse {
7862
7863
  operations: Vec<Operation>,
7863
7864
  versions: HashMap<String, u64>,
7865
+ catchup: CatchupStatus,
7866
+ }
7867
+
7868
+ #[derive(Serialize, Deserialize)]
7869
+ struct CatchupStatus {
7870
+ peer_id: String,
7871
+ peer_position: HashMap<String, u64>,
7872
+ authority_position: HashMap<String, u64>,
7873
+ retained_from: HashMap<String, u64>,
7874
+ retained_through: HashMap<String, u64>,
7875
+ lag: u64,
7876
+ recoverability: String,
7877
+ catchup_mode: String,
7878
+ recovery_required: bool,
7879
+ }
7880
+
7881
+ fn catchup_status(
7882
+ peer_id: String,
7883
+ peer_position: HashMap<String, u64>,
7884
+ authority_position: HashMap<String, u64>,
7885
+ retained_from: HashMap<String, u64>,
7886
+ ) -> CatchupStatus {
7887
+ let lag = authority_position.iter().map(|(origin, sequence)| {
7888
+ sequence.saturating_sub(peer_position.get(origin).copied().unwrap_or(0))
7889
+ }).sum();
7890
+ let recovery_required = authority_position.iter().any(|(origin, through)| {
7891
+ let position = peer_position.get(origin).copied().unwrap_or(0);
7892
+ let floor = retained_from.get(origin).copied().unwrap_or(through.saturating_add(1));
7893
+ position < *through && position.saturating_add(1) < floor
7894
+ });
7895
+ let catchup_mode = if recovery_required {
7896
+ "snapshot_rebootstrap_required"
7897
+ } else if lag == 0 {
7898
+ "current"
7899
+ } else {
7900
+ "incremental_replay"
7901
+ };
7902
+ CatchupStatus {
7903
+ peer_id,
7904
+ peer_position,
7905
+ retained_through: authority_position.clone(),
7906
+ authority_position,
7907
+ retained_from,
7908
+ lag,
7909
+ recoverability: if recovery_required { "rebootstrap_required" } else { "recoverable" }.into(),
7910
+ catchup_mode: catchup_mode.into(),
7911
+ recovery_required,
7912
+ }
7864
7913
  }
7865
7914
 
7866
7915
  #[derive(Serialize, Deserialize)]
@@ -8241,6 +8290,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8241
8290
  if manage_workloads()? {
8242
8291
  return Ok(());
8243
8292
  }
8293
+ if manage_state_triggers()? {
8294
+ return Ok(());
8295
+ }
8244
8296
  if manage_backup()? {
8245
8297
  return Ok(());
8246
8298
  }
@@ -8250,6 +8302,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8250
8302
  let config = Config::from_args().map_err(|error| format!("configuration error: {error}"))?;
8251
8303
  validate_production_configuration(&config)?;
8252
8304
  let db = FeltDb::open(&config.data)?;
8305
+ // Indexes are derived execution structures, so they are declared by
8306
+ // configuration and built from the durable state this open just replayed.
8307
+ // Nothing about them is read from disk: a restart re-derives every bucket
8308
+ // from the records that survived, which is why there is no index format,
8309
+ // no index log, and nothing to repair.
8310
+ let declared_indexes = declare_configured_equality_indexes(&db)?;
8311
+ if !declared_indexes.is_empty() {
8312
+ println!(
8313
+ "feltdb: equality indexes maintained for {}",
8314
+ declared_indexes.join(", ")
8315
+ );
8316
+ }
8253
8317
  let peer_token = match std::env::var(&config.peer_token_env) {
8254
8318
  Ok(token) => Some(Arc::<str>::from(token)),
8255
8319
  Err(_) if config.peers.is_empty() => None,
@@ -8276,6 +8340,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8276
8340
  let grant_store = GrantStore::load(config.data.with_extension("grants.json"))?;
8277
8341
  let sync_store = SyncStore::load(config.data.with_extension("sync.json"))?;
8278
8342
  let workload_store = WorkloadStore::load(config.data.with_extension("workloads.json"))?;
8343
+ let state_trigger_store = StateTriggerStore::load(config.data.with_extension("triggers.json"))?;
8279
8344
  let mesh_store = WorkerMeshStore::load(config.data.with_extension("workers.json"))?;
8280
8345
  let content_store = ContentStore::new(config.data.with_extension("content"))?;
8281
8346
  let artifact_store = ArtifactStore::load(
@@ -8287,6 +8352,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8287
8352
  let causal_store = CausalStore::load(config.data.with_extension("causal-events.json"))?;
8288
8353
  let provider_store = ProviderStore::load(config.data.with_extension("providers.json"))?;
8289
8354
  let readiness_probe = config.data.with_extension("readiness");
8355
+ // Constructed before the state so the same handle can be stopped after
8356
+ // serving: the pipeline outlives the router by exactly one shutdown.
8357
+ let audit_handle = AuditLog::new(config.audit.clone());
8290
8358
  let state = AppState {
8291
8359
  started_at: Instant::now(),
8292
8360
  ids: Arc::new(AtomicU64::new(db.sequence()?)),
@@ -8299,7 +8367,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8299
8367
  config.data.with_extension("cluster.json"),
8300
8368
  config.peers.clone(),
8301
8369
  )?,
8302
- audit: AuditLog::new(config.audit.clone()),
8370
+ audit: audit_handle.clone(),
8303
8371
  peer_client,
8304
8372
  peer_token,
8305
8373
  lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
@@ -8340,6 +8408,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8340
8408
  grants: Arc::new(std::sync::Mutex::new(grant_store)),
8341
8409
  sync: Arc::new(std::sync::Mutex::new(sync_store)),
8342
8410
  workloads: Arc::new(std::sync::Mutex::new(workload_store)),
8411
+ state_triggers: Arc::new(std::sync::Mutex::new(state_trigger_store)),
8343
8412
  mesh: Arc::new(std::sync::Mutex::new(mesh_store)),
8344
8413
  readiness_probe: Arc::new(readiness_probe),
8345
8414
  bounded_query_cursors: Arc::new(std::sync::Mutex::new(HashMap::new())),
@@ -9016,7 +9085,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9016
9085
  axum::routing::delete(delete_certification_fixture),
9017
9086
  )
9018
9087
  .merge(protected)
9088
+ .merge(query_execution_diagnostics_router())
9019
9089
  .layer(middleware::from_fn(protocol_version))
9090
+ .layer(middleware::from_fn(attribute_request))
9020
9091
  .layer(middleware::from_fn_with_state(state.clone(), count_request))
9021
9092
  .layer(RequestBodyLimitLayer::new(1024 * 1024))
9022
9093
  .layer(TraceLayer::new_for_http())
@@ -9049,9 +9120,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9049
9120
  "FeltDB server ready at http://{bound_address} (namespace: {})",
9050
9121
  config.namespace
9051
9122
  );
9052
- axum::serve(listener, app)
9123
+ let serving = axum::serve(listener, app)
9053
9124
  .with_graceful_shutdown(shutdown_signal())
9054
- .await?;
9125
+ .await;
9126
+ // The audit writer owns the stream, so an orderly stop is what makes the
9127
+ // last durability group durable. Run it whether or not serving ended
9128
+ // cleanly: a server that failed still recorded events, and losing the tail
9129
+ // of the security log because of an unrelated bind error would be its own
9130
+ // defect. Dropping the last handle would do this too; doing it here means
9131
+ // it happens at a point in the process the operator can reason about.
9132
+ audit_handle.shutdown();
9133
+ serving?;
9055
9134
  Ok(())
9056
9135
  }
9057
9136
 
@@ -9081,6 +9160,12 @@ async fn authenticate(
9081
9160
  mut request: Request<axum::body::Body>,
9082
9161
  next: Next,
9083
9162
  ) -> Response {
9163
+ // Everything up to the handoff below is authorization: key lookup, session
9164
+ // resolution and the capability check. It is timed separately from the rest
9165
+ // of the request because "the request costs something outside state" is not
9166
+ // an answer to PR36's question — which part of the request does is.
9167
+ let authorization =
9168
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Authorization);
9084
9169
  let principal = if state.auth_enabled {
9085
9170
  let token = request
9086
9171
  .headers()
@@ -9271,6 +9356,7 @@ async fn authenticate(
9271
9356
  .key_id
9272
9357
  .clone();
9273
9358
  let target = request.uri().path().to_string();
9359
+ drop(authorization);
9274
9360
  let response = next.run(request).await;
9275
9361
  let status = response.status().as_u16();
9276
9362
  audit(
@@ -9292,6 +9378,18 @@ fn audit(
9292
9378
  outcome: &str,
9293
9379
  status: u16,
9294
9380
  ) {
9381
+ // Every authenticated request records one of these. Since PR38 the request
9382
+ // waits only for ACCEPTANCE — the audit writer's append — and never for a
9383
+ // durability barrier; the barrier happens afterwards, in a bounded group,
9384
+ // on the writer's own threads.
9385
+ //
9386
+ // The error is still discarded, and that is still deliberate: PR37 proved
9387
+ // request success does not depend on audit durability, and PR38 does not
9388
+ // change that policy. What changed is that the failure is no longer
9389
+ // invisible — `AuditLog::record` has already counted it, timestamped it and
9390
+ // degraded the audit health state by the time this returns.
9391
+ let _span =
9392
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::AuditWrite);
9295
9393
  if let Err(error) = state.audit.record(AuditEvent {
9296
9394
  timestamp_ms: 0,
9297
9395
  namespace: &state.namespace,
@@ -9318,6 +9416,21 @@ async fn protocol_version(request: Request<axum::body::Body>, next: Next) -> Res
9318
9416
  next.run(request).await
9319
9417
  }
9320
9418
 
9419
+ /// Time a whole request inside the server, from the middleware stack down.
9420
+ ///
9421
+ /// This is the server's own share of a request. Subtracting it from the client's
9422
+ /// measured latency leaves transport, connection handling, and the JSON decode
9423
+ /// and encode that axum performs in the extractor and the response — none of
9424
+ /// which can be bracketed from inside a handler. PR36 reports that difference as
9425
+ /// a bounded residual rather than pretending to have decomposed it.
9426
+ ///
9427
+ /// Disabled, this costs one relaxed atomic load per request.
9428
+ async fn attribute_request(request: Request<axum::body::Body>, next: Next) -> Response {
9429
+ let _span =
9430
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HttpHandler);
9431
+ next.run(request).await
9432
+ }
9433
+
9321
9434
  async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>> {
9322
9435
  let membership = match state.cluster.proposal().map(|value| value.phase) {
9323
9436
  Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
@@ -9459,10 +9572,15 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
9459
9572
  .map(str::to_string)
9460
9573
  .collect()
9461
9574
  };
9462
- let key = store.create(
9575
+ // `--worker <id>` issues a credential that authenticates as that
9576
+ // one worker rather than as a service. It is a narrowing: the
9577
+ // worker lifecycle routes accept it only for its own worker id, and
9578
+ // it cannot satisfy a check that wants a human or a service.
9579
+ let key = store.create_for_worker(
9463
9580
  name,
9464
9581
  values("--scope", "state:read,state:write,events:read"),
9465
9582
  values("--namespace", "default"),
9583
+ option("--worker"),
9466
9584
  )?;
9467
9585
  println!(
9468
9586
  "API key created (id: {}). This secret will not be shown again:\n{}",
@@ -9477,7 +9595,7 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
9477
9595
  }
9478
9596
  println!("API key revoked: {id}");
9479
9597
  }
9480
- _ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--id key-id]".into()),
9598
+ _ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--id key-id]".into()),
9481
9599
  }
9482
9600
  Ok(true)
9483
9601
  }
@@ -9575,6 +9693,61 @@ fn manage_workloads() -> Result<bool, Box<dyn std::error::Error>> {
9575
9693
  Ok(true)
9576
9694
  }
9577
9695
 
9696
+ fn manage_state_triggers() -> Result<bool, Box<dyn std::error::Error>> {
9697
+ let arguments: Vec<String> = std::env::args().collect();
9698
+ if arguments.get(1).map(String::as_str) != Some("trigger") {
9699
+ return Ok(false);
9700
+ }
9701
+ let command = arguments.get(2).map(String::as_str).unwrap_or("list");
9702
+ let option = |name: &str| {
9703
+ arguments
9704
+ .iter()
9705
+ .position(|value| value == name)
9706
+ .and_then(|index| arguments.get(index + 1))
9707
+ .cloned()
9708
+ };
9709
+ let data = PathBuf::from(option("--data").unwrap_or_else(|| "./data/feltdb.log".into()));
9710
+ let trigger_path = PathBuf::from(option("--store").unwrap_or_else(|| {
9711
+ data.with_extension("triggers.json")
9712
+ .to_string_lossy()
9713
+ .into_owned()
9714
+ }));
9715
+ let mut triggers = StateTriggerStore::load(trigger_path)?;
9716
+ match command {
9717
+ "define" => {
9718
+ let input = option("--input").ok_or("trigger define requires --input trigger.json")?;
9719
+ let trigger: feltdb::StateTrigger = serde_json::from_slice(&std::fs::read(input)?)?;
9720
+ triggers.define(trigger)?;
9721
+ println!("{}", serde_json::to_string_pretty(&triggers.list())?);
9722
+ }
9723
+ "list" => println!("{}", serde_json::to_string_pretty(&triggers.list())?),
9724
+ "status" => println!(
9725
+ "{}",
9726
+ serde_json::to_string_pretty(&json!({
9727
+ "triggers": triggers.triggers.len(),
9728
+ "cursors": triggers.cursor_versions(),
9729
+ }))?
9730
+ ),
9731
+ "evaluate" => {
9732
+ let db = FeltDb::open(&data)?;
9733
+ let workload_path = PathBuf::from(
9734
+ option("--workload-store")
9735
+ .unwrap_or_else(|| data.with_extension("workloads.json").to_string_lossy().into_owned()),
9736
+ );
9737
+ let mut workloads = WorkloadStore::load(workload_path)?;
9738
+ let produced = triggers.evaluate(&db, &mut workloads, unix_seconds_i64())?;
9739
+ println!("{}", serde_json::to_string_pretty(&produced)?);
9740
+ }
9741
+ _ => {
9742
+ return Err(
9743
+ "usage: feltdb-server trigger <define|list|status|evaluate> [--data path] [--store path] [--workload-store path] [--input trigger.json]"
9744
+ .into(),
9745
+ )
9746
+ }
9747
+ }
9748
+ Ok(true)
9749
+ }
9750
+
9578
9751
  fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
9579
9752
  let arguments: Vec<String> = std::env::args().collect();
9580
9753
  if arguments.get(1).map(String::as_str) != Some("backup") {
@@ -9749,10 +9922,21 @@ async fn create_online_backup(
9749
9922
  Ok(Json(manifest))
9750
9923
  }
9751
9924
 
9752
- async fn network_metrics(
9753
- State(state): State<AppState>,
9754
- ) -> Json<feltdb_server::metrics::MetricsSnapshot> {
9755
- Json(state.metrics.snapshot())
9925
+ /// Network counters, plus the audit subsystem's health.
9926
+ ///
9927
+ /// The audit block is here rather than on a route of its own because PR37's
9928
+ /// finding was that losing security evidence was invisible, and a signal an
9929
+ /// operator has to know to go and look for is not much better than no signal.
9930
+ /// It rides with the metrics they already read.
9931
+ async fn network_metrics(State(state): State<AppState>) -> Json<Value> {
9932
+ let mut body = serde_json::to_value(state.metrics.snapshot()).unwrap_or_else(|_| json!({}));
9933
+ if let Some(fields) = body.as_object_mut() {
9934
+ fields.insert(
9935
+ "audit".to_string(),
9936
+ serde_json::to_value(state.audit.health()).unwrap_or(Value::Null),
9937
+ );
9938
+ }
9939
+ Json(body)
9756
9940
  }
9757
9941
 
9758
9942
  async fn sync_pull(
@@ -9760,11 +9944,25 @@ async fn sync_pull(
9760
9944
  Json(request): Json<SyncPullRequest>,
9761
9945
  ) -> Result<Json<SyncPullResponse>, ApiError> {
9762
9946
  authorize_sync_member(&state, &request.requester, request.membership_epoch)?;
9763
- let operations = state.db.operations_since(&request.versions)?;
9947
+ let peer_position = request.versions;
9948
+ let authority_position = state.db.operation_versions()?;
9949
+ let retained_from = state.db.retained_operation_floors()?;
9950
+ let catchup = catchup_status(request.requester, peer_position.clone(), authority_position.clone(), retained_from);
9951
+ if catchup.recovery_required {
9952
+ return Err(ApiError::structured(
9953
+ StatusCode::CONFLICT,
9954
+ serde_json::to_value(&catchup).unwrap_or_else(|_| json!({
9955
+ "catchup_mode": "snapshot_rebootstrap_required",
9956
+ "recovery_required": true
9957
+ })),
9958
+ ));
9959
+ }
9960
+ let operations = state.db.operations_since(&peer_position)?;
9764
9961
  state.metrics.sync_sent(operations.len() as u64);
9765
9962
  Ok(Json(SyncPullResponse {
9766
9963
  operations,
9767
- versions: state.db.operation_versions()?,
9964
+ versions: authority_position,
9965
+ catchup,
9768
9966
  }))
9769
9967
  }
9770
9968
 
@@ -9971,6 +10169,197 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
9971
10169
  }
9972
10170
  execute_agent_run(state, &row.value, &worker).await?;
9973
10171
  }
10172
+
10173
+ evaluate_state_triggers(state)?;
10174
+ execute_eligible_workloads(state, &worker)?;
10175
+ Ok(())
10176
+ }
10177
+
10178
+ /// How long an autonomous claim holds its lease.
10179
+ ///
10180
+ /// Long enough that an ordinary capability program finishes inside it, short
10181
+ /// enough that a worker dying mid-execution returns the work quickly. The lease
10182
+ /// is the only thing that makes worker death recoverable, so this is a real
10183
+ /// bound rather than a formality.
10184
+ const WORKLOAD_LEASE_MS: u64 = 30_000;
10185
+
10186
+ fn state_trigger_store(
10187
+ state: &AppState,
10188
+ ) -> Result<std::sync::MutexGuard<'_, feltdb::StateTriggerStore>, ApiError> {
10189
+ state.state_triggers.lock().map_err(|_| {
10190
+ ApiError(
10191
+ StatusCode::INTERNAL_SERVER_ERROR,
10192
+ "state trigger store unavailable".into(),
10193
+ )
10194
+ })
10195
+ }
10196
+
10197
+ fn state_trigger_error(error: feltdb::StateTriggerError) -> ApiError {
10198
+ let status = match error.code.as_str() {
10199
+ "TRIGGER_INVALID" | "TRIGGER_UNAUTHORIZED" => StatusCode::BAD_REQUEST,
10200
+ "WORKLOAD_UNAUTHORIZED" => StatusCode::FORBIDDEN,
10201
+ "TRIGGER_STATE_REPLAY_FAILED" | "TRIGGER_STORAGE_FAILURE" | "WORKLOAD_STORAGE_FAILURE" => {
10202
+ StatusCode::SERVICE_UNAVAILABLE
10203
+ }
10204
+ _ => StatusCode::CONFLICT,
10205
+ };
10206
+ ApiError(
10207
+ status,
10208
+ serde_json::to_string(&error).unwrap_or(error.message),
10209
+ )
10210
+ }
10211
+
10212
+ fn evaluate_state_triggers(state: &AppState) -> Result<(), ApiError> {
10213
+ let mut triggers = state_trigger_store(state)?;
10214
+ if triggers.triggers.is_empty() {
10215
+ return Ok(());
10216
+ }
10217
+ let mut workloads = workload_store(state)?;
10218
+ triggers
10219
+ .evaluate(&state.db, &mut workloads, unix_seconds_i64())
10220
+ .map_err(state_trigger_error)?;
10221
+ Ok(())
10222
+ }
10223
+
10224
+ /// The durable workload store, or a service error if its lock is poisoned.
10225
+ fn workload_store(
10226
+ state: &AppState,
10227
+ ) -> Result<std::sync::MutexGuard<'_, feltdb::workload::WorkloadStore>, ApiError> {
10228
+ state.workloads.lock().map_err(|_| {
10229
+ ApiError(
10230
+ StatusCode::INTERNAL_SERVER_ERROR,
10231
+ "workload store unavailable".into(),
10232
+ )
10233
+ })
10234
+ }
10235
+
10236
+ /// The capabilities this node can actually run.
10237
+ ///
10238
+ /// This is what the worker presents to [`WorkloadStore::claim`], which requires
10239
+ /// the workload's `capability_snapshot` to be a subset of it. That check is
10240
+ /// therefore load-bearing rather than ceremonial: a workload whose declared
10241
+ /// capabilities this node cannot execute is never claimed by it, and stays
10242
+ /// `Ready` for a node that can.
10243
+ fn worker_capabilities(state: &AppState) -> Result<BTreeSet<String>, ApiError> {
10244
+ let mut capabilities: BTreeSet<String> = ["search", "identity"]
10245
+ .into_iter()
10246
+ .map(str::to_string)
10247
+ .collect();
10248
+ for row in state.db.list_collection("_flow_capabilities")? {
10249
+ if let Some((_, name)) = row.key.split_once(':') {
10250
+ capabilities.insert(name.to_string());
10251
+ }
10252
+ }
10253
+ Ok(capabilities)
10254
+ }
10255
+
10256
+ /// Claim and execute the durable workloads this node is eligible to run.
10257
+ ///
10258
+ /// This is the composition that makes `WorkloadStore` an execution primitive
10259
+ /// rather than a work data model. It adds no queue and no worker system: the
10260
+ /// scan is the pass that already runs, the claim is the existing lease and
10261
+ /// fencing, the execution is the existing capability interpreter, and the
10262
+ /// transitions are the existing state machine.
10263
+ ///
10264
+ /// Temporal eligibility needs no special handling here — `Workload::is_eligible_at`
10265
+ /// filters candidates and `WorkloadStore::claim` refuses anything early, so
10266
+ /// `not_before` and retry backoff gate this path by construction.
10267
+ ///
10268
+ /// The store lock is released before execution. A capability program reads
10269
+ /// application state through the same database, so holding the workload lock
10270
+ /// across it would make every other workload operation wait on unrelated work.
10271
+ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiError> {
10272
+ let capabilities = worker_capabilities(state)?;
10273
+ let now = unix_seconds_i64();
10274
+ let candidates: Vec<(String, String, Value)> = {
10275
+ let store = workload_store(state)?;
10276
+ store
10277
+ .workloads
10278
+ .values()
10279
+ // Selection, not enforcement. `WorkloadStore::claim` independently
10280
+ // refuses work that is early or beyond this node's capabilities, and
10281
+ // that is where the guarantee lives; filtering here only avoids
10282
+ // attempting a claim, every pass, that is known to be refused.
10283
+ .filter(|workload| {
10284
+ workload.state == feltdb::workload::WorkloadState::Ready
10285
+ && workload.is_eligible_at(now)
10286
+ && workload.capability_snapshot.is_subset(&capabilities)
10287
+ })
10288
+ .map(|workload| {
10289
+ (
10290
+ workload.workload_id.clone(),
10291
+ workload.definition_id.clone(),
10292
+ workload.input.clone(),
10293
+ )
10294
+ })
10295
+ .collect()
10296
+ };
10297
+
10298
+ for (id, definition, input) in candidates {
10299
+ // Unlike the filter above, this one is enforcement: nothing downstream
10300
+ // knows whether `definition_id` names something runnable. Claiming a
10301
+ // workload only to dead-letter it would consume work another node could
10302
+ // have executed, so an unrunnable definition is left untouched.
10303
+ if !is_executable_capability(state, &definition)? {
10304
+ continue;
10305
+ }
10306
+ let claim = match workload_store(state)?.claim(
10307
+ &id,
10308
+ worker,
10309
+ &capabilities,
10310
+ unix_seconds_i64(),
10311
+ WORKLOAD_LEASE_MS,
10312
+ ) {
10313
+ Ok(claim) => claim,
10314
+ // Another worker took it, or eligibility moved between the scan and
10315
+ // the claim. Both are ordinary outcomes of concurrent workers, not
10316
+ // failures of this pass.
10317
+ Err(_) => continue,
10318
+ };
10319
+ workload_store(state)?
10320
+ .start(
10321
+ &id,
10322
+ &claim.claim_id,
10323
+ worker,
10324
+ claim.fencing_token,
10325
+ unix_seconds_i64(),
10326
+ )
10327
+ .map_err(workload_error)?;
10328
+
10329
+ match execute_named_capability(state, &definition, &input) {
10330
+ Ok(output) => {
10331
+ workload_store(state)?
10332
+ .complete(
10333
+ &id,
10334
+ &claim.claim_id,
10335
+ worker,
10336
+ claim.fencing_token,
10337
+ &format!("result_{id}_{}", claim.fencing_token),
10338
+ output,
10339
+ vec![],
10340
+ 1,
10341
+ unix_seconds_i64(),
10342
+ )
10343
+ .map_err(workload_error)?;
10344
+ }
10345
+ Err(error) => {
10346
+ // A capability program that fails does so deterministically, so
10347
+ // this failure kind is deliberately outside the default
10348
+ // `retry_on` set: retrying it would burn attempts to reach the
10349
+ // same dead letter. Only the message is carried, because the
10350
+ // store refuses failure text that looks like a secret.
10351
+ let _ = workload_store(state)?.fail(
10352
+ &id,
10353
+ &claim.claim_id,
10354
+ worker,
10355
+ claim.fencing_token,
10356
+ "execution_error",
10357
+ &format!("capability {definition} failed with status {}", error.0),
10358
+ unix_seconds_i64(),
10359
+ );
10360
+ }
10361
+ }
10362
+ }
9974
10363
  Ok(())
9975
10364
  }
9976
10365
 
@@ -11795,7 +12184,15 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
11795
12184
  }
11796
12185
  }
11797
12186
 
11798
- fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Result<bool, ApiError> {
12187
+ /// Apply one condition to the field value the record supplies for it.
12188
+ ///
12189
+ /// Taking the field value rather than the record lets the scan path evaluate a
12190
+ /// condition against a borrowed record plus its authority-supplied `recordId`,
12191
+ /// without first cloning the record to insert that field.
12192
+ fn condition_matches_field(
12193
+ actual: Option<&Value>,
12194
+ condition: &BoundedQueryCondition,
12195
+ ) -> Result<bool, ApiError> {
11799
12196
  if condition.operators.len() != 1 || condition.field.trim().is_empty() {
11800
12197
  return Err(bounded_query_error(
11801
12198
  "INVALID_QUERY",
@@ -11807,7 +12204,6 @@ fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Resul
11807
12204
  .iter()
11808
12205
  .next()
11809
12206
  .expect("validated operator");
11810
- let actual = record.get(&condition.field);
11811
12207
  if operator == "eq" {
11812
12208
  return Ok(actual == Some(expected));
11813
12209
  }
@@ -11832,11 +12228,166 @@ fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Resul
11832
12228
  })
11833
12229
  }
11834
12230
 
12231
+ /// The record identity the query surface exposes as `recordId`.
12232
+ fn bounded_query_record_id(key: &str) -> &str {
12233
+ key.split_once(':').map(|(_, id)| id).unwrap_or(key)
12234
+ }
12235
+
12236
+ /// Does a stored record satisfy every condition of a bounded query?
12237
+ ///
12238
+ /// This is the same conjunction the materializing path applied, evaluated
12239
+ /// against a borrowed record. `recordId` is authority metadata rather than
12240
+ /// caller-controlled document data, so it shadows a document field of that name
12241
+ /// exactly where the materializing path inserted it: on object records only.
12242
+ fn bounded_query_matches(row: &feltdb::StoredRow, conditions: &[BoundedQueryCondition]) -> bool {
12243
+ let record_id = row
12244
+ .value
12245
+ .is_object()
12246
+ .then(|| Value::String(bounded_query_record_id(&row.key).to_string()));
12247
+ conditions.iter().all(|condition| {
12248
+ let actual = match record_id.as_ref() {
12249
+ Some(id) if condition.field == "recordId" => Some(id),
12250
+ _ => row.value.get(&condition.field),
12251
+ };
12252
+ condition_matches_field(actual, condition).unwrap_or(false)
12253
+ })
12254
+ }
12255
+
12256
+ /// Declare the equality indexes this authority maintains.
12257
+ ///
12258
+ /// `FELTDB_EQUALITY_INDEXES=orders.status,orders.tenantId` — a comma-separated
12259
+ /// list of `collection.field`, split at the first dot so a field name may itself
12260
+ /// contain one. Declaration is explicit and static on purpose: PR35 adds an
12261
+ /// equality execution primitive, not a planner, so nothing here inspects a
12262
+ /// workload, collects statistics, or decides on its own what deserves an index.
12263
+ ///
12264
+ /// Each declaration is populated from authoritative state as it is made, so the
12265
+ /// authority never serves a query against a half-built index.
12266
+ fn declare_configured_equality_indexes(db: &FeltDb) -> Result<Vec<String>, String> {
12267
+ let Ok(declaration) = std::env::var("FELTDB_EQUALITY_INDEXES") else {
12268
+ return Ok(Vec::new());
12269
+ };
12270
+ let mut declared = Vec::new();
12271
+ for entry in declaration.split(',').map(str::trim).filter(|entry| !entry.is_empty()) {
12272
+ let Some((collection, field)) = entry.split_once('.') else {
12273
+ return Err(format!(
12274
+ "FELTDB_EQUALITY_INDEXES entry {entry:?} must be written as collection.field"
12275
+ ));
12276
+ };
12277
+ db.create_equality_index(collection, field)
12278
+ .map_err(|error| format!("cannot index {entry}: {error}"))?;
12279
+ declared.push(format!("{collection}.{field}"));
12280
+ }
12281
+ Ok(declared)
12282
+ }
12283
+
12284
+ /// Which execution a bounded query is allowed to take.
12285
+ ///
12286
+ /// `Auto` is the only mode the product has: use the index when one applies,
12287
+ /// scan otherwise. `Scan` and `Index` exist so a test can run the *same* query
12288
+ /// down both paths and compare the externally visible result, which is the only
12289
+ /// way to demonstrate that an optimization changed nothing but cost. They are
12290
+ /// not a public API: the mode arrives on an undocumented request header that is
12291
+ /// read only when the process was started with `FELTDB_QUERY_DIAGNOSTICS=1`, so
12292
+ /// a production authority has exactly one execution mode and no way to be asked
12293
+ /// for another. The request body — the query contract itself — is untouched.
12294
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
12295
+ enum BoundedQueryExecution {
12296
+ Auto,
12297
+ Scan,
12298
+ Index,
12299
+ }
12300
+
12301
+ /// Test instrumentation is compiled in but inert unless the process opted in,
12302
+ /// evaluated once so a request cannot pay for the lookup.
12303
+ fn query_diagnostics_enabled() -> bool {
12304
+ static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
12305
+ *ENABLED.get_or_init(|| std::env::var("FELTDB_QUERY_DIAGNOSTICS").as_deref() == Ok("1"))
12306
+ }
12307
+
12308
+ const QUERY_EXECUTION_HEADER: &str = "x-feltdb-query-execution";
12309
+
12310
+ fn bounded_query_execution(headers: &HeaderMap) -> Result<BoundedQueryExecution, ApiError> {
12311
+ if !query_diagnostics_enabled() {
12312
+ return Ok(BoundedQueryExecution::Auto);
12313
+ }
12314
+ match headers
12315
+ .get(QUERY_EXECUTION_HEADER)
12316
+ .and_then(|value| value.to_str().ok())
12317
+ {
12318
+ None | Some("auto") => Ok(BoundedQueryExecution::Auto),
12319
+ Some("scan") => Ok(BoundedQueryExecution::Scan),
12320
+ Some("index") => Ok(BoundedQueryExecution::Index),
12321
+ Some(other) => Err(bounded_query_error(
12322
+ "INVALID_QUERY",
12323
+ format!("unsupported query execution mode: {other}"),
12324
+ )),
12325
+ }
12326
+ }
12327
+
12328
+ /// The equality conditions of a query, as candidate-selection inputs.
12329
+ ///
12330
+ /// Only `eq` contributes: every other operator is a comparison this index does
12331
+ /// not model, and PR35 adds no range index. `recordId` is excluded because on
12332
+ /// this surface it is authority metadata that shadows a document field of the
12333
+ /// same name on object-valued records only — an index over the document field
12334
+ /// would answer a different question from the one `bounded_query_matches` asks,
12335
+ /// so a `recordId` condition is never index eligible and its query falls back to
12336
+ /// the scan (or is served by candidates from a different, indexed condition).
12337
+ ///
12338
+ /// Conditions the index cannot use are simply absent here. They are not dropped
12339
+ /// from the query: `bounded_query_matches` still evaluates every one of them
12340
+ /// against authoritative records.
12341
+ fn bounded_query_equalities(conditions: &[BoundedQueryCondition]) -> Vec<(&str, &Value)> {
12342
+ conditions
12343
+ .iter()
12344
+ .filter(|condition| condition.field != "recordId" && condition.operators.len() == 1)
12345
+ .filter_map(|condition| {
12346
+ let (operator, expected) = condition.operators.iter().next()?;
12347
+ (operator == "eq").then_some((condition.field.as_str(), expected))
12348
+ })
12349
+ .collect()
12350
+ }
12351
+
12352
+ /// Evaluate a bounded query's conjunction against authoritative records.
12353
+ ///
12354
+ /// Two executions, one result. The indexed execution narrows *which* records the
12355
+ /// predicate sees; `bounded_query_matches` — unchanged, and the sole authority on
12356
+ /// what matches — decides the answer on either path. When no index applies the
12357
+ /// scan runs exactly as PR34 left it.
12358
+ fn bounded_query_records(
12359
+ state: &AppState,
12360
+ request: &BoundedQueryRequest,
12361
+ execution: BoundedQueryExecution,
12362
+ ) -> Result<Vec<feltdb::StoredRow>, ApiError> {
12363
+ if execution != BoundedQueryExecution::Scan {
12364
+ let equalities = bounded_query_equalities(&request.conditions);
12365
+ let indexed = state.db.query_collection_by_equality(
12366
+ &request.collection,
12367
+ &equalities,
12368
+ |row| bounded_query_matches(row, &request.conditions),
12369
+ )?;
12370
+ if let Some(records) = indexed {
12371
+ return Ok(records);
12372
+ }
12373
+ if execution == BoundedQueryExecution::Index {
12374
+ // Only reachable through the test-only header. A forced-index run
12375
+ // that silently scanned would make an equivalence test prove nothing.
12376
+ return Err(bounded_query_error(
12377
+ "INDEX_UNAVAILABLE",
12378
+ "no equality index applies to this query",
12379
+ ));
12380
+ }
12381
+ }
12382
+ Ok(state.db.query_collection(&request.collection, None, |row| {
12383
+ bounded_query_matches(row, &request.conditions)
12384
+ })?)
12385
+ }
12386
+
11835
12387
  fn issue_bounded_cursor(
11836
12388
  state: &AppState,
11837
12389
  mut cursor: BoundedQueryCursor,
11838
- ) -> Result<String, ApiError> {
11839
- let token = uuid::Uuid::new_v4().simple().to_string();
12390
+ ) -> Result<String, ApiError> { let token = uuid::Uuid::new_v4().simple().to_string();
11840
12391
  let now = unix_seconds_i64().max(0) as u64;
11841
12392
  cursor.created_at = now;
11842
12393
  let mut cursors = state.bounded_query_cursors.lock().map_err(|_| {
@@ -11859,8 +12410,12 @@ fn issue_bounded_cursor(
11859
12410
  async fn execute_bounded_query(
11860
12411
  State(state): State<AppState>,
11861
12412
  Extension(principal): Extension<Principal>,
12413
+ headers: HeaderMap,
11862
12414
  Json(request): Json<BoundedQueryRequest>,
11863
12415
  ) -> Result<Json<BoundedQueryPage>, ApiError> {
12416
+ let _handler =
12417
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
12418
+ let execution = bounded_query_execution(&headers)?;
11864
12419
  validate_segment(&request.collection)?;
11865
12420
  if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
11866
12421
  return Err(bounded_query_error(
@@ -11928,30 +12483,40 @@ async fn execute_bounded_query(
11928
12483
  }
11929
12484
  (cursor.records, cursor.position)
11930
12485
  } else {
11931
- let mut records = Vec::new();
11932
- for row in state.db.list_collection(&request.collection)? {
12486
+ // Bounded queries evaluate their conjunction against borrowed records
12487
+ // and clone only the matches, so a query never pays to materialize the
12488
+ // records it is about to discard. An equality index, where one applies,
12489
+ // narrows which records the conjunction is evaluated against; it does
12490
+ // not decide the answer and it does not order it. Ordering is part of
12491
+ // this surface's contract, so the matching set — not the collection, and
12492
+ // not an index bucket — is what gets sorted, and the limit is applied to
12493
+ // that ordered set. Nothing below this line knows which execution ran.
12494
+ let matches = bounded_query_records(&state, &request, execution)?;
12495
+ // Result materialization happens outside the state lock, so it is a cost
12496
+ // this request pays alone rather than one it imposes on every other
12497
+ // operation. PR36 measures it separately for exactly that reason.
12498
+ let materialization = feltdb::workload_diagnostics::span(
12499
+ feltdb::workload_diagnostics::Phase::ResultMaterialization,
12500
+ );
12501
+ let mut records = Vec::with_capacity(matches.len());
12502
+ for row in matches {
11933
12503
  let mut value = row.value;
11934
12504
  if let Some(object) = value.as_object_mut() {
11935
- let id = row
11936
- .key
11937
- .split_once(':')
11938
- .map(|(_, id)| id)
11939
- .unwrap_or(&row.key);
11940
12505
  // `recordId` is authority metadata for this query surface, not
11941
12506
  // caller-controlled document data. It is the final total-order
11942
12507
  // tie-breaker even when a document contains a field by that name.
11943
- object.insert("recordId".into(), Value::String(id.to_string()));
11944
- }
11945
- if request
11946
- .conditions
11947
- .iter()
11948
- .all(|condition| condition_matches(&value, condition).unwrap_or(false))
11949
- {
11950
- records.push(value);
12508
+ object.insert(
12509
+ "recordId".into(),
12510
+ Value::String(bounded_query_record_id(&row.key).to_string()),
12511
+ );
11951
12512
  }
12513
+ records.push(value);
11952
12514
  }
12515
+ drop(materialization);
11953
12516
  // The authority always adds its immutable record identity as the final
11954
12517
  // tie-breaker, so equal user sort values still form a total order.
12518
+ let ordering =
12519
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Ordering);
11955
12520
  records.sort_by(|left, right| {
11956
12521
  for order in &request.order_by {
11957
12522
  let comparison = query_scalar_cmp(left.get(&order.field), right.get(&order.field));
@@ -11965,6 +12530,7 @@ async fn execute_bounded_query(
11965
12530
  }
11966
12531
  query_scalar_cmp(left.get("recordId"), right.get("recordId"))
11967
12532
  });
12533
+ drop(ordering);
11968
12534
  (Arc::new(records), 0)
11969
12535
  };
11970
12536
  let end = position.saturating_add(request.limit).min(records.len());
@@ -11991,6 +12557,131 @@ async fn execute_bounded_query(
11991
12557
  }))
11992
12558
  }
11993
12559
 
12560
+ /// Collection-read counters, mounted only when the process opts in.
12561
+ ///
12562
+ /// This is test instrumentation for the PR34 no-materialization proof, not part
12563
+ /// of the query contract, so a server only carries the route when it is started
12564
+ /// with `FELTDB_QUERY_DIAGNOSTICS=1`. It returns counts, never record data.
12565
+ fn query_execution_diagnostics_router() -> Router<AppState> {
12566
+ let mut router = Router::new();
12567
+ if std::env::var("FELTDB_QUERY_DIAGNOSTICS").as_deref() == Ok("1") {
12568
+ router = router.route(
12569
+ "/internal/query-execution-diagnostics",
12570
+ get(query_execution_diagnostics),
12571
+ );
12572
+ }
12573
+ // Phase attribution is a separate switch from the execution counters,
12574
+ // because it is a separate cost: the counters are a handful of atomic adds
12575
+ // per query, while attribution reads the clock at every instrumented
12576
+ // boundary. Keeping them apart lets a run take execution counts without
12577
+ // paying for timing, which is how the headline PR36 figures are measured.
12578
+ if std::env::var("FELTDB_WORKLOAD_DIAGNOSTICS").as_deref() == Ok("1") {
12579
+ router = router
12580
+ .route(
12581
+ "/internal/workload-diagnostics",
12582
+ get(workload_phase_diagnostics),
12583
+ )
12584
+ .route(
12585
+ "/internal/workload-diagnostics/reset",
12586
+ axum::routing::post(reset_workload_phase_diagnostics),
12587
+ );
12588
+ }
12589
+ router
12590
+ }
12591
+
12592
+ /// Phase attribution: where elapsed time went, as counts and nanoseconds.
12593
+ ///
12594
+ /// Test instrumentation, mounted only under `FELTDB_WORKLOAD_DIAGNOSTICS=1`.
12595
+ /// It reports durations and call counts and nothing else — no record, no field,
12596
+ /// no value, no id — and no product surface reads it.
12597
+ ///
12598
+ /// `parent` is part of the payload because the phases nest: a caller that summed
12599
+ /// every row would double-count the work reported inside `state_lock_hold` and
12600
+ /// `indexed_query`. The nesting is declared here so an analysis can compute
12601
+ /// exclusive time instead of guessing at it.
12602
+ async fn workload_phase_diagnostics() -> Json<Value> {
12603
+ let phases: Vec<Value> = feltdb::workload_diagnostics::counters()
12604
+ .into_iter()
12605
+ .map(|counter| {
12606
+ json!({
12607
+ "phase": counter.phase.name(),
12608
+ "parent": counter.phase.parent().map(|parent| parent.name()),
12609
+ "calls": counter.calls,
12610
+ "nanos": counter.nanos,
12611
+ "meanNanos": counter.mean_nanos(),
12612
+ })
12613
+ })
12614
+ .collect();
12615
+ Json(json!({
12616
+ "enabled": feltdb::workload_diagnostics::enabled(),
12617
+ "phases": phases,
12618
+ }))
12619
+ }
12620
+
12621
+ /// Zero the phase counters, so a measured window starts from a clean base.
12622
+ async fn reset_workload_phase_diagnostics() -> Json<Value> {
12623
+ feltdb::workload_diagnostics::reset();
12624
+ Json(json!({ "reset": true }))
12625
+ }
12626
+
12627
+ /// Ask the diagnostics route to also verify the index against records.
12628
+ #[derive(Deserialize)]
12629
+ struct QueryDiagnosticsScope {
12630
+ #[serde(default)]
12631
+ verify: Option<String>,
12632
+ }
12633
+
12634
+ async fn query_execution_diagnostics(
12635
+ State(state): State<AppState>,
12636
+ Query(scope): Query<QueryDiagnosticsScope>,
12637
+ ) -> Json<Value> {
12638
+ let counters = feltdb::query_execution_diagnostics::counters();
12639
+ // Verifying rebuilds the whole index from records to compare against the
12640
+ // live one, so it is linear in collection size and is asked for explicitly.
12641
+ // A counter read that silently paid for a rebuild would make every
12642
+ // measurement that brackets a query with a counter read measure the rebuild.
12643
+ let consistency = match scope.verify.as_deref() {
12644
+ Some("1") => state
12645
+ .db
12646
+ .verify_equality_index()
12647
+ .map(|outcome| Value::Bool(outcome.is_ok()))
12648
+ .unwrap_or(Value::Null),
12649
+ _ => Value::Null,
12650
+ };
12651
+ Json(json!({
12652
+ "fullCollectionMaterializations": counters.full_collection_materializations,
12653
+ "fullCollectionRecordsCloned": counters.full_collection_records_cloned,
12654
+ "collectionScans": counters.collection_scans,
12655
+ "scanRecordsVisited": counters.scan_records_visited,
12656
+ "scanRecordsMaterialized": counters.scan_records_materialized,
12657
+ "queriesTotal": counters.queries_total,
12658
+ "queriesScan": counters.queries_scan,
12659
+ "queriesIndexed": counters.queries_indexed,
12660
+ "indexHits": counters.index_hits,
12661
+ "indexMisses": counters.index_misses,
12662
+ "indexCandidatesExamined": counters.index_candidates_examined,
12663
+ "recordsPredicateEvaluated": counters.records_predicate_evaluated,
12664
+ "equalityIndexes": state
12665
+ .db
12666
+ .equality_indexes()
12667
+ .map(|indexes| {
12668
+ indexes
12669
+ .into_iter()
12670
+ .map(|(collection, field)| format!("{collection}.{field}"))
12671
+ .collect::<Vec<_>>()
12672
+ })
12673
+ .unwrap_or_default(),
12674
+ "equalityIndexStats": state.db.equality_index_stats().map(|stats| json!({
12675
+ "indexedFields": stats.indexed_fields,
12676
+ "valueBuckets": stats.value_buckets,
12677
+ "entries": stats.entries,
12678
+ })).unwrap_or(Value::Null),
12679
+ // Null unless `?verify=1` asked for the check; a null here means "not
12680
+ // checked", never "checked and inconsistent".
12681
+ "equalityIndexConsistent": consistency,
12682
+ }))
12683
+ }
12684
+
11994
12685
  async fn list_records(
11995
12686
  State(state): State<AppState>,
11996
12687
  Path(collection): Path<String>,
@@ -12015,6 +12706,8 @@ async fn get_record(
12015
12706
  State(state): State<AppState>,
12016
12707
  Path((collection, id)): Path<(String, String)>,
12017
12708
  ) -> Result<Json<RecordResponse>, ApiError> {
12709
+ let _handler =
12710
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
12018
12711
  if collection == PROPOSAL_COLLECTION
12019
12712
  || collection == PROPOSAL_EVENT_COLLECTION
12020
12713
  || collection == CONVERGENCE_COLLECTION
@@ -12123,6 +12816,8 @@ async fn commit_transaction(
12123
12816
  State(state): State<AppState>,
12124
12817
  Json(request): Json<AtomicTxRequest>,
12125
12818
  ) -> Result<(StatusCode, Json<AtomicTxResponse>), ApiError> {
12819
+ let _handler =
12820
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
12126
12821
  if request.transaction_id.trim().is_empty() {
12127
12822
  return Err(ApiError(
12128
12823
  StatusCode::BAD_REQUEST,
@@ -12272,6 +12967,8 @@ async fn create_record(
12272
12967
  Path(collection): Path<String>,
12273
12968
  Json(mut record): Json<CreateRecord>,
12274
12969
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
12970
+ let _handler =
12971
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
12275
12972
  if collection == PROPOSAL_COLLECTION
12276
12973
  || collection == PROPOSAL_EVENT_COLLECTION
12277
12974
  || collection == CONVERGENCE_COLLECTION
@@ -12316,6 +13013,8 @@ async fn update_record(
12316
13013
  Path((collection, id)): Path<(String, String)>,
12317
13014
  Json(changes): Json<Map<String, Value>>,
12318
13015
  ) -> Result<Json<RecordResponse>, ApiError> {
13016
+ let _handler =
13017
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
12319
13018
  if collection == PROPOSAL_COLLECTION
12320
13019
  || collection == PROPOSAL_EVENT_COLLECTION
12321
13020
  || collection == CONVERGENCE_COLLECTION
@@ -12353,6 +13052,8 @@ async fn compare_and_set_record(
12353
13052
  Path((collection, id)): Path<(String, String)>,
12354
13053
  Json(request): Json<CasRecordRequest>,
12355
13054
  ) -> Result<Response, ApiError> {
13055
+ let _handler =
13056
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
12356
13057
  let key = record_key(&collection, &id)?;
12357
13058
  let result = state.db.compare_and_set_json(
12358
13059
  &key,
@@ -12442,6 +13143,8 @@ async fn delete_record(
12442
13143
  State(state): State<AppState>,
12443
13144
  Path((collection, id)): Path<(String, String)>,
12444
13145
  ) -> Result<StatusCode, ApiError> {
13146
+ let _handler =
13147
+ feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
12445
13148
  if collection == PROPOSAL_COLLECTION
12446
13149
  || collection == PROPOSAL_EVENT_COLLECTION
12447
13150
  || collection == CONVERGENCE_COLLECTION
@@ -12562,6 +13265,69 @@ async fn shutdown_signal() {
12562
13265
  tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
12563
13266
  }
12564
13267
 
13268
+ #[cfg(test)]
13269
+ mod worker_identity_tests {
13270
+ use super::grant_subject;
13271
+ use feltdb::authorization::Subject as GrantSubject;
13272
+ use feltdb_server::auth::Principal;
13273
+
13274
+ fn principal(subject_type: &str, identity: Option<&str>) -> Principal {
13275
+ Principal {
13276
+ key_id: "key-1".into(),
13277
+ scopes: vec!["*".into()],
13278
+ subject_type: subject_type.into(),
13279
+ identity_id: identity.map(str::to_string),
13280
+ session_id: None,
13281
+ }
13282
+ }
13283
+
13284
+ /// The workload worker lifecycle compares the authenticated subject against
13285
+ /// the `worker_id` in the request, so which subject each principal maps to
13286
+ /// *is* the security boundary. Nothing but the authenticated principal
13287
+ /// participates in this mapping.
13288
+ #[test]
13289
+ fn only_a_worker_principal_maps_to_a_worker_subject() {
13290
+ assert_eq!(
13291
+ grant_subject(&principal("worker", Some("worker-x"))),
13292
+ GrantSubject::Worker("worker-x".into()),
13293
+ );
13294
+ // Every other principal maps somewhere a worker check can never accept,
13295
+ // no matter what identity it carries or what scopes it holds.
13296
+ for (subject_type, expected) in [
13297
+ ("human", GrantSubject::Human("worker-x".into())),
13298
+ ("user", GrantSubject::Human("worker-x".into())),
13299
+ ("service", GrantSubject::Service("worker-x".into())),
13300
+ ("api_key", GrantSubject::Service("worker-x".into())),
13301
+ ("agent", GrantSubject::Agent("worker-x".into())),
13302
+ ] {
13303
+ let subject = grant_subject(&principal(subject_type, Some("worker-x")));
13304
+ assert_eq!(subject, expected, "{subject_type} mapped unexpectedly");
13305
+ assert_ne!(
13306
+ subject,
13307
+ GrantSubject::Worker("worker-x".into()),
13308
+ "{subject_type} must never satisfy a worker check"
13309
+ );
13310
+ }
13311
+ }
13312
+
13313
+ /// A worker principal is one worker, not any worker.
13314
+ #[test]
13315
+ fn a_worker_subject_is_bound_to_its_own_identity() {
13316
+ let subject = grant_subject(&principal("worker", Some("worker-x")));
13317
+ assert_ne!(subject, GrantSubject::Worker("worker-y".into()));
13318
+ }
13319
+
13320
+ /// Without an identity the principal falls back to its key id, so a worker
13321
+ /// credential missing its worker id cannot silently become a wildcard.
13322
+ #[test]
13323
+ fn a_worker_principal_without_an_identity_falls_back_to_its_key_id() {
13324
+ assert_eq!(
13325
+ grant_subject(&principal("worker", None)),
13326
+ GrantSubject::Worker("key-1".into()),
13327
+ );
13328
+ }
13329
+ }
13330
+
12565
13331
  #[cfg(test)]
12566
13332
  mod authority_gate_tests {
12567
13333
  use super::{api_error_body, authorize_transaction_collections, state_authorization};