@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
@@ -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,
@@ -7778,6 +7843,11 @@ async fn execute_canonical_transaction(
7778
7843
  "state:denied"
7779
7844
  },
7780
7845
  );
7846
+ let _transaction_permit = state
7847
+ .state_transaction_admission
7848
+ .acquire()
7849
+ .await
7850
+ .expect("transaction admission is never closed");
7781
7851
  let result = execute_transaction_with_collection_policies(
7782
7852
  &state.db,
7783
7853
  &contract.state.schema,
@@ -7793,7 +7863,13 @@ async fn execute_canonical_transaction(
7793
7863
  "allowed",
7794
7864
  201,
7795
7865
  );
7796
- 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))
7797
7873
  }
7798
7874
 
7799
7875
  async fn get_cardinality_diagnostic(
@@ -8388,7 +8464,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8388
8464
  let grant_signer = GrantSigner::new("feltdb-primary-v1", grant_key.to_vec())?;
8389
8465
  let grant_store = GrantStore::load(config.data.with_extension("grants.json"))?;
8390
8466
  let sync_store = SyncStore::load(config.data.with_extension("sync.json"))?;
8391
- 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
+ };
8392
8473
  let state_trigger_store = StateTriggerStore::load(config.data.with_extension("triggers.json"))?;
8393
8474
  let mesh_store = WorkerMeshStore::load(config.data.with_extension("workers.json"))?;
8394
8475
  let content_store = ContentStore::new(config.data.with_extension("content"))?;
@@ -8398,9 +8479,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8398
8479
  )?;
8399
8480
  let bundle_store = BundleStore::load(config.data.with_extension("bundles.json"))?;
8400
8481
  let release_store = ReleaseStore::load(config.data.with_extension("releases.json"))?;
8401
- 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
+ }
8402
8487
  let provider_store = ProviderStore::load(config.data.with_extension("providers.json"))?;
8403
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
+ }
8404
8504
  // Constructed before the state so the same handle can be stopped after
8405
8505
  // serving: the pipeline outlives the router by exactly one shutdown.
8406
8506
  let audit_handle = AuditLog::new(config.audit.clone());
@@ -8426,13 +8526,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8426
8526
  peer_client,
8427
8527
  peer_token,
8428
8528
  lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
8529
+ state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(2)),
8429
8530
  worker_enabled: config.worker_enabled,
8430
8531
  leases: LeaseStore::load(config.data.with_extension("leases.json"))?,
8431
8532
  content: content_store,
8432
8533
  artifacts: artifact_store,
8433
8534
  bundles: bundle_store,
8434
8535
  releases: release_store,
8435
- causal: causal_store,
8536
+ events: event_authority,
8436
8537
  providers: provider_store,
8437
8538
  contracts: ApplicationContractStore::load(
8438
8539
  config.data.with_extension("application-contracts.json"),
@@ -8441,14 +8542,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8441
8542
  lease_clock: LeaseClock::new(config.maximum_clock_drift_ms),
8442
8543
  backup_barrier: Arc::new(tokio::sync::RwLock::new(())),
8443
8544
  backup_artifacts: Arc::new(backup_artifacts),
8444
- tenancy: TenancyStore::load(config.data.with_extension("platform.json"))?,
8545
+ tenancy: tenancy_store,
8445
8546
  sessions: SessionVerifier::load(
8446
8547
  std::env::var("FELTDB_SESSION_SECRET")
8447
8548
  .unwrap_or_else(|_| "feltdb-development-session-secret-change-me".into())
8448
8549
  .into_bytes(),
8449
8550
  config.data.with_extension("revoked-sessions.json"),
8450
8551
  )?,
8451
- principals: PrincipalStore::load(config.data.with_extension("principals.json"))?,
8552
+ principals: principal_authority,
8452
8553
  connections: ConnectionStore::load_with_provider(
8453
8554
  config.data.with_extension("connections.json"),
8454
8555
  provider_from_environment(
@@ -8457,7 +8558,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8457
8558
  )?,
8458
8559
  )?,
8459
8560
  identities: IdentityStore::load(config.data.with_extension("identities.json"))?,
8460
- applications: ApplicationStore::load(config.data.with_extension("applications.json"))?,
8561
+ applications: application_store,
8461
8562
  runtimes: RuntimeStore::load(config.data.with_extension("application-runtimes.json"))?,
8462
8563
  grant_signer,
8463
8564
  grants: Arc::new(std::sync::Mutex::new(grant_store)),
@@ -8796,6 +8897,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8796
8897
  .route("/v1/auth/session", get(auth_session))
8797
8898
  .route("/v1/auth/signout", axum::routing::post(auth_sign_out))
8798
8899
  .route("/v1/events", get(events))
8900
+ .route("/v1/public/events/replay", get(public_event_replay))
8799
8901
  .route(
8800
8902
  "/v1/workflows/{name}/run",
8801
8903
  axum::routing::post(start_workflow_run),
@@ -9831,6 +9933,13 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
9831
9933
  status: overall_status(membership == "healthy", clock, &database.storage),
9832
9934
  version: env!("CARGO_PKG_VERSION"),
9833
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(),
9834
9943
  runtime: "self-hosted",
9835
9944
  storage: database.storage.label(),
9836
9945
  fabric: "healthy",
@@ -10072,8 +10181,7 @@ fn manage_workloads() -> Result<bool, Box<dyn std::error::Error>> {
10072
10181
  .and_then(|i| arguments.get(i + 1))
10073
10182
  .cloned()
10074
10183
  };
10075
- let path =
10076
- 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()));
10077
10185
  let mut store = WorkloadStore::load(path)?;
