@feltdb/core 0.7.3 → 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 (147) hide show
  1. package/README.md +25 -0
  2. package/dist/application-development.d.ts +78 -0
  3. package/dist/application-development.d.ts.map +1 -0
  4. package/dist/application-development.js +153 -0
  5. package/dist/application-manifest.d.ts +6 -1
  6. package/dist/application-manifest.d.ts.map +1 -1
  7. package/dist/application-manifest.js +10 -3
  8. package/dist/canonical-application.d.ts +27 -0
  9. package/dist/canonical-application.d.ts.map +1 -0
  10. package/dist/canonical-application.js +46 -0
  11. package/dist/cli/ai-model-config.js +97 -0
  12. package/dist/cli/cli.js +1 -1
  13. package/dist/cli/commands.js +664 -120
  14. package/dist/cli/index.js +1 -1
  15. package/dist/cli/managed-environment.js +6 -0
  16. package/dist/cli/managed-project.js +6 -0
  17. package/dist/cli/proposal-client.js +63 -0
  18. package/dist/cli/publish-engine.js +165 -0
  19. package/dist/cli/source-sync.js +183 -0
  20. package/dist/create/cli.js +11 -2
  21. package/dist/create/create.js +38 -16
  22. package/dist/create/managed-account.js +82 -11
  23. package/dist/create/package-versions.js +1 -1
  24. package/dist/create/server-source/crates/feltdb/src/application.rs +41 -1
  25. package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +84 -6
  26. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +126 -38
  27. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +72 -34
  28. package/dist/create/server-source/crates/feltdb/src/lib.rs +20 -2
  29. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +12 -24
  30. package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +11 -3
  31. package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +0 -1
  32. package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +23 -22
  33. package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +5 -15
  34. package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +13 -28
  35. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +111 -3
  36. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +28 -7
  37. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +5 -5
  38. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1960 -143
  39. package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +10 -30
  40. package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +12 -4
  41. package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +8 -6
  42. package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +10 -14
  43. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +195 -13
  44. package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +9 -21
  45. package/dist/create/server-source/crates/feltdb-server/src/transaction_idempotency.rs +5 -3
  46. package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +9 -8
  47. package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +1 -4
  48. package/dist/create/template/default-project/README.md +57 -0
  49. package/dist/create/template/default-project/agents/activity-assistant.ts +18 -0
  50. package/dist/create/template/default-project/agents/project-assistant.ts +19 -0
  51. package/dist/create/template/default-project/capabilities/activity-summary.ts +14 -0
  52. package/dist/create/template/default-project/capabilities/project-search.ts +16 -0
  53. package/dist/create/template/default-project/capabilities/project-summary.ts +15 -0
  54. package/dist/create/template/default-project/feltdb.flow +174 -0
  55. package/dist/create/template/default-project/src/App.tsx +20 -0
  56. package/dist/create/template/default-project/src/context/AuthContext.tsx +27 -0
  57. package/dist/create/template/default-project/src/feltdb.ts +87 -0
  58. package/dist/create/template/default-project/src/index.tsx +14 -0
  59. package/dist/create/template/default-project/src/pages/Activity.tsx +7 -0
  60. package/dist/create/template/default-project/src/pages/Agents.tsx +15 -0
  61. package/dist/create/template/default-project/src/pages/Dashboard.tsx +17 -0
  62. package/dist/create/template/default-project/src/pages/Invitations.tsx +11 -0
  63. package/dist/create/template/default-project/src/pages/Projects.tsx +11 -0
  64. package/dist/create/template/default-project/src/pages/SignIn.tsx +8 -0
  65. package/dist/create/template/default-project/src/pages/SignUp.tsx +8 -0
  66. package/dist/create/template/default-project/src/styles-application.css +21 -0
  67. package/dist/create/template/default-project/src/styles.css +1 -0
  68. package/dist/create/template/default-project/workflows/agent-assisted-summary.ts +1 -0
  69. package/dist/create/template/default-project/workflows/invitation.ts +30 -0
  70. package/dist/create/template/default-project/workflows/project-created.ts +2 -0
  71. package/dist/db.d.ts +49 -1
  72. package/dist/db.d.ts.map +1 -1
  73. package/dist/db.js +77 -0
  74. package/dist/error-codes.d.ts +20 -1
  75. package/dist/error-codes.d.ts.map +1 -1
  76. package/dist/error-codes.js +25 -0
  77. package/dist/flowspec.d.ts +1 -0
  78. package/dist/flowspec.d.ts.map +1 -1
  79. package/dist/flowspec.js +66 -11
  80. package/dist/http-client.d.ts +135 -14
  81. package/dist/http-client.d.ts.map +1 -1
  82. package/dist/http-client.js +79 -31
  83. package/dist/http-db.d.ts +15 -2
  84. package/dist/http-db.d.ts.map +1 -1
  85. package/dist/http-db.js +113 -5
  86. package/dist/index-core.d.ts +5 -0
  87. package/dist/index-core.d.ts.map +1 -1
  88. package/dist/index-core.js +5 -0
  89. package/dist/module-intent.d.ts +37 -0
  90. package/dist/module-intent.d.ts.map +1 -0
  91. package/dist/module-intent.js +81 -0
  92. package/dist/module.d.ts +76 -0
  93. package/dist/module.d.ts.map +1 -0
  94. package/dist/module.js +61 -0
  95. package/dist/proposal.d.ts +161 -0
  96. package/dist/proposal.d.ts.map +1 -0
  97. package/dist/proposal.js +232 -0
  98. package/dist/studio/app.d.ts +5 -1
  99. package/dist/studio/app.d.ts.map +1 -1
  100. package/dist/studio/components/ApplicationDesigner.d.ts +2 -1
  101. package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
  102. package/dist/studio/components/AskFeltDB.d.ts +21 -0
  103. package/dist/studio/components/AskFeltDB.d.ts.map +1 -0
  104. package/dist/studio/components/ContractInspector.d.ts +7 -0
  105. package/dist/studio/components/ContractInspector.d.ts.map +1 -0
  106. package/dist/studio/components/ManagedInstancePanel.d.ts.map +1 -1
  107. package/dist/studio/components/ManagedInstancePanel.js +1 -5
  108. package/dist/studio/components/ModuleExplorer.d.ts +6 -0
  109. package/dist/studio/components/ModuleExplorer.d.ts.map +1 -0
  110. package/dist/studio/components/ProposalBrowser.d.ts +12 -0
  111. package/dist/studio/components/ProposalBrowser.d.ts.map +1 -0
  112. package/dist/studio/components/StateExplorer.d.ts +6 -7
  113. package/dist/studio/components/StateExplorer.d.ts.map +1 -1
  114. package/dist/studio/components/index.d.ts +4 -0
  115. package/dist/studio/components/index.d.ts.map +1 -1
  116. package/dist/studio/components/index.js +4 -4
  117. package/dist/studio/components-CNSNrmA9.js +2787 -0
  118. package/dist/studio/index.js +214 -157
  119. package/dist/studio/studio.css +1 -1
  120. package/dist/studio/utils/index.d.ts +2 -0
  121. package/dist/studio/utils/index.d.ts.map +1 -1
  122. package/dist/studio/utils/index.js +14 -12
  123. package/dist/studio/utils/managed-instance.d.ts +0 -4
  124. package/dist/studio/utils/managed-instance.d.ts.map +1 -1
  125. package/dist/studio/utils/managed-instance.js +0 -4
  126. package/dist/studio/utils/proposal-api.d.ts +53 -0
  127. package/dist/studio/utils/proposal-api.d.ts.map +1 -0
  128. package/dist/studio/utils/proposal-api.js +91 -0
  129. package/dist/studio/utils/service-api.d.ts +81 -0
  130. package/dist/studio/utils/service-api.d.ts.map +1 -0
  131. package/dist/studio/utils/service-api.js +51 -0
  132. package/dist/studio-app/assets/dist-CCOk39Uc.js +1 -0
  133. package/dist/studio-app/assets/{feltdb_wasm-DVKsw75S.js → feltdb_wasm-CSB6KVgw.js} +1 -1
  134. package/dist/studio-app/assets/feltdb_wasm_bg-Dd1fnO9U.wasm +0 -0
  135. package/dist/studio-app/assets/index-Bncji3aN.js +43 -0
  136. package/dist/studio-app/assets/{index-C1GWyazR.css → index-yazaWMUN.css} +1 -1
  137. package/dist/studio-app/assets/lib-B2_7fcn7.js +69 -0
  138. package/dist/studio-app/assets/worker-CARZ5g7K.js +69 -0
  139. package/dist/studio-app/index.html +2 -2
  140. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  141. package/dist/workspace/local-development-authority.d.ts +3 -0
  142. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  143. package/dist/workspace/local-development-authority.js +124 -2
  144. package/package.json +1 -1
  145. package/dist/studio/components-Dxuhrv8_.js +0 -1832
  146. package/dist/studio-app/assets/feltdb_wasm_bg-DNyNf0yy.wasm +0 -0
  147. package/dist/studio-app/assets/index-B5tnmvSD.js +0 -28
