@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
@@ -130,6 +130,7 @@ pub mod transaction_preconditions;
130
130
  pub mod transactions;
131
131
  mod trigger;
132
132
  pub mod trigger_contract;
133
+ pub mod versioned_object_substrate;
133
134
  pub mod worker_mesh;
134
135
  mod workflow;
135
136
  #[cfg(test)]
@@ -238,9 +239,11 @@ pub use state_facade::FeltDBStateSystem;
238
239
  pub use state_hash::{CanonicalState, StateHash};
239
240
  pub use state_model::{
240
241
  apply_reconciliation_plan, path_relation, paths_overlap, reconcile, resolve_path, ChangeKind,
241
- ConflictClass, ConflictClassification, PathComponent, PathConflict, PathRelation,
242
- ReconciliationPlan, Relationship, SemanticChange, SemanticDiff, StateId,
243
- StateReconciliationResult, StateRevision, StateStore, StateTopology, STATE_MODEL_VERSION,
242
+ ConflictClass, ConflictClassification, GarbageCollectionError, GarbageCollectionReport,
243
+ MaterializationError, MaterializationProjection, PathComponent, PathConflict, PathRelation,
244
+ ReconciliationPlan, RefError, Relationship, SemanticChange, SemanticDiff, StateId, StateObject,
245
+ StateReconciliationResult, StateRef, StateRevision, StateStore, StateTopology,
246
+ STATE_MODEL_VERSION,
244
247
  };
245
248
  pub use state_transition_store::{MemoryStateTransitionStore, StateTransitionRecord};
