@feltdb/core 0.8.8 → 0.9.1

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 (73) hide show
  1. package/dist/authority-scope.d.ts +16 -0
  2. package/dist/authority-scope.d.ts.map +1 -0
  3. package/dist/authority-scope.js +39 -0
  4. package/dist/create/package-versions.js +1 -1
  5. package/dist/create/server-source/crates/feltdb/src/lib.rs +343 -1
  6. package/dist/create/server-source/crates/feltdb/src/state_model.rs +15 -2
  7. package/dist/create/server-source/crates/feltdb-server/src/control_plane.rs +845 -0
  8. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  9. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1381 -5
  10. package/dist/create/server-source/crates/feltdb-server/src/principals.rs +37 -0
  11. package/dist/db.d.ts +16 -1
  12. package/dist/db.d.ts.map +1 -1
  13. package/dist/db.js +76 -4
  14. package/dist/deployment.d.ts +2 -0
  15. package/dist/deployment.d.ts.map +1 -1
  16. package/dist/error-codes.d.ts +7 -1
  17. package/dist/error-codes.d.ts.map +1 -1
  18. package/dist/error-codes.js +20 -4
  19. package/dist/file-db.d.ts.map +1 -1
  20. package/dist/file-db.js +5 -1
  21. package/dist/http-db.d.ts +3 -0
  22. package/dist/http-db.d.ts.map +1 -1
  23. package/dist/http-db.js +10 -3
  24. package/dist/index-core.d.ts +1 -0
  25. package/dist/index-core.d.ts.map +1 -1
  26. package/dist/index-core.js +1 -0
  27. package/dist/operation-admission.d.ts +7 -0
  28. package/dist/operation-admission.d.ts.map +1 -1
  29. package/dist/operation-admission.js +0 -7
  30. package/dist/studio/app.d.ts.map +1 -1
  31. package/dist/studio/components/index.js +2 -2
  32. package/dist/studio/{components-Duq2xQWt.js → components-CehkcX_k.js} +2355 -2355
  33. package/dist/studio/control-plane/Agents.d.ts +6 -0
  34. package/dist/studio/control-plane/Agents.d.ts.map +1 -0
  35. package/dist/studio/control-plane/Data.d.ts +10 -0
  36. package/dist/studio/control-plane/Data.d.ts.map +1 -0
  37. package/dist/studio/control-plane/Instance.d.ts +7 -0
  38. package/dist/studio/control-plane/Instance.d.ts.map +1 -0
  39. package/dist/studio/control-plane/Operations.d.ts +6 -0
  40. package/dist/studio/control-plane/Operations.d.ts.map +1 -0
  41. package/dist/studio/control-plane/Overview.d.ts +4 -0
  42. package/dist/studio/control-plane/Overview.d.ts.map +1 -0
  43. package/dist/studio/control-plane/Schema.d.ts +4 -0
  44. package/dist/studio/control-plane/Schema.d.ts.map +1 -0
  45. package/dist/studio/control-plane/State.d.ts +4 -0
  46. package/dist/studio/control-plane/State.d.ts.map +1 -0
  47. package/dist/studio/control-plane/context.d.ts +42 -0
  48. package/dist/studio/control-plane/context.d.ts.map +1 -0
  49. package/dist/studio/control-plane/index.d.ts +18 -0
  50. package/dist/studio/control-plane/index.d.ts.map +1 -0
  51. package/dist/studio/control-plane/index.js +2 -0
  52. package/dist/studio/control-plane/primitives.d.ts +70 -0
  53. package/dist/studio/control-plane/primitives.d.ts.map +1 -0
  54. package/dist/studio/control-plane-D6TT2fCQ.js +3064 -0
  55. package/dist/studio/index.d.ts +2 -0
  56. package/dist/studio/index.d.ts.map +1 -1
  57. package/dist/studio/index.js +326 -323
  58. package/dist/studio/studio.css +1 -1
  59. package/dist/studio/utils/control-plane-api.d.ts +442 -0
  60. package/dist/studio/utils/control-plane-api.d.ts.map +1 -0
  61. package/dist/studio/utils/control-plane-api.js +204 -0
  62. package/dist/studio/utils/index.d.ts +1 -0
  63. package/dist/studio/utils/index.d.ts.map +1 -1
  64. package/dist/studio/utils/index.js +13 -12
  65. package/dist/studio-app/assets/{feltdb_wasm-C1VhI-U5.js → feltdb_wasm-DksXDnLa.js} +1 -1
  66. package/dist/studio-app/assets/feltdb_wasm_bg-Yy4QRHJT.wasm +0 -0
  67. package/dist/studio-app/assets/{index-Cd_NPw14.css → index-BrsF-kWL.css} +1 -1
  68. package/dist/studio-app/assets/index-CZ55nLgp.js +30 -0
  69. package/dist/studio-app/index.html +2 -2
  70. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  71. package/package.json +1 -1
  72. package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
  73. package/dist/studio-app/assets/index-ByHX4xDq.js +0 -29