@@ -30,7 +30,8 @@ use axum::{
30
30
  use base64::Engine;
31
31
  use feltdb::{
32
32
  application::{
33
- ActionOperation, ApplicationManifest, ApplicationStore, PreviewDataMode, PreviewPolicy,
33
+ diff_manifests, manifest_hash, validate_manifest, ActionOperation, ApplicationManifest,
34
+ ApplicationRevision, ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
34
35
  },
35
36
  application_runtime::{
36
37
  resolve_runtime, runtime_readiness, snapshot_state_namespace, validate_runtime,
@@ -41,12 +42,15 @@ use feltdb::{
41
42
  Grant, GrantSigner, GrantStore, Subject as GrantSubject,
42
43
  },
43
44
  cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
44
- AtomicMutation,
45
- policy_evaluation::{Actor, PolicyContext, PolicyEvaluator, PolicySubject},
45
+ policy_evaluation::{
46
+ Actor, AuthorizationState, PolicyContext, PolicyEvaluator, PolicySubject,
47
+ RecordAuthorizationContext,
48
+ },
46
49
  state_contract::{
47
50
  begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
48
- execute_transaction_with_collection_policies, schema_from_revision, validate_schema, AuthorizationContext, CanonicalQuery, QueryFilter,
49
- StateSchema, TransactionOperation, TransactionOperationKind, TransactionRequest,
51
+ execute_transaction_with_collection_policies, schema_from_revision, validate_schema,
52
+ AuthorizationContext, CanonicalQuery, QueryFilter, StateSchema, TransactionOperation,
53
+ TransactionOperationKind, TransactionRequest,
50
54
  },
51
55
  sync_contract::{
52
56
  DeviceIdentity, ReconciliationOutcome, SyncAck, SyncBatch, SyncConflict, SyncCursor,
@@ -57,7 +61,8 @@ use feltdb::{
57
61
  WorkerRegistration,
58
62
  },
59
63
  workload::{CreateWorkload, WorkloadStore},
60
- DatabaseSnapshot, FeltDb, JsonCasResult, Operation, PeerAdvertisement, PeerId, StoredRow, FlowError, RecordPrecondition,
64
+ AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
65
+ PeerAdvertisement, PeerId, RecordPrecondition, StoredRow,
61
66
  };
62
67
  use feltdb_server::{
63
68
  app_state::{AppState, BoundedQueryCursor},
@@ -109,7 +114,10 @@ impl ApiError {
109
114
  /// `api_error_body` already parses a message that happens to be JSON, so
110
115
  /// this is the existing mechanism named rather than a second one.
111
116
  fn structured(status: StatusCode, body: Value) -> Self {
112
- 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
+ )
113
121
  }
114
122
  }
115
123
 
@@ -121,10 +129,44 @@ impl IntoResponse for ApiError {
121
129
  }
122
130
 
123
131
  fn api_error_body(status: StatusCode, message: String) -> Value {
124
- if status == StatusCode::FORBIDDEN {
125
- return json!({ "error": "AUTHORIZATION_DENIED" });
126
- }
127
- serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "error": message }))
132
+ let request_id = format!("req_{}", uuid::Uuid::new_v4().simple());
133
+ let code = match status {
134
+ StatusCode::UNAUTHORIZED => "AUTHENTICATION_REQUIRED",
135
+ StatusCode::FORBIDDEN => "FORBIDDEN",
136
+ StatusCode::NOT_FOUND => "NOT_FOUND",
137
+ StatusCode::CONFLICT => "CONFLICT",
138
+ StatusCode::TOO_MANY_REQUESTS => "QUERY_LIMIT_EXCEEDED",
139
+ StatusCode::SERVICE_UNAVAILABLE => "SERVICE_UNAVAILABLE",
140
+ StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => "VALIDATION_ERROR",
141
+ _ if status.is_server_error() => "EXECUTION_FAILED",
142
+ _ => "REQUEST_FAILED",
143
+ };
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();
159
+ object.insert("error".into(), json!(resolved_code));
160
+ object.insert("code".into(), json!(resolved_code));
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
+ });
168
+ object.insert("request_id".into(), json!(request_id));
169
+ body
128
170
  }