246
249
  pub use state_trigger::{
@@ -262,6 +265,10 @@ pub use trigger_contract::{
262
265
  ContractRuntime, ContractTrigger, MatchOutcome, MatchReason, TriggerMatchInput,
263
266
  TriggerMatchOutput, TRIGGER_MATCH_CONTRACT_ID, TRIGGER_MATCH_CONTRACT_VERSION,
264
267
  };
268
+ pub use versioned_object_substrate::{
269
+ DurableObjectStore, DurableRef, LocalObjectStore, Reachability, S3CompatibleBucket,
270
+ S3CompatibleObjectStore, SubstrateError, VersionedObjectSubstrate,
271
+ };
265
272
  pub use workflow::{
266
273
  BlockedReason, WorkflowGraph, WorkflowInstance, WorkflowNode, WorkflowOperation, WorkflowRef,
267
274
  WorkflowState, WorkflowStepRef, WorkflowStepState,
@@ -403,6 +410,10 @@ struct Inner {
403
410
  /// audit archive, and the durable log remains the record of what happened.
404
411
  /// Keeping it bounded is what makes it safe to maintain on the commit path.
405
412
  transaction_attributions: VecDeque<TransactionAttribution>,
413
+ /// Complete committed transaction order, rebuilt exclusively from the
414
+ /// durable transaction log. Unlike the bounded operator window above this
415
+ /// is the canonical replay/provenance index.
416
+ transaction_history: Vec<TransactionAttribution>,
406
417
  collection_cardinality: HashMap<String, u64>,
407
418
  /// Derived equality index over `rows`. Never durable, never replicated, and
408
419
  /// never consulted for anything but candidate selection: it is maintained
@@ -505,6 +516,12 @@ impl Inner {
505
516
  let bucket = self.rows.entry(capability.clone()).or_default();
506
517
  let previous = bucket.insert(key.clone(), row);
507
518
  drop(mutation);
519
+ if previous.is_none() {
520
+ *self
521
+ .collection_cardinality
522
+ .entry(capability.clone())
523
+ .or_default() += 1;
524
+ }
508
525
  if !indexed {
509
526
  return previous.is_none();
510
527
  }
@@ -543,6 +560,11 @@ impl Inner {
543
560
  return false;
544
561
  };
545
562
  drop(mutation);
563
+ let count = self
564
+ .collection_cardinality
565
+ .entry(capability.to_string())
566
+ .or_default();
567
+ *count = count.saturating_sub(1);
546
568
  let _index = workload_diagnostics::span(workload_diagnostics::Phase::IndexMaintenance);
547
569
  self.equality_index
548
570
  .remove_record(capability, key, &removed.value);
@@ -552,6 +574,7 @@ impl Inner {
552
574
  /// Drop all records, as a snapshot install does. The index follows.
553
575
  fn clear_rows(&mut self) {
554
576
  self.rows.clear();
577
+ self.collection_cardinality.clear();
555
578
  self.equality_index.clear();
556
579
  }
557
580
  }
@@ -773,6 +796,10 @@ const TRANSACTION_ATTRIBUTION_KEYS: usize = 32;
773
796
  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774
797
  pub struct TransactionAttribution {
775
798
  pub transaction_id: String,
799
+ /// Stable operation identities assigned to the rows in this transaction.
800
+ /// These are distinct from both transaction and revision identity.
801
+ #[serde(default)]
802
+ pub operation_ids: Vec<String>,
776
803
  /// The actor the caller authenticated as, when the transaction named one.
777
804
  pub subject: Option<String>,
778
805
  pub tenant_id: Option<String>,
@@ -789,8 +816,23 @@ pub struct TransactionAttribution {
789
816
  /// How many mutations the transaction made, whether or not `keys` lists
790
817
  /// them all.
791
818
  pub operations: usize,
819
+ #[serde(default)]
820
+ pub principal_id: Option<String>,
821
+ #[serde(default)]
822
+ pub agent_id: Option<String>,
823
+ #[serde(default)]
824
+ pub session_id: Option<String>,
825
+ #[serde(default)]
826
+ pub delegator_id: Option<String>,
827
+ #[serde(default)]
828
+ pub work_id: Option<String>,
792
829
  /// Milliseconds since the epoch, matching `StoredRow::unix_ms`.
793
830
  pub unix_ms: u128,
831
+ /// Canonical transaction metadata exactly as committed. Record values are
832
+ /// deliberately absent; stable work/session/agent/operation references
833
+ /// live here so historical provenance survives process loss.
834
+ #[serde(default)]
835
+ pub audit: Option<Value>,
794
836
  }
795
837
 
796
838
  impl TransactionAttribution {
@@ -813,6 +855,13 @@ impl TransactionAttribution {
813
855
  };
814
856
  TransactionAttribution {
815
857
  transaction_id: transaction_id.to_string(),
858
+ operation_ids: rows
859
+ .iter()
860
+ .filter_map(|row| row.operation.as_ref())
861
+ .map(|operation| {
862
+ format!("operation:{}:{}", operation.instance_id, operation.sequence)
863
+ })
864
+ .collect(),
816
865
  subject: text("subject"),
817
866
  tenant_id: text("tenant"),
818
867
  application_id: text("application"),
@@ -827,7 +876,13 @@ impl TransactionAttribution {
827
876
  .map(|row| format!("{}:{}", row.capability, row.key))
828
877
  .collect(),
829
878
  operations: rows.len(),
879
+ principal_id: text("principal_id"),
880
+ agent_id: text("agent_id"),
881
+ session_id: text("session_id"),
882
+ delegator_id: text("delegator_id"),
883
+ work_id: text("work_id").or_else(|| text("workload_id")),
830
884
  unix_ms,
885
+ audit: audit.cloned(),
831
886
  }
832
887
  }
833
888
 
@@ -838,14 +893,84 @@ impl TransactionAttribution {
838
893
  }
839
894
  }
840
895
 
896
+ /// Upgrade compacted attribution written before operation identities became a
897
+ /// first-class part of canonical history. The mapping is deterministic from
898
+ /// durable transaction data, so reopening never regenerates a different ID.
899
+ fn normalize_transaction_attribution(
900
+ mut attribution: TransactionAttribution,
901
+ ) -> TransactionAttribution {
902
+ if attribution.operation_ids.is_empty() {
903
+ if attribution.transaction_id.starts_with("operation:") {
904
+ let operation_id = attribution.transaction_id.clone();
905
+ attribution.transaction_id = operation_id.replacen("operation:", "transaction:", 1);
906
+ attribution.operation_ids.push(operation_id);
907
+ } else {
908
+ attribution.operation_ids = (1..=attribution.operations)
909
+ .map(|index| {
910
+ format!(
911
+ "operation:legacy-transaction:{}:{index}",
912
+ attribution.transaction_id
913
+ )
914
+ })
915
+ .collect();
916
+ }
917
+ }
918
+ attribution
919
+ }
920
+
841
921
  /// Records an attribution, discarding the oldest once the window is full.
842
922
  fn remember_attribution(inner: &mut Inner, attribution: TransactionAttribution) {
923
+ inner.transaction_history.push(attribution.clone());
843
924
  if inner.transaction_attributions.len() >= TRANSACTION_ATTRIBUTION_WINDOW {
844
925
  inner.transaction_attributions.pop_front();
845
926
  }
846
927
  inner.transaction_attributions.push_back(attribution);
847
928
  }
848
929
 
930
+ fn remember_operation_attribution(
931
+ inner: &mut Inner,
932
+ row: &StoredRow,
933
+ base_revision: u64,
934
+ commit_revision: u64,
935
+ ) {
936
+ let Some(operation) = row.operation.as_ref() else {
937
+ return;
938
+ };
939
+ remember_attribution(
940
+ inner,
941
+ TransactionAttribution {
942
+ transaction_id: format!(
943
+ "transaction:{}:{}",
944
+ operation.instance_id, operation.sequence
945
+ ),
946
+ operation_ids: vec![format!(
947
+ "operation:{}:{}",
948
+ operation.instance_id, operation.sequence
949
+ )],
950
+ subject: None,
951
+ tenant_id: None,
952
+ application_id: None,
953
+ application_revision: operation
954
+ .revision
955
+ .as_ref()
956
+ .map(|revision| revision.id.clone()),
957
+ base_revision,
958
+ commit_revision,
959
+ state_before: operation.sequence.saturating_sub(1),
960
+ state_after: operation.sequence,
961
+ keys: vec![format!("{}:{}", row.capability, row.key)],
962
+ operations: 1,
963
+ principal_id: None,
964
+ agent_id: None,
965
+ session_id: None,
966
+ delegator_id: None,
967
+ work_id: None,
968
+ unix_ms: row.unix_ms,
969
+ audit: None,
970
+ },
971
+ );
972
+ }
973
+
849
974
  #[derive(Debug, Clone, Serialize, Deserialize)]
850
975
  struct TransactionLogRecord {
851
976
  record_type: String,
@@ -1551,6 +1676,15 @@ struct LogHeader {
1551
1676
  /// which deserialize as 0 and are classified as unversioned.
1552
1677
  #[serde(default)]
1553
1678
  format_version: u32,
1679
+ /// Canonical commit provenance retained when operation-log compaction
1680
+ /// rewrites current state. This is history, not a derived event store.
1681
+ #[serde(default)]
1682
+ transaction_history: Vec<TransactionAttribution>,
1683
+ /// Durable binding between a caller transaction identity and its semantic
1684
+ /// request. Compaction must retain this or an old id could be reused for a
1685
+ /// different mutation after restart.
1686
+ #[serde(default)]
1687
+ transaction_payload_hashes: HashMap<String, String>,
1554
1688
  }
1555
1689
 
1556
1690
  #[derive(Debug, Default, Serialize, Deserialize)]
@@ -2658,13 +2792,19 @@ impl FeltDb {
2658
2792
  .get(transaction_id)
2659
2793
  .copied()
2660
2794
  .unwrap_or((inner.authority_revision, inner.authority_revision));
2795
+ let (state_before, state_after) = inner
2796
+ .transaction_history
2797
+ .iter()
2798
+ .find(|attribution| attribution.transaction_id == transaction_id)
2799
+ .map(|attribution| (attribution.state_before, attribution.state_after))
2800
+ .unwrap_or((inner.sequence, inner.sequence));
2661
2801
  return Ok(AtomicCommit {
2662
2802
  transaction_id: transaction_id.into(),
2663
2803
  base_revision,
2664
2804
  commit_revision,
2665
2805
  status: TransactionStatus::Committed,
2666
- state_before: inner.sequence,
2667
- state_after: inner.sequence,
2806
+ state_before,
2807
+ state_after,
2668
2808
  rows: vec![],
2669
2809
  duplicate: true,
2670
2810
  });
@@ -2960,26 +3100,11 @@ impl FeltDb {
2960
3100
  }
2961
3101
  if row.deleted {
2962
3102
  inner.remove_row(&row.capability, &row.key);
2963
- // Decrement cardinality on delete
2964
- let count = inner
2965
- .collection_cardinality
2966
- .entry(row.capability.clone())
2967
- .or_default();
2968
- *count = count.saturating_sub(1);
2969
3103
  } else {
2970
3104
  // Record and index change together, inside the commit that
2971
3105
  // is already holding the state lock: a transaction that is
2972
3106
  // refused leaves neither, and one that commits publishes both.
2973
- let is_insert = inner.put_row(row.clone());
2974
-
2975
- // Increment cardinality only on insert, not update
2976
- if is_insert {
2977
- let count = inner
2978
- .collection_cardinality
2979
- .entry(row.capability.clone())
2980
- .or_default();
2981
- *count = count.saturating_add(1);
2982
- }
3107
+ inner.put_row(row.clone());
2983
3108
  }
2984
3109
  }
2985
3110
  let tail_began = workload_diagnostics::enabled().then(std::time::Instant::now);
@@ -3123,7 +3248,14 @@ impl FeltDb {
3123
3248
  inner.change_log.add_operation(op.clone());
3124
3249
 
3125
3250
  append_event_locked(&mut inner, &row)?;
3251
+ let base_revision = inner.authority_revision;
3126
3252
  inner.authority_revision += 1;
3253
+ remember_operation_attribution(
3254
+ &mut inner,
3255
+ &row,
3256
+ base_revision,
3257
+ base_revision + 1,
3258
+ );
3127
3259
  inner.put_row(row);
3128
3260
  if let Some(revision) = planned {
3129
3261
  persist_revision_locked(&mut inner, revision)?;
@@ -3202,7 +3334,14 @@ impl FeltDb {
3202
3334
  inner.change_log.add_operation(op.clone());
3203
3335
 
3204
3336
  append_event_locked(&mut inner, &row)?;
3337
+ let base_revision = inner.authority_revision;
3205
3338
  inner.authority_revision += 1;
3339
+ remember_operation_attribution(
3340
+ &mut inner,
3341
+ &row,
3342
+ base_revision,
3343
+ base_revision + 1,
3344
+ );
3206
3345
  inner.put_row(row);
3207
3346
  if let Some(revision) = planned {
3208
3347
  persist_revision_locked(&mut inner, revision)?;
@@ -3270,7 +3409,14 @@ impl FeltDb {
3270
3409
  operation: Some(op),
3271
3410
  };
3272
3411
  append_event(&inner.path, &tombstone)?;
3412
+ let base_revision = inner.authority_revision;
3273
3413
  inner.authority_revision += 1;
3414
+ remember_operation_attribution(
3415
+ &mut inner,
3416
+ &tombstone,
3417
+ base_revision,
3418
+ base_revision + 1,
3419
+ );
3274
3420
 
3275
3421
  // Remove from in-memory store, index entries included.
3276
3422
  inner.remove_row(&capability, &key);
@@ -3879,6 +4025,8 @@ impl FeltDb {
3879
4025
  format_version: DURABLE_FORMAT_VERSION,
3880
4026
  local_sequence: inner.sequence,
3881
4027
  observed_versions: snapshot.versions.clone(),
4028
+ transaction_history: Vec::new(),
4029
+ transaction_payload_hashes: HashMap::new(),
3882
4030
  },
3883
4031
  )?;
3884
4032
  for row in &snapshot.rows {
@@ -3951,25 +4099,43 @@ impl FeltDb {
3951
4099
  .collect())
3952
4100
  }
3953
4101
 
3954
- /// One transaction's attribution, if it is still inside the window.
4102
+ /// One transaction's durable attribution, regardless of the operator
4103
+ /// window. This lookup is canonical historical provenance.
3955
4104
  pub fn transaction_attribution(
3956
4105
  &self,
3957
4106
  transaction_id: &str,
3958
4107
  ) -> Result<Option<TransactionAttribution>> {
3959
4108
  let inner = self.state();
3960
4109
  Ok(inner
3961
- .transaction_attributions
4110
+ .transaction_history
3962
4111
  .iter()
3963
4112
  .rev()
3964
4113
  .find(|attribution| attribution.transaction_id == transaction_id)
3965
4114
  .cloned())
3966
4115
  }
3967
4116
 
3968
- /// Recent commits that named this record, newest first.
4117
+ /// Canonical commit history in authority order, suitable for resumable
4118
+ /// event projection and historical provenance reconstruction.
4119
+ pub fn committed_transactions_after(
4120
+ &self,
4121
+ commit_revision: u64,
4122
+ limit: usize,
4123
+ ) -> Result<Vec<TransactionAttribution>> {
4124
+ let inner = self.state();
4125
+ Ok(inner
4126
+ .transaction_history
4127
+ .iter()
4128
+ .filter(|transaction| transaction.commit_revision > commit_revision)
4129
+ .take(limit)
4130
+ .cloned()
4131
+ .collect())
4132
+ }
4133
+
4134
+ /// Durable commits that named this record, newest first.
3969
4135
  ///
3970
- /// This is how "who last changed this record" is answered without scanning
3971
- /// the durable log: a transaction names the keys it mutated, so the join is
3972
- /// over data the commit already recorded.
4136
+ /// This deliberately reads complete canonical history rather than the
4137
+ /// bounded operator window. Historical provenance must not disappear when
4138
+ /// a cache rolls over.
3973
4139
  pub fn transactions_touching(
3974
4140
  &self,
3975
4141
  capability: &str,
@@ -3978,7 +4144,7 @@ impl FeltDb {
3978
4144
  ) -> Result<Vec<TransactionAttribution>> {
3979
4145
  let inner = self.state();
3980
4146
  Ok(inner
3981
- .transaction_attributions
4147
+ .transaction_history
3982
4148
  .iter()
3983
4149
  .rev()
3984
4150
  .filter(|attribution| attribution.touched(capability, key))
@@ -4076,6 +4242,8 @@ impl FeltDb {
4076
4242
  format_version: DURABLE_FORMAT_VERSION,
4077
4243
  local_sequence: inner.sequence,
4078
4244
  observed_versions: inner.change_log.versions(),
4245
+ transaction_history: inner.transaction_history.clone(),
4246
+ transaction_payload_hashes: inner.transaction_payload_hashes.clone(),
4079
4247
  },
4080
4248
  )?;
4081
4249
  for operation in inner.change_log.pending_operations() {
@@ -4846,6 +5014,25 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
4846
5014
  .map_err(|error| corruption(line, &format!("malformed log header: {error}")))?;
4847
5015
  inner.sequence = inner.sequence.max(header.local_sequence);
4848
5016
  inner.change_log.observed_versions = header.observed_versions;
5017
+ for attribution in header.transaction_history {
5018
+ let attribution = normalize_transaction_attribution(attribution);
5019
+ inner.authority_revision = inner
5020
+ .authority_revision
5021
+ .max(attribution.commit_revision);
5022
+ inner
5023
+ .transaction_revisions
5024
+ .insert(
5025
+ attribution.transaction_id.clone(),
5026
+ (attribution.base_revision, attribution.commit_revision),
5027
+ );
5028
+ inner
5029
+ .applied_transactions
5030
+ .insert(attribution.transaction_id.clone());
5031
+ remember_attribution(inner, attribution);
5032
+ }
5033
+ inner
5034
+ .transaction_payload_hashes
5035
+ .extend(header.transaction_payload_hashes);
4849
5036
  inner.bootstrapped = true;
4850
5037
  snapshot_log = true;
4851
5038
  continue;
@@ -4925,6 +5112,7 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
4925
5112
  }
4926
5113
  let row: StoredRow = serde_json::from_value(value)
4927
5114
  .map_err(|_| corruption(line, "not a durable record this build understands"))?;
5115
+ let derived_revision = row.capability == "state" && row.operation.is_none();
4928
5116
  if let Some(operation) = row.operation.clone() {
4929
5117
  // A revision is a deterministic function of the operation that
4930
5118
  // produced it, so it never has to be lost. If the crash landed
@@ -4942,12 +5130,23 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
4942
5130
  inner.sync_state.merge_vector_clock(clock);
4943
5131
  }
4944
5132
  inner.change_log.add_operation(operation);
4945
- } else if !snapshot_log {
5133
+ } else if !snapshot_log && !derived_revision {
4946
5134
  // Pre-operation-envelope logs were produced only by this local
4947
5135
  // store. Preserve a monotonic sequence when upgrading them.
4948
5136
  inner.sequence += 1;
4949
5137
  }
4950
- inner.authority_revision += 1;
5138
+ if !derived_revision {
5139
+ let base_revision = inner.authority_revision;
5140
+ inner.authority_revision += 1;
5141
+ if !snapshot_log {
5142
+ remember_operation_attribution(
5143
+ inner,
5144
+ &row,
5145
+ base_revision,
5146
+ base_revision + 1,
5147
+ );
5148
+ }
5149
+ }
4951
5150
  if let Some(id) = row
4952
5151
  .key
4953
5152
  .rsplit_once(':')
@@ -5292,13 +5491,232 @@ mod tests {
5292
5491
  format!("tx_{}", commits - 3),
5293
5492
  ]
5294
5493
  );
5295
- // The oldest commits rolled out of the window rather than being kept
5296
- // forever; the durable log still holds them.
5297
- assert!(db.transaction_attribution("tx_0").expect("lookup").is_none());
5494
+ // The oldest commit rolled out of the recent window but remains
5495
+ // addressable through canonical history.
5496
+ assert!(db
5497
+ .transaction_attribution("tx_0")
5498
+ .expect("lookup")
5499
+ .is_some());
5298
5500
  drop(db);
5299
5501
  let _ = fs::remove_file(path);
5300
5502
  }
5301
5503
 
5504
+ #[test]
5505
+ fn canonical_transaction_history_survives_window_restart_and_compaction() {
5506
+ let path = temp_file();
5507
+ let db = open(&path).expect("open");
5508
+ let commits = TRANSACTION_ATTRIBUTION_WINDOW + 1;
5509
+ for index in 0..commits {
5510
+ db.apply_atomic_transaction(
5511
+ &format!("history_tx_{index}"),
5512
+ None,
5513
+ &[],
5514
+ &[AtomicMutation {
5515
+ capability: "history:Note".into(),
5516
+ key: format!("note_{index}"),
5517
+ value: Some(serde_json::json!({"index": index})),
5518
+ }],
5519
+ Some(serde_json::json!({"subject": "agent", "work_id": "work-1"})),
5520
+ )
5521
+ .expect("commit");
5522
+ }
5523
+ let complete = db.committed_transactions_after(0, usize::MAX).unwrap();
5524
+ assert_eq!(complete.len(), commits);
5525
+ assert_eq!(complete[0].transaction_id, "history_tx_0");
5526
+ assert_eq!(complete[0].operation_ids.len(), 1);
5527
+ assert_ne!(complete[0].operation_ids[0], complete[0].transaction_id);
5528
+ assert_eq!(complete[0].work_id.as_deref(), Some("work-1"));
5529
+ assert_eq!(
5530
+ db.transactions_touching("history:Note", "note_0", 1)
5531
+ .unwrap()[0]
5532
+ .transaction_id,
5533
+ "history_tx_0"
5534
+ );
5535
+ assert_eq!(
5536
+ db.transaction_attribution("history_tx_0")
5537
+ .unwrap()
5538
+ .unwrap()
5539
+ .audit
5540
+ .unwrap()["work_id"],
5541
+ "work-1"
5542
+ );
5543
+ let versions = db.operation_versions().unwrap();
5544
+ db.acknowledge_peer_versions("history-peer".into(), versions)
5545
+ .unwrap();
5546
+ db.compact_operation_log(&["history-peer".into()]).unwrap();
5547
+ drop(db);
5548
+
5549
+ let reopened = open(&path).expect("reopen");
5550
+ assert_eq!(
5551
+ reopened
5552
+ .committed_transactions_after(0, usize::MAX)
5553
+ .unwrap()
5554
+ .len(),
5555
+ commits
5556
+ );
5557
+ assert!(reopened
5558
+ .transaction_attribution("history_tx_0")
5559
+ .unwrap()
5560
+ .is_some());
5561
+ assert_eq!(
5562
+ reopened
5563
+ .transactions_touching("history:Note", "note_0", 1)
5564
+ .unwrap()[0]
5565
+ .transaction_id,
5566
+ "history_tx_0"
5567
+ );
5568
+ assert_eq!(
5569
+ reopened
5570
+ .transaction_attribution("history_tx_0")
5571
+ .unwrap()
5572
+ .unwrap()
5573
+ .operation_ids,
5574
+ complete[0].operation_ids
5575
+ );
5576
+ let _ = fs::remove_file(sync_metadata_path(&path));
5577
+ let _ = fs::remove_file(path);
5578
+ }
5579
+
5580
+ #[test]
5581
+ fn transaction_payload_binding_survives_restart_and_compaction() {
5582
+ let path = temp_file();
5583
+ let db = open(&path).expect("open");
5584
+ let mutation = AtomicMutation {
5585
+ capability: "managed:State".into(),
5586
+ key: "record-1".into(),
5587
+ value: Some(serde_json::json!({"value": 1})),
5588
+ };
5589
+ let first = db
5590
+ .apply_atomic_transaction_content_addressed(
5591
+ "managed-idempotency-1",
5592
+ Some("sha256:request-a"),
5593
+ None,
5594
+ &[],
5595
+ &[mutation.clone()],
5596
+ None,
5597
+ )
5598
+ .expect("initial commit");
5599
+ let duplicate = db
5600
+ .apply_atomic_transaction_content_addressed(
5601
+ "managed-idempotency-1",
5602
+ Some("sha256:request-a"),
5603
+ None,
5604
+ &[],
5605
+ &[mutation.clone()],
5606
+ None,
5607
+ )
5608
+ .expect("same semantic request");
5609
+ assert!(duplicate.duplicate);
5610
+ assert_eq!(duplicate.base_revision, first.base_revision);
5611
+ assert_eq!(duplicate.commit_revision, first.commit_revision);
5612
+ assert_eq!(duplicate.state_before, first.state_before);
5613
+ assert_eq!(duplicate.state_after, first.state_after);
5614
+
5615
+ let versions = db.operation_versions().unwrap();
5616
+ db.acknowledge_peer_versions("managed-peer".into(), versions)
5617
+ .unwrap();
5618
+ db.compact_operation_log(&["managed-peer".into()]).unwrap();
5619
+ drop(db);
5620
+
5621
+ let reopened = open(&path).expect("restart");
5622
+ let replay = reopened
5623
+ .apply_atomic_transaction_content_addressed(
5624
+ "managed-idempotency-1",
5625
+ Some("sha256:request-a"),
5626
+ None,
5627
+ &[],
5628
+ &[mutation.clone()],
5629
+ None,
5630
+ )
5631
+ .expect("retry after restart");
5632
+ assert!(replay.duplicate);
5633
+ assert_eq!(replay.commit_revision, first.commit_revision);
5634
+ let conflict = reopened
5635
+ .apply_atomic_transaction_content_addressed(
5636
+ "managed-idempotency-1",
5637
+ Some("sha256:request-b"),
5638
+ None,
5639
+ &[],
5640
+ &[mutation],
5641
+ None,
5642
+ )
5643
+ .unwrap_err();
5644
+ assert!(conflict
5645
+ .to_string()
5646
+ .contains("TRANSACTION_ID_PAYLOAD_MISMATCH:"));
5647
+ let _ = fs::remove_file(sync_metadata_path(&path));
5648
+ let _ = fs::remove_file(path);
5649
+ }
5650
+
5651
+ #[test]
5652
+ fn single_record_commits_are_canonical_history_positions() {
5653
+ let path = temp_file();
5654
+ let db = open(&path).unwrap();
5655
+ db.insert("orders:one", serde_json::json!({"status":"paid"}))
5656
+ .unwrap();
5657
+ let history = db.committed_transactions_after(0, 10).unwrap();
5658
+ assert_eq!(history.len(), 1);
5659
+ assert!(history[0].transaction_id.starts_with("transaction:"));
5660
+ assert!(history[0].operation_ids[0].starts_with("operation:"));
5661
+ assert_ne!(history[0].transaction_id, history[0].operation_ids[0]);
5662
+ assert_eq!(history[0].keys, vec!["orders:orders:one"]);
5663
+ drop(db);
5664
+ let reopened = open(&path).unwrap();
5665
+ assert_eq!(
5666
+ reopened.committed_transactions_after(0, 10).unwrap()[0].transaction_id,
5667
+ history[0].transaction_id
5668
+ );
5669
+ assert_eq!(
5670
+ reopened.committed_transactions_after(0, 10).unwrap()[0].operation_ids,
5671
+ history[0].operation_ids
5672
+ );
5673
+ let _ = fs::remove_file(path);
5674
+ }
5675
+
5676
+ #[test]
5677
+ fn compacted_legacy_attribution_gets_stable_distinct_operation_identity() {
5678
+ let legacy = TransactionAttribution {
5679
+ transaction_id: "legacy-transaction".into(),
5680
+ operation_ids: Vec::new(),
5681
+ subject: Some("agent-1".into()),
5682
+ tenant_id: Some("tenant-1".into()),
5683
+ application_id: Some("app-1".into()),
5684
+ application_revision: None,
5685
+ base_revision: 4,
5686
+ commit_revision: 5,
5687
+ state_before: 4,
5688
+ state_after: 5,
5689
+ keys: vec!["orders:one".into()],
5690
+ operations: 2,
5691
+ principal_id: None,
5692
+ agent_id: Some("agent-1".into()),
5693
+ session_id: None,
5694
+ delegator_id: None,
5695
+ work_id: None,
5696
+ unix_ms: 1,
5697
+ audit: None,
5698
+ };
5699
+ let first = normalize_transaction_attribution(legacy.clone());
5700
+ let second = normalize_transaction_attribution(legacy);
5701
+ assert_eq!(first, second);
5702
+ assert_eq!(first.transaction_id, "legacy-transaction");
5703
+ assert_eq!(first.operation_ids.len(), 2);
5704
+ assert!(first
5705
+ .operation_ids
5706
+ .iter()
5707
+ .all(|operation| operation.starts_with("operation:legacy-transaction:")));
5708
+
5709
+ let single = TransactionAttribution {
5710
+ transaction_id: "operation:authority:9".into(),
5711
+ operation_ids: Vec::new(),
5712
+ operations: 1,
5713
+ ..first
5714
+ };
5715
+ let upgraded = normalize_transaction_attribution(single);
5716
+ assert_eq!(upgraded.transaction_id, "transaction:authority:9");
5717
+ assert_eq!(upgraded.operation_ids, vec!["operation:authority:9"]);
5718
+ }
5719
+
5302
5720
  /// A bulk transaction names a bounded sample of keys and an exact count.
5303
5721
  #[test]
5304
5722
  fn a_bulk_transaction_truncates_its_key_list_and_keeps_the_count() {