@feltdb/core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/authority-scope.d.ts +2 -0
  2. package/dist/authority-scope.d.ts.map +1 -1
  3. package/dist/authority-scope.js +7 -2
  4. package/dist/cli/commands.js +19 -21
  5. package/dist/cli/development-session.js +16 -0
  6. package/dist/cli/studio-artifact.js +33 -0
  7. package/dist/create/package-versions.js +1 -1
  8. package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +8 -1
  9. package/dist/create/server-source/crates/feltdb/src/application.rs +16 -0
  10. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +21 -22
  11. package/dist/create/server-source/crates/feltdb/src/lib.rs +451 -33
  12. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +48 -3
  13. package/dist/create/server-source/crates/feltdb/src/state_model.rs +569 -4
  14. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +1 -0
  15. package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +708 -0
  16. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  17. package/dist/create/server-source/crates/feltdb/src/workload.rs +480 -44
  18. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +123 -0
  19. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +281 -0
  20. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +15 -3
  21. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +28 -18
  22. package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +328 -0
  23. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +20 -3
  24. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -4
  25. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +0 -4
  26. package/dist/create/server-source/crates/feltdb-server/src/canonical_principals.rs +1093 -0
  27. package/dist/create/server-source/crates/feltdb-server/src/causal.rs +905 -144
  28. package/dist/create/server-source/crates/feltdb-server/src/connections.rs +9 -6
  29. package/dist/create/server-source/crates/feltdb-server/src/control_plane.rs +90 -0
  30. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  31. package/dist/create/server-source/crates/feltdb-server/src/main.rs +443 -77
  32. package/dist/create/server-source/crates/feltdb-server/src/principals.rs +9 -501
  33. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +22 -0
  34. package/dist/create/server-source/crates/feltdb-server/tests/principal_tenant_migration.rs +247 -0
  35. package/dist/db.d.ts +14 -7
  36. package/dist/db.d.ts.map +1 -1
  37. package/dist/db.js +50 -26
  38. package/dist/deployment.d.ts +2 -0
  39. package/dist/deployment.d.ts.map +1 -1
  40. package/dist/deployment.js +4 -2
  41. package/dist/embedded-transaction.d.ts.map +1 -1
  42. package/dist/embedded-transaction.js +4 -0
  43. package/dist/error-codes.d.ts +12 -0
  44. package/dist/error-codes.d.ts.map +1 -1
  45. package/dist/error-codes.js +20 -2
  46. package/dist/feltdb.d.ts +4 -0
  47. package/dist/feltdb.d.ts.map +1 -1
  48. package/dist/file-db.d.ts +10 -0
  49. package/dist/file-db.d.ts.map +1 -1
  50. package/dist/file-db.js +18 -0
  51. package/dist/http-db.d.ts +7 -0
  52. package/dist/http-db.d.ts.map +1 -1
  53. package/dist/http-db.js +83 -15
  54. package/dist/index-core.d.ts +1 -0
  55. package/dist/index-core.d.ts.map +1 -1
  56. package/dist/index-core.js +1 -0
  57. package/dist/operation-admission.d.ts +2 -0
  58. package/dist/operation-admission.d.ts.map +1 -1
  59. package/dist/public-contract.d.ts +112 -0
  60. package/dist/public-contract.d.ts.map +1 -0
  61. package/dist/public-contract.js +125 -0
  62. package/dist/studio/app.d.ts +23 -0
  63. package/dist/studio/app.d.ts.map +1 -1
  64. package/dist/studio/components/ContractInspector.d.ts +5 -0
  65. package/dist/studio/components/ContractInspector.d.ts.map +1 -1
  66. package/dist/studio/components/SettingsPanel.d.ts +4 -1
  67. package/dist/studio/components/SettingsPanel.d.ts.map +1 -1
  68. package/dist/studio/components/index.js +4 -4
  69. package/dist/studio/{components-CehkcX_k.js → components-CJFgdKG6.js} +322 -303
  70. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  71. package/dist/studio/control-plane/Instance.d.ts +10 -0
  72. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  73. package/dist/studio/control-plane/Operations.d.ts +3 -0
  74. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  75. package/dist/studio/control-plane/State.d.ts.map +1 -1
  76. package/dist/studio/control-plane/index.d.ts +4 -2
  77. package/dist/studio/control-plane/index.d.ts.map +1 -1
  78. package/dist/studio/control-plane/index.js +2 -2
  79. package/dist/studio/control-plane-DP8ljTDO.js +3207 -0
  80. package/dist/studio/index.js +96 -75
  81. package/dist/studio/utils/api.d.ts +1 -1
  82. package/dist/studio/utils/api.d.ts.map +1 -1
  83. package/dist/studio/utils/control-plane-api.d.ts +12 -1
  84. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  85. package/dist/studio/utils/control-plane-api.js +1 -1
  86. package/dist/studio/utils/index.js +16 -16
  87. package/dist/studio/utils/service-api.d.ts +8 -0
  88. package/dist/studio/utils/service-api.d.ts.map +1 -1
  89. package/dist/studio/utils/service-api.js +15 -12
  90. package/dist/studio-app/assets/{feltdb_wasm-DksXDnLa.js → feltdb_wasm-CfNb3IoF.js} +1 -1
  91. package/dist/studio-app/assets/feltdb_wasm_bg-D9d3gY-E.wasm +0 -0
  92. package/dist/studio-app/assets/index-SDXdROi1.js +30 -0
  93. package/dist/studio-app/feltdb-studio.json +6 -0
  94. package/dist/studio-app/index.html +1 -1
  95. package/dist/transaction.d.ts +9 -1
  96. package/dist/transaction.d.ts.map +1 -1
  97. package/dist/transaction.js +6 -2
  98. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  99. package/dist/workspace/local-development-authority.d.ts +8 -0
  100. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  101. package/dist/workspace/local-development-authority.js +136 -1
  102. package/package.json +2 -2
  103. package/dist/studio/control-plane-D6TT2fCQ.js +0 -3064
  104. package/dist/studio-app/assets/feltdb_wasm_bg-Yy4QRHJT.wasm +0 -0
  105. package/dist/studio-app/assets/index-CjELc-ul.js +0 -30