129
171
 
130
172
  impl From<feltdb::FlowError> for ApiError {
@@ -390,6 +432,96 @@ struct RuntimeEnvironmentRequest {
390
432
  environment: String,
391
433
  }
392
434
  #[derive(Deserialize)]
435
+ struct ApplicationDiscoveryRequest {
436
+ application_id: String,
437
+ #[serde(default = "production_environment")]
438
+ environment: String,
439
+ }
440
+ #[derive(Deserialize)]
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
+ }
483
+ #[derive(Deserialize)]
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
+ }
494
+ #[derive(Deserialize)]
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
+ }
504
+ #[derive(Deserialize)]
505
+ struct PreviewSimulationRequest {
506
+ event: String,
507
+ }
508
+ #[derive(Deserialize, Default)]
509
+ struct EventScopeRequest {
510
+ application_id: Option<String>,
511
+ }
512
+ #[derive(Deserialize)]
513
+ struct SignUpRequest {
514
+ application_id: String,
515
+ email: String,
516
+ password: String,
517
+ display_name: Option<String>,
518
+ }
519
+ #[derive(Deserialize)]
520
+ struct SignInRequest {
521
+ email: String,
522
+ password: String,
523
+ }
524
+ #[derive(Deserialize)]
393
525
  struct StateContractTarget {
394
526
  application_id: String,
395
527
  revision_id: String,
@@ -2236,7 +2368,10 @@ async fn delete_certification_fixture(
2236
2368
  || ring::constant_time::verify_slices_are_equal(expected.as_bytes(), provided.as_bytes())
2237
2369
  .is_err()
2238
2370
  {
2239
- return Err(ApiError(StatusCode::FORBIDDEN, "certification recovery denied".into()));
2371
+ return Err(ApiError(
2372
+ StatusCode::FORBIDDEN,
2373
+ "certification recovery denied".into(),
2374
+ ));
2240
2375
  }
2241
2376
  let tenant_id = state
2242
2377
  .tenancy
@@ -3864,9 +3999,8 @@ async fn commit_application_draft(
3864
3999
  .commit(&tenant, &application_id, &draft_id, &principal.key_id)
3865
4000
  .map_err(manifest_error)?;
3866
4001
  let encoded_revision = percent_encode_path_segment(&revision.revision_id);
3867
- let mut response = serde_json::to_value(revision).map_err(|error| {
3868
- ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
3869
- })?;
4002
+ let mut response = serde_json::to_value(revision)
4003
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
3870
4004
  response
3871
4005
  .as_object_mut()
3872
4006
  .expect("application revisions serialize as objects")
@@ -4346,62 +4480,1403 @@ fn runtime_inventory(
4346
4480
  .iter()
4347
4481
  .filter(|binding| binding.provider.is_none())
4348
4482
  {
4349
- capabilities.insert(binding.name.clone());
4483
+ capabilities.insert(binding.name.clone());
4484
+ }
4485
+ Ok(RuntimeInventory {
4486
+ capabilities,
4487
+ connections: connection_ids,
4488
+ enforce_availability: true,
4489
+ })
4490
+ }
4491
+ fn revision_contract(
4492
+ state: &AppState,
4493
+ actor: &str,
4494
+ application_id: &str,
4495
+ revision_id: &str,
4496
+ environment: &str,
4497
+ ) -> Result<ApplicationRuntimeContract, ApiError> {
4498
+ let tenant = application_scope(state, actor, application_id, "application:revision:read")?;
4499
+ let revision = state
4500
+ .applications
4501
+ .revision(&tenant, application_id, revision_id)
4502
+ .ok_or(ApiError(StatusCode::NOT_FOUND, "revision not found".into()))?;
4503
+ let inventory = runtime_inventory(state, actor, application_id, &revision.manifest)?;
4504
+ resolve_runtime(&revision, environment, &inventory).map_err(|report| {
4505
+ ApiError(
4506
+ StatusCode::CONFLICT,
4507
+ serde_json::to_string(&report).unwrap_or_else(|_| "runtime validation failed".into()),
4508
+ )
4509
+ })
4510
+ }
4511
+ async fn get_active_application_runtime(
4512
+ State(state): State<AppState>,
4513
+ Extension(principal): Extension<Principal>,
4514
+ Path(application_id): Path<String>,
4515
+ Query(input): Query<RuntimeEnvironmentRequest>,
4516
+ ) -> Result<Json<Value>, ApiError> {
4517
+ let app = state
4518
+ .tenancy
4519
+ .application_for(&principal.key_id, &application_id)
4520
+ .ok_or(ApiError(
4521
+ StatusCode::NOT_FOUND,
4522
+ "application not found".into(),
4523
+ ))?;
4524
+ let pointers = state.applications.pointers(&application_id);
4525
+ let revision = pointers.get(&input.environment).ok_or(ApiError(
4526
+ StatusCode::CONFLICT,
4527
+ "application has no revision promoted to this environment".into(),
4528
+ ))?;
4529
+ let contract = revision_contract(
4530
+ &state,
4531
+ &principal.key_id,
4532
+ &application_id,
4533
+ revision,
4534
+ &input.environment,
4535
+ )?;
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))
5750
+ }
5751
+
5752
+ async fn approve_proposal(
5753
+ state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
5754
+ path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
5755
+ ) -> Result<Json<Value>, ApiError> {
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
5760
+ }
5761
+
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
5770
+ }
5771
+ fn auth_response(
5772
+ identity: feltdb_server::identity::Identity,
5773
+ session: feltdb_server::identity::IdentitySession,
5774
+ token: String,
5775
+ ) -> Json<Value> {
5776
+ Json(json!({
5777
+ "access_token": token,
5778
+ "token_type": "Bearer",
5779
+ "expires_at": session.expires_at,
5780
+ "actor": { "user_id": identity.subject_id, "identity_id": identity.identity_id, "email": identity.email, "display_name": identity.display_name },
5781
+ "session": { "session_id": session.session_id, "issued_at": session.issued_at, "expires_at": session.expires_at }
5782
+ }))
5783
+ }
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
+ ));
4350
5797
  }
