@feltdb/core 0.7.4 → 0.8.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 (106) hide show
  1. package/README.md +25 -0
  2. package/dist/application-development.d.ts +3 -1
  3. package/dist/application-development.d.ts.map +1 -1
  4. package/dist/application-development.js +16 -0
  5. package/dist/application-manifest.d.ts +1 -0
  6. package/dist/application-manifest.d.ts.map +1 -1
  7. package/dist/canonical-application.d.ts +1 -0
  8. package/dist/canonical-application.d.ts.map +1 -1
  9. package/dist/canonical-application.js +3 -2
  10. package/dist/cli/cli.js +1 -1
  11. package/dist/cli/commands.js +225 -42
  12. package/dist/cli/index.js +1 -1
  13. package/dist/cli/managed-environment.js +6 -0
  14. package/dist/cli/managed-project.js +6 -0
  15. package/dist/cli/proposal-client.js +12 -3
  16. package/dist/cli/publish-engine.js +7 -4
  17. package/dist/cli/source-sync.js +47 -3
  18. package/dist/create/cli.js +10 -1
  19. package/dist/create/create.js +10 -12
  20. package/dist/create/managed-account.js +71 -7
  21. package/dist/create/package-versions.js +1 -1
  22. package/dist/create/server-source/crates/feltdb/src/application.rs +10 -0
  23. package/dist/create/server-source/crates/feltdb/src/lib.rs +20 -2
  24. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +12 -24
  25. package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +11 -3
  26. package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +0 -1
  27. package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +23 -22
  28. package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +5 -15
  29. package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +13 -28
  30. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +79 -14
  31. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +28 -7
  32. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +5 -5
  33. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1777 -259
  34. package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +10 -30
  35. package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +12 -4
  36. package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +8 -6
  37. package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +10 -14
  38. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +183 -21
  39. package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +9 -21
  40. package/dist/create/server-source/crates/feltdb-server/src/transaction_idempotency.rs +5 -3
  41. package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +9 -8
  42. package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +1 -4
  43. package/dist/create/template/default-project/feltdb.flow +5 -0
  44. package/dist/flowspec.d.ts +1 -0
  45. package/dist/flowspec.d.ts.map +1 -1
  46. package/dist/flowspec.js +46 -12
  47. package/dist/http-client.d.ts +135 -14
  48. package/dist/http-client.d.ts.map +1 -1
  49. package/dist/http-client.js +79 -31
  50. package/dist/index-core.d.ts +2 -0
  51. package/dist/index-core.d.ts.map +1 -1
  52. package/dist/index-core.js +2 -0
  53. package/dist/module-intent.d.ts +37 -0
  54. package/dist/module-intent.d.ts.map +1 -0
  55. package/dist/module-intent.js +81 -0
  56. package/dist/module.d.ts +76 -0
  57. package/dist/module.d.ts.map +1 -0
  58. package/dist/module.js +61 -0
  59. package/dist/proposal.d.ts +80 -4
  60. package/dist/proposal.d.ts.map +1 -1
  61. package/dist/proposal.js +132 -5
  62. package/dist/studio/app.d.ts +3 -1
  63. package/dist/studio/app.d.ts.map +1 -1
  64. package/dist/studio/components/AskFeltDB.d.ts +2 -1
  65. package/dist/studio/components/AskFeltDB.d.ts.map +1 -1
  66. package/dist/studio/components/ContractInspector.d.ts +7 -0
  67. package/dist/studio/components/ContractInspector.d.ts.map +1 -0
  68. package/dist/studio/components/ManagedInstancePanel.d.ts.map +1 -1
  69. package/dist/studio/components/ManagedInstancePanel.js +1 -5
  70. package/dist/studio/components/ModuleExplorer.d.ts +6 -0
  71. package/dist/studio/components/ModuleExplorer.d.ts.map +1 -0
  72. package/dist/studio/components/ProposalBrowser.d.ts +10 -1
  73. package/dist/studio/components/ProposalBrowser.d.ts.map +1 -1
  74. package/dist/studio/components/StateExplorer.d.ts +6 -7
  75. package/dist/studio/components/StateExplorer.d.ts.map +1 -1
  76. package/dist/studio/components/index.d.ts +2 -0
  77. package/dist/studio/components/index.d.ts.map +1 -1
  78. package/dist/studio/components/index.js +4 -4
  79. package/dist/studio/components-CNSNrmA9.js +2787 -0
  80. package/dist/studio/index.js +193 -147
  81. package/dist/studio/studio.css +1 -1
  82. package/dist/studio/utils/index.d.ts +2 -0
  83. package/dist/studio/utils/index.d.ts.map +1 -1
  84. package/dist/studio/utils/index.js +14 -12
  85. package/dist/studio/utils/managed-instance.d.ts +0 -4
  86. package/dist/studio/utils/managed-instance.d.ts.map +1 -1
  87. package/dist/studio/utils/managed-instance.js +0 -4
  88. package/dist/studio/utils/proposal-api.d.ts +53 -0
  89. package/dist/studio/utils/proposal-api.d.ts.map +1 -0
  90. package/dist/studio/utils/proposal-api.js +91 -0
  91. package/dist/studio/utils/service-api.d.ts +81 -0
  92. package/dist/studio/utils/service-api.d.ts.map +1 -0
  93. package/dist/studio/utils/service-api.js +51 -0
  94. package/dist/studio-app/assets/{dist-DA1MYSzp.js → dist-CCOk39Uc.js} +1 -1
  95. package/dist/studio-app/assets/{feltdb_wasm-B-Ccgccb.js → feltdb_wasm-CSB6KVgw.js} +1 -1
  96. package/dist/studio-app/assets/feltdb_wasm_bg-Dd1fnO9U.wasm +0 -0
  97. package/dist/studio-app/assets/index-Bncji3aN.js +43 -0
  98. package/dist/studio-app/assets/{index-BRrmvaKw.css → index-yazaWMUN.css} +1 -1
  99. package/dist/studio-app/index.html +2 -2
  100. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  101. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  102. package/dist/workspace/local-development-authority.js +64 -3
  103. package/package.json +1 -1
  104. package/dist/studio/components-Bm4sjhcX.js +0 -2162
  105. package/dist/studio-app/assets/feltdb_wasm_bg-wH3enJIA.wasm +0 -0
  106. package/dist/studio-app/assets/index-CEIqowCh.js +0 -42
@@ -30,8 +30,8 @@ use axum::{
30
30
  use base64::Engine;
31
31
  use feltdb::{
32
32
  application::{
33
- diff_manifests, validate_manifest, ActionOperation, ApplicationManifest, ApplicationRevision,
34
- ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
33
+ diff_manifests, manifest_hash, validate_manifest, ActionOperation, ApplicationManifest,
34
+ ApplicationRevision, ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
35
35
  },
36
36
  application_runtime::{
37
37
  resolve_runtime, runtime_readiness, snapshot_state_namespace, validate_runtime,
@@ -42,12 +42,15 @@ use feltdb::{
42
42
  Grant, GrantSigner, GrantStore, Subject as GrantSubject,
43
43
  },
44
44
  cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
45
- AtomicMutation,
46
- policy_evaluation::{Actor, PolicyContext, PolicyEvaluator, PolicySubject},
45
+ policy_evaluation::{
46
+ Actor, AuthorizationState, PolicyContext, PolicyEvaluator, PolicySubject,
47
+ RecordAuthorizationContext,
48
+ },
47
49
  state_contract::{
48
50
  begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
49
- execute_transaction_with_collection_policies, schema_from_revision, validate_schema, AuthorizationContext, CanonicalQuery, QueryFilter,
50
- StateSchema, TransactionOperation, TransactionOperationKind, TransactionRequest,
51
+ execute_transaction_with_collection_policies, schema_from_revision, validate_schema,
52
+ AuthorizationContext, CanonicalQuery, QueryFilter, StateSchema, TransactionOperation,
53
+ TransactionOperationKind, TransactionRequest,
51
54
  },
52
55
  sync_contract::{
53
56
  DeviceIdentity, ReconciliationOutcome, SyncAck, SyncBatch, SyncConflict, SyncCursor,
@@ -58,7 +61,8 @@ use feltdb::{
58
61
  WorkerRegistration,
59
62
  },
60
63
  workload::{CreateWorkload, WorkloadStore},
61
- DatabaseSnapshot, FeltDb, JsonCasResult, Operation, PeerAdvertisement, PeerId, StoredRow, FlowError, RecordPrecondition,
64
+ AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
65
+ PeerAdvertisement, PeerId, RecordPrecondition, StoredRow,
62
66
  };
63
67
  use feltdb_server::{
64
68
  app_state::{AppState, BoundedQueryCursor},
@@ -110,7 +114,10 @@ impl ApiError {
110
114
  /// `api_error_body` already parses a message that happens to be JSON, so
111
115
  /// this is the existing mechanism named rather than a second one.
112
116
  fn structured(status: StatusCode, body: Value) -> Self {
113
- Self(status, serde_json::to_string(&body).unwrap_or_else(|_| body.to_string()))
117
+ Self(
118
+ status,
119
+ serde_json::to_string(&body).unwrap_or_else(|_| body.to_string()),
120
+ )
114
121
  }
115
122
  }
116
123
 
@@ -134,13 +141,30 @@ fn api_error_body(status: StatusCode, message: String) -> Value {
134
141
  _ if status.is_server_error() => "EXECUTION_FAILED",
135
142
  _ => "REQUEST_FAILED",
136
143
  };
137
- let parsed = serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "message": message }));
138
- let mut body = if parsed.is_object() { parsed } else { json!({ "message": parsed }) };
139
- let object = body.as_object_mut().expect("normalized error body is an object");
140
- let resolved_code = object.get("code").and_then(Value::as_str).unwrap_or(code).to_string();
144
+ let parsed =
145
+ serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "message": message }));
146
+ let mut body = if parsed.is_object() {
147
+ parsed
148
+ } else {
149
+ json!({ "message": parsed })
150
+ };
151
+ let object = body
152
+ .as_object_mut()
153
+ .expect("normalized error body is an object");
154
+ let resolved_code = object
155
+ .get("code")
156
+ .and_then(Value::as_str)
157
+ .unwrap_or(code)
158
+ .to_string();
141
159
  object.insert("error".into(), json!(resolved_code));
142
160
  object.insert("code".into(), json!(resolved_code));
143
- object.entry("message").or_insert_with(|| json!(if status == StatusCode::FORBIDDEN { "request is not authorized" } else { status.canonical_reason().unwrap_or("request failed") }));
161
+ object.entry("message").or_insert_with(|| {
162
+ json!(if status == StatusCode::FORBIDDEN {
163
+ "request is not authorized"
164
+ } else {
165
+ status.canonical_reason().unwrap_or("request failed")
166
+ })
167
+ });
144
168
  object.insert("request_id".into(), json!(request_id));
145
169
  body
146
170
  }
@@ -414,24 +438,89 @@ struct ApplicationDiscoveryRequest {
414
438
  environment: String,
415
439
  }
416
440
  #[derive(Deserialize)]
417
- struct ProposalScopeRequest { application_id: String, #[serde(default = "production_environment")] environment: String }
441
+ struct ProposalScopeRequest {
442
+ application_id: String,
443
+ #[serde(default = "production_environment")]
444
+ environment: String,
445
+ #[serde(default)]
446
+ status: Option<String>,
447
+ #[serde(default)]
448
+ limit: Option<usize>,
449
+ #[serde(default)]
450
+ cursor: Option<String>,
451
+ }
452
+ #[derive(Deserialize)]
453
+ struct CreateProposalRequest {
454
+ application_id: String,
455
+ #[serde(default = "production_environment")]
456
+ environment: String,
457
+ base_contract_hash: String,
458
+ base_flow_hash: String,
459
+ proposed_flow: String,
460
+ #[serde(default)]
461
+ proposal_version: Option<u64>,
462
+ #[serde(default)]
463
+ contract_diff: Option<Value>,
464
+ #[serde(default)]
465
+ source_plan: Option<Value>,
466
+ summary: String,
467
+ #[serde(default)]
468
+ warnings: Vec<String>,
469
+ #[serde(default)]
470
+ metadata: Value,
471
+ parent_proposal_id: Option<String>,
472
+ expires_at: Option<String>,
473
+ #[serde(default)]
474
+ status: Option<String>,
475
+ }
476
+ #[derive(Deserialize)]
477
+ struct ProposalActorRequest {
478
+ application_id: String,
479
+ #[serde(default = "production_environment")]
480
+ environment: String,
481
+ proposed_manifest: Option<ApplicationManifest>,
482
+ }
418
483
  #[derive(Deserialize)]
419
- struct CreateProposalRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
420
- base_contract_hash: String, base_flow_hash: String, proposed_flow: String, summary: String,
421
- #[serde(default)] warnings: Vec<String>, #[serde(default)] metadata: Value,
422
- parent_proposal_id: Option<String>, expires_at: Option<String> }
484
+ struct ProposalStatusRequest {
485
+ application_id: String,
486
+ #[serde(default = "production_environment")]
487
+ environment: String,
488
+ status: String,
489
+ #[serde(default)]
490
+ approve_authorization: bool,
491
+ #[serde(default)]
492
+ reason: Option<String>,
493
+ }
423
494
  #[derive(Deserialize)]
424
- struct ProposalActorRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
425
- proposed_manifest: Option<ApplicationManifest> }
495
+ struct ProposalLifecycleRequest {
496
+ application_id: String,
497
+ #[serde(default = "production_environment")]
498
+ environment: String,
499
+ #[serde(default)]
500
+ approve_authorization: bool,
501
+ #[serde(default)]
502
+ reason: Option<String>,
503
+ }
426
504
  #[derive(Deserialize)]
427
- struct ProposalStatusRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
428
- status: String, #[serde(default)] approve_authorization: bool }
505
+ struct PreviewSimulationRequest {
506
+ event: String,
507
+ }
429
508
  #[derive(Deserialize, Default)]
430
- struct EventScopeRequest { application_id: Option<String> }
509
+ struct EventScopeRequest {
510
+ application_id: Option<String>,
511
+ }
431
512
  #[derive(Deserialize)]
432
- struct SignUpRequest { application_id: String, email: String, password: String, display_name: Option<String> }
513
+ struct SignUpRequest {
514
+ application_id: String,
515
+ email: String,
516
+ password: String,
517
+ display_name: Option<String>,
518
+ }
433
519
  #[derive(Deserialize)]
434
- struct SignInRequest { email: String, password: String }
520
+ struct SignInRequest {
521
+ email: String,
522
+ password: String,
523
+ }
435
524
  #[derive(Deserialize)]