10078
10186
  if family == "worker" || family == "mesh" {
10079
10187
  let mesh_path = PathBuf::from(
@@ -10126,17 +10234,13 @@ fn manage_state_triggers() -> Result<bool, Box<dyn std::error::Error>> {
10126
10234
  ),
10127
10235
  "evaluate" => {
10128
10236
  let db = FeltDb::open(&data)?;
10129
- let workload_path = PathBuf::from(
10130
- option("--workload-store")
10131
- .unwrap_or_else(|| data.with_extension("workloads.json").to_string_lossy().into_owned()),
10132
- );
10133
- let mut workloads = WorkloadStore::load(workload_path)?;
10237
+ let mut workloads = WorkloadStore::from_db(db.clone())?;
10134
10238
  let produced = triggers.evaluate(&db, &mut workloads, unix_seconds_i64())?;
10135
10239
  println!("{}", serde_json::to_string_pretty(&produced)?);
10136
10240
  }
10137
10241
  _ => {
10138
10242
  return Err(
10139
- "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]"
10140
10244
  .into(),
10141
10245
  )
10142
10246
  }
@@ -13147,7 +13251,7 @@ async fn get_record(
13147
13251
  }
13148
13252
 
13149
13253
  /// One operation inside an application transaction.
13150
- #[derive(Deserialize)]
13254
+ #[derive(Deserialize, Serialize)]
13151
13255
  #[serde(rename_all = "camelCase")]
13152
13256
  struct AtomicTxOperationRequest {
13153
13257
  collection: String,
@@ -13177,7 +13281,7 @@ struct AtomicTxOperationRequest {
13177
13281
  /// fencing routinely guards on a record it does not modify -- a session
13178
13282
  /// generation, an ownership epoch -- and expressing that as a no-op write would
13179
13283
  /// make the guard a mutation.
13180
- #[derive(Deserialize)]
13284
+ #[derive(Deserialize, Serialize)]
13181
13285
  #[serde(rename_all = "camelCase")]
13182
13286
  struct AtomicTxPreconditionRequest {
13183
13287
  collection: String,
@@ -13192,7 +13296,7 @@ struct AtomicTxPreconditionRequest {
13192
13296
  expected_lease_id: Option<String>,
13193
13297
  }
13194
13298
 
13195
- #[derive(Deserialize)]
13299
+ #[derive(Deserialize, Serialize)]
13196
13300
  #[serde(rename_all = "camelCase")]
13197
13301
  struct AtomicTxRequest {
13198
13302
  /// Caller-supplied identity. Reusing one makes the commit idempotent.
@@ -13217,6 +13321,8 @@ struct AtomicTxResponse {
13217
13321
  operations: usize,
13218
13322
  state_before: u64,
13219
13323
  state_after: u64,
13324
+ revision_id: String,
13325
+ operation_ids: Vec<String>,
13220
13326
  }
13221
13327
 
13222
13328
  /// Commit several operations as one atomic, durable transaction.
@@ -13245,6 +13351,16 @@ async fn commit_transaction(
13245
13351
  ));
13246
13352
  }
13247
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
+
13248
13364
  // Validate every operation before any of them is turned into a mutation, so
13249
13365
  // a malformed request cannot commit a prefix of the transaction.
13250
13366
  let mut mutations = Vec::with_capacity(request.operations.len());
@@ -13330,7 +13446,7 @@ async fn commit_transaction(
13330
13446
  .db
13331
13447
  .apply_atomic_transaction_guarded(
13332
13448
  &request.transaction_id,
13333
- None,
13449
+ Some(&payload_hash),
13334
13450
  None,
13335
13451
  &[],
13336
13452
  &record_preconditions,
@@ -13353,9 +13469,27 @@ async fn commit_transaction(
13353
13469
  "failure": *failure,
13354
13470
  }),
13355
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
+ }
13356
13485
  other => ApiError(StatusCode::CONFLICT, other.to_string()),
13357
13486
  }
13358
13487
  })?;
13488
+ let operation_ids = state
13489
+ .db
13490
+ .transaction_attribution(&commit.transaction_id)?
13491
+ .map(|value| value.operation_ids)
13492
+ .unwrap_or_default();
13359
13493
 
13360
13494
  Ok((
13361
13495
  if commit.duplicate {
@@ -13372,6 +13506,8 @@ async fn commit_transaction(
13372
13506
  operations,
13373
13507
  state_before: commit.state_before,
13374
13508
  state_after: commit.state_after,
13509
+ revision_id: format!("revision-{}", commit.commit_revision),
13510
+ operation_ids,
13375
13511
  }),
13376
13512
  ))
13377
13513
  }