4351
- Ok(RuntimeInventory {
4352
- capabilities,
4353
- connections: connection_ids,
4354
- enforce_availability: true,
4355
- })
4356
- }
4357
- fn revision_contract(
4358
- state: &AppState,
4359
- actor: &str,
4360
- application_id: &str,
4361
- revision_id: &str,
4362
- environment: &str,
4363
- ) -> Result<ApplicationRuntimeContract, ApiError> {
4364
- let tenant = application_scope(state, actor, application_id, "application:revision:read")?;
4365
- let revision = state
4366
- .applications
4367
- .revision(&tenant, application_id, revision_id)
4368
- .ok_or(ApiError(StatusCode::NOT_FOUND, "revision not found".into()))?;
4369
- let inventory = runtime_inventory(state, actor, application_id, &revision.manifest)?;
4370
- resolve_runtime(&revision, environment, &inventory).map_err(|report| {
4371
- ApiError(
4372
- StatusCode::CONFLICT,
4373
- serde_json::to_string(&report).unwrap_or_else(|_| "runtime validation failed".into()),
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),
4374
5816
  )
4375
- })
5817
+ .map_err(control_error)?;
5818
+ state
5819
+ .tenancy
5820
+ .register_application_actor(&identity.subject_id, &input.application_id)
5821
+ .map_err(control_error)?;
5822
+ Ok((StatusCode::CREATED, auth_response(identity, session, token)))
4376
5823
  }
4377
- async fn get_active_application_runtime(
5824
+ async fn auth_sign_in(
4378
5825
  State(state): State<AppState>,
4379
- Extension(principal): Extension<Principal>,
4380
- Path(application_id): Path<String>,
4381
- Query(input): Query<RuntimeEnvironmentRequest>,
5826
+ Json(input): Json<SignInRequest>,
4382
5827
  ) -> Result<Json<Value>, ApiError> {
4383
- let app = state
4384
- .tenancy
4385
- .application_for(&principal.key_id, &application_id)
4386
- .ok_or(ApiError(
4387
- StatusCode::NOT_FOUND,
4388
- "application not found".into(),
4389
- ))?;
4390
- let pointers = state.applications.pointers(&application_id);
4391
- let revision = pointers.get(&input.environment).ok_or(ApiError(
4392
- StatusCode::CONFLICT,
4393
- "application has no revision promoted to this environment".into(),
5828
+ let (identity, session, token) = state
5829
+ .identities
5830
+ .sign_in(&input.email, &input.password)
5831
+ .map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
5832
+ Ok(auth_response(identity, session, token))
5833
+ }
5834
+ async fn auth_session(Extension(principal): Extension<Principal>) -> Json<Value> {
5835
+ Json(
5836
+ json!({ "actor": { "user_id": principal.key_id, "identity_id": principal.identity_id }, "session_id": principal.session_id }),
5837
+ )
5838
+ }
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(),
4394
5846
  ))?;
4395
- let contract = revision_contract(
4396
- &state,
4397
- &principal.key_id,
4398
- &application_id,
4399
- revision,
4400
- &input.environment,
4401
- )?;
4402
- Ok(Json(
4403
- json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
4404
- ))
5847
+ state
5848
+ .identities
5849
+ .revoke_session(&principal.key_id, &session, "user_logout")
5850
+ .map_err(control_error)?;
5851
+ Ok(StatusCode::NO_CONTENT)
5852
+ }
5853
+ async fn application_openapi() -> Json<Value> {
5854
+ Json(json!({
5855
+ "openapi": "3.1.0",
5856
+ "info": { "title": "FeltDB Application Service", "version": "1.0.0" },
5857
+ "servers": [{ "url": "/v1" }],
5858
+ "components": {
5859
+ "securitySchemes": { "bearer": { "type": "http", "scheme": "bearer" } },
5860
+ "schemas": {
5861
+ "Error": { "type": "object", "required": ["code", "message", "request_id"], "properties": {
5862
+ "code": { "type": "string" }, "message": { "type": "string" }, "request_id": { "type": "string" }
5863
+ }},
5864
+ "ApplicationEvent": { "type": "object", "required": ["event_id", "type", "resource", "resource_id", "actor", "timestamp", "payload"] }
5865
+ }
5866
+ },
5867
+ "security": [{ "bearer": [] }],
5868
+ "paths": {
5869
+ "/health": { "get": { "security": [], "responses": { "200": { "description": "Runtime health" } } } },
5870
+ "/application": { "get": { "responses": { "200": { "description": "Active application contract identity" } } } },
5871
+ "/schema": { "get": { "responses": { "200": { "description": "Active application schema" } } } },
5872
+ "/query": { "post": { "responses": { "200": { "description": "Bounded authorized query" } } } },
5873
+ "/transactions": { "post": { "responses": { "200": { "description": "Atomic idempotent mutation" } } } },
5874
+ "/events": { "get": { "responses": { "200": { "description": "Application event stream" } } } },
5875
+ "/workflows/{name}/run": { "post": { "responses": { "201": { "description": "Workflow execution" } } } },
5876
+ "/agents/{name}/run": { "post": { "responses": { "201": { "description": "Agent execution" } } } },
5877
+ "/capabilities/{name}/run": { "post": { "responses": { "200": { "description": "Public capability result" } } } }
5878
+ }
5879
+ }))
4405
5880
  }
4406
5881
  async fn get_revision_runtime(
4407
5882
  State(state): State<AppState>,
@@ -4802,8 +6277,29 @@ async fn list_runtime_triggers(
4802
6277
  Ok(Json(json!(contract.triggers)))
4803
6278
  }
4804
6279
 
4805
- 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))}
4806
- 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
+ }
4807
6303
  fn authorize_transaction_collections(
4808
6304
  principal: &Principal,
4809
6305
  contract: &ApplicationRuntimeContract,
@@ -4836,14 +6332,14 @@ fn state_authorization(
4836
6332
  ) -> AuthorizationContext {
4837
6333
  let subject = format!("{}:{}", principal.subject_type, principal.key_id);
4838
6334
 
4839
- let policy=matching_state_policy(contract,collection);
6335
+ let policy = matching_state_policy(contract, collection);
4840
6336
  let policy_subject = policy.and_then(|p| {
4841
- if operation == "read" {
4842
- p.read.as_ref().and_then(|s| PolicySubject::from_str(s))
4843
- } else {
4844
- p.write.as_ref().and_then(|s| PolicySubject::from_str(s))
4845
- }
4846
- });
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
+ });
4847
6343
 
