@feltdb/core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/authority-scope.d.ts +2 -0
  2. package/dist/authority-scope.d.ts.map +1 -1
  3. package/dist/authority-scope.js +7 -2
  4. package/dist/cli/commands.js +19 -21
  5. package/dist/cli/development-session.js +16 -0
  6. package/dist/cli/studio-artifact.js +33 -0
  7. package/dist/create/package-versions.js +1 -1
  8. package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +8 -1
  9. package/dist/create/server-source/crates/feltdb/src/application.rs +16 -0
  10. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +21 -22
  11. package/dist/create/server-source/crates/feltdb/src/lib.rs +451 -33
  12. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +48 -3
  13. package/dist/create/server-source/crates/feltdb/src/state_model.rs +569 -4
  14. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +1 -0
  15. package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +708 -0
  16. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  17. package/dist/create/server-source/crates/feltdb/src/workload.rs +480 -44
  18. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +123 -0
  19. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +281 -0
  20. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +15 -3
  21. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +28 -18
  22. package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +328 -0
  23. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +20 -3
  24. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -4
  25. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +0 -4
  26. package/dist/create/server-source/crates/feltdb-server/src/canonical_principals.rs +1093 -0
  27. package/dist/create/server-source/crates/feltdb-server/src/causal.rs +905 -144
  28. package/dist/create/server-source/crates/feltdb-server/src/connections.rs +9 -6
  29. package/dist/create/server-source/crates/feltdb-server/src/control_plane.rs +90 -0
  30. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  31. package/dist/create/server-source/crates/feltdb-server/src/main.rs +443 -77
  32. package/dist/create/server-source/crates/feltdb-server/src/principals.rs +9 -501
  33. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +22 -0
  34. package/dist/create/server-source/crates/feltdb-server/tests/principal_tenant_migration.rs +247 -0
  35. package/dist/db.d.ts +14 -7
  36. package/dist/db.d.ts.map +1 -1
  37. package/dist/db.js +50 -26
  38. package/dist/deployment.d.ts +2 -0
  39. package/dist/deployment.d.ts.map +1 -1
  40. package/dist/deployment.js +4 -2
  41. package/dist/embedded-transaction.d.ts.map +1 -1
  42. package/dist/embedded-transaction.js +4 -0
  43. package/dist/error-codes.d.ts +12 -0
  44. package/dist/error-codes.d.ts.map +1 -1
  45. package/dist/error-codes.js +20 -2
  46. package/dist/feltdb.d.ts +4 -0
  47. package/dist/feltdb.d.ts.map +1 -1
  48. package/dist/file-db.d.ts +10 -0
  49. package/dist/file-db.d.ts.map +1 -1
  50. package/dist/file-db.js +18 -0
  51. package/dist/http-db.d.ts +7 -0
  52. package/dist/http-db.d.ts.map +1 -1
  53. package/dist/http-db.js +83 -15
  54. package/dist/index-core.d.ts +1 -0
  55. package/dist/index-core.d.ts.map +1 -1
  56. package/dist/index-core.js +1 -0
  57. package/dist/operation-admission.d.ts +2 -0
  58. package/dist/operation-admission.d.ts.map +1 -1
  59. package/dist/public-contract.d.ts +112 -0
  60. package/dist/public-contract.d.ts.map +1 -0
  61. package/dist/public-contract.js +125 -0
  62. package/dist/studio/app.d.ts +23 -0
  63. package/dist/studio/app.d.ts.map +1 -1
  64. package/dist/studio/components/ContractInspector.d.ts +5 -0
  65. package/dist/studio/components/ContractInspector.d.ts.map +1 -1
  66. package/dist/studio/components/SettingsPanel.d.ts +4 -1
  67. package/dist/studio/components/SettingsPanel.d.ts.map +1 -1
  68. package/dist/studio/components/index.js +4 -4
  69. package/dist/studio/{components-CehkcX_k.js → components-CJFgdKG6.js} +322 -303
  70. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  71. package/dist/studio/control-plane/Instance.d.ts +10 -0
  72. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  73. package/dist/studio/control-plane/Operations.d.ts +3 -0
  74. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  75. package/dist/studio/control-plane/State.d.ts.map +1 -1
  76. package/dist/studio/control-plane/index.d.ts +4 -2
  77. package/dist/studio/control-plane/index.d.ts.map +1 -1
  78. package/dist/studio/control-plane/index.js +2 -2
  79. package/dist/studio/control-plane-DP8ljTDO.js +3207 -0
  80. package/dist/studio/index.js +96 -75
  81. package/dist/studio/utils/api.d.ts +1 -1
  82. package/dist/studio/utils/api.d.ts.map +1 -1
  83. package/dist/studio/utils/control-plane-api.d.ts +12 -1
  84. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  85. package/dist/studio/utils/control-plane-api.js +1 -1
  86. package/dist/studio/utils/index.js +16 -16
  87. package/dist/studio/utils/service-api.d.ts +8 -0
  88. package/dist/studio/utils/service-api.d.ts.map +1 -1
  89. package/dist/studio/utils/service-api.js +15 -12
  90. package/dist/studio-app/assets/{feltdb_wasm-DksXDnLa.js → feltdb_wasm-CfNb3IoF.js} +1 -1
  91. package/dist/studio-app/assets/feltdb_wasm_bg-D9d3gY-E.wasm +0 -0
  92. package/dist/studio-app/assets/index-SDXdROi1.js +30 -0
  93. package/dist/studio-app/feltdb-studio.json +6 -0
  94. package/dist/studio-app/index.html +1 -1
  95. package/dist/transaction.d.ts +9 -1
  96. package/dist/transaction.d.ts.map +1 -1
  97. package/dist/transaction.js +6 -2
  98. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  99. package/dist/workspace/local-development-authority.d.ts +8 -0
  100. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  101. package/dist/workspace/local-development-authority.js +136 -1
  102. package/package.json +2 -2
  103. package/dist/studio/control-plane-D6TT2fCQ.js +0 -3064
  104. package/dist/studio-app/assets/feltdb_wasm_bg-Yy4QRHJT.wasm +0 -0
  105. package/dist/studio-app/assets/index-CjELc-ul.js +0 -30
@@ -76,7 +76,10 @@ use feltdb_server::{
76
76
  create_bundle, create_bundle_with_virtual, restore_bundle, select_bundle_at,
77
77
  server_artifacts, verify_bundle,
78
78
  },
79
- causal::{CausalEvent, CausalStore},
79
+ causal::{
80
+ decode_cursor, decode_scoped_cursor, encode_cursor, encode_scoped_cursor, CausalEvent,
81
+ CanonicalEventAuthority,
82
+ },
80
83
  certification::default_report,
