@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
@@ -62,7 +62,7 @@ use feltdb::{
62
62
  },
63
63
  workload::{CreateWorkload, WorkloadStore},
64
64
  AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
65
- PeerAdvertisement, PeerId, RecordPrecondition, StateTriggerStore, StoredRow,
65
+ PeerAdvertisement, PeerId, RecordPrecondition, StateRevision, StateTriggerStore, StoredRow,
66
66
  };
67
67
  use feltdb_server::{
68
68
  app_state::{AppState, BoundedQueryCursor},
@@ -82,13 +82,14 @@ use feltdb_server::{
82
82
  cluster::{ClusterConfig, ClusterStore, MembershipProposal, ProposalPhase},
83
83
  connections::{ConnectionProvider, ConnectionStatus, ConnectionStore, SecretReference},
84
84
  content::ContentStore,
85
+ control_plane,
85
86
  identity::IdentityStore,
86
87
  key_management::{create_key, list_keys, revoke_key},
87
88
  key_provider::provider_from_environment,
88
89
  leases::LeaseStore,
89
90
  metrics::Metrics,
90
91
  portable_bundle::{BundleArtifact, BundleStore, ExportOptions, ImportMode, PortableBundle},
91
- principals::{PrincipalKind, PrincipalStore},
92
+ principals::{PrincipalKind, PrincipalStatus, PrincipalStore},
92
93
  providers::{ProviderManifest, ProviderStatus, ProviderStore},
93
94
  releases::{
94
95
  provider_for, AuthorizedDeploymentContext, DeploymentPlan, DeploymentProfile,
@@ -7512,17 +7513,29 @@ async fn get_state_version(
7512
7513
  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}),
7513
7514
  ))
7514
7515
  }