@@ -1,12 +1,16 @@
1
1
  //! Durable workload authority. Workers execute; this state machine decides.
2
- use crate::authorization::{SignedGrant, Subject};
2
+ use crate::{
3
+ authorization::{SignedGrant, Subject},
4
+ AtomicMutation, FeltDb, FlowError, RecordPrecondition,
5
+ };
6
+ use rand::{distributions::Alphanumeric, Rng};
3
7
  use serde::{Deserialize, Serialize};
4
8
  use serde_json::Value;
5
9
  use sha2::{Digest, Sha256};
6
10
  use std::collections::{BTreeMap, BTreeSet};
7
- use std::fs;
8
- use std::path::{Path, PathBuf};
11
+ use std::path::Path;
9
12
  pub const WORKLOAD_CONTRACT_VERSION: u32 = 1;
13
+ pub const DURABLE_WORK_COLLECTION: &str = "system_durable_work";
10
14
 
11
15
  #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
12
16
  #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
@@ -174,6 +178,8 @@ pub struct Workload {
174
178
  pub input: Value,
175
179
  pub input_hash: String,
176
180
  pub requested_by: Subject,
181
+ #[serde(default)]
182
+ pub session_id: Option<String>,
177
183
  pub authorization_snapshot: SignedGrant,
178
184
  pub capability_snapshot: BTreeSet<String>,
179
185
  pub connection_scopes: BTreeSet<String>,
@@ -228,6 +234,8 @@ pub struct CreateWorkload {
228
234
  pub trigger: String,
229
235
  pub input: Value,
230
236
  pub requested_by: Subject,
237
+ #[serde(default)]
238
+ pub session_id: Option<String>,
231
239
  pub authorization_snapshot: SignedGrant,
232
240
  pub capability_snapshot: BTreeSet<String>,
233
241
  #[serde(default)]
@@ -277,12 +285,21 @@ impl std::fmt::Display for WorkloadError {
277
285
  }
278
286
  impl std::error::Error for WorkloadError {}
279
287
 
280
- #[derive(Debug, Clone, Serialize, Deserialize)]
288
+ #[derive(Clone)]
281
289
  pub struct WorkloadStore {
282
- #[serde(skip)]
283
- path: PathBuf,
284
- #[serde(default)]
290
+ db: FeltDb,
285
291
  pub workloads: BTreeMap<String, Workload>,
292
+ idempotency: BTreeMap<String, String>,
293
+ result_ids: BTreeMap<String, String>,
294
+ /// Rebuildable cache of FeltDB-owned document versions. This is never
295
+ /// serialized and never advances independently of a canonical commit.
296
+ document_versions: BTreeMap<String, u64>,
297
+ }
298
+
299
+ #[derive(Serialize, Deserialize)]
300
+ struct LegacyWorkloadStore {
301
+ #[serde(default)]
302
+ workloads: BTreeMap<String, Workload>,
286
303
  #[serde(default)]
287
304
  idempotency: BTreeMap<String, String>,
288
305
  #[serde(default)]
@@ -290,29 +307,151 @@ pub struct WorkloadStore {
290
307
  #[serde(default)]
291
308
  event_sequence: u64,
292
309
  }
310
+
293
311
  impl WorkloadStore {
294
312
  pub fn load(path: impl AsRef<Path>) -> Result<Self, WorkloadError> {
295
- let path = path.as_ref().to_path_buf();
296
- if !path.exists() {
297
- return Ok(Self {
298
- path,
299
- workloads: BTreeMap::new(),
300
- idempotency: BTreeMap::new(),
301
- result_ids: BTreeMap::new(),
302
- event_sequence: 0,
303
- });
313
+ Self::from_db(FeltDb::open(path).map_err(io)?)
314
+ }
315
+
316
+ pub fn from_db(db: FeltDb) -> Result<Self, WorkloadError> {
317
+ let mut workloads = BTreeMap::new();
318
+ let mut document_versions = BTreeMap::new();
319
+ for row in db.list_collection(DURABLE_WORK_COLLECTION).map_err(io)? {
320
+ let version = row
321
+ .value
322
+ .get("__version")
323
+ .and_then(Value::as_u64)
324
+ // Canonical FeltDB CAS defines a pre-versioned document as
325
+ // version 1. The next guarded mutation writes version 2, which
326
+ // upgrades rows created before Work adopted RecordPrecondition.
327
+ .unwrap_or(1);
328
+ let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
329
+ document_versions.insert(workload.workload_id.clone(), version);
330
+ workloads.insert(workload.workload_id.clone(), workload);
331
+ }
332
+ let mut idempotency = BTreeMap::new();
333
+ let mut result_ids = BTreeMap::new();
334
+ for workload in workloads.values() {
335
+ idempotency.insert(
336
+ format!(
337
+ "{}:{}:{}",
338
+ workload.tenant_id, workload.application_id, workload.idempotency_key
339
+ ),
340
+ workload.workload_id.clone(),
341
+ );
342
+ if let Some(result) = &workload.result {
343
+ result_ids.insert(result.result_id.clone(), workload.workload_id.clone());
344
+ }
304
345
  }
305
- let mut value: Self = serde_json::from_slice(&fs::read(&path).map_err(io)?).map_err(io)?;
306
- value.path = path;
307
- Ok(value)
346
+ Ok(Self {
347
+ db,
348
+ workloads,
349
+ idempotency,
350
+ result_ids,
351
+ document_versions,
352
+ })
308
353
  }
309
- fn persist(&self) -> Result<(), WorkloadError> {
310
- if let Some(parent) = self.path.parent() {
311
- fs::create_dir_all(parent).map_err(io)?;
354
+
355
+ pub fn migrate_legacy_file(db: FeltDb, path: &Path) -> Result<Self, WorkloadError> {
356
+ let bytes = std::fs::read(path).map_err(io)?;
357
+ let legacy: LegacyWorkloadStore = serde_json::from_slice(&bytes).map_err(io)?;
358
+ let mut store = Self::from_db(db)?;
359
+ for (id, workload) in legacy.workloads {
360
+ if id != workload.workload_id {
361
+ return Err(WorkloadError::new(
362
+ "WORKLOAD_MIGRATION_CONFLICT",
363
+ format!(
364
+ "legacy key {id} does not match workload identity {}",
365
+ workload.workload_id
366
+ ),
367
+ ));
368
+ }
369
+ if let Some(existing) = store.workloads.get(&id) {
370
+ if serde_json::to_value(existing).map_err(io)?
371
+ != serde_json::to_value(&workload).map_err(io)?
372
+ {
373
+ return Err(WorkloadError::new(
374
+ "WORKLOAD_MIGRATION_CONFLICT",
375
+ format!("canonical workload {id} differs from legacy source"),
376
+ ));
377
+ }
378
+ continue;
379
+ }
380
+ store.workloads.insert(id.clone(), workload);
381
+ store.persist_workload(&id, "workload_migration")?;
312
382
  }
313
- let temp = self.path.with_extension("tmp");
314
- fs::write(&temp, serde_json::to_vec_pretty(self).map_err(io)?).map_err(io)?;
315
- fs::rename(temp, &self.path).map_err(io)
383
+ // The maps are verification evidence, not migrated authorities.
384
+ let rebuilt = Self::from_db(store.db.clone())?;
385
+ if legacy.idempotency != rebuilt.idempotency
386
+ || legacy.result_ids != rebuilt.result_ids
387
+ || legacy.event_sequence != derived_event_sequence(&rebuilt.workloads)
388
+ {
389
+ return Err(WorkloadError::new(
390
+ "WORKLOAD_MIGRATION_CONFLICT",
391
+ "legacy derived indexes do not match canonical work aggregates",
392
+ ));
393
+ }
394
+ Ok(rebuilt)
395
+ }
396
+
397
+ fn persist_workload(&mut self, id: &str, action: &str) -> Result<(), WorkloadError> {
398
+ let workload = self
399
+ .workloads
400
+ .get(id)
401
+ .ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))?;
402
+ let key = work_key(workload);
403
+ let expected_version = self.document_versions.get(id).copied();
404
+ let transaction_id = runtime_id("work_tx");
405
+ let aggregate_digest = hash(&serde_json::to_value(workload).map_err(io)?);
406
+ let commit = self
407
+ .db
408
+ .apply_atomic_transaction_guarded(
409
+ &transaction_id,
410
+ None,
411
+ None,
412
+ &[],
413
+ &[RecordPrecondition {
414
+ capability: DURABLE_WORK_COLLECTION.into(),
415
+ key: key.clone(),
416
+ require_absent: expected_version.is_none(),
417
+ expected_version,
418
+ expected_epoch: None,
419
+ expected_lease_id: None,
420
+ }],
421
+ &[AtomicMutation {
422
+ capability: DURABLE_WORK_COLLECTION.into(),
423
+ key,
424
+ value: Some(serde_json::to_value(workload).map_err(io)?),
425
+ }],
426
+ Some(serde_json::json!({
427
+ "kind": action,
428
+ "subject": workload.requested_by.id(),
429
+ "principal_id": workload.requested_by.id(),
430
+ "agent_id": (workload.requested_by.kind() == "agent").then(|| workload.requested_by.id()),
431
+ "session_id": workload.session_id,
432
+ "delegator_id": workload.authorization_snapshot.grant.issuer.id(),
433
+ "authorization_decision_id": workload.authorization_snapshot.grant.id,
434
+ "tenant": workload.tenant_id,
435
+ "application": workload.application_id,
436
+ "work_id": workload.workload_id,
437
+ "operation_id": transaction_id,
438
+ "attempt_id": workload.attempts.last().map(|attempt| attempt.attempt_id.as_str()),
439
+ "worker_id": workload.current_claim.as_ref().map(|claim| claim.worker_id.as_str()),
440
+ "result_id": workload.result.as_ref().map(|result| result.result_id.as_str()),
441
+ "source_kind": (action == "workload_migration").then_some("WorkloadStore"),
442
+ "source_digest": aggregate_digest,
443
+ "migration_outcome": (action == "workload_migration").then_some("imported"),
444
+ })),
445
+ )
446
+ .map_err(workload_storage_error)?;
447
+ let version = commit
448
+ .rows
449
+ .first()
450
+ .and_then(|row| row.value.get("__version"))
451
+ .and_then(Value::as_u64)
452
+ .ok_or_else(|| io("canonical work commit has no document version"))?;
453
+ self.document_versions.insert(id.into(), version);
454
+ Ok(())
316
455
  }
317
456
  pub fn create(&mut self, input: CreateWorkload) -> Result<Workload, WorkloadError> {
318
457
  ensure_safe(&input.input)?;
@@ -338,12 +477,11 @@ impl WorkloadStore {
338
477
  "authorization snapshot does not bind the requester",
339
478
  ));
340
479
  }
341
- let seed = format!(
342
- "{idempotency}:{}:{}",
343
- input.definition_revision,
344
- hash(&input.input)
345
- );
346
- let id = format!("workload_{}", &hex(&Sha256::digest(seed))[..24]);
480
+ // The scoped idempotency identity is the record identity. Competing
481
+ // admissions therefore contend on one canonical require-absent CAS
482
+ // even when their payloads differ; no separate durable uniqueness
483
+ // index is needed.
484
+ let id = format!("workload_{}", &hex(&Sha256::digest(&idempotency))[..24]);
347
485
  let input_hash = hash(&input.input);
348
486
  let mut workload = Workload {
349
487
  contract_version: 1,
@@ -357,6 +495,7 @@ impl WorkloadStore {
357
495
  input: input.input,
358
496
  input_hash,
359
497
  requested_by: input.requested_by,
498
+ session_id: input.session_id,
360
499
  authorization_snapshot: input.authorization_snapshot,
361
500
  capability_snapshot: input.capability_snapshot,
362
501
  connection_scopes: input.connection_scopes,
@@ -396,9 +535,13 @@ impl WorkloadStore {
396
535
  input.created_at,
397
536
  evidence,
398
537
  )?;
399
- self.idempotency.insert(idempotency, id.clone());
400
- self.workloads.insert(id, workload.clone());
401
- self.persist()?;
538
+ self.idempotency.insert(idempotency.clone(), id.clone());
539
+ self.workloads.insert(id.clone(), workload.clone());
540
+ if let Err(error) = self.persist_workload(&id, "workload.created") {
541
+ self.workloads.remove(&id);
542
+ self.idempotency.remove(&idempotency);
543
+ return Err(error);
544
+ }
402
545
  Ok(workload)
403
546
  }
404
547
  pub fn get(&self, id: &str) -> Result<&Workload, WorkloadError> {
@@ -421,16 +564,19 @@ impl WorkloadStore {
421
564
  id: &str,
422
565
  action: impl FnOnce(&mut Self, &mut Workload) -> Result<R, WorkloadError>,
423
566
  ) -> Result<R, WorkloadError> {
424
- let mut workload = self
567
+ let workload = self
425
568
  .workloads
426
- .remove(id)
569
+ .get(id)
570
+ .cloned()
427
571
  .ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))?;
428
- let result = action(self, &mut workload);
429
- self.workloads.insert(id.into(), workload);
430
- if result.is_ok() {
431
- self.persist()?;
572
+ let mut edited = workload.clone();
573
+ let result = action(self, &mut edited)?;
574
+ self.workloads.insert(id.into(), edited);
575
+ if let Err(error) = self.persist_workload(id, "workload.mutated") {
576
+ self.workloads.insert(id.into(), workload);
577
+ return Err(error);
432
578
  }
433
- result
579
+ Ok(result)
434
580
  }
435
581
  fn transition(
436
582
  &mut self,
@@ -449,9 +595,12 @@ impl WorkloadStore {
449
595
  let from = w.state;
450
596
  w.state = to;
451
597
  w.updated_at = at;
452
- self.event_sequence += 1;
598
+ let sequence = w
599
+ .execution_history
600
+ .last()
601
+ .map_or(1, |event| event.sequence + 1);
453
602
  w.execution_history.push(WorkloadEvent {
454
- sequence: self.event_sequence,
603
+ sequence,
455
604
  event_type: event_name(to).into(),
456
605
  from: Some(from),
457
606
  to,
@@ -730,7 +879,6 @@ impl WorkloadStore {
730
879
  Ok(result)
731
880
  })?;
732
881
  self.result_ids.insert(result_id.into(), id.into());
733
- self.persist()?;
734
882
  Ok(result)
735
883
  }
736
884
  pub fn fail(
@@ -1111,9 +1259,42 @@ fn ensure_safe(value: &Value) -> Result<(), WorkloadError> {
1111
1259
  fn sensitive_text(v: &str) -> bool {
1112
1260
  v.contains("BEGIN PRIVATE KEY") || v.starts_with("sk-") || v.starts_with("fdb_live_")
1113
1261
  }
1262
+ fn work_key(workload: &Workload) -> String {
1263
+ format!(
1264
+ "{}/{}/{}",
1265
+ workload.tenant_id, workload.application_id, workload.workload_id
1266
+ )
1267
+ }
1268
+ fn derived_event_sequence(workloads: &BTreeMap<String, Workload>) -> u64 {
1269
+ workloads
1270
+ .values()
1271
+ .flat_map(|workload| workload.execution_history.iter())
1272
+ .map(|event| event.sequence)
1273
+ .max()
1274
+ .unwrap_or_default()
1275
+ }
1276
+ fn runtime_id(prefix: &str) -> String {
1277
+ let value: String = rand::thread_rng()
1278
+ .sample_iter(&Alphanumeric)
1279
+ .take(24)
1280
+ .map(char::from)
1281
+ .collect();
1282
+ format!("{prefix}_{value}")
1283
+ }
1114
1284
  fn io(e: impl std::fmt::Display) -> WorkloadError {
1115
1285
  WorkloadError::new("WORKLOAD_STORAGE_FAILURE", e.to_string())
1116
1286
  }
1287
+ fn workload_storage_error(error: FlowError) -> WorkloadError {
1288
+ match error {
1289
+ FlowError::PreconditionFailed(_) | FlowError::RevisionConflict { .. } => {
1290
+ WorkloadError::new("WORKLOAD_CONFLICT", error.to_string())
1291
+ }
1292
+ FlowError::CapabilityError(ref message) if message.starts_with("PRECONDITION_FAILED:") => {
1293
+ WorkloadError::new("WORKLOAD_CONFLICT", error.to_string())
1294
+ }
1295
+ _ => io(error),
1296
+ }
1297
+ }
1117
1298
  fn hash(value: &Value) -> String {
1118
1299
  hex(&Sha256::digest(
1119
1300
  serde_json::to_vec(value).unwrap_or_default(),
@@ -1129,6 +1310,7 @@ mod tests {
1129
1310
  use crate::authorization::{
1130
1311
  Grant, GrantConstraints, GrantSigner, GrantStatus, ResourceScope, ResourceScopeKind,
1131
1312
  };
1313
+ use std::{fs, path::PathBuf};
1132
1314
  fn create() -> CreateWorkload {
1133
1315
  let signer = GrantSigner::new("k", vec![1; 32]).unwrap();
1134
1316
  let subject = Subject::Agent("a1".into());
@@ -1160,6 +1342,7 @@ mod tests {
1160
1342
  trigger: "manual".into(),
1161
1343
  input: serde_json::json!({"id":1}),
1162
1344
  requested_by: subject,
1345
+ session_id: Some("session-1".into()),
1163
1346
  authorization_snapshot: signer.sign(grant).unwrap(),
1164
1347
  capability_snapshot: ["email:send".into()].into(),
1165
1348
  connection_scopes: BTreeSet::new(),
@@ -1179,6 +1362,259 @@ mod tests {
1179
1362
  (path, store)
1180
1363
  }
1181
1364
  #[test]
1365
+ fn legacy_migration_is_idempotent_conflict_detecting_and_source_independent() {
1366
+ let (source_db, mut source) = store("legacy-source-builder");
1367
+ let workload = source.create(create()).unwrap();
1368
+ let legacy = LegacyWorkloadStore {
1369
+ workloads: source.workloads.clone(),
1370
+ idempotency: source.idempotency.clone(),
1371
+ result_ids: source.result_ids.clone(),
1372
+ event_sequence: derived_event_sequence(&source.workloads),
1373
+ };
1374
+ let legacy_path = source_db.with_extension("legacy-workloads.json");
1375
+ fs::write(&legacy_path, serde_json::to_vec(&legacy).unwrap()).unwrap();
1376
+ let canonical_path = source_db.with_extension("canonical.log");
1377
+ let db = FeltDb::open(&canonical_path).unwrap();
1378
+ let migrated = WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
1379
+ assert_eq!(
1380
+ migrated.get(&workload.workload_id).unwrap().input_hash,
1381
+ workload.input_hash
1382
+ );
1383
+ WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
1384
+ fs::remove_file(&legacy_path).unwrap();
1385
+ let reopened = WorkloadStore::from_db(FeltDb::open(&canonical_path).unwrap()).unwrap();
1386
+ assert_eq!(
1387
+ reopened.get(&workload.workload_id).unwrap().workload_id,
1388
+ workload.workload_id
1389
+ );
1390
+
1391
+ let mut conflicting = legacy;
1392
+ conflicting
1393
+ .workloads
1394
+ .get_mut(&workload.workload_id)
1395
+ .unwrap()
1396
+ .priority += 1;
1397
+ fs::write(&legacy_path, serde_json::to_vec(&conflicting).unwrap()).unwrap();
1398
+ assert_eq!(
1399
+ WorkloadStore::migrate_legacy_file(db, &legacy_path)
1400
+ .err()
1401
+ .unwrap()
1402
+ .code,
1403
+ "WORKLOAD_MIGRATION_CONFLICT"
1404
+ );
1405
+ let _ = fs::remove_file(source_db);
1406
+ let _ = fs::remove_file(canonical_path);
1407
+ let _ = fs::remove_file(legacy_path);
1408
+ }
1409
+
1410
+ #[test]
1411
+ fn canonical_history_reconstructs_work_identity_provenance() {
1412
+ let (path, mut store) = store("provenance");
1413
+ let workload = store.create(create()).unwrap();
1414
+ let transaction = store
1415
+ .db
1416
+ .committed_transactions_after(0, usize::MAX)
1417
+ .unwrap()
1418
+ .into_iter()
1419
+ .last()
1420
+ .unwrap();
1421
+ let audit = transaction.audit.unwrap();
1422
+ assert_eq!(audit["work_id"], workload.workload_id);
1423
+ assert_eq!(audit["session_id"], workload.session_id.as_deref().unwrap());
1424
+ assert_eq!(audit["agent_id"], workload.requested_by.id());
1425
+ assert_eq!(
1426
+ audit["delegator_id"],
1427
+ workload.authorization_snapshot.grant.issuer.id()
1428
+ );
1429
+ assert!(transaction.commit_revision > transaction.base_revision);
1430
+ assert_eq!(transaction.work_id.as_deref(), Some(workload.workload_id.as_str()));
1431
+ assert_eq!(transaction.agent_id.as_deref(), Some(workload.requested_by.id()));
1432
+ assert_eq!(transaction.session_id, workload.session_id);
1433
+ assert_eq!(
1434
+ transaction.delegator_id.as_deref(),
1435
+ Some(workload.authorization_snapshot.grant.issuer.id())
1436
+ );
1437
+ assert_eq!(transaction.operation_ids.len(), 1);
1438
+ assert_ne!(transaction.operation_ids[0], transaction.transaction_id);
1439
+ drop(store);
1440
+ let reopened = WorkloadStore::load(&path).unwrap();
1441
+ assert_eq!(
1442
+ reopened.get(&workload.workload_id).unwrap().workload_id,
1443
+ workload.workload_id
1444
+ );
1445
+ let _ = fs::remove_file(path);
1446
+ }
1447
+
1448
+ #[test]
1449
+ fn competing_writers_use_canonical_document_cas_before_and_after_restart() {
1450
+ let (path, mut seed) = store("canonical-cas");
1451
+ let workload = seed.create(create()).unwrap();
1452
+ let id = workload.workload_id.clone();
1453
+ let db = seed.db.clone();
1454
+ let caps = ["email:send".into()].into();
1455
+
1456
+ let mut winner = WorkloadStore::from_db(db.clone()).unwrap();
1457
+ let mut loser = WorkloadStore::from_db(db.clone()).unwrap();
1458
+ winner.claim(&id, "worker-a", &caps, 11, 1_000).unwrap();
1459
+ let conflict = loser.claim(&id, "worker-b", &caps, 11, 1_000).unwrap_err();
1460
+ assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
1461
+ assert_eq!(loser.get(&id).unwrap().state, WorkloadState::Ready);
1462
+
1463
+ let row = db
1464
+ .list_collection(DURABLE_WORK_COLLECTION)
1465
+ .unwrap()
1466
+ .into_iter()
1467
+ .find(|row| row.key.ends_with(&id))
1468
+ .unwrap();
1469
+ assert_eq!(row.value["__version"], 2);
1470
+
1471
+ drop((seed, winner, loser, db));
1472
+ let restarted_db = FeltDb::open(&path).unwrap();
1473
+ let mut recovery = WorkloadStore::from_db(restarted_db.clone()).unwrap();
1474
+ recovery.recover_expired(13).unwrap();
1475
+ let mut restarted_winner = WorkloadStore::from_db(restarted_db.clone()).unwrap();
1476
+ let mut restarted_loser = WorkloadStore::from_db(restarted_db.clone()).unwrap();
1477
+ restarted_winner
1478
+ .claim(&id, "worker-c", &caps, 13, 10_000)
1479
+ .unwrap();
1480
+ let conflict = restarted_loser
1481
+ .claim(&id, "worker-d", &caps, 13, 10_000)
1482
+ .unwrap_err();
1483
+ assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
1484
+ assert_eq!(
1485
+ restarted_loser.get(&id).unwrap().state,
1486
+ WorkloadState::Ready
1487
+ );
1488
+ drop((recovery, restarted_winner, restarted_loser, restarted_db));
1489
+ let _ = fs::remove_file(path);
1490
+ }
1491
+
1492
+ #[test]
1493
+ fn competing_admissions_share_one_canonical_idempotency_identity() {
1494
+ let path = std::env::temp_dir().join(format!(
1495
+ "workload-idempotency-cas-{}.log",
1496
+ std::process::id()
1497
+ ));
1498
+ let db = FeltDb::open(&path).unwrap();
1499
+ let mut first = WorkloadStore::from_db(db.clone()).unwrap();
1500
+ let mut second = WorkloadStore::from_db(db.clone()).unwrap();
1501
+ let accepted = first.create(create()).unwrap();
1502
+ let mut competing = create();
1503
+ competing.input = serde_json::json!({"id": 2});
1504
+ let conflict = second.create(competing).unwrap_err();
1505
+ assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
1506
+ assert!(
1507
+ second.workloads.is_empty(),
1508
+ "failed projection was rolled back"
1509
+ );
1510
+
1511
+ drop((first, second, db));
1512
+ let restarted = WorkloadStore::from_db(FeltDb::open(&path).unwrap()).unwrap();
1513
+ assert_eq!(restarted.workloads.len(), 1);
1514
+ assert_eq!(restarted.idempotency["t:app:one"], accepted.workload_id);
1515
+ let _ = fs::remove_file(path);
1516
+ }
1517
+
1518
+ #[test]
1519
+ fn pre_versioned_canonical_rows_upgrade_through_the_standard_cas_contract() {
1520
+ let (path, mut store) = store("pre-versioned-upgrade");
1521
+ let workload = store.create(create()).unwrap();
1522
+ let db = store.db.clone();
1523
+ db.apply_atomic_transaction(
1524
+ "fixture:pre-versioned-work",
1525
+ None,
1526
+ &[],
1527
+ &[AtomicMutation {
1528
+ capability: DURABLE_WORK_COLLECTION.into(),
1529
+ key: work_key(&workload),
1530
+ value: Some(serde_json::to_value(&workload).unwrap()),
1531
+ }],
1532
+ None,
1533
+ )
1534
+ .unwrap();
1535
+
1536
+ let mut upgraded = WorkloadStore::from_db(db.clone()).unwrap();
1537
+ upgraded
1538
+ .claim(
1539
+ &workload.workload_id,
1540
+ "worker",
1541
+ &["email:send".into()].into(),
1542
+ 11,
1543
+ 10_000,
1544
+ )
1545
+ .unwrap();
1546
+ let row = db
1547
+ .list_collection(DURABLE_WORK_COLLECTION)
1548
+ .unwrap()
1549
+ .into_iter()
1550
+ .find(|row| row.key.ends_with(&workload.workload_id))
1551
+ .unwrap();
1552
+ assert_eq!(row.value["__version"], 2);
1553
+ let _ = fs::remove_file(path);
1554
+ }
1555
+
1556
+ #[test]
1557
+ fn derived_indexes_rebuild_exclusively_from_canonical_work() {
1558
+ let (path, mut store) = store("derived-index-rebuild");
1559
+ let workload = store.create(create()).unwrap();
1560
+ let caps = ["email:send".into()].into();
1561
+ let claim = store
1562
+ .claim(&workload.workload_id, "worker", &caps, 11, 10_000)
1563
+ .unwrap();
1564
+ store
1565
+ .start(
1566
+ &workload.workload_id,
1567
+ &claim.claim_id,
1568
+ "worker",
1569
+ claim.fencing_token,
1570
+ 12,
1571
+ )
1572
+ .unwrap();
1573
+ store
1574
+ .complete(
1575
+ &workload.workload_id,
1576
+ &claim.claim_id,
1577
+ "worker",
1578
+ claim.fencing_token,
1579
+ "result-rebuilt",
1580
+ serde_json::json!({"ok": true}),
1581
+ vec![],
1582
+ 1,
1583
+ 13,
1584
+ )
1585
+ .unwrap();
1586
+
1587
+ store.idempotency.clear();
1588
+ store.result_ids.clear();
1589
+ store.document_versions.clear();
1590
+ let rebuilt = WorkloadStore::from_db(store.db.clone()).unwrap();
1591
+ assert_eq!(
1592
+ rebuilt.idempotency.get("t:app:one"),
1593
+ Some(&workload.workload_id)
1594
+ );
1595
+ assert_eq!(
1596
+ rebuilt.result_ids.get("result-rebuilt"),
1597
+ Some(&workload.workload_id)
1598
+ );
1599
+ assert!(rebuilt.document_versions[&workload.workload_id] > 1);
1600
+ assert_eq!(
1601
+ rebuilt
1602
+ .get(&workload.workload_id)
1603
+ .unwrap()
1604
+ .execution_history
1605
+ .iter()
1606
+ .map(|event| event.sequence)
1607
+ .collect::<Vec<_>>(),
1608
+ vec![1, 2, 3, 4]
1609
+ );
1610
+ let duplicate = WorkloadStore::from_db(store.db.clone())
1611
+ .unwrap()
1612
+ .create(create())
1613
+ .unwrap();
1614
+ assert_eq!(duplicate.workload_id, workload.workload_id);
1615
+ let _ = fs::remove_file(path);
1616
+ }
1617
+ #[test]
1182
1618
  fn idempotent_create_and_legal_lifecycle() {
1183
1619
  let (path, mut store) = store("lifecycle");
1184
1620
  let one = store.create(create()).unwrap();