@feltdb/core 0.9.1 → 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 (99) hide show
  1. package/dist/cli/commands.js +19 -21
  2. package/dist/cli/development-session.js +16 -0
  3. package/dist/cli/studio-artifact.js +33 -0
  4. package/dist/create/package-versions.js +1 -1
  5. package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +8 -1
  6. package/dist/create/server-source/crates/feltdb/src/application.rs +16 -0
  7. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +21 -22
  8. package/dist/create/server-source/crates/feltdb/src/lib.rs +451 -33
  9. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +48 -3
  10. package/dist/create/server-source/crates/feltdb/src/state_model.rs +569 -4
  11. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +1 -0
  12. package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +708 -0
  13. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  14. package/dist/create/server-source/crates/feltdb/src/workload.rs +480 -44
  15. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +123 -0
  16. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +281 -0
  17. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +15 -3
  18. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +28 -18
  19. package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +328 -0
  20. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +20 -3
  21. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -4
  22. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +0 -4
  23. package/dist/create/server-source/crates/feltdb-server/src/canonical_principals.rs +1093 -0
  24. package/dist/create/server-source/crates/feltdb-server/src/causal.rs +905 -144
  25. package/dist/create/server-source/crates/feltdb-server/src/connections.rs +9 -6
  26. package/dist/create/server-source/crates/feltdb-server/src/control_plane.rs +90 -0
  27. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  28. package/dist/create/server-source/crates/feltdb-server/src/main.rs +408 -74
  29. package/dist/create/server-source/crates/feltdb-server/src/principals.rs +9 -501
  30. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +22 -0
  31. package/dist/create/server-source/crates/feltdb-server/tests/principal_tenant_migration.rs +247 -0
  32. package/dist/db.d.ts +6 -5
  33. package/dist/db.d.ts.map +1 -1
  34. package/dist/db.js +18 -24
  35. package/dist/deployment.d.ts.map +1 -1
  36. package/dist/deployment.js +4 -2
  37. package/dist/embedded-transaction.d.ts.map +1 -1
  38. package/dist/embedded-transaction.js +4 -0
  39. package/dist/error-codes.d.ts +12 -0
  40. package/dist/error-codes.d.ts.map +1 -1
  41. package/dist/error-codes.js +20 -2
  42. package/dist/feltdb.d.ts +4 -0
  43. package/dist/feltdb.d.ts.map +1 -1
  44. package/dist/file-db.d.ts +10 -0
  45. package/dist/file-db.d.ts.map +1 -1
  46. package/dist/file-db.js +18 -0
  47. package/dist/http-db.d.ts +4 -0
  48. package/dist/http-db.d.ts.map +1 -1
  49. package/dist/http-db.js +73 -12
  50. package/dist/index-core.d.ts +1 -0
  51. package/dist/index-core.d.ts.map +1 -1
  52. package/dist/index-core.js +1 -0
  53. package/dist/public-contract.d.ts +112 -0
  54. package/dist/public-contract.d.ts.map +1 -0
  55. package/dist/public-contract.js +125 -0
  56. package/dist/studio/app.d.ts +23 -0
  57. package/dist/studio/app.d.ts.map +1 -1
  58. package/dist/studio/components/ContractInspector.d.ts +5 -0
  59. package/dist/studio/components/ContractInspector.d.ts.map +1 -1
  60. package/dist/studio/components/SettingsPanel.d.ts +4 -1
  61. package/dist/studio/components/SettingsPanel.d.ts.map +1 -1
  62. package/dist/studio/components/index.js +4 -4
  63. package/dist/studio/{components-CehkcX_k.js → components-CJFgdKG6.js} +322 -303
  64. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  65. package/dist/studio/control-plane/Instance.d.ts +10 -0
  66. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  67. package/dist/studio/control-plane/Operations.d.ts +3 -0
  68. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  69. package/dist/studio/control-plane/State.d.ts.map +1 -1
  70. package/dist/studio/control-plane/index.d.ts +4 -2
  71. package/dist/studio/control-plane/index.d.ts.map +1 -1
  72. package/dist/studio/control-plane/index.js +2 -2
  73. package/dist/studio/control-plane-DP8ljTDO.js +3207 -0
  74. package/dist/studio/index.js +96 -75
  75. package/dist/studio/utils/api.d.ts +1 -1
  76. package/dist/studio/utils/api.d.ts.map +1 -1
  77. package/dist/studio/utils/control-plane-api.d.ts +12 -1
  78. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  79. package/dist/studio/utils/control-plane-api.js +1 -1
  80. package/dist/studio/utils/index.js +16 -16
  81. package/dist/studio/utils/service-api.d.ts +8 -0
  82. package/dist/studio/utils/service-api.d.ts.map +1 -1
  83. package/dist/studio/utils/service-api.js +15 -12
  84. package/dist/studio-app/assets/{feltdb_wasm-DksXDnLa.js → feltdb_wasm-CfNb3IoF.js} +1 -1
  85. package/dist/studio-app/assets/feltdb_wasm_bg-D9d3gY-E.wasm +0 -0
  86. package/dist/studio-app/assets/index-SDXdROi1.js +30 -0
  87. package/dist/studio-app/feltdb-studio.json +6 -0
  88. package/dist/studio-app/index.html +1 -1
  89. package/dist/transaction.d.ts +9 -1
  90. package/dist/transaction.d.ts.map +1 -1
  91. package/dist/transaction.js +6 -2
  92. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  93. package/dist/workspace/local-development-authority.d.ts +8 -0
  94. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  95. package/dist/workspace/local-development-authority.js +136 -1
  96. package/package.json +2 -2
  97. package/dist/studio/control-plane-D6TT2fCQ.js +0 -3064
  98. package/dist/studio-app/assets/feltdb_wasm_bg-Yy4QRHJT.wasm +0 -0
  99. package/dist/studio-app/assets/index-CZ55nLgp.js +0 -30
@@ -1,14 +1,17 @@
1
+ use feltdb::{
2
+ workload::DURABLE_WORK_COLLECTION, AtomicMutation, AtomicPrecondition, FeltDb,
3
+ TransactionAttribution,
4
+ };
1
5
  use rand::{distributions::Alphanumeric, Rng};
2
6
  use serde::{Deserialize, Serialize};
