@feltdb/core 0.8.4 → 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 (149) hide show
  1. package/dist/create/package-versions.js +1 -1
  2. package/dist/create/server-source/Cargo.lock +165 -0
  3. package/dist/create/server-source/Cargo.toml +9 -0
  4. package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
  5. package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
  6. package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
  7. package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
  8. package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
  9. package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
  10. package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
  11. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
  12. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
  13. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
  14. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
  15. package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
  16. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
  17. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
  18. package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
  19. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
  20. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
  21. package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
  22. package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
  23. package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
  24. package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
  25. package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
  26. package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
  27. package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
  28. package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
  29. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
  30. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
  31. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
  32. package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
  33. package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
  34. package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
  35. package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
  36. package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
  37. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
  38. package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
  39. package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
  40. package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
  41. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
  42. package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
  43. package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
  44. package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
  45. package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
  46. package/dist/create/server-source/crates/feltdb/src/lib.rs +507 -6
  47. package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
  48. package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
  49. package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
  50. package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
  51. package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
  52. package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
  53. package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
  54. package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
  55. package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
  56. package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
  57. package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
  58. package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
  59. package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
  60. package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
  61. package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
  62. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +79 -70
  63. package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
  64. package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
  65. package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
  66. package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
  67. package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
  68. package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
  69. package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
  70. package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
  71. package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
  72. package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
  73. package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
  74. package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
  75. package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
  76. package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
  77. package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
  78. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
  79. package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
  80. package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
  81. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
  82. package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
  83. package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
  84. package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
  85. package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
  86. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +13 -4
  87. package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
  88. package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
  89. package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
  90. package/dist/create/server-source/crates/feltdb/src/state_model.rs +1514 -537
  91. package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
  92. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
  93. package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
  94. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
  95. package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
  96. package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
  97. package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
  98. package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
  99. package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
  100. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  101. package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
  102. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +3 -4
  103. package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
  104. package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -0
  105. package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
  106. package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
  107. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
  108. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
  109. package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
  110. package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +2 -1
  111. package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +80 -25
  112. package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
  113. package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
  114. package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
  115. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
  116. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
  117. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
  118. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
  119. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
  120. package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
  121. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1032 -0
  122. package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
  123. package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
  124. package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
  125. package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
  126. package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
  127. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
  128. package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
  129. package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
  130. package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
  131. package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
  132. package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
  133. package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
  134. package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
  135. package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
  136. package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
  137. package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
  138. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +2 -1
  139. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
  140. package/dist/create/server-source/crates/feltdb-server/src/main.rs +326 -6
  141. package/dist/studio-app/assets/{feltdb_wasm-CVQWgXO-.js → feltdb_wasm-DaNwCLRX.js} +1 -1
  142. package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
  143. package/dist/studio-app/assets/{index-DwgNAIIX.js → index-j8IlhNqJ.js} +1 -1
  144. package/dist/studio-app/index.html +1 -1
  145. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  146. package/dist/workload.d.ts +2 -1
  147. package/dist/workload.d.ts.map +1 -1
  148. package/package.json +1 -1
  149. package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
@@ -31,7 +31,7 @@ use feltdb::{
31
31
  sync_contract::SyncStore,
32
32
  worker_mesh::WorkerMeshStore,
33
33
  workload::WorkloadStore,
34
- FeltDb,
34
+ FeltDb, StateTriggerStore,
35
35
  };
36
36
 
37
37
  #[derive(Clone)]