436
525
  struct StateContractTarget {
437
526
  application_id: String,
@@ -2279,7 +2368,10 @@ async fn delete_certification_fixture(
2279
2368
  || ring::constant_time::verify_slices_are_equal(expected.as_bytes(), provided.as_bytes())
2280
2369
  .is_err()
2281
2370
  {
2282
- return Err(ApiError(StatusCode::FORBIDDEN, "certification recovery denied".into()));
2371
+ return Err(ApiError(
2372
+ StatusCode::FORBIDDEN,
2373
+ "certification recovery denied".into(),
2374
+ ));
2283
2375
  }
2284
2376
  let tenant_id = state
2285
2377
  .tenancy
@@ -3907,9 +3999,8 @@ async fn commit_application_draft(
3907
3999
  .commit(&tenant, &application_id, &draft_id, &principal.key_id)
3908
4000
  .map_err(manifest_error)?;
3909
4001
  let encoded_revision = percent_encode_path_segment(&revision.revision_id);
3910
- let mut response = serde_json::to_value(revision).map_err(|error| {
3911
- ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
3912
- })?;
4002
+ let mut response = serde_json::to_value(revision)
4003
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
3913
4004
  response
3914
4005
  .as_object_mut()
3915
4006
  .expect("application revisions serialize as objects")
@@ -4442,137 +4533,1246 @@ async fn get_active_application_runtime(
4442
4533
  revision,
4443
4534
  &input.environment,
4444
4535
  )?;
4445
- Ok(Json(
4446
- json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
4447
- ))
4536
+ Ok(Json(
4537
+ json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
4538
+ ))
4539
+ }
4540
+ async fn discover_application(
4541
+ State(state): State<AppState>,
4542
+ Extension(principal): Extension<Principal>,
4543
+ Query(input): Query<ApplicationDiscoveryRequest>,
4544
+ ) -> Result<Json<Value>, ApiError> {
4545
+ let tenant = application_scope(
4546
+ &state,
4547
+ &principal.key_id,
4548
+ &input.application_id,
4549
+ "application:read",
4550
+ )?;
4551
+ let revision_id = state
4552
+ .applications
4553
+ .pointers(&input.application_id)
4554
+ .get(&input.environment)
4555
+ .cloned()
4556
+ .ok_or(ApiError(
4557
+ StatusCode::NOT_FOUND,
4558
+ "application environment has no active contract".into(),
4559
+ ))?;
4560
+ let revision = state
4561
+ .applications
4562
+ .revision(&tenant, &input.application_id, &revision_id)
4563
+ .ok_or(ApiError(
4564
+ StatusCode::NOT_FOUND,
4565
+ "application revision not found".into(),
4566
+ ))?;
4567
+ let contract_hash = revision
4568
+ .manifest
4569
+ .metadata
4570
+ .labels
4571
+ .get("feltdb.contract_hash")
4572
+ .unwrap_or(&revision.manifest_hash);
4573
+ let dsl_version = revision
4574
+ .manifest
4575
+ .metadata
4576
+ .labels
4577
+ .get("feltdb.dsl_version")
4578
+ .and_then(|value| value.parse::<u32>().ok())
4579
+ .unwrap_or(1);
4580
+ let application_name = state
4581
+ .tenancy
4582
+ .application_for(&principal.key_id, &input.application_id)
4583
+ .map(|application| application.name);
4584
+ let flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash");
4585
+ Ok(Json(json!({
4586
+ "application_id": input.application_id,
4587
+ "application_name": application_name,
4588
+ "environment": input.environment,
4589
+ "version": revision.revision_number,
4590
+ "revision_id": revision.revision_id,
4591
+ "contract_hash": contract_hash,
4592
+ "flow_hash": flow_hash,
4593
+ "dsl_version": dsl_version,
4594
+ "snapshot": {
4595
+ "application": { "application_id": input.application_id, "environment": input.environment },
4596
+ "contract_version": revision.revision_number,
4597
+ "contract_hash": contract_hash,
4598
+ "dsl_version": dsl_version,
4599
+ "schema": revision.manifest.collections,
4600
+ "indexes": revision.manifest.indexes,
4601
+ "policies": revision.manifest.policies,
4602
+ "workflows": revision.manifest.workflows,
4603
+ "agents": revision.manifest.agents,
4604
+ "capabilities": revision.manifest.capabilities,
4605
+ "modules": revision.manifest.modules,
4606
+ "supported_primitives": ["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
4607
+ }
4608
+ })))
4609
+ }
4610
+
4611
+ const DEFAULT_INSPECTION_LIMIT: usize = 50;
4612
+ const MAX_INSPECTION_LIMIT: usize = 100;
4613
+ fn inspection_error(status: StatusCode, code: &str, message: impl Into<String>) -> ApiError {
4614
+ ApiError::structured(status, json!({"code":code,"message":message.into()}))
4615
+ }
4616
+ fn active_inspection_contract(
4617
+ state: &AppState,
4618
+ principal: &Principal,
4619
+ application_id: &str,
4620
+ environment: &str,
4621
+ ) -> Result<ApplicationRuntimeContract, ApiError> {
4622
+ application_scope(state, &principal.key_id, application_id, "state:read")?;
4623
+ let revision = state
4624
+ .applications
4625
+ .pointers(application_id)
4626
+ .get(environment)
4627
+ .cloned()
4628
+ .ok_or_else(|| {
4629
+ inspection_error(
4630
+ StatusCode::NOT_FOUND,
4631
+ "collection_not_found",
4632
+ "application environment has no active contract",
4633
+ )
4634
+ })?;
4635
+ revision_contract(
4636
+ state,
4637
+ &principal.key_id,
4638
+ application_id,
4639
+ &revision,
4640
+ environment,
4641
+ )
4642
+ }
4643
+ fn inspectable_collection<'a>(
4644
+ contract: &'a ApplicationRuntimeContract,
4645
+ collection: &str,
4646
+ ) -> Result<&'a feltdb::state_contract::CollectionSchema, ApiError> {
4647
+ if collection.starts_with("_feltdb") {
4648
+ return Err(inspection_error(
4649
+ StatusCode::FORBIDDEN,
4650
+ "system_collection",
4651
+ "system collections require their dedicated Service API",
4652
+ ));
4653
+ }
4654
+ contract
4655
+ .state
4656
+ .schema
4657
+ .collections
4658
+ .iter()
4659
+ .find(|value| value.name == collection)
4660
+ .ok_or_else(|| {
4661
+ inspection_error(
4662
+ StatusCode::NOT_FOUND,
4663
+ "collection_not_found",
4664
+ format!("collection {collection} is not part of the active application contract"),
4665
+ )
4666
+ })
4667
+ }
4668
+ fn secret_name(name: &str) -> bool {
4669
+ let value = name.to_ascii_lowercase();
4670
+ [
4671
+ "secret",
4672
+ "password",
4673
+ "token",
4674
+ "api_key",
4675
+ "apikey",
4676
+ "credential",
4677
+ "private_key",
4678
+ ]
4679
+ .iter()
4680
+ .any(|part| value.contains(part))
4681
+ }
4682
+ fn redact_inspection_value(value: &mut Value) {
4683
+ match value {
4684
+ Value::Object(object) => {
4685
+ for (key, child) in object.iter_mut() {
4686
+ if key.eq_ignore_ascii_case("secrets") && child.is_array() {
4687
+ redact_inspection_value(child)
4688
+ } else if secret_name(key) {
4689
+ *child = json!("[REDACTED]")
4690
+ } else {
4691
+ redact_inspection_value(child)
4692
+ }
4693
+ }
4694
+ }
4695
+ Value::Array(values) => {
4696
+ for child in values {
4697
+ redact_inspection_value(child)
4698
+ }
4699
+ }
4700
+ Value::String(text)
4701
+ if text.contains("sk_live_")
4702
+ || text.contains("sk_test_")
4703
+ || text.contains("whsec_")
4704
+ || text.contains("-----BEGIN PRIVATE KEY-----") =>
4705
+ {
4706
+ *value = json!("[REDACTED]")
4707
+ }
4708
+ _ => {}
4709
+ }
4710
+ }
4711
+ fn relationship_metadata(
4712
+ contract: &ApplicationRuntimeContract,
4713
+ collection: &str,
4714
+ record: &Value,
4715
+ ) -> Vec<Value> {
4716
+ let Some(definition) = contract
4717
+ .state
4718
+ .collections
4719
+ .iter()
4720
+ .find(|value| value.name == collection)
4721
+ else {
4722
+ return vec![];
4723
+ };
4724
+ definition.fields.iter().filter_map(|field|field.reference.as_ref().and_then(|target|record.get(&field.name).and_then(Value::as_str).map(|id|json!({"name":field.name,"source_collection":collection,"target_collection":target,"cardinality":"one","foreign_field":field.name,"reference":{"collection":target,"id":id}})))).collect()
4725
+ }
4726
+ fn inspection_cursor(
4727
+ collection: &str,
4728
+ state_version: u64,
4729
+ after: &str,
4730
+ filter_hash: &str,
4731
+ ) -> String {
4732
+ base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json!({"v":1,"state":state_version,"after":after,"query":format!("{:x}",Sha256::digest(format!("{collection}:{filter_hash}").as_bytes()))})).unwrap_or_default())
4733
+ }
4734
+ fn parse_inspection_cursor(
4735
+ cursor: &str,
4736
+ collection: &str,
4737
+ current_version: u64,
4738
+ filter_hash: &str,
4739
+ ) -> Result<String, ApiError> {
4740
+ let value = base64::engine::general_purpose::URL_SAFE_NO_PAD
4741
+ .decode(cursor)
4742
+ .ok()
4743
+ .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
4744
+ .ok_or_else(|| {
4745
+ inspection_error(
4746
+ StatusCode::UNPROCESSABLE_ENTITY,
4747
+ "invalid_cursor",
4748
+ "cursor is malformed",
4749
+ )
4750
+ })?;
4751
+ let expected = format!(
4752
+ "{:x}",
4753
+ Sha256::digest(format!("{collection}:{filter_hash}").as_bytes())
4754
+ );
4755
+ if value.get("v").and_then(Value::as_u64) != Some(1)
4756
+ || value.get("state").and_then(Value::as_u64) != Some(current_version)
4757
+ || value.get("query").and_then(Value::as_str) != Some(&expected)
4758
+ {
4759
+ return Err(inspection_error(
4760
+ StatusCode::UNPROCESSABLE_ENTITY,
4761
+ "invalid_cursor",
4762
+ "cursor is stale or belongs to another query",
4763
+ ));
4764
+ }
4765
+ value
4766
+ .get("after")
4767
+ .and_then(Value::as_str)
4768
+ .map(str::to_string)
4769
+ .ok_or_else(|| {
4770
+ inspection_error(
4771
+ StatusCode::UNPROCESSABLE_ENTITY,
4772
+ "invalid_cursor",
4773
+ "cursor position is invalid",
4774
+ )
4775
+ })
4776
+ }
4777
+ async fn inspect_application_schema(
4778
+ State(state): State<AppState>,
4779
+ Extension(principal): Extension<Principal>,
4780
+ Query(input): Query<ApplicationDiscoveryRequest>,
4781
+ ) -> Result<Json<Value>, ApiError> {
4782
+ let contract = active_inspection_contract(
4783
+ &state,
4784
+ &principal,
4785
+ &input.application_id,
4786
+ &input.environment,
4787
+ )?;
4788
+ let collections=contract.state.collections.iter().map(|collection|{let policy=matching_state_policy(&contract,&collection.name);
4789
+ let relationships=collection.fields.iter().filter_map(|field|field.reference.as_ref().map(|target|json!({"name":field.name,"source_collection":collection.name,"target_collection":target,"cardinality":"one","foreign_field":field.name}))).collect::<Vec<_>>();
4790
+ let reverse=contract.state.collections.iter().flat_map(|source|source.fields.iter().filter_map(|field|field.reference.as_ref().filter(|target|*target==&collection.name).map(|_|json!({"name":format!("{}_{}",source.name,field.name),"source_collection":source.name,"target_collection":collection.name,"cardinality":"many","foreign_field":field.name})))).collect::<Vec<_>>();
4791
+ json!({"collection":collection.name,"description":"","fields":collection.fields,"relationships":relationships.into_iter().chain(reverse).collect::<Vec<_>>(),"indexes":contract.state.indexes.iter().filter(|index|index.collection==collection.name).collect::<Vec<_>>(),"authorization":policy.map(|value|json!({"read":value.read,"write":value.write,"capabilities":value.capabilities}))})}).collect::<Vec<_>>();
4792
+ Ok(Json(
4793
+ json!({"application":{"application_id":input.application_id,"environment":input.environment},"contract_version":contract.revision_number,"collections":collections}),
4794
+ ))
4795
+ }
4796
+ async fn inspect_collection(
4797
+ State(state): State<AppState>,
4798
+ Extension(principal): Extension<Principal>,
4799
+ Path(collection): Path<String>,
4800
+ Query(params): Query<HashMap<String, String>>,
4801
+ ) -> Result<Json<Value>, ApiError> {
4802
+ let application_id = params.get("application_id").ok_or_else(|| {
4803
+ inspection_error(
4804
+ StatusCode::BAD_REQUEST,
4805
+ "inspection_not_allowed",
4806
+ "application_id is required",
4807
+ )
4808
+ })?;
4809
+ let environment = params
4810
+ .get("environment")
4811
+ .map(String::as_str)
4812
+ .unwrap_or("production");
4813
+ let contract = active_inspection_contract(&state, &principal, application_id, environment)?;
4814
+ let definition = inspectable_collection(&contract, &collection)?;
4815
+ let limit = params
4816
+ .get("limit")
4817
+ .map(|value| value.parse::<usize>())
4818
+ .transpose()
4819
+ .map_err(|_| {
4820
+ inspection_error(
4821
+ StatusCode::UNPROCESSABLE_ENTITY,
4822
+ "invalid_pagination",
4823
+ "limit must be an integer",
4824
+ )
4825
+ })?
4826
+ .unwrap_or(DEFAULT_INSPECTION_LIMIT);
4827
+ if limit == 0 || limit > MAX_INSPECTION_LIMIT {
4828
+ return Err(inspection_error(
4829
+ StatusCode::UNPROCESSABLE_ENTITY,
4830
+ "invalid_pagination",
4831
+ format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}"),
4832
+ ));
4833
+ }
4834
+ let reserved = ["application_id", "environment", "limit", "cursor"];
4835
+ let mut filters = Vec::new();
4836
+ for (field, value) in params
4837
+ .iter()
4838
+ .filter(|(key, _)| !reserved.contains(&key.as_str()))
4839
+ {
4840
+ if field.contains('[') || field.contains(']') {
4841
+ return Err(inspection_error(
4842
+ StatusCode::UNPROCESSABLE_ENTITY,
4843
+ "unsupported_filter",
4844
+ format!("unsupported filter operator in {field}"),
4845
+ ));
4846
+ }
4847
+ if !definition
4848
+ .fields
4849
+ .iter()
4850
+ .any(|candidate| candidate.name == *field)
4851
+ {
4852
+ return Err(inspection_error(
4853
+ StatusCode::UNPROCESSABLE_ENTITY,
4854
+ "field_not_found",
4855
+ format!("field {field} is not in collection {collection}"),
4856
+ ));
4857
+ }
4858
+ filters.push(QueryFilter::Eq {
4859
+ field: field.clone(),
4860
+ value: Value::String(value.clone()),
4861
+ });
4862
+ }
4863
+ filters.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}")));
4864
+ let filter_hash = format!(
4865
+ "{:x}",
4866
+ Sha256::digest(serde_json::to_vec(&filters).unwrap_or_default())
4867
+ );
4868
+ let state_version = state.db.sequence()?;
4869
+ let mut after = params
4870
+ .get("cursor")
4871
+ .map(|value| parse_inspection_cursor(value, &collection, state_version, &filter_hash))
4872
+ .transpose()?;
4873
+ let policy = matching_state_policy(&contract, &collection).ok_or_else(|| {
4874
+ inspection_error(
4875
+ StatusCode::FORBIDDEN,
4876
+ "inspection_not_allowed",
4877
+ "collection has no inspection policy",
4878
+ )
4879
+ })?;
4880
+ let read_policy = policy.read.as_deref().and_then(PolicySubject::from_str);
4881
+ if read_policy.is_none()
4882
+ && !policy
4883
+ .capabilities
4884
+ .iter()
4885
+ .any(|value| value == "state:read")
4886
+ {
4887
+ return Err(inspection_error(
4888
+ StatusCode::FORBIDDEN,
4889
+ "forbidden",
4890
+ "application policy denies collection inspection",
4891
+ ));
4892
+ }
4893
+ let auth_state = if matches!(read_policy, Some(PolicySubject::Member)) {
4894
+ Some(AuthorizationState::new(
4895
+ std::sync::Arc::new(contract.state.schema.clone()),
4896
+ contract.environment.state_namespace.clone(),
4897
+ std::sync::Arc::new(state.db.state_rows()?),
4898
+ ))
4899
+ } else {
4900
+ None
4901
+ };
4902
+ let capability = format!("{}:{}", contract.environment.state_namespace, collection);
4903
+ let mut accepted: Vec<(String, Value)> = Vec::new();
4904
+ let mut exhausted = false;
4905
+ while accepted.len() <= limit && !exhausted {
4906
+ let page = state
4907
+ .db
4908
+ .list_collection_page(&capability, after.as_deref(), 256)?;
4909
+ if page.len() < 256 {
4910
+ exhausted = true;
4911
+ }
4912
+ if page.is_empty() {
4913
+ break;
4914
+ }
4915
+ for row in page {
4916
+ after = Some(row.key.clone());
4917
+ if !filters.iter().all(|filter| match filter {
4918
+ QueryFilter::Eq { field, value } => row.value.get(field) == Some(value),
4919
+ _ => false,
4920
+ }) {
4921
+ continue;
4922
+ }
4923
+ if let Some(subject) = read_policy.clone() {
4924
+ let mut context = RecordAuthorizationContext::new(
4925
+ Some(Actor::new(&principal.key_id)),
4926
+ collection.clone(),
4927
+ row.key.clone(),
4928
+ row.value.clone(),
4929
+ );
4930
+ if let Some(snapshot) = auth_state.clone() {
4931
+ context = context.with_state(snapshot);
4932
+ }
4933
+ if PolicyEvaluator::evaluate_record(subject, &context).is_err() {
4934
+ continue;
4935
+ }
4936
+ }
4937
+ accepted.push((row.key, row.value));
4938
+ if accepted.len() > limit {
4939
+ break;
4940
+ }
4941
+ }
4942
+ }
4943
+ let has_more = accepted.len() > limit;
4944
+ let last_key = accepted
4945
+ .get(limit.saturating_sub(1))
4946
+ .map(|value| value.0.clone());
4947
+ let records = accepted
4948
+ .into_iter()
4949
+ .take(limit)
4950
+ .map(|(id, mut record)| {
4951
+ let relationships = relationship_metadata(&contract, &collection, &record);
4952
+ redact_inspection_value(&mut record);
4953
+ if let Some(object) = record.as_object_mut() {
4954
+ object.entry("id").or_insert(json!(id));
4955
+ object.insert("_relationships".into(), json!(relationships));
4956
+ }
4957
+ record
4958
+ })
4959
+ .collect::<Vec<_>>();
4960
+ Ok(Json(
4961
+ json!({"collection":collection,"records":records,"pagination":{"limit":limit,"nextCursor":if has_more{last_key.map(|key|inspection_cursor(&collection,state_version,&key,&filter_hash))}else{None}},"state_version":state_version}),
4962
+ ))
4963
+ }
4964
+ async fn inspect_record(
4965
+ State(state): State<AppState>,
4966
+ Extension(principal): Extension<Principal>,
4967
+ Path((collection, id)): Path<(String, String)>,
4968
+ Query(params): Query<HashMap<String, String>>,
4969
+ ) -> Result<Json<Value>, ApiError> {
4970
+ let application_id = params.get("application_id").ok_or_else(|| {
4971
+ inspection_error(
4972
+ StatusCode::BAD_REQUEST,
4973
+ "inspection_not_allowed",
4974
+ "application_id is required",
4975
+ )
4976
+ })?;
4977
+ let environment = params
4978
+ .get("environment")
4979
+ .map(String::as_str)
4980
+ .unwrap_or("production");
4981
+ let contract = active_inspection_contract(&state, &principal, application_id, environment)?;
4982
+ inspectable_collection(&contract, &collection)?;
4983
+ let policy = matching_state_policy(&contract, &collection).ok_or_else(|| {
4984
+ inspection_error(
4985
+ StatusCode::FORBIDDEN,
4986
+ "inspection_not_allowed",
4987
+ "collection has no inspection policy",
4988
+ )
4989
+ })?;
4990
+ let read_policy = policy.read.as_deref().and_then(PolicySubject::from_str);
4991
+ if read_policy.is_none()
4992
+ && !policy
4993
+ .capabilities
4994
+ .iter()
4995
+ .any(|value| value == "state:read")
4996
+ {
4997
+ return Err(inspection_error(
4998
+ StatusCode::FORBIDDEN,
4999
+ "forbidden",
5000
+ "application policy denies record inspection",
5001
+ ));
5002
+ }
5003
+ let capability = format!("{}:{}", contract.environment.state_namespace, collection);
5004
+ let row = state
5005
+ .db
5006
+ .get_collection_record(&capability, &id)?
5007
+ .ok_or_else(|| {
5008
+ inspection_error(
5009
+ StatusCode::NOT_FOUND,
5010
+ "record_not_found",
5011
+ "record not found or is not authorized",
5012
+ )
5013
+ })?;
5014
+ if let Some(subject) = read_policy {
5015
+ let mut context = RecordAuthorizationContext::new(
5016
+ Some(Actor::new(&principal.key_id)),
5017
+ collection.clone(),
5018
+ id.clone(),
5019
+ row.value.clone(),
5020
+ );
5021
+ if matches!(subject, PolicySubject::Member) {
5022
+ context = context.with_state(AuthorizationState::new(
5023
+ std::sync::Arc::new(contract.state.schema.clone()),
5024
+ contract.environment.state_namespace.clone(),
5025
+ std::sync::Arc::new(state.db.state_rows()?),
5026
+ ));
5027
+ }
5028
+ if PolicyEvaluator::evaluate_record(subject, &context).is_err() {
5029
+ return Err(inspection_error(
5030
+ StatusCode::NOT_FOUND,
5031
+ "record_not_found",
5032
+ "record not found or is not authorized",
5033
+ ));
5034
+ }
5035
+ }
5036
+ let mut record = row.value;
5037
+ let relationships = relationship_metadata(&contract, &collection, &record);
5038
+ redact_inspection_value(&mut record);
5039
+ if let Some(object) = record.as_object_mut() {
5040
+ object.insert("id".into(), json!(id));
5041
+ }
5042
+ Ok(Json(
5043
+ json!({"collection":collection,"record":record,"relationships":relationships}),
5044
+ ))
5045
+ }
5046
+ const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
5047
+ const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
5048
+ const PROPOSAL_PREVIEW_COLLECTION: &str = "_feltdb_proposal_previews";
5049
+
5050
+ fn proposal_contract_snapshot(
5051
+ revision: &ApplicationRevision,
5052
+ environment: &str,
5053
+ ) -> Value {
5054
+ let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
5055
+ let flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash").cloned();
5056
+ let dsl_version = revision.manifest.metadata.labels.get("feltdb.dsl_version").and_then(|value| value.parse::<u32>().ok()).unwrap_or(1);
5057
+ json!({
5058
+ "application":{"application_id":revision.application_id,"environment":environment},
5059
+ "contract_version":revision.revision_number,"contract_hash":contract_hash,"flow_hash":flow_hash,
5060
+ "dsl_version":dsl_version,"schema":revision.manifest.collections,"indexes":revision.manifest.indexes,
5061
+ "policies":revision.manifest.policies,"workflows":revision.manifest.workflows,"agents":revision.manifest.agents,
5062
+ "capabilities":revision.manifest.capabilities,"modules":revision.manifest.modules,
5063
+ "supported_primitives":["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
5064
+ })
5065
+ }
5066
+
5067
+ fn proposal_hash(value: &Value) -> String {
5068
+ format!("sha256:{:x}", Sha256::digest(serde_json::to_vec(value).unwrap_or_default()))
5069
+ }
5070
+
5071
+ fn preview_ttl_seconds() -> i64 {
5072
+ std::env::var("FELTDB_PROPOSAL_PREVIEW_TTL_SECONDS").ok().and_then(|value| value.parse().ok()).map(|value: i64| value.clamp(1, 86_400)).unwrap_or(3_600)
5073
+ }
5074
+
5075
+ fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
5076
+ required_value(state, PROPOSAL_PREVIEW_COLLECTION, id, "preview not found")
5077
+ }
5078
+
5079
+ fn preview_available(value: &Value) -> bool {
5080
+ value.get("status").and_then(Value::as_str) == Some("ready")
5081
+ && value.get("expires_at").and_then(Value::as_i64).is_some_and(|expires| expires > unix_seconds_i64())
5082
+ }
5083
+
5084
+ fn proposal_event(
5085
+ state: &AppState,
5086
+ proposal_id: &str,
5087
+ from: Option<&str>,
5088
+ to: &str,
5089
+ actor: &str,
5090
+ ) -> Result<(), ApiError> {
5091
+ let id = format!("pe_{}", uuid::Uuid::new_v4().simple());
5092
+ let proposal = proposal_value(state, proposal_id).unwrap_or(Value::Null);
5093
+ let event_sequence = state.db.sequence()?.saturating_add(1);
5094
+ insert_canonical(
5095
+ state,
5096
+ PROPOSAL_EVENT_COLLECTION,
5097
+ &id,
5098
+ json!({ "id":id, "event_id":id, "proposal_id":proposal_id, "event_type":to,
5099
+ "from_status":from, "to_status":to, "actor":actor, "created_at":unix_seconds_i64(),
5100
+ "timestamp":unix_seconds_i64(), "event_sequence":event_sequence, "contract_hash":proposal.get("base_contract_hash"),
5101
+ "flow_hash":proposal.get("base_flow_hash"), "metadata":{} }),
5102
+ )?;
5103
+ Ok(())
5104
+ }
5105
+ fn proposal_value(state: &AppState, id: &str) -> Result<Value, ApiError> {
5106
+ required_value(state, PROPOSAL_COLLECTION, id, "proposal not found")
5107
+ }
5108
+ fn proposal_scope_matches(value: &Value, application_id: &str, environment: &str) -> bool {
5109
+ value.get("application_id").and_then(Value::as_str) == Some(application_id)
5110
+ && value.get("environment").and_then(Value::as_str) == Some(environment)
5111
+ }
5112
+ async fn create_proposal(
5113
+ State(state): State<AppState>,
5114
+ Extension(principal): Extension<Principal>,
5115
+ Json(input): Json<CreateProposalRequest>,
5116
+ ) -> Result<(StatusCode, Json<Value>), ApiError> {
5117
+ if input.status.is_some() {
5118
+ return Err(inspection_error(StatusCode::BAD_REQUEST, "invalid_proposal", "proposal lifecycle state is server-controlled"));
5119
+ }
5120
+ if input.proposal_version.unwrap_or(1) != 1 {
5121
+ return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", "proposal_version must be 1"));
5122
+ }
5123
+ if input.contract_diff.as_ref().is_none_or(|value| !value.is_array())
5124
+ || input.source_plan.as_ref().is_none_or(|value| !value.get("files").is_some_and(Value::is_array))
5125
+ {
5126
+ return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", "contract_diff and source_plan.files are required arrays"));
5127
+ }
5128
+ let tenant = application_scope(
5129
+ &state,
5130
+ &principal.key_id,
5131
+ &input.application_id,
5132
+ "application:write",
5133
+ )?;
5134
+ let revision_id = state
5135
+ .applications
5136
+ .pointers(&input.application_id)
5137
+ .get(&input.environment)
5138
+ .cloned()
5139
+ .ok_or(ApiError(
5140
+ StatusCode::NOT_FOUND,
5141
+ "application environment has no active contract".into(),
5142
+ ))?;
5143
+ let revision = state
5144
+ .applications
5145
+ .revision(&tenant, &input.application_id, &revision_id)
5146
+ .ok_or(ApiError(
5147
+ StatusCode::NOT_FOUND,
5148
+ "application revision not found".into(),
5149
+ ))?;
5150
+ let contract_hash = revision
5151
+ .manifest
5152
+ .metadata
5153
+ .labels
5154
+ .get("feltdb.contract_hash")
5155
+ .unwrap_or(&revision.manifest_hash);
5156
+ if input.base_contract_hash != *contract_hash {
5157
+ return Err(ApiError(
5158
+ StatusCode::CONFLICT,
5159
+ "STALE_PROPOSAL: base contract does not match active contract".into(),
5160
+ ));
5161
+ }
5162
+ if input.proposed_flow.trim().is_empty() || input.base_flow_hash.trim().is_empty() {
5163
+ return Err(ApiError(
5164
+ StatusCode::BAD_REQUEST,
5165
+ "proposed_flow and base_flow_hash are required".into(),
5166
+ ));
5167
+ }
5168
+ let id = format!("p_{}", uuid::Uuid::new_v4().simple());
5169
+ let now = unix_seconds_i64();
5170
+ let mut value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
5171
+ "status":"proposed", "proposal_version":1, "base_contract_version":revision.revision_number,
5172
+ "base_contract_hash":input.base_contract_hash, "base_flow_hash":input.base_flow_hash, "proposed_flow":input.proposed_flow,
5173
+ "contract_diff":input.contract_diff.unwrap_or_else(||json!([])), "source_plan":input.source_plan.unwrap_or_else(||json!({"files":[]})), "summary":input.summary, "warnings":input.warnings,
5174
+ "created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
5175
+ "parent_proposal_id":input.parent_proposal_id,
5176
+ "base_module_versions": revision.manifest.modules.iter().filter_map(|module| Some(json!({
5177
+ "id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
5178
+ }))).collect::<Vec<_>>(),
5179
+ "module_versions": revision.manifest.modules.iter().filter_map(|module| Some(json!({
5180
+ "id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
5181
+ }))).collect::<Vec<_>>() });
5182
+ redact_inspection_value(&mut value);
5183
+ insert_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?;
5184
+ proposal_event(&state, &id, None, "proposed", &principal.key_id)?;
5185
+ Ok((StatusCode::CREATED, Json(value)))
5186
+ }
5187
+ async fn list_proposals(
5188
+ State(state): State<AppState>,
5189
+ Extension(principal): Extension<Principal>,
5190
+ Query(input): Query<ProposalScopeRequest>,
5191
+ ) -> Result<Json<Value>, ApiError> {
5192
+ application_scope(
5193
+ &state,
5194
+ &principal.key_id,
5195
+ &input.application_id,
5196
+ "application:read",
5197
+ )?;
5198
+ let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
5199
+ if limit == 0 || limit > MAX_INSPECTION_LIMIT {
5200
+ return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_pagination", format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}")));
5201
+ }
5202
+ if input.status.as_ref().is_some_and(|status| !matches!(status.as_str(), "proposed"|"validated"|"previewed"|"approved"|"applied"|"rejected"|"expired")) {
5203
+ return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "unsupported_filter", "unsupported proposal status"));
5204
+ }
5205
+ let filter_hash = format!("{}:{}:{}", input.application_id, input.environment, input.status.as_deref().unwrap_or("*"));
5206
+ let state_version = state.db.sequence()?;
5207
+ let mut after = input.cursor.as_deref().map(|cursor| parse_inspection_cursor(cursor, PROPOSAL_COLLECTION, state_version, &filter_hash)).transpose()?;
5208
+ let mut accepted = Vec::new();
5209
+ let mut exhausted = false;
5210
+ while accepted.len() <= limit && !exhausted {
5211
+ let page = state.db.list_collection_page(PROPOSAL_COLLECTION, after.as_deref(), 256)?;
5212
+ if page.len() < 256 { exhausted = true; }
5213
+ if page.is_empty() { break; }
5214
+ for row in page {
5215
+ after = Some(row.key.clone());
5216
+ if !proposal_scope_matches(&row.value, &input.application_id, &input.environment) { continue; }
5217
+ if input.status.as_ref().is_some_and(|status| row.value.get("status").and_then(Value::as_str) != Some(status)) { continue; }
5218
+ let mut value = row.value;
5219
+ redact_inspection_value(&mut value);
5220
+ accepted.push((row.key, value));
5221
+ if accepted.len() > limit { break; }
5222
+ }
5223
+ }
5224
+ let has_more = accepted.len() > limit;
5225
+ let last_key = accepted.get(limit.saturating_sub(1)).map(|entry| entry.0.clone());
5226
+ let proposals = accepted.into_iter().take(limit).map(|entry| entry.1).collect::<Vec<_>>();
5227
+ let next_cursor = if has_more { last_key.map(|key| inspection_cursor(PROPOSAL_COLLECTION, state_version, &key, &filter_hash)) } else { None };
5228
+ Ok(Json(json!({"proposals":proposals,"pagination":{"limit":limit,"nextCursor":next_cursor},"state_version":state_version})))
5229
+ }
5230
+ async fn get_proposal(
5231
+ State(state): State<AppState>,
5232
+ Extension(principal): Extension<Principal>,
5233
+ Path(id): Path<String>,
5234
+ Query(input): Query<ProposalScopeRequest>,
5235
+ ) -> Result<Json<Value>, ApiError> {
5236
+ application_scope(
5237
+ &state,
5238
+ &principal.key_id,
5239
+ &input.application_id,
5240
+ "application:read",
5241
+ )?;
5242
+ let mut value = proposal_value(&state, &id)?;
5243
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5244
+ return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
5245
+ }
5246
+ let mut value = value;
5247
+ redact_inspection_value(&mut value);
5248
+ Ok(Json(value))
5249
+ }
5250
+ async fn proposal_history(
5251
+ State(state): State<AppState>,
5252
+ Extension(principal): Extension<Principal>,
5253
+ Path(id): Path<String>,
5254
+ Query(input): Query<ProposalScopeRequest>,
5255
+ ) -> Result<Json<Value>, ApiError> {
5256
+ let _ = get_proposal(
5257
+ State(state.clone()),
5258
+ Extension(principal),
5259
+ Path(id.clone()),
5260
+ Query(input),
5261
+ )
5262
+ .await?;
5263
+ let mut events: Vec<Value> = state
5264
+ .db
5265
+ .list_collection(PROPOSAL_EVENT_COLLECTION)?
5266
+ .into_iter()
5267
+ .map(|row| row.value)
5268
+ .filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str()))
5269
+ .collect();
5270
+ events.sort_by(|left, right| left.get("event_sequence").and_then(Value::as_u64).cmp(&right.get("event_sequence").and_then(Value::as_u64)).then_with(|| left.get("id").and_then(Value::as_str).cmp(&right.get("id").and_then(Value::as_str))));
5271
+ for event in &mut events { redact_inspection_value(event); }
5272
+ Ok(Json(json!({"events":events})))
5273
+ }
5274
+ fn move_proposal(
5275
+ state: &AppState,
5276
+ id: &str,
5277
+ mut value: Value,
5278
+ to: &str,
5279
+ actor: &str,
5280
+ ) -> Result<Value, ApiError> {
5281
+ let from = value
5282
+ .get("status")
5283
+ .and_then(Value::as_str)
5284
+ .unwrap_or("")
5285
+ .to_string();
5286
+ let allowed = matches!(
5287
+ (from.as_str(), to),
5288
+ ("proposed", "validated")
5289
+ | ("proposed", "rejected")
5290
+ | ("validated", "previewed")
5291
+ | ("validated", "rejected")
5292
+ | ("previewed", "approved")
5293
+ | ("previewed", "rejected")
5294
+ | ("approved", "applied")
5295
+ | ("approved", "expired")
5296
+ );
5297
+ if !allowed {
5298
+ return Err(ApiError(
5299
+ StatusCode::CONFLICT,
5300
+ format!("INVALID_PROPOSAL_TRANSITION: {from} -> {to}"),
5301
+ ));
5302
+ }
5303
+ value["status"] = json!(to);
5304
+ if to == "approved" {
5305
+ value["approved_by"] = json!(actor);
5306
+ value["approved_at"] = json!(unix_seconds_i64());
5307
+ }
5308
+ if to == "applied" {
5309
+ value["applied_at"] = json!(unix_seconds_i64());
5310
+ }
5311
+ if to == "rejected" {
5312
+ value["rejected_by"] = json!(actor);
5313
+ value["rejected_at"] = json!(unix_seconds_i64());
5314
+ }
5315
+ let _ = put_canonical(state, PROPOSAL_COLLECTION, id, value.clone())?;
5316
+ proposal_event(state, id, Some(&from), to, actor)?;
5317
+ Ok(value)
5318
+ }
5319
+
5320
+ fn proposal_is_expired(value: &Value) -> bool {
5321
+ value.get("expires_at").and_then(|expires| expires.as_i64().or_else(|| expires.as_str()?.parse().ok())).is_some_and(|expires| expires <= unix_seconds_i64())
5322
+ }
5323
+
5324
+ fn proposal_readiness_value(
5325
+ state: &AppState,
5326
+ tenant: &str,
5327
+ value: &Value,
5328
+ ) -> Result<Value, ApiError> {
5329
+ let application_id = value.get("application_id").and_then(Value::as_str).unwrap_or("");
5330
+ let environment = value.get("environment").and_then(Value::as_str).unwrap_or("production");
5331
+ let revision_id = state.applications.pointers(application_id).get(environment).cloned().ok_or_else(|| inspection_error(StatusCode::NOT_FOUND, "application_not_found", "active application contract not found"))?;
5332
+ let revision = state.applications.revision(tenant, application_id, &revision_id).ok_or_else(|| inspection_error(StatusCode::NOT_FOUND, "application_not_found", "active application revision not found"))?;
5333
+ let current_contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash).clone();
5334
+ let current_flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash").cloned();
5335
+ let proposal_contract_hash = value.get("base_contract_hash").and_then(Value::as_str).unwrap_or("");
5336
+ let proposal_flow_hash = value.get("base_flow_hash").and_then(Value::as_str).unwrap_or("");
5337
+ let current_modules = revision.manifest.modules.iter().filter_map(|module| Some(json!({"id":module.get("id")?,"provider":module.get("provider")?,"version":module.get("version")?}))).collect::<Vec<_>>();
5338
+ let contract_changed = current_contract_hash != proposal_contract_hash;
5339
+ let flow_changed = current_flow_hash.as_deref().is_some_and(|hash| hash != proposal_flow_hash);
5340
+ let modules_changed = value.get("base_module_versions").and_then(Value::as_array).is_some_and(|modules| modules != &current_modules);
5341
+ let expired = proposal_is_expired(value);
5342
+ let mut blockers = Vec::new();
5343
+ if contract_changed { blockers.push("contract_changed"); }
5344
+ if flow_changed { blockers.push("flow_changed"); }
5345
+ if modules_changed { blockers.push("module_versions_changed"); }
5346
+ if expired { blockers.push("expired"); }
5347
+ let stale = contract_changed || flow_changed || modules_changed;
5348
+ Ok(json!({
5349
+ "status":value.get("status"), "readiness":if blockers.is_empty(){"ready"}else{"blocked"},
5350
+ "blockers":blockers, "notes":[], "current_contract_hash":current_contract_hash,
5351
+ "proposal_contract_hash":proposal_contract_hash, "current_flow_hash":current_flow_hash,
5352
+ "proposal_flow_hash":proposal_flow_hash, "stale":stale, "expires_at":value.get("expires_at")
5353
+ }))
5354
+ }
5355
+
5356
+ async fn proposal_status(
5357
+ State(state): State<AppState>,
5358
+ Extension(principal): Extension<Principal>,
5359
+ Path(id): Path<String>,
5360
+ Query(input): Query<ProposalScopeRequest>,
5361
+ ) -> Result<Json<Value>, ApiError> {
5362
+ let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
5363
+ let mut value = proposal_value(&state, &id)?;
5364
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5365
+ return Err(inspection_error(StatusCode::NOT_FOUND, "proposal_not_found", "proposal not found"));
5366
+ }
5367
+ Ok(Json(proposal_readiness_value(&state, &tenant, &value)?))
5368
+ }
5369
+ async fn validate_proposal(
5370
+ State(state): State<AppState>,
5371
+ Extension(principal): Extension<Principal>,
5372
+ Path(id): Path<String>,
5373
+ Json(input): Json<ProposalActorRequest>,
5374
+ ) -> Result<Json<Value>, ApiError> {
5375
+ let tenant = application_scope(
5376
+ &state,
5377
+ &principal.key_id,
5378
+ &input.application_id,
5379
+ "application:write",
5380
+ )?;
5381
+ let mut value = proposal_value(&state, &id)?;
5382
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5383
+ return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
5384
+ }
5385
+ if value.get("status").and_then(Value::as_str) == Some("validated") {
5386
+ redact_inspection_value(&mut value);
5387
+ return Ok(Json(value));
5388
+ }
5389
+ let revision_id = state
5390
+ .applications
5391
+ .pointers(&input.application_id)
5392
+ .get(&input.environment)
5393
+ .cloned()
5394
+ .ok_or(ApiError(
5395
+ StatusCode::NOT_FOUND,
5396
+ "active contract not found".into(),
5397
+ ))?;
5398
+ let revision = state
5399
+ .applications
5400
+ .revision(&tenant, &input.application_id, &revision_id)
5401
+ .ok_or(ApiError(
5402
+ StatusCode::NOT_FOUND,
5403
+ "application revision not found".into(),
5404
+ ))?;
5405
+ let hash = revision
5406
+ .manifest
5407
+ .metadata
5408
+ .labels
5409
+ .get("feltdb.contract_hash")
5410
+ .unwrap_or(&revision.manifest_hash);
5411
+ if value.get("base_contract_hash").and_then(Value::as_str) != Some(hash) {
5412
+ return Err(ApiError(StatusCode::CONFLICT, "STALE_PROPOSAL".into()));
5413
+ }
5414
+ if let Some(manifest) = input.proposed_manifest {
5415
+ let report = validate_manifest(
5416
+ &manifest,
5417
+ &tenant,
5418
+ &input.application_id,
5419
+ Some(&revision.manifest),
5420
+ );
5421
+ if !report.valid {
5422
+ return Err(ApiError::structured(
5423
+ StatusCode::UNPROCESSABLE_ENTITY,
5424
+ json!({"code":"INVALID_PROPOSAL","validation":report}),
5425
+ ));
5426
+ }
5427
+ value["module_versions"] = json!(manifest.modules.iter().filter_map(|module| Some(json!({
5428
+ "id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
5429
+ }))).collect::<Vec<_>>());
5430
+ let module_ids = manifest
5431
+ .modules
5432
+ .iter()
5433
+ .filter_map(|module| module.get("id").and_then(Value::as_str))
5434
+ .collect::<Vec<_>>();
5435
+ let module_models = manifest
5436
+ .collections
5437
+ .iter()
5438
+ .flat_map(|collection| collection.fields.iter())
5439
+ .filter_map(|field| field.reference.as_deref())
5440
+ .filter(|reference| {
5441
+ module_ids
5442
+ .iter()
5443
+ .any(|id| reference.starts_with(&format!("{id}.")))
5444
+ })
5445
+ .collect::<Vec<_>>();
5446
+ let relationships = manifest
5447
+ .collections
5448
+ .iter()
5449
+ .flat_map(|collection| {
5450
+ collection.fields.iter().filter_map(|field| {
5451
+ field.reference.as_ref().map(|reference| {
5452
+ format!("{}.{} -> {}", collection.name, field.name, reference)
5453
+ })
5454
+ })
5455
+ })
5456
+ .collect::<Vec<_>>();
5457
+ let ownership = manifest
5458
+ .modules
5459
+ .iter()
5460
+ .flat_map(|module| {
5461
+ let id = module.get("id").and_then(Value::as_str).unwrap_or("");
5462
+ module
5463
+ .get("models")
5464
+ .and_then(Value::as_array)
5465
+ .into_iter()
5466
+ .flatten()
5467
+ .filter_map(move |model| {
5468
+ Some((
5469
+ format!("{}.{}", id, model.get("name")?.as_str()?),
5470
+ Value::String(model.get("ownership")?.as_str()?.to_string()),
5471
+ ))
5472
+ })
5473
+ })
5474
+ .collect::<serde_json::Map<String, Value>>();
5475
+ value["module_impact"] = json!({
5476
+ "modules":module_ids, "models":module_models, "relationships":relationships,
5477
+ "events":manifest.triggers.iter().filter(|trigger| module_ids.iter().any(|id| trigger.event.starts_with(&format!("{id}.")))).map(|trigger|trigger.event.clone()).collect::<Vec<_>>(),
5478
+ "workflows":manifest.workflows.iter().map(|workflow|workflow.name.clone()).collect::<Vec<_>>(),
5479
+ "capabilities":manifest.capabilities.iter().filter(|capability| capability.provider.as_deref().map(|provider|module_ids.contains(&provider)).unwrap_or(false)).map(|capability|capability.name.clone()).collect::<Vec<_>>(),
5480
+ "authorization":manifest.policies.iter().map(|policy|policy.name.clone()).collect::<Vec<_>>(),
5481
+ "configuration":manifest.modules.iter().flat_map(|module| module.get("configuration").and_then(Value::as_array).into_iter().flatten().filter_map(|item|item.get("name").cloned())).collect::<Vec<_>>(),
5482
+ "secrets":manifest.modules.iter().flat_map(|module| module.get("secrets").and_then(Value::as_array).into_iter().flatten().filter_map(|item|item.get("name").cloned())).collect::<Vec<_>>(),
5483
+ "ownership":ownership,
5484
+ "state_flow":manifest.modules.iter().flat_map(|module| module.get("reconciliation").and_then(Value::as_array).into_iter().flatten().filter_map(|item| Some(format!("{} -> {} -> {}",item.get("event")?.as_str()?,item.get("workflow")?.as_str()?,item.get("outcome")?.as_str()?)))).collect::<Vec<_>>()
5485
+ });
5486
+ let proposed = ApplicationRevision {
5487
+ revision_id: format!("proposal://{id}"),
5488
+ application_id: input.application_id.clone(),
5489
+ tenant_id: tenant,
5490
+ revision_number: revision.revision_number + 1,
5491
+ parent_revision_id: Some(revision.revision_id.clone()),
5492
+ manifest_hash: String::new(),
5493
+ manifest,
5494
+ created_by: principal.key_id.clone(),
5495
+ created_at: unix_seconds_i64() as u64,
5496
+ status: RevisionStatus::Committed,
5497
+ };
5498
+ value["contract_diff"] =
5499
+ serde_json::to_value(diff_manifests(Some(&revision), &proposed).changes)
5500
+ .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
5501
+ value["proposed_contract"] = serde_json::to_value(&proposed.manifest)
5502
+ .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
5503
+ }
5504
+ value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
5505
+ Ok(Json(move_proposal(
5506
+ &state,
5507
+ &id,
5508
+ value,
5509
+ "validated",
5510
+ &principal.key_id,
5511
+ )?))
5512
+ }
5513
+ async fn preview_proposal(
5514
+ State(state): State<AppState>,
5515
+ Extension(principal): Extension<Principal>,
5516
+ Path(id): Path<String>,
5517
+ Json(input): Json<ProposalActorRequest>,
5518
+ ) -> Result<Json<Value>, ApiError> {
5519
+ let tenant = application_scope(
5520
+ &state,
5521
+ &principal.key_id,
5522
+ &input.application_id,
5523
+ "application:write",
5524
+ )?;
5525
+ let mut value = proposal_value(&state, &id)?;
5526
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5527
+ return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
5528
+ }
5529
+ let replacing_expired_preview = value.get("status").and_then(Value::as_str) == Some("previewed");
5530
+ if replacing_expired_preview {
5531
+ if let Some(preview_id) = value.get("preview_id").and_then(Value::as_str) {
5532
+ let mut artifact = preview_artifact(&state, preview_id)?;
5533
+ if preview_available(&artifact) {
5534
+ let mut proposal = value.clone();
5535
+ redact_inspection_value(&mut proposal);
5536
+ redact_inspection_value(&mut artifact);
5537
+ if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
5538
+ artifact["proposal"] = proposal;
5539
+ return Ok(Json(artifact));
5540
+ }
5541
+ }
5542
+ }
5543
+ if !replacing_expired_preview && value.get("status").and_then(Value::as_str) != Some("validated") {
5544
+ return Err(inspection_error(StatusCode::CONFLICT, "invalid_proposal_transition", "only a validated proposal can be previewed"));
5545
+ }
5546
+ let proposed_manifest: ApplicationManifest = serde_json::from_value(value.get("proposed_contract").cloned().ok_or_else(|| inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "missing_required_semantics", "validated proposal has no proposed contract snapshot"))?).map_err(|error| inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", error.to_string()))?;
5547
+ let current_revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned().ok_or_else(|| inspection_error(StatusCode::NOT_FOUND, "application_not_found", "active application contract not found"))?;
5548
+ let current_revision = state.applications.revision(&tenant, &input.application_id, &current_revision_id).ok_or_else(|| inspection_error(StatusCode::NOT_FOUND, "application_not_found", "active application revision not found"))?;
5549
+ let readiness = proposal_readiness_value(&state, &tenant, &value)?;
5550
+ if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
5551
+ return Err(ApiError::structured(StatusCode::CONFLICT, json!({"code":"preview_not_ready","message":"proposal cannot be previewed","readiness":readiness})));
5552
+ }
5553
+ let preview_id = format!("pv_{}", uuid::Uuid::new_v4().simple());
5554
+ let preview_environment = format!("preview/proposal/{preview_id}");
5555
+ let proposed_manifest_hash = manifest_hash(&proposed_manifest).map_err(|error| inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "preview_contract_invalid", error))?;
5556
+ let proposed_revision = ApplicationRevision { revision_id:format!("proposal://{id}"), application_id:input.application_id.clone(), tenant_id:tenant,
5557
+ revision_number:value.get("base_contract_version").and_then(Value::as_u64).unwrap_or(current_revision.revision_number)+1,
5558
+ parent_revision_id:Some(current_revision.revision_id.clone()), manifest_hash:proposed_manifest_hash, manifest:proposed_manifest,
5559
+ created_by:principal.key_id.clone(), created_at:unix_seconds_i64() as u64, status:RevisionStatus::Committed };
5560
+ let runtime = resolve_runtime(&proposed_revision, &preview_environment, &RuntimeInventory::default()).map_err(|report| ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({"code":"preview_contract_invalid","validation":report})))?;
5561
+ let current_contract = proposal_contract_snapshot(&current_revision, &input.environment);
5562
+ let mut proposed_contract = proposal_contract_snapshot(&proposed_revision, &input.environment);
5563
+ proposed_contract["contract_hash"] = json!(proposal_hash(&proposed_contract));
5564
+ proposed_contract["flow_hash"] = json!(format!("sha256:{:x}", Sha256::digest(value.get("proposed_flow").and_then(Value::as_str).unwrap_or("").replace("\r\n", "\n").as_bytes())));
5565
+ let namespace = runtime.environment.state_namespace.clone();
5566
+ let mut seeded_records = Vec::new();
5567
+ for collection in &proposed_revision.manifest.collections {
5568
+ if collection.name.starts_with("_feltdb") { continue; }
5569
+ let record_id = format!("preview_{}", collection.name.to_ascii_lowercase());
5570
+ let record = json!({"id":record_id,"_preview_data":true,"_preview_source":"synthetic","label":format!("Synthetic {} preview",collection.name)});
5571
+ state.db.capability(&format!("{}:{}", namespace, collection.name)).insert_with_key(&record_id, record)?;
5572
+ seeded_records.push(json!({"collection":collection.name,"id":record_id,"preview_data":true}));
5573
+ }
5574
+ let now = unix_seconds_i64();
5575
+ let mut artifact = json!({
5576
+ "preview_id":preview_id,"proposal_id":id,"application_id":input.application_id,"environment":input.environment,
5577
+ "proposal_hash":proposal_hash(&value),"proposal_contract_hash":value.get("base_contract_hash"),"proposal_flow_hash":value.get("base_flow_hash"),
5578
+ "module_versions":value.get("module_versions"),"created_at":now,"expires_at":now+preview_ttl_seconds(),"status":"ready",
5579
+ "namespace":namespace,"current":current_contract,"proposed":proposed_contract,"current_contract":current_contract,
5580
+ "proposed_contract":proposed_contract,"semantic_diff":value.get("contract_diff"),"state_flow":value.pointer("/module_impact/state_flow"),
5581
+ "capabilities":runtime.capabilities,"workflows":runtime.workflows,"authorization":runtime.policies,
5582
+ "configuration":value.pointer("/module_impact/configuration"),"secrets":value.pointer("/module_impact/secrets"),
5583
+ "modules":value.pointer("/module_impact/modules"),"ownership":value.pointer("/module_impact/ownership"),
5584
+ "relationships":value.pointer("/module_impact/relationships"),"events":value.pointer("/module_impact/events"),
5585
+ "seeded_records":seeded_records,"data_classification":"synthetic_preview","external_side_effects":"simulated","readiness":readiness
5586
+ });
5587
+ redact_inspection_value(&mut artifact);
5588
+ insert_canonical(&state, PROPOSAL_PREVIEW_COLLECTION, artifact.get("preview_id").and_then(Value::as_str).unwrap_or(""), artifact.clone())?;
5589
+ value["preview_id"] = artifact.get("preview_id").cloned().unwrap_or(Value::Null);
5590
+ value["preview_expires_at"] = artifact.get("expires_at").cloned().unwrap_or(Value::Null);
5591
+ let mut stored_proposal = if replacing_expired_preview {
5592
+ let _ = put_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?;
5593
+ value
5594
+ } else {
5595
+ move_proposal(&state, &id, value, "previewed", &principal.key_id)?
5596
+ };
5597
+ redact_inspection_value(&mut stored_proposal);
5598
+ if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
5599
+ artifact["proposal"] = stored_proposal;
5600
+ Ok(Json(artifact))
5601
+ }
5602
+
5603
+ async fn get_proposal_preview(
5604
+ State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>,
5605
+ Query(input): Query<ProposalScopeRequest>,
5606
+ ) -> Result<Json<Value>, ApiError> {
5607
+ let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
5608
+ let mut artifact = preview_artifact(&state, &id)?;
5609
+ if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
5610
+ return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found"));
5611
+ }
5612
+ if !preview_available(&artifact) {
5613
+ return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired"));
5614
+ }
5615
+ let proposal = proposal_value(&state, artifact.get("proposal_id").and_then(Value::as_str).unwrap_or(""))?;
5616
+ if proposal.get("preview_id") != artifact.get("preview_id") {
5617
+ return Err(inspection_error(StatusCode::CONFLICT, "preview_invalidated", "preview has been replaced"));
5618
+ }
5619
+ if proposal_readiness_value(&state, &tenant, &proposal)?.get("stale").and_then(Value::as_bool) == Some(true) {
5620
+ return Err(inspection_error(StatusCode::CONFLICT, "preview_invalidated", "the application authority changed after preview creation"));
5621
+ }
5622
+ redact_inspection_value(&mut artifact);
5623
+ if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
5624
+ Ok(Json(artifact))
5625
+ }
5626
+
5627
+ async fn inspect_proposal_preview_data(
5628
+ State(state): State<AppState>, Extension(principal): Extension<Principal>, Path((id, collection)): Path<(String, String)>,
5629
+ Query(input): Query<ProposalScopeRequest>,
5630
+ ) -> Result<Json<Value>, ApiError> {
5631
+ application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
5632
+ let artifact = preview_artifact(&state, &id)?;
5633
+ if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
5634
+ return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found"));
5635
+ }
5636
+ if !preview_available(&artifact) { return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired")); }
5637
+ if collection.starts_with("_feltdb") || !artifact.pointer("/proposed/schema").and_then(Value::as_array).is_some_and(|collections| collections.iter().any(|value| value.get("name").and_then(Value::as_str)==Some(&collection))) {
5638
+ return Err(inspection_error(StatusCode::NOT_FOUND, "collection_not_found", "collection is not part of the proposed contract"));
5639
+ }
5640
+ let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
5641
+ if limit == 0 || limit > MAX_INSPECTION_LIMIT { return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_pagination", format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}"))); }
5642
+ let state_version = state.db.sequence()?;
5643
+ let filter_hash = format!("{id}:{collection}");
5644
+ let after = input.cursor.as_deref().map(|cursor| parse_inspection_cursor(cursor, &filter_hash, state_version, "preview-data")).transpose()?;
5645
+ let capability = format!("{}:{}", artifact.get("namespace").and_then(Value::as_str).unwrap_or(""), collection);
5646
+ let mut rows = state.db.list_collection_page(&capability, after.as_deref(), limit.saturating_add(1))?;
5647
+ let has_more = rows.len() > limit;
5648
+ rows.truncate(limit);
5649
+ let last_key = rows.last().map(|row| row.key.clone());
5650
+ let mut records = rows.into_iter().map(|row| row.value).collect::<Vec<_>>();
5651
+ for record in &mut records { redact_inspection_value(record); }
5652
+ let next_cursor = if has_more { last_key.map(|key| inspection_cursor(&filter_hash, state_version, &key, "preview-data")) } else { None };
5653
+ Ok(Json(json!({"preview_id":id,"collection":collection,"records":records,"data_classification":"synthetic_preview","pagination":{"limit":limit,"nextCursor":next_cursor}})))
5654
+ }
5655
+
5656
+ async fn simulate_proposal_preview(
5657
+ State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>,
5658
+ Query(input): Query<ProposalScopeRequest>, Json(simulation): Json<PreviewSimulationRequest>,
5659
+ ) -> Result<Json<Value>, ApiError> {
5660
+ application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
5661
+ let artifact = preview_artifact(&state, &id)?;
5662
+ if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) { return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found")); }
5663
+ if !preview_available(&artifact) { return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired")); }
5664
+ let known = artifact.get("events").and_then(Value::as_array).is_some_and(|events| events.iter().any(|event| event.as_str()==Some(&simulation.event)));
5665
+ if !known { return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "unsupported_preview_event", "event is not part of the proposal")); }
5666
+ let state_flow = artifact.get("state_flow").cloned().unwrap_or_else(||json!([]));
5667
+ Ok(Json(json!({"preview_id":id,"event":simulation.event,"status":"simulated","external_side_effects":"none","trace":state_flow,"writes":"isolated_preview_namespace_only"})))
5668
+ }
5669
+ async fn transition_proposal(
5670
+ State(state): State<AppState>,
5671
+ Extension(principal): Extension<Principal>,
5672
+ headers: HeaderMap,
5673
+ Path(id): Path<String>,
5674
+ Json(input): Json<ProposalStatusRequest>,
5675
+ ) -> Result<Json<Value>, ApiError> {
5676
+ let tenant = application_scope(
5677
+ &state,
5678
+ &principal.key_id,
5679
+ &input.application_id,
5680
+ "application:write",
5681
+ )?;
5682
+ let mut value = proposal_value(&state, &id)?;
5683
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5684
+ return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
5685
+ }
5686
+ if !matches!(
5687
+ input.status.as_str(),
5688
+ "approved" | "rejected" | "expired" | "applied"
5689
+ ) {
5690
+ return Err(ApiError(
5691
+ StatusCode::BAD_REQUEST,
5692
+ "unsupported proposal status".into(),
5693
+ ));
5694
+ }
5695
+ if proposal_is_expired(&value) && input.status != "expired" {
5696
+ return Err(inspection_error(StatusCode::CONFLICT, "proposal_expired", "proposal has expired"));
5697
+ }
5698
+ if input.status == "approved" {
5699
+ let readiness = proposal_readiness_value(&state, &tenant, &value)?;
5700
+ if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
5701
+ return Err(ApiError::structured(StatusCode::CONFLICT, json!({"code":"stale_proposal","message":"proposal is no longer applicable","readiness":readiness})));
5702
+ }
5703
+ let preview_id = value.get("preview_id").and_then(Value::as_str).ok_or_else(|| inspection_error(StatusCode::CONFLICT, "preview_required", "approval requires a ready preview artifact"))?;
5704
+ let artifact = preview_artifact(&state, preview_id)?;
5705
+ let bound = preview_available(&artifact)
5706
+ && artifact.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())
5707
+ && artifact.get("proposal_contract_hash") == value.get("base_contract_hash")
5708
+ && artifact.get("proposal_flow_hash") == value.get("base_flow_hash")
5709
+ && artifact.get("module_versions") == value.get("module_versions");
5710
+ if !bound { return Err(inspection_error(StatusCode::CONFLICT, "preview_invalid", "approval requires a current preview bound to this proposal")); }
5711
+ }
5712
+ if input.status == "applied" {
5713
+ let configured = std::env::var("FELTDB_PROPOSAL_AUTHORITY_TOKEN").ok();
5714
+ let supplied = headers.get("FeltDB-Proposal-Authority").and_then(|value| value.to_str().ok());
5715
+ let repository_apply = headers.get("FeltDB-Repository-Apply").and_then(|value| value.to_str().ok()) == Some("1");
5716
+ let authorized = configured.as_deref().zip(supplied).is_some_and(|(expected, actual)| expected.len() == actual.len() && ring::constant_time::verify_slices_are_equal(expected.as_bytes(), actual.as_bytes()).is_ok());
5717
+ if !repository_apply || !authorized {
5718
+ return Err(inspection_error(StatusCode::FORBIDDEN, "repository_authority_required", "valid repository application authority is required"));
5719
+ }
5720
+ }
5721
+ if input.status == "approved"
5722
+ && value
5723
+ .get("warnings")
5724
+ .and_then(Value::as_array)
5725
+ .map(|items| {
5726
+ items
5727
+ .iter()
5728
+ .any(|item| item.as_str().unwrap_or("").starts_with("Authorization "))
5729
+ })
5730
+ .unwrap_or(false)
5731
+ && !input.approve_authorization
5732
+ {
5733
+ return Err(ApiError(
5734
+ StatusCode::FORBIDDEN,
5735
+ "AUTHORIZATION_APPROVAL_REQUIRED".into(),
5736
+ ));
5737
+ }
5738
+ if input.status == "rejected" {
5739
+ if let Some(reason) = input.reason { value["rejection_reason"] = json!(reason); }
5740
+ }
5741
+ let mut moved = move_proposal(
5742
+ &state,
5743
+ &id,
5744
+ value,
5745
+ &input.status,
5746
+ &principal.key_id,
5747
+ )?;
5748
+ redact_inspection_value(&mut moved);
5749
+ Ok(Json(moved))
4448
5750
  }