4848
6344
  // Evaluate policy if found
4849
6345
  let capabilities = if let Some(policy) = policy_subject {
@@ -4871,7 +6367,16 @@ fn state_authorization(
4871
6367
  BTreeSet::new()
4872
6368
  }
4873
6369
  }
4874
- } 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
+ }) {
4875
6380
  let capability = if operation == "read" {
4876
6381
  "state:read"
4877
6382
  } else {
@@ -5207,16 +6712,25 @@ async fn execute_canonical_transaction(
5207
6712
  input.transaction.revision_id = contract.revision_id.clone();
5208
6713
  input.transaction.schema_version = contract.state.schema.schema_version;
5209
6714
  input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
5210
- let collections = input.transaction.operations.iter()
6715
+ let collections = input
6716
+ .transaction
6717
+ .operations
6718
+ .iter()
5211
6719
  .map(|operation| operation.collection.clone())
5212
6720
  .collect::<Vec<_>>();
5213
- 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(
5214
6724
  &principal,
5215
- &contract,
5216
6725
  &tenant,
5217
- &collections,
6726
+ &input.application_id,
6727
+ &contract.revision_id,
6728
+ if authorized {
6729
+ "state:write"
6730
+ } else {
6731
+ "state:denied"
6732
+ },
5218
6733
  );
5219
- input.transaction.authorization=state_authorization_legacy(&principal,&tenant,&input.application_id,&contract.revision_id,if authorized{"state:write"}else{"state:denied"});
5220
6734
  let result = execute_transaction_with_collection_policies(
5221
6735
  &state.db,
5222
6736
  &contract.state.schema,
@@ -6029,6 +7543,51 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6029
7543
  axum::routing::post(validate_state_schema),
6030
7544
  )
6031
7545
  .route("/v1/schema", get(get_state_schema))
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))
7550
+ .route("/v1/proposals", get(list_proposals).post(create_proposal))
7551
+ .route("/v1/proposals/{id}", get(get_proposal))
7552
+ .route("/v1/proposals/{id}/history", get(proposal_history))
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))
7576
+ .route("/v1/auth/session", get(auth_session))
7577
+ .route("/v1/auth/signout", axum::routing::post(auth_sign_out))
7578
+ .route("/v1/events", get(events))
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
+ )
6032
7591
  .route("/v1/state/version", get(get_state_version))
6033
7592
  .route("/v1/query", axum::routing::post(execute_canonical_query))
6034
7593
  .route(
@@ -6393,6 +7952,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6393
7952
 
6394
7953
  let mut app = Router::new()
6395
7954
  .route("/health", get(health))
7955
+ .route("/v1/health", get(health))
7956
+ .route("/v1/openapi.json", get(application_openapi))
7957
+ .route("/v1/auth/signup", axum::routing::post(auth_sign_up))
7958
+ .route("/v1/auth/signin", axum::routing::post(auth_sign_in))
6396
7959
  .route("/ready", get(readiness))
6397
7960
  .route("/runtime", get(runtime))
6398
7961
  .route("/metrics", get(network_metrics))
@@ -6422,6 +7985,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6422
7985
  axum::http::header::CONTENT_TYPE,
6423
7986
  axum::http::HeaderName::from_static("feltdb-protocol"),
6424
7987
  axum::http::HeaderName::from_static("if-version"),
7988
+ axum::http::HeaderName::from_static("idempotency-key"),
6425
7989
  ]),
6426
7990
  );
6427
7991
  }
@@ -6472,6 +8036,7 @@ async fn authenticate(
6472
8036
  .and_then(|header| header.to_str().ok())
6473
8037
  .and_then(|header| header.strip_prefix("Bearer "));
6474
8038
  let machine = token.and_then(|token| state.keys.authenticate(token, &state.namespace));
8039
+ let actor = token.and_then(|token| state.identities.authenticate_session(token));
6475
8040
  let human = request
6476
8041
  .headers()
6477
8042
  .get(COOKIE)
@@ -6483,9 +8048,25 @@ async fn authenticate(
6483
8048
  .find_map(|cookie| cookie.strip_prefix("feltdb_session="))
6484
8049
  })
6485
8050
  .and_then(|token| state.sessions.verify(token));