@@ -72,6 +72,7 @@ pub struct AppState {
72
72
  pub grants: Arc<std::sync::Mutex<GrantStore>>,
73
73
  pub sync: Arc<std::sync::Mutex<SyncStore>>,
74
74
  pub workloads: Arc<std::sync::Mutex<WorkloadStore>>,
75
+ pub state_triggers: Arc<std::sync::Mutex<StateTriggerStore>>,
75
76
  pub mesh: Arc<std::sync::Mutex<WorkerMeshStore>>,
76
77
  pub readiness_probe: Arc<PathBuf>,
77
78
  pub bounded_query_cursors: Arc<std::sync::Mutex<HashMap<String, BoundedQueryCursor>>>,
@@ -40,6 +40,17 @@ pub struct ApiKeyRecord {
40
40
  pub namespaces: Vec<String>,
41
41
  #[serde(default)]
42
42
  pub revoked: bool,
43
+ /// The worker this credential *is*, when it is a worker credential.
44
+ ///
45
+ /// A key without one authenticates as a service, which is what every key
46
+ /// did before this field existed and what every key without it still does.
47
+ /// A key with one authenticates as `Subject::Worker(worker_id)` and is
48
+ /// thereby **narrower**, not wider: the worker lifecycle routes compare the
49
+ /// authenticated subject against the `worker_id` in the request body, so
50
+ /// such a credential can act as exactly one worker and no other, and cannot
51
+ /// satisfy a check that requires a human or service subject.
52
+ #[serde(default)]
53
+ pub worker_id: Option<String>,
43
54
  }
44
55
 
45
56
  #[derive(Debug, Clone, Serialize)]
@@ -104,6 +115,9 @@ impl KeyStore {
104
115
  scopes: token.scopes,
105
116
  namespaces: vec![token.namespace],
106
117
  revoked: false,
118
+ // Legacy credentials predate worker identity and
119
+ // stay services, which is what they already were.
120
+ worker_id: None,
107
121
  });
108
122
  }
109
123
  (records, true)