@@ -0,0 +1,16 @@
1
+ export interface TenantAuthorityScope {
2
+ kind: 'tenant';
3
+ tenantId: string;
4
+ environmentId?: string;
5
+ }
6
+ export interface PlatformAuthorityScope {
7
+ kind: 'platform';
8
+ /** Tenants this platform session may explicitly select. Empty means none. */
9
+ tenantIds?: readonly string[];
10
+ }
11
+ export type AuthorityScope = TenantAuthorityScope | PlatformAuthorityScope;
12
+ export declare function normalizeAuthorityScope(scope: AuthorityScope | undefined): AuthorityScope;
13
+ export declare function authorityScopeKey(scope: AuthorityScope): string;
14
+ export declare function sameAuthorityScope(left: AuthorityScope, right: AuthorityScope): boolean;
15
+ export declare function assertAuthorityScope(expected: AuthorityScope, actual: AuthorityScope): void;
16
+ //# sourceMappingURL=authority-scope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authority-scope.d.ts","sourceRoot":"","sources":["../src/authority-scope.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,QAAQ,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,UAAU,CAAC;IACjB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B;AAED,MAAM,MAAM,cAAc,GAAG,oBAAoB,GAAG,sBAAsB,CAAC;AAE3E,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,GAAG,cAAc,CA+BzF;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAK/D;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,cAAc,GAAG,OAAO,CAGvF;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,cAAc,EAAE,MAAM,EAAE,cAAc,GAAG,IAAI,CAI3F"}
@@ -0,0 +1,39 @@
1
+ import { FeltDBErrorCode, FeltDBServiceError } from './error-codes.js';
2
+ export function normalizeAuthorityScope(scope) {
3
+ if (!scope) {
4
+ throw new FeltDBServiceError('An explicit tenant or platform authority scope is required', FeltDBErrorCode.AUTHORITY_SCOPE_REQUIRED);
5
+ }
6
+ if (scope.kind === 'platform') {
7
+ const tenantIds = [...new Set((scope.tenantIds ?? []).map(value => value.trim()))];
8
+ if (tenantIds.some(value => !value)) {
9
+ throw new FeltDBServiceError('Platform tenantIds must be non-empty strings', FeltDBErrorCode.AUTHORITY_SCOPE_INVALID);
10
+ }
11
+ return { kind: 'platform', tenantIds };
12
+ }
13
+ if (scope.kind !== 'tenant' || typeof scope.tenantId !== 'string' || !scope.tenantId.trim()) {
14
+ throw new FeltDBServiceError('Tenant authority requires a non-empty tenantId', FeltDBErrorCode.AUTHORITY_SCOPE_REQUIRED);
15
+ }
16
+ if (scope.environmentId !== undefined && (typeof scope.environmentId !== 'string' || !scope.environmentId.trim())) {
17
+ throw new FeltDBServiceError('environmentId must be a non-empty string when provided', FeltDBErrorCode.AUTHORITY_SCOPE_INVALID);
18
+ }
19
+ return {
20
+ kind: 'tenant',
21
+ tenantId: scope.tenantId.trim(),
22
+ ...(scope.environmentId ? { environmentId: scope.environmentId.trim() } : {}),
23
+ };
24
+ }
25
+ export function authorityScopeKey(scope) {
26
+ const pathSafe = (value) => Array.from(new TextEncoder().encode(value), byte => byte.toString(16).padStart(2, '0')).join('');
27
+ return scope.kind === 'platform'
28
+ ? 'platform'
29
+ : `tenant/${pathSafe(scope.tenantId)}/${pathSafe(scope.environmentId ?? 'default')}`;
30
+ }
31
+ export function sameAuthorityScope(left, right) {
32
+ return left.kind === right.kind && (left.kind === 'platform'
33
+ || (right.kind === 'tenant' && left.tenantId === right.tenantId && left.environmentId === right.environmentId));
34
+ }
35
+ export function assertAuthorityScope(expected, actual) {
36
+ if (!sameAuthorityScope(expected, normalizeAuthorityScope(actual))) {
37
+ throw new FeltDBServiceError('Operation authority scope does not match the bound session', FeltDBErrorCode.AUTHORITY_SCOPE_MISMATCH);
38
+ }
39
+ }
@@ -1,4 +1,4 @@
1
1
  // One release train keeps generated applications installable. The repository