6486
- match (machine, human) {
6487
- (Some(principal), _) => principal,
6488
- (None, Some(session)) => {
8051
+ match (machine, actor, human) {
8052
+ (Some(principal), _, _) => principal,
8053
+ (None, Some(session), _) => Principal {
8054
+ key_id: session.subject_id,
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
+ ],
8065
+ subject_type: "human".into(),
8066
+ identity_id: Some(session.identity_id),
8067
+ session_id: Some(session.session_id),
8068
+ },
8069
+ (None, None, Some(session)) => {
6489
8070
  if state
6490
8071
  .tenancy
6491
8072
  .register_platform_user(&session.user_id, &session.identity)
@@ -6503,7 +8084,7 @@ async fn authenticate(
6503
8084
  session_id: Some(session.id),
6504
8085
  }
6505
8086
  }
6506
- (None, None) => {
8087
+ (None, None, None) => {
6507
8088
  state.metrics.authentication_failure();
6508
8089
  audit(
6509
8090
  &state,
@@ -6711,9 +8292,7 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
6711
8292
  })
6712
8293
  }
6713
8294
 
6714
- async fn managed_diagnostics(
6715
- State(state): State<AppState>,
6716
- ) -> Json<ManagedDiagnosticsResponse> {
8295
+ async fn managed_diagnostics(State(state): State<AppState>) -> Json<ManagedDiagnosticsResponse> {
6717
8296
  let recovery = match state.cluster.proposal().map(|value| value.phase) {
6718
8297
  Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
6719
8298
  Some(ProposalPhase::Aborted) => "attention-required",
@@ -6730,8 +8309,7 @@ async fn managed_diagnostics(
6730
8309
  storage_format: 1,
6731
8310
  promoted_revision: std::env::var("FELTDB_PROMOTED_REVISION")
6732
8311
  .unwrap_or_else(|_| "unknown".into()),
6733
- environment: std::env::var("FELTDB_ENVIRONMENT")
6734
- .unwrap_or_else(|_| "development".into()),
8312
+ environment: std::env::var("FELTDB_ENVIRONMENT").unwrap_or_else(|_| "development".into()),
6735
8313
  recovery: recovery.into(),
6736
8314
  uptime: format!("{uptime_seconds}s"),
6737
8315
  })
@@ -8476,13 +10054,67 @@ async fn define_capability(
8476
10054
 
8477
10055
  async fn execute_capability(
8478
10056
  State(state): State<AppState>,
10057
+ Extension(principal): Extension<Principal>,
8479
10058
  Path(name): Path<String>,
8480
10059
  Json(input): Json<Value>,
8481
10060
  ) -> Result<Json<Value>, ApiError> {
10061
+ if !principal.permits("capabilities:run") {
10062
+ return Err(ApiError(
10063
+ StatusCode::FORBIDDEN,
10064
+ "capability execution is not permitted".into(),
10065
+ ));
10066
+ }
8482
10067
  validate_segment(&name)?;
8483
10068
  Ok(Json(execute_named_capability(&state, &name, &input)?))
8484
10069
  }
8485
10070
 
10071
+ async fn execute_application_capability(
10072
+ State(state): State<AppState>,
10073
+ Extension(principal): Extension<Principal>,
10074
+ Path(name): Path<String>,
10075
+ Query(input): Query<ApplicationDiscoveryRequest>,
10076
+ Json(payload): Json<Value>,
10077
+ ) -> Result<Json<Value>, ApiError> {
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
+ ))?;
10109
+ if capability.visibility != "public" {
10110
+ return Err(ApiError(
10111
+ StatusCode::FORBIDDEN,
10112
+ "capability is not externally invocable".into(),
10113
+ ));
10114
+ }
10115
+ Ok(Json(execute_named_capability(&state, &name, &payload)?))
10116
+ }
10117
+
8486
10118
  fn validate_capability_program(program: &CapabilityProgram) -> Result<(), ApiError> {
8487
10119
  if program.steps.is_empty() || program.steps.len() > 16 {
8488
10120
  return Err(ApiError(
@@ -8668,9 +10300,16 @@ async fn define_workflow(
8668
10300
 
8669
10301
  async fn start_workflow_run(
8670
10302
  State(state): State<AppState>,
10303
+ Extension(principal): Extension<Principal>,
8671
10304
  Path(name): Path<String>,
8672
10305
  Json(request): Json<StartRun>,
8673
10306
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
10307
+ if !principal.permits("workflows:run") {
10308
+ return Err(ApiError(
10309
+ StatusCode::FORBIDDEN,
10310
+ "workflow execution is not permitted".into(),
10311
+ ));
10312
+ }
8674
10313
  validate_segment(&name)?;
8675
10314
  let definition = required_value(&state, "_flow_workflows", &name, "workflow not found")?;
8676
10315
  let steps = definition["steps"].as_array().ok_or_else(|| {
@@ -8828,9 +10467,16 @@ async fn define_agent(
8828
10467
 
8829
10468
  async fn start_agent_run(
8830
10469
  State(state): State<AppState>,
10470
+ Extension(principal): Extension<Principal>,
8831
10471
  Path(name): Path<String>,
8832
10472
  Json(request): Json<StartAgentRun>,
8833
10473
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
10474
+ if !principal.permits("agents:run") {
10475
+ return Err(ApiError(
10476
+ StatusCode::FORBIDDEN,
10477
+ "agent execution is not permitted".into(),
10478
+ ));
10479
+ }
8834
10480
  validate_segment(&name)?;
8835
10481
  required_value(&state, "_flow_agents", &name, "agent not found")?;
8836
10482
  if request.goal.trim().is_empty() {
@@ -9052,10 +10698,13 @@ struct BoundedQueryPage {
9052
10698
  }
9053
10699
 
9054
10700
  fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
9055
- ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({
9056
- "code": code,
9057
- "message": message.into(),
9058
- }))
10701
+ ApiError::structured(
10702
+ StatusCode::UNPROCESSABLE_ENTITY,
10703
+ json!({
10704
+ "code": code,
10705
+ "message": message.into(),
10706
+ }),
10707
+ )
9059
10708
  }
9060
10709
 
9061
10710
  fn bounded_query_hash(request: &BoundedQueryRequest) -> Result<String, ApiError> {
@@ -9078,7 +10727,10 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
9078
10727
  (Some(Value::Null), Some(Value::Null)) => std::cmp::Ordering::Equal,
9079
10728
  (Some(Value::Null), Some(_)) => std::cmp::Ordering::Less,
9080
10729
  (Some(_), Some(Value::Null)) => std::cmp::Ordering::Greater,
9081
- (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),
9082
10734
  (Some(Value::String(a)), Some(Value::String(b))) => a.cmp(b),
9083
10735
  (Some(Value::Bool(a)), Some(Value::Bool(b))) => a.cmp(b),
9084
10736
  (Some(a), Some(b)) => a.to_string().cmp(&b.to_string()),
@@ -9087,31 +10739,60 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
9087
10739
 
9088
10740
  fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Result<bool, ApiError> {
9089
10741
  if condition.operators.len() != 1 || condition.field.trim().is_empty() {
9090
- 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
+ ));
9091
10746
  }
9092
- 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");
9093
10752
  let actual = record.get(&condition.field);
9094
- if operator == "eq" { return Ok(actual == Some(expected)); }
9095
- if operator == "neq" { return Ok(actual != Some(expected)); }
9096
- 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
+ };
9097
10762
  let comparison = query_scalar_cmp(Some(actual), Some(expected));
9098
10763
  Ok(match operator.as_str() {
9099
10764
  "lt" => comparison.is_lt(),
9100
10765
  "lte" => !comparison.is_gt(),
9101
10766
  "gt" => comparison.is_gt(),
9102
10767
  "gte" => !comparison.is_lt(),
9103
- _ => 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
+ }
9104
10774
  })
9105
10775
  }
9106
10776
 
9107
- 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> {
9108
10781
  let token = uuid::Uuid::new_v4().simple().to_string();
9109
10782
  let now = unix_seconds_i64().max(0) as u64;
9110
10783
  cursor.created_at = now;
9111
- 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
+ })?;
9112
10790
  cursors.retain(|_, value| now.saturating_sub(value.created_at) <= BOUNDED_CURSOR_TTL_SECONDS);
9113
10791
  if cursors.len() >= 1024 {
9114
- 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
+ ));
9115
10796
  }
9116
10797
  cursors.insert(token.clone(), cursor);
9117
10798
  Ok(token)
@@ -9124,32 +10805,67 @@ async fn execute_bounded_query(
9124
10805
  ) -> Result<Json<BoundedQueryPage>, ApiError> {
9125
10806
  validate_segment(&request.collection)?;
9126
10807
  if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
9127
- 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
+ ));
9128
10812
  }
9129
10813
  if request.order_by.is_empty() {
9130
- 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
+ ));
9131
10818
  }
9132
10819
  for order in &request.order_by {
9133
10820
  if order.field.trim().is_empty() || !matches!(order.direction.as_str(), "asc" | "desc") {
9134
- 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
+ ));
9135
10825
  }
9136
10826
  }
