create-feltdb 0.9.1 → 0.11.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 (30) hide show
  1. package/dist/package-versions.js +1 -1
  2. package/dist/server-source/crates/feltdb/src/acceptance_tests.rs +8 -1
  3. package/dist/server-source/crates/feltdb/src/application.rs +16 -0
  4. package/dist/server-source/crates/feltdb/src/bin/feltdb_node.rs +21 -22
  5. package/dist/server-source/crates/feltdb/src/lib.rs +556 -45
  6. package/dist/server-source/crates/feltdb/src/state_contract.rs +48 -3
  7. package/dist/server-source/crates/feltdb/src/state_model.rs +569 -4
  8. package/dist/server-source/crates/feltdb/src/state_trigger.rs +1 -0
  9. package/dist/server-source/crates/feltdb/src/versioned_object_substrate.rs +708 -0
  10. package/dist/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  11. package/dist/server-source/crates/feltdb/src/workload.rs +480 -44
  12. package/dist/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +123 -0
  13. package/dist/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +281 -0
  14. package/dist/server-source/crates/feltdb/tests/local_process_ownership.rs +69 -0
  15. package/dist/server-source/crates/feltdb/tests/production_readiness_contract.rs +15 -3
  16. package/dist/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +49 -24
  17. package/dist/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +328 -0
  18. package/dist/server-source/crates/feltdb/tests/workload_envelope_contract.rs +20 -3
  19. package/dist/server-source/crates/feltdb-server/src/app_state.rs +14 -4
  20. package/dist/server-source/crates/feltdb-server/src/backup.rs +0 -4
  21. package/dist/server-source/crates/feltdb-server/src/canonical_principals.rs +1093 -0
  22. package/dist/server-source/crates/feltdb-server/src/causal.rs +905 -144
  23. package/dist/server-source/crates/feltdb-server/src/connections.rs +9 -6
  24. package/dist/server-source/crates/feltdb-server/src/control_plane.rs +90 -0
  25. package/dist/server-source/crates/feltdb-server/src/lib.rs +1 -0
  26. package/dist/server-source/crates/feltdb-server/src/main.rs +418 -82
  27. package/dist/server-source/crates/feltdb-server/src/principals.rs +9 -501
  28. package/dist/server-source/crates/feltdb-server/src/tenancy.rs +22 -0
  29. package/dist/server-source/crates/feltdb-server/tests/principal_tenant_migration.rs +247 -0
  30. package/package.json +1 -1
@@ -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,
@@ -280,12 +287,12 @@ use std::any::type_name;
280
287
  use std::collections::hash_map::DefaultHasher;
281
288
  use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
282
289
  use std::fmt::{Display, Formatter};
283
- use std::fs::{self, OpenOptions};
290
+ use std::fs::{self, File, OpenOptions};
284
291
  use std::hash::{Hash, Hasher};
285
292
  use std::io::{BufRead, BufReader, Write};
286
293
  use std::ops::Bound::{Excluded, Unbounded};
287
294
  use std::path::{Path, PathBuf};
288
- use std::sync::{Arc, Mutex};
295
+ use std::sync::{Arc, Mutex, OnceLock, Weak};
289
296
  use std::time::{SystemTime, UNIX_EPOCH};
290
297
  use tokio::sync::broadcast;
291
298
 