2
2
  // validation script checks these values against every workspace manifest.
3
- export const FELTDB_PACKAGE_VERSION = '0.8.8';
3
+ export const FELTDB_PACKAGE_VERSION = '0.9.1';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -278,7 +278,7 @@ use serde_json::Value;
278
278
  use sha2::{Digest, Sha256};
279
279
  use std::any::type_name;
280
280
  use std::collections::hash_map::DefaultHasher;
281
- use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
281
+ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
282
282
  use std::fmt::{Display, Formatter};
283
283
  use std::fs::{self, OpenOptions};
284
284
  use std::hash::{Hash, Hasher};
@@ -390,6 +390,19 @@ struct Inner {
390
390
  applied_transactions: HashSet<String>,
391
391
  transaction_payload_hashes: HashMap<String, String>,
392
392
  transaction_revisions: HashMap<String, (u64, u64)>,
393
+ /// Recent commits, and who asked for them.
394
+ ///
395
+ /// Every transaction already records its actor: the audit document written
396
+ /// into the durable log carries `subject`, tenant, application and
397
+ /// revision. Until this existed that document was written and never read
398
+ /// back — replay parsed the record and discarded the audit — so the
399
+ /// database could say *what* changed and never *who changed it*.
400
+ ///
401
+ /// Bounded on purpose. This answers "who did this, recently", which is the
402
+ /// question a human asks while operating a running system; it is not an
403
+ /// audit archive, and the durable log remains the record of what happened.
404
+ /// Keeping it bounded is what makes it safe to maintain on the commit path.
405
+ transaction_attributions: VecDeque<TransactionAttribution>,
393
406
  collection_cardinality: HashMap<String, u64>,
394
407
  /// Derived equality index over `rows`. Never durable, never replicated, and
395
408
  /// never consulted for anything but candidate selection: it is maintained
@@ -737,6 +750,102 @@ pub enum JsonCasResult {
737
750
  },
738
751
  NotFound,
739
752
  }