9137
10827
  for condition in &request.conditions {
9138
- if condition.operators.len() != 1 || condition.field.trim().is_empty()
9139
- || !condition.operators.keys().all(|operator| matches!(operator.as_str(), "eq" | "neq" | "lt" | "lte" | "gt" | "gte")) {
9140
- 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
+ ));
9141
10841
  }
9142
10842
  }
9143
10843
  let hash = bounded_query_hash(&request)?;
9144
10844
  let (records, position) = if let Some(token) = &request.cursor {
9145
- let cursor = state.bounded_query_cursors.lock()
9146
- .map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "query cursor store unavailable".into()))?
9147
- .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()
9148
10856
  .ok_or_else(|| bounded_query_error("INVALID_CURSOR", "cursor is invalid or expired"))?;
9149
- if cursor.query_hash != hash || cursor.principal_key_id != principal.key_id || cursor.namespace != state.namespace.as_ref() {
9150
- 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
+ ));
9151
10865
  }
9152
- 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
+ {
9153
10869
  return Err(bounded_query_error("INVALID_CURSOR", "cursor is expired"));
9154
10870
  }
9155
10871
  (cursor.records, cursor.position)
@@ -9158,13 +10874,21 @@ async fn execute_bounded_query(
9158
10874
  for row in state.db.list_collection(&request.collection)? {
9159
10875
  let mut value = row.value;
9160
10876
  if let Some(object) = value.as_object_mut() {
9161
- 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);
9162
10882
  // `recordId` is authority metadata for this query surface, not
9163
10883
  // caller-controlled document data. It is the final total-order
9164
10884
  // tie-breaker even when a document contains a field by that name.
9165
10885
  object.insert("recordId".into(), Value::String(id.to_string()));
9166
10886
  }
9167
- 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
+ {
9168
10892
  records.push(value);
9169
10893
  }
9170
10894
  }
@@ -9173,7 +10897,13 @@ async fn execute_bounded_query(
9173
10897
  records.sort_by(|left, right| {
9174
10898
  for order in &request.order_by {
9175
10899
  let comparison = query_scalar_cmp(left.get(&order.field), right.get(&order.field));
9176
- 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
+ }
9177
10907
  }
9178
10908
  query_scalar_cmp(left.get("recordId"), right.get("recordId"))
9179
10909
  });
@@ -9182,22 +10912,37 @@ async fn execute_bounded_query(
9182
10912
  let end = position.saturating_add(request.limit).min(records.len());
9183
10913
  let page = records[position.min(records.len())..end].to_vec();
9184
10914
  let next_cursor = if end < records.len() {
9185
- Some(issue_bounded_cursor(&state, BoundedQueryCursor {
9186
- query_hash: hash,
9187
- principal_key_id: principal.key_id,
9188
- namespace: state.namespace.to_string(),
9189
- records: records.clone(),
9190
- position: end,
9191
- created_at: 0,
9192
- })?)
9193
- } else { None };
9194
- 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
+ }))
9195
10934
  }
9196
10935
 
9197
10936
  async fn list_records(
9198
10937
  State(state): State<AppState>,
9199
10938
  Path(collection): Path<String>,
9200
10939
  ) -> Result<Json<Vec<RecordResponse>>, ApiError> {
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
+ }
9201
10946
  validate_segment(&collection)?;
9202
10947
  let rows = state.db.list_collection(&collection)?;
9203
10948
  Ok(Json(rows.into_iter().map(record_response).collect()))
@@ -9207,6 +10952,12 @@ async fn get_record(
9207
10952
  State(state): State<AppState>,
9208
10953
  Path((collection, id)): Path<(String, String)>,
9209
10954
  ) -> Result<Json<RecordResponse>, ApiError> {
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
+ }
9210
10961
  let key = record_key(&collection, &id)?;
9211
10962
  let value = state
9212
10963
  .db
@@ -9427,7 +11178,11 @@ async fn commit_transaction(
9427
11178
  })?;
9428
11179
 