@@ -362,8 +369,17 @@ impl From<String> for FlowError {
362
369
  pub struct FeltDb {
363
370
  inner: Arc<Mutex<Inner>>,
364
371
  event_tx: broadcast::Sender<ChangeEvent>,
372
+ _ownership: Arc<File>,
373
+ }
374
+
375
+ struct OpenDatabase {
376
+ inner: Weak<Mutex<Inner>>,
377
+ event_tx: broadcast::Sender<ChangeEvent>,
378
+ ownership: Weak<File>,
365
379
  }
366
380
 
381
+ static OPEN_DATABASES: OnceLock<Mutex<HashMap<PathBuf, OpenDatabase>>> = OnceLock::new();
382
+
367
383
  #[derive(Default)]
368
384
  #[allow(dead_code)] // Reserved runtime subsystems are initialized before their public APIs land.
369
385
  struct Inner {
@@ -403,6 +419,10 @@ struct Inner {
403
419
  /// audit archive, and the durable log remains the record of what happened.
404
420
  /// Keeping it bounded is what makes it safe to maintain on the commit path.
405
421
  transaction_attributions: VecDeque<TransactionAttribution>,
422
+ /// Complete committed transaction order, rebuilt exclusively from the
423
+ /// durable transaction log. Unlike the bounded operator window above this
424
+ /// is the canonical replay/provenance index.
425
+ transaction_history: Vec<TransactionAttribution>,
406
426
  collection_cardinality: HashMap<String, u64>,
407
427
  /// Derived equality index over `rows`. Never durable, never replicated, and
408
428
  /// never consulted for anything but candidate selection: it is maintained
@@ -505,6 +525,12 @@ impl Inner {
505
525
  let bucket = self.rows.entry(capability.clone()).or_default();
506
526
  let previous = bucket.insert(key.clone(), row);
507
527
  drop(mutation);
528
+ if previous.is_none() {
529
+ *self
530
+ .collection_cardinality
531
+ .entry(capability.clone())
532
+ .or_default() += 1;
533
+ }
508
534
  if !indexed {
509
535
  return previous.is_none();
510
536
  }
@@ -543,6 +569,11 @@ impl Inner {
543
569
  return false;
544
570
  };
545
571
  drop(mutation);
572
+ let count = self
573
+ .collection_cardinality
574
+ .entry(capability.to_string())
575
+ .or_default();
576
+ *count = count.saturating_sub(1);
546
577
  let _index = workload_diagnostics::span(workload_diagnostics::Phase::IndexMaintenance);
547
578
  self.equality_index
548
579
  .remove_record(capability, key, &removed.value);
@@ -552,6 +583,7 @@ impl Inner {
552
583
  /// Drop all records, as a snapshot install does. The index follows.
553
584
  fn clear_rows(&mut self) {
554
585
  self.rows.clear();
586
+ self.collection_cardinality.clear();
555
587
  self.equality_index.clear();
556
588
  }
557
589
  }
@@ -773,6 +805,10 @@ const TRANSACTION_ATTRIBUTION_KEYS: usize = 32;
773
805
  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774
806
  pub struct TransactionAttribution {
775
807
  pub transaction_id: String,
808
+ /// Stable operation identities assigned to the rows in this transaction.
809
+ /// These are distinct from both transaction and revision identity.
810
+ #[serde(default)]
811
+ pub operation_ids: Vec<String>,
776
812
  /// The actor the caller authenticated as, when the transaction named one.
777
813
  pub subject: Option<String>,
778
814
  pub tenant_id: Option<String>,
@@ -789,8 +825,23 @@ pub struct TransactionAttribution {
789
825
  /// How many mutations the transaction made, whether or not `keys` lists
790
826
  /// them all.
791
827
  pub operations: usize,
828
+ #[serde(default)]
829
+ pub principal_id: Option<String>,
830
+ #[serde(default)]
831
+ pub agent_id: Option<String>,
832
+ #[serde(default)]
833
+ pub session_id: Option<String>,
834
+ #[serde(default)]
835
+ pub delegator_id: Option<String>,
836
+ #[serde(default)]
837
+ pub work_id: Option<String>,
792
838
  /// Milliseconds since the epoch, matching `StoredRow::unix_ms`.
793
839
  pub unix_ms: u128,
840
+ /// Canonical transaction metadata exactly as committed. Record values are
841
+ /// deliberately absent; stable work/session/agent/operation references
842
+ /// live here so historical provenance survives process loss.
843
+ #[serde(default)]
844
+ pub audit: Option<Value>,
794
845
  }
795
846
 
796
847
  impl TransactionAttribution {
@@ -813,6 +864,13 @@ impl TransactionAttribution {
813
864
  };
814
865
  TransactionAttribution {
815
866
  transaction_id: transaction_id.to_string(),
867
+ operation_ids: rows
868
+ .iter()
869
+ .filter_map(|row| row.operation.as_ref())
870
+ .map(|operation| {
871
+ format!("operation:{}:{}", operation.instance_id, operation.sequence)
872
+ })
873
+ .collect(),
816
874
  subject: text("subject"),
817
875
  tenant_id: text("tenant"),
818
876
  application_id: text("application"),
@@ -827,7 +885,13 @@ impl TransactionAttribution {
827
885
  .map(|row| format!("{}:{}", row.capability, row.key))
828
886
  .collect(),
829
887
  operations: rows.len(),
888
+ principal_id: text("principal_id"),
889
+ agent_id: text("agent_id"),
890
+ session_id: text("session_id"),
891
+ delegator_id: text("delegator_id"),
892
+ work_id: text("work_id").or_else(|| text("workload_id")),
830
893
  unix_ms,
894
+ audit: audit.cloned(),
831
895
  }
832
896
  }
833
897
 
@@ -838,14 +902,84 @@ impl TransactionAttribution {
838
902
  }
839
903
  }
840
904
 
905
+ /// Upgrade compacted attribution written before operation identities became a
906
+ /// first-class part of canonical history. The mapping is deterministic from
907
+ /// durable transaction data, so reopening never regenerates a different ID.
908
+ fn normalize_transaction_attribution(
909
+ mut attribution: TransactionAttribution,
910
+ ) -> TransactionAttribution {
911
+ if attribution.operation_ids.is_empty() {
912
+ if attribution.transaction_id.starts_with("operation:") {
913
+ let operation_id = attribution.transaction_id.clone();
914
+ attribution.transaction_id = operation_id.replacen("operation:", "transaction:", 1);
915
+ attribution.operation_ids.push(operation_id);
916
+ } else {
917
+ attribution.operation_ids = (1..=attribution.operations)
918
+ .map(|index| {
919
+ format!(
920
+ "operation:legacy-transaction:{}:{index}",
921
+ attribution.transaction_id
922
+ )
923
+ })
924
+ .collect();
925
+ }
926
+ }
927
+ attribution
928
+ }
929
+
841
930
  /// Records an attribution, discarding the oldest once the window is full.
842
931
  fn remember_attribution(inner: &mut Inner, attribution: TransactionAttribution) {
932
+ inner.transaction_history.push(attribution.clone());
843
933
  if inner.transaction_attributions.len() >= TRANSACTION_ATTRIBUTION_WINDOW {
844
934
  inner.transaction_attributions.pop_front();
845
935
  }
846
936
  inner.transaction_attributions.push_back(attribution);
847
937
  }
848
938
 
939
+ fn remember_operation_attribution(
940
+ inner: &mut Inner,
941
+ row: &StoredRow,
942
+ base_revision: u64,
943
+ commit_revision: u64,
944
+ ) {
945
+ let Some(operation) = row.operation.as_ref() else {
946
+ return;
947
+ };
948
+ remember_attribution(
949
+ inner,
950
+ TransactionAttribution {
951
+ transaction_id: format!(
952
+ "transaction:{}:{}",
953
+ operation.instance_id, operation.sequence
954
+ ),
955
+ operation_ids: vec![format!(
956
+ "operation:{}:{}",
957
+ operation.instance_id, operation.sequence
958
+ )],
959
+ subject: None,
960
+ tenant_id: None,
961
+ application_id: None,
962
+ application_revision: operation
963
+ .revision
964
+ .as_ref()
965
+ .map(|revision| revision.id.clone()),
966
+ base_revision,
967
+ commit_revision,
968
+ state_before: operation.sequence.saturating_sub(1),
969
+ state_after: operation.sequence,
970
+ keys: vec![format!("{}:{}", row.capability, row.key)],
971
+ operations: 1,
972
+ principal_id: None,
973
+ agent_id: None,
974
+ session_id: None,
975
+ delegator_id: None,
976
+ work_id: None,
977
+ unix_ms: row.unix_ms,
978
+ audit: None,
979
+ },
980
+ );
981
+ }
982
+
849
983
  #[derive(Debug, Clone, Serialize, Deserialize)]
850
984
  struct TransactionLogRecord {
851
985
  record_type: String,
@@ -1551,6 +1685,15 @@ struct LogHeader {
1551
1685
  /// which deserialize as 0 and are classified as unversioned.
1552
1686
  #[serde(default)]
1553
1687
  format_version: u32,
1688
+ /// Canonical commit provenance retained when operation-log compaction
1689
+ /// rewrites current state. This is history, not a derived event store.
1690
+ #[serde(default)]
1691
+ transaction_history: Vec<TransactionAttribution>,
1692
+ /// Durable binding between a caller transaction identity and its semantic
1693
+ /// request. Compaction must retain this or an old id could be reused for a
1694
+ /// different mutation after restart.
1695
+ #[serde(default)]
1696
+ transaction_payload_hashes: HashMap<String, String>,
1554
1697
  }
1555
1698
 
1556
1699
  #[derive(Debug, Default, Serialize, Deserialize)]
@@ -1834,7 +1977,56 @@ struct RetentionRecord {
1834
1977
 
1835
1978
  impl FeltDb {
1836
1979
  pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
1837
- let path = path.as_ref().to_path_buf();
1980
+ let requested_path = path.as_ref();
1981
+ let parent = requested_path
1982
+ .parent()
1983
+ .filter(|parent| !parent.as_os_str().is_empty())
1984
+ .unwrap_or_else(|| Path::new("."));
1985
+ fs::create_dir_all(parent)?;
1986
+ let path = if requested_path.exists() {
1987
+ fs::canonicalize(requested_path)?
1988
+ } else {
1989
+ let file_name = requested_path.file_name().ok_or_else(|| {
1990
+ std::io::Error::new(
1991
+ std::io::ErrorKind::InvalidInput,
1992
+ "FeltDB database path has no file name",
1993
+ )
1994
+ })?;
1995
+ fs::canonicalize(parent)?.join(file_name)
1996
+ };
1997
+
1998
+ let databases = OPEN_DATABASES.get_or_init(|| Mutex::new(HashMap::new()));
1999
+ let mut databases = databases.lock().expect("open database registry poisoned");
2000
+ if let Some(open) = databases.get(&path) {
2001
+ if let (Some(inner), Some(ownership)) =
2002
+ (open.inner.upgrade(), open.ownership.upgrade())
2003
+ {
2004
+ return Ok(Self {
2005
+ inner,
2006
+ event_tx: open.event_tx.clone(),
2007
+ _ownership: ownership,
2008
+ });
2009
+ }
2010
+ }
2011
+ databases.remove(&path);
2012
+
2013
+ let mut ownership_path = path.as_os_str().to_os_string();
2014
+ ownership_path.push(".lock");
2015
+ let ownership = OpenOptions::new()
2016
+ .create(true)
2017
+ .read(true)
2018
+ .write(true)
2019
+ .open(PathBuf::from(ownership_path))?;
2020
+ ownership.try_lock().map_err(|error| match error {
2021
+ std::fs::TryLockError::WouldBlock => std::io::Error::new(
2022
+ std::io::ErrorKind::WouldBlock,
2023
+ format!(
2024
+ "FeltDB database {} is already owned by another process",
2025
+ path.display()
2026
+ ),
2027
+ ),
2028
+ std::fs::TryLockError::Error(error) => error,
2029
+ })?;
1838
2030
 
1839
2031
  // Fail closed. The format is decided before any record is interpreted
1840
2032
  // and before anything is written, so a database this build does not
@@ -1847,9 +2039,6 @@ impl FeltDb {
1847
2039
  FormatCompatibility::Compatible(format) => format,
1848
2040
  };
1849
2041
 
1850
- if let Some(parent) = path.parent() {
1851
- fs::create_dir_all(parent)?;
1852
- }
1853
2042
  if !path.exists() {
1854
2043
  let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
1855
2044
  // A new database states its format in its first record, so it never
@@ -1885,10 +2074,20 @@ impl FeltDb {
1885
2074
 
1886
2075
  let (event_tx, _) = broadcast::channel(1024);
1887
2076
 
1888
- Ok(Self {
2077
+ let database = Self {
1889
2078
  inner: Arc::new(Mutex::new(inner)),
1890
2079
  event_tx,
1891
- })
2080
+ _ownership: Arc::new(ownership),
2081
+ };
2082
+ databases.insert(
2083
+ path,
2084
+ OpenDatabase {
2085
+ inner: Arc::downgrade(&database.inner),
2086
+ event_tx: database.event_tx.clone(),
2087
+ ownership: Arc::downgrade(&database._ownership),
2088
+ },
2089
+ );
2090
+ Ok(database)
1892
2091
  }
1893
2092
 
1894
2093
  /// Write a self-contained durable backup artifact.
@@ -2130,10 +2329,21 @@ impl FeltDb {
2130
2329
  .map(|(cap, _)| cap)
2131
2330
  .unwrap_or("default")
2132
2331
  .to_string();
2133
- self.insert_internal(capability, key.to_string(), value)?;
2332
+ self.insert_internal(capability, key.to_string(), value, false)?;
2134
2333
  Ok(())
2135
2334
  }
2136
2335
 
2336
+ /// Insert only if the logical record is absent, deciding presence under the
2337
+ /// same lock that appends the operation and mints its first revision.
2338
+ pub fn insert_if_absent<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
2339
+ let capability = key
2340
+ .split_once(':')
2341
+ .map(|(cap, _)| cap)
2342
+ .unwrap_or("default")
2343
+ .to_string();
2344
+ self.insert_internal(capability, key.to_string(), value, true)
2345
+ }
2346
+
2137
2347
  /// Update an existing record with vector clock tracking
2138
2348
  pub fn update<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
2139
2349
  let capability = key
@@ -2346,7 +2556,7 @@ impl FeltDb {
2346
2556
  }
2347
2557
 
2348
2558
  // Authorized: apply mutation
2349
- self.insert_internal(capability, key.to_string(), value)?;
2559
+ self.insert_internal(capability, key.to_string(), value, false)?;
2350
2560
  Ok(())
2351
2561
  }
2352
2562
 
@@ -2658,13 +2868,19 @@ impl FeltDb {
2658
2868
  .get(transaction_id)
2659
2869
  .copied()
2660
2870
  .unwrap_or((inner.authority_revision, inner.authority_revision));
2871
+ let (state_before, state_after) = inner
2872
+ .transaction_history
2873
+ .iter()
2874
+ .find(|attribution| attribution.transaction_id == transaction_id)
2875
+ .map(|attribution| (attribution.state_before, attribution.state_after))
2876
+ .unwrap_or((inner.sequence, inner.sequence));
2661
2877
  return Ok(AtomicCommit {
2662
2878
  transaction_id: transaction_id.into(),
2663
2879
  base_revision,
2664
2880
  commit_revision,
2665
2881
  status: TransactionStatus::Committed,
2666
- state_before: inner.sequence,
2667
- state_after: inner.sequence,
2882
+ state_before,
2883
+ state_after,
2668
2884
  rows: vec![],
2669
2885
  duplicate: true,
2670
2886
  });
@@ -2960,26 +3176,11 @@ impl FeltDb {
2960
3176
  }
2961
3177
  if row.deleted {
2962
3178
  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
3179
  } else {
2970
3180
  // Record and index change together, inside the commit that
2971
3181
  // is already holding the state lock: a transaction that is
2972
3182
  // 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
- }
3183
+ inner.put_row(row.clone());
2983
3184
  }
2984
3185
  }
2985
3186
  let tail_began = workload_diagnostics::enabled().then(std::time::Instant::now);
@@ -3070,6 +3271,7 @@ impl FeltDb {
3070
3271
  capability: String,
3071
3272
  key: String,
3072
3273
  value: T,
3274
+ require_absent: bool,
3073
3275
  ) -> Result<()> {
3074
3276
  let rust_type = type_name::<T>().to_string();
3075
3277
  let value_json = serde_json::to_value(value)?;
@@ -3091,6 +3293,21 @@ impl FeltDb {
3091
3293
  {
3092
3294
  let mut inner = self.state();
3093
3295
 
3296
+ if require_absent
3297
+ && inner
3298
+ .rows
3299
+ .get(&capability)
3300
+ .and_then(|bucket| bucket.get(&key))
3301
+ .is_some()
3302
+ {
3303
+ return Err(FlowError::PreconditionFailed(Box::new(
3304
+ PreconditionFailure::Present {
3305
+ collection: capability,
3306
+ key,
3307
+ },
3308
+ )));
3309
+ }
3310
+
3094
3311
  // Increment sequence number
3095
3312
  inner.sequence += 1;
3096
3313
 
@@ -3123,7 +3340,14 @@ impl FeltDb {
3123
3340
  inner.change_log.add_operation(op.clone());
3124
3341
 
3125
3342
  append_event_locked(&mut inner, &row)?;
3343
+ let base_revision = inner.authority_revision;
3126
3344
  inner.authority_revision += 1;
3345
+ remember_operation_attribution(
3346
+ &mut inner,
3347
+ &row,
3348
+ base_revision,
3349
+ base_revision + 1,
3350
+ );
3127
3351
  inner.put_row(row);
3128
3352
  if let Some(revision) = planned {
3129
3353
  persist_revision_locked(&mut inner, revision)?;
@@ -3202,7 +3426,14 @@ impl FeltDb {
3202
3426
  inner.change_log.add_operation(op.clone());
3203
3427
 
3204
3428
  append_event_locked(&mut inner, &row)?;
3429
+ let base_revision = inner.authority_revision;
3205
3430
  inner.authority_revision += 1;
3431
+ remember_operation_attribution(
3432
+ &mut inner,
3433
+ &row,
3434
+ base_revision,
3435
+ base_revision + 1,
3436
+ );
3206
3437
  inner.put_row(row);
3207
3438
  if let Some(revision) = planned {
3208
3439
  persist_revision_locked(&mut inner, revision)?;
@@ -3270,7 +3501,14 @@ impl FeltDb {
3270
3501
  operation: Some(op),
3271
3502
  };
3272
3503
  append_event(&inner.path, &tombstone)?;
3504
+ let base_revision = inner.authority_revision;
3273
3505
  inner.authority_revision += 1;
3506
+ remember_operation_attribution(
3507
+ &mut inner,
3508
+ &tombstone,
3509
+ base_revision,
3510
+ base_revision + 1,
3511
+ );
3274
3512
 
3275
3513
  // Remove from in-memory store, index entries included.
3276
3514
  inner.remove_row(&capability, &key);
@@ -3879,6 +4117,8 @@ impl FeltDb {
3879
4117
  format_version: DURABLE_FORMAT_VERSION,
3880
4118
  local_sequence: inner.sequence,
3881
4119
  observed_versions: snapshot.versions.clone(),
4120
+ transaction_history: Vec::new(),
4121
+ transaction_payload_hashes: HashMap::new(),
3882
4122
  },
3883
4123
  )?;
3884
4124
  for row in &snapshot.rows {
@@ -3951,25 +4191,43 @@ impl FeltDb {
3951
4191
  .collect())
3952
4192
  }
3953
4193
 
3954
- /// One transaction's attribution, if it is still inside the window.
4194
+ /// One transaction's durable attribution, regardless of the operator
4195
+ /// window. This lookup is canonical historical provenance.
3955
4196
  pub fn transaction_attribution(
3956
4197
  &self,
3957
4198
  transaction_id: &str,
3958
4199
  ) -> Result<Option<TransactionAttribution>> {
3959
4200
  let inner = self.state();
3960
4201
  Ok(inner
3961
- .transaction_attributions
4202
+ .transaction_history
3962
4203
  .iter()
3963
4204
  .rev()
3964
4205
  .find(|attribution| attribution.transaction_id == transaction_id)
3965
4206
  .cloned())
3966
4207
  }
3967
4208
 
3968
- /// Recent commits that named this record, newest first.
4209
+ /// Canonical commit history in authority order, suitable for resumable
4210
+ /// event projection and historical provenance reconstruction.
4211
+ pub fn committed_transactions_after(
4212
+ &self,
4213
+ commit_revision: u64,
4214
+ limit: usize,
4215
+ ) -> Result<Vec<TransactionAttribution>> {
4216
+ let inner = self.state();
4217
+ Ok(inner
4218
+ .transaction_history
4219
+ .iter()
4220
+ .filter(|transaction| transaction.commit_revision > commit_revision)
4221
+ .take(limit)
4222
+ .cloned()
4223
+ .collect())
4224
+ }
4225
+
4226
+ /// Durable commits that named this record, newest first.
3969
4227
  ///
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.
4228
+ /// This deliberately reads complete canonical history rather than the
4229
+ /// bounded operator window. Historical provenance must not disappear when
4230
+ /// a cache rolls over.
3973
4231
  pub fn transactions_touching(
3974
4232
  &self,
3975
4233
  capability: &str,
@@ -3978,7 +4236,7 @@ impl FeltDb {
3978
4236
  ) -> Result<Vec<TransactionAttribution>> {
3979
4237
  let inner = self.state();
3980
4238
  Ok(inner
3981
- .transaction_attributions
4239
+ .transaction_history
3982
4240
  .iter()
3983
4241
  .rev()
3984
4242
  .filter(|attribution| attribution.touched(capability, key))
@@ -4076,6 +4334,8 @@ impl FeltDb {
4076
4334
  format_version: DURABLE_FORMAT_VERSION,
4077
4335
  local_sequence: inner.sequence,
4078
4336
  observed_versions: inner.change_log.versions(),
4337
+ transaction_history: inner.transaction_history.clone(),
4338
+ transaction_payload_hashes: inner.transaction_payload_hashes.clone(),
4079
4339
  },
4080
4340
  )?;
4081
4341
  for operation in inner.change_log.pending_operations() {
@@ -4329,7 +4589,7 @@ impl Capability {
4329
4589
  key
4330
4590
  };
4331
4591
  self.db
4332
- .insert_internal(self.name.clone(), key.clone(), value)?;
4592
+ .insert_internal(self.name.clone(), key.clone(), value, false)?;
4333
4593
  Ok(key)
4334
4594
  }
4335
4595
 
@@ -4339,7 +4599,8 @@ impl Capability {
4339
4599
  } else {
4340
4600
  format!("{}:{key}", self.name)
4341
4601
  };
4342
- self.db.insert_internal(self.name.clone(), full_key, value)
4602
+ self.db
4603
+ .insert_internal(self.name.clone(), full_key, value, false)
4343
4604
  }
4344
4605
 
4345
4606
  pub fn update<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
@@ -4846,6 +5107,25 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
4846
5107
  .map_err(|error| corruption(line, &format!("malformed log header: {error}")))?;
4847
5108
  inner.sequence = inner.sequence.max(header.local_sequence);
4848
5109
  inner.change_log.observed_versions = header.observed_versions;
5110
+ for attribution in header.transaction_history {
5111
+ let attribution = normalize_transaction_attribution(attribution);
5112
+ inner.authority_revision = inner
5113
+ .authority_revision
5114
+ .max(attribution.commit_revision);
5115
+ inner
5116
+ .transaction_revisions
5117
+ .insert(
5118
+ attribution.transaction_id.clone(),
5119
+ (attribution.base_revision, attribution.commit_revision),
5120
+ );
5121
+ inner
5122
+ .applied_transactions
5123
+ .insert(attribution.transaction_id.clone());
5124
+ remember_attribution(inner, attribution);
5125
+ }
5126
+ inner
5127
+ .transaction_payload_hashes
5128
+ .extend(header.transaction_payload_hashes);
4849
5129
  inner.bootstrapped = true;
4850
5130
  snapshot_log = true;
4851
5131
  continue;
@@ -4925,6 +5205,7 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
4925
5205
  }
4926
5206
  let row: StoredRow = serde_json::from_value(value)
4927
5207
  .map_err(|_| corruption(line, "not a durable record this build understands"))?;
5208
+ let derived_revision = row.capability == "state" && row.operation.is_none();
4928
5209
  if let Some(operation) = row.operation.clone() {
4929
5210
  // A revision is a deterministic function of the operation that
4930
5211
  // produced it, so it never has to be lost. If the crash landed
@@ -4942,12 +5223,23 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
4942
5223
  inner.sync_state.merge_vector_clock(clock);
4943
5224
  }
4944
5225
  inner.change_log.add_operation(operation);
4945
- } else if !snapshot_log {
5226
+ } else if !snapshot_log && !derived_revision {
4946
5227
  // Pre-operation-envelope logs were produced only by this local
4947
5228
  // store. Preserve a monotonic sequence when upgrading them.
4948
5229
  inner.sequence += 1;
4949
5230
  }
4950
- inner.authority_revision += 1;
5231
+ if !derived_revision {
5232
+ let base_revision = inner.authority_revision;
5233
+ inner.authority_revision += 1;
5234
+ if !snapshot_log {
5235
+ remember_operation_attribution(
5236
+ inner,
5237
+ &row,
5238
+ base_revision,
5239
+ base_revision + 1,
5240
+ );
5241
+ }
5242
+ }
4951
5243
  if let Some(id) = row
4952
5244
  .key
4953
5245
  .rsplit_once(':')
@@ -5292,13 +5584,232 @@ mod tests {
5292
5584
  format!("tx_{}", commits - 3),
5293
5585
  ]
5294
5586
  );
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());
5587
+ // The oldest commit rolled out of the recent window but remains
5588
+ // addressable through canonical history.
5589
+ assert!(db
5590
+ .transaction_attribution("tx_0")
5591
+ .expect("lookup")
5592
+ .is_some());
5593
+ drop(db);
5594
+ let _ = fs::remove_file(path);
5595
+ }
5596
+
5597
+ #[test]
5598
+ fn canonical_transaction_history_survives_window_restart_and_compaction() {
5599
+ let path = temp_file();
5600
+ let db = open(&path).expect("open");
5601
+ let commits = TRANSACTION_ATTRIBUTION_WINDOW + 1;
5602
+ for index in 0..commits {
5603
+ db.apply_atomic_transaction(
5604
+ &format!("history_tx_{index}"),
5605
+ None,
5606
+ &[],
5607
+ &[AtomicMutation {
5608
+ capability: "history:Note".into(),
5609
+ key: format!("note_{index}"),
5610
+ value: Some(serde_json::json!({"index": index})),
5611
+ }],
5612
+ Some(serde_json::json!({"subject": "agent", "work_id": "work-1"})),
5613
+ )
5614
+ .expect("commit");
5615
+ }
5616
+ let complete = db.committed_transactions_after(0, usize::MAX).unwrap();
5617
+ assert_eq!(complete.len(), commits);
5618
+ assert_eq!(complete[0].transaction_id, "history_tx_0");
5619
+ assert_eq!(complete[0].operation_ids.len(), 1);
5620
+ assert_ne!(complete[0].operation_ids[0], complete[0].transaction_id);
5621
+ assert_eq!(complete[0].work_id.as_deref(), Some("work-1"));
5622
+ assert_eq!(
5623
+ db.transactions_touching("history:Note", "note_0", 1)
5624
+ .unwrap()[0]
5625
+ .transaction_id,
5626
+ "history_tx_0"
5627
+ );
5628
+ assert_eq!(
5629
+ db.transaction_attribution("history_tx_0")
5630
+ .unwrap()
5631
+ .unwrap()
5632
+ .audit
5633
+ .unwrap()["work_id"],
5634
+ "work-1"
5635
+ );
5636
+ let versions = db.operation_versions().unwrap();
5637
+ db.acknowledge_peer_versions("history-peer".into(), versions)
5638
+ .unwrap();
5639
+ db.compact_operation_log(&["history-peer".into()]).unwrap();
5640
+ drop(db);
5641
+
5642
+ let reopened = open(&path).expect("reopen");
5643
+ assert_eq!(
5644
+ reopened
5645
+ .committed_transactions_after(0, usize::MAX)
5646
+ .unwrap()
5647
+ .len(),
5648
+ commits
5649
+ );
5650
+ assert!(reopened
5651
+ .transaction_attribution("history_tx_0")
5652
+ .unwrap()
5653
+ .is_some());
5654
+ assert_eq!(
5655
+ reopened
5656
+ .transactions_touching("history:Note", "note_0", 1)
5657
+ .unwrap()[0]
5658
+ .transaction_id,
5659
+ "history_tx_0"
5660
+ );
5661
+ assert_eq!(
5662
+ reopened
5663
+ .transaction_attribution("history_tx_0")
5664
+ .unwrap()
5665
+ .unwrap()
5666
+ .operation_ids,
5667
+ complete[0].operation_ids
5668
+ );
5669
+ let _ = fs::remove_file(sync_metadata_path(&path));
5670
+ let _ = fs::remove_file(path);
5671
+ }
5672
+
5673
+ #[test]
5674
+ fn transaction_payload_binding_survives_restart_and_compaction() {
5675
+ let path = temp_file();
5676
+ let db = open(&path).expect("open");
5677
+ let mutation = AtomicMutation {
5678
+ capability: "managed:State".into(),
5679
+ key: "record-1".into(),
5680
+ value: Some(serde_json::json!({"value": 1})),
5681
+ };
5682
+ let first = db
5683
+ .apply_atomic_transaction_content_addressed(
5684
+ "managed-idempotency-1",
5685
+ Some("sha256:request-a"),
5686
+ None,
5687
+ &[],
5688
+ &[mutation.clone()],
5689
+ None,
5690
+ )
5691
+ .expect("initial commit");
5692
+ let duplicate = db
5693
+ .apply_atomic_transaction_content_addressed(
5694
+ "managed-idempotency-1",
5695
+ Some("sha256:request-a"),
5696
+ None,
5697
+ &[],
5698
+ &[mutation.clone()],
5699
+ None,
5700
+ )
5701
+ .expect("same semantic request");
5702
+ assert!(duplicate.duplicate);
5703
+ assert_eq!(duplicate.base_revision, first.base_revision);
5704
+ assert_eq!(duplicate.commit_revision, first.commit_revision);
5705
+ assert_eq!(duplicate.state_before, first.state_before);
5706
+ assert_eq!(duplicate.state_after, first.state_after);
5707
+
5708
+ let versions = db.operation_versions().unwrap();
5709
+ db.acknowledge_peer_versions("managed-peer".into(), versions)
5710
+ .unwrap();
5711
+ db.compact_operation_log(&["managed-peer".into()]).unwrap();
5298
5712
  drop(db);
5713
+
5714
+ let reopened = open(&path).expect("restart");
5715
+ let replay = reopened
5716
+ .apply_atomic_transaction_content_addressed(
5717
+ "managed-idempotency-1",
5718
+ Some("sha256:request-a"),
5719
+ None,
5720
+ &[],
5721
+ &[mutation.clone()],
5722
+ None,
5723
+ )
5724
+ .expect("retry after restart");
5725
+ assert!(replay.duplicate);
5726
+ assert_eq!(replay.commit_revision, first.commit_revision);
5727
+ let conflict = reopened
5728
+ .apply_atomic_transaction_content_addressed(
5729
+ "managed-idempotency-1",
5730
+ Some("sha256:request-b"),
5731
+ None,
5732
+ &[],
5733
+ &[mutation],
5734
+ None,
5735
+ )
5736
+ .unwrap_err();
5737
+ assert!(conflict
5738
+ .to_string()
5739
+ .contains("TRANSACTION_ID_PAYLOAD_MISMATCH:"));
5740
+ let _ = fs::remove_file(sync_metadata_path(&path));
5299
5741
  let _ = fs::remove_file(path);
5300
5742
  }
5301
5743
 
5744
+ #[test]
5745
+ fn single_record_commits_are_canonical_history_positions() {
5746
+ let path = temp_file();
5747
+ let db = open(&path).unwrap();
5748
+ db.insert("orders:one", serde_json::json!({"status":"paid"}))
5749
+ .unwrap();
5750
+ let history = db.committed_transactions_after(0, 10).unwrap();
5751
+ assert_eq!(history.len(), 1);
5752
+ assert!(history[0].transaction_id.starts_with("transaction:"));
5753
+ assert!(history[0].operation_ids[0].starts_with("operation:"));
5754
+ assert_ne!(history[0].transaction_id, history[0].operation_ids[0]);
5755
+ assert_eq!(history[0].keys, vec!["orders:orders:one"]);
5756
+ drop(db);
5757
+ let reopened = open(&path).unwrap();
5758
+ assert_eq!(
5759
+ reopened.committed_transactions_after(0, 10).unwrap()[0].transaction_id,
5760
+ history[0].transaction_id
5761
+ );
5762
+ assert_eq!(
5763
+ reopened.committed_transactions_after(0, 10).unwrap()[0].operation_ids,
5764
+ history[0].operation_ids
5765
+ );
5766
+ let _ = fs::remove_file(path);
5767
+ }
5768
+
5769
+ #[test]
5770
+ fn compacted_legacy_attribution_gets_stable_distinct_operation_identity() {
5771
+ let legacy = TransactionAttribution {
5772
+ transaction_id: "legacy-transaction".into(),
5773
+ operation_ids: Vec::new(),
5774
+ subject: Some("agent-1".into()),
5775
+ tenant_id: Some("tenant-1".into()),
5776
+ application_id: Some("app-1".into()),
5777
+ application_revision: None,
5778
+ base_revision: 4,
5779
+ commit_revision: 5,
5780
+ state_before: 4,
5781
+ state_after: 5,
5782
+ keys: vec!["orders:one".into()],
5783
+ operations: 2,
5784
+ principal_id: None,
5785
+ agent_id: Some("agent-1".into()),
5786
+ session_id: None,
5787
+ delegator_id: None,
5788
+ work_id: None,
5789
+ unix_ms: 1,
5790
+ audit: None,
5791
+ };
5792
+ let first = normalize_transaction_attribution(legacy.clone());
5793
+ let second = normalize_transaction_attribution(legacy);
5794
+ assert_eq!(first, second);
5795
+ assert_eq!(first.transaction_id, "legacy-transaction");
5796
+ assert_eq!(first.operation_ids.len(), 2);
5797
+ assert!(first
5798
+ .operation_ids
5799
+ .iter()
5800
+ .all(|operation| operation.starts_with("operation:legacy-transaction:")));
5801
+
5802
+ let single = TransactionAttribution {
5803
+ transaction_id: "operation:authority:9".into(),
5804
+ operation_ids: Vec::new(),
5805
+ operations: 1,
5806
+ ..first
5807
+ };
5808
+ let upgraded = normalize_transaction_attribution(single);
5809
+ assert_eq!(upgraded.transaction_id, "transaction:authority:9");
5810
+ assert_eq!(upgraded.operation_ids, vec!["operation:authority:9"]);
5811
+ }
5812
+
5302
5813
  /// A bulk transaction names a bounded sample of keys and an exact count.
5303
5814
  #[test]
5304
5815
  fn a_bulk_transaction_truncates_its_key_list_and_keeps_the_count() {