753
+ /// How many recent commits keep their attribution in memory.
754
+ ///
755
+ /// Sized to cover what an operator reads in one sitting rather than to be a
756
+ /// log. A larger window costs memory on every instance to answer a question the
757
+ /// durable log already answers more completely.
758
+ const TRANSACTION_ATTRIBUTION_WINDOW: usize = 512;
759
+
760
+ /// How many mutated keys one attribution names before it stops listing them.
761
+ ///
762
+ /// A bulk transaction can touch thousands of records; naming them all would
763
+ /// make one commit's attribution larger than the rest of the window. The count
764
+ /// is always exact, so a truncated list is visible as a truncated list.
765
+ const TRANSACTION_ATTRIBUTION_KEYS: usize = 32;
766
+
767
+ /// Who committed a transaction, and what it touched.
768
+ ///
769
+ /// Projected from the audit document the transaction already writes. It carries
770
+ /// no record values: attribution answers *who, when, and to what*, and the
771
+ /// values are reachable through the record and revision APIs that authorize
772
+ /// each read.
773
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774
+ pub struct TransactionAttribution {
775
+ pub transaction_id: String,
776
+ /// The actor the caller authenticated as, when the transaction named one.
777
+ pub subject: Option<String>,
778
+ pub tenant_id: Option<String>,
779
+ pub application_id: Option<String>,
780
+ /// The application contract revision the transaction was executed against.
781
+ pub application_revision: Option<String>,
782
+ pub base_revision: u64,
783
+ pub commit_revision: u64,
784
+ pub state_before: u64,
785
+ pub state_after: u64,
786
+ /// `capability:key` for each mutation, truncated at
787
+ /// [`TRANSACTION_ATTRIBUTION_KEYS`].
788
+ pub keys: Vec<String>,
789
+ /// How many mutations the transaction made, whether or not `keys` lists
790
+ /// them all.
791
+ pub operations: usize,
792
+ /// Milliseconds since the epoch, matching `StoredRow::unix_ms`.
793
+ pub unix_ms: u128,
794
+ }
795
+
796
+ impl TransactionAttribution {
797
+ /// Builds an attribution from a transaction record's own audit document.
798
+ fn from_record(
799
+ transaction_id: &str,
800
+ audit: Option<&Value>,
801
+ base_revision: u64,
802
+ commit_revision: u64,
803
+ state_before: u64,
804
+ state_after: u64,
805
+ rows: &[StoredRow],
806
+ unix_ms: u128,
807
+ ) -> Self {
808
+ let text = |field: &str| {
809
+ audit
810
+ .and_then(|value| value.get(field))
811
+ .and_then(Value::as_str)
812
+ .map(str::to_string)
813
+ };
814
+ TransactionAttribution {
815
+ transaction_id: transaction_id.to_string(),
816
+ subject: text("subject"),
817
+ tenant_id: text("tenant"),
818
+ application_id: text("application"),
819
+ application_revision: text("revision"),
820
+ base_revision,
821
+ commit_revision,
822
+ state_before,
823
+ state_after,
824
+ keys: rows
825
+ .iter()
826
+ .take(TRANSACTION_ATTRIBUTION_KEYS)
827
+ .map(|row| format!("{}:{}", row.capability, row.key))
828
+ .collect(),
829
+ operations: rows.len(),
830
+ unix_ms,
831
+ }
832
+ }
833
+
834
+ /// Whether this transaction touched a record, as far as `keys` records it.
835
+ pub fn touched(&self, capability: &str, key: &str) -> bool {
836
+ let target = format!("{capability}:{key}");
837
+ self.keys.iter().any(|held| *held == target)
838
+ }
839
+ }
840
+
841
+ /// Records an attribution, discarding the oldest once the window is full.
842
+ fn remember_attribution(inner: &mut Inner, attribution: TransactionAttribution) {
843
+ if inner.transaction_attributions.len() >= TRANSACTION_ATTRIBUTION_WINDOW {
844
+ inner.transaction_attributions.pop_front();
845
+ }
846
+ inner.transaction_attributions.push_back(attribution);
847
+ }
848
+
740
849
  #[derive(Debug, Clone, Serialize, Deserialize)]