9429
11180
  Ok((
9430
- if commit.duplicate { StatusCode::OK } else { StatusCode::CREATED },
11181
+ if commit.duplicate {
11182
+ StatusCode::OK
11183
+ } else {
11184
+ StatusCode::CREATED
11185
+ },
9431
11186
  Json(AtomicTxResponse {
9432
11187
  transaction_id: commit.transaction_id,
9433
11188
  duplicate: commit.duplicate,
@@ -9443,6 +11198,12 @@ async fn create_record(
9443
11198
  Path(collection): Path<String>,
9444
11199
  Json(mut record): Json<CreateRecord>,
9445
11200
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
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
+ }
9446
11207
  validate_segment(&collection)?;
9447
11208
  let id = record
9448
11209
  .id
@@ -9476,6 +11237,12 @@ async fn update_record(
9476
11237
  Path((collection, id)): Path<(String, String)>,
9477
11238
  Json(changes): Json<Map<String, Value>>,
9478
11239
  ) -> Result<Json<RecordResponse>, ApiError> {
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
+ }
9479
11246
  let key = record_key(&collection, &id)?;
9480
11247
  let current = state
9481
11248
  .db
@@ -9591,6 +11358,12 @@ async fn delete_record(
9591
11358
  State(state): State<AppState>,
9592
11359
  Path((collection, id)): Path<(String, String)>,
9593
11360
  ) -> Result<StatusCode, ApiError> {
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
+ }
9594
11367
  let key = record_key(&collection, &id)?;
9595
11368
  if state.db.get_value(&key)?.is_none() {
9596
11369
  return Err(ApiError(
@@ -9604,14 +11377,36 @@ async fn delete_record(
9604
11377
 
9605
11378
  async fn events(
9606
11379
  State(state): State<AppState>,
9607
- ) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
11380
+ Extension(principal): Extension<Principal>,
11381
+ Query(scope): Query<EventScopeRequest>,
11382
+ ) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
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
+ }
9608
11392
  let mut receiver = state.db.subscribe_changes();
9609
11393
  let namespace = state.namespace.clone();
11394
+ let actor = principal.key_id;
9610
11395
  let stream = async_stream::stream! {
9611
11396
  loop {
9612
11397
  match receiver.recv().await {
9613
11398
  Ok(change) => {
9614
- let data = json!({ "namespace": namespace.as_ref(), "type": "state.changed", "change": change });
11399
+ let resource = change.capability.clone();
11400
+ let resource_id = change.key.split_once(':').map(|(_, id)| id).unwrap_or(&change.key);
11401
+ let data = json!({
11402
+ "event_id": uuid::Uuid::new_v4().to_string(),
11403
+ "type": "state.changed",
11404
+ "resource": resource,
11405
+ "resource_id": resource_id,
11406
+ "actor": actor,
11407
+ "timestamp": unix_seconds_i64().max(0) as u64,
11408
+ "payload": { "operation": "mutation", "namespace": namespace.as_ref() }
11409
+ });
9615
11410
  yield Ok(Event::default().event("state.changed").json_data(data).expect("serializable event"));
9616
11411
  }
9617
11412
  Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
@@ -9621,7 +11416,7 @@ async fn events(
9621
11416
  }
9622
11417
  }
9623
11418
  };
9624
- Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
11419
+ Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
9625
11420
  }
9626
11421
 
9627
11422
  fn record_response(row: StoredRow) -> RecordResponse {
@@ -9683,7 +11478,10 @@ mod authority_gate_tests {
9683
11478
  use super::{api_error_body, authorize_transaction_collections, state_authorization};
9684
11479
  use axum::http::StatusCode;
9685
11480
  use feltdb::{
9686
- application::{manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition, RevisionStatus},
11481
+ application::{
11482
+ manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition,
11483
+ RevisionStatus,
11484
+ },
9687
11485
  application_runtime::{resolve_runtime, ApplicationRuntimeContract, RuntimeInventory},
9688
11486
  };
9689
11487
  use feltdb_server::auth::Principal;
@@ -9729,9 +11527,16 @@ mod authority_gate_tests {
9729
11527
  #[test]
9730
11528
  fn no_matching_policy_denies() {
9731
11529
  let contract = contract(vec![]);
9732
- 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
+ );
9733
11535
  let (allowed, _) = authorize_transaction_collections(
9734
- &principal(), &contract, "tenant", &["secrets".into()],
11536
+ &principal(),
11537
+ &contract,
11538
+ "tenant",
11539
+ &["secrets".into()],
9735
11540
  );
9736
11541
  assert!(!allowed);
9737
11542
  }
@@ -9740,7 +11545,10 @@ mod authority_gate_tests {
9740
11545
  fn terminal_wildcard_policy_allows_matching_collection() {
9741
11546
  let contract = contract(vec![allow("buzz_*")]);
9742
11547
  let (allowed, _) = authorize_transaction_collections(
9743
- &principal(), &contract, "tenant", &["buzz_rooms".into()],
11548
+ &principal(),
11549
+ &contract,
11550
+ "tenant",
11551
+ &["buzz_rooms".into()],
9744
11552
  );
9745
11553
  assert!(allowed);
9746
11554
  }
@@ -9749,10 +11557,16 @@ mod authority_gate_tests {
9749
11557
  fn multi_collection_transaction_authorizes_every_operation() {
9750
11558
  let contract = contract(vec![allow("alpha"), allow("beta")]);
9751
11559
  let (all_allowed, _) = authorize_transaction_collections(
9752
- &principal(), &contract, "tenant", &["alpha".into(), "beta".into()],
11560
+ &principal(),
11561
+ &contract,
11562
+ "tenant",
11563
+ &["alpha".into(), "beta".into()],
9753
11564
  );
9754
11565
  let (mixed_allowed, _) = authorize_transaction_collections(
9755
- &principal(), &contract, "tenant", &["alpha".into(), "secrets".into()],
11566
+ &principal(),
11567
+ &contract,
11568
+ "tenant",
11569
+ &["alpha".into(), "secrets".into()],
9756
11570
  );
9757
11571
  assert!(all_allowed);
9758
11572
  assert!(!mixed_allowed);
@@ -9762,7 +11576,10 @@ mod authority_gate_tests {
9762
11576
  fn cross_tenant_transaction_denies() {
9763
11577
  let contract = contract(vec![allow("alpha")]);
9764
11578
  let (allowed, _) = authorize_transaction_collections(
9765
- &principal(), &contract, "other-tenant", &["alpha".into()],
11579
+ &principal(),
11580
+ &contract,
11581
+ "other-tenant",
11582
+ &["alpha".into()],
9766
11583
  );
9767
11584
  assert!(!allowed);
9768
11585
  }
@@ -9775,10 +11592,10 @@ mod authority_gate_tests {
9775
11592
  "missing scope: state:read",
9776
11593
  r#"{"code":"AUTHORIZATION_DENIED","message":"policy denied"}"#,
9777
11594
  ] {
9778
- assert_eq!(
9779
- api_error_body(StatusCode::FORBIDDEN, internal_message.into()),
9780
- serde_json::json!({ "error": "AUTHORIZATION_DENIED" })
9781
- );
11595
+ let body = api_error_body(StatusCode::FORBIDDEN, internal_message.into());
11596
+ assert_eq!(body["error"], "FORBIDDEN");
11597
+ assert_eq!(body["code"], "FORBIDDEN");
11598
+ assert!(body["request_id"].as_str().unwrap().starts_with("req_"));
9782
11599
  }
9783
11600
  }
9784
11601
  }