4449
- async fn discover_application(
4450
- State(state): State<AppState>,
4451
- Extension(principal): Extension<Principal>,
4452
- Query(input): Query<ApplicationDiscoveryRequest>,
5751
+
5752
+ async fn approve_proposal(
5753
+ state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
5754
+ path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
4453
5755
  ) -> Result<Json<Value>, ApiError> {
4454
- let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
4455
- let revision_id = state.applications.pointers(&input.application_id)
4456
- .get(&input.environment).cloned()
4457
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
4458
- let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
4459
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
4460
- let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
4461
- let dsl_version = revision.manifest.metadata.labels.get("feltdb.dsl_version").and_then(|value| value.parse::<u32>().ok()).unwrap_or(1);
4462
- Ok(Json(json!({
4463
- "application_id": input.application_id,
4464
- "environment": input.environment,
4465
- "version": revision.revision_number,
4466
- "revision_id": revision.revision_id,
4467
- "contract_hash": contract_hash,
4468
- "dsl_version": dsl_version,
4469
- "snapshot": {
4470
- "application": { "application_id": input.application_id, "environment": input.environment },
4471
- "contract_version": revision.revision_number,
4472
- "contract_hash": contract_hash,
4473
- "dsl_version": dsl_version,
4474
- "schema": revision.manifest.collections,
4475
- "indexes": revision.manifest.indexes,
4476
- "policies": revision.manifest.policies,
4477
- "workflows": revision.manifest.workflows,
4478
- "agents": revision.manifest.agents,
4479
- "capabilities": revision.manifest.capabilities,
4480
- "supported_primitives": ["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
4481
- }
4482
- })))
5756
+ transition_proposal(state, principal, headers, path, Json(ProposalStatusRequest {
5757
+ application_id: input.application_id, environment: input.environment, status: "approved".into(),
5758
+ approve_authorization: input.approve_authorization, reason: input.reason,
5759
+ })).await
4483
5760
  }
4484
- const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
4485
- const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
4486
5761
 
4487
- fn proposal_event(state: &AppState, proposal_id: &str, from: Option<&str>, to: &str, actor: &str) -> Result<(), ApiError> {
4488
- let id = format!("pe_{}", uuid::Uuid::new_v4().simple());
4489
- insert_canonical(state, PROPOSAL_EVENT_COLLECTION, &id, json!({ "id":id, "proposal_id":proposal_id,
4490
- "from_status":from, "to_status":to, "actor":actor, "created_at":unix_seconds_i64(), "metadata":{} }))?;
4491
- Ok(())
4492
- }
4493
- fn proposal_value(state: &AppState, id: &str) -> Result<Value, ApiError> {
4494
- required_value(state, PROPOSAL_COLLECTION, id, "proposal not found")
4495
- }
4496
- fn proposal_scope_matches(value: &Value, application_id: &str, environment: &str) -> bool {
4497
- value.get("application_id").and_then(Value::as_str) == Some(application_id)
4498
- && value.get("environment").and_then(Value::as_str) == Some(environment)
4499
- }
4500
- async fn create_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Json(input): Json<CreateProposalRequest>) -> Result<(StatusCode, Json<Value>), ApiError> {
4501
- let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
4502
- let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned()
4503
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
4504
- let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
4505
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
4506
- let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
4507
- if input.base_contract_hash != *contract_hash { return Err(ApiError(StatusCode::CONFLICT, "STALE_PROPOSAL: base contract does not match active contract".into())); }
4508
- if input.proposed_flow.trim().is_empty() || input.base_flow_hash.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "proposed_flow and base_flow_hash are required".into())); }
4509
- let id = format!("p_{}", uuid::Uuid::new_v4().simple()); let now = unix_seconds_i64();
4510
- let value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
4511
- "status":"proposed", "proposal_version":1, "base_contract_version":revision.revision_number,
4512
- "base_contract_hash":input.base_contract_hash, "base_flow_hash":input.base_flow_hash, "proposed_flow":input.proposed_flow,
4513
- "contract_diff":[], "source_plan":{"files":[]}, "summary":input.summary, "warnings":input.warnings,
4514
- "created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
4515
- "parent_proposal_id":input.parent_proposal_id });
4516
- insert_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?; proposal_event(&state, &id, None, "proposed", &principal.key_id)?;
4517
- Ok((StatusCode::CREATED, Json(value)))
4518
- }
4519
- async fn list_proposals(State(state): State<AppState>, Extension(principal): Extension<Principal>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
4520
- application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
4521
- let proposals: Vec<Value> = state.db.list_collection(PROPOSAL_COLLECTION)?.into_iter().map(|row| row.value)
4522
- .filter(|value| proposal_scope_matches(value, &input.application_id, &input.environment)).collect();
4523
- Ok(Json(json!({"proposals":proposals})))
4524
- }
4525
- async fn get_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
4526
- application_scope(&state, &principal.key_id, &input.application_id, "application:read")?; let value = proposal_value(&state, &id)?;
4527
- if !proposal_scope_matches(&value, &input.application_id, &input.environment) { return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into())); }
4528
- Ok(Json(value))
4529
- }
4530
- async fn proposal_history(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
4531
- let _ = get_proposal(State(state.clone()), Extension(principal), Path(id.clone()), Query(input)).await?;
4532
- let events: Vec<Value> = state.db.list_collection(PROPOSAL_EVENT_COLLECTION)?.into_iter().map(|row| row.value)
4533
- .filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())).collect();
4534
- Ok(Json(json!({"events":events})))
5762
+ async fn reject_proposal(
5763
+ state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
5764
+ path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
5765
+ ) -> Result<Json<Value>, ApiError> {
5766
+ transition_proposal(state, principal, headers, path, Json(ProposalStatusRequest {
5767
+ application_id: input.application_id, environment: input.environment, status: "rejected".into(),
5768
+ approve_authorization: false, reason: input.reason,
5769
+ })).await
4535
5770
  }