81
84
  clock::LeaseClock,
82
85
  cluster::{ClusterConfig, ClusterStore, MembershipProposal, ProposalPhase},
@@ -89,7 +92,7 @@ use feltdb_server::{
89
92
  leases::LeaseStore,
90
93
  metrics::Metrics,
91
94
  portable_bundle::{BundleArtifact, BundleStore, ExportOptions, ImportMode, PortableBundle},
92
- principals::{PrincipalKind, PrincipalStatus, PrincipalStore},
95
+ principals::{PrincipalKind, PrincipalStatus},
93
96
  providers::{ProviderManifest, ProviderStatus, ProviderStore},
94
97
  releases::{
95
98
  provider_for, AuthorizedDeploymentContext, DeploymentPlan, DeploymentProfile,
@@ -152,15 +155,18 @@ fn api_error_body(status: StatusCode, message: String) -> Value {
152
155
  let object = body
153
156
  .as_object_mut()
154
157
  .expect("normalized error body is an object");
155
- let resolved_code = if status == StatusCode::FORBIDDEN {
156
- code.to_string()
158
+ // A scope-bound cursor has a stable public recovery action, so preserve
159
+ // that code. Other forbidden details retain the non-disclosing canonical
160
+ // `FORBIDDEN` wire contract.
161
+ let explicit_code = object.get("code").and_then(Value::as_str);
162
+ let resolved_code = if status != StatusCode::FORBIDDEN
163
+ || explicit_code == Some("CURSOR_SCOPE_VIOLATION")
164
+ {
165
+ explicit_code.unwrap_or(code)
157
166
  } else {
158
- object
159
- .get("code")
160
- .and_then(Value::as_str)
161
- .unwrap_or(code)
162
- .to_string()
163
- };
167
+ code
168
+ }
169
+ .to_string();
164
170
  object.insert("error".into(), json!(resolved_code));
165
171
  object.insert("code".into(), json!(resolved_code));
166
172
  object.entry("message").or_insert_with(|| {
@@ -185,6 +191,11 @@ struct HealthResponse<'a> {
185
191
  status: &'a str,
186
192
  version: &'a str,
187
193
  git_commit: &'a str,
194
+ build_timestamp: &'a str,
195
+ release_id: &'a str,
196
+ deployment_id: String,
197
+ public_contract: &'a str,
198
+ capability_fingerprint: String,
188
199
  runtime: &'a str,
189
200
  storage: &'a str,
190
201
  fabric: &'a str,
@@ -542,6 +553,16 @@ struct PreviewSimulationRequest {
542
553
  #[derive(Deserialize, Default)]
543
554
  struct EventScopeRequest {
544
555
  application_id: Option<String>,
556
+ cursor: Option<String>,
557
+ }
558
+ #[derive(Deserialize)]
559
+ struct PublicEventReplayRequest {
560
+ application_id: String,
561
+ #[serde(default = "production_environment")]
562
+ environment: String,
563
+ cursor: Option<String>,
564
+ #[serde(default)]
565
+ limit: Option<usize>,
545
566
  }
546
567
  #[derive(Deserialize)]
547
568
  struct SignUpRequest {
@@ -1150,6 +1171,11 @@ async fn push_sync(
1150
1171
  operations: vec![operation.operation.clone()],
1151
1172
  preconditions: vec![],
1152
1173
  };
1174
+ let _transaction_permit = state
1175
+ .state_transaction_admission
1176
+ .acquire()
1177
+ .await
1178
+ .expect("transaction admission is never closed");
1153
1179
  match execute_transaction(&state.db, &schema, &transaction, None) {
1154
1180
  Ok(committed) => {
1155
1181
  let position = state
@@ -1451,6 +1477,18 @@ fn worker_error(error: feltdb::worker_mesh::WorkerMeshError) -> ApiError {
1451
1477
  serde_json::to_string(&error).unwrap_or(error.message),
1452
1478
  )
1453
1479
  }
1480
+ fn release_worker_claim_projection(state: &AppState, workload_id: &str, worker_id: &str, claim_id: &str) {
1481
+ match state.mesh.lock() {
1482
+ Ok(mut mesh) => {
1483
+ if let Err(error) = mesh.release_claim(worker_id, claim_id) {
1484
+ tracing::warn!(%workload_id, %claim_id, %error,
1485
+ "durable work reached a terminal attempt state but the worker mesh projection did not release its claim");
1486
+ }
1487
+ }
1488
+ Err(_) => tracing::warn!(%workload_id, %claim_id,
1489
+ "durable work reached a terminal attempt state but the worker mesh projection was unavailable"),
1490
+ }
1491
+ }
1454
1492
  fn workload_decision(
1455
1493
  state: &AppState,
1456
1494
  subject: GrantSubject,
@@ -1502,6 +1540,7 @@ async fn create_workload(
1502
1540
  ) -> Result<(StatusCode, Json<Value>), ApiError> {
1503
1541
  let subject = grant_subject(&principal);
1504
1542
  input.requested_by = subject.clone();
1543
+ input.session_id = principal.session_id.clone();
1505
1544
  let resource = format!("flow://{}/workloads", input.application_id);
1506
1545
  workload_decision(
1507
1546
  &state,
@@ -1754,6 +1793,12 @@ async fn complete_workload(
1754
1793
  unix_seconds_i64(),
1755
1794
  )
1756
1795
  .map_err(workload_error)?;
1796
+ release_worker_claim_projection(
1797
+ &state,
1798
+ &id,
1799
+ &input.fence.worker_id,
1800
+ &input.fence.claim_id,
1801
+ );
1757
1802
  Ok(Json(json!(result)))
1758
1803
  }
1759
1804
  async fn fail_workload(
@@ -1777,6 +1822,12 @@ async fn fail_workload(
1777
1822
  unix_seconds_i64(),
1778
1823
  )
1779
1824
  .map_err(workload_error)?;
1825
+ release_worker_claim_projection(
1826
+ &state,
1827
+ &id,
1828
+ &input.fence.worker_id,
1829
+ &input.fence.claim_id,
1830
+ );
1780
1831
  Ok(Json(json!({"state":status})))
1781
1832
  }
1782
1833
  async fn confirm_workload_cancelled(
@@ -3103,7 +3154,7 @@ async fn append_causal_event(
3103
3154
  event.subject_id = principal.key_id;
3104
3155
  Ok((
3105
3156
  StatusCode::CREATED,
3106
- Json(json!(state.causal.append(event).map_err(control_error)?)),
3157
+ Json(json!(state.events.append(event).map_err(control_error)?)),
3107
3158
  ))
3108
3159
  }
3109
3160
  async fn certification_report() -> Json<Value> {
@@ -3172,32 +3223,32 @@ async fn causal_events(
3172
3223
  "applications:read",
3173
3224
  )?;
3174
3225
  Ok(Json(
3175
- json!({"events":state.causal.all().into_iter().filter(|e|e.application_id==scope.application_id).collect::<Vec<_>>() }),
3226
+ json!({"events":state.events.all().map_err(control_error)?.into_iter().filter(|e|e.application_id==scope.application_id).collect::<Vec<_>>() }),
3176
3227
  ))
3177
3228
  }
3178
3229
  async fn causal_chain(
3179
3230
  State(state): State<AppState>,
3180
3231
  Path(id): Path<String>,
3181
3232
  ) -> Result<Json<Value>, ApiError> {
3182
- Ok(Json(json!({"events":state.causal.list(&id)})))
3233
+ Ok(Json(json!({"events":state.events.list(&id).map_err(control_error)?})))
3183
3234
  }
3184
3235
  async fn workload_timeline(
3185
3236
  State(state): State<AppState>,
3186
3237
  Path(id): Path<String>,
3187
3238
  ) -> Result<Json<Value>, ApiError> {
3188
- Ok(Json(json!({"events":state.causal.workload(&id)})))
3239
+ Ok(Json(json!({"events":state.events.workload(&id).map_err(control_error)?})))
3189
3240
  }
3190
3241
  async fn execution_timeline(
3191
3242
  State(state): State<AppState>,
3192
3243
  Path(id): Path<String>,
3193
3244
  ) -> Result<Json<Value>, ApiError> {
3194
- Ok(Json(json!({"events":state.causal.execution(&id)})))
3245
+ Ok(Json(json!({"events":state.events.execution(&id).map_err(control_error)?})))
3195
3246
  }
3196
3247
  async fn revision_timeline(
3197
3248
  State(state): State<AppState>,
3198
3249
  Path(id): Path<String>,
3199
3250
  ) -> Result<Json<Value>, ApiError> {
3200
- Ok(Json(json!({"events":state.causal.revision(&id)})))
3251
+ Ok(Json(json!({"events":state.events.revision(&id).map_err(control_error)?})))
3201
3252
  }
3202
3253
  async fn export_bundle(
3203
3254
  State(state): State<AppState>,
@@ -5450,14 +5501,13 @@ fn proposal_event(
5450
5501
  ) -> Result<(), ApiError> {
5451
5502
  let id = format!("pe_{}", uuid::Uuid::new_v4().simple());
5452
5503
  let proposal = proposal_value(state, proposal_id).unwrap_or(Value::Null);
5453
- let event_sequence = state.db.sequence()?.saturating_add(1);
5454
5504
  insert_canonical(
5455
5505
  state,
5456
5506
  PROPOSAL_EVENT_COLLECTION,
5457
5507
  &id,
5458
5508
  json!({ "id":id, "event_id":id, "proposal_id":proposal_id, "event_type":to,
5459
5509
  "from_status":from, "to_status":to, "actor":actor, "created_at":unix_seconds_i64(),
5460
- "timestamp":unix_seconds_i64(), "event_sequence":event_sequence, "contract_hash":proposal.get("base_contract_hash"),
5510
+ "timestamp":unix_seconds_i64(), "contract_hash":proposal.get("base_contract_hash"),
5461
5511
  "flow_hash":proposal.get("base_flow_hash"), "metadata":{} }),
5462
5512
  )?;
5463
5513
  Ok(())
@@ -5743,15 +5793,25 @@ async fn proposal_history(
5743
5793
  .map(|row| row.value)
5744
5794
  .filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str()))
5745
5795
  .collect();
5746
- events.sort_by(|left, right| {
5747
- left.get("event_sequence")
5748
- .and_then(Value::as_u64)
5749
- .cmp(&right.get("event_sequence").and_then(Value::as_u64))
5750
- .then_with(|| {
5751
- left.get("id")
5752
- .and_then(Value::as_str)
5753
- .cmp(&right.get("id").and_then(Value::as_str))
5754
- })
5796
+ let canonical_order = state
5797
+ .db
5798
+ .committed_transactions_after(0, usize::MAX)?
5799
+ .into_iter()
5800
+ .flat_map(|transaction| {
5801
+ transaction
5802
+ .keys
5803
+ .into_iter()
5804
+ .map(move |key| (key, transaction.commit_revision))
5805
+ })
5806
+ .collect::<HashMap<_, _>>();
5807
+ events.sort_by_key(|event| {
5808
+ let key = event
5809
+ .get("id")
5810
+ .and_then(Value::as_str)
5811
+ .and_then(|event_id| record_key(PROPOSAL_EVENT_COLLECTION, event_id).ok())
5812
+ .map(|key| format!("{PROPOSAL_EVENT_COLLECTION}:{key}"));
5813
+ key.and_then(|key| canonical_order.get(&key).copied())
5814
+ .unwrap_or(u64::MAX)
5755
5815
  });
5756
5816
  for event in &mut events {
5757
5817
  redact_inspection_value(event);
@@ -7116,6 +7176,11 @@ async fn run_runtime_action(
7116
7176
  .iter()
7117
7177
  .find(|p| p.resource == definition.collection)
7118
7178
  .and_then(|p| p.write.as_ref().and_then(|s| PolicySubject::from_str(s)));
7179
+ let _transaction_permit = state
7180
+ .state_transaction_admission
7181
+ .acquire()
7182
+ .await
7183
+ .expect("transaction admission is never closed");
7119
7184
  Ok(Json(json!(execute_transaction(
7120
7185
  &state.db,
7121
7186
  &contract.state.schema,
@@ -7448,7 +7513,7 @@ fn state_authorization_legacy(
7448
7513
  fn state_contract_error(error: feltdb::state_contract::StateFailure) -> ApiError {
7449
7514
  let status = match error.code.as_str() {
7450
7515
  "AUTHORIZATION_DENIED" => StatusCode::FORBIDDEN,
7451
- "PRECONDITION_FAILED" | "CONFLICT" => StatusCode::CONFLICT,
7516
+ "PRECONDITION_FAILED" | "CONFLICT" | "IDEMPOTENCY_CONFLICT" => StatusCode::CONFLICT,
7452
7517
  "UNKNOWN_COLLECTION" => StatusCode::NOT_FOUND,
7453
7518
  "STORAGE_FAILURE" | "STATE_UNAVAILABLE" => StatusCode::SERVICE_UNAVAILABLE,
7454
7519
  _ => StatusCode::UNPROCESSABLE_ENTITY,
@@ -7513,17 +7578,29 @@ async fn get_state_version(
7513
7578
  json!({"application_id":target.application_id,"revision_id":contract.revision_id,"schema_version":contract.state.schema.schema_version,"state_version":state.db.sequence()?,"causal_cursor":state.db.operation_versions()?,"state_namespace":contract.environment.state_namespace}),
7514
7579
  ))
7515
7580
  }
7581
+ fn require_bound_tenant(headers: &HeaderMap, tenant: &str) -> Result<(), ApiError> {
7582
+ let Some(bound) = headers.get("feltdb-tenant-id") else { return Ok(()); };
7583
+ if bound.to_str().ok() == Some(tenant) { return Ok(()); }
7584
+ Err(ApiError(StatusCode::FORBIDDEN, json!({
7585
+ "code":"AUTHORITY_SCOPE_MISMATCH",
7586
+ "message":"request tenant does not match the authenticated application authority",
7587
+ "http_status":403
7588
+ }).to_string()))
7589
+ }
7590
+
7516
7591
  async fn execute_canonical_query(
7517
7592
  State(state): State<AppState>,
7518
7593
  Extension(principal): Extension<Principal>,
7594
+ headers: HeaderMap,
7519
7595
  Json(input): Json<CanonicalQueryRequest>,
7520
7596
  ) -> Result<Json<Value>, ApiError> {
7521
- let _ = application_scope(
7597
+ let tenant = application_scope(
7522
7598
  &state,
7523
7599
  &principal.key_id,
7524
7600
  &input.application_id,
7525
7601
  "state:read",
7526
7602
  )?;
7603
+ require_bound_tenant(&headers, &tenant)?;
7527
7604
  let contract = revision_contract(
7528
7605
  &state,
7529
7606
  &principal.key_id,
@@ -7718,6 +7795,7 @@ async fn execute_revision_recovery(
7718
7795
  async fn execute_canonical_transaction(
7719
7796
  State(state): State<AppState>,
7720
7797
  Extension(principal): Extension<Principal>,
7798
+ headers: HeaderMap,
7721
7799
  Json(mut input): Json<CanonicalTransactionRequest>,
7722
7800
  ) -> Result<Json<Value>, ApiError> {
7723
7801
  let tenant = application_scope(
@@ -7726,6 +7804,7 @@ async fn execute_canonical_transaction(
7726
7804
  &input.application_id,
7727
7805
  "state:write",
7728
7806
  )?;
7807
+ require_bound_tenant(&headers, &tenant)?;
7729
7808
  let contract = revision_contract(
7730
7809
  &state,
7731
7810
  &principal.key_id,
@@ -7733,6 +7812,13 @@ async fn execute_canonical_transaction(
7733
7812
  &input.revision_id,
7734
7813
  &input.environment,
7735
7814
  )?;
7815
+ if !input.transaction.tenant_id.is_empty() && input.transaction.tenant_id != tenant {
7816
+ return Err(ApiError(StatusCode::FORBIDDEN, json!({
7817
+ "code":"AUTHORITY_SCOPE_MISMATCH",
7818
+ "message":"transaction tenant does not match the bound authority",
7819
+ "http_status":403
7820
+ }).to_string()));
7821
+ }
7736
7822
  input.transaction.tenant_id = tenant.clone();
7737
7823
  input.transaction.application_id = input.application_id.clone();
7738
7824
  input.transaction.revision_id = contract.revision_id.clone();
@@ -7757,6 +7843,11 @@ async fn execute_canonical_transaction(
7757
7843
  "state:denied"
7758
7844
  },
7759
7845
  );
7846
+ let _transaction_permit = state
7847
+ .state_transaction_admission
7848
+ .acquire()
7849
+ .await
7850
+ .expect("transaction admission is never closed");
7760
7851
  let result = execute_transaction_with_collection_policies(
7761
7852
  &state.db,
7762
7853
  &contract.state.schema,
@@ -7772,7 +7863,13 @@ async fn execute_canonical_transaction(
7772
7863
  "allowed",
7773
7864
  201,
7774
7865
  );
7775
- Ok(Json(json!(result)))
7866
+ let attribution = state.db.transaction_attribution(&result.transaction_id)?;
7867
+ let mut response = json!(result);
7868
+ response["revision_id"] = json!(format!("revision-{}", result.commit_revision));
7869
+ response["operation_ids"] = json!(attribution
7870
+ .map(|value| value.operation_ids)
7871
+ .unwrap_or_default());
7872
+ Ok(Json(response))
7776
7873
  }
7777
7874
 
7778
7875
  async fn get_cardinality_diagnostic(
@@ -8367,7 +8464,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8367
8464
  let grant_signer = GrantSigner::new("feltdb-primary-v1", grant_key.to_vec())?;
8368
8465
  let grant_store = GrantStore::load(config.data.with_extension("grants.json"))?;
8369
8466
  let sync_store = SyncStore::load(config.data.with_extension("sync.json"))?;
8370
- let workload_store = WorkloadStore::load(config.data.with_extension("workloads.json"))?;
8467
+ let legacy_workloads = config.data.with_extension("workloads.json");
8468
+ let workload_store = if legacy_workloads.exists() {
8469
+ WorkloadStore::migrate_legacy_file(db.clone(), &legacy_workloads)?
8470
+ } else {
8471
+ WorkloadStore::from_db(db.clone())?
8472
+ };
8371
8473
  let state_trigger_store = StateTriggerStore::load(config.data.with_extension("triggers.json"))?;
8372
8474
  let mesh_store = WorkerMeshStore::load(config.data.with_extension("workers.json"))?;
8373
8475
  let content_store = ContentStore::new(config.data.with_extension("content"))?;
@@ -8377,9 +8479,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8377
8479
  )?;
8378
8480
  let bundle_store = BundleStore::load(config.data.with_extension("bundles.json"))?;
8379
8481
  let release_store = ReleaseStore::load(config.data.with_extension("releases.json"))?;
8380
- let causal_store = CausalStore::load(config.data.with_extension("causal-events.json"))?;
8482
+ let event_authority = CanonicalEventAuthority::new(db.clone());
8483
+ let legacy_causal = config.data.with_extension("causal-events.json");
8484
+ if legacy_causal.exists() {
8485
+ event_authority.migrate_legacy_file(&legacy_causal)?;
8486
+ }
8381
8487
  let provider_store = ProviderStore::load(config.data.with_extension("providers.json"))?;
8382
8488
  let readiness_probe = config.data.with_extension("readiness");
8489
+ let tenancy_store = TenancyStore::load(config.data.with_extension("platform.json"))?;
8490
+ let application_store = ApplicationStore::load(config.data.with_extension("applications.json"))?;
8491
+ let principal_authority =
8492
+ feltdb_server::canonical_principals::CanonicalPrincipalAuthority::new(db.clone());
8493
+ let legacy_principals = config.data.with_extension("principals.json");
8494
+ if legacy_principals.exists() {
8495
+ principal_authority.migrate_legacy_file(&legacy_principals, |application_id| {
8496
+ feltdb_server::canonical_principals::resolve_principal_tenant(
8497
+ application_id,
8498
+ &tenancy_store,
8499
+ &application_store,
8500
+ &db,
8501
+ )
8502
+ })?;
8503
+ }
8383
8504
  // Constructed before the state so the same handle can be stopped after
8384
8505
  // serving: the pipeline outlives the router by exactly one shutdown.
8385
8506
  let audit_handle = AuditLog::new(config.audit.clone());
@@ -8405,13 +8526,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8405
8526
  peer_client,
8406
8527
  peer_token,
8407
8528
  lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
8529
+ state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(2)),
8408
8530
  worker_enabled: config.worker_enabled,
8409
8531
  leases: LeaseStore::load(config.data.with_extension("leases.json"))?,
8410
8532
  content: content_store,
8411
8533
  artifacts: artifact_store,
8412
8534
  bundles: bundle_store,
8413
8535
  releases: release_store,
8414
- causal: causal_store,
8536
+ events: event_authority,
8415
8537
  providers: provider_store,
8416
8538
  contracts: ApplicationContractStore::load(
8417
8539
  config.data.with_extension("application-contracts.json"),
@@ -8420,14 +8542,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8420
8542
  lease_clock: LeaseClock::new(config.maximum_clock_drift_ms),
8421
8543
  backup_barrier: Arc::new(tokio::sync::RwLock::new(())),
8422
8544
  backup_artifacts: Arc::new(backup_artifacts),
8423
- tenancy: TenancyStore::load(config.data.with_extension("platform.json"))?,
8545
+ tenancy: tenancy_store,
8424
8546
  sessions: SessionVerifier::load(
8425
8547
  std::env::var("FELTDB_SESSION_SECRET")
8426
8548
  .unwrap_or_else(|_| "feltdb-development-session-secret-change-me".into())
8427
8549
  .into_bytes(),
8428
8550
  config.data.with_extension("revoked-sessions.json"),
8429
8551
  )?,
8430
- principals: PrincipalStore::load(config.data.with_extension("principals.json"))?,
8552
+ principals: principal_authority,
8431
8553
  connections: ConnectionStore::load_with_provider(
8432
8554
  config.data.with_extension("connections.json"),
8433
8555
  provider_from_environment(
@@ -8436,7 +8558,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8436
8558
  )?,
8437
8559
  )?,
8438
8560
  identities: IdentityStore::load(config.data.with_extension("identities.json"))?,
8439
- applications: ApplicationStore::load(config.data.with_extension("applications.json"))?,
8561
+ applications: application_store,
8440
8562
  runtimes: RuntimeStore::load(config.data.with_extension("application-runtimes.json"))?,
8441
8563
  grant_signer,
8442
8564
  grants: Arc::new(std::sync::Mutex::new(grant_store)),
@@ -8775,6 +8897,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8775
8897
  .route("/v1/auth/session", get(auth_session))
8776
8898
  .route("/v1/auth/signout", axum::routing::post(auth_sign_out))
8777
8899
  .route("/v1/events", get(events))
8900
+ .route("/v1/public/events/replay", get(public_event_replay))
8778
8901
  .route(
8779
8902
  "/v1/workflows/{name}/run",
8780
8903
  axum::routing::post(start_workflow_run),
@@ -9810,6 +9933,13 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
9810
9933
  status: overall_status(membership == "healthy", clock, &database.storage),
9811
9934
  version: env!("CARGO_PKG_VERSION"),
9812
9935
  git_commit: option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
9936
+ build_timestamp: option_env!("FELTDB_BUILD_TIMESTAMP").unwrap_or("unknown"),
9937
+ release_id: option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
9938
+ deployment_id: std::env::var("FELTDB_DEPLOYMENT_ID")
9939
+ .or_else(|_| std::env::var("FLY_IMAGE_REF"))
9940
+ .unwrap_or_else(|_| "unknown".into()),
9941
+ public_contract: "feltdb.public.substrate@1.0",
9942
+ capability_fingerprint: control_plane::public_contract_capability_fingerprint(),
9813
9943
  runtime: "self-hosted",
9814
9944
  storage: database.storage.label(),
9815
9945
  fabric: "healthy",
@@ -10051,8 +10181,7 @@ fn manage_workloads() -> Result<bool, Box<dyn std::error::Error>> {
10051
10181
  .and_then(|i| arguments.get(i + 1))
10052
10182
  .cloned()
10053
10183
  };
10054
- let path =
10055
- PathBuf::from(option("--store").unwrap_or_else(|| "./data/feltdb.workloads.json".into()));
10184
+ let path = PathBuf::from(option("--data").unwrap_or_else(|| "./data/feltdb.log".into()));
10056
10185
  let mut store = WorkloadStore::load(path)?;
10057
10186
  if family == "worker" || family == "mesh" {
10058
10187
  let mesh_path = PathBuf::from(
@@ -10105,17 +10234,13 @@ fn manage_state_triggers() -> Result<bool, Box<dyn std::error::Error>> {
10105
10234
  ),
10106
10235
  "evaluate" => {
10107
10236
  let db = FeltDb::open(&data)?;
10108
- let workload_path = PathBuf::from(
10109
- option("--workload-store")
10110
- .unwrap_or_else(|| data.with_extension("workloads.json").to_string_lossy().into_owned()),
10111
- );
10112
- let mut workloads = WorkloadStore::load(workload_path)?;
10237
+ let mut workloads = WorkloadStore::from_db(db.clone())?;
10113
10238
  let produced = triggers.evaluate(&db, &mut workloads, unix_seconds_i64())?;
10114
10239
  println!("{}", serde_json::to_string_pretty(&produced)?);
10115
10240
  }
10116
10241
  _ => {
10117
10242
  return Err(
10118
- "usage: feltdb-server trigger <define|list|status|evaluate> [--data path] [--store path] [--workload-store path] [--input trigger.json]"
10243
+ "usage: feltdb-server trigger <define|list|status|evaluate> [--data path] [--store path] [--input trigger.json]"
10119
10244
  .into(),
10120
10245
  )
10121
10246
  }
@@ -13126,7 +13251,7 @@ async fn get_record(
13126
13251
  }
13127
13252
 
13128
13253
  /// One operation inside an application transaction.
13129
- #[derive(Deserialize)]
13254
+ #[derive(Deserialize, Serialize)]
13130
13255
  #[serde(rename_all = "camelCase")]
13131
13256
  struct AtomicTxOperationRequest {
13132
13257
  collection: String,
@@ -13156,7 +13281,7 @@ struct AtomicTxOperationRequest {
13156
13281
  /// fencing routinely guards on a record it does not modify -- a session
13157
13282
  /// generation, an ownership epoch -- and expressing that as a no-op write would
13158
13283
  /// make the guard a mutation.
13159
- #[derive(Deserialize)]
13284
+ #[derive(Deserialize, Serialize)]
13160
13285
  #[serde(rename_all = "camelCase")]
13161
13286
  struct AtomicTxPreconditionRequest {
13162
13287
  collection: String,
@@ -13171,7 +13296,7 @@ struct AtomicTxPreconditionRequest {
13171
13296
  expected_lease_id: Option<String>,
13172
13297
  }
13173
13298
 
13174
- #[derive(Deserialize)]
13299
+ #[derive(Deserialize, Serialize)]
13175
13300
  #[serde(rename_all = "camelCase")]
13176
13301
  struct AtomicTxRequest {
13177
13302
  /// Caller-supplied identity. Reusing one makes the commit idempotent.
@@ -13196,6 +13321,8 @@ struct AtomicTxResponse {
13196
13321
  operations: usize,
13197
13322
  state_before: u64,
13198
13323
  state_after: u64,
13324
+ revision_id: String,
13325
+ operation_ids: Vec<String>,
13199
13326
  }
13200
13327
 
13201
13328
  /// Commit several operations as one atomic, durable transaction.
@@ -13224,6 +13351,16 @@ async fn commit_transaction(
13224
13351
  ));
13225
13352
  }
13226
13353
 
13354
+ // The authority, not the adapter, binds idempotency identity to semantic
13355
+ // input. This digest is written in the same transaction record as the
13356
+ // mutations and retained across compaction.
13357
+ let payload_hash = format!(
13358
+ "sha256:{:x}",
13359
+ Sha256::digest(serde_json::to_vec(&request).map_err(|error| {
13360
+ ApiError(StatusCode::BAD_REQUEST, error.to_string())
13361
+ })?)
13362
+ );
13363
+
13227
13364
  // Validate every operation before any of them is turned into a mutation, so
13228
13365
  // a malformed request cannot commit a prefix of the transaction.
13229
13366
  let mut mutations = Vec::with_capacity(request.operations.len());
@@ -13309,7 +13446,7 @@ async fn commit_transaction(
13309
13446
  .db
13310
13447
  .apply_atomic_transaction_guarded(
13311
13448
  &request.transaction_id,
13312
- None,
13449
+ Some(&payload_hash),
13313
13450
  None,
13314
13451
  &[],
13315
13452
  &record_preconditions,
@@ -13332,9 +13469,27 @@ async fn commit_transaction(
13332
13469
  "failure": *failure,
13333
13470
  }),
13334
13471
  ),
13472
+ FlowError::CapabilityError(message)
13473
+ if message.starts_with("TRANSACTION_ID_PAYLOAD_MISMATCH:") =>
13474
+ {
13475
+ ApiError::structured(
13476
+ StatusCode::CONFLICT,
13477
+ json!({
13478
+ "committed": false,
13479
+ "code": "IDEMPOTENCY_CONFLICT",
13480
+ "error": "transaction identity is already bound to different semantic input",
13481
+ "transaction_id": request.transaction_id,
13482
+ }),
13483
+ )
13484
+ }
13335
13485
  other => ApiError(StatusCode::CONFLICT, other.to_string()),
13336
13486
  }
13337
13487
  })?;
13488
+ let operation_ids = state
13489
+ .db
13490
+ .transaction_attribution(&commit.transaction_id)?
13491
+ .map(|value| value.operation_ids)
13492
+ .unwrap_or_default();
13338
13493
 
13339
13494
  Ok((
13340
13495
  if commit.duplicate {
@@ -13351,6 +13506,8 @@ async fn commit_transaction(
13351
13506
  operations,
13352
13507
  state_before: commit.state_before,
13353
13508
  state_after: commit.state_after,
13509
+ revision_id: format!("revision-{}", commit.commit_revision),
13510
+ operation_ids,
13354
13511
  }),
13355
13512
  ))
13356
13513
  }
@@ -13563,6 +13720,7 @@ async fn delete_record(
13563
13720
  async fn events(
13564
13721
  State(state): State<AppState>,
13565
13722
  Extension(principal): Extension<Principal>,
13723
+ headers: HeaderMap,
13566
13724
  Query(scope): Query<EventScopeRequest>,
13567
13725
  ) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
13568
13726
  if !principal.permits("events:read") {
@@ -13571,39 +13729,137 @@ async fn events(
13571
13729
  "event access is not permitted".into(),
13572
13730
  ));
13573
13731
  }
13574
- if let Some(application_id) = scope.application_id {
13732
+ if let Some(application_id) = scope.application_id.as_ref() {
13575
13733
  application_scope(&state, &principal.key_id, &application_id, "events:read")?;
13576
13734
  }
13577
- let mut receiver = state.db.subscribe_changes();
13578
- let namespace = state.namespace.clone();
13579
- let actor = principal.key_id;
13735
+ let requested_cursor = scope.cursor.as_deref().or_else(|| {
13736
+ headers
13737
+ .get("last-event-id")
13738
+ .and_then(|value| value.to_str().ok())
13739
+ });
13740
+ let mut revision = requested_cursor
13741
+ .map(decode_cursor)
13742
+ .transpose()
13743
+ .map_err(control_error)?
13744
+ .unwrap_or_default();
13745
+ let authority = state.events.clone();
13746
+ let application_id = scope.application_id;
13747
+ let mut pending = authority.after(revision, 256).map_err(control_error)?;
13580
13748
  let stream = async_stream::stream! {
13581
13749
  loop {
13582
- match receiver.recv().await {
13583
- Ok(change) => {
13584
- let resource = change.capability.clone();
13585
- let resource_id = change.key.split_once(':').map(|(_, id)| id).unwrap_or(&change.key);
13586
- let data = json!({
13587
- "event_id": uuid::Uuid::new_v4().to_string(),
13588
- "type": "state.changed",
13589
- "resource": resource,
13590
- "resource_id": resource_id,
13591
- "actor": actor,
13592
- "timestamp": unix_seconds_i64().max(0) as u64,
13593
- "payload": { "operation": "mutation", "namespace": namespace.as_ref() }
13594
- });
13595
- yield Ok(Event::default().event("state.changed").json_data(data).expect("serializable event"));
13596
- }
13597
- Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
13598
- yield Ok(Event::default().event("stream.lagged").data(skipped.to_string()));
13750
+ for event in pending.drain(..) {
13751
+ let event_revision = event
13752
+ .state_version
13753
+ .as_deref()
13754
+ .and_then(|value| value.parse::<u64>().ok())
13755
+ .unwrap_or(revision);
13756
+ revision = revision.max(event_revision);
13757
+ if application_id
13758
+ .as_ref()
13759
+ .is_some_and(|application| event.application_id != *application)
13760
+ {
13761
+ continue;
13599
13762
  }
13600
- Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
13763
+ let cursor = encode_cursor(revision);
13764
+ yield Ok(Event::default()
13765
+ .id(cursor)
13766
+ .event("state.committed")
13767
+ .json_data(event)
13768
+ .expect("serializable event"));
13601
13769
  }
13770
+ tokio::time::sleep(Duration::from_millis(250)).await;
13771
+ pending = match authority.after(revision, 256) {
13772
+ Ok(committed) => committed,
13773
+ Err(error) => {
13774
+ yield Ok(Event::default().event("authority.error").data(error));
13775
+ break;
13776
+ }
13777
+ };
13602
13778
  }
13603
13779
  };
13604
13780
  Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
13605
13781
  }
13606
13782
 
13783
+ /// Bounded JSON replay over canonical committed history.
13784
+ ///
13785
+ /// The cursor is bound to this authority and application. It is deliberately
13786
+ /// not interchangeable with a cursor from another tenant/application scope.
13787
+ async fn public_event_replay(
13788
+ State(state): State<AppState>,
13789
+ Extension(principal): Extension<Principal>,
13790
+ headers: HeaderMap,
13791
+ Query(request): Query<PublicEventReplayRequest>,
13792
+ ) -> Result<Json<Value>, ApiError> {
13793
+ if !principal.permits("events:read") {
13794
+ return Err(ApiError::structured(
13795
+ StatusCode::FORBIDDEN,
13796
+ json!({"code":"FORBIDDEN","message":"event access is not permitted"}),
13797
+ ));
13798
+ }
13799
+ let tenant = application_scope(
13800
+ &state,
13801
+ &principal.key_id,
13802
+ &request.application_id,
13803
+ "events:read",
13804
+ )?;
13805
+ require_bound_tenant(&headers, &tenant)?;
13806
+ let instance_id = state.db.instance_id()?;
13807
+ let cursor_scope = format!("{instance_id}:{tenant}:{}", request.application_id);
13808
+ let mut revision = match request.cursor.as_deref() {
13809
+ Some(cursor) => decode_scoped_cursor(&cursor_scope, cursor).map_err(|code| {
13810
+ let status = if code == "CURSOR_SCOPE_VIOLATION" {
13811
+ StatusCode::FORBIDDEN
13812
+ } else {
13813
+ StatusCode::BAD_REQUEST
13814
+ };
13815
+ ApiError::structured(
13816
+ status,
13817
+ json!({"code":code,"message":"event cursor is invalid for this authority scope"}),
13818
+ )
13819
+ })?,
13820
+ None => 0,
13821
+ };
13822
+ let limit = request.limit.unwrap_or(100).clamp(1, 256);
13823
+ let mut projected = Vec::new();
13824
+ loop {
13825
+ let batch = state.events.after(revision, 256).map_err(control_error)?;
13826
+ let batch_len = batch.len();
13827
+ if batch_len == 0 {
13828
+ break;
13829
+ }
13830
+ for mut event in batch {
13831
+ let event_revision = event
13832
+ .state_version
13833
+ .as_deref()
13834
+ .and_then(|value| value.parse::<u64>().ok())
13835
+ .unwrap_or(revision);
13836
+ revision = revision.max(event_revision);
13837
+ if event.application_id != request.application_id {
13838
+ continue;
13839
+ }
13840
+ event.metadata.insert(
13841
+ "cursor".into(),
13842
+ Value::String(encode_scoped_cursor(&cursor_scope, event_revision)),
13843
+ );
13844
+ projected.push(event);
13845
+ if projected.len() == limit {
13846
+ break;
13847
+ }
13848
+ }
13849
+ if projected.len() == limit || batch_len < 256 {
13850
+ break;
13851
+ }
13852
+ }
13853
+ Ok(Json(json!({
13854
+ "contract_version":"1.0",
13855
+ "cursor_format":"fh1",
13856
+ "application_id":request.application_id,
13857
+ "environment":request.environment,
13858
+ "events":projected,
13859
+ "next_cursor":encode_scoped_cursor(&cursor_scope, revision),
13860
+ })))
13861
+ }
13862
+
13607
13863
  fn record_response(row: StoredRow) -> RecordResponse {
13608
13864
  let id = row
13609
13865
  .key
@@ -13690,19 +13946,84 @@ struct ControlPlaneScope {
13690
13946
  environment: String,
13691
13947
  }
13692
13948
 
13949
+ fn authority_state_summary(
13950
+ current_sequence: u64,
13951
+ events: u64,
13952
+ cardinalities: &[(String, u64)],
13953
+ namespace_prefix: &str,
13954
+ ) -> Value {
13955
+ let collections = cardinalities
13956
+ .iter()
13957
+ .filter(|(capability, count)| *capability != "state" && *count > 0)
13958
+ .map(|(capability, count)| {
13959
+ json!({
13960
+ "collection": capability.strip_prefix(namespace_prefix).unwrap_or(capability),
13961
+ "records": count,
13962
+ })
13963
+ })
13964
+ .collect::<Vec<_>>();
13965
+ let records = collections
13966
+ .iter()
13967
+ .filter_map(|entry| entry["records"].as_u64())
13968
+ .sum::<u64>();
13969
+ json!({
13970
+ "current_sequence": current_sequence,
13971
+ "records": records,
13972
+ "events": events,
13973
+ "collections": collections,
13974
+ })
13975
+ }
13976
+
13977
+ #[cfg(test)]
13978
+ mod studio_state_summary_tests {
13979
+ use super::authority_state_summary;
13980
+ use serde_json::json;
13981
+
13982
+ #[test]
13983
+ fn fresh_authority_is_a_truthful_zero_summary() {
13984
+ assert_eq!(
13985
+ authority_state_summary(0, 0, &[], "default:"),
13986
+ json!({"current_sequence":0,"records":0,"events":0,"collections":[]})
13987
+ );
13988
+ }
13989
+
13990
+ #[test]
13991
+ fn summary_counts_live_authority_rows_without_counting_revision_infrastructure() {
13992
+ let cardinalities = vec![
13993
+ ("_feltdb.Intent".to_string(), 1),
13994
+ ("_feltdb.IntentEvent".to_string(), 2),
13995
+ ("state".to_string(), 7),
13996
+ ("empty".to_string(), 0),
13997
+ ];
13998
+ assert_eq!(
13999
+ authority_state_summary(4, 4, &cardinalities, "default:"),
14000
+ json!({
14001
+ "current_sequence":4,
14002
+ "records":3,
14003
+ "events":4,
14004
+ "collections":[
14005
+ {"collection":"_feltdb.Intent","records":1},
14006
+ {"collection":"_feltdb.IntentEvent","records":2}
14007
+ ]
14008
+ })
14009
+ );
14010
+ }
14011
+ }
14012
+
13693
14013
  /// Instance, authority, runtime, storage and activity in one document.
13694
14014
  ///
13695
14015
  /// One request rather than eight, because an overview whose panels arrive
13696
14016
  /// independently shows eight moments and calls them one instant.
13697
14017
  async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
13698
14018
  let db = state.db.clone();
13699
- let (state_version, authority_revision, instance_id, cardinalities, log) =
14019
+ let (state_version, authority_revision, instance_id, cardinalities, events, log) =
13700
14020
  tokio::task::spawn_blocking(move || {
13701
14021
  Ok::<_, FlowError>((
13702
14022
  db.sequence()?,
13703
14023
  db.current_revision()?,
13704
14024
  db.instance_id()?,
13705
14025
  db.list_cardinalities()?,
14026
+ db.operations_since(&HashMap::new())?.len() as u64,
13706
14027
  db.log_recovery(),
13707
14028
  ))
13708
14029
  })
@@ -13730,6 +14051,12 @@ async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Va
13730
14051
  .find(|(capability, _)| capability == "state")
13731
14052
  .map(|(_, count)| *count)
13732
14053
  .unwrap_or(0);
14054
+ // This is the authority-wide materialized view used by Studio's State
14055
+ // summary. Unlike the application-oriented `storage.collections` above,
14056
+ // it deliberately includes `_feltdb.*` system collections. State revision
14057
+ // rows are history infrastructure rather than current materialized records
14058
+ // and remain reported separately as `storage.state_revisions`.
14059
+ let authority_state = authority_state_summary(state_version, events, &cardinalities, &prefix);
13733
14060
  Ok(Json(json!({
13734
14061
  "instance": {
13735
14062
  "instance_id": instance_id,
@@ -13745,6 +14072,7 @@ async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Va
13745
14072
  "lease_clock_healthy": state.lease_clock.is_healthy(),
13746
14073
  "advertise_url": state.advertise_url.as_ref().map(|value| value.to_string()),
13747
14074
  },
14075
+ "authority_state": authority_state,
13748
14076
  "storage": {
13749
14077
  "records": records,
13750
14078
  "state_revisions": revisions,
@@ -14232,10 +14560,37 @@ async fn control_plane_transaction(
14232
14560
  let db = state.db.clone();
14233
14561
  let id = transaction_id.clone();
14234
14562
  let (applied, attribution) = tokio::task::spawn_blocking(move || {
14235
- Ok::<_, FlowError>((
14236
- db.has_applied_transaction(&id)?,
14237
- db.transaction_attribution(&id)?,
14238
- ))
14563
+ let direct = db.transaction_attribution(&id)?;
14564
+ let mut public_matches = if direct.is_none() {
14565
+ db.committed_transactions_after(0, usize::MAX)?
14566
+ .into_iter()
14567
+ .filter(|value| {
14568
+ value
14569
+ .audit
14570
+ .as_ref()
14571
+ .and_then(|audit| audit.get("transaction_id"))
14572
+ .and_then(Value::as_str)
14573
+ == Some(id.as_str())
14574
+ })
14575
+ .collect::<Vec<_>>()
14576
+ } else {
14577
+ Vec::new()
14578
+ };
14579
+ if public_matches.len() > 1 {
14580
+ return Err(FlowError::CapabilityError(
14581
+ "PUBLIC_TRANSACTION_ID_AMBIGUOUS".into(),
14582
+ ));
14583
+ }
14584
+ let mut attribution = direct.or_else(|| public_matches.pop());
14585
+ let storage_id = attribution
14586
+ .as_ref()
14587
+ .map(|value| value.transaction_id.as_str())
14588
+ .unwrap_or(id.as_str());
14589
+ let applied = db.has_applied_transaction(storage_id)?;
14590
+ if let Some(value) = attribution.as_mut() {
14591
+ value.transaction_id = id;
14592
+ }
14593
+ Ok::<_, FlowError>((applied, attribution))
14239
14594
  })
14240
14595
  .await
14241
14596
  .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
@@ -15002,8 +15357,8 @@ mod worker_identity_tests {
15002
15357
 
15003
15358
  #[cfg(test)]
15004
15359
  mod authority_gate_tests {
15005
- use super::{api_error_body, authorize_transaction_collections, state_authorization};
15006
- use axum::http::StatusCode;
15360
+ use super::{api_error_body, authorize_transaction_collections, require_bound_tenant, state_authorization};
15361
+ use axum::http::{HeaderMap, HeaderValue, StatusCode};
15007
15362
  use feltdb::{
15008
15363
  application::{
15009
15364
  manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition,
@@ -15111,6 +15466,17 @@ mod authority_gate_tests {
15111
15466
  assert!(!allowed);
15112
15467
  }
15113
15468
 
15469
+ #[test]
15470
+ fn bound_tenant_header_fails_closed_on_mismatch() {
15471
+ let mut headers = HeaderMap::new();
15472
+ headers.insert("feltdb-tenant-id", HeaderValue::from_static("tenant-a"));
15473
+ assert!(require_bound_tenant(&headers, "tenant-a").is_ok());
15474
+ let error = require_bound_tenant(&headers, "tenant-b").unwrap_err();
15475
+ assert_eq!(error.0, StatusCode::FORBIDDEN);
15476
+ let body: serde_json::Value = serde_json::from_str(&error.1).unwrap();
15477
+ assert_eq!(body.get("code").and_then(serde_json::Value::as_str), Some("AUTHORITY_SCOPE_MISMATCH"));
15478
+ }
15479
+
15114
15480
  #[test]
15115
15481
  fn every_forbidden_error_uses_the_canonical_wire_contract() {
15116
15482
  for internal_message in [