7516
+ fn require_bound_tenant(headers: &HeaderMap, tenant: &str) -> Result<(), ApiError> {
7517
+ let Some(bound) = headers.get("feltdb-tenant-id") else { return Ok(()); };
7518
+ if bound.to_str().ok() == Some(tenant) { return Ok(()); }
7519
+ Err(ApiError(StatusCode::FORBIDDEN, json!({
7520
+ "code":"AUTHORITY_SCOPE_MISMATCH",
7521
+ "message":"request tenant does not match the authenticated application authority",
7522
+ "http_status":403
7523
+ }).to_string()))
7524
+ }
7525
+
7515
7526
  async fn execute_canonical_query(
7516
7527
  State(state): State<AppState>,
7517
7528
  Extension(principal): Extension<Principal>,
7529
+ headers: HeaderMap,
7518
7530
  Json(input): Json<CanonicalQueryRequest>,
7519
7531
  ) -> Result<Json<Value>, ApiError> {
7520
- let _ = application_scope(
7532
+ let tenant = application_scope(
7521
7533
  &state,
7522
7534
  &principal.key_id,
7523
7535
  &input.application_id,
7524
7536
  "state:read",
7525
7537
  )?;
7538
+ require_bound_tenant(&headers, &tenant)?;
7526
7539
  let contract = revision_contract(
7527
7540
  &state,
7528
7541
  &principal.key_id,
@@ -7717,6 +7730,7 @@ async fn execute_revision_recovery(
7717
7730
  async fn execute_canonical_transaction(
7718
7731
  State(state): State<AppState>,
7719
7732
  Extension(principal): Extension<Principal>,
7733
+ headers: HeaderMap,
7720
7734
  Json(mut input): Json<CanonicalTransactionRequest>,
7721
7735
  ) -> Result<Json<Value>, ApiError> {
7722
7736
  let tenant = application_scope(
@@ -7725,6 +7739,7 @@ async fn execute_canonical_transaction(
7725
7739
  &input.application_id,
7726
7740
  "state:write",
7727
7741
  )?;
7742
+ require_bound_tenant(&headers, &tenant)?;
7728
7743
  let contract = revision_contract(
7729
7744
  &state,
7730
7745
  &principal.key_id,
@@ -7732,6 +7747,13 @@ async fn execute_canonical_transaction(
7732
7747
  &input.revision_id,
7733
7748
  &input.environment,
7734
7749
  )?;
7750
+ if !input.transaction.tenant_id.is_empty() && input.transaction.tenant_id != tenant {
7751
+ return Err(ApiError(StatusCode::FORBIDDEN, json!({
7752
+ "code":"AUTHORITY_SCOPE_MISMATCH",
7753
+ "message":"transaction tenant does not match the bound authority",
7754
+ "http_status":403
7755
+ }).to_string()));
7756
+ }
7735
7757
  input.transaction.tenant_id = tenant.clone();
7736
7758
  input.transaction.application_id = input.application_id.clone();
7737
7759
  input.transaction.revision_id = contract.revision_id.clone();
@@ -8675,6 +8697,60 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8675
8697
  )
8676
8698
  .route("/v1/schema", get(get_state_schema))
8677
8699
  .route("/v1/application", get(discover_application))
8700
+ .route(
8701
+ "/v1/control-plane/capabilities",
8702
+ get(control_plane_capabilities),
8703
+ )
8704
+ .route("/v1/control-plane/overview", get(control_plane_overview))
8705
+ .route("/v1/control-plane/actions", get(control_plane_actions))
8706
+ .route("/v1/control-plane/indexes", get(control_plane_indexes))
8707
+ .route(
8708
+ "/v1/control-plane/indexes/rebuild",
8709
+ axum::routing::post(control_plane_rebuild_index),
8710
+ )
8711
+ .route(
8712
+ "/v1/control-plane/operations",
8713
+ get(control_plane_operations),
8714
+ )
8715
+ .route(
8716
+ "/v1/control-plane/replication",
8717
+ get(control_plane_replication),
8718
+ )
8719
+ .route(
8720
+ "/v1/control-plane/applications",
8721
+ get(control_plane_applications),
8722
+ )
8723
+ .route("/v1/control-plane/developer", get(control_plane_developer))
8724
+ .route(
8725
+ "/v1/control-plane/transactions",
8726
+ get(control_plane_transactions),
8727
+ )
8728
+ .route(
8729
+ "/v1/control-plane/transactions/{transaction_id}",
8730
+ get(control_plane_transaction),
8731
+ )
8732
+ .route(
8733
+ "/v1/control-plane/provenance/{collection}/{id}",
8734
+ get(control_plane_record_provenance),
8735
+ )
8736
+ .route("/v1/control-plane/agents", get(control_plane_agents))
8737
+ .route("/v1/control-plane/agents/{agent_id}", get(control_plane_agent))
8738
+ .route("/v1/state/resources", get(state_resources))
8739
+ .route(
8740
+ "/v1/state/resources/{resource}/history",
8741
+ get(state_resource_history),
8742
+ )
8743
+ .route("/v1/state/revisions/{id}", get(state_revision))
8744
+ .route("/v1/state/diff", axum::routing::post(state_diff))
8745
+ .route("/v1/state/conflicts", axum::routing::post(state_conflicts))
8746
+ .route(
8747
+ "/v1/state/reconciliation/plan",
8748
+ axum::routing::post(state_reconciliation_plan),
8749
+ )
8750
+ .route(
8751
+ "/v1/state/reconciliation/execute",
8752
+ axum::routing::post(state_reconciliation_execute),
8753
+ )
8678
8754
  .route("/v1/application/schema", get(inspect_application_schema))
8679
8755
  .route("/v1/data/{collection}", get(inspect_collection))
8680
8756
  .route("/v1/data/{collection}/{id}", get(inspect_record))
@@ -9329,6 +9405,16 @@ async fn authenticate(
9329
9405
  let path = request.uri().path();
9330
9406
  let required = if path == "/events" {
9331
9407
  "events:read"
9408
+ } else if path == "/v1/state/diff"
9409
+ || path == "/v1/state/conflicts"
9410
+ || path == "/v1/state/reconciliation/plan"
9411
+ {
9412
+ // Describing state is reading it. These are POSTs because they take
9413
+ // three revision identities and a plan, not because they change
9414
+ // anything: the diff, the classification and the plan preview are all
9415
+ // pure. Letting the method decide the capability would make inspecting
9416
+ // a conflict require permission to resolve one.
9417
+ "state:read"
9332
9418
  } else if path == "/sync/pull" || path == "/sync/snapshot" {
9333
9419
  "sync:read"
9334
9420
  } else if path == "/sync/push" {
@@ -13554,6 +13640,1285 @@ fn record_response(row: StoredRow) -> RecordResponse {
13554
13640
  }
13555
13641
  }
13556
13642
 
13643
+ // ============================================================================
13644
+ // The human control plane
13645
+ // ============================================================================
13646
+ //
13647
+ // Studio is FeltDB's human surface. It is not a second implementation of
13648
+ // FeltDB, and these routes are what make that possible: every one of them is a
13649
+ // public, authorized FeltDB capability that an application or an agent may call
13650
+ // on exactly the same terms. Nothing here reads a state file, reaches into
13651
+ // storage, or offers a mutation path Studio alone can use.
13652
+ //
13653
+ // The rule this section exists to hold: **if Studio can perform it, the
13654
+ // operation is a FeltDB capability**. When it is not one, the capability
13655
+ // declaration says so, and the interface renders it unavailable rather than
13656
+ // drawing a control that cannot work.
13657
+
13658
+ /// The state store, opened through the canonical facade.
13659
+ ///
13660
+ /// Deliberately the facade rather than `StateStore::with_feltdb`: the state
13661
+ /// subsystem names one way in, and a second entry point here would be the first
13662
+ /// step toward the control plane growing its own copy of the state model.
13663
+ fn control_plane_state_store(state: &AppState) -> Result<feltdb::StateStore, ApiError> {
13664
+ feltdb::FeltDBStateSystem::create_store(&Arc::new(state.db.clone()))
13665
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error))
13666
+ }
13667
+
13668
+ fn control_plane_error(status: StatusCode, code: &str, message: impl Into<String>) -> ApiError {
13669
+ ApiError::structured(status, json!({"code": code, "message": message.into()}))
13670
+ }
13671
+
13672
+ /// Reads a revision, or says precisely which one was missing.
13673
+ fn control_plane_revision(
13674
+ store: &feltdb::StateStore,
13675
+ label: &str,
13676
+ id: &str,
13677
+ ) -> Result<StateRevision, ApiError> {
13678
+ store
13679
+ .get(&feltdb::StateId::from_hex(id.to_string()))
13680
+ .ok_or_else(|| {
13681
+ control_plane_error(
13682
+ StatusCode::NOT_FOUND,
13683
+ "revision_not_found",
13684
+ format!("the {label} revision {id} is not held by this instance"),
13685
+ )
13686
+ })
13687
+ }
13688
+
13689
+ /// A revision's content as JSON, redacted the same way inspected records are.
13690
+ ///
13691
+ /// Revision content is record content. It reaches a human through the same
13692
+ /// redaction the data browser applies, because a secret does not stop being a
13693
+ /// secret by being historical.
13694
+ fn control_plane_revision_content(revision: &StateRevision) -> Value {
13695
+ let mut content =
13696
+ serde_json::from_str::<Value>(&revision.content).unwrap_or(Value::String(String::new()));
13697
+ redact_inspection_value(&mut content);
13698
+ content
13699
+ }
13700
+
13701
+ /// What the whole control plane declares it can do.
13702
+ async fn control_plane_capabilities() -> Json<Value> {
13703
+ Json(control_plane::capabilities())
13704
+ }
13705
+
13706
+ #[derive(Deserialize)]
13707
+ struct ControlPlaneScope {
13708
+ #[serde(default)]
13709
+ application_id: Option<String>,
13710
+ #[serde(default = "production_environment")]
13711
+ environment: String,
13712
+ }
13713
+
13714
+ /// Instance, authority, runtime, storage and activity in one document.
13715
+ ///
13716
+ /// One request rather than eight, because an overview whose panels arrive
13717
+ /// independently shows eight moments and calls them one instant.
13718
+ async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
13719
+ let db = state.db.clone();
13720
+ let (state_version, authority_revision, instance_id, cardinalities, log) =
13721
+ tokio::task::spawn_blocking(move || {
13722
+ Ok::<_, FlowError>((
13723
+ db.sequence()?,
13724
+ db.current_revision()?,
13725
+ db.instance_id()?,
13726
+ db.list_cardinalities()?,
13727
+ db.log_recovery(),
13728
+ ))
13729
+ })
13730
+ .await
13731
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
13732
+ let namespace = state.namespace.to_string();
13733
+ let prefix = format!("{namespace}:");
13734
+ let collections = cardinalities
13735
+ .iter()
13736
+ .filter(|(capability, _)| !capability.starts_with('_') && *capability != "state")
13737
+ .map(|(capability, count)| {
13738
+ json!({
13739
+ "capability": capability,
13740
+ "collection": capability.strip_prefix(&prefix).unwrap_or(capability),
13741
+ "records": count,
13742
+ })
13743
+ })
13744
+ .collect::<Vec<_>>();
13745
+ let records: u64 = collections
13746
+ .iter()
13747
+ .filter_map(|entry| entry["records"].as_u64())
13748
+ .sum();
13749
+ let revisions = cardinalities
13750
+ .iter()
13751
+ .find(|(capability, _)| capability == "state")
13752
+ .map(|(_, count)| *count)
13753
+ .unwrap_or(0);
13754
+ Ok(Json(json!({
13755
+ "instance": {
13756
+ "instance_id": instance_id,
13757
+ "namespace": namespace,
13758
+ "engine_version": env!("CARGO_PKG_VERSION"),
13759
+ "protocol_version": PROTOCOL_VERSION,
13760
+ "uptime_seconds": state.started_at.elapsed().as_secs(),
13761
+ "authentication_enabled": state.auth_enabled,
13762
+ },
13763
+ "authority": {
13764
+ "authority_revision": authority_revision,
13765
+ "state_version": state_version,
13766
+ "lease_clock_healthy": state.lease_clock.is_healthy(),
13767
+ "advertise_url": state.advertise_url.as_ref().map(|value| value.to_string()),
13768
+ },
13769
+ "storage": {
13770
+ "records": records,
13771
+ "state_revisions": revisions,
13772
+ "collections": collections,
13773
+ // Reported as the two distinct facts the type carries: a clean
13774
+ // open, and an open that discarded an incomplete final append.
13775
+ // Flattening them to a boolean would hide the second, which is the
13776
+ // one an operator needs to see.
13777
+ "log_recovery": match log {
13778
+ feltdb::LogRecovery::Clean => json!({"status": "clean"}),
13779
+ feltdb::LogRecovery::RecoveredTornTail { byte_offset, discarded_bytes } => json!({
13780
+ "status": "recovered-torn-tail",
13781
+ "byte_offset": byte_offset,
13782
+ "discarded_bytes": discarded_bytes,
13783
+ }),
13784
+ },
13785
+ "audit": serde_json::to_value(state.audit.health()).unwrap_or(Value::Null),
13786
+ },
13787
+ "runtime": {
13788
+ "worker_enabled": state.worker_enabled,
13789
+ "request_deadline_ms": state.request_deadline.as_millis() as u64,
13790
+ "admission_permits_available": state.admission.available_permits(),
13791
+ },
13792
+ "activity": serde_json::to_value(state.metrics.snapshot()).unwrap_or(Value::Null),
13793
+ })))
13794
+ }
13795
+
13796
+ /// The write operations this application declares, per collection.
13797
+ ///
13798
+ /// Studio binds its create, update and delete controls to this. A collection
13799
+ /// that declares no action for an operation gets no control for it — which is
13800
+ /// the same answer any other caller gets, because there is no other way in.
13801
+ async fn control_plane_actions(
13802
+ State(state): State<AppState>,
13803
+ Extension(principal): Extension<Principal>,
13804
+ Query(scope): Query<ControlPlaneScope>,
13805
+ ) -> Result<Json<Value>, ApiError> {
13806
+ let application_id = scope.application_id.clone().ok_or_else(|| {
13807
+ control_plane_error(
13808
+ StatusCode::BAD_REQUEST,
13809
+ "application_required",
13810
+ "application_id is required",
13811
+ )
13812
+ })?;
13813
+ let contract =
13814
+ active_inspection_contract(&state, &principal, &application_id, &scope.environment)?;
13815
+ let collections = contract
13816
+ .state
13817
+ .collections
13818
+ .iter()
13819
+ .map(|collection| {
13820
+ let actions = contract
13821
+ .actions
13822
+ .definitions
13823
+ .iter()
13824
+ .filter(|action| action.collection == collection.name)
13825
+ .map(|action| {
13826
+ json!({
13827
+ "name": action.name,
13828
+ "operation": match action.operation {
13829
+ ActionOperation::Create => "create",
13830
+ ActionOperation::Update => "update",
13831
+ ActionOperation::Delete => "delete",
13832
+ },
13833
+ "input_fields": action.input_fields,
13834
+ "required_capabilities": action.required_capabilities,
13835
+ "audit": action.audit,
13836
+ })
13837
+ })
13838
+ .collect::<Vec<_>>();
13839
+ json!({"collection": collection.name, "actions": actions})
13840
+ })
13841
+ .collect::<Vec<_>>();
13842
+ Ok(Json(json!({
13843
+ "application": {"application_id": application_id, "environment": scope.environment},
13844
+ "revision_id": contract.revision_id,
13845
+ "route": "/api/applications/{application_id}/revisions/{revision_id}/actions/{action}",
13846
+ "collections": collections,
13847
+ })))
13848
+ }
13849
+
13850
+ /// Declared indexes, and whether the running instance actually has them.
13851
+ ///
13852
+ /// The two are separate questions and are answered separately. A contract can
13853
+ /// declare an index the runtime has not built, and the runtime can hold an
13854
+ /// equality index no contract declares; showing only one number would hide
13855
+ /// exactly the case an operator opened this page to find.
13856
+ async fn control_plane_indexes(
13857
+ State(state): State<AppState>,
13858
+ Extension(principal): Extension<Principal>,
13859
+ Query(scope): Query<ControlPlaneScope>,
13860
+ ) -> Result<Json<Value>, ApiError> {
13861
+ let application_id = scope.application_id.clone().ok_or_else(|| {
13862
+ control_plane_error(
13863
+ StatusCode::BAD_REQUEST,
13864
+ "application_required",
13865
+ "application_id is required",
13866
+ )
13867
+ })?;
13868
+ let contract =
13869
+ active_inspection_contract(&state, &principal, &application_id, &scope.environment)?;
13870
+ let namespace = contract.environment.state_namespace.clone();
13871
+ let runtime = state.db.equality_indexes()?;
13872
+ let backed = |collection: &str, field: &str| {
13873
+ let capability = format!("{namespace}:{collection}");
13874
+ runtime
13875
+ .iter()
13876
+ .any(|(indexed, indexed_field)| *indexed == capability && indexed_field == field)
13877
+ };
13878
+ let indexes = contract
13879
+ .state
13880
+ .indexes
13881
+ .iter()
13882
+ .map(|index| {
13883
+ let fields = index
13884
+ .fields
13885
+ .iter()
13886
+ .map(|field| json!({"field": field, "backed": backed(&index.collection, field)}))
13887
+ .collect::<Vec<_>>();
13888
+ let all_backed = fields.iter().all(|field| field["backed"] == json!(true));
13889
+ json!({
13890
+ "name": index.name,
13891
+ "collection": index.collection,
13892
+ "fields": fields,
13893
+ "unique": index.unique,
13894
+ "sparse": index.sparse,
13895
+ "version": index.version,
13896
+ "state": if all_backed { "backed" } else { "declared" },
13897
+ })
13898
+ })
13899
+ .collect::<Vec<_>>();
13900
+ // Which equality filters the server can serve from an index. A field that
13901
+ // is not here is still filterable; it is filterable by scanning, and saying
13902
+ // so is the difference between a usable browser and a surprising one.
13903
+ let access_paths = contract
13904
+ .state
13905
+ .collections
13906
+ .iter()
13907
+ .map(|collection| {
13908
+ json!({
13909
+ "collection": collection.name,
13910
+ "fields": collection.fields.iter().map(|field| json!({
13911
+ "field": field.name,
13912
+ "access_method": if backed(&collection.name, &field.name) { "index" } else { "scan" },
13913
+ })).collect::<Vec<_>>(),
13914
+ })
13915
+ })
13916
+ .collect::<Vec<_>>();
13917
+ Ok(Json(json!({
13918
+ "application": {"application_id": application_id, "environment": scope.environment},
13919
+ "state_namespace": namespace,
13920
+ "declared": indexes,
13921
+ "runtime_equality_indexes": runtime.iter().map(|(capability, field)| json!({
13922
+ "capability": capability, "field": field,
13923
+ })).collect::<Vec<_>>(),
13924
+ "access_paths": access_paths,
13925
+ })))
13926
+ }
13927
+
13928
+ #[derive(Deserialize)]
13929
+ struct IndexRebuildRequest {
13930
+ application_id: String,
13931
+ #[serde(default = "production_environment")]
13932
+ environment: String,
13933
+ collection: String,
13934
+ field: String,
13935
+ }
13936
+
13937
+ /// Rebuilds one declared equality index from authoritative state.
13938
+ ///
13939
+ /// Restricted to a field the application's contract declares an index on.
13940
+ /// Rebuilding is a maintenance operation on a declared index, not a way to
13941
+ /// create indexes the contract never asked for.
13942
+ async fn control_plane_rebuild_index(
13943
+ State(state): State<AppState>,
13944
+ Extension(principal): Extension<Principal>,
13945
+ Json(request): Json<IndexRebuildRequest>,
13946
+ ) -> Result<Json<Value>, ApiError> {
13947
+ let contract = active_inspection_contract(
13948
+ &state,
13949
+ &principal,
13950
+ &request.application_id,
13951
+ &request.environment,
13952
+ )?;
13953
+ let declared = contract.state.indexes.iter().any(|index| {
13954
+ index.collection == request.collection && index.fields.iter().any(|f| *f == request.field)
13955
+ });
13956
+ if !declared {
13957
+ return Err(control_plane_error(
13958
+ StatusCode::UNPROCESSABLE_ENTITY,
13959
+ "index_not_declared",
13960
+ format!(
13961
+ "the contract declares no index on {}.{}",
13962
+ request.collection, request.field
13963
+ ),
13964
+ ));
13965
+ }
13966
+ let capability = format!(
13967
+ "{}:{}",
13968
+ contract.environment.state_namespace, request.collection
13969
+ );
13970
+ let created = state.db.create_equality_index(&capability, &request.field)?;
13971
+ Ok(Json(json!({
13972
+ "collection": request.collection,
13973
+ "field": request.field,
13974
+ "capability": capability,
13975
+ "created": created,
13976
+ "state": "backed",
13977
+ })))
13978
+ }
13979
+
13980
+ #[derive(Deserialize)]
13981
+ struct OperationsQuery {
13982
+ #[serde(default)]
13983
+ collection: Option<String>,
13984
+ #[serde(default)]
13985
+ kind: Option<String>,
13986
+ #[serde(default)]
13987
+ limit: Option<usize>,
13988
+ }
13989
+
13990
+ const CONTROL_PLANE_OPERATION_LIMIT: usize = 200;
13991
+
13992
+ /// Recent writes, newest first, without the values they wrote.
13993
+ ///
13994
+ /// The change log is what the instance retains for replication, so this is a
13995
+ /// window on recent activity rather than an operation archive; the response
13996
+ /// says so rather than letting an empty tail read as a quiet system.
13997
+ async fn control_plane_operations(
13998
+ State(state): State<AppState>,
13999
+ Query(query): Query<OperationsQuery>,
14000
+ ) -> Result<Json<Value>, ApiError> {
14001
+ let limit = query
14002
+ .limit
14003
+ .unwrap_or(50)
14004
+ .clamp(1, CONTROL_PLANE_OPERATION_LIMIT);
14005
+ let db = state.db.clone();
14006
+ let (mut operations, retained_from) = tokio::task::spawn_blocking(move || {
14007
+ Ok::<_, FlowError>((
14008
+ db.operations_since(&HashMap::new())?,
14009
+ db.retained_operation_floors()?,
14010
+ ))
14011
+ })
14012
+ .await
14013
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
14014
+ operations.sort_by(|left, right| right.op_id.cmp(&left.op_id));
14015
+ let matched = operations
14016
+ .iter()
14017
+ .filter(|operation| {
14018
+ query
14019
+ .collection
14020
+ .as_deref()
14021
+ .is_none_or(|collection| control_plane::collection_of(&operation.capability) == collection)
14022
+ })
14023
+ .filter(|operation| {
14024
+ query.kind.as_deref().is_none_or(|kind| {
14025
+ matches!(
14026
+ (kind, &operation.op_type),
14027
+ ("insert", feltdb::OperationType::Insert)
14028
+ | ("update", feltdb::OperationType::Update)
14029
+ | ("delete", feltdb::OperationType::Delete)
14030
+ )
14031
+ })
14032
+ })
14033
+ .take(limit)
14034
+ .map(control_plane::operation_summary_to_json)
14035
+ .collect::<Vec<_>>();
14036
+ Ok(Json(json!({
14037
+ "operations": matched,
14038
+ "retained": {
14039
+ "operations_held": operations.len(),
14040
+ "floors": retained_from,
14041
+ "note": "The change log is bounded by retention and compaction. Operations older than the floors above are no longer held by this instance.",
14042
+ },
14043
+ })))
14044
+ }
14045
+
14046
+ /// Peers, causal position, and what replication still owes.
14047
+ async fn control_plane_replication(
14048
+ State(state): State<AppState>,
14049
+ ) -> Result<Json<Value>, ApiError> {
14050
+ let db = state.db.clone();
14051
+ let namespace = state.namespace.to_string();
14052
+ let scan_namespace = namespace.clone();
14053
+ let (peers, positions, floors, instance_id) = tokio::task::spawn_blocking(move || {
14054
+ Ok::<_, FlowError>((
14055
+ db.peers_for_namespace(&scan_namespace)?,
14056
+ db.operation_versions()?,
14057
+ db.retained_operation_floors()?,
14058
+ db.instance_id()?,
14059
+ ))
14060
+ })
14061
+ .await
14062
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
14063
+ let metrics = state.metrics.snapshot();
14064
+ let membership = state.cluster.get();
14065
+ Ok(Json(json!({
14066
+ "local": {"instance_id": instance_id, "namespace": namespace},
14067
+ "membership": membership,
14068
+ "peers": peers,
14069
+ "causal_position": positions,
14070
+ "retained_operation_floors": floors,
14071
+ "health": {
14072
+ "peer_sync_successes": metrics.peer_sync_successes,
14073
+ "peer_sync_failures": metrics.peer_sync_failures,
14074
+ "operations_sent": metrics.sync_operations_sent,
14075
+ "operations_received": metrics.sync_operations_received,
14076
+ "operations_compacted": metrics.operations_compacted,
14077
+ },
14078
+ })))
14079
+ }
14080
+
14081
+ /// Applications this instance runs, and whether their runtimes are up.
14082
+ ///
14083
+ /// Scoped by the caller's own tenant and application memberships, exactly as
14084
+ /// `GET /api/tenants/{id}/applications` is. Studio sees the applications its
14085
+ /// operator may see, not the applications the instance happens to hold.
14086
+ async fn control_plane_applications(
14087
+ State(state): State<AppState>,
14088
+ Extension(principal): Extension<Principal>,
14089
+ ) -> Result<Json<Value>, ApiError> {
14090
+ let mut applications = Vec::new();
14091
+ for tenant in state.tenancy.tenants_for(&principal.key_id) {
14092
+ for application in state
14093
+ .tenancy
14094
+ .applications_for(&principal.key_id, &tenant.id)
14095
+ {
14096
+ let pointers = state.applications.pointers(&application.id);
14097
+ let instances = state.runtimes.list(&tenant.id, &application.id);
14098
+ let environments = pointers
14099
+ .iter()
14100
+ .map(|(environment, revision_id)| {
14101
+ let instance = instances.iter().find(|instance| {
14102
+ instance.environment == *environment
14103
+ && instance.revision_id == *revision_id
14104
+ });
14105
+ json!({
14106
+ "environment": environment,
14107
+ "revision_id": revision_id,
14108
+ "runtime": instance.map(|instance| json!({
14109
+ "runtime_id": instance.runtime_id,
14110
+ "status": instance.status,
14111
+ "state_namespace": instance.state_namespace,
14112
+ "contract_hash": instance.contract_hash,
14113
+ "started_at": instance.started_at,
14114
+ "started_by": instance.started_by,
14115
+ })),
14116
+ })
14117
+ })
14118
+ .collect::<Vec<_>>();
14119
+ applications.push(json!({
14120
+ "application_id": application.id,
14121
+ "application_name": application.name,
14122
+ "tenant_id": tenant.id,
14123
+ "tenant_name": tenant.name,
14124
+ "namespace": application.namespace,
14125
+ "status": application.status,
14126
+ "environments": environments,
14127
+ }));
14128
+ }
14129
+ }
14130
+ Ok(Json(json!({"applications": applications})))
14131
+ }
14132
+
14133
+ /// What a developer needs to talk to this instance from code.
14134
+ async fn control_plane_developer(
14135
+ State(state): State<AppState>,
14136
+ Query(scope): Query<ControlPlaneScope>,
14137
+ ) -> Result<Json<Value>, ApiError> {
14138
+ let endpoint = state
14139
+ .advertise_url
14140
+ .as_ref()
14141
+ .map(|value| value.to_string())
14142
+ .unwrap_or_default();
14143
+ let application = scope.application_id.clone().unwrap_or_default();
14144
+ Ok(Json(json!({
14145
+ "endpoint": endpoint,
14146
+ "namespace": state.namespace.to_string(),
14147
+ "protocol_version": PROTOCOL_VERSION,
14148
+ "authentication": if state.auth_enabled { "bearer-api-key" } else { "disabled" },
14149
+ "openapi": "/v1/openapi.json",
14150
+ "sdk": {
14151
+ "package": "@feltdb/core",
14152
+ "install": "npm install @feltdb/core",
14153
+ "snippet": "import { createFeltDB } from '@feltdb/core';\nconst db = createFeltDB({ namespace: 'NAMESPACE', remoteUrl: 'ENDPOINT', token: 'API_KEY' });",
14154
+ },
14155
+ "cli": {
14156
+ "package": "@feltdb/core",
14157
+ "snippet": "npx feltdb --url ENDPOINT --token API_KEY collections list",
14158
+ },
14159
+ "environment": {
14160
+ "FELTDB_URL": endpoint,
14161
+ "FELTDB_NAMESPACE": state.namespace.to_string(),
14162
+ "FELTDB_APPLICATION_ID": application,
14163
+ },
14164
+ })))
14165
+ }
14166
+
14167
+ // ---------------------------------------------------------------------------
14168
+ // Attribution: who caused a change
14169
+ // ---------------------------------------------------------------------------
14170
+ //
14171
+ // The invariant these routes exist to hold: **there is no invisible mutation**.
14172
+ // Whatever wrote a record — a person, an application, an agent, replication —
14173
+ // the same question is answerable about it, through the same durable model.
14174
+
14175
+ #[derive(Deserialize)]
14176
+ struct TransactionsQuery {
14177
+ #[serde(default)]
14178
+ subject: Option<String>,
14179
+ #[serde(default)]
14180
+ collection: Option<String>,
14181
+ #[serde(default)]
14182
+ limit: Option<usize>,
14183
+ }
14184
+
14185
+ const CONTROL_PLANE_TRANSACTION_LIMIT: usize = 200;
14186
+
14187
+ /// Recent commits and who made them.
14188
+ ///
14189
+ /// The window is bounded and says so. An operator reading this must be able to
14190
+ /// tell "nothing happened" from "it happened before what this instance still
14191
+ /// holds in memory", so the window's size and occupancy are part of the answer.
14192
+ async fn control_plane_transactions(
14193
+ State(state): State<AppState>,
14194
+ Query(query): Query<TransactionsQuery>,
14195
+ ) -> Result<Json<Value>, ApiError> {
14196
+ let limit = query
14197
+ .limit
14198
+ .unwrap_or(50)
14199
+ .clamp(1, CONTROL_PLANE_TRANSACTION_LIMIT);
14200
+ let db = state.db.clone();
14201
+ let (attributions, window) = tokio::task::spawn_blocking(move || {
14202
+ Ok::<_, FlowError>((
14203
+ db.recent_transactions(CONTROL_PLANE_TRANSACTION_LIMIT)?,
14204
+ db.transaction_attribution_window()?,
14205
+ ))
14206
+ })
14207
+ .await
14208
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
14209
+ let matched = attributions
14210
+ .into_iter()
14211
+ .filter(|attribution| {
14212
+ query
14213
+ .subject
14214
+ .as_deref()
14215
+ .is_none_or(|subject| attribution.subject.as_deref() == Some(subject))
14216
+ })
14217
+ .filter(|attribution| {
14218
+ query.collection.as_deref().is_none_or(|collection| {
14219
+ attribution.keys.iter().any(|key| {
14220
+ key.rsplit_once(':')
14221
+ .map(|(capability, _)| control_plane::collection_of(capability))
14222
+ == Some(collection)
14223
+ })
14224
+ })
14225
+ })
14226
+ .take(limit)
14227
+ .map(|attribution| serde_json::to_value(&attribution).unwrap_or(Value::Null))
14228
+ .collect::<Vec<_>>();
14229
+ Ok(Json(json!({
14230
+ "transactions": matched,
14231
+ "window": {
14232
+ "held": window.0,
14233
+ "capacity": window.1,
14234
+ "note": "Attribution is answered from a bounded in-memory window over recent commits. Commits older than the window are in the durable transaction log, which this route does not read.",
14235
+ },
14236
+ })))
14237
+ }
14238
+
14239
+ /// One transaction: who committed it, what it touched, and whether it applied.
14240
+ ///
14241
+ /// Two different facts, deliberately both reported. *Applied* comes from the
14242
+ /// database's own idempotency record and is true for as long as the database
14243
+ /// holds the transaction id — it is what makes a retried commit a no-op rather
14244
+ /// than a second write. *Attribution* comes from the bounded recent-commit
14245
+ /// window. A transaction that applied and has no attribution is one that
14246
+ /// committed longer ago than the window keeps, which is a different thing from
14247
+ /// one that never committed, and collapsing the two would make a replayed
14248
+ /// transaction id read as new.
14249
+ async fn control_plane_transaction(
14250
+ State(state): State<AppState>,
14251
+ Path(transaction_id): Path<String>,
14252
+ ) -> Result<Json<Value>, ApiError> {
14253
+ let db = state.db.clone();
14254
+ let id = transaction_id.clone();
14255
+ let (applied, attribution) = tokio::task::spawn_blocking(move || {
14256
+ Ok::<_, FlowError>((
14257
+ db.has_applied_transaction(&id)?,
14258
+ db.transaction_attribution(&id)?,
14259
+ ))
14260
+ })
14261
+ .await
14262
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
14263
+ if !applied && attribution.is_none() {
14264
+ return Err(control_plane_error(
14265
+ StatusCode::NOT_FOUND,
14266
+ "transaction_not_found",
14267
+ "this instance has not applied a transaction with that id",
14268
+ ));
14269
+ }
14270
+ Ok(Json(json!({
14271
+ "transaction_id": transaction_id,
14272
+ "applied": applied,
14273
+ "idempotent_replay": if applied {
14274
+ "A commit repeating this id with the same payload is a no-op; with a different payload it is refused."
14275
+ } else {
14276
+ "This instance holds no idempotency record for this id."
14277
+ },
14278
+ "attribution": attribution,
14279
+ "attribution_available": attribution.is_some(),
14280
+ })))
14281
+ }
14282
+
14283
+ /// Everything this instance can say about how one record came to be as it is.
14284
+ ///
14285
+ /// Four sources, joined and labelled rather than merged: the row's own
14286
+ /// operation provenance, the transactions that named it, the state revisions of
14287
+ /// its resource, and — when the record belongs to an application — the agent or
14288
+ /// principal each transaction names. They answer different parts of the same
14289
+ /// question and disagree in informative ways, so none of them is collapsed into
14290
+ /// the others.
14291
+ async fn control_plane_record_provenance(
14292
+ State(state): State<AppState>,
14293
+ Extension(principal): Extension<Principal>,
14294
+ Path((collection, id)): Path<(String, String)>,
14295
+ Query(scope): Query<ControlPlaneScope>,
14296
+ ) -> Result<Json<Value>, ApiError> {
14297
+ let capability = match scope.application_id.as_deref() {
14298
+ Some(application_id) => {
14299
+ let contract =
14300
+ active_inspection_contract(&state, &principal, application_id, &scope.environment)?;
14301
+ inspectable_collection(&contract, &collection)?;
14302
+ format!("{}:{}", contract.environment.state_namespace, collection)
14303
+ }
14304
+ // Without an application the record is a direct-authority record, whose
14305
+ // capability is the collection itself.
14306
+ None => {
14307
+ validate_segment(&collection)?;
14308
+ collection.clone()
14309
+ }
14310
+ };
14311
+ validate_segment(&id)?;
14312
+ // The two write paths key their rows differently, and the revision resource
14313
+ // is the row key in both cases. An application transaction stores
14314
+ // `{state_namespace}:{collection}` / `{id}`; a direct-authority write stores
14315
+ // `{collection}` / `{collection}:{id}`. Getting this wrong reads as "no such
14316
+ // record" rather than as a bug, so it is derived once, here.
14317
+ let row_key = if capability == collection {
14318
+ record_key(&collection, &id)?
14319
+ } else {
14320
+ id.clone()
14321
+ };
14322
+ let resource = row_key.clone();
14323
+ let db = state.db.clone();
14324
+ let lookup_capability = capability.clone();
14325
+ let lookup_id = row_key.clone();
14326
+ let lookup_resource = resource.clone();
14327
+ let (row, transactions, revisions) = tokio::task::spawn_blocking(move || {
14328
+ let store = feltdb::FeltDBStateSystem::create_store(&Arc::new(db.clone()))
14329
+ .map_err(FlowError::CapabilityError)?;
14330
+ Ok::<_, FlowError>((
14331
+ db.get_collection_record(&lookup_capability, &lookup_id)?,
14332
+ db.transactions_touching(&lookup_capability, &lookup_id, 20)?,
14333
+ store.history_of(&lookup_resource),
14334
+ ))
14335
+ })
14336
+ .await
14337
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
14338
+ let row = row.ok_or_else(|| {
14339
+ control_plane_error(
14340
+ StatusCode::NOT_FOUND,
14341
+ "record_not_found",
14342
+ "no record with that identity",
14343
+ )
14344
+ })?;
14345
+ Ok(Json(json!({
14346
+ "record": {"collection": collection, "id": id, "capability": capability, "key": row_key, "resource": resource},
14347
+ // What the storage row itself records: the operation that last wrote it.
14348
+ "operation": row.operation.as_ref().map(|operation| json!({
14349
+ "type": match operation.op_type {
14350
+ feltdb::OperationType::Insert => "insert",
14351
+ feltdb::OperationType::Update => "update",
14352
+ feltdb::OperationType::Delete => "delete",
14353
+ },
14354
+ "instance_id": operation.instance_id,
14355
+ "sequence": operation.sequence,
14356
+ "vector_clock": operation.vector_clock,
14357
+ "state_id": operation.revision.as_ref().map(|revision| revision.id.clone()),
14358
+ })),
14359
+ "content_hash": row.content_hash,
14360
+ "updated_ms": row.unix_ms,
14361
+ // Who asked for the changes: the actor each transaction authenticated as.
14362
+ "transactions": transactions,
14363
+ // What the state model recorded: the resource's revision chain, if the
14364
+ // write path that produced it mints revisions.
14365
+ "revisions": revisions.iter().map(control_plane::revision_summary_to_json).collect::<Vec<_>>(),
14366
+ "attribution": {
14367
+ "actor": transactions.first().and_then(|attribution| attribution.subject.clone()),
14368
+ "authority": row.operation.as_ref().map(|operation| operation.instance_id.clone()),
14369
+ "source": if transactions.first().is_some() { "transaction" } else if row.operation.is_some() { "authority" } else { "unknown" },
14370
+ },
14371
+ })))
14372
+ }
14373
+
14374
+ // ---------------------------------------------------------------------------
14375
+ // Agents
14376
+ // ---------------------------------------------------------------------------
14377
+ //
14378
+ // An agent is a principal. It is not a separate identity system, it does not
14379
+ // get a private write path, and its changes are the same operations,
14380
+ // transactions and revisions every other writer produces. What the control
14381
+ // plane adds is the human's view of it: what it may do, what it has done, and
14382
+ // what it is doing now.
14383
+
14384
+ /// What an agent principal may reach in an application's contract.
14385
+ ///
14386
+ /// Computed from the two things that actually decide it: the capabilities the
14387
+ /// principal holds, and the actions and policies the contract declares. Nothing
14388
+ /// is inferred from the agent's name or history.
14389
+ fn agent_access(
14390
+ contract: &ApplicationRuntimeContract,
14391
+ capabilities: &[String],
14392
+ ) -> Vec<Value> {
14393
+ let holds = |required: &[String]| required.iter().all(|value| capabilities.contains(value));
14394
+ contract
14395
+ .state
14396
+ .collections
14397
+ .iter()
14398
+ .map(|collection| {
14399
+ let policy = matching_state_policy(contract, &collection.name);
14400
+ let actions = contract
14401
+ .actions
14402
+ .definitions
14403
+ .iter()
14404
+ .filter(|action| action.collection == collection.name)
14405
+ .map(|action| {
14406
+ json!({
14407
+ "name": action.name,
14408
+ "operation": match action.operation {
14409
+ ActionOperation::Create => "create",
14410
+ ActionOperation::Update => "update",
14411
+ ActionOperation::Delete => "delete",
14412
+ },
14413
+ "required_capabilities": action.required_capabilities,
14414
+ "permitted": holds(&action.required_capabilities),
14415
+ })
14416
+ })
14417
+ .collect::<Vec<_>>();
14418
+ json!({
14419
+ "collection": collection.name,
14420
+ "read": {
14421
+ "policy": policy.and_then(|value| value.read.clone()),
14422
+ "capabilities": policy.map(|value| value.capabilities.clone()).unwrap_or_default(),
14423
+ },
14424
+ "write": {
14425
+ "policy": policy.and_then(|value| value.write.clone()),
14426
+ "actions": actions,
14427
+ },
14428
+ })
14429
+ })
14430
+ .collect()
14431
+ }
14432
+
14433
+ fn principal_kind_name(kind: &PrincipalKind) -> &'static str {
14434
+ match kind {
14435
+ PrincipalKind::Agent => "agent",
14436
+ PrincipalKind::Workload => "workload",
14437
+ PrincipalKind::Service => "service",
14438
+ PrincipalKind::Worker => "worker",
14439
+ PrincipalKind::Connection => "connection",
14440
+ }
14441
+ }
14442
+
14443
+ /// Agents registered against an application, with what each has been doing.
14444
+ async fn control_plane_agents(
14445
+ State(state): State<AppState>,
14446
+ Extension(principal): Extension<Principal>,
14447
+ Query(scope): Query<ControlPlaneScope>,
14448
+ ) -> Result<Json<Value>, ApiError> {
14449
+ let application_id = scope.application_id.clone().ok_or_else(|| {
14450
+ control_plane_error(
14451
+ StatusCode::BAD_REQUEST,
14452
+ "application_required",
14453
+ "application_id is required",
14454
+ )
14455
+ })?;
14456
+ let principals = state
14457
+ .principals
14458
+ .list(&principal.key_id, &state.tenancy, &application_id)
14459
+ .map_err(control_error)?;
14460
+ let db = state.db.clone();
14461
+ let attributions = tokio::task::spawn_blocking(move || {
14462
+ db.recent_transactions(CONTROL_PLANE_TRANSACTION_LIMIT)
14463
+ })
14464
+ .await
14465
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
14466
+ let agents = principals
14467
+ .iter()
14468
+ .filter(|record| record.kind == PrincipalKind::Agent)
14469
+ .map(|agent| {
14470
+ let sessions = principals
14471
+ .iter()
14472
+ .filter(|record| record.parent_id.as_deref() == Some(agent.id.as_str()))
14473
+ .collect::<Vec<_>>();
14474
+ let commits = attributions
14475
+ .iter()
14476
+ .filter(|attribution| attribution.subject.as_deref() == Some(agent.id.as_str()))
14477
+ .collect::<Vec<_>>();
14478
+ json!({
14479
+ "agent_id": agent.id,
14480
+ "name": agent.name,
14481
+ "status": agent.status,
14482
+ "capabilities": agent.capabilities,
14483
+ "created_by": agent.created_by,
14484
+ "created_at": agent.created_at,
14485
+ "version": agent.version,
14486
+ "application_id": agent.application_id,
14487
+ "sessions": {
14488
+ "total": sessions.len(),
14489
+ "running": sessions
14490
+ .iter()
14491
+ .filter(|record| record.status == PrincipalStatus::Running)
14492
+ .count(),
14493
+ },
14494
+ "activity": {
14495
+ "recent_transactions": commits.len(),
14496
+ "records_changed": commits.iter().map(|attribution| attribution.operations).sum::<usize>(),
14497
+ "last_activity_ms": commits.first().map(|attribution| attribution.unix_ms as u64),
14498
+ },
14499
+ })
14500
+ })
14501
+ .collect::<Vec<_>>();
14502
+ Ok(Json(json!({
14503
+ "application": {"application_id": application_id, "environment": scope.environment},
14504
+ "agents": agents,
14505
+ })))
14506
+ }
14507
+
14508
+ /// One agent: identity, access, what it has done, what it is doing now.
14509
+ async fn control_plane_agent(
14510
+ State(state): State<AppState>,
14511
+ Extension(principal): Extension<Principal>,
14512
+ Path(agent_id): Path<String>,
14513
+ Query(scope): Query<ControlPlaneScope>,
14514
+ ) -> Result<Json<Value>, ApiError> {
14515
+ let application_id = scope.application_id.clone().ok_or_else(|| {
14516
+ control_plane_error(
14517
+ StatusCode::BAD_REQUEST,
14518
+ "application_required",
14519
+ "application_id is required",
14520
+ )
14521
+ })?;
14522
+ let principals = state
14523
+ .principals
14524
+ .list(&principal.key_id, &state.tenancy, &application_id)
14525
+ .map_err(control_error)?;
14526
+ let agent = principals
14527
+ .iter()
14528
+ .find(|record| record.id == agent_id && record.kind == PrincipalKind::Agent)
14529
+ .ok_or_else(|| {
14530
+ control_plane_error(
14531
+ StatusCode::NOT_FOUND,
14532
+ "agent_not_found",
14533
+ "no agent principal with that identity in this application",
14534
+ )
14535
+ })?;
14536
+ let contract =
14537
+ active_inspection_contract(&state, &principal, &application_id, &scope.environment)?;
14538
+ let db = state.db.clone();
14539
+ let attributions = tokio::task::spawn_blocking(move || {
14540
+ db.recent_transactions(CONTROL_PLANE_TRANSACTION_LIMIT)
14541
+ })
14542
+ .await
14543
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
14544
+ let commits = attributions
14545
+ .iter()
14546
+ .filter(|attribution| attribution.subject.as_deref() == Some(agent.id.as_str()))
14547
+ .collect::<Vec<_>>();
14548
+ let sessions = principals
14549
+ .iter()
14550
+ .filter(|record| record.parent_id.as_deref() == Some(agent.id.as_str()))
14551
+ .map(|session| {
14552
+ json!({
14553
+ "session_id": session.id,
14554
+ "kind": principal_kind_name(&session.kind),
14555
+ "name": session.name,
14556
+ "status": session.status,
14557
+ "capabilities": session.capabilities,
14558
+ "worker_id": session.worker_id,
14559
+ "started_at": session.started_at,
14560
+ "completed_at": session.completed_at,
14561
+ "created_by": session.created_by,
14562
+ })
14563
+ })
14564
+ .collect::<Vec<_>>();
14565
+ let tenant = state
14566
+ .tenancy
14567
+ .application_tenant(&application_id)
14568
+ .unwrap_or_default();
14569
+ let work = state
14570
+ .workloads
14571
+ .lock()
14572
+ .map_err(|_| {
14573
+ ApiError(
14574
+ StatusCode::INTERNAL_SERVER_ERROR,
14575
+ "workload store unavailable".into(),
14576
+ )
14577
+ })?
14578
+ .list(&tenant, &application_id)
14579
+ .into_iter()
14580
+ .filter(|workload| {
14581
+ serde_json::to_value(&workload.requested_by)
14582
+ .map(|value| value.to_string().contains(&agent.id))
14583
+ .unwrap_or(false)
14584
+ })
14585
+ .map(|workload| {
14586
+ json!({
14587
+ "workload_id": workload.workload_id,
14588
+ "definition_id": workload.definition_id,
14589
+ "state": workload.state,
14590
+ "trigger": workload.trigger,
14591
+ "attempts": workload.attempt_count,
14592
+ "created_at": workload.created_at,
14593
+ "updated_at": workload.updated_at,
14594
+ "next_attempt_at": workload.next_attempt_at,
14595
+ "last_failure": workload.last_failure,
14596
+ "capabilities": workload.capability_snapshot,
14597
+ })
14598
+ })
14599
+ .collect::<Vec<_>>();
14600
+ let decisions = state
14601
+ .principals
14602
+ .audit_for(
14603
+ &principal.key_id,
14604
+ &state.tenancy,
14605
+ &application_id,
14606
+ Some(&agent.id),
14607
+ 50,
14608
+ )
14609
+ .map_err(control_error)?;
14610
+ Ok(Json(json!({
14611
+ "identity": {
14612
+ "agent_id": agent.id,
14613
+ "name": agent.name,
14614
+ "kind": principal_kind_name(&agent.kind),
14615
+ "status": agent.status,
14616
+ "application_id": agent.application_id,
14617
+ "environment": scope.environment,
14618
+ "application_revision": contract.revision_id,
14619
+ "created_by": agent.created_by,
14620
+ "created_at": agent.created_at,
14621
+ "version": agent.version,
14622
+ },
14623
+ "access": {
14624
+ "capabilities": agent.capabilities,
14625
+ "collections": agent_access(&contract, &agent.capabilities),
14626
+ },
14627
+ // Every change this agent made, as the same durable facts every other
14628
+ // writer produces: a transaction, the records it named, and the state
14629
+ // revisions those records carry.
14630
+ "did": {
14631
+ "transactions": commits,
14632
+ "records_changed": commits.iter().map(|attribution| attribution.operations).sum::<usize>(),
14633
+ "last_activity_ms": commits.first().map(|attribution| attribution.unix_ms as u64),
14634
+ },
14635
+ "doing": {
14636
+ "sessions": sessions,
14637
+ "work": work,
14638
+ },
14639
+ "authorization_decisions": decisions,
14640
+ })))
14641
+ }
14642
+
14643
+ // ---------------------------------------------------------------------------
14644
+ // State: history, diff, conflict, reconciliation
14645
+ // ---------------------------------------------------------------------------
14646
+
14647
+ #[derive(Deserialize)]
14648
+ struct StateResourceQuery {
14649
+ #[serde(default)]
14650
+ prefix: Option<String>,
14651
+ #[serde(default)]
14652
+ limit: Option<usize>,
14653
+ }
14654
+
14655
+ /// Every resource with history, and the newest revision of each.
14656
+ async fn state_resources(
14657
+ State(state): State<AppState>,
14658
+ Query(query): Query<StateResourceQuery>,
14659
+ ) -> Result<Json<Value>, ApiError> {
14660
+ let store = control_plane_state_store(&state)?;
14661
+ let limit = query.limit.unwrap_or(100).clamp(1, 500);
14662
+ let prefix = query.prefix.unwrap_or_default();
14663
+ let names = tokio::task::spawn_blocking(move || {
14664
+ let names: Vec<String> = store
14665
+ .resources()
14666
+ .into_iter()
14667
+ .filter(|resource| resource.starts_with(&prefix))
14668
+ .take(limit)
14669
+ .collect();
14670
+ names
14671
+ .into_iter()
14672
+ .map(|resource| {
14673
+ let history = store.history_of(&resource);
14674
+ let head = store.head_of(&resource);
14675
+ json!({
14676
+ "resource": resource,
14677
+ "revisions": history.len(),
14678
+ "head": head.as_ref().map(control_plane::revision_summary_to_json),
14679
+ })
14680
+ })
14681
+ .collect::<Vec<_>>()
14682
+ })
14683
+ .await
14684
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
14685
+ Ok(Json(json!({"resources": names})))
14686
+ }
14687
+
14688
+ /// One resource's history, oldest first, with its retention horizon.
14689
+ ///
14690
+ /// The horizon is reported because it is the difference between "this is where
14691
+ /// the resource began" and "this is as far back as we still keep".
14692
+ async fn state_resource_history(
14693
+ State(state): State<AppState>,
14694
+ Path(resource): Path<String>,
14695
+ ) -> Result<Json<Value>, ApiError> {
14696
+ let store = control_plane_state_store(&state)?;
14697
+ let document = tokio::task::spawn_blocking(move || {
14698
+ let history = store.history_of(&resource);
14699
+ json!({
14700
+ "resource": resource,
14701
+ "retention": {
14702
+ "keep_last": store.retention_policy(&resource).keep_last,
14703
+ "horizon": store.retention_horizon(&resource),
14704
+ },
14705
+ "revisions": history.iter().map(control_plane::revision_summary_to_json).collect::<Vec<_>>(),
14706
+ })
14707
+ })
14708
+ .await
14709
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
14710
+ Ok(Json(document))
14711
+ }
14712
+
14713
+ /// One revision, with its content and what its parent link found.
14714
+ async fn state_revision(
14715
+ State(state): State<AppState>,
14716
+ Path(id): Path<String>,
14717
+ ) -> Result<Json<Value>, ApiError> {
14718
+ let store = control_plane_state_store(&state)?;
14719
+ let revision = control_plane_revision(&store, "requested", &id)?;
14720
+ let parent = match store.parent_of(&revision.id) {
14721
+ None | Some(feltdb::state_model::ParentLookup::Root) => json!({"status": "root"}),
14722
+ Some(feltdb::state_model::ParentLookup::Revision(parent)) => json!({
14723
+ "status": "retained",
14724
+ "revision": control_plane::revision_summary_to_json(&parent),
14725
+ }),
14726
+ Some(feltdb::state_model::ParentLookup::Expired(id)) => {
14727
+ json!({"status": "expired", "state_id": id.as_hex()})
14728
+ }
14729
+ Some(feltdb::state_model::ParentLookup::Missing(id)) => {
14730
+ json!({"status": "missing", "state_id": id.as_hex()})
14731
+ }
14732
+ };
14733
+ let mut document =
14734
+ control_plane::revision_to_json(&revision, control_plane_revision_content(&revision));
14735
+ if let Some(object) = document.as_object_mut() {
14736
+ object.insert("parent".into(), parent);
14737
+ }
14738
+ Ok(Json(document))
14739
+ }
14740
+
14741
+ #[derive(Deserialize)]
14742
+ struct StateDiffRequest {
14743
+ from: String,
14744
+ to: String,
14745
+ }
14746
+
14747
+ /// The semantic difference between two revisions.
14748
+ async fn state_diff(
14749
+ State(state): State<AppState>,
14750
+ Json(request): Json<StateDiffRequest>,
14751
+ ) -> Result<Json<Value>, ApiError> {
14752
+ let store = control_plane_state_store(&state)?;
14753
+ let from = control_plane_revision(&store, "from", &request.from)?;
14754
+ let to = control_plane_revision(&store, "to", &request.to)?;
14755
+ let diff = feltdb::SemanticDiff::compute(
14756
+ &serde_json::from_str(&from.content).unwrap_or(Value::Null),
14757
+ &serde_json::from_str(&to.content).unwrap_or(Value::Null),
14758
+ );
14759
+ let mut document = control_plane::diff_to_json(&diff);
14760
+ if let Some(object) = document.as_object_mut() {
14761
+ object.insert(
14762
+ "from".into(),
14763
+ control_plane::revision_summary_to_json(&from),
14764
+ );
14765
+ object.insert("to".into(), control_plane::revision_summary_to_json(&to));
14766
+ object.insert(
14767
+ "relationship".into(),
14768
+ json!(match (from.resource == to.resource, to.parent_id.as_ref() == Some(&from.id)) {
14769
+ (true, true) => "parent",
14770
+ (true, false) => "same-resource",
14771
+ (false, _) => "cross-resource",
14772
+ }),
14773
+ );
14774
+ }
14775
+ Ok(Json(document))
14776
+ }
14777
+
14778
+ #[derive(Deserialize)]
14779
+ struct StateConflictRequest {
14780
+ base: String,
14781
+ left: String,
14782
+ right: String,
14783
+ }
14784
+
14785
+ /// How two branches of one resource interact, path by path.
14786
+ async fn state_conflicts(
14787
+ State(state): State<AppState>,
14788
+ Json(request): Json<StateConflictRequest>,
14789
+ ) -> Result<Json<Value>, ApiError> {
14790
+ let store = control_plane_state_store(&state)?;
14791
+ let base = control_plane_revision(&store, "base", &request.base)?;
14792
+ let left = control_plane_revision(&store, "left", &request.left)?;
14793
+ let right = control_plane_revision(&store, "right", &request.right)?;
14794
+ let classification = feltdb::ConflictClassification::classify(&base, &left, &right);
14795
+ let mut document = control_plane::classification_to_json(&classification);
14796
+ if let Some(object) = document.as_object_mut() {
14797
+ object.insert(
14798
+ "revisions".into(),
14799
+ json!({
14800
+ "base": control_plane::revision_summary_to_json(&base),
14801
+ "left": control_plane::revision_summary_to_json(&left),
14802
+ "right": control_plane::revision_summary_to_json(&right),
14803
+ }),
14804
+ );
14805
+ }
14806
+ Ok(Json(document))
14807
+ }
14808
+
14809
+ /// Loads the three revisions a reconciliation plan names.
14810
+ fn control_plane_reconciliation_inputs(
14811
+ store: &feltdb::StateStore,
14812
+ request: &control_plane::ReconciliationPlanRequest,
14813
+ ) -> Result<(StateRevision, StateRevision, StateRevision, feltdb::ReconciliationPlan), ApiError> {
14814
+ let plan = request.to_plan().map_err(|message| {
14815
+ control_plane_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_plan", message)
14816
+ })?;
14817
+ Ok((
14818
+ control_plane_revision(store, "base", &request.base)?,
14819
+ control_plane_revision(store, "left", &request.left)?,
14820
+ control_plane_revision(store, "right", &request.right)?,
14821
+ plan,
14822
+ ))
14823
+ }
14824
+
14825
+ /// What this plan would produce, without producing it.
14826
+ ///
14827
+ /// Pure: `apply_reconciliation_plan` reads no clock and writes nothing, so a
14828
+ /// human can see the resulting state before deciding to commit it.
14829
+ async fn state_reconciliation_plan(
14830
+ State(state): State<AppState>,
14831
+ Json(request): Json<control_plane::ReconciliationPlanRequest>,
14832
+ ) -> Result<Json<Value>, ApiError> {
14833
+ let store = control_plane_state_store(&state)?;
14834
+ let (base, left, right, plan) = control_plane_reconciliation_inputs(&store, &request)?;
14835
+ let classification = feltdb::ConflictClassification::classify(&base, &left, &right);
14836
+ let preview = feltdb::apply_reconciliation_plan(&base, &left, &right, &plan).map_err(
14837
+ |message| {
14838
+ control_plane_error(StatusCode::UNPROCESSABLE_ENTITY, "plan_refused", message)
14839
+ },
14840
+ )?;
14841
+ let mut content = serde_json::from_str::<Value>(&preview).unwrap_or(Value::Null);
14842
+ redact_inspection_value(&mut content);
14843
+ Ok(Json(json!({
14844
+ "plan": control_plane::plan_to_json(&plan),
14845
+ "classification": control_plane::classification_to_json(&classification),
14846
+ "preview": {
14847
+ "content": content,
14848
+ "content_id": feltdb::StateId::compute(&preview).as_hex(),
14849
+ "parent": control_plane::revision_summary_to_json(plan.selected_parent(&left, &right)),
14850
+ },
14851
+ "unresolved": classification
14852
+ .path_conflicts
14853
+ .iter()
14854
+ .filter(|conflict| matches!(conflict.classification, feltdb::ConflictClass::Conflict))
14855
+ .filter(|conflict| !plan.path_overrides.contains_key(&conflict.path))
14856
+ .map(|conflict| control_plane::path_to_json(&conflict.path))
14857
+ .collect::<Vec<_>>(),
14858
+ })))
14859
+ }
14860
+
14861
+ /// Applies a plan and commits the revision it produces.
14862
+ ///
14863
+ /// The result is committed to the resource's history like any other revision:
14864
+ /// reconciliation produces history, it does not rewrite it.
14865
+ async fn state_reconciliation_execute(
14866
+ State(state): State<AppState>,
14867
+ Extension(principal): Extension<Principal>,
14868
+ Json(request): Json<control_plane::ReconciliationPlanRequest>,
14869
+ ) -> Result<Json<Value>, ApiError> {
14870
+ let store = control_plane_state_store(&state)?;
14871
+ let (base, left, right, plan) = control_plane_reconciliation_inputs(&store, &request)?;
14872
+ let authority = format!("studio:{}", principal.key_id);
14873
+ let outcome = feltdb::reconcile(&base, &left, &right, &plan, authority).map_err(|message| {
14874
+ control_plane_error(StatusCode::UNPROCESSABLE_ENTITY, "plan_refused", message)
14875
+ })?;
14876
+ // `reconcile` produces the revision; the store is what makes it durable.
14877
+ // Committing its content through the store's own `commit` rather than
14878
+ // inserting the value directly keeps one minting path, so the revision a
14879
+ // human sees in history was created the way every other revision was.
14880
+ let parent = plan.selected_parent(&left, &right).clone();
14881
+ let committed = store
14882
+ .commit(
14883
+ outcome.materialized_state.content.clone(),
14884
+ &parent,
14885
+ outcome.materialized_state.authority.clone(),
14886
+ )
14887
+ .map_err(|error| {
14888
+ control_plane_error(
14889
+ StatusCode::CONFLICT,
14890
+ "revision_refused",
14891
+ error.to_string(),
14892
+ )
14893
+ })?;
14894
+ // The two must agree. They are computed by the same constructor from the
14895
+ // same content, parent and authority, so a disagreement means the store and
14896
+ // `reconcile` have drifted apart — which would put a revision in history
14897
+ // that is not the one the plan was previewed as producing.
14898
+ if committed.id != outcome.materialized_state.id {
14899
+ return Err(control_plane_error(
14900
+ StatusCode::INTERNAL_SERVER_ERROR,
14901
+ "revision_identity_mismatch",
14902
+ "the committed revision is not the revision the plan produced",
14903
+ ));
14904
+ }
14905
+ audit(
14906
+ &state,
14907
+ &principal.key_id,
14908
+ "state:reconcile",
14909
+ &committed.resource,
14910
+ "allowed",
14911
+ 200,
14912
+ );
14913
+ Ok(Json(json!({
14914
+ "plan": control_plane::plan_to_json(&plan),
14915
+ "revision": control_plane::revision_to_json(
14916
+ &committed,
14917
+ control_plane_revision_content(&committed),
14918
+ ),
14919
+ })))
14920
+ }
14921
+
13557
14922
  fn record_key(collection: &str, id: &str) -> Result<String, ApiError> {
13558
14923
  validate_segment(collection)?;
13559
14924
  validate_segment(id)?;
@@ -13658,8 +15023,8 @@ mod worker_identity_tests {
13658
15023
 
13659
15024
  #[cfg(test)]
13660
15025
  mod authority_gate_tests {
13661
- use super::{api_error_body, authorize_transaction_collections, state_authorization};
13662
- use axum::http::StatusCode;
15026
+ use super::{api_error_body, authorize_transaction_collections, require_bound_tenant, state_authorization};
15027
+ use axum::http::{HeaderMap, HeaderValue, StatusCode};
13663
15028
  use feltdb::{
13664
15029
  application::{
13665
15030
  manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition,
@@ -13767,6 +15132,17 @@ mod authority_gate_tests {
13767
15132
  assert!(!allowed);
13768
15133
  }
13769
15134
 
15135
+ #[test]
15136
+ fn bound_tenant_header_fails_closed_on_mismatch() {
15137
+ let mut headers = HeaderMap::new();
15138
+ headers.insert("feltdb-tenant-id", HeaderValue::from_static("tenant-a"));
15139
+ assert!(require_bound_tenant(&headers, "tenant-a").is_ok());
15140
+ let error = require_bound_tenant(&headers, "tenant-b").unwrap_err();
15141
+ assert_eq!(error.0, StatusCode::FORBIDDEN);
15142
+ let body: serde_json::Value = serde_json::from_str(&error.1).unwrap();
15143
+ assert_eq!(body.get("code").and_then(serde_json::Value::as_str), Some("AUTHORITY_SCOPE_MISMATCH"));
15144
+ }
15145
+
13770
15146
  #[test]
13771
15147
  fn every_forbidden_error_uses_the_canonical_wire_contract() {
13772
15148
  for internal_message in [