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