741
850
  struct TransactionLogRecord {
742
851
  record_type: String,
@@ -2827,6 +2936,22 @@ impl FeltDb {
2827
2936
  bookkeeping_nanos += began.elapsed().as_nanos().min(u64::MAX as u128) as u64;
2828
2937
  }
2829
2938
  append_transaction(&inner.path, &record)?;
2939
+ // Retained from the record that was just made durable, so the
2940
+ // in-memory answer to "who committed this" cannot describe a
2941
+ // transaction the log does not hold.
2942
+ remember_attribution(
2943
+ &mut inner,
2944
+ TransactionAttribution::from_record(
2945
+ transaction_id,
2946
+ record.audit.as_ref(),
2947
+ base_revision,
2948
+ base_revision + 1,
2949
+ state_before,
2950
+ next_sequence,
2951
+ &record.rows,
2952
+ now_ms(),
2953
+ ),
2954
+ );
2830
2955
  inner.authority_revision = base_revision + 1;
2831
2956
  inner.sequence = next_sequence;
2832
2957
  for row in &rows {
@@ -3809,6 +3934,71 @@ impl FeltDb {
3809
3934
  }
3810
3935
 
3811
3936
  /// Earliest operation still available for incremental replay, by origin.
3937
+ /// Recent commits and who made them, newest first.
3938
+ ///
3939
+ /// Bounded by [`TRANSACTION_ATTRIBUTION_WINDOW`]. A transaction older than
3940
+ /// the window is not absent from history — it is in the durable log — it is
3941
+ /// simply no longer answerable from memory, and callers that present this
3942
+ /// to a human must say which of the two they are showing.
3943
+ pub fn recent_transactions(&self, limit: usize) -> Result<Vec<TransactionAttribution>> {
3944
+ let inner = self.state();
3945
+ Ok(inner
3946
+ .transaction_attributions
3947
+ .iter()
3948
+ .rev()
3949
+ .take(limit)
3950
+ .cloned()
3951
+ .collect())
3952
+ }
3953
+
3954
+ /// One transaction's attribution, if it is still inside the window.
3955
+ pub fn transaction_attribution(
3956
+ &self,
3957
+ transaction_id: &str,
3958
+ ) -> Result<Option<TransactionAttribution>> {
3959
+ let inner = self.state();
3960
+ Ok(inner
3961
+ .transaction_attributions
3962
+ .iter()
3963
+ .rev()
3964
+ .find(|attribution| attribution.transaction_id == transaction_id)
3965
+ .cloned())
3966
+ }
3967
+
3968
+ /// Recent commits that named this record, newest first.
3969
+ ///
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.
3973
+ pub fn transactions_touching(
3974
+ &self,
3975
+ capability: &str,
3976
+ key: &str,
3977
+ limit: usize,
3978
+ ) -> Result<Vec<TransactionAttribution>> {
3979
+ let inner = self.state();
3980
+ Ok(inner
3981
+ .transaction_attributions
3982
+ .iter()
3983
+ .rev()
3984
+ .filter(|attribution| attribution.touched(capability, key))
3985
+ .take(limit)
3986
+ .cloned()
3987
+ .collect())
3988
+ }
3989
+
3990
+ /// How much of the attribution window is in use, and what it can hold.
3991
+ ///
3992
+ /// Reported so a reader can tell an idle system from a window that has
3993
+ /// already rolled over.
3994
+ pub fn transaction_attribution_window(&self) -> Result<(usize, usize)> {
3995
+ let inner = self.state();
3996
+ Ok((
3997
+ inner.transaction_attributions.len(),
3998
+ TRANSACTION_ATTRIBUTION_WINDOW,
3999
+ ))
4000
+ }
4001
+
3812
4002
  pub fn retained_operation_floors(&self) -> Result<HashMap<String, u64>> {
3813
4003
  let inner = self.state();
3814
4004
  Ok(inner.change_log.retained_from())
@@ -4693,6 +4883,24 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
4693
4883
  .transaction_payload_hashes
4694
4884
  .insert(transaction.transaction_id.clone(), hash);
4695
4885
  }
4886
+ // Attribution is restored on replay for the same reason it is kept
4887
+ // on commit: an operator who restarts a server must not lose the
4888
+ // answer to "who did this" for work the server already holds.
4889
+ let attribution = TransactionAttribution::from_record(
4890
+ &transaction.transaction_id,
4891
+ transaction.audit.as_ref(),
4892
+ base_revision,
4893
+ inner.authority_revision,
4894
+ transaction.state_before,
4895
+ transaction.state_after,
4896
+ &transaction.rows,
4897
+ transaction
4898
+ .rows
4899
+ .first()
4900
+ .map(|row| row.unix_ms)
4901
+ .unwrap_or_default(),
4902
+ );
4903
+ remember_attribution(inner, attribution);
4696
4904
  if inner
4697
4905
  .applied_transactions
4698
4906
  .insert(transaction.transaction_id)
@@ -4987,6 +5195,140 @@ mod tests {
4987
5195
  ))
4988
5196
  }