4536
- fn move_proposal(state: &AppState, id: &str, mut value: Value, to: &str, actor: &str) -> Result<Value, ApiError> {
4537
- let from = value.get("status").and_then(Value::as_str).unwrap_or("").to_string();
4538
- let allowed = matches!((from.as_str(),to), ("proposed","validated")|("proposed","rejected")|("validated","previewed")|("validated","rejected")|("previewed","approved")|("previewed","rejected")|("approved","applied")|("approved","expired"));
4539
- if !allowed { return Err(ApiError(StatusCode::CONFLICT, format!("INVALID_PROPOSAL_TRANSITION: {from} -> {to}"))); }
4540
- value["status"] = json!(to); if to == "approved" { value["approved_by"] = json!(actor); value["approved_at"] = json!(unix_seconds_i64()); }
4541
- if to == "applied" { value["applied_at"] = json!(unix_seconds_i64()); }
4542
- let _ = put_canonical(state, PROPOSAL_COLLECTION, id, value.clone())?; proposal_event(state, id, Some(&from), to, actor)?; Ok(value)
4543
- }
4544
- async fn validate_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(input): Json<ProposalActorRequest>) -> Result<Json<Value>, ApiError> {
4545
- let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:write")?; let mut value = proposal_value(&state, &id)?;
4546
- if !proposal_scope_matches(&value, &input.application_id, &input.environment) { return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into())); }
4547
- let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned().ok_or(ApiError(StatusCode::NOT_FOUND,"active contract not found".into()))?;
4548
- let revision = state.applications.revision(&tenant,&input.application_id,&revision_id).ok_or(ApiError(StatusCode::NOT_FOUND,"application revision not found".into()))?;
4549
- let hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
4550
- if value.get("base_contract_hash").and_then(Value::as_str) != Some(hash) { return Err(ApiError(StatusCode::CONFLICT,"STALE_PROPOSAL".into())); }
4551
- if let Some(manifest) = input.proposed_manifest {
4552
- let report = validate_manifest(&manifest, &tenant, &input.application_id, Some(&revision.manifest));
4553
- if !report.valid { return Err(ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({"code":"INVALID_PROPOSAL","validation":report}))); }
4554
- let proposed = ApplicationRevision { revision_id: format!("proposal://{id}"), application_id:input.application_id.clone(), tenant_id:tenant,
4555
- revision_number:revision.revision_number + 1, parent_revision_id:Some(revision.revision_id.clone()), manifest_hash:String::new(),
4556
- manifest, created_by:principal.key_id.clone(), created_at:unix_seconds_i64() as u64, status:RevisionStatus::Committed };
4557
- value["contract_diff"] = serde_json::to_value(diff_manifests(Some(&revision), &proposed).changes).map_err(|e|ApiError(StatusCode::INTERNAL_SERVER_ERROR,e.to_string()))?;
4558
- }
4559
- value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
4560
- Ok(Json(move_proposal(&state,&id,value,"validated",&principal.key_id)?))
4561
- }
4562
- async fn preview_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(input): Json<ProposalActorRequest>) -> Result<Json<Value>, ApiError> {
4563
- application_scope(&state,&principal.key_id,&input.application_id,"application:write")?; let value=proposal_value(&state,&id)?;
4564
- if !proposal_scope_matches(&value,&input.application_id,&input.environment) { return Err(ApiError(StatusCode::NOT_FOUND,"proposal not found".into())); }
4565
- Ok(Json(json!({"proposal":move_proposal(&state,&id,value,"previewed",&principal.key_id)?})))
4566
- }
4567
- async fn transition_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, headers: HeaderMap, Path(id): Path<String>, Json(input): Json<ProposalStatusRequest>) -> Result<Json<Value>, ApiError> {
4568
- application_scope(&state,&principal.key_id,&input.application_id,"application:write")?; let value=proposal_value(&state,&id)?;
4569
- if !proposal_scope_matches(&value,&input.application_id,&input.environment) { return Err(ApiError(StatusCode::NOT_FOUND,"proposal not found".into())); }
4570
- if !matches!(input.status.as_str(),"approved"|"rejected"|"expired"|"applied") { return Err(ApiError(StatusCode::BAD_REQUEST,"unsupported proposal status".into())); }
4571
- if input.status == "applied" && headers.get("FeltDB-Repository-Apply").and_then(|v|v.to_str().ok()) != Some("1") { return Err(ApiError(StatusCode::FORBIDDEN,"REPOSITORY_AUTHORITY_REQUIRED".into())); }
4572
- if input.status == "approved" && value.get("warnings").and_then(Value::as_array).map(|items|items.iter().any(|item|item.as_str().unwrap_or("").starts_with("Authorization "))).unwrap_or(false) && !input.approve_authorization { return Err(ApiError(StatusCode::FORBIDDEN,"AUTHORIZATION_APPROVAL_REQUIRED".into())); }
4573
- Ok(Json(move_proposal(&state,&id,value,&input.status,&principal.key_id)?))
4574
- }
4575
- fn auth_response(identity: feltdb_server::identity::Identity, session: feltdb_server::identity::IdentitySession, token: String) -> Json<Value> {
5771
+ fn auth_response(
5772
+ identity: feltdb_server::identity::Identity,
5773
+ session: feltdb_server::identity::IdentitySession,
5774
+ token: String,
5775
+ ) -> Json<Value> {
4576
5776
  Json(json!({
4577
5777
  "access_token": token,
4578
5778
  "token_type": "Bearer",
@@ -4581,25 +5781,73 @@ fn auth_response(identity: feltdb_server::identity::Identity, session: feltdb_se
4581
5781
  "session": { "session_id": session.session_id, "issued_at": session.issued_at, "expires_at": session.expires_at }
4582
5782
  }))
4583
5783
  }
4584
- async fn auth_sign_up(State(state): State<AppState>, Json(input): Json<SignUpRequest>) -> Result<(StatusCode, Json<Value>), ApiError> {
4585
- if state.tenancy.application_tenant(&input.application_id).is_none() { return Err(ApiError(StatusCode::NOT_FOUND, "application not found".into())); }
4586
- let (identity, session, token) = state.identities.sign_up(&input.email, &input.password, input.display_name)
4587
- .map_err(|error| ApiError(if error == "identity already exists" { StatusCode::CONFLICT } else { StatusCode::BAD_REQUEST }, error))?;
4588
- state.tenancy.register_platform_user(&identity.subject_id, identity.email.as_deref().unwrap_or(&identity.identity_id)).map_err(control_error)?;
4589
- state.tenancy.register_application_actor(&identity.subject_id, &input.application_id).map_err(control_error)?;
5784
+ async fn auth_sign_up(
5785
+ State(state): State<AppState>,
5786
+ Json(input): Json<SignUpRequest>,
5787
+ ) -> Result<(StatusCode, Json<Value>), ApiError> {
5788
+ if state
5789
+ .tenancy
5790
+ .application_tenant(&input.application_id)
5791
+ .is_none()
5792
+ {
5793
+ return Err(ApiError(
5794
+ StatusCode::NOT_FOUND,
5795
+ "application not found".into(),
5796
+ ));
5797
+ }
5798
+ let (identity, session, token) = state
5799
+ .identities
5800
+ .sign_up(&input.email, &input.password, input.display_name)
5801
+ .map_err(|error| {
5802
+ ApiError(
5803
+ if error == "identity already exists" {
5804
+ StatusCode::CONFLICT
5805
+ } else {
5806
+ StatusCode::BAD_REQUEST
5807
+ },
5808
+ error,
5809
+ )
5810
+ })?;
5811
+ state
5812
+ .tenancy
5813
+ .register_platform_user(
5814
+ &identity.subject_id,
5815
+ identity.email.as_deref().unwrap_or(&identity.identity_id),
5816
+ )
5817
+ .map_err(control_error)?;
5818
+ state
5819
+ .tenancy
5820
+ .register_application_actor(&identity.subject_id, &input.application_id)
5821
+ .map_err(control_error)?;
4590
5822
  Ok((StatusCode::CREATED, auth_response(identity, session, token)))
4591
5823
  }
4592
- async fn auth_sign_in(State(state): State<AppState>, Json(input): Json<SignInRequest>) -> Result<Json<Value>, ApiError> {
4593
- let (identity, session, token) = state.identities.sign_in(&input.email, &input.password)
5824
+ async fn auth_sign_in(
5825
+ State(state): State<AppState>,
5826
+ Json(input): Json<SignInRequest>,
5827
+ ) -> Result<Json<Value>, ApiError> {
5828
+ let (identity, session, token) = state
5829
+ .identities
5830
+ .sign_in(&input.email, &input.password)
4594
5831
  .map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
4595
5832
  Ok(auth_response(identity, session, token))
4596
5833
  }
4597
5834
  async fn auth_session(Extension(principal): Extension<Principal>) -> Json<Value> {
4598
- Json(json!({ "actor": { "user_id": principal.key_id, "identity_id": principal.identity_id }, "session_id": principal.session_id }))
5835
+ Json(
5836
+ json!({ "actor": { "user_id": principal.key_id, "identity_id": principal.identity_id }, "session_id": principal.session_id }),
5837
+ )
4599
5838
  }
4600
- async fn auth_sign_out(State(state): State<AppState>, Extension(principal): Extension<Principal>) -> Result<StatusCode, ApiError> {
4601
- let session = principal.session_id.ok_or(ApiError(StatusCode::BAD_REQUEST, "request is not an actor session".into()))?;
4602
- state.identities.revoke_session(&principal.key_id, &session, "user_logout").map_err(control_error)?;
5839
+ async fn auth_sign_out(
5840
+ State(state): State<AppState>,
5841
+ Extension(principal): Extension<Principal>,
5842
+ ) -> Result<StatusCode, ApiError> {
5843
+ let session = principal.session_id.ok_or(ApiError(
5844
+ StatusCode::BAD_REQUEST,
5845
+ "request is not an actor session".into(),
5846
+ ))?;
5847
+ state
5848
+ .identities
5849
+ .revoke_session(&principal.key_id, &session, "user_logout")
5850
+ .map_err(control_error)?;
4603
5851
  Ok(StatusCode::NO_CONTENT)
4604
5852
  }
4605
5853
  async fn application_openapi() -> Json<Value> {
@@ -5029,8 +6277,29 @@ async fn list_runtime_triggers(
5029
6277
  Ok(Json(json!(contract.triggers)))
5030
6278
  }
5031
6279
 
5032
- fn state_policy_resource_matches(resource:&str,collection:&str)->bool{resource==collection||resource.strip_suffix('*').is_some_and(|prefix|!prefix.contains('*')&&collection.starts_with(prefix))}
5033
- fn matching_state_policy<'a>(contract:&'a ApplicationRuntimeContract,collection:&str)->Option<&'a feltdb::application::PolicyDefinition>{contract.policies.definitions.iter().find(|p|p.resource==collection).or_else(||contract.policies.definitions.iter().find(|p|state_policy_resource_matches(&p.resource,collection)))}
6280
+ fn state_policy_resource_matches(resource: &str, collection: &str) -> bool {
6281
+ resource == collection
6282
+ || resource
6283
+ .strip_suffix('*')
6284
+ .is_some_and(|prefix| !prefix.contains('*') && collection.starts_with(prefix))
6285
+ }
6286
+ fn matching_state_policy<'a>(
6287
+ contract: &'a ApplicationRuntimeContract,
6288
+ collection: &str,
6289
+ ) -> Option<&'a feltdb::application::PolicyDefinition> {
6290
+ contract
6291
+ .policies
6292
+ .definitions
6293
+ .iter()
6294
+ .find(|p| p.resource == collection)
6295
+ .or_else(|| {
6296
+ contract
6297
+ .policies
6298
+ .definitions
6299
+ .iter()
6300
+ .find(|p| state_policy_resource_matches(&p.resource, collection))
6301
+ })
6302
+ }
5034
6303
  fn authorize_transaction_collections(
5035
6304
  principal: &Principal,
5036
6305
  contract: &ApplicationRuntimeContract,
@@ -5063,14 +6332,14 @@ fn state_authorization(
5063
6332
  ) -> AuthorizationContext {
5064
6333
  let subject = format!("{}:{}", principal.subject_type, principal.key_id);
5065
6334
 
5066
- let policy=matching_state_policy(contract,collection);
6335
+ let policy = matching_state_policy(contract, collection);
5067
6336
  let policy_subject = policy.and_then(|p| {
5068
- if operation == "read" {
5069
- p.read.as_ref().and_then(|s| PolicySubject::from_str(s))
5070
- } else {
5071
- p.write.as_ref().and_then(|s| PolicySubject::from_str(s))
5072
- }
5073
- });
6337
+ if operation == "read" {
6338
+ p.read.as_ref().and_then(|s| PolicySubject::from_str(s))
6339
+ } else {
6340
+ p.write.as_ref().and_then(|s| PolicySubject::from_str(s))
6341
+ }
6342
+ });
5074
6343
 
5075
6344
  // Evaluate policy if found
5076
6345
  let capabilities = if let Some(policy) = policy_subject {
@@ -5098,7 +6367,16 @@ fn state_authorization(
5098
6367
  BTreeSet::new()
5099
6368
  }
5100
6369
  }
5101
- } else if policy.is_some_and(|p|p.capabilities.iter().any(|capability|capability==if operation=="read"{"state:read"}else{"state:write"})) {
6370
+ } else if policy.is_some_and(|p| {
6371
+ p.capabilities.iter().any(|capability| {
6372
+ capability
6373
+ == if operation == "read" {
6374
+ "state:read"
6375
+ } else {
6376
+ "state:write"
6377
+ }
6378
+ })
6379
+ }) {
5102
6380
  let capability = if operation == "read" {
5103
6381
  "state:read"
5104
6382
  } else {
@@ -5434,16 +6712,25 @@ async fn execute_canonical_transaction(
5434
6712
  input.transaction.revision_id = contract.revision_id.clone();
5435
6713
  input.transaction.schema_version = contract.state.schema.schema_version;
5436
6714
  input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
5437
- let collections = input.transaction.operations.iter()
6715
+ let collections = input
6716
+ .transaction
6717
+ .operations
6718
+ .iter()
5438
6719
  .map(|operation| operation.collection.clone())
5439
6720
  .collect::<Vec<_>>();
5440
- let (authorized, write_policies) = authorize_transaction_collections(
6721
+ let (authorized, write_policies) =
6722
+ authorize_transaction_collections(&principal, &contract, &tenant, &collections);
6723
+ input.transaction.authorization = state_authorization_legacy(
5441
6724
  &principal,
5442
- &contract,
5443
6725
  &tenant,
5444
- &collections,
6726
+ &input.application_id,
6727
+ &contract.revision_id,
6728
+ if authorized {
6729
+ "state:write"
6730
+ } else {
6731
+ "state:denied"
6732
+ },
5445
6733
  );
5446
- input.transaction.authorization=state_authorization_legacy(&principal,&tenant,&input.application_id,&contract.revision_id,if authorized{"state:write"}else{"state:denied"});
5447
6734
  let result = execute_transaction_with_collection_policies(
5448
6735
  &state.db,
5449
6736
  &contract.state.schema,
@@ -6257,18 +7544,50 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6257
7544
  )
6258
7545
  .route("/v1/schema", get(get_state_schema))
6259
7546
  .route("/v1/application", get(discover_application))
7547
+ .route("/v1/application/schema", get(inspect_application_schema))
7548
+ .route("/v1/data/{collection}", get(inspect_collection))
7549
+ .route("/v1/data/{collection}/{id}", get(inspect_record))
6260
7550
  .route("/v1/proposals", get(list_proposals).post(create_proposal))
6261
7551
  .route("/v1/proposals/{id}", get(get_proposal))
6262
7552
  .route("/v1/proposals/{id}/history", get(proposal_history))
6263
- .route("/v1/proposals/{id}/validate", axum::routing::post(validate_proposal))
6264
- .route("/v1/proposals/{id}/preview", axum::routing::post(preview_proposal))
6265
- .route("/v1/proposals/{id}/status", axum::routing::patch(transition_proposal))
7553
+ .route(
7554
+ "/v1/proposals/{id}/validate",
7555
+ axum::routing::post(validate_proposal),
7556
+ )
7557
+ .route(
7558
+ "/v1/proposals/{id}/preview",
7559
+ axum::routing::post(preview_proposal),
7560
+ )
7561
+ .route(
7562
+ "/v1/proposals/{id}/approve",
7563
+ axum::routing::post(approve_proposal),
7564
+ )
7565
+ .route(
7566
+ "/v1/proposals/{id}/reject",
7567
+ axum::routing::post(reject_proposal),
7568
+ )
7569
+ .route(
7570
+ "/v1/proposals/{id}/status",
7571
+ get(proposal_status).patch(transition_proposal),
7572
+ )
7573
+ .route("/v1/previews/{id}", get(get_proposal_preview))
7574
+ .route("/v1/previews/{id}/data/{collection}", get(inspect_proposal_preview_data))
7575
+ .route("/v1/previews/{id}/simulate", axum::routing::post(simulate_proposal_preview))
6266
7576
  .route("/v1/auth/session", get(auth_session))
6267
7577
  .route("/v1/auth/signout", axum::routing::post(auth_sign_out))
6268
7578
  .route("/v1/events", get(events))
6269
- .route("/v1/workflows/{name}/run", axum::routing::post(start_workflow_run))
6270
- .route("/v1/agents/{name}/run", axum::routing::post(start_agent_run))
6271
- .route("/v1/capabilities/{name}/run", axum::routing::post(execute_application_capability))
7579
+ .route(
7580
+ "/v1/workflows/{name}/run",
7581
+ axum::routing::post(start_workflow_run),
7582
+ )
7583
+ .route(
7584
+ "/v1/agents/{name}/run",
7585
+ axum::routing::post(start_agent_run),
7586
+ )
7587
+ .route(
7588
+ "/v1/capabilities/{name}/run",
7589
+ axum::routing::post(execute_application_capability),
7590
+ )
6272
7591
  .route("/v1/state/version", get(get_state_version))
6273
7592
  .route("/v1/query", axum::routing::post(execute_canonical_query))
6274
7593
  .route(
@@ -6733,7 +8052,16 @@ async fn authenticate(
6733
8052
  (Some(principal), _, _) => principal,
6734
8053
  (None, Some(session), _) => Principal {
6735
8054
  key_id: session.subject_id,
6736
- scopes: vec!["application:read".into(), "application:revision:read".into(), "state:read".into(), "state:write".into(), "events:read".into(), "workflows:run".into(), "agents:run".into(), "capabilities:run".into()],
8055
+ scopes: vec![
8056
+ "application:read".into(),
8057
+ "application:revision:read".into(),
8058
+ "state:read".into(),
8059
+ "state:write".into(),
8060
+ "events:read".into(),
8061
+ "workflows:run".into(),
8062
+ "agents:run".into(),
8063
+ "capabilities:run".into(),
8064
+ ],
6737
8065
  subject_type: "human".into(),
6738
8066
  identity_id: Some(session.identity_id),
6739
8067
  session_id: Some(session.session_id),
@@ -6964,9 +8292,7 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
6964
8292
  })
6965
8293
  }
6966
8294
 
6967
- async fn managed_diagnostics(
6968
- State(state): State<AppState>,
6969
- ) -> Json<ManagedDiagnosticsResponse> {
8295
+ async fn managed_diagnostics(State(state): State<AppState>) -> Json<ManagedDiagnosticsResponse> {
6970
8296
  let recovery = match state.cluster.proposal().map(|value| value.phase) {
6971
8297
  Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
6972
8298
  Some(ProposalPhase::Aborted) => "attention-required",
@@ -6983,8 +8309,7 @@ async fn managed_diagnostics(
6983
8309
  storage_format: 1,
6984
8310
  promoted_revision: std::env::var("FELTDB_PROMOTED_REVISION")
6985
8311
  .unwrap_or_else(|_| "unknown".into()),
6986
- environment: std::env::var("FELTDB_ENVIRONMENT")
6987
- .unwrap_or_else(|_| "development".into()),
8312
+ environment: std::env::var("FELTDB_ENVIRONMENT").unwrap_or_else(|_| "development".into()),
6988
8313
  recovery: recovery.into(),
6989
8314
  uptime: format!("{uptime_seconds}s"),
6990
8315
  })
@@ -8733,7 +10058,12 @@ async fn execute_capability(
8733
10058
  Path(name): Path<String>,
8734
10059
  Json(input): Json<Value>,
8735
10060
  ) -> Result<Json<Value>, ApiError> {
8736
- if !principal.permits("capabilities:run") { return Err(ApiError(StatusCode::FORBIDDEN, "capability execution is not permitted".into())); }
10061
+ if !principal.permits("capabilities:run") {
10062
+ return Err(ApiError(
10063
+ StatusCode::FORBIDDEN,
10064
+ "capability execution is not permitted".into(),
10065
+ ));
10066
+ }
8737
10067
  validate_segment(&name)?;
8738
10068
  Ok(Json(execute_named_capability(&state, &name, &input)?))
8739
10069
  }
@@ -8745,15 +10075,42 @@ async fn execute_application_capability(
8745
10075
  Query(input): Query<ApplicationDiscoveryRequest>,
8746
10076
  Json(payload): Json<Value>,
8747
10077
  ) -> Result<Json<Value>, ApiError> {
8748
- let tenant = application_scope(&state, &principal.key_id, &input.application_id, "capabilities:run")?;
8749
- let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned()
8750
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
8751
- let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
8752
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
8753
- let capability = revision.manifest.capabilities.iter().find(|value| value.name == name)
8754
- .ok_or(ApiError(StatusCode::NOT_FOUND, "capability not found".into()))?;
10078
+ let tenant = application_scope(
10079
+ &state,
10080
+ &principal.key_id,
10081
+ &input.application_id,
10082
+ "capabilities:run",
10083
+ )?;
10084
+ let revision_id = state
10085
+ .applications
10086
+ .pointers(&input.application_id)
10087
+ .get(&input.environment)
10088
+ .cloned()
10089
+ .ok_or(ApiError(
10090
+ StatusCode::NOT_FOUND,
10091
+ "application environment has no active contract".into(),
10092
+ ))?;
10093
+ let revision = state
10094
+ .applications
10095
+ .revision(&tenant, &input.application_id, &revision_id)
10096
+ .ok_or(ApiError(
10097
+ StatusCode::NOT_FOUND,
10098
+ "application revision not found".into(),
10099
+ ))?;
10100
+ let capability = revision
10101
+ .manifest
10102
+ .capabilities
10103
+ .iter()
10104
+ .find(|value| value.name == name)
10105
+ .ok_or(ApiError(
10106
+ StatusCode::NOT_FOUND,
10107
+ "capability not found".into(),
10108
+ ))?;
8755
10109
  if capability.visibility != "public" {
8756
- return Err(ApiError(StatusCode::FORBIDDEN, "capability is not externally invocable".into()));
10110
+ return Err(ApiError(
10111
+ StatusCode::FORBIDDEN,
10112
+ "capability is not externally invocable".into(),
10113
+ ));
8757
10114
  }
8758
10115
  Ok(Json(execute_named_capability(&state, &name, &payload)?))
8759
10116
  }
@@ -8947,7 +10304,12 @@ async fn start_workflow_run(
8947
10304
  Path(name): Path<String>,
8948
10305
  Json(request): Json<StartRun>,
8949
10306
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
8950
- if !principal.permits("workflows:run") { return Err(ApiError(StatusCode::FORBIDDEN, "workflow execution is not permitted".into())); }
10307
+ if !principal.permits("workflows:run") {
10308
+ return Err(ApiError(
10309
+ StatusCode::FORBIDDEN,
10310
+ "workflow execution is not permitted".into(),
10311
+ ));
10312
+ }
8951
10313
  validate_segment(&name)?;
8952
10314
  let definition = required_value(&state, "_flow_workflows", &name, "workflow not found")?;
8953
10315
  let steps = definition["steps"].as_array().ok_or_else(|| {
@@ -9109,7 +10471,12 @@ async fn start_agent_run(
9109
10471
  Path(name): Path<String>,
9110
10472
  Json(request): Json<StartAgentRun>,
9111
10473
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
9112
- if !principal.permits("agents:run") { return Err(ApiError(StatusCode::FORBIDDEN, "agent execution is not permitted".into())); }
10474
+ if !principal.permits("agents:run") {
10475
+ return Err(ApiError(
10476
+ StatusCode::FORBIDDEN,
10477
+ "agent execution is not permitted".into(),
10478
+ ));
10479
+ }
9113
10480
  validate_segment(&name)?;
9114
10481
  required_value(&state, "_flow_agents", &name, "agent not found")?;
9115
10482
  if request.goal.trim().is_empty() {
@@ -9331,10 +10698,13 @@ struct BoundedQueryPage {
9331
10698
  }
9332
10699
 
9333
10700
  fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
9334
- ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({
9335
- "code": code,
9336
- "message": message.into(),
9337
- }))
10701
+ ApiError::structured(
10702
+ StatusCode::UNPROCESSABLE_ENTITY,
10703
+ json!({
10704
+ "code": code,
10705
+ "message": message.into(),
10706
+ }),
10707
+ )
9338
10708
  }
9339
10709
 
9340
10710
  fn bounded_query_hash(request: &BoundedQueryRequest) -> Result<String, ApiError> {
@@ -9357,7 +10727,10 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
9357
10727
  (Some(Value::Null), Some(Value::Null)) => std::cmp::Ordering::Equal,
9358
10728
  (Some(Value::Null), Some(_)) => std::cmp::Ordering::Less,
9359
10729
  (Some(_), Some(Value::Null)) => std::cmp::Ordering::Greater,
9360
- (Some(Value::Number(a)), Some(Value::Number(b))) => a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(std::cmp::Ordering::Equal),
10730
+ (Some(Value::Number(a)), Some(Value::Number(b))) => a
10731
+ .as_f64()
10732
+ .partial_cmp(&b.as_f64())
10733
+ .unwrap_or(std::cmp::Ordering::Equal),
9361
10734
  (Some(Value::String(a)), Some(Value::String(b))) => a.cmp(b),
9362
10735
  (Some(Value::Bool(a)), Some(Value::Bool(b))) => a.cmp(b),
9363
10736
  (Some(a), Some(b)) => a.to_string().cmp(&b.to_string()),
@@ -9366,31 +10739,60 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
9366
10739
 
9367
10740
  fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Result<bool, ApiError> {
9368
10741
  if condition.operators.len() != 1 || condition.field.trim().is_empty() {
9369
- return Err(bounded_query_error("INVALID_QUERY", "each where condition requires a field and exactly one operator"));
10742
+ return Err(bounded_query_error(
10743
+ "INVALID_QUERY",
10744
+ "each where condition requires a field and exactly one operator",
10745
+ ));
9370
10746
  }
9371
- let (operator, expected) = condition.operators.iter().next().expect("validated operator");
10747
+ let (operator, expected) = condition
10748
+ .operators
10749
+ .iter()
10750
+ .next()
10751
+ .expect("validated operator");
9372
10752
  let actual = record.get(&condition.field);
9373
- if operator == "eq" { return Ok(actual == Some(expected)); }
9374
- if operator == "neq" { return Ok(actual != Some(expected)); }
9375
- let Some(actual) = actual else { return Ok(false) };
10753
+ if operator == "eq" {
10754
+ return Ok(actual == Some(expected));
10755
+ }
10756
+ if operator == "neq" {
10757
+ return Ok(actual != Some(expected));
10758
+ }
10759
+ let Some(actual) = actual else {
10760
+ return Ok(false);
10761
+ };
9376
10762
  let comparison = query_scalar_cmp(Some(actual), Some(expected));
9377
10763
  Ok(match operator.as_str() {
9378
10764
  "lt" => comparison.is_lt(),
9379
10765
  "lte" => !comparison.is_gt(),
9380
10766
  "gt" => comparison.is_gt(),
9381
10767
  "gte" => !comparison.is_lt(),
9382
- _ => return Err(bounded_query_error("INVALID_QUERY", format!("unsupported where operator: {operator}"))),
10768
+ _ => {
10769
+ return Err(bounded_query_error(
10770
+ "INVALID_QUERY",
10771
+ format!("unsupported where operator: {operator}"),
10772
+ ))
10773
+ }
9383
10774
  })
9384
10775
  }
9385
10776
 
9386
- fn issue_bounded_cursor(state: &AppState, mut cursor: BoundedQueryCursor) -> Result<String, ApiError> {
10777
+ fn issue_bounded_cursor(
10778
+ state: &AppState,
10779
+ mut cursor: BoundedQueryCursor,
10780
+ ) -> Result<String, ApiError> {
9387
10781
  let token = uuid::Uuid::new_v4().simple().to_string();
9388
10782
  let now = unix_seconds_i64().max(0) as u64;
9389
10783
  cursor.created_at = now;
9390
- let mut cursors = state.bounded_query_cursors.lock().map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "query cursor store unavailable".into()))?;
10784
+ let mut cursors = state.bounded_query_cursors.lock().map_err(|_| {
10785
+ ApiError(
10786
+ StatusCode::INTERNAL_SERVER_ERROR,
10787
+ "query cursor store unavailable".into(),
10788
+ )
10789
+ })?;
9391
10790
  cursors.retain(|_, value| now.saturating_sub(value.created_at) <= BOUNDED_CURSOR_TTL_SECONDS);
9392
10791
  if cursors.len() >= 1024 {
9393
- return Err(ApiError(StatusCode::SERVICE_UNAVAILABLE, "query cursor capacity exhausted".into()));
10792
+ return Err(ApiError(
10793
+ StatusCode::SERVICE_UNAVAILABLE,
10794
+ "query cursor capacity exhausted".into(),
10795
+ ));
9394
10796
  }
9395
10797
  cursors.insert(token.clone(), cursor);
9396
10798
  Ok(token)
@@ -9403,32 +10805,67 @@ async fn execute_bounded_query(
9403
10805
  ) -> Result<Json<BoundedQueryPage>, ApiError> {
9404
10806
  validate_segment(&request.collection)?;
9405
10807
  if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
9406
- return Err(bounded_query_error("INVALID_QUERY", format!("limit must be between 1 and {MAX_BOUNDED_QUERY_LIMIT}")));
10808
+ return Err(bounded_query_error(
10809
+ "INVALID_QUERY",
10810
+ format!("limit must be between 1 and {MAX_BOUNDED_QUERY_LIMIT}"),
10811
+ ));
9407
10812
  }
9408
10813
  if request.order_by.is_empty() {
9409
- return Err(bounded_query_error("INVALID_QUERY", "orderBy must contain at least one field"));
10814
+ return Err(bounded_query_error(
10815
+ "INVALID_QUERY",
10816
+ "orderBy must contain at least one field",
10817
+ ));
9410
10818
  }
9411
10819
  for order in &request.order_by {
9412
10820
  if order.field.trim().is_empty() || !matches!(order.direction.as_str(), "asc" | "desc") {
9413
- return Err(bounded_query_error("INVALID_QUERY", "orderBy directions must be asc or desc"));
10821
+ return Err(bounded_query_error(
10822
+ "INVALID_QUERY",
10823
+ "orderBy directions must be asc or desc",
10824
+ ));
9414
10825
  }
9415
10826
  }
9416
10827
  for condition in &request.conditions {
9417
- if condition.operators.len() != 1 || condition.field.trim().is_empty()
9418
- || !condition.operators.keys().all(|operator| matches!(operator.as_str(), "eq" | "neq" | "lt" | "lte" | "gt" | "gte")) {
9419
- return Err(bounded_query_error("INVALID_QUERY", "each where condition requires a field and exactly one operator"));
10828
+ if condition.operators.len() != 1
10829
+ || condition.field.trim().is_empty()
10830
+ || !condition.operators.keys().all(|operator| {
10831
+ matches!(
10832
+ operator.as_str(),
10833
+ "eq" | "neq" | "lt" | "lte" | "gt" | "gte"
10834
+ )
10835
+ })
10836
+ {
10837
+ return Err(bounded_query_error(
10838
+ "INVALID_QUERY",
10839
+ "each where condition requires a field and exactly one operator",
10840
+ ));
9420
10841
  }
9421
10842
  }
9422
10843
  let hash = bounded_query_hash(&request)?;
9423
10844
  let (records, position) = if let Some(token) = &request.cursor {
9424
- let cursor = state.bounded_query_cursors.lock()
9425
- .map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "query cursor store unavailable".into()))?
9426
- .get(token).cloned()
10845
+ let cursor = state
10846
+ .bounded_query_cursors
10847
+ .lock()
10848
+ .map_err(|_| {
10849
+ ApiError(
10850
+ StatusCode::INTERNAL_SERVER_ERROR,
10851
+ "query cursor store unavailable".into(),
10852
+ )
10853
+ })?
10854
+ .get(token)
10855
+ .cloned()
9427
10856
  .ok_or_else(|| bounded_query_error("INVALID_CURSOR", "cursor is invalid or expired"))?;
9428
- if cursor.query_hash != hash || cursor.principal_key_id != principal.key_id || cursor.namespace != state.namespace.as_ref() {
9429
- return Err(bounded_query_error("INVALID_CURSOR", "cursor does not match this query, principal, or namespace"));
10857
+ if cursor.query_hash != hash
10858
+ || cursor.principal_key_id != principal.key_id
10859
+ || cursor.namespace != state.namespace.as_ref()
10860
+ {
10861
+ return Err(bounded_query_error(
10862
+ "INVALID_CURSOR",
10863
+ "cursor does not match this query, principal, or namespace",
10864
+ ));
9430
10865
  }
9431
- if (unix_seconds_i64().max(0) as u64).saturating_sub(cursor.created_at) > BOUNDED_CURSOR_TTL_SECONDS {
10866
+ if (unix_seconds_i64().max(0) as u64).saturating_sub(cursor.created_at)
10867
+ > BOUNDED_CURSOR_TTL_SECONDS
10868
+ {
9432
10869
  return Err(bounded_query_error("INVALID_CURSOR", "cursor is expired"));
9433
10870
  }
9434
10871
  (cursor.records, cursor.position)
@@ -9437,13 +10874,21 @@ async fn execute_bounded_query(
9437
10874
  for row in state.db.list_collection(&request.collection)? {
9438
10875
  let mut value = row.value;
9439
10876
  if let Some(object) = value.as_object_mut() {
9440
- let id = row.key.split_once(':').map(|(_, id)| id).unwrap_or(&row.key);
10877
+ let id = row
10878
+ .key
10879
+ .split_once(':')
10880
+ .map(|(_, id)| id)
10881
+ .unwrap_or(&row.key);
9441
10882
  // `recordId` is authority metadata for this query surface, not
9442
10883
  // caller-controlled document data. It is the final total-order
9443
10884
  // tie-breaker even when a document contains a field by that name.
9444
10885
  object.insert("recordId".into(), Value::String(id.to_string()));
9445
10886
  }
9446
- if request.conditions.iter().all(|condition| condition_matches(&value, condition).unwrap_or(false)) {
10887
+ if request
10888
+ .conditions
10889
+ .iter()
10890
+ .all(|condition| condition_matches(&value, condition).unwrap_or(false))
10891
+ {
9447
10892
  records.push(value);
9448
10893
  }
9449
10894
  }
@@ -9452,7 +10897,13 @@ async fn execute_bounded_query(
9452
10897
  records.sort_by(|left, right| {
9453
10898
  for order in &request.order_by {
9454
10899
  let comparison = query_scalar_cmp(left.get(&order.field), right.get(&order.field));
9455
- if !comparison.is_eq() { return if order.direction == "desc" { comparison.reverse() } else { comparison }; }
10900
+ if !comparison.is_eq() {
10901
+ return if order.direction == "desc" {
10902
+ comparison.reverse()
10903
+ } else {
10904
+ comparison
10905
+ };
10906
+ }
9456
10907
  }
9457
10908
  query_scalar_cmp(left.get("recordId"), right.get("recordId"))
9458
10909
  });
@@ -9461,23 +10912,37 @@ async fn execute_bounded_query(
9461
10912
  let end = position.saturating_add(request.limit).min(records.len());
9462
10913
  let page = records[position.min(records.len())..end].to_vec();
9463
10914
  let next_cursor = if end < records.len() {
9464
- Some(issue_bounded_cursor(&state, BoundedQueryCursor {
9465
- query_hash: hash,
9466
- principal_key_id: principal.key_id,
9467
- namespace: state.namespace.to_string(),
9468
- records: records.clone(),
9469
- position: end,
9470
- created_at: 0,
9471
- })?)
9472
- } else { None };
9473
- Ok(Json(BoundedQueryPage { records: page, exhausted: next_cursor.is_none(), next_cursor }))
10915
+ Some(issue_bounded_cursor(
10916
+ &state,
10917
+ BoundedQueryCursor {
10918
+ query_hash: hash,
10919
+ principal_key_id: principal.key_id,
10920
+ namespace: state.namespace.to_string(),
10921
+ records: records.clone(),
10922
+ position: end,
10923
+ created_at: 0,
10924
+ },
10925
+ )?)
10926
+ } else {
10927
+ None
10928
+ };
10929
+ Ok(Json(BoundedQueryPage {
10930
+ records: page,
10931
+ exhausted: next_cursor.is_none(),
10932
+ next_cursor,
10933
+ }))
9474
10934
  }
9475
10935
 
9476
10936
  async fn list_records(
9477
10937
  State(state): State<AppState>,
9478
10938
  Path(collection): Path<String>,
9479
10939
  ) -> Result<Json<Vec<RecordResponse>>, ApiError> {
9480
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
10940
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
10941
+ return Err(ApiError(
10942
+ StatusCode::FORBIDDEN,
10943
+ "system collection requires proposal service".into(),
10944
+ ));
10945
+ }
9481
10946
  validate_segment(&collection)?;
9482
10947
  let rows = state.db.list_collection(&collection)?;
9483
10948
  Ok(Json(rows.into_iter().map(record_response).collect()))
@@ -9487,7 +10952,12 @@ async fn get_record(
9487
10952
  State(state): State<AppState>,
9488
10953
  Path((collection, id)): Path<(String, String)>,
9489
10954
  ) -> Result<Json<RecordResponse>, ApiError> {
9490
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
10955
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
10956
+ return Err(ApiError(
10957
+ StatusCode::FORBIDDEN,
10958
+ "system collection requires proposal service".into(),
10959
+ ));
10960
+ }
9491
10961
  let key = record_key(&collection, &id)?;
9492
10962
  let value = state
9493
10963
  .db
@@ -9708,7 +11178,11 @@ async fn commit_transaction(
9708
11178
  })?;
9709
11179
 
9710
11180
  Ok((
9711
- if commit.duplicate { StatusCode::OK } else { StatusCode::CREATED },
11181
+ if commit.duplicate {
11182
+ StatusCode::OK
11183
+ } else {
11184
+ StatusCode::CREATED
11185
+ },
9712
11186
  Json(AtomicTxResponse {
9713
11187
  transaction_id: commit.transaction_id,
9714
11188
  duplicate: commit.duplicate,
@@ -9724,7 +11198,12 @@ async fn create_record(
9724
11198
  Path(collection): Path<String>,
9725
11199
  Json(mut record): Json<CreateRecord>,
9726
11200
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
9727
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
11201
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
11202
+ return Err(ApiError(
11203
+ StatusCode::FORBIDDEN,
11204
+ "system collection requires proposal service".into(),
11205
+ ));
11206
+ }
9728
11207
  validate_segment(&collection)?;
9729
11208
  let id = record
9730
11209
  .id
@@ -9758,7 +11237,12 @@ async fn update_record(
9758
11237
  Path((collection, id)): Path<(String, String)>,
9759
11238
  Json(changes): Json<Map<String, Value>>,
9760
11239
  ) -> Result<Json<RecordResponse>, ApiError> {
9761
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
11240
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
11241
+ return Err(ApiError(
11242
+ StatusCode::FORBIDDEN,
11243
+ "system collection requires proposal service".into(),
11244
+ ));
11245
+ }
9762
11246
  let key = record_key(&collection, &id)?;
9763
11247
  let current = state
9764
11248
  .db
@@ -9874,7 +11358,12 @@ async fn delete_record(
9874
11358
  State(state): State<AppState>,
9875
11359
  Path((collection, id)): Path<(String, String)>,
9876
11360
  ) -> Result<StatusCode, ApiError> {
9877
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
11361
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
11362
+ return Err(ApiError(
11363
+ StatusCode::FORBIDDEN,
11364
+ "system collection requires proposal service".into(),
11365
+ ));
11366
+ }
9878
11367
  let key = record_key(&collection, &id)?;
9879
11368
  if state.db.get_value(&key)?.is_none() {
9880
11369
  return Err(ApiError(
@@ -9891,8 +11380,15 @@ async fn events(
9891
11380
  Extension(principal): Extension<Principal>,
9892
11381
  Query(scope): Query<EventScopeRequest>,
9893
11382
  ) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
9894
- if !principal.permits("events:read") { return Err(ApiError(StatusCode::FORBIDDEN, "event access is not permitted".into())); }
9895
- if let Some(application_id) = scope.application_id { application_scope(&state, &principal.key_id, &application_id, "events:read")?; }
11383
+ if !principal.permits("events:read") {
11384
+ return Err(ApiError(
11385
+ StatusCode::FORBIDDEN,
11386
+ "event access is not permitted".into(),
11387
+ ));
11388
+ }
11389
+ if let Some(application_id) = scope.application_id {
11390
+ application_scope(&state, &principal.key_id, &application_id, "events:read")?;
11391
+ }
9896
11392
  let mut receiver = state.db.subscribe_changes();
9897
11393
  let namespace = state.namespace.clone();
9898
11394
  let actor = principal.key_id;
@@ -9982,7 +11478,10 @@ mod authority_gate_tests {
9982
11478
  use super::{api_error_body, authorize_transaction_collections, state_authorization};
9983
11479
  use axum::http::StatusCode;
9984
11480
  use feltdb::{
9985
- application::{manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition, RevisionStatus},
11481
+ application::{
11482
+ manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition,
11483
+ RevisionStatus,
11484
+ },
9986
11485
  application_runtime::{resolve_runtime, ApplicationRuntimeContract, RuntimeInventory},
9987
11486
  };
9988
11487
  use feltdb_server::auth::Principal;
@@ -10028,9 +11527,16 @@ mod authority_gate_tests {
10028
11527
  #[test]
10029
11528
  fn no_matching_policy_denies() {
10030
11529
  let contract = contract(vec![]);
10031
- assert!(state_authorization(&principal(), &contract, "secrets", "read").capabilities.is_empty());
11530
+ assert!(
11531
+ state_authorization(&principal(), &contract, "secrets", "read")
11532
+ .capabilities
11533
+ .is_empty()
11534
+ );
10032
11535
  let (allowed, _) = authorize_transaction_collections(
10033
- &principal(), &contract, "tenant", &["secrets".into()],
11536
+ &principal(),
11537
+ &contract,
11538
+ "tenant",
11539
+ &["secrets".into()],
10034
11540
  );
10035
11541
  assert!(!allowed);
10036
11542
  }
@@ -10039,7 +11545,10 @@ mod authority_gate_tests {
10039
11545
  fn terminal_wildcard_policy_allows_matching_collection() {
10040
11546
  let contract = contract(vec![allow("buzz_*")]);
10041
11547
  let (allowed, _) = authorize_transaction_collections(
10042
- &principal(), &contract, "tenant", &["buzz_rooms".into()],
11548
+ &principal(),
11549
+ &contract,
11550
+ "tenant",
11551
+ &["buzz_rooms".into()],
10043
11552
  );
10044
11553
  assert!(allowed);
10045
11554
  }
@@ -10048,10 +11557,16 @@ mod authority_gate_tests {
10048
11557
  fn multi_collection_transaction_authorizes_every_operation() {
10049
11558
  let contract = contract(vec![allow("alpha"), allow("beta")]);
10050
11559
  let (all_allowed, _) = authorize_transaction_collections(
10051
- &principal(), &contract, "tenant", &["alpha".into(), "beta".into()],
11560
+ &principal(),
11561
+ &contract,
11562
+ "tenant",
11563
+ &["alpha".into(), "beta".into()],
10052
11564
  );
10053
11565
  let (mixed_allowed, _) = authorize_transaction_collections(
10054
- &principal(), &contract, "tenant", &["alpha".into(), "secrets".into()],
11566
+ &principal(),
11567
+ &contract,
11568
+ "tenant",
11569
+ &["alpha".into(), "secrets".into()],
10055
11570
  );
10056
11571
  assert!(all_allowed);
10057
11572
  assert!(!mixed_allowed);
@@ -10061,7 +11576,10 @@ mod authority_gate_tests {
10061
11576
  fn cross_tenant_transaction_denies() {
10062
11577
  let contract = contract(vec![allow("alpha")]);
10063
11578
  let (allowed, _) = authorize_transaction_collections(
10064
- &principal(), &contract, "other-tenant", &["alpha".into()],
11579
+ &principal(),
11580
+ &contract,
11581
+ "other-tenant",
11582
+ &["alpha".into()],
10065
11583
  );
10066
11584
  assert!(!allowed);
10067
11585
  }