3
- use serde_json::Value;
4
- use std::{
5
- collections::BTreeMap,
6
- fs,
7
- path::PathBuf,
8
- sync::{Arc, RwLock},
9
- time::{SystemTime, UNIX_EPOCH},
10
- };
11
- #[derive(Debug, Clone, Serialize, Deserialize)]
7
+ use serde_json::{json, Value};
8
+ use sha2::{Digest, Sha256};
9
+ use std::{collections::BTreeMap, fs, path::Path};
10
+
11
+ const CAUSAL_METADATA_COLLECTION: &str = "system_causal_metadata";
12
+ const MIGRATION_EVIDENCE_COLLECTION: &str = "system_migration_evidence";
13
+
14
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12
15
  pub struct CausalEvent {
13
16
  pub event_id: String,
14
17
  pub causal_id: String,
@@ -37,182 +40,940 @@ pub struct CausalEvent {
37
40
  pub metadata: BTreeMap<String, Value>,
38
41
  pub schema_version: u32,
39
42
  }
43
+
40
44
  #[derive(Debug, Clone, Serialize, Deserialize)]
41
45
  pub struct CausalContext {
42
46
  pub causal_id: String,
43
47
  pub parent_event_id: Option<String>,
44
48
  }
45
- #[derive(Debug, Default, Serialize, Deserialize)]
46
- struct Records {
49
+
50
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
51
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
52
+ pub enum CausalClassification {
53
+ Authoritative,
54
+ CausalMetadata,
55
+ ExecutionFact,
56
+ Telemetry,
57
+ NotNeeded,
58
+ }
59
+
60
+ #[derive(Debug, Default, Deserialize)]
61
+ struct LegacyRecords {
47
62
  events: Vec<CausalEvent>,
48
63
  }
64
+
65
+ /// Adapter over canonical FeltDB commits. It has no independent event
66
+ /// sequence or persistence: ordering, replay, and provenance come from the
67
+ /// canonical transaction log.
49
68
  #[derive(Clone)]
50
- pub struct CausalStore {
51
- path: PathBuf,
52
- records: Arc<RwLock<Records>>,
53
- }
54
- fn now() -> u64 {
55
- SystemTime::now()
56
- .duration_since(UNIX_EPOCH)
57
- .unwrap_or_default()
58
- .as_secs()
59
- }
60
- fn id(prefix: &str) -> String {
61
- format!(
62
- "{prefix}_{}",
63
- rand::thread_rng()
64
- .sample_iter(&Alphanumeric)
65
- .take(18)
66
- .map(char::from)
67
- .collect::<String>()
68
- )
69
+ pub struct CanonicalEventAuthority {
70
+ db: FeltDb,
69
71
  }
70
- impl CausalStore {
71
- pub fn load(path: impl Into<PathBuf>) -> Result<Self, String> {
72
- let path = path.into();
73
- let records = if path.exists() {
74
- serde_json::from_slice(&fs::read(&path).map_err(|e| e.to_string())?)
75
- .map_err(|e| e.to_string())?
76
- } else {
77
- Records::default()
78
- };
79
- Ok(Self {
80
- path,
81
- records: Arc::new(RwLock::new(records)),
82
- })
72
+
73
+ impl CanonicalEventAuthority {
74
+ pub fn new(db: FeltDb) -> Self {
75
+ Self { db }
83
76
  }
84
- fn persist(&self, r: &Records) -> Result<(), String> {
85
- if let Some(p) = self.path.parent() {
86
- fs::create_dir_all(p).map_err(|e| e.to_string())?
87
- }
88
- let tmp = self.path.with_extension("tmp");
89
- fs::write(
90
- &tmp,
91
- serde_json::to_vec_pretty(r).map_err(|e| e.to_string())?,
77
+
78
+ pub fn migrate_legacy_file(&self, path: &Path) -> Result<(), String> {
79
+ let legacy: LegacyRecords = serde_json::from_slice(
80
+ &fs::read(path).map_err(|error| error.to_string())?,
92
81
  )
93
- .map_err(|e| e.to_string())?;
94
- fs::rename(tmp, &self.path).map_err(|e| e.to_string())
82
+ .map_err(|error| error.to_string())?;
83
+ for event in legacy.events {
84
+ validate_scope(&event)?;
85
+ if event.event_id.is_empty() {
86
+ return Err("legacy causal record has no stable event identity".into());
87
+ }
88
+ let classification = self.classify(&event);
89
+ self.record_classification(&event, classification)?;
90
+ match classification {
91
+ CausalClassification::CausalMetadata => {
92
+ self.commit_metadata(event, classification, "causal_event_migration")?;
93
+ }
94
+ CausalClassification::Authoritative
95
+ | CausalClassification::ExecutionFact
96
+ | CausalClassification::Telemetry
97
+ | CausalClassification::NotNeeded => {}
98
+ }
99
+ }
100
+ Ok(())
95
101
  }
102
+
96
103
  pub fn append(&self, mut event: CausalEvent) -> Result<CausalEvent, String> {
104
+ validate_scope(&event)?;
105
+ let classification = self.classify(&event);
106
+ if matches!(classification, CausalClassification::Authoritative) {
107
+ return Err("authoritative events are projections of their existing canonical commit".into());
108
+ }
109
+ if matches!(classification, CausalClassification::ExecutionFact) {
110
+ return Err("execution facts must commit through canonical Durable Work".into());
111
+ }
112
+ if matches!(classification, CausalClassification::Telemetry | CausalClassification::NotNeeded) {
113
+ return Err("telemetry and non-semantic events are not durable application authority".into());
114
+ }
115
+ if event.event_id.is_empty() {
116
+ event.event_id = random_id("event");
117
+ }
97
118
  if event.causal_id.is_empty() {
98
- event.causal_id = id("causal")
119
+ event.causal_id = random_id("causal");
99
120
  }
100
- event.event_id = if event.event_id.is_empty() {
101
- id("event")
102
- } else {
103
- event.event_id
104
- };
105
- event.timestamp = if event.timestamp == 0 {
106
- now()
107
- } else {
108
- event.timestamp
109
- };
110
121
  event.schema_version = 1;
111
- let mut r = self
112
- .records
113
- .write()
114
- .map_err(|_| "causal store lock poisoned")?;
115
- if r.events.iter().any(|e| e.event_id == event.event_id) {
116
- return Ok(event);
117
- }
118
- r.events.push(event.clone());
119
- self.persist(&r)?;
122
+ self.commit_metadata(event.clone(), classification, "causal_metadata_recorded")?;
120
123
  Ok(event)
121
124
  }
122
- pub fn list(&self, causal_id: &str) -> Vec<CausalEvent> {
123
- self.records
124
- .read()
125
- .map(|r| {
126
- r.events
127
- .iter()
128
- .filter(|e| e.causal_id == causal_id)
129
- .cloned()
130
- .collect()
131
- })
132
- .unwrap_or_default()
133
- }
134
- pub fn workload(&self, id: &str) -> Vec<CausalEvent> {
135
- self.records
136
- .read()
137
- .map(|r| {
138
- r.events
139
- .iter()
140
- .filter(|e| e.workload_id.as_deref() == Some(id))
141
- .cloned()
142
- .collect()
125
+
126
+ pub fn all(&self) -> Result<Vec<CausalEvent>, String> {
127
+ Ok(self
128
+ .db
129
+ .committed_transactions_after(0, usize::MAX)
130
+ .map_err(|error| error.to_string())?
131
+ .into_iter()
132
+ .map(project_transaction)
133
+ .collect())
134
+ }
135
+
136
+ pub fn after(&self, revision: u64, limit: usize) -> Result<Vec<CausalEvent>, String> {
137
+ Ok(self
138
+ .db
139
+ .committed_transactions_after(revision, limit)
140
+ .map_err(|error| error.to_string())?
141
+ .into_iter()
142
+ .map(project_transaction)
143
+ .collect())
144
+ }
145
+
146
+ pub fn list(&self, causal_id: &str) -> Result<Vec<CausalEvent>, String> {
147
+ Ok(self
148
+ .all()?
149
+ .into_iter()
150
+ .filter(|event| event.causal_id == causal_id)
151
+ .collect())
152
+ }
153
+
154
+ pub fn workload(&self, id: &str) -> Result<Vec<CausalEvent>, String> {
155
+ Ok(self
156
+ .all()?
157
+ .into_iter()
158
+ .filter(|event| event.workload_id.as_deref() == Some(id))
159
+ .collect())
160
+ }
161
+
162
+ pub fn execution(&self, id: &str) -> Result<Vec<CausalEvent>, String> {
163
+ Ok(self
164
+ .all()?
165
+ .into_iter()
166
+ .filter(|event| event.execution_id.as_deref() == Some(id))
167
+ .collect())
168
+ }
169
+
170
+ pub fn revision(&self, id: &str) -> Result<Vec<CausalEvent>, String> {
171
+ Ok(self
172
+ .all()?
173
+ .into_iter()
174
+ .filter(|event| event.revision_id.as_deref() == Some(id))
175
+ .collect())
176
+ }
177
+
178
+ fn classify(&self, event: &CausalEvent) -> CausalClassification {
179
+ if event.revision_id.is_some()
180
+ || event.state_version.is_some()
181
+ || !event.operation_ids.is_empty()
182
+ {
183
+ CausalClassification::Authoritative
184
+ } else if event.workload_id.is_some()
185
+ || event.attempt_id.is_some()
186
+ || event.result_id.is_some()
187
+ {
188
+ CausalClassification::ExecutionFact
189
+ } else if event.worker_id.is_some()
190
+ || event.connection_id.is_some()
191
+ || event.execution_id.is_some()
192
+ {
193
+ CausalClassification::Telemetry
194
+ } else if event.parent_event_id.is_some() || !event.causal_id.is_empty() {
195
+ CausalClassification::CausalMetadata
196
+ } else {
197
+ CausalClassification::NotNeeded
198
+ }
199
+ }
200
+
201
+ fn commit_metadata(
202
+ &self,
203
+ event: CausalEvent,
204
+ classification: CausalClassification,
205
+ kind: &str,
206
+ ) -> Result<(), String> {
207
+ let key = format!(
208
+ "{}/{}/{}",
209
+ event.tenant_id, event.application_id, event.event_id
210
+ );
211
+ let source_digest = format!(
212
+ "sha256:{:x}",
213
+ Sha256::digest(
214
+ serde_json::to_vec(&event).map_err(|error| error.to_string())?
215
+ )
216
+ );
217
+ if let Some(existing) = self
218
+ .db
219
+ .list_collection(CAUSAL_METADATA_COLLECTION)
220
+ .map_err(|error| error.to_string())?
221
+ .into_iter()
222
+ .find(|row| row.key == key)
223
+ {
224
+ if existing.value.get("event")
225
+ == Some(&serde_json::to_value(&event).map_err(|error| error.to_string())?)
226
+ && existing.value.get("source_digest") == Some(&Value::String(source_digest.clone()))
227
+ {
228
+ return Ok(());
229
+ }
230
+ return Err(format!(
231
+ "causal metadata identity conflict: {}",
232
+ event.event_id
233
+ ));
234
+ }
235
+ self.db
236
+ .apply_atomic_transaction(
237
+ &format!("causal:{}", event.event_id),
238
+ None,
239
+ &[AtomicPrecondition {
240
+ capability: CAUSAL_METADATA_COLLECTION.into(),
241
+ key: key.clone(),
242
+ expected_version: None,
243
+ }],
244
+ &[AtomicMutation {
245
+ capability: CAUSAL_METADATA_COLLECTION.into(),
246
+ key,
247
+ value: Some(json!({
248
+ "classification": classification,
249
+ "event": event,
250
+ "source_digest": source_digest,
251
+ })),
252
+ }],
253
+ Some(json!({
254
+ "kind": kind,
255
+ "subject": event.subject_id,
256
+ "tenant": event.tenant_id,
257
+ "application": event.application_id,
258
+ "legacy_event": event,
259
+ "classification": classification,
260
+ "source_kind": "CausalStore",
261
+ "source_identity": event.event_id,
262
+ "source_digest": source_digest,
263
+ "migration_outcome": (kind == "causal_event_migration").then_some("imported"),
264
+ })),
265
+ )
266
+ .map_err(|error| error.to_string())?;
267
+ Ok(())
268
+ }
269
+
270
+ fn record_classification(
271
+ &self,
272
+ event: &CausalEvent,
273
+ classification: CausalClassification,
274
+ ) -> Result<(), String> {
275
+ validate_scope(event)?;
276
+ let mut resolved = match classification {
277
+ CausalClassification::Authoritative => self.resolve_authoritative(event)?,
278
+ CausalClassification::ExecutionFact => vec![self.resolve_execution_fact(event)?],
279
+ _ => Vec::new(),
280
+ };
281
+ if let Some(decision_id) = event.authorization_decision_id.as_deref() {
282
+ let transaction = self.resolve_audit_reference(
283
+ event,
284
+ "authorization_decision_id",
285
+ decision_id,
286
+ )?;
287
+ if !resolved.contains(&transaction) {
288
+ resolved.push(transaction);
289
+ }
290
+ }
291
+ let source_digest = format!(
292
+ "sha256:{:x}",
293
+ Sha256::digest(serde_json::to_vec(event).map_err(|error| error.to_string())?)
294
+ );
295
+ let key = format!("CausalStore/{}", event.event_id);
296
+ let evidence = json!({
297
+ "source_kind": "CausalStore",
298
+ "source_identity": event.event_id,
299
+ "tenant_id": event.tenant_id,
300
+ "application_id": event.application_id,
301
+ "source_digest": source_digest,
302
+ "classification": classification,
303
+ "canonical_transaction_ids": resolved,
304
+ "outcome": if matches!(classification, CausalClassification::Telemetry | CausalClassification::NotNeeded) { "classified_not_migrated" } else { "verified_or_imported" },
305
+ });
306
+ if let Some(existing) = self
307
+ .db
308
+ .list_collection(MIGRATION_EVIDENCE_COLLECTION)
309
+ .map_err(|error| error.to_string())?
310
+ .into_iter()
311
+ .find(|row| row.key == key)
312
+ {
313
+ let mut held = existing.value;
314
+ if let Some(object) = held.as_object_mut() {
315
+ object.remove("__version");
316
+ }
317
+ if held == evidence {
318
+ return Ok(());
319
+ }
320
+ return Err(format!("causal classification conflict: {}", event.event_id));
321
+ }
322
+ self.db
323
+ .apply_atomic_transaction(
324
+ &format!("causal_classification:{}", event.event_id),
325
+ None,
326
+ &[AtomicPrecondition {
327
+ capability: MIGRATION_EVIDENCE_COLLECTION.into(),
328
+ key: key.clone(),
329
+ expected_version: None,
330
+ }],
331
+ &[AtomicMutation {
332
+ capability: MIGRATION_EVIDENCE_COLLECTION.into(),
333
+ key,
334
+ value: Some(evidence),
335
+ }],
336
+ Some(json!({
337
+ "kind": "causal_classification",
338
+ "subject": event.subject_id,
339
+ "tenant": event.tenant_id,
340
+ "application": event.application_id,
341
+ "source_identity": event.event_id,
342
+ "source_digest": source_digest,
343
+ "classification": classification,
344
+ })),
345
+ )
346
+ .map_err(|error| error.to_string())?;
347
+ Ok(())
348
+ }
349
+
350
+ fn resolve_authoritative(&self, event: &CausalEvent) -> Result<Vec<String>, String> {
351
+ let mut references = event.operation_ids.clone();
352
+ references.extend(event.revision_id.clone());
353
+ references.extend(event.state_version.clone());
354
+ let history = self
355
+ .db
356
+ .committed_transactions_after(0, usize::MAX)
357
+ .map_err(|error| error.to_string())?;
358
+ let mut resolved = Vec::new();
359
+ for reference in references {
360
+ let transaction = history.iter().find(|transaction| {
361
+ transaction_scope_matches(transaction, event)
362
+ && (transaction.transaction_id == reference
363
+ || transaction.operation_ids.iter().any(|id| id == &reference)
364
+ || parse_revision_reference(&reference)
365
+ .is_some_and(|revision| transaction.commit_revision == revision))
366
+ });
367
+ let Some(transaction) = transaction else {
368
+ return Err(format!(
369
+ "authoritative causal reference is unresolved: {reference}"
370
+ ));
371
+ };
372
+ if !resolved.contains(&transaction.transaction_id) {
373
+ resolved.push(transaction.transaction_id.clone());
374
+ }
375
+ }
376
+ if resolved.is_empty() {
377
+ return Err("authoritative causal record has no canonical reference".into());
378
+ }
379
+ Ok(resolved)
380
+ }
381
+
382
+ fn resolve_execution_fact(&self, event: &CausalEvent) -> Result<String, String> {
383
+ let workload_id = event
384
+ .workload_id
385
+ .as_deref()
386
+ .ok_or("execution fact has no workload identity")?;
387
+ let workload = self
388
+ .db
389
+ .list_collection(DURABLE_WORK_COLLECTION)
390
+ .map_err(|error| error.to_string())?
391
+ .into_iter()
392
+ .find(|row| {
393
+ row.value.get("workload_id").and_then(Value::as_str) == Some(workload_id)
394
+ && row.value.get("tenant_id").and_then(Value::as_str)
395
+ == Some(event.tenant_id.as_str())
396
+ && row.value.get("application_id").and_then(Value::as_str)
397
+ == Some(event.application_id.as_str())
143
398
  })
144
- .unwrap_or_default()
145
- }
146
- pub fn execution(&self, id: &str) -> Vec<CausalEvent> {
147
- self.records
148
- .read()
149
- .map(|r| {
150
- r.events
151
- .iter()
152
- .filter(|e| e.execution_id.as_deref() == Some(id))
153
- .cloned()
154
- .collect()
399
+ .ok_or_else(|| format!("execution fact workload is unresolved: {workload_id}"))?;
400
+ if let Some(attempt_id) = event.attempt_id.as_deref() {
401
+ let found = workload
402
+ .value
403
+ .get("attempts")
404
+ .and_then(Value::as_array)
405
+ .is_some_and(|attempts| {
406
+ attempts.iter().any(|attempt| {
407
+ attempt.get("attempt_id").and_then(Value::as_str) == Some(attempt_id)
408
+ })
409
+ });
410
+ if !found {
411
+ return Err(format!("execution fact attempt is unresolved: {attempt_id}"));
412
+ }
413
+ }
414
+ if let Some(result_id) = event.result_id.as_deref() {
415
+ if workload.value.pointer("/result/result_id").and_then(Value::as_str)
416
+ != Some(result_id)
417
+ {
418
+ return Err(format!("execution fact result is unresolved: {result_id}"));
419
+ }
420
+ }
421
+ self.db
422
+ .committed_transactions_after(0, usize::MAX)
423
+ .map_err(|error| error.to_string())?
424
+ .into_iter()
425
+ .rev()
426
+ .find(|transaction| {
427
+ transaction.work_id.as_deref() == Some(workload_id)
428
+ && transaction_scope_matches(transaction, event)
155
429
  })
156
- .unwrap_or_default()
157
- }
158
- pub fn revision(&self, id: &str) -> Vec<CausalEvent> {
159
- self.records
160
- .read()
161
- .map(|r| {
162
- r.events
163
- .iter()
164
- .filter(|e| e.revision_id.as_deref() == Some(id))
165
- .cloned()
166
- .collect()
430
+ .map(|transaction| transaction.transaction_id)
431
+ .ok_or_else(|| format!("execution fact has no canonical transaction: {workload_id}"))
432
+ }
433
+
434
+ fn resolve_audit_reference(
435
+ &self,
436
+ event: &CausalEvent,
437
+ field: &str,
438
+ reference: &str,
439
+ ) -> Result<String, String> {
440
+ self.db
441
+ .committed_transactions_after(0, usize::MAX)
442
+ .map_err(|error| error.to_string())?
443
+ .into_iter()
444
+ .find(|transaction| {
445
+ transaction_scope_matches(transaction, event)
446
+ && transaction
447
+ .audit
448
+ .as_ref()
449
+ .and_then(|audit| audit.get(field))
450
+ .and_then(Value::as_str)
451
+ == Some(reference)
167
452
  })
168
- .unwrap_or_default()
453
+ .map(|transaction| transaction.transaction_id)
454
+ .ok_or_else(|| format!("causal {field} reference is unresolved: {reference}"))
169
455
  }
170
- pub fn all(&self) -> Vec<CausalEvent> {
171
- self.records
172
- .read()
173
- .map(|r| r.events.clone())
174
- .unwrap_or_default()
456
+ }
457
+
458
+ fn validate_scope(event: &CausalEvent) -> Result<(), String> {
459
+ if event.tenant_id.trim().is_empty() || event.application_id.trim().is_empty() {
460
+ return Err("causal record requires tenant and application scope".into());
175
461
  }
462
+ Ok(())
463
+ }
464
+
465
+ fn transaction_scope_matches(
466
+ transaction: &TransactionAttribution,
467
+ event: &CausalEvent,
468
+ ) -> bool {
469
+ transaction.tenant_id.as_deref() == Some(event.tenant_id.as_str())
470
+ && transaction.application_id.as_deref() == Some(event.application_id.as_str())
471
+ }
472
+
473
+ pub fn encode_cursor(revision: u64) -> String {
474
+ format!("fh1-{revision:016x}")
176
475
  }
476
+
477
+ pub fn decode_cursor(cursor: &str) -> Result<u64, String> {
478
+ let value = cursor
479
+ .strip_prefix("fh1-")
480
+ .ok_or("invalid event cursor")?;
481
+ u64::from_str_radix(value, 16).map_err(|_| "invalid event cursor".into())
482
+ }
483
+
484
+ /// Public cursor bound to one durable authority and application scope.
485
+ /// Consumers treat the complete value as opaque.
486
+ pub fn encode_scoped_cursor(scope: &str, revision: u64) -> String {
487
+ let digest = format!("{:x}", Sha256::digest(scope.as_bytes()));
488
+ format!("fh1-{}-{revision:016x}", &digest[..16])
489
+ }
490
+
491
+ pub fn decode_scoped_cursor(scope: &str, cursor: &str) -> Result<u64, String> {
492
+ let expected_prefix = encode_scoped_cursor(scope, 0)
493
+ .rsplit_once('-')
494
+ .map(|(prefix, _)| format!("{prefix}-"))
495
+ .expect("scoped cursor has revision suffix");
496
+ let revision = cursor
497
+ .strip_prefix(&expected_prefix)
498
+ .ok_or_else(|| {
499
+ if cursor.starts_with("fh1-") {
500
+ "CURSOR_SCOPE_VIOLATION".to_string()
501
+ } else {
502
+ "INVALID_CURSOR".to_string()
503
+ }
504
+ })?;
505
+ if revision.len() != 16 {
506
+ return Err("INVALID_CURSOR".into());
507
+ }
508
+ u64::from_str_radix(revision, 16).map_err(|_| "INVALID_CURSOR".into())
509
+ }
510
+
511
+ fn parse_revision_reference(reference: &str) -> Option<u64> {
512
+ reference
513
+ .strip_prefix("revision-")
514
+ .or_else(|| reference.strip_prefix("commit-"))
515
+ .unwrap_or(reference)
516
+ .parse()
517
+ .ok()
518
+ .or_else(|| decode_cursor(reference).ok())
519
+ }
520
+
521
+ fn project_transaction(transaction: TransactionAttribution) -> CausalEvent {
522
+ if let Some(mut event) = transaction
523
+ .audit
524
+ .as_ref()
525
+ .and_then(|audit| audit.get("legacy_event"))
526
+ .and_then(|event| serde_json::from_value::<CausalEvent>(event.clone()).ok())
527
+ {
528
+ event.state_version = Some(transaction.commit_revision.to_string());
529
+ event.revision_id = Some(format!("revision-{}", transaction.commit_revision));
530
+ event.operation_ids = transaction.operation_ids.clone();
531
+ event
532
+ .metadata
533
+ .extend(event_metadata(&transaction, transaction.commit_revision));
534
+ return event;
535
+ }
536
+ let audit = transaction.audit.as_ref();
537
+ let text = |field: &str| {
538
+ audit
539
+ .and_then(|value| value.get(field))
540
+ .and_then(Value::as_str)
541
+ .map(str::to_string)
542
+ };
543
+ let revision = transaction.commit_revision;
544
+ CausalEvent {
545
+ event_id: format!("commit-{revision}"),
546
+ causal_id: text("causal_id").unwrap_or_else(|| transaction.transaction_id.clone()),
547
+ parent_event_id: text("parent_event_id"),
548
+ timestamp: (transaction.unix_ms / 1000) as u64,
549
+ tenant_id: transaction.tenant_id.clone().unwrap_or_default(),
550
+ application_id: transaction.application_id.clone().unwrap_or_default(),
551
+ revision_id: Some(format!("revision-{revision}")),
552
+ subject_id: transaction.subject.clone().unwrap_or_default(),
553
+ subject_type: text("subject_type").unwrap_or_else(|| "principal".into()),
554
+ authorization_decision_id: text("authorization_decision_id"),
555
+ capability: text("capability"),
556
+ resource: text("resource"),
557
+ operation: text("kind").unwrap_or_else(|| "state.committed".into()),
558
+ workload_id: text("work_id").or_else(|| text("workload_id")),
559
+ attempt_id: text("attempt_id"),
560
+ worker_id: text("worker_id"),
561
+ connection_id: text("connection_id"),
562
+ execution_id: text("execution_id"),
563
+ state_version: Some(revision.to_string()),
564
+ operation_ids: transaction.operation_ids.clone(),
565
+ artifact_ids: Vec::new(),
566
+ result_id: text("result_id"),
567
+ status: "committed".into(),
568
+ error_code: None,
569
+ metadata: event_metadata(&transaction, revision),
570
+ schema_version: 1,
571
+ }
572
+ }
573
+
574
+ fn event_metadata(transaction: &TransactionAttribution, revision: u64) -> BTreeMap<String, Value> {
575
+ // Managed application transaction ids are scoped internally so two
576
+ // applications cannot collide in one substrate log. The caller's stable
577
+ // transaction identity remains in the durable audit and is the public
578
+ // identity used by transaction responses, provenance lookup, and events.
579
+ let public_transaction_id = transaction
580
+ .audit
581
+ .as_ref()
582
+ .and_then(|audit| audit.get("transaction_id"))
583
+ .and_then(Value::as_str)
584
+ .unwrap_or(&transaction.transaction_id);
585
+ let mut metadata = BTreeMap::from([
586
+ ("cursor".into(), Value::String(encode_cursor(revision))),
587
+ (
588
+ "transaction_id".into(),
589
+ Value::String(public_transaction_id.to_string()),
590
+ ),
591
+ (
592
+ "revision_id".into(),
593
+ Value::String(format!("revision-{revision}")),
594
+ ),
595
+ ]);
596
+ for (name, value) in [
597
+ ("principal_id", transaction.principal_id.as_ref()),
598
+ ("agent_id", transaction.agent_id.as_ref()),
599
+ ("session_id", transaction.session_id.as_ref()),
600
+ ("delegator_id", transaction.delegator_id.as_ref()),
601
+ ("work_id", transaction.work_id.as_ref()),
602
+ ] {
603
+ if let Some(value) = value {
604
+ metadata.insert(name.into(), Value::String(value.clone()));
605
+ }
606
+ }
607
+ metadata
608
+ }
609
+
610
+ fn random_id(prefix: &str) -> String {
611
+ format!(
612
+ "{prefix}_{}",
613
+ rand::thread_rng()
614
+ .sample_iter(&Alphanumeric)
615
+ .take(18)
616
+ .map(char::from)
617
+ .collect::<String>()
618
+ )
619
+ }
620
+
177
621
  #[cfg(test)]
178
622
  mod tests {
179
623
  use super::*;
180
- #[test]
181
- fn causal_chain_is_durable_idempotent_and_queryable() {
182
- let p = std::env::temp_dir().join(format!("causal-{}", now()));
183
- let s = CausalStore::load(&p).unwrap();
184
- let e = CausalEvent {
185
- event_id: "e1".into(),
186
- causal_id: "c1".into(),
624
+ use std::time::{SystemTime, UNIX_EPOCH};
625
+
626
+ fn event(id: &str) -> CausalEvent {
627
+ CausalEvent {
628
+ event_id: id.into(),
629
+ causal_id: String::new(),
187
630
  parent_event_id: None,
188
- timestamp: 0,
631
+ timestamp: 1,
189
632
  tenant_id: "t".into(),
190
- application_id: "a".into(),
191
- revision_id: Some("r".into()),
192
- subject_id: "u".into(),
193
- subject_type: "human".into(),
633
+ application_id: "app".into(),
634
+ revision_id: None,
635
+ subject_id: "agent-1".into(),
636
+ subject_type: "agent".into(),
194
637
  authorization_decision_id: None,
195
638
  capability: None,
196
639
  resource: None,
197
640
  operation: "test".into(),
198
- workload_id: Some("w".into()),
641
+ workload_id: None,
642
+ attempt_id: None,
643
+ worker_id: None,
644
+ connection_id: None,
645
+ execution_id: None,
646
+ state_version: None,
647
+ operation_ids: Vec::new(),
648
+ artifact_ids: Vec::new(),
649
+ result_id: None,
650
+ status: "committed".into(),
651
+ error_code: None,
652
+ metadata: BTreeMap::new(),
653
+ schema_version: 1,
654
+ }
655
+ }
656
+
657
+ #[test]
658
+ fn cursor_is_canonical_and_restartable() {
659
+ let cursor = encode_cursor(42);
660
+ assert_eq!(decode_cursor(&cursor).unwrap(), 42);
661
+ assert!(decode_cursor("42").is_err());
662
+ let scoped = encode_scoped_cursor("authority-1:app-1", 42);
663
+ assert_eq!(
664
+ decode_scoped_cursor("authority-1:app-1", &scoped).unwrap(),
665
+ 42
666
+ );
667
+ assert_eq!(
668
+ decode_scoped_cursor("authority-1:app-2", &scoped).unwrap_err(),
669
+ "CURSOR_SCOPE_VIOLATION"
670
+ );
671
+ }
672
+
673
+ #[test]
674
+ fn canonical_projection_survives_source_deletion_and_restart() {
675
+ let nonce = SystemTime::now()
676
+ .duration_since(UNIX_EPOCH)
677
+ .unwrap()
678
+ .as_nanos();
679
+ let root = std::env::temp_dir().join(format!("feltdb-causal-authority-{nonce}"));
680
+ fs::create_dir_all(&root).unwrap();
681
+ let data = root.join("state.log");
682
+ let legacy = root.join("causal-events.json");
683
+ let db = FeltDb::open(&data).unwrap();
684
+ db.apply_atomic_transaction(
685
+ "source-tx",
686
+ None,
687
+ &[],
688
+ &[AtomicMutation {
689
+ capability: "orders".into(),
690
+ key: "one".into(),
691
+ value: Some(json!({"status": "paid"})),
692
+ }],
693
+ Some(json!({"subject":"agent-1","tenant":"t","application":"app"})),
694
+ )
695
+ .unwrap();
696
+ let event = CausalEvent {
697
+ event_id: "legacy-event".into(),
698
+ causal_id: "cause-1".into(),
699
+ parent_event_id: None,
700
+ timestamp: 1,
701
+ tenant_id: "t".into(),
702
+ application_id: "app".into(),
703
+ revision_id: None,
704
+ subject_id: "agent-1".into(),
705
+ subject_type: "agent".into(),
706
+ authorization_decision_id: None,
707
+ capability: None,
708
+ resource: None,
709
+ operation: "order.paid".into(),
710
+ workload_id: None,
199
711
  attempt_id: None,
200
712
  worker_id: None,
201
713
  connection_id: None,
202
714
  execution_id: None,
203
715
  state_version: None,
204
- operation_ids: vec![],
205
- artifact_ids: vec![],
716
+ operation_ids: Vec::new(),
717
+ artifact_ids: Vec::new(),
206
718
  result_id: None,
207
- status: "succeeded".into(),
719
+ status: "committed".into(),
208
720
  error_code: None,
209
721
  metadata: BTreeMap::new(),
210
- schema_version: 0,
722
+ schema_version: 1,
211
723
  };
212
- s.append(e.clone()).unwrap();
213
- s.append(e).unwrap();
214
- assert_eq!(s.list("c1").len(), 1);
215
- assert_eq!(s.workload("w").len(), 1);
216
- let _ = fs::remove_file(p);
724
+ fs::write(&legacy, serde_json::to_vec(&json!({"events":[event]})).unwrap()).unwrap();
725
+ let authority = CanonicalEventAuthority::new(db);
726
+ authority.migrate_legacy_file(&legacy).unwrap();
727
+ authority.migrate_legacy_file(&legacy).unwrap();
728
+ fs::remove_file(&legacy).unwrap();
729
+ drop(authority);
730
+
731
+ let recovered = CanonicalEventAuthority::new(FeltDb::open(&data).unwrap());
732
+ let chain = recovered.list("cause-1").unwrap();
733
+ assert_eq!(chain.len(), 1);
734
+ assert_eq!(chain[0].event_id, "legacy-event");
735
+ assert!(!legacy.exists());
736
+ let _ = fs::remove_dir_all(root);
737
+ }
738
+
739
+ #[test]
740
+ fn every_legacy_class_is_evidenced_without_copying_execution_facts() {
741
+ let nonce = SystemTime::now()
742
+ .duration_since(UNIX_EPOCH)
743
+ .unwrap()
744
+ .as_nanos();
745
+ let root = std::env::temp_dir().join(format!("feltdb-causal-classes-{nonce}"));
746
+ fs::create_dir_all(&root).unwrap();
747
+ let data = root.join("state.log");
748
+ let legacy = root.join("causal-events.json");
749
+ let db = FeltDb::open(&data).unwrap();
750
+ db.apply_atomic_transaction(
751
+ "source-tx",
752
+ None,
753
+ &[],
754
+ &[AtomicMutation {
755
+ capability: "orders".into(),
756
+ key: "one".into(),
757
+ value: Some(json!({"status":"paid"})),
758
+ }],
759
+ Some(json!({"subject":"agent-1","tenant":"t","application":"app"})),
760
+ )
761
+ .unwrap();
762
+ db.apply_atomic_transaction(
763
+ "work-tx",
764
+ None,
765
+ &[],
766
+ &[AtomicMutation {
767
+ capability: DURABLE_WORK_COLLECTION.into(),
768
+ key: "t/app/work-1".into(),
769
+ value: Some(json!({
770
+ "workload_id":"work-1",
771
+ "tenant_id":"t",
772
+ "application_id":"app",
773
+ "attempts":[{"attempt_id":"attempt-1"}],
774
+ "result":{"result_id":"result-1"}
775
+ })),
776
+ }],
777
+ Some(json!({
778
+ "subject":"agent-1", "tenant":"t", "application":"app",
779
+ "work_id":"work-1", "agent_id":"agent-1", "session_id":"session-1",
780
+ "delegator_id":"human-1"
781
+ })),
782
+ )
783
+ .unwrap();
784
+
785
+ let source = db.transaction_attribution("source-tx").unwrap().unwrap();
786
+ let mut authoritative = event("authoritative");
787
+ authoritative.operation_ids = vec![source.operation_ids[0].clone()];
788
+ let mut execution = event("execution");
789
+ execution.workload_id = Some("work-1".into());
790
+ execution.attempt_id = Some("attempt-1".into());
791
+ execution.result_id = Some("result-1".into());
792
+ let mut telemetry = event("telemetry");
793
+ telemetry.causal_id = "telemetry-correlation".into();
794
+ telemetry.execution_id = Some("runtime-only".into());
795
+ let mut metadata = event("metadata");
796
+ metadata.causal_id = "cause-1".into();
797
+ let not_needed = event("not-needed");
798
+ fs::write(
799
+ &legacy,
800
+ serde_json::to_vec(&json!({
801
+ "events":[authoritative, execution, telemetry, metadata, not_needed]
802
+ }))
803
+ .unwrap(),
804
+ )
805
+ .unwrap();
806
+
807
+ let authority = CanonicalEventAuthority::new(db.clone());
808
+ authority.migrate_legacy_file(&legacy).unwrap();
809
+ let evidence = db.list_collection(MIGRATION_EVIDENCE_COLLECTION).unwrap();
810
+ assert_eq!(evidence.len(), 5);
811
+ let classes = evidence
812
+ .iter()
813
+ .map(|row| row.value["classification"].as_str().unwrap())
814
+ .collect::<std::collections::BTreeSet<_>>();
815
+ assert_eq!(
816
+ classes,
817
+ [
818
+ "AUTHORITATIVE",
819
+ "CAUSAL_METADATA",
820
+ "EXECUTION_FACT",
821
+ "NOT_NEEDED",
822
+ "TELEMETRY",
823
+ ]
824
+ .into()
825
+ );
826
+ let metadata_rows = db.list_collection(CAUSAL_METADATA_COLLECTION).unwrap();
827
+ assert_eq!(metadata_rows.len(), 1);
828
+ assert_eq!(metadata_rows[0].value["event"]["event_id"], "metadata");
829
+ assert!(!metadata_rows.iter().any(|row| {
830
+ row.value["event"]["event_id"] == Value::String("execution".into())
831
+ }));
832
+ drop((authority, db));
833
+ let _ = fs::remove_dir_all(root);
834
+ }
835
+
836
+ #[test]
837
+ fn unresolved_authority_and_execution_references_fail_closed() {
838
+ let root = std::env::temp_dir().join(format!(
839
+ "feltdb-causal-unresolved-{}",
840
+ std::process::id()
841
+ ));
842
+ fs::create_dir_all(&root).unwrap();
843
+ let authority = CanonicalEventAuthority::new(FeltDb::open(root.join("state.log")).unwrap());
844
+ let mut authoritative = event("bad-authority");
845
+ authoritative.operation_ids = vec!["missing-operation".into()];
846
+ assert!(authority
847
+ .record_classification(&authoritative, CausalClassification::Authoritative)
848
+ .unwrap_err()
849
+ .contains("unresolved"));
850
+ let mut execution = event("bad-execution");
851
+ execution.workload_id = Some("missing-work".into());
852
+ assert!(authority
853
+ .record_classification(&execution, CausalClassification::ExecutionFact)
854
+ .unwrap_err()
855
+ .contains("unresolved"));
856
+ assert!(authority.append(execution).unwrap_err().contains("Durable Work"));
857
+ let _ = fs::remove_dir_all(root);
858
+ }
859
+
860
+ #[test]
861
+ fn revision_and_decision_resolution_is_tenant_application_scoped() {
862
+ let root = std::env::temp_dir().join(format!(
863
+ "feltdb-causal-scope-{}",
864
+ std::process::id()
865
+ ));
866
+ fs::create_dir_all(&root).unwrap();
867
+ let db = FeltDb::open(root.join("state.log")).unwrap();
868
+ db.apply_atomic_transaction(
869
+ "scoped-transaction",
870
+ None,
871
+ &[],
872
+ &[AtomicMutation {
873
+ capability: "orders".into(),
874
+ key: "one".into(),
875
+ value: Some(json!({"status":"paid"})),
876
+ }],
877
+ Some(json!({
878
+ "subject":"agent-1", "tenant":"t", "application":"app",
879
+ "authorization_decision_id":"decision-1"
880
+ })),
881
+ )
882
+ .unwrap();
883
+ let authority = CanonicalEventAuthority::new(db);
884
+ let mut scoped = event("scoped");
885
+ scoped.state_version = Some("1".into());
886
+ scoped.authorization_decision_id = Some("decision-1".into());
887
+ authority
888
+ .record_classification(&scoped, CausalClassification::Authoritative)
889
+ .unwrap();
890
+
891
+ let mut cross_scope = scoped;
892
+ cross_scope.event_id = "cross-scope".into();
893
+ cross_scope.application_id = "other-app".into();
894
+ assert!(authority
895
+ .record_classification(&cross_scope, CausalClassification::Authoritative)
896
+ .unwrap_err()
897
+ .contains("unresolved"));
898
+ let _ = fs::remove_dir_all(root);
899
+ }
900
+
901
+ #[test]
902
+ fn projected_identity_and_provenance_are_distinct_and_restartable() {
903
+ let root = std::env::temp_dir().join(format!(
904
+ "feltdb-event-provenance-{}",
905
+ std::process::id()
906
+ ));
907
+ fs::create_dir_all(&root).unwrap();
908
+ let data = root.join("state.log");
909
+ let db = FeltDb::open(&data).unwrap();
910
+ db.apply_atomic_transaction(
911
+ "transaction-1",
912
+ None,
913
+ &[],
914
+ &[AtomicMutation {
915
+ capability: "orders".into(),
916
+ key: "one".into(),
917
+ value: Some(json!({"status":"paid"})),
918
+ }],
919
+ Some(json!({
920
+ "subject":"agent-1", "tenant":"t", "application":"app",
921
+ "principal_id":"agent-1", "agent_id":"agent-1",
922
+ "session_id":"session-1", "delegator_id":"human-1", "work_id":"work-1"
923
+ })),
924
+ )
925
+ .unwrap();
926
+ drop(db);
927
+
928
+ let recovered = CanonicalEventAuthority::new(FeltDb::open(&data).unwrap());
929
+ let projected = recovered.after(0, 10).unwrap().remove(0);
930
+ assert_eq!(projected.event_id, "commit-1");
931
+ assert_eq!(projected.revision_id.as_deref(), Some("revision-1"));
932
+ assert_eq!(projected.metadata["transaction_id"], "transaction-1");
933
+ assert_ne!(projected.operation_ids[0], "transaction-1");
934
+ assert_eq!(projected.metadata["session_id"], "session-1");
935
+ assert_eq!(projected.metadata["agent_id"], "agent-1");
936
+ assert_eq!(projected.metadata["delegator_id"], "human-1");
937
+ assert_eq!(projected.metadata["work_id"], "work-1");
938
+ assert_eq!(projected.metadata["cursor"], encode_cursor(1));
939
+ let _ = fs::remove_dir_all(root);
940
+ }
941
+
942
+ #[test]
943
+ fn projected_events_expose_the_public_transaction_identity() {
944
+ let root = std::env::temp_dir().join(format!(
945
+ "feltdb-event-public-transaction-{}",
946
+ std::process::id()
947
+ ));
948
+ fs::create_dir_all(&root).unwrap();
949
+ let db = FeltDb::open(root.join("state.log")).unwrap();
950
+ db.apply_atomic_transaction(
951
+ "txscope_private_public-transaction",
952
+ None,
953
+ &[],
954
+ &[AtomicMutation {
955
+ capability: "records".into(),
956
+ key: "one".into(),
957
+ value: Some(json!({"id":"one"})),
958
+ }],
959
+ Some(json!({
960
+ "transaction_id":"public-transaction",
961
+ "subject":"service-1",
962
+ "tenant":"tenant-1",
963
+ "application":"app-1"
964
+ })),
965
+ )
966
+ .unwrap();
967
+
968
+ let event = CanonicalEventAuthority::new(db)
969
+ .after(0, 1)
970
+ .unwrap()
971
+ .remove(0);
972
+ assert_eq!(event.metadata["transaction_id"], "public-transaction");
973
+ assert_ne!(
974
+ event.metadata["transaction_id"],
975
+ "txscope_private_public-transaction"
976
+ );
977
+ let _ = fs::remove_dir_all(root);
217
978
  }
218
979
  }