4989
5197
 
5198
+ /// A commit is attributable: the audit document a transaction already
5199
+ /// writes is retained, so a human can ask who made a change.
5200
+ #[test]
5201
+ fn a_committed_transaction_records_who_committed_it() {
5202
+ let path = temp_file();
5203
+ let db = open(&path).expect("open");
5204
+ db.apply_atomic_transaction(
5205
+ "tx_agent_1",
5206
+ None,
5207
+ &[],
5208
+ &[AtomicMutation {
5209
+ capability: "billing:Customer".into(),
5210
+ key: "cus_1".into(),
5211
+ value: Some(serde_json::json!({"name": "Ada", "status": "active"})),
5212
+ }],
5213
+ Some(serde_json::json!({
5214
+ "subject": "agent_data_cleanup",
5215
+ "tenant": "acme",
5216
+ "application": "app_billing",
5217
+ "revision": "rev_7",
5218
+ })),
5219
+ )
5220
+ .expect("commit");
5221
+
5222
+ let recent = db.recent_transactions(10).expect("attributions");
5223
+ assert_eq!(recent.len(), 1);
5224
+ assert_eq!(recent[0].transaction_id, "tx_agent_1");
5225
+ assert_eq!(recent[0].subject.as_deref(), Some("agent_data_cleanup"));
5226
+ assert_eq!(recent[0].application_id.as_deref(), Some("app_billing"));
5227
+ assert_eq!(recent[0].operations, 1);
5228
+ assert_eq!(recent[0].keys, vec!["billing:Customer:cus_1".to_string()]);
5229
+ // Attribution says who and what, never what was written.
5230
+ assert!(!serde_json::to_string(&recent[0]).unwrap().contains("Ada"));
5231
+
5232
+ let touching = db
5233
+ .transactions_touching("billing:Customer", "cus_1", 10)
5234
+ .expect("touching");
5235
+ assert_eq!(touching.len(), 1);
5236
+ assert_eq!(touching[0].transaction_id, "tx_agent_1");
5237
+ assert!(db
5238
+ .transactions_touching("billing:Customer", "cus_2", 10)
5239
+ .expect("touching")
5240
+ .is_empty());
5241
+
5242
+ assert_eq!(
5243
+ db.transaction_attribution("tx_agent_1")
5244
+ .expect("lookup")
5245
+ .map(|value| value.subject)
5246
+ .flatten()
5247
+ .as_deref(),
5248
+ Some("agent_data_cleanup")
5249
+ );
5250
+
5251
+ // Attribution survives a restart, because it is rebuilt from the same
5252
+ // durable records the rest of recovery replays.
5253
+ drop(db);
5254
+ let reopened = open(&path).expect("reopen");
5255
+ let after = reopened.recent_transactions(10).expect("attributions");
5256
+ assert_eq!(after.len(), 1);
5257
+ assert_eq!(after[0].subject.as_deref(), Some("agent_data_cleanup"));
5258
+ assert_eq!(after[0].keys, vec!["billing:Customer:cus_1".to_string()]);
5259
+ drop(reopened);
5260
+ let _ = fs::remove_file(path);
5261
+ }
5262
+
5263
+ /// The window is bounded, and reports that it is.
5264
+ #[test]
5265
+ fn transaction_attribution_is_bounded_and_newest_first() {
5266
+ let path = temp_file();
5267
+ let db = open(&path).expect("open");
5268
+ let commits = TRANSACTION_ATTRIBUTION_WINDOW + 5;
5269
+ for index in 0..commits {
5270
+ db.apply_atomic_transaction(
5271
+ &format!("tx_{index}"),
5272
+ None,
5273
+ &[],
5274
+ &[AtomicMutation {
5275
+ capability: "app:Note".into(),
5276
+ key: format!("note_{index}"),
5277
+ value: Some(serde_json::json!({"index": index})),
5278
+ }],
5279
+ Some(serde_json::json!({"subject": format!("actor_{index}")})),
5280
+ )
5281
+ .expect("commit");
5282
+ }
5283
+ let (held, capacity) = db.transaction_attribution_window().expect("window");
5284
+ assert_eq!(capacity, TRANSACTION_ATTRIBUTION_WINDOW);
5285
+ assert_eq!(held, TRANSACTION_ATTRIBUTION_WINDOW);
5286
+ let recent = db.recent_transactions(3).expect("attributions");
5287
+ assert_eq!(
5288
+ recent.iter().map(|value| value.transaction_id.clone()).collect::<Vec<_>>(),
5289
+ vec![
5290
+ format!("tx_{}", commits - 1),
5291
+ format!("tx_{}", commits - 2),
5292
+ format!("tx_{}", commits - 3),
5293
+ ]
5294
+ );
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());
5298
+ drop(db);
5299
+ let _ = fs::remove_file(path);
5300
+ }
5301
+
5302
+ /// A bulk transaction names a bounded sample of keys and an exact count.
5303
+ #[test]
5304
+ fn a_bulk_transaction_truncates_its_key_list_and_keeps_the_count() {
5305
+ let path = temp_file();
5306
+ let db = open(&path).expect("open");
5307
+ let mutations = (0..TRANSACTION_ATTRIBUTION_KEYS + 20)
5308
+ .map(|index| AtomicMutation {
5309
+ capability: "app:Customer".into(),
5310
+ key: format!("cus_{index}"),
5311
+ value: Some(serde_json::json!({"index": index})),
5312
+ })
5313
+ .collect::<Vec<_>>();
5314
+ db.apply_atomic_transaction(
5315
+ "tx_bulk",
5316
+ None,
5317
+ &[],
5318
+ &mutations,
5319
+ Some(serde_json::json!({"subject": "migration_agent"})),
5320
+ )
5321
+ .expect("commit");
5322
+ let attribution = db
5323
+ .transaction_attribution("tx_bulk")
5324
+ .expect("lookup")
5325
+ .expect("held");
5326
+ assert_eq!(attribution.operations, mutations.len());
5327
+ assert_eq!(attribution.keys.len(), TRANSACTION_ATTRIBUTION_KEYS);
5328
+ drop(db);
5329
+ let _ = fs::remove_file(path);
5330
+ }
5331
+
4990
5332
  #[test]