@@ -13584,6 +13720,7 @@ async fn delete_record(
13584
13720
  async fn events(
13585
13721
  State(state): State<AppState>,
13586
13722
  Extension(principal): Extension<Principal>,
13723
+ headers: HeaderMap,
13587
13724
  Query(scope): Query<EventScopeRequest>,
13588
13725
  ) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
13589
13726
  if !principal.permits("events:read") {
@@ -13592,39 +13729,137 @@ async fn events(
13592
13729
  "event access is not permitted".into(),
13593
13730
  ));
13594
13731
  }
13595
- if let Some(application_id) = scope.application_id {
13732
+ if let Some(application_id) = scope.application_id.as_ref() {
13596
13733
  application_scope(&state, &principal.key_id, &application_id, "events:read")?;
13597
13734
  }
13598
- let mut receiver = state.db.subscribe_changes();
13599
- let namespace = state.namespace.clone();
13600
- 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)?;
13601
13748
  let stream = async_stream::stream! {
13602
13749
  loop {
13603
- match receiver.recv().await {
13604
- Ok(change) => {
13605
- let resource = change.capability.clone();
13606
- let resource_id = change.key.split_once(':').map(|(_, id)| id).unwrap_or(&change.key);
13607
- let data = json!({
13608
- "event_id": uuid::Uuid::new_v4().to_string(),
13609
- "type": "state.changed",
13610
- "resource": resource,
13611
- "resource_id": resource_id,
13612
- "actor": actor,
13613
- "timestamp": unix_seconds_i64().max(0) as u64,
13614
- "payload": { "operation": "mutation", "namespace": namespace.as_ref() }
13615
- });
13616
- yield Ok(Event::default().event("state.changed").json_data(data).expect("serializable event"));
13617
- }
13618
- Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
13619
- 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;
13620
13762
  }
13621
- 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"));
13622
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
+ };
13623
13778
  }
13624
13779
  };
13625
13780
  Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
13626
13781
  }
13627
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
+
13628
13863
  fn record_response(row: StoredRow) -> RecordResponse {
13629
13864
  let id = row
13630
13865
  .key
@@ -13711,19 +13946,84 @@ struct ControlPlaneScope {
13711
13946
  environment: String,
13712
13947
  }
13713
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
+
13714
14013
  /// Instance, authority, runtime, storage and activity in one document.
13715
14014
  ///
13716
14015
  /// One request rather than eight, because an overview whose panels arrive
13717
14016
  /// independently shows eight moments and calls them one instant.
13718
14017
  async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
13719
14018
  let db = state.db.clone();
13720
- let (state_version, authority_revision, instance_id, cardinalities, log) =
14019
+ let (state_version, authority_revision, instance_id, cardinalities, events, log) =
13721
14020
  tokio::task::spawn_blocking(move || {
13722
14021
  Ok::<_, FlowError>((
13723
14022
  db.sequence()?,
13724
14023
  db.current_revision()?,
13725
14024
  db.instance_id()?,
13726
14025
  db.list_cardinalities()?,
14026
+ db.operations_since(&HashMap::new())?.len() as u64,
13727
14027
  db.log_recovery(),
13728
14028
  ))
13729
14029
  })
@@ -13751,6 +14051,12 @@ async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Va
13751
14051
  .find(|(capability, _)| capability == "state")
13752
14052
  .map(|(_, count)| *count)
13753
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);
13754
14060
  Ok(Json(json!({
13755
14061
  "instance": {
13756
14062
  "instance_id": instance_id,
@@ -13766,6 +14072,7 @@ async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Va
13766
14072
  "lease_clock_healthy": state.lease_clock.is_healthy(),
13767
14073
  "advertise_url": state.advertise_url.as_ref().map(|value| value.to_string()),
13768
14074
  },
14075
+ "authority_state": authority_state,
13769
14076
  "storage": {
13770
14077
  "records": records,
13771
14078
  "state_revisions": revisions,
@@ -14253,10 +14560,37 @@ async fn control_plane_transaction(
14253
14560
  let db = state.db.clone();
14254
14561
  let id = transaction_id.clone();
14255
14562
  let (applied, attribution) = tokio::task::spawn_blocking(move || {
14256
- Ok::<_, FlowError>((
14257
- db.has_applied_transaction(&id)?,
14258
- db.transaction_attribution(&id)?,
14259
- ))
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))
14260
14594
  })
14261
14595
  .await
14262
14596
  .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;