@@ -141,12 +155,25 @@ impl KeyStore {
141
155
  .iter()
142
156
  .any(|allowed| allowed == "*" || allowed == namespace);
143
157
  if namespace_allowed && verify_api_key(token, &record.hash) {
144
- Some(Principal {
145
- key_id: record.id.clone(),
146
- scopes: record.scopes.clone(),
147
- subject_type: "service".into(),
148
- identity_id: None,
149
- session_id: None,
158
+ // The credential decides the subject type. A worker
159
+ // credential can never present as a service and a service
160
+ // credential can never present as a worker, because neither
161
+ // the request nor any header participates in this choice.
162
+ Some(match &record.worker_id {
163
+ Some(worker) => Principal {
164
+ key_id: record.id.clone(),
165
+ scopes: record.scopes.clone(),
166
+ subject_type: "worker".into(),
167
+ identity_id: Some(worker.clone()),
168
+ session_id: None,
169
+ },
170
+ None => Principal {
171
+ key_id: record.id.clone(),
172
+ scopes: record.scopes.clone(),
173
+ subject_type: "service".into(),
174
+ identity_id: None,
175
+ session_id: None,
176
+ },
150
177
  })
151
178
  } else {
152
179
  None
@@ -159,6 +186,21 @@ impl KeyStore {
159
186
  name: String,
160
187
  scopes: Vec<String>,
161
188
  namespaces: Vec<String>,
189
+ ) -> Result<CreatedApiKey, String> {
190
+ self.create_for_worker(name, scopes, namespaces, None)
191
+ }
192
+
193
+ /// Issue a credential that authenticates as one specific worker.
194
+ ///
195
+ /// `worker_id` of `None` produces an ordinary service credential, so this
196
+ /// is the one construction path and [`KeyStore::create`] is the common case
197
+ /// spelled out.
198
+ pub fn create_for_worker(
199
+ &self,
200
+ name: String,
201
+ scopes: Vec<String>,
202
+ namespaces: Vec<String>,
203
+ worker_id: Option<String>,
162
204
  ) -> Result<CreatedApiKey, String> {
163
205
  let secret: String = rand::thread_rng()
164
206
  .sample_iter(&Alphanumeric)
@@ -185,6 +227,7 @@ impl KeyStore {
185
227
  scopes,
186
228
  namespaces,
187
229
  revoked: false,
230
+ worker_id,
188
231
  });
189
232
  self.persist()?;
190
233
  Ok(CreatedApiKey { id, secret: token })
@@ -266,13 +309,11 @@ fn verify_api_key(token: &str, stored_hash: &str) -> bool {
266
309
  .is_ok();
267
310
  }
268
311
 
269
- PasswordHash::new(stored_hash)
270
- .ok()
271
- .is_some_and(|parsed| {
272
- Argon2::default()
273
- .verify_password(token.as_bytes(), &parsed)
274
- .is_ok()
275
- })
312
+ PasswordHash::new(stored_hash).ok().is_some_and(|parsed| {
313
+ Argon2::default()
314
+ .verify_password(token.as_bytes(), &parsed)
315
+ .is_ok()
316
+ })
276
317
  }
277
318
 
278
319
  #[cfg(test)]
@@ -341,4 +382,114 @@ mod tests {
341
382
  assert!(migrated.contains("managed-instance"));
342
383
  let _ = fs::remove_file(path);
343
384
  }
385
+
386
+ // ------------------------------------------------------------------
387
+ // Worker identity.
388
+ //
389
+ // The whole worker security boundary rests on one thing: which subject a
390
+ // credential authenticates as. That is decided here, by the stored
391
+ // credential alone — no request field, header or body participates — so
392
+ // these tests are the root of the boundary the workload lifecycle routes
393
+ // enforce further up.
394
+ // ------------------------------------------------------------------
395
+
396
+ fn key_store(name: &str) -> (PathBuf, KeyStore) {
397
+ let path = std::env::temp_dir().join(format!(
398
+ "feltdb-worker-identity-{name}-{}-{}.json",
399
+ std::process::id(),
400
+ rand::random::<u64>()
401
+ ));
402
+ let _ = fs::remove_file(&path);
403
+ let store = KeyStore::load(&path).unwrap();
404
+ (path, store)
405
+ }
406
+
407
+ #[test]
408
+ fn a_worker_credential_authenticates_as_that_one_worker() {
409
+ let (path, store) = key_store("worker");
410
+ let key = store
411
+ .create_for_worker(
412
+ "extractor".into(),
413
+ vec!["state:read".into()],
414
+ vec!["default".into()],
415
+ Some("worker-x".into()),
416
+ )
417
+ .unwrap();
418
+
419
+ let principal = store
420
+ .authenticate(&key.secret, "default")
421
+ .expect("authenticates");
422
+ assert_eq!(principal.subject_type, "worker");
423
+ assert_eq!(
424
+ principal.identity_id.as_deref(),
425
+ Some("worker-x"),
426
+ "the worker it is, not the key it uses, is the identity"
427
+ );
428
+ let _ = fs::remove_file(path);
429
+ }
430
+
431
+ #[test]
432
+ fn a_service_credential_cannot_present_as_a_worker() {
433
+ let (path, store) = key_store("service");
434
+ let key = store
435
+ .create(
436
+ "control-plane".into(),
437
+ vec!["state:read".into()],
438
+ vec!["default".into()],
439
+ )
440
+ .unwrap();
441
+
442
+ let principal = store
443
+ .authenticate(&key.secret, "default")
444
+ .expect("authenticates");
445
+ assert_eq!(
446
+ principal.subject_type, "service",
447
+ "a key issued without a worker is a service, exactly as before this field existed"
448
+ );
449
+ assert_eq!(principal.identity_id, None);
450
+ let _ = fs::remove_file(path);
451
+ }
452
+
453
+ #[test]
454
+ fn worker_identity_survives_a_restart_of_the_credential_store() {
455
+ let (path, store) = key_store("reload");
456
+ let key = store
457
+ .create_for_worker(
458
+ "extractor".into(),
459
+ vec!["state:read".into()],
460
+ vec!["default".into()],
461
+ Some("worker-x".into()),
462
+ )
463
+ .unwrap();
464
+ drop(store);
465
+
466
+ // A credential that forgot which worker it was on restart would silently
467
+ // downgrade to a service and start failing the identity check.
468
+ let reopened = KeyStore::load(&path).unwrap();
469
+ let principal = reopened
470
+ .authenticate(&key.secret, "default")
471
+ .expect("authenticates");
472
+ assert_eq!(principal.subject_type, "worker");
473
+ assert_eq!(principal.identity_id.as_deref(), Some("worker-x"));
474
+ let _ = fs::remove_file(path);
475
+ }
476
+
477
+ #[test]
478
+ fn a_revoked_worker_credential_authenticates_as_nothing() {
479
+ let (path, store) = key_store("revoked");
480
+ let key = store
481
+ .create_for_worker(
482
+ "extractor".into(),
483
+ vec!["state:read".into()],
484
+ vec!["default".into()],
485
+ Some("worker-x".into()),
486
+ )
487
+ .unwrap();
488
+ assert!(store.revoke(&key.id).unwrap());
489
+ assert!(
490
+ store.authenticate(&key.secret, "default").is_none(),
491
+ "revocation is not weakened by carrying a worker identity"
492
+ );
493
+ let _ = fs::remove_file(path);
494
+ }
344
495
  }
@@ -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,
@@ -8289,6 +8290,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8289
8290
  if manage_workloads()? {
8290
8291
  return Ok(());
8291
8292
  }
8293
+ if manage_state_triggers()? {
8294
+ return Ok(());
8295
+ }
8292
8296
  if manage_backup()? {
8293
8297
  return Ok(());
8294
8298
  }
@@ -8336,6 +8340,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8336
8340
  let grant_store = GrantStore::load(config.data.with_extension("grants.json"))?;
8337
8341
  let sync_store = SyncStore::load(config.data.with_extension("sync.json"))?;
8338
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"))?;
8339
8344
  let mesh_store = WorkerMeshStore::load(config.data.with_extension("workers.json"))?;
8340
8345
  let content_store = ContentStore::new(config.data.with_extension("content"))?;
8341
8346
  let artifact_store = ArtifactStore::load(
@@ -8403,6 +8408,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8403
8408
  grants: Arc::new(std::sync::Mutex::new(grant_store)),
8404
8409
  sync: Arc::new(std::sync::Mutex::new(sync_store)),
8405
8410
  workloads: Arc::new(std::sync::Mutex::new(workload_store)),
8411
+ state_triggers: Arc::new(std::sync::Mutex::new(state_trigger_store)),
8406
8412
  mesh: Arc::new(std::sync::Mutex::new(mesh_store)),
8407
8413
  readiness_probe: Arc::new(readiness_probe),
8408
8414
  bounded_query_cursors: Arc::new(std::sync::Mutex::new(HashMap::new())),
@@ -9566,10 +9572,15 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
9566
9572
  .map(str::to_string)
9567
9573
  .collect()
9568
9574
  };
9569
- 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(
9570
9580
  name,
9571
9581
  values("--scope", "state:read,state:write,events:read"),
9572
9582
  values("--namespace", "default"),
9583
+ option("--worker"),
9573
9584
  )?;
9574
9585
  println!(
9575
9586
  "API key created (id: {}). This secret will not be shown again:\n{}",
@@ -9584,7 +9595,7 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
9584
9595
  }
9585
9596
  println!("API key revoked: {id}");
9586
9597
  }
9587
- _ => 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()),
9588
9599
  }
9589
9600
  Ok(true)
9590
9601
  }
@@ -9682,6 +9693,61 @@ fn manage_workloads() -> Result<bool, Box<dyn std::error::Error>> {
9682
9693
  Ok(true)
9683
9694
  }
9684
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
+
9685
9751
  fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
9686
9752
  let arguments: Vec<String> = std::env::args().collect();
9687
9753
  if arguments.get(1).map(String::as_str) != Some("backup") {
@@ -10103,6 +10169,197 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
10103
10169
  }
10104
10170
  execute_agent_run(state, &row.value, &worker).await?;
10105
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
+ }
10106
10363
  Ok(())
10107
10364
  }
10108
10365
 
@@ -13008,6 +13265,69 @@ async fn shutdown_signal() {
13008
13265
  tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
13009
13266
  }
13010
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
+
13011
13331
  #[cfg(test)]
13012
13332
  mod authority_gate_tests {
13013
13333
  use super::{api_error_body, authorize_transaction_collections, state_authorization};