4991
5333
  fn open_is_zero_init_and_creates_store() {
4992
5334
  let path = temp_file();
@@ -1089,9 +1089,22 @@ pub fn reconcile(
1089
1089
  /// Proven for this type: durable single-writer append, retrieval by identity
1090
1090
  /// across restart, ancestry by parent traversal, and independence between
1091
1091
  /// records. **Not** proven and not claimed: multi-writer persistence semantics,
1092
- /// concurrent revision creation, authorization, resource isolation, garbage
1093
- /// collection, server lifecycle integration, current/head semantics, and
1092
+ /// concurrent revision creation, resource isolation, garbage collection, and
1094
1093
  /// distributed replication of revision history.
1094
+ ///
1095
+ /// Two entries left that list when `/v1/state/*` began serving this type.
1096
+ /// *Server lifecycle integration*: `feltdb-server` opens a store over its own
1097
+ /// `FeltDb` through [`crate::FeltDBStateSystem`] and serves history, diff,
1098
+ /// classification and reconciliation from it. *Authorization*: those routes are
1099
+ /// authorized like every other route — `state:read` to read, `state:write` to
1100
+ /// commit — which is authorization **at the route**, not in this type. This
1101
+ /// type still has none of its own, and a caller holding a `StateStore` still
1102
+ /// reaches every resource in it.
1103
+ ///
1104
+ /// *Current/head semantics* also left, but only partly, and the distinction
1105
+ /// matters: [`head_of`](Self::head_of) answers "the newest retained revision of
1106
+ /// this resource, by sequence". That is not a branch head and there is still no
1107
+ /// ref anything can move.
1095
1108
  pub struct StateStore {
1096
1109
  backing: Backing,
1097
1110
  }