@feltdb/core 0.8.0 → 0.8.2

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 (85) hide show
  1. package/dist/application-development.d.ts +7 -2
  2. package/dist/application-development.d.ts.map +1 -1
  3. package/dist/application-development.js +7 -13
  4. package/dist/application-manifest.d.ts +1 -0
  5. package/dist/application-manifest.d.ts.map +1 -1
  6. package/dist/application-manifest.js +1 -1
  7. package/dist/application-understanding.d.ts +76 -0
  8. package/dist/application-understanding.d.ts.map +1 -0
  9. package/dist/application-understanding.js +282 -0
  10. package/dist/cli/ai-model-config.js +3 -3
  11. package/dist/cli/commands.js +131 -38
  12. package/dist/cli/proposal-client.js +5 -1
  13. package/dist/cli/publish-engine.js +1 -1
  14. package/dist/cli/repository-evidence.js +55 -0
  15. package/dist/cli/source-sync.js +190 -75
  16. package/dist/convergence.d.ts +55 -0
  17. package/dist/convergence.d.ts.map +1 -0
  18. package/dist/convergence.js +63 -0
  19. package/dist/create/managed-account.js +1 -0
  20. package/dist/create/package-versions.js +1 -1
  21. package/dist/create/server-source/crates/feltdb/src/application.rs +23 -0
  22. package/dist/create/server-source/crates/feltdb/src/authority.rs +349 -0
  23. package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +32 -0
  24. package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority.rs +19 -0
  25. package/dist/create/server-source/crates/feltdb/src/lib.rs +444 -189
  26. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +244 -51
  27. package/dist/create/server-source/crates/feltdb/tests/authority_process.rs +297 -0
  28. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +41 -11
  29. package/dist/create/server-source/crates/feltdb-server/src/connections.rs +14 -0
  30. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +8 -3
  31. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1262 -173
  32. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +142 -1
  33. package/dist/create/template/default-project/src/context/AuthContext.tsx +9 -1
  34. package/dist/create/template/default-project/src/pages/SignUp.tsx +1 -1
  35. package/dist/flowspec.d.ts.map +1 -1
  36. package/dist/flowspec.js +6 -1
  37. package/dist/http-client.d.ts +32 -1
  38. package/dist/http-client.d.ts.map +1 -1
  39. package/dist/http-client.js +14 -2
  40. package/dist/http-db.d.ts +6 -0
  41. package/dist/http-db.d.ts.map +1 -1
  42. package/dist/http-db.js +30 -4
  43. package/dist/index-core.d.ts +3 -0
  44. package/dist/index-core.d.ts.map +1 -1
  45. package/dist/index-core.js +3 -0
  46. package/dist/intent.d.ts +98 -0
  47. package/dist/intent.d.ts.map +1 -0
  48. package/dist/intent.js +343 -0
  49. package/dist/module-intent.d.ts +19 -5
  50. package/dist/module-intent.d.ts.map +1 -1
  51. package/dist/module-intent.js +211 -62
  52. package/dist/module.d.ts +41 -12
  53. package/dist/module.d.ts.map +1 -1
  54. package/dist/module.js +543 -38
  55. package/dist/proposal.d.ts +13 -1
  56. package/dist/proposal.d.ts.map +1 -1
  57. package/dist/proposal.js +21 -4
  58. package/dist/state-contract.d.ts +4 -1
  59. package/dist/state-contract.d.ts.map +1 -1
  60. package/dist/state-contract.js +1 -1
  61. package/dist/studio/components/AskFeltDB.d.ts.map +1 -1
  62. package/dist/studio/components/ProposalBrowser.d.ts +1 -1
  63. package/dist/studio/components/ProposalBrowser.d.ts.map +1 -1
  64. package/dist/studio/components/index.js +1 -1
  65. package/dist/studio/{components-CNSNrmA9.js → components-Duq2xQWt.js} +251 -193
  66. package/dist/studio/index.js +1 -1
  67. package/dist/studio/studio.css +1 -1
  68. package/dist/studio/utils/proposal-api.d.ts +4 -1
  69. package/dist/studio/utils/proposal-api.d.ts.map +1 -1
  70. package/dist/studio/utils/proposal-api.js +3 -0
  71. package/dist/studio-app/assets/{feltdb_wasm-CSB6KVgw.js → feltdb_wasm-DB8cX151.js} +1 -1
  72. package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
  73. package/dist/studio-app/assets/index-B0k4UAlI.js +29 -0
  74. package/dist/studio-app/assets/{index-yazaWMUN.css → index-Cd_NPw14.css} +1 -1
  75. package/dist/studio-app/index.html +2 -2
  76. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  77. package/dist/workspace/local-development-authority.d.ts +2 -0
  78. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  79. package/dist/workspace/local-development-authority.js +65 -2
  80. package/package.json +1 -1
  81. package/dist/studio-app/assets/dist-CCOk39Uc.js +0 -1
  82. package/dist/studio-app/assets/feltdb_wasm_bg-Dd1fnO9U.wasm +0 -0
  83. package/dist/studio-app/assets/index-Bncji3aN.js +0 -43
  84. package/dist/studio-app/assets/lib-B2_7fcn7.js +0 -69
  85. package/dist/studio-app/assets/worker-CARZ5g7K.js +0 -69
@@ -96,7 +96,7 @@ use feltdb_server::{
96
96
  ScalingPolicy, SecretReference as DeploymentSecretReference,
97
97
  },
98
98
  sessions::SessionVerifier,
99
- tenancy::{MembershipStatus, TenancyStore},
99
+ tenancy::{MembershipStatus, QualificationMarker, TenancyStore},
100
100
  };
101
101
  use serde::{Deserialize, Serialize};
102
102
  use serde_json::{json, Map, Value};
@@ -151,11 +151,15 @@ fn api_error_body(status: StatusCode, message: String) -> Value {
151
151
  let object = body
152
152
  .as_object_mut()
153
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();
154
+ let resolved_code = if status == StatusCode::FORBIDDEN {
155
+ code.to_string()
156
+ } else {
157
+ object
158
+ .get("code")
159
+ .and_then(Value::as_str)
160
+ .unwrap_or(code)
161
+ .to_string()
162
+ };
159
163
  object.insert("error".into(), json!(resolved_code));
160
164
  object.insert("code".into(), json!(resolved_code));
161
165
  object.entry("message").or_insert_with(|| {
@@ -230,8 +234,15 @@ struct CreateTenantRequest {
230
234
  #[derive(Deserialize)]
231
235
  struct CreateApplicationRequest {
232
236
  name: String,
237
+ #[serde(default)]
238
+ qualification: Option<QualificationMarker>,
239
+ }
240
+ #[derive(Deserialize)]
241
+ struct QualificationDestroyRequest {
242
+ proof_run_id: String,
233
243
  }
234
244
  #[derive(Deserialize)]
245
+ #[serde(deny_unknown_fields)]
235
246
  struct UpdateApplicationRequest {
236
247
  name: String,
237
248
  }
@@ -448,6 +459,8 @@ struct ProposalScopeRequest {
448
459
  limit: Option<usize>,
449
460
  #[serde(default)]
450
461
  cursor: Option<String>,
462
+ #[serde(default)]
463
+ current_evidence_hash: Option<String>,
451
464
  }
452
465
  #[derive(Deserialize)]
453
466
  struct CreateProposalRequest {
@@ -472,6 +485,26 @@ struct CreateProposalRequest {
472
485
  expires_at: Option<String>,
473
486
  #[serde(default)]
474
487
  status: Option<String>,
488
+ #[serde(default)]
489
+ understanding: Option<Value>,
490
+ #[serde(default)]
491
+ evidence_hash: Option<String>,
492
+ #[serde(default)]
493
+ understanding_hash: Option<String>,
494
+ }
495
+ #[derive(Deserialize, Serialize)]
496
+ struct ExecuteIntentRequest {
497
+ application_id: String,
498
+ #[serde(default = "production_environment")]
499
+ environment: String,
500
+ #[serde(alias = "raw_request")]
501
+ request: String,
502
+ }
503
+ #[derive(Deserialize)]
504
+ struct IntentHashRequest {
505
+ application_id: String,
506
+ environment: String,
507
+ intent_hash: String,
475
508
  }
476
509
  #[derive(Deserialize)]
477
510
  struct ProposalActorRequest {
@@ -518,6 +551,7 @@ struct SignUpRequest {
518
551
  }
519
552
  #[derive(Deserialize)]
520
553
  struct SignInRequest {
554
+ application_id: Option<String>,
521
555
  email: String,
522
556
  password: String,
523
557
  }
@@ -2219,6 +2253,7 @@ async fn platform_overview(
2219
2253
  let (users, tenants, applications, tenant_memberships, application_memberships) =
2220
2254
  state.tenancy.platform_inventory().map_err(control_error)?;
2221
2255
  let identities = state.identities.list().map_err(control_error)?;
2256
+ let runtime_metrics = state.metrics.snapshot();
2222
2257
  Ok(Json(json!({
2223
2258
  "owner": principal.identity_id,
2224
2259
  "users": users,
@@ -2227,6 +2262,11 @@ async fn platform_overview(
2227
2262
  "tenant_memberships": tenant_memberships,
2228
2263
  "application_memberships": application_memberships,
2229
2264
  "identities": identities,
2265
+ "adoption": {
2266
+ "measured_at": unix_seconds_i64().max(0) as u64,
2267
+ "runtime_process_started_seconds_ago": state.started_at.elapsed().as_secs(),
2268
+ "runtime": runtime_metrics,
2269
+ },
2230
2270
  })))
2231
2271
  }
2232
2272
  async fn platform_update_user(
@@ -2319,7 +2359,12 @@ async fn create_application_control(
2319
2359
  ) -> Result<(StatusCode, Json<Value>), ApiError> {
2320
2360
  let app = state
2321
2361
  .tenancy
2322
- .create_application(&principal.key_id, &tenant_id, &input.name)
2362
+ .create_application_with_qualification(
2363
+ &principal.key_id,
2364
+ &tenant_id,
2365
+ &input.name,
2366
+ input.qualification,
2367
+ )
2323
2368
  .map_err(control_error)?;
2324
2369
  Ok((StatusCode::CREATED, Json(json!(app))))
2325
2370
  }
@@ -2349,6 +2394,23 @@ async fn delete_application_control(
2349
2394
  Ok(StatusCode::NO_CONTENT)
2350
2395
  }
2351
2396
 
2397
+ async fn delete_qualification_application_control(
2398
+ State(state): State<AppState>,
2399
+ Extension(principal): Extension<Principal>,
2400
+ Path(application_id): Path<String>,
2401
+ Json(input): Json<QualificationDestroyRequest>,
2402
+ ) -> Result<StatusCode, ApiError> {
2403
+ state
2404
+ .tenancy
2405
+ .delete_qualification_application(&principal.key_id, &application_id, &input.proof_run_id)
2406
+ .map_err(control_error)?;
2407
+ state
2408
+ .applications
2409
+ .purge_application(&application_id)
2410
+ .map_err(control_error)?;
2411
+ Ok(StatusCode::NO_CONTENT)
2412
+ }
2413
+
2352
2414
  async fn delete_certification_fixture(
2353
2415
  State(state): State<AppState>,
2354
2416
  Path(application_id): Path<String>,
@@ -3793,6 +3855,9 @@ fn application_scope(
3793
3855
  StatusCode::NOT_FOUND,
3794
3856
  "application not found".into(),
3795
3857
  ))?;
3858
+ if std::env::var("FELTDB_INTENT_AUTHORITY_KEY_ID").as_deref() == Ok(actor) {
3859
+ return Ok(tenant_id);
3860
+ }
3796
3861
  if !state
3797
3862
  .tenancy
3798
3863
  .authorize_application(actor, &tenant_id, application_id, capability)
@@ -4449,13 +4514,13 @@ async fn import_application_draft(
4449
4514
 
4450
4515
  fn runtime_inventory(
4451
4516
  state: &AppState,
4452
- actor: &str,
4517
+ _actor: &str,
4453
4518
  application_id: &str,
4454
4519
  manifest: &ApplicationManifest,
4455
4520
  ) -> Result<RuntimeInventory, ApiError> {
4456
4521
  let connections = state
4457
4522
  .connections
4458
- .list_connections(actor, &state.tenancy, application_id)
4523
+ .application_connections(application_id)
4459
4524
  .map_err(control_error)?;
4460
4525
  let connection_ids = connections
4461
4526
  .into_iter()
@@ -5046,14 +5111,297 @@ async fn inspect_record(
5046
5111
  const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
5047
5112
  const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
5048
5113
  const PROPOSAL_PREVIEW_COLLECTION: &str = "_feltdb_proposal_previews";
5114
+ const CONVERGENCE_COLLECTION: &str = "_feltdb_convergence";
5115
+ const INTENT_COLLECTION: &str = "_feltdb_intents";
5116
+ const INTENT_EVENT_COLLECTION: &str = "_feltdb_intent_events";
5049
5117
 
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);
5118
+ fn require_intent_service(headers: &HeaderMap) -> Result<(), ApiError> {
5119
+ let expected = std::env::var("FELTDB_INTENT_SERVICE_SECRET").map_err(|_| {
5120
+ inspection_error(
5121
+ StatusCode::SERVICE_UNAVAILABLE,
5122
+ "intent_service_unconfigured",
5123
+ "Intent Engine service authentication is not configured",
5124
+ )
5125
+ })?;
5126
+ let supplied = headers
5127
+ .get("x-feltdb-intent-service")
5128
+ .and_then(|value| value.to_str().ok())
5129
+ .unwrap_or_default();
5130
+ if Sha256::digest(supplied.as_bytes()) != Sha256::digest(expected.as_bytes()) {
5131
+ return Err(inspection_error(
5132
+ StatusCode::FORBIDDEN,
5133
+ "forbidden",
5134
+ "invalid Intent Engine service credential",
5135
+ ));
5136
+ }
5137
+ Ok(())
5138
+ }
5139
+
5140
+ async fn execute_intent(
5141
+ State(state): State<AppState>,
5142
+ Extension(principal): Extension<Principal>,
5143
+ headers: HeaderMap,
5144
+ Json(input): Json<ExecuteIntentRequest>,
5145
+ ) -> Result<Json<Value>, ApiError> {
5146
+ application_scope(
5147
+ &state,
5148
+ &principal.key_id,
5149
+ &input.application_id,
5150
+ "application:write",
5151
+ )?;
5152
+ if input.request.trim().is_empty() {
5153
+ return Err(inspection_error(
5154
+ StatusCode::BAD_REQUEST,
5155
+ "invalid_intent",
5156
+ "request is required",
5157
+ ));
5158
+ }
5159
+ let endpoint = std::env::var("FELTDB_INTENT_ENGINE_URL").map_err(|_| {
5160
+ inspection_error(
5161
+ StatusCode::SERVICE_UNAVAILABLE,
5162
+ "intent_engine_unavailable",
5163
+ "managed Intent Engine is not configured",
5164
+ )
5165
+ })?;
5166
+ let secret = std::env::var("FELTDB_INTENT_SERVICE_SECRET").map_err(|_| {
5167
+ inspection_error(
5168
+ StatusCode::SERVICE_UNAVAILABLE,
5169
+ "intent_engine_unavailable",
5170
+ "managed Intent Engine authentication is not configured",
5171
+ )
5172
+ })?;
5173
+ let mut request = state
5174
+ .peer_client
5175
+ .post(format!(
5176
+ "{}/internal/v1/intents",
5177
+ endpoint.trim_end_matches('/')
5178
+ ))
5179
+ .header("x-feltdb-intent-service", secret)
5180
+ .json(&input);
5181
+ if let Some(value) = headers.get("idempotency-key") {
5182
+ request = request.header("idempotency-key", value);
5183
+ }
5184
+ let response = request.send().await.map_err(|error| {
5185
+ inspection_error(
5186
+ StatusCode::BAD_GATEWAY,
5187
+ "intent_engine_unavailable",
5188
+ error.to_string(),
5189
+ )
5190
+ })?;
5191
+ let status = response.status();
5192
+ let value: Value = response.json().await.map_err(|error| {
5193
+ inspection_error(
5194
+ StatusCode::BAD_GATEWAY,
5195
+ "invalid_intent_engine_response",
5196
+ error.to_string(),
5197
+ )
5198
+ })?;
5199
+ if !status.is_success() {
5200
+ let republish =
5201
+ value.get("error").and_then(Value::as_str) == Some("APPLICATION_REPUBLISH_REQUIRED");
5202
+ return Err(inspection_error(
5203
+ if republish {
5204
+ StatusCode::CONFLICT
5205
+ } else {
5206
+ StatusCode::BAD_GATEWAY
5207
+ },
5208
+ if republish {
5209
+ "application_republish_required"
5210
+ } else {
5211
+ "intent_execution_failed"
5212
+ },
5213
+ value
5214
+ .get("message")
5215
+ .and_then(Value::as_str)
5216
+ .unwrap_or("Intent Engine failed"),
5217
+ ));
5218
+ }
5219
+ Ok(Json(value))
5220
+ }
5221
+
5222
+ async fn list_intents(
5223
+ State(state): State<AppState>,
5224
+ Extension(principal): Extension<Principal>,
5225
+ Query(input): Query<ProposalScopeRequest>,
5226
+ ) -> Result<Json<Value>, ApiError> {
5227
+ application_scope(
5228
+ &state,
5229
+ &principal.key_id,
5230
+ &input.application_id,
5231
+ "application:read",
5232
+ )?;
5233
+ let mut intents = state
5234
+ .db
5235
+ .list_collection(INTENT_COLLECTION)?
5236
+ .into_iter()
5237
+ .map(|row| row.value)
5238
+ .filter(|value| proposal_scope_matches(value, &input.application_id, &input.environment))
5239
+ .collect::<Vec<_>>();
5240
+ intents.sort_by(|a, b| {
5241
+ a.get("created_at")
5242
+ .and_then(Value::as_str)
5243
+ .cmp(&b.get("created_at").and_then(Value::as_str))
5244
+ });
5245
+ for value in &mut intents {
5246
+ if let Some(object) = value.as_object_mut() {
5247
+ object.remove("execution_result");
5248
+ }
5249
+ redact_inspection_value(value);
5250
+ }
5251
+ Ok(Json(json!({"intents":intents})))
5252
+ }
5253
+ async fn get_intent(
5254
+ State(state): State<AppState>,
5255
+ Extension(principal): Extension<Principal>,
5256
+ Path((id, operation)): Path<(String, String)>,
5257
+ Query(input): Query<ProposalScopeRequest>,
5258
+ ) -> Result<Json<Value>, ApiError> {
5259
+ application_scope(
5260
+ &state,
5261
+ &principal.key_id,
5262
+ &input.application_id,
5263
+ "application:read",
5264
+ )?;
5265
+ let intent = required_value(&state, INTENT_COLLECTION, &id, "intent not found")?;
5266
+ if !proposal_scope_matches(&intent, &input.application_id, &input.environment) {
5267
+ return Err(ApiError(StatusCode::NOT_FOUND, "intent not found".into()));
5268
+ }
5269
+ let value = match operation.as_str() {
5270
+ "understanding" => intent.get("understanding").cloned().unwrap_or(Value::Null),
5271
+ "plan" => intent.get("plan").cloned().unwrap_or(Value::Null),
5272
+ "proposal" => intent
5273
+ .get("proposal_id")
5274
+ .and_then(Value::as_str)
5275
+ .map(|id| proposal_value(&state, id))
5276
+ .transpose()?
5277
+ .unwrap_or(Value::Null),
5278
+ "history" => {
5279
+ let mut events = state
5280
+ .db
5281
+ .list_collection(INTENT_EVENT_COLLECTION)?
5282
+ .into_iter()
5283
+ .map(|row| row.value)
5284
+ .filter(|value| value.get("intent_id").and_then(Value::as_str) == Some(id.as_str()))
5285
+ .collect::<Vec<_>>();
5286
+ events.sort_by(|a, b| {
5287
+ a.get("created_at")
5288
+ .and_then(Value::as_str)
5289
+ .cmp(&b.get("created_at").and_then(Value::as_str))
5290
+ });
5291
+ json!({"events":events})
5292
+ }
5293
+ _ => {
5294
+ return Err(ApiError(
5295
+ StatusCode::NOT_FOUND,
5296
+ "intent resource not found".into(),
5297
+ ))
5298
+ }
5299
+ };
5300
+ Ok(Json(value))
5301
+ }
5302
+ async fn get_intent_root(
5303
+ State(state): State<AppState>,
5304
+ Extension(principal): Extension<Principal>,
5305
+ Path(id): Path<String>,
5306
+ Query(input): Query<ProposalScopeRequest>,
5307
+ ) -> Result<Json<Value>, ApiError> {
5308
+ application_scope(
5309
+ &state,
5310
+ &principal.key_id,
5311
+ &input.application_id,
5312
+ "application:read",
5313
+ )?;
5314
+ let mut value = required_value(&state, INTENT_COLLECTION, &id, "intent not found")?;
5315
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5316
+ return Err(ApiError(StatusCode::NOT_FOUND, "intent not found".into()));
5317
+ }
5318
+ if let Some(object) = value.as_object_mut() {
5319
+ object.remove("execution_result");
5320
+ }
5321
+ Ok(Json(value))
5322
+ }
5323
+ async fn internal_find_intent(
5324
+ State(state): State<AppState>,
5325
+ headers: HeaderMap,
5326
+ Query(input): Query<IntentHashRequest>,
5327
+ ) -> Result<Json<Value>, ApiError> {
5328
+ require_intent_service(&headers)?;
5329
+ let value = state
5330
+ .db
5331
+ .list_collection(INTENT_COLLECTION)?
5332
+ .into_iter()
5333
+ .map(|row| row.value)
5334
+ .find(|value| {
5335
+ proposal_scope_matches(value, &input.application_id, &input.environment)
5336
+ && value.get("intent_hash").and_then(Value::as_str)
5337
+ == Some(input.intent_hash.as_str())
5338
+ })
5339
+ .unwrap_or(Value::Null);
5340
+ Ok(Json(value))
5341
+ }
5342
+ async fn internal_create_intent(
5343
+ State(state): State<AppState>,
5344
+ headers: HeaderMap,
5345
+ Json(value): Json<Value>,
5346
+ ) -> Result<(StatusCode, Json<Value>), ApiError> {
5347
+ require_intent_service(&headers)?;
5348
+ let id = value.get("id").and_then(Value::as_str).ok_or_else(|| {
5349
+ inspection_error(
5350
+ StatusCode::BAD_REQUEST,
5351
+ "invalid_intent",
5352
+ "intent id is required",
5353
+ )
5354
+ })?;
5355
+ insert_canonical(&state, INTENT_COLLECTION, id, value.clone())?;
5356
+ Ok((StatusCode::CREATED, Json(value)))
5357
+ }
5358
+ async fn internal_replace_intent(
5359
+ State(state): State<AppState>,
5360
+ headers: HeaderMap,
5361
+ Path(id): Path<String>,
5362
+ Json(value): Json<Value>,
5363
+ ) -> Result<Json<Value>, ApiError> {
5364
+ require_intent_service(&headers)?;
5365
+ let _ = put_canonical(&state, INTENT_COLLECTION, &id, value.clone())?;
5366
+ Ok(Json(value))
5367
+ }
5368
+ async fn internal_create_intent_event(
5369
+ State(state): State<AppState>,
5370
+ headers: HeaderMap,
5371
+ Json(value): Json<Value>,
5372
+ ) -> Result<(StatusCode, Json<Value>), ApiError> {
5373
+ require_intent_service(&headers)?;
5374
+ let id = value.get("id").and_then(Value::as_str).ok_or_else(|| {
5375
+ inspection_error(
5376
+ StatusCode::BAD_REQUEST,
5377
+ "invalid_intent_event",
5378
+ "event id is required",
5379
+ )
5380
+ })?;
5381
+ insert_canonical(&state, INTENT_EVENT_COLLECTION, id, value.clone())?;
5382
+ Ok((StatusCode::CREATED, Json(value)))
5383
+ }
5384
+
5385
+ fn proposal_contract_snapshot(revision: &ApplicationRevision, environment: &str) -> Value {
5386
+ let contract_hash = revision
5387
+ .manifest
5388
+ .metadata
5389
+ .labels
5390
+ .get("feltdb.contract_hash")
5391
+ .unwrap_or(&revision.manifest_hash);
5392
+ let flow_hash = revision
5393
+ .manifest
5394
+ .metadata
5395
+ .labels
5396
+ .get("feltdb.flow_hash")
5397
+ .cloned();
5398
+ let dsl_version = revision
5399
+ .manifest
5400
+ .metadata
5401
+ .labels
5402
+ .get("feltdb.dsl_version")
5403
+ .and_then(|value| value.parse::<u32>().ok())
5404
+ .unwrap_or(1);
5057
5405
  json!({
5058
5406
  "application":{"application_id":revision.application_id,"environment":environment},
5059
5407
  "contract_version":revision.revision_number,"contract_hash":contract_hash,"flow_hash":flow_hash,
@@ -5065,11 +5413,18 @@ fn proposal_contract_snapshot(
5065
5413
  }
5066
5414
 
5067
5415
  fn proposal_hash(value: &Value) -> String {
5068
- format!("sha256:{:x}", Sha256::digest(serde_json::to_vec(value).unwrap_or_default()))
5416
+ format!(
5417
+ "sha256:{:x}",
5418
+ Sha256::digest(serde_json::to_vec(value).unwrap_or_default())
5419
+ )
5069
5420
  }
5070
5421
 
5071
5422
  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)
5423
+ std::env::var("FELTDB_PROPOSAL_PREVIEW_TTL_SECONDS")
5424
+ .ok()
5425
+ .and_then(|value| value.parse().ok())
5426
+ .map(|value: i64| value.clamp(1, 86_400))
5427
+ .unwrap_or(3_600)
5073
5428
  }
5074
5429
 
5075
5430
  fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
@@ -5078,7 +5433,10 @@ fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
5078
5433
 
5079
5434
  fn preview_available(value: &Value) -> bool {
5080
5435
  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())
5436
+ && value
5437
+ .get("expires_at")
5438
+ .and_then(Value::as_i64)
5439
+ .is_some_and(|expires| expires > unix_seconds_i64())
5082
5440
  }
5083
5441
 
5084
5442
  fn proposal_event(
@@ -5115,15 +5473,33 @@ async fn create_proposal(
5115
5473
  Json(input): Json<CreateProposalRequest>,
5116
5474
  ) -> Result<(StatusCode, Json<Value>), ApiError> {
5117
5475
  if input.status.is_some() {
5118
- return Err(inspection_error(StatusCode::BAD_REQUEST, "invalid_proposal", "proposal lifecycle state is server-controlled"));
5476
+ return Err(inspection_error(
5477
+ StatusCode::BAD_REQUEST,
5478
+ "invalid_proposal",
5479
+ "proposal lifecycle state is server-controlled",
5480
+ ));
5119
5481
  }
5120
5482
  if input.proposal_version.unwrap_or(1) != 1 {
5121
- return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", "proposal_version must be 1"));
5483
+ return Err(inspection_error(
5484
+ StatusCode::UNPROCESSABLE_ENTITY,
5485
+ "invalid_proposal",
5486
+ "proposal_version must be 1",
5487
+ ));
5122
5488
  }
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))
5489
+ if input
5490
+ .contract_diff
5491
+ .as_ref()
5492
+ .is_none_or(|value| !value.is_array())
5493
+ || input
5494
+ .source_plan
5495
+ .as_ref()
5496
+ .is_none_or(|value| !value.get("files").is_some_and(Value::is_array))
5125
5497
  {
5126
- return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", "contract_diff and source_plan.files are required arrays"));
5498
+ return Err(inspection_error(
5499
+ StatusCode::UNPROCESSABLE_ENTITY,
5500
+ "invalid_proposal",
5501
+ "contract_diff and source_plan.files are required arrays",
5502
+ ));
5127
5503
  }
5128
5504
  let tenant = application_scope(
5129
5505
  &state,
@@ -5165,6 +5541,46 @@ async fn create_proposal(
5165
5541
  "proposed_flow and base_flow_hash are required".into(),
5166
5542
  ));
5167
5543
  }
5544
+ if input.metadata.get("created_from").and_then(Value::as_str) == Some("module-intent")
5545
+ && input.understanding.is_none()
5546
+ {
5547
+ return Err(inspection_error(
5548
+ StatusCode::UNPROCESSABLE_ENTITY,
5549
+ "understanding_required",
5550
+ "module proposals require an evidence-backed understanding snapshot",
5551
+ ));
5552
+ }
5553
+ if input.understanding.is_some()
5554
+ && (input
5555
+ .evidence_hash
5556
+ .as_deref()
5557
+ .is_none_or(|value| !value.starts_with("sha256:"))
5558
+ || input
5559
+ .understanding_hash
5560
+ .as_deref()
5561
+ .is_none_or(|value| !value.starts_with("sha256:")))
5562
+ {
5563
+ return Err(inspection_error(
5564
+ StatusCode::UNPROCESSABLE_ENTITY,
5565
+ "invalid_understanding",
5566
+ "understanding requires evidence_hash and understanding_hash fingerprints",
5567
+ ));
5568
+ }
5569
+ if let Some(understanding) = input.understanding.as_ref() {
5570
+ if understanding.get("evidence_hash").and_then(Value::as_str)
5571
+ != input.evidence_hash.as_deref()
5572
+ || understanding
5573
+ .get("understanding_hash")
5574
+ .and_then(Value::as_str)
5575
+ != input.understanding_hash.as_deref()
5576
+ {
5577
+ return Err(inspection_error(
5578
+ StatusCode::UNPROCESSABLE_ENTITY,
5579
+ "invalid_understanding",
5580
+ "understanding fingerprints must match the Proposal fingerprints",
5581
+ ));
5582
+ }
5583
+ }
5168
5584
  let id = format!("p_{}", uuid::Uuid::new_v4().simple());
5169
5585
  let now = unix_seconds_i64();
5170
5586
  let mut value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
@@ -5173,6 +5589,7 @@ async fn create_proposal(
5173
5589
  "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
5590
  "created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
5175
5591
  "parent_proposal_id":input.parent_proposal_id,
5592
+ "understanding":input.understanding, "evidence_hash":input.evidence_hash, "understanding_hash":input.understanding_hash,
5176
5593
  "base_module_versions": revision.manifest.modules.iter().filter_map(|module| Some(json!({
5177
5594
  "id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
5178
5595
  }))).collect::<Vec<_>>(),
@@ -5197,35 +5614,92 @@ async fn list_proposals(
5197
5614
  )?;
5198
5615
  let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
5199
5616
  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}")));
5617
+ return Err(inspection_error(
5618
+ StatusCode::UNPROCESSABLE_ENTITY,
5619
+ "invalid_pagination",
5620
+ format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}"),
5621
+ ));
5201
5622
  }
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"));
5623
+ if input.status.as_ref().is_some_and(|status| {
5624
+ !matches!(
5625
+ status.as_str(),
5626
+ "proposed"
5627
+ | "validated"
5628
+ | "previewed"
5629
+ | "approved"
5630
+ | "applied"
5631
+ | "rejected"
5632
+ | "expired"
5633
+ )
5634
+ }) {
5635
+ return Err(inspection_error(
5636
+ StatusCode::UNPROCESSABLE_ENTITY,
5637
+ "unsupported_filter",
5638
+ "unsupported proposal status",
5639
+ ));
5204
5640
  }
5205
- let filter_hash = format!("{}:{}:{}", input.application_id, input.environment, input.status.as_deref().unwrap_or("*"));
5641
+ let filter_hash = format!(
5642
+ "{}:{}:{}",
5643
+ input.application_id,
5644
+ input.environment,
5645
+ input.status.as_deref().unwrap_or("*")
5646
+ );
5206
5647
  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()?;
5648
+ let mut after = input
5649
+ .cursor
5650
+ .as_deref()
5651
+ .map(|cursor| {
5652
+ parse_inspection_cursor(cursor, PROPOSAL_COLLECTION, state_version, &filter_hash)
5653
+ })
5654
+ .transpose()?;
5208
5655
  let mut accepted = Vec::new();
5209
5656
  let mut exhausted = false;
5210
5657
  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; }
5658
+ let page = state
5659
+ .db
5660
+ .list_collection_page(PROPOSAL_COLLECTION, after.as_deref(), 256)?;
5661
+ if page.len() < 256 {
5662
+ exhausted = true;
5663
+ }
5664
+ if page.is_empty() {
5665
+ break;
5666
+ }
5214
5667
  for row in page {
5215
5668
  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; }
5669
+ if !proposal_scope_matches(&row.value, &input.application_id, &input.environment) {
5670
+ continue;
5671
+ }
5672
+ if input.status.as_ref().is_some_and(|status| {
5673
+ row.value.get("status").and_then(Value::as_str) != Some(status)
5674
+ }) {
5675
+ continue;
5676
+ }
5218
5677
  let mut value = row.value;
5219
5678
  redact_inspection_value(&mut value);
5220
5679
  accepted.push((row.key, value));
5221
- if accepted.len() > limit { break; }
5680
+ if accepted.len() > limit {
5681
+ break;
5682
+ }
5222
5683
  }
5223
5684
  }
5224
5685
  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})))
5686
+ let last_key = accepted
5687
+ .get(limit.saturating_sub(1))
5688
+ .map(|entry| entry.0.clone());
5689
+ let proposals = accepted
5690
+ .into_iter()
5691
+ .take(limit)
5692
+ .map(|entry| entry.1)
5693
+ .collect::<Vec<_>>();
5694
+ let next_cursor = if has_more {
5695
+ last_key
5696
+ .map(|key| inspection_cursor(PROPOSAL_COLLECTION, state_version, &key, &filter_hash))
5697
+ } else {
5698
+ None
5699
+ };
5700
+ Ok(Json(
5701
+ json!({"proposals":proposals,"pagination":{"limit":limit,"nextCursor":next_cursor},"state_version":state_version}),
5702
+ ))
5229
5703
  }
5230
5704
  async fn get_proposal(
5231
5705
  State(state): State<AppState>,
@@ -5267,8 +5741,19 @@ async fn proposal_history(
5267
5741
  .map(|row| row.value)
5268
5742
  .filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str()))
5269
5743
  .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); }
5744
+ events.sort_by(|left, right| {
5745
+ left.get("event_sequence")
5746
+ .and_then(Value::as_u64)
5747
+ .cmp(&right.get("event_sequence").and_then(Value::as_u64))
5748
+ .then_with(|| {
5749
+ left.get("id")
5750
+ .and_then(Value::as_str)
5751
+ .cmp(&right.get("id").and_then(Value::as_str))
5752
+ })
5753
+ });
5754
+ for event in &mut events {
5755
+ redact_inspection_value(event);
5756
+ }
5272
5757
  Ok(Json(json!({"events":events})))
5273
5758
  }
5274
5759
  fn move_proposal(
@@ -5318,33 +5803,102 @@ fn move_proposal(
5318
5803
  }
5319
5804
 
5320
5805
  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())
5806
+ value
5807
+ .get("expires_at")
5808
+ .and_then(|expires| expires.as_i64().or_else(|| expires.as_str()?.parse().ok()))
5809
+ .is_some_and(|expires| expires <= unix_seconds_i64())
5322
5810
  }
5323
5811
 
5324
5812
  fn proposal_readiness_value(
5325
5813
  state: &AppState,
5326
5814
  tenant: &str,
5327
5815
  value: &Value,
5816
+ current_evidence_hash: Option<&str>,
5328
5817
  ) -> 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("");
5818
+ let application_id = value
5819
+ .get("application_id")
5820
+ .and_then(Value::as_str)
5821
+ .unwrap_or("");
5822
+ let environment = value
5823
+ .get("environment")
5824
+ .and_then(Value::as_str)
5825
+ .unwrap_or("production");
5826
+ let revision_id = state
5827
+ .applications
5828
+ .pointers(application_id)
5829
+ .get(environment)
5830
+ .cloned()
5831
+ .ok_or_else(|| {
5832
+ inspection_error(
5833
+ StatusCode::NOT_FOUND,
5834
+ "application_not_found",
5835
+ "active application contract not found",
5836
+ )
5837
+ })?;
5838
+ let revision = state
5839
+ .applications
5840
+ .revision(tenant, application_id, &revision_id)
5841
+ .ok_or_else(|| {
5842
+ inspection_error(
5843
+ StatusCode::NOT_FOUND,
5844
+ "application_not_found",
5845
+ "active application revision not found",
5846
+ )
5847
+ })?;
5848
+ let current_contract_hash = revision
5849
+ .manifest
5850
+ .metadata
5851
+ .labels
5852
+ .get("feltdb.contract_hash")
5853
+ .unwrap_or(&revision.manifest_hash)
5854
+ .clone();
5855
+ let current_flow_hash = revision
5856
+ .manifest
5857
+ .metadata
5858
+ .labels
5859
+ .get("feltdb.flow_hash")
5860
+ .cloned();
5861
+ let proposal_contract_hash = value
5862
+ .get("base_contract_hash")
5863
+ .and_then(Value::as_str)
5864
+ .unwrap_or("");
5865
+ let proposal_flow_hash = value
5866
+ .get("base_flow_hash")
5867
+ .and_then(Value::as_str)
5868
+ .unwrap_or("");
5337
5869
  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
5870
  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);
5871
+ let flow_changed = current_flow_hash
5872
+ .as_deref()
5873
+ .is_some_and(|hash| hash != proposal_flow_hash);
5874
+ let modules_changed = value
5875
+ .get("base_module_versions")
5876
+ .and_then(Value::as_array)
5877
+ .is_some_and(|modules| modules != &current_modules);
5878
+ let evidence_changed = current_evidence_hash.is_some_and(|current| {
5879
+ value
5880
+ .get("evidence_hash")
5881
+ .and_then(Value::as_str)
5882
+ .is_some_and(|base| base != current)
5883
+ });
5341
5884
  let expired = proposal_is_expired(value);
5342
5885
  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;
5886
+ if contract_changed {
5887
+ blockers.push("contract_changed");
5888
+ }
5889
+ if flow_changed {
5890
+ blockers.push("flow_changed");
5891
+ }
5892
+ if modules_changed {
5893
+ blockers.push("module_versions_changed");
5894
+ }
5895
+ if evidence_changed {
5896
+ blockers.push("evidence_changed");
5897
+ }
5898
+ if expired {
5899
+ blockers.push("expired");
5900
+ }
5901
+ let stale = contract_changed || flow_changed || modules_changed || evidence_changed;
5348
5902
  Ok(json!({
5349
5903
  "status":value.get("status"), "readiness":if blockers.is_empty(){"ready"}else{"blocked"},
5350
5904
  "blockers":blockers, "notes":[], "current_contract_hash":current_contract_hash,
@@ -5352,19 +5906,163 @@ fn proposal_readiness_value(
5352
5906
  "proposal_flow_hash":proposal_flow_hash, "stale":stale, "expires_at":value.get("expires_at")
5353
5907
  }))
5354
5908
  }
5355
-
5356
- async fn proposal_status(
5909
+
5910
+ async fn proposal_status(
5911
+ State(state): State<AppState>,
5912
+ Extension(principal): Extension<Principal>,
5913
+ Path(id): Path<String>,
5914
+ Query(input): Query<ProposalScopeRequest>,
5915
+ ) -> Result<Json<Value>, ApiError> {
5916
+ let tenant = application_scope(
5917
+ &state,
5918
+ &principal.key_id,
5919
+ &input.application_id,
5920
+ "application:read",
5921
+ )?;
5922
+ let mut value = proposal_value(&state, &id)?;
5923
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5924
+ return Err(inspection_error(
5925
+ StatusCode::NOT_FOUND,
5926
+ "proposal_not_found",
5927
+ "proposal not found",
5928
+ ));
5929
+ }
5930
+ Ok(Json(proposal_readiness_value(
5931
+ &state,
5932
+ &tenant,
5933
+ &value,
5934
+ input.current_evidence_hash.as_deref(),
5935
+ )?))
5936
+ }
5937
+ async fn record_proposal_convergence(
5938
+ State(state): State<AppState>,
5939
+ Extension(principal): Extension<Principal>,
5940
+ Path(id): Path<String>,
5941
+ Json(mut input): Json<Value>,
5942
+ ) -> Result<(StatusCode, Json<Value>), ApiError> {
5943
+ let application_id = input
5944
+ .get("application_id")
5945
+ .and_then(Value::as_str)
5946
+ .ok_or_else(|| {
5947
+ inspection_error(
5948
+ StatusCode::BAD_REQUEST,
5949
+ "invalid_convergence",
5950
+ "application_id is required",
5951
+ )
5952
+ })?
5953
+ .to_string();
5954
+ let environment = input
5955
+ .get("environment")
5956
+ .and_then(Value::as_str)
5957
+ .unwrap_or("production")
5958
+ .to_string();
5959
+ application_scope(
5960
+ &state,
5961
+ &principal.key_id,
5962
+ &application_id,
5963
+ "application:write",
5964
+ )?;
5965
+ let proposal = proposal_value(&state, &id)?;
5966
+ if !proposal_scope_matches(&proposal, &application_id, &environment) {
5967
+ return Err(inspection_error(
5968
+ StatusCode::NOT_FOUND,
5969
+ "proposal_not_found",
5970
+ "proposal not found",
5971
+ ));
5972
+ }
5973
+ if proposal.get("status").and_then(Value::as_str) != Some("applied") {
5974
+ return Err(inspection_error(
5975
+ StatusCode::CONFLICT,
5976
+ "proposal_not_applied",
5977
+ "only an applied Proposal can record convergence",
5978
+ ));
5979
+ }
5980
+ if input.get("proposal_id").and_then(Value::as_str) != Some(id.as_str()) {
5981
+ return Err(inspection_error(
5982
+ StatusCode::UNPROCESSABLE_ENTITY,
5983
+ "invalid_convergence",
5984
+ "proposal_id must match the route",
5985
+ ));
5986
+ }
5987
+ let valid_status = input
5988
+ .get("status")
5989
+ .and_then(Value::as_str)
5990
+ .is_some_and(|value| {
5991
+ matches!(
5992
+ value,
5993
+ "pending"
5994
+ | "checking"
5995
+ | "converged"
5996
+ | "partially_converged"
5997
+ | "diverged"
5998
+ | "blocked"
5999
+ | "unverified"
6000
+ )
6001
+ });
6002
+ if !valid_status
6003
+ || input.get("findings").is_none_or(|value| !value.is_array())
6004
+ || input
6005
+ .get("evidence_hash")
6006
+ .and_then(Value::as_str)
6007
+ .is_none_or(|value| !value.starts_with("sha256:"))
6008
+ {
6009
+ return Err(inspection_error(
6010
+ StatusCode::UNPROCESSABLE_ENTITY,
6011
+ "invalid_convergence",
6012
+ "status, findings, and evidence_hash are required",
6013
+ ));
6014
+ }
6015
+ let convergence_id = format!("cv_{}", uuid::Uuid::new_v4().simple());
6016
+ input["id"] = json!(convergence_id);
6017
+ input["created_at"] = json!(unix_seconds_i64());
6018
+ input["verifier_version"] = json!(1);
6019
+ redact_inspection_value(&mut input);
6020
+ insert_canonical(
6021
+ &state,
6022
+ CONVERGENCE_COLLECTION,
6023
+ &convergence_id,
6024
+ input.clone(),
6025
+ )?;
6026
+ Ok((StatusCode::CREATED, Json(input)))
6027
+ }
6028
+ async fn proposal_convergence(
5357
6029
  State(state): State<AppState>,
5358
6030
  Extension(principal): Extension<Principal>,
5359
6031
  Path(id): Path<String>,
5360
6032
  Query(input): Query<ProposalScopeRequest>,
5361
6033
  ) -> 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"));
6034
+ application_scope(
6035
+ &state,
6036
+ &principal.key_id,
6037
+ &input.application_id,
6038
+ "application:read",
6039
+ )?;
6040
+ let proposal = proposal_value(&state, &id)?;
6041
+ if !proposal_scope_matches(&proposal, &input.application_id, &input.environment) {
6042
+ return Err(inspection_error(
6043
+ StatusCode::NOT_FOUND,
6044
+ "proposal_not_found",
6045
+ "proposal not found",
6046
+ ));
5366
6047
  }
5367
- Ok(Json(proposal_readiness_value(&state, &tenant, &value)?))
6048
+ let mut values = state
6049
+ .db
6050
+ .list_collection(CONVERGENCE_COLLECTION)?
6051
+ .into_iter()
6052
+ .map(|row| row.value)
6053
+ .filter(|value| {
6054
+ value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())
6055
+ && proposal_scope_matches(value, &input.application_id, &input.environment)
6056
+ })
6057
+ .collect::<Vec<_>>();
6058
+ values.sort_by_key(|value| {
6059
+ value
6060
+ .get("created_at")
6061
+ .and_then(Value::as_i64)
6062
+ .unwrap_or_default()
6063
+ });
6064
+ values.reverse();
6065
+ Ok(Json(json!({"convergence":values})))
5368
6066
  }
5369
6067
  async fn validate_proposal(
5370
6068
  State(state): State<AppState>,
@@ -5501,7 +6199,13 @@ async fn validate_proposal(
5501
6199
  value["proposed_contract"] = serde_json::to_value(&proposed.manifest)
5502
6200
  .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
5503
6201
  }
5504
- value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
6202
+ if !value
6203
+ .pointer("/source_plan/files")
6204
+ .and_then(Value::as_array)
6205
+ .is_some_and(|files| !files.is_empty())
6206
+ {
6207
+ value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
6208
+ }
5505
6209
  Ok(Json(move_proposal(
5506
6210
  &state,
5507
6211
  &id,
@@ -5526,7 +6230,8 @@ async fn preview_proposal(
5526
6230
  if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5527
6231
  return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
5528
6232
  }
5529
- let replacing_expired_preview = value.get("status").and_then(Value::as_str) == Some("previewed");
6233
+ let replacing_expired_preview =
6234
+ value.get("status").and_then(Value::as_str) == Some("previewed");
5530
6235
  if replacing_expired_preview {
5531
6236
  if let Some(preview_id) = value.get("preview_id").and_then(Value::as_str) {
5532
6237
  let mut artifact = preview_artifact(&state, preview_id)?;
@@ -5534,42 +6239,131 @@ async fn preview_proposal(
5534
6239
  let mut proposal = value.clone();
5535
6240
  redact_inspection_value(&mut proposal);
5536
6241
  redact_inspection_value(&mut artifact);
5537
- if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
6242
+ if let Some(object) = artifact.as_object_mut() {
6243
+ object.remove("namespace");
6244
+ }
5538
6245
  artifact["proposal"] = proposal;
5539
6246
  return Ok(Json(artifact));
5540
6247
  }
5541
6248
  }
5542
6249
  }
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"));
6250
+ if !replacing_expired_preview
6251
+ && value.get("status").and_then(Value::as_str) != Some("validated")
6252
+ {
6253
+ return Err(inspection_error(
6254
+ StatusCode::CONFLICT,
6255
+ "invalid_proposal_transition",
6256
+ "only a validated proposal can be previewed",
6257
+ ));
5545
6258
  }
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)?;
6259
+ let proposed_manifest: ApplicationManifest =
6260
+ serde_json::from_value(value.get("proposed_contract").cloned().ok_or_else(|| {
6261
+ inspection_error(
6262
+ StatusCode::UNPROCESSABLE_ENTITY,
6263
+ "missing_required_semantics",
6264
+ "validated proposal has no proposed contract snapshot",
6265
+ )
6266
+ })?)
6267
+ .map_err(|error| {
6268
+ inspection_error(
6269
+ StatusCode::UNPROCESSABLE_ENTITY,
6270
+ "invalid_proposal",
6271
+ error.to_string(),
6272
+ )
6273
+ })?;
6274
+ let current_revision_id = state
6275
+ .applications
6276
+ .pointers(&input.application_id)
6277
+ .get(&input.environment)
6278
+ .cloned()
6279
+ .ok_or_else(|| {
6280
+ inspection_error(
6281
+ StatusCode::NOT_FOUND,
6282
+ "application_not_found",
6283
+ "active application contract not found",
6284
+ )
6285
+ })?;
6286
+ let current_revision = state
6287
+ .applications
6288
+ .revision(&tenant, &input.application_id, &current_revision_id)
6289
+ .ok_or_else(|| {
6290
+ inspection_error(
6291
+ StatusCode::NOT_FOUND,
6292
+ "application_not_found",
6293
+ "active application revision not found",
6294
+ )
6295
+ })?;
6296
+ let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
5550
6297
  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})));
6298
+ return Err(ApiError::structured(
6299
+ StatusCode::CONFLICT,
6300
+ json!({"code":"preview_not_ready","message":"proposal cannot be previewed","readiness":readiness}),
6301
+ ));
5552
6302
  }
5553
6303
  let preview_id = format!("pv_{}", uuid::Uuid::new_v4().simple());
5554
6304
  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})))?;
6305
+ let proposed_manifest_hash = manifest_hash(&proposed_manifest).map_err(|error| {
6306
+ inspection_error(
6307
+ StatusCode::UNPROCESSABLE_ENTITY,
6308
+ "preview_contract_invalid",
6309
+ error,
6310
+ )
6311
+ })?;
6312
+ let proposed_revision = ApplicationRevision {
6313
+ revision_id: format!("proposal://{id}"),
6314
+ application_id: input.application_id.clone(),
6315
+ tenant_id: tenant,
6316
+ revision_number: value
6317
+ .get("base_contract_version")
6318
+ .and_then(Value::as_u64)
6319
+ .unwrap_or(current_revision.revision_number)
6320
+ + 1,
6321
+ parent_revision_id: Some(current_revision.revision_id.clone()),
6322
+ manifest_hash: proposed_manifest_hash,
6323
+ manifest: proposed_manifest,
6324
+ created_by: principal.key_id.clone(),
6325
+ created_at: unix_seconds_i64() as u64,
6326
+ status: RevisionStatus::Committed,
6327
+ };
6328
+ let runtime = resolve_runtime(
6329
+ &proposed_revision,
6330
+ &preview_environment,
6331
+ &RuntimeInventory::default(),
6332
+ )
6333
+ .map_err(|report| {
6334
+ ApiError::structured(
6335
+ StatusCode::UNPROCESSABLE_ENTITY,
6336
+ json!({"code":"preview_contract_invalid","validation":report}),
6337
+ )
6338
+ })?;
5561
6339
  let current_contract = proposal_contract_snapshot(&current_revision, &input.environment);
5562
6340
  let mut proposed_contract = proposal_contract_snapshot(&proposed_revision, &input.environment);
5563
6341
  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())));
6342
+ proposed_contract["flow_hash"] = json!(format!(
6343
+ "sha256:{:x}",
6344
+ Sha256::digest(
6345
+ value
6346
+ .get("proposed_flow")
6347
+ .and_then(Value::as_str)
6348
+ .unwrap_or("")
6349
+ .replace("\r\n", "\n")
6350
+ .as_bytes()
6351
+ )
6352
+ ));
5565
6353
  let namespace = runtime.environment.state_namespace.clone();
5566
6354
  let mut seeded_records = Vec::new();
5567
6355
  for collection in &proposed_revision.manifest.collections {
5568
- if collection.name.starts_with("_feltdb") { continue; }
6356
+ if collection.name.starts_with("_feltdb") {
6357
+ continue;
6358
+ }
5569
6359
  let record_id = format!("preview_{}", collection.name.to_ascii_lowercase());
5570
6360
  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}));
6361
+ state
6362
+ .db
6363
+ .capability(&format!("{}:{}", namespace, collection.name))
6364
+ .insert_with_key(&record_id, record)?;
6365
+ seeded_records
6366
+ .push(json!({"collection":collection.name,"id":record_id,"preview_data":true}));
5573
6367
  }
5574
6368
  let now = unix_seconds_i64();
5575
6369
  let mut artifact = json!({
@@ -5585,7 +6379,15 @@ async fn preview_proposal(
5585
6379
  "seeded_records":seeded_records,"data_classification":"synthetic_preview","external_side_effects":"simulated","readiness":readiness
5586
6380
  });
5587
6381
  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())?;
6382
+ insert_canonical(
6383
+ &state,
6384
+ PROPOSAL_PREVIEW_COLLECTION,
6385
+ artifact
6386
+ .get("preview_id")
6387
+ .and_then(Value::as_str)
6388
+ .unwrap_or(""),
6389
+ artifact.clone(),
6390
+ )?;
5589
6391
  value["preview_id"] = artifact.get("preview_id").cloned().unwrap_or(Value::Null);
5590
6392
  value["preview_expires_at"] = artifact.get("expires_at").cloned().unwrap_or(Value::Null);
5591
6393
  let mut stored_proposal = if replacing_expired_preview {
@@ -5595,76 +6397,209 @@ async fn preview_proposal(
5595
6397
  move_proposal(&state, &id, value, "previewed", &principal.key_id)?
5596
6398
  };
5597
6399
  redact_inspection_value(&mut stored_proposal);
5598
- if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
6400
+ if let Some(object) = artifact.as_object_mut() {
6401
+ object.remove("namespace");
6402
+ }
5599
6403
  artifact["proposal"] = stored_proposal;
5600
6404
  Ok(Json(artifact))
5601
6405
  }
5602
6406
 
5603
6407
  async fn get_proposal_preview(
5604
- State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>,
6408
+ State(state): State<AppState>,
6409
+ Extension(principal): Extension<Principal>,
6410
+ Path(id): Path<String>,
5605
6411
  Query(input): Query<ProposalScopeRequest>,
5606
6412
  ) -> Result<Json<Value>, ApiError> {
5607
- let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
6413
+ let tenant = application_scope(
6414
+ &state,
6415
+ &principal.key_id,
6416
+ &input.application_id,
6417
+ "application:read",
6418
+ )?;
5608
6419
  let mut artifact = preview_artifact(&state, &id)?;
5609
6420
  if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
5610
- return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found"));
6421
+ return Err(inspection_error(
6422
+ StatusCode::NOT_FOUND,
6423
+ "preview_not_found",
6424
+ "preview not found",
6425
+ ));
5611
6426
  }
5612
6427
  if !preview_available(&artifact) {
5613
- return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired"));
6428
+ return Err(inspection_error(
6429
+ StatusCode::GONE,
6430
+ "preview_expired",
6431
+ "preview artifact has expired",
6432
+ ));
5614
6433
  }
5615
- let proposal = proposal_value(&state, artifact.get("proposal_id").and_then(Value::as_str).unwrap_or(""))?;
6434
+ let proposal = proposal_value(
6435
+ &state,
6436
+ artifact
6437
+ .get("proposal_id")
6438
+ .and_then(Value::as_str)
6439
+ .unwrap_or(""),
6440
+ )?;
5616
6441
  if proposal.get("preview_id") != artifact.get("preview_id") {
5617
- return Err(inspection_error(StatusCode::CONFLICT, "preview_invalidated", "preview has been replaced"));
6442
+ return Err(inspection_error(
6443
+ StatusCode::CONFLICT,
6444
+ "preview_invalidated",
6445
+ "preview has been replaced",
6446
+ ));
5618
6447
  }
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"));
6448
+ if proposal_readiness_value(&state, &tenant, &proposal, None)?
6449
+ .get("stale")
6450
+ .and_then(Value::as_bool)
6451
+ == Some(true)
6452
+ {
6453
+ return Err(inspection_error(
6454
+ StatusCode::CONFLICT,
6455
+ "preview_invalidated",
6456
+ "the application authority changed after preview creation",
6457
+ ));
5621
6458
  }
5622
6459
  redact_inspection_value(&mut artifact);
5623
- if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
6460
+ if let Some(object) = artifact.as_object_mut() {
6461
+ object.remove("namespace");
6462
+ }
5624
6463
  Ok(Json(artifact))
5625
6464
  }
5626
6465
 
5627
6466
  async fn inspect_proposal_preview_data(
5628
- State(state): State<AppState>, Extension(principal): Extension<Principal>, Path((id, collection)): Path<(String, String)>,
6467
+ State(state): State<AppState>,
6468
+ Extension(principal): Extension<Principal>,
6469
+ Path((id, collection)): Path<(String, String)>,
5629
6470
  Query(input): Query<ProposalScopeRequest>,
5630
6471
  ) -> Result<Json<Value>, ApiError> {
5631
- application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
6472
+ application_scope(
6473
+ &state,
6474
+ &principal.key_id,
6475
+ &input.application_id,
6476
+ "application:read",
6477
+ )?;
5632
6478
  let artifact = preview_artifact(&state, &id)?;
5633
6479
  if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
5634
- return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found"));
6480
+ return Err(inspection_error(
6481
+ StatusCode::NOT_FOUND,
6482
+ "preview_not_found",
6483
+ "preview not found",
6484
+ ));
6485
+ }
6486
+ if !preview_available(&artifact) {
6487
+ return Err(inspection_error(
6488
+ StatusCode::GONE,
6489
+ "preview_expired",
6490
+ "preview artifact has expired",
6491
+ ));
5635
6492
  }
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"));
6493
+ if collection.starts_with("_feltdb")
6494
+ || !artifact
6495
+ .pointer("/proposed/schema")
6496
+ .and_then(Value::as_array)
6497
+ .is_some_and(|collections| {
6498
+ collections
6499
+ .iter()
6500
+ .any(|value| value.get("name").and_then(Value::as_str) == Some(&collection))
6501
+ })
6502
+ {
6503
+ return Err(inspection_error(
6504
+ StatusCode::NOT_FOUND,
6505
+ "collection_not_found",
6506
+ "collection is not part of the proposed contract",
6507
+ ));
5639
6508
  }
5640
6509
  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}"))); }
6510
+ if limit == 0 || limit > MAX_INSPECTION_LIMIT {
6511
+ return Err(inspection_error(
6512
+ StatusCode::UNPROCESSABLE_ENTITY,
6513
+ "invalid_pagination",
6514
+ format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}"),
6515
+ ));
6516
+ }
5642
6517
  let state_version = state.db.sequence()?;
5643
6518
  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))?;
6519
+ let after = input
6520
+ .cursor
6521
+ .as_deref()
6522
+ .map(|cursor| parse_inspection_cursor(cursor, &filter_hash, state_version, "preview-data"))
6523
+ .transpose()?;
6524
+ let capability = format!(
6525
+ "{}:{}",
6526
+ artifact
6527
+ .get("namespace")
6528
+ .and_then(Value::as_str)
6529
+ .unwrap_or(""),
6530
+ collection
6531
+ );
6532
+ let mut rows =
6533
+ state
6534
+ .db
6535
+ .list_collection_page(&capability, after.as_deref(), limit.saturating_add(1))?;
5647
6536
  let has_more = rows.len() > limit;
5648
6537
  rows.truncate(limit);
5649
6538
  let last_key = rows.last().map(|row| row.key.clone());
5650
6539
  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}})))
6540
+ for record in &mut records {
6541
+ redact_inspection_value(record);
6542
+ }
6543
+ let next_cursor = if has_more {
6544
+ last_key.map(|key| inspection_cursor(&filter_hash, state_version, &key, "preview-data"))
6545
+ } else {
6546
+ None
6547
+ };
6548
+ Ok(Json(
6549
+ json!({"preview_id":id,"collection":collection,"records":records,"data_classification":"synthetic_preview","pagination":{"limit":limit,"nextCursor":next_cursor}}),
6550
+ ))
5654
6551
  }
5655
6552
 
5656
6553
  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>,
6554
+ State(state): State<AppState>,
6555
+ Extension(principal): Extension<Principal>,
6556
+ Path(id): Path<String>,
6557
+ Query(input): Query<ProposalScopeRequest>,
6558
+ Json(simulation): Json<PreviewSimulationRequest>,
5659
6559
  ) -> Result<Json<Value>, ApiError> {
5660
- application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
6560
+ application_scope(
6561
+ &state,
6562
+ &principal.key_id,
6563
+ &input.application_id,
6564
+ "application:write",
6565
+ )?;
5661
6566
  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"})))
6567
+ if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
6568
+ return Err(inspection_error(
6569
+ StatusCode::NOT_FOUND,
6570
+ "preview_not_found",
6571
+ "preview not found",
6572
+ ));
6573
+ }
6574
+ if !preview_available(&artifact) {
6575
+ return Err(inspection_error(
6576
+ StatusCode::GONE,
6577
+ "preview_expired",
6578
+ "preview artifact has expired",
6579
+ ));
6580
+ }
6581
+ let known = artifact
6582
+ .get("events")
6583
+ .and_then(Value::as_array)
6584
+ .is_some_and(|events| {
6585
+ events
6586
+ .iter()
6587
+ .any(|event| event.as_str() == Some(&simulation.event))
6588
+ });
6589
+ if !known {
6590
+ return Err(inspection_error(
6591
+ StatusCode::UNPROCESSABLE_ENTITY,
6592
+ "unsupported_preview_event",
6593
+ "event is not part of the proposal",
6594
+ ));
6595
+ }
6596
+ let state_flow = artifact
6597
+ .get("state_flow")
6598
+ .cloned()
6599
+ .unwrap_or_else(|| json!([]));
6600
+ Ok(Json(
6601
+ json!({"preview_id":id,"event":simulation.event,"status":"simulated","external_side_effects":"none","trace":state_flow,"writes":"isolated_preview_namespace_only"}),
6602
+ ))
5668
6603
  }
5669
6604
  async fn transition_proposal(
5670
6605
  State(state): State<AppState>,
@@ -5693,29 +6628,70 @@ async fn transition_proposal(
5693
6628
  ));
5694
6629
  }
5695
6630
  if proposal_is_expired(&value) && input.status != "expired" {
5696
- return Err(inspection_error(StatusCode::CONFLICT, "proposal_expired", "proposal has expired"));
6631
+ return Err(inspection_error(
6632
+ StatusCode::CONFLICT,
6633
+ "proposal_expired",
6634
+ "proposal has expired",
6635
+ ));
5697
6636
  }
5698
6637
  if input.status == "approved" {
5699
- let readiness = proposal_readiness_value(&state, &tenant, &value)?;
6638
+ let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
5700
6639
  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})));
6640
+ return Err(ApiError::structured(
6641
+ StatusCode::CONFLICT,
6642
+ json!({"code":"stale_proposal","message":"proposal is no longer applicable","readiness":readiness}),
6643
+ ));
5702
6644
  }
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"))?;
6645
+ let preview_id = value
6646
+ .get("preview_id")
6647
+ .and_then(Value::as_str)
6648
+ .ok_or_else(|| {
6649
+ inspection_error(
6650
+ StatusCode::CONFLICT,
6651
+ "preview_required",
6652
+ "approval requires a ready preview artifact",
6653
+ )
6654
+ })?;
5704
6655
  let artifact = preview_artifact(&state, preview_id)?;
5705
6656
  let bound = preview_available(&artifact)
5706
6657
  && artifact.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())
5707
6658
  && artifact.get("proposal_contract_hash") == value.get("base_contract_hash")
5708
6659
  && artifact.get("proposal_flow_hash") == value.get("base_flow_hash")
5709
6660
  && 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")); }
6661
+ if !bound {
6662
+ return Err(inspection_error(
6663
+ StatusCode::CONFLICT,
6664
+ "preview_invalid",
6665
+ "approval requires a current preview bound to this proposal",
6666
+ ));
6667
+ }
5711
6668
  }
5712
6669
  if input.status == "applied" {
5713
6670
  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());
6671
+ let supplied = headers
6672
+ .get("FeltDB-Proposal-Authority")
6673
+ .and_then(|value| value.to_str().ok());
6674
+ let repository_apply = headers
6675
+ .get("FeltDB-Repository-Apply")
6676
+ .and_then(|value| value.to_str().ok())
6677
+ == Some("1");
6678
+ let authorized = configured
6679
+ .as_deref()
6680
+ .zip(supplied)
6681
+ .is_some_and(|(expected, actual)| {
6682
+ expected.len() == actual.len()
6683
+ && ring::constant_time::verify_slices_are_equal(
6684
+ expected.as_bytes(),
6685
+ actual.as_bytes(),
6686
+ )
6687
+ .is_ok()
6688
+ });
5717
6689
  if !repository_apply || !authorized {
5718
- return Err(inspection_error(StatusCode::FORBIDDEN, "repository_authority_required", "valid repository application authority is required"));
6690
+ return Err(inspection_error(
6691
+ StatusCode::FORBIDDEN,
6692
+ "repository_authority_required",
6693
+ "valid repository application authority is required",
6694
+ ));
5719
6695
  }
5720
6696
  }
5721
6697
  if input.status == "approved"
@@ -5736,37 +6712,59 @@ async fn transition_proposal(
5736
6712
  ));
5737
6713
  }
5738
6714
  if input.status == "rejected" {
5739
- if let Some(reason) = input.reason { value["rejection_reason"] = json!(reason); }
6715
+ if let Some(reason) = input.reason {
6716
+ value["rejection_reason"] = json!(reason);
6717
+ }
5740
6718
  }
5741
- let mut moved = move_proposal(
5742
- &state,
5743
- &id,
5744
- value,
5745
- &input.status,
5746
- &principal.key_id,
5747
- )?;
6719
+ let mut moved = move_proposal(&state, &id, value, &input.status, &principal.key_id)?;
5748
6720
  redact_inspection_value(&mut moved);
5749
6721
  Ok(Json(moved))
5750
6722
  }
5751
6723
 
5752
6724
  async fn approve_proposal(
5753
- state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
5754
- path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
6725
+ state: State<AppState>,
6726
+ principal: Extension<Principal>,
6727
+ headers: HeaderMap,
6728
+ path: Path<String>,
6729
+ Json(input): Json<ProposalLifecycleRequest>,
5755
6730
  ) -> 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
6731
+ transition_proposal(
6732
+ state,
6733
+ principal,
6734
+ headers,
6735
+ path,
6736
+ Json(ProposalStatusRequest {
6737
+ application_id: input.application_id,
6738
+ environment: input.environment,
6739
+ status: "approved".into(),
6740
+ approve_authorization: input.approve_authorization,
6741
+ reason: input.reason,
6742
+ }),
6743
+ )
6744
+ .await
5760
6745
  }
5761
6746
 
5762
6747
  async fn reject_proposal(
5763
- state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
5764
- path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
6748
+ state: State<AppState>,
6749
+ principal: Extension<Principal>,
6750
+ headers: HeaderMap,
6751
+ path: Path<String>,
6752
+ Json(input): Json<ProposalLifecycleRequest>,
5765
6753
  ) -> 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
6754
+ transition_proposal(
6755
+ state,
6756
+ principal,
6757
+ headers,
6758
+ path,
6759
+ Json(ProposalStatusRequest {
6760
+ application_id: input.application_id,
6761
+ environment: input.environment,
6762
+ status: "rejected".into(),
6763
+ approve_authorization: false,
6764
+ reason: input.reason,
6765
+ }),
6766
+ )
6767
+ .await
5770
6768
  }
5771
6769
  fn auth_response(
5772
6770
  identity: feltdb_server::identity::Identity,
@@ -5795,19 +6793,18 @@ async fn auth_sign_up(
5795
6793
  "application not found".into(),
5796
6794
  ));
5797
6795
  }
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
- })?;
6796
+ let (identity, session, token) =
6797
+ match state
6798
+ .identities
6799
+ .sign_up(&input.email, &input.password, input.display_name)
6800
+ {
6801
+ Ok(created) => created,
6802
+ Err(error) if error == "identity already exists" => state
6803
+ .identities
6804
+ .sign_in(&input.email, &input.password)
6805
+ .map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?,
6806
+ Err(error) => return Err(ApiError(StatusCode::BAD_REQUEST, error)),
6807
+ };
5811
6808
  state
5812
6809
  .tenancy
5813
6810
  .register_platform_user(
@@ -5829,6 +6826,25 @@ async fn auth_sign_in(
5829
6826
  .identities
5830
6827
  .sign_in(&input.email, &input.password)
5831
6828
  .map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
6829
+ if let Some(application_id) = input.application_id {
6830
+ if state.tenancy.application_tenant(&application_id).is_none() {
6831
+ return Err(ApiError(
6832
+ StatusCode::NOT_FOUND,
6833
+ "application not found".into(),
6834
+ ));
6835
+ }
6836
+ state
6837
+ .tenancy
6838
+ .register_platform_user(
6839
+ &identity.subject_id,
6840
+ identity.email.as_deref().unwrap_or(&identity.identity_id),
6841
+ )
6842
+ .map_err(control_error)?;
6843
+ state
6844
+ .tenancy
6845
+ .register_application_actor(&identity.subject_id, &application_id)
6846
+ .map_err(control_error)?;
6847
+ }
5832
6848
  Ok(auth_response(identity, session, token))
5833
6849
  }
5834
6850
  async fn auth_session(Extension(principal): Extension<Principal>) -> Json<Value> {
@@ -5872,6 +6888,13 @@ async fn application_openapi() -> Json<Value> {
5872
6888
  "/query": { "post": { "responses": { "200": { "description": "Bounded authorized query" } } } },
5873
6889
  "/transactions": { "post": { "responses": { "200": { "description": "Atomic idempotent mutation" } } } },
5874
6890
  "/events": { "get": { "responses": { "200": { "description": "Application event stream" } } } },
6891
+ "/intents": {
6892
+ "get": { "responses": { "200": { "description": "Durable application Intents" } } },
6893
+ "post": { "parameters": [{ "name":"Idempotency-Key", "in":"header", "required":false, "schema":{"type":"string"} }], "responses": { "200": { "description": "Canonical Intent execution result" } } }
6894
+ },
6895
+ "/intents/{id}": { "get": { "responses": { "200": { "description": "Canonical Intent" } } } },
6896
+ "/intents/{id}/{artifact}": { "get": { "responses": { "200": { "description": "Intent understanding, plan, Proposal, or history" } } } },
6897
+ "/proposals": { "get": { "responses": { "200": { "description": "Durable Proposals" } } }, "post": { "responses": { "201": { "description": "Created Proposal" } } } },
5875
6898
  "/workflows/{name}/run": { "post": { "responses": { "201": { "description": "Workflow execution" } } } },
5876
6899
  "/agents/{name}/run": { "post": { "responses": { "201": { "description": "Agent execution" } } } },
5877
6900
  "/capabilities/{name}/run": { "post": { "responses": { "200": { "description": "Public capability result" } } } }
@@ -7199,7 +8222,9 @@ fn validate_production_configuration(config: &Config) -> Result<(), Box<dyn std:
7199
8222
  Ok(())
7200
8223
  }
7201
8224
 
7202
- #[tokio::main]
8225
+ // Durable stores currently perform bounded synchronous filesystem operations.
8226
+ // Keep independent health and API requests schedulable on single-vCPU hosts.
8227
+ #[tokio::main(flavor = "multi_thread", worker_threads = 4)]
7203
8228
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
7204
8229
  tracing_subscriber::fmt()
7205
8230
  .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
@@ -7394,6 +8419,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7394
8419
  .patch(update_application_control)
7395
8420
  .delete(delete_application_control),
7396
8421
  )
8422
+ .route(
8423
+ "/api/applications/{application_id}/qualification/destroy",
8424
+ axum::routing::post(delete_qualification_application_control),
8425
+ )
7397
8426
  .route(
7398
8427
  "/api/applications/{application_id}/members",
7399
8428
  get(list_application_members),
@@ -7547,6 +8576,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7547
8576
  .route("/v1/application/schema", get(inspect_application_schema))
7548
8577
  .route("/v1/data/{collection}", get(inspect_collection))
7549
8578
  .route("/v1/data/{collection}/{id}", get(inspect_record))
8579
+ .route("/v1/intents", get(list_intents).post(execute_intent))
8580
+ .route("/v1/intents/{id}", get(get_intent_root))
8581
+ .route("/v1/intents/{id}/{operation}", get(get_intent))
7550
8582
  .route("/v1/proposals", get(list_proposals).post(create_proposal))
7551
8583
  .route("/v1/proposals/{id}", get(get_proposal))
7552
8584
  .route("/v1/proposals/{id}/history", get(proposal_history))
@@ -7570,9 +8602,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7570
8602
  "/v1/proposals/{id}/status",
7571
8603
  get(proposal_status).patch(transition_proposal),
7572
8604
  )
8605
+ .route(
8606
+ "/v1/proposals/{id}/convergence",
8607
+ get(proposal_convergence).post(record_proposal_convergence),
8608
+ )
7573
8609
  .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))
8610
+ .route(
8611
+ "/v1/previews/{id}/data/{collection}",
8612
+ get(inspect_proposal_preview_data),
8613
+ )
8614
+ .route(
8615
+ "/v1/previews/{id}/simulate",
8616
+ axum::routing::post(simulate_proposal_preview),
8617
+ )
7576
8618
  .route("/v1/auth/session", get(auth_session))
7577
8619
  .route("/v1/auth/signout", axum::routing::post(auth_sign_out))
7578
8620
  .route("/v1/events", get(events))
@@ -7956,6 +8998,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7956
8998
  .route("/v1/openapi.json", get(application_openapi))
7957
8999
  .route("/v1/auth/signup", axum::routing::post(auth_sign_up))
7958
9000
  .route("/v1/auth/signin", axum::routing::post(auth_sign_in))
9001
+ .route("/internal/intents/by-hash", get(internal_find_intent))
9002
+ .route(
9003
+ "/internal/intents",
9004
+ axum::routing::post(internal_create_intent),
9005
+ )
9006
+ .route("/internal/intents/{id}", put(internal_replace_intent))
9007
+ .route(
9008
+ "/internal/intent-events",
9009
+ axum::routing::post(internal_create_intent_event),
9010
+ )
7959
9011
  .route("/ready", get(readiness))
7960
9012
  .route("/runtime", get(runtime))
7961
9013
  .route("/metrics", get(network_metrics))
@@ -8036,7 +9088,14 @@ async fn authenticate(
8036
9088
  .and_then(|header| header.to_str().ok())
8037
9089
  .and_then(|header| header.strip_prefix("Bearer "));
8038
9090
  let machine = token.and_then(|token| state.keys.authenticate(token, &state.namespace));
8039
- let actor = token.and_then(|token| state.identities.authenticate_session(token));
9091
+ // A valid service key is already authoritative. Do not feed that
9092
+ // high-entropy bearer token through the Argon2-backed human-session
9093
+ // verifier as well; doing so blocks the request worker needlessly.
9094
+ let actor = if machine.is_none() {
9095
+ token.and_then(|token| state.identities.authenticate_session(token))
9096
+ } else {
9097
+ None
9098
+ };
8040
9099
  let human = request
8041
9100
  .headers()
8042
9101
  .get(COOKIE)
@@ -10637,10 +11696,9 @@ fn put_canonical(
10637
11696
 
10638
11697
  /// The authority's current committed-state revision.
10639
11698
  ///
10640
- /// This is the value `apply_atomic_transaction` advances and writes into each
10641
- /// durable transaction record as `state_after`. It already survived restart and
10642
- /// already refused to move backwards; the only thing missing was a way to read
10643
- /// it without performing a write, which is what a cache needs.
11699
+ /// This is the commit-level value `apply_atomic_transaction` advances exactly
11700
+ /// once per successful transaction. It survives restart and never moves
11701
+ /// backwards.
10644
11702
  ///
10645
11703
  /// `scope` is the store's instance id. A revision is comparable only against
10646
11704
  /// another revision carrying the same scope, so a client that fails over to a
@@ -10654,7 +11712,7 @@ struct RevisionResponse {
10654
11712
 
10655
11713
  async fn get_revision(State(state): State<AppState>) -> Result<Json<RevisionResponse>, ApiError> {
10656
11714
  Ok(Json(RevisionResponse {
10657
- revision: state.db.sequence()?,
11715
+ revision: state.db.current_revision()?,
10658
11716
  scope: state.db.instance_id()?,
10659
11717
  }))
10660
11718
  }
@@ -10937,7 +11995,12 @@ async fn list_records(
10937
11995
  State(state): State<AppState>,
10938
11996
  Path(collection): Path<String>,
10939
11997
  ) -> Result<Json<Vec<RecordResponse>>, ApiError> {
10940
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
11998
+ if collection == PROPOSAL_COLLECTION
11999
+ || collection == PROPOSAL_EVENT_COLLECTION
12000
+ || collection == CONVERGENCE_COLLECTION
12001
+ || collection == INTENT_COLLECTION
12002
+ || collection == INTENT_EVENT_COLLECTION
12003
+ {
10941
12004
  return Err(ApiError(
10942
12005
  StatusCode::FORBIDDEN,
10943
12006
  "system collection requires proposal service".into(),
@@ -10952,7 +12015,12 @@ async fn get_record(
10952
12015
  State(state): State<AppState>,
10953
12016
  Path((collection, id)): Path<(String, String)>,
10954
12017
  ) -> Result<Json<RecordResponse>, ApiError> {
10955
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
12018
+ if collection == PROPOSAL_COLLECTION
12019
+ || collection == PROPOSAL_EVENT_COLLECTION
12020
+ || collection == CONVERGENCE_COLLECTION
12021
+ || collection == INTENT_COLLECTION
12022
+ || collection == INTENT_EVENT_COLLECTION
12023
+ {
10956
12024
  return Err(ApiError(
10957
12025
  StatusCode::FORBIDDEN,
10958
12026
  "system collection requires proposal service".into(),
@@ -11034,6 +12102,9 @@ struct AtomicTxRequest {
11034
12102
  #[serde(rename_all = "camelCase")]
11035
12103
  struct AtomicTxResponse {
11036
12104
  transaction_id: String,
12105
+ base_revision: u64,
12106
+ commit_revision: u64,
12107
+ status: feltdb::TransactionStatus,
11037
12108
  /// True when this id had already been committed; nothing was reapplied.
11038
12109
  duplicate: bool,
11039
12110
  operations: usize,
@@ -11185,6 +12256,9 @@ async fn commit_transaction(
11185
12256
  },
11186
12257
  Json(AtomicTxResponse {
11187
12258
  transaction_id: commit.transaction_id,
12259
+ base_revision: commit.base_revision,
12260
+ commit_revision: commit.commit_revision,
12261
+ status: commit.status,
11188
12262
  duplicate: commit.duplicate,
11189
12263
  operations,
11190
12264
  state_before: commit.state_before,
@@ -11198,7 +12272,12 @@ async fn create_record(
11198
12272
  Path(collection): Path<String>,
11199
12273
  Json(mut record): Json<CreateRecord>,
11200
12274
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
11201
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
12275
+ if collection == PROPOSAL_COLLECTION
12276
+ || collection == PROPOSAL_EVENT_COLLECTION
12277
+ || collection == CONVERGENCE_COLLECTION
12278
+ || collection == INTENT_COLLECTION
12279
+ || collection == INTENT_EVENT_COLLECTION
12280
+ {
11202
12281
  return Err(ApiError(
11203
12282
  StatusCode::FORBIDDEN,
11204
12283
  "system collection requires proposal service".into(),
@@ -11237,7 +12316,12 @@ async fn update_record(
11237
12316
  Path((collection, id)): Path<(String, String)>,
11238
12317
  Json(changes): Json<Map<String, Value>>,
11239
12318
  ) -> Result<Json<RecordResponse>, ApiError> {
11240
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
12319
+ if collection == PROPOSAL_COLLECTION
12320
+ || collection == PROPOSAL_EVENT_COLLECTION
12321
+ || collection == CONVERGENCE_COLLECTION
12322
+ || collection == INTENT_COLLECTION
12323
+ || collection == INTENT_EVENT_COLLECTION
12324
+ {
11241
12325
  return Err(ApiError(
11242
12326
  StatusCode::FORBIDDEN,
11243
12327
  "system collection requires proposal service".into(),
@@ -11358,7 +12442,12 @@ async fn delete_record(
11358
12442
  State(state): State<AppState>,
11359
12443
  Path((collection, id)): Path<(String, String)>,
11360
12444
  ) -> Result<StatusCode, ApiError> {
11361
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
12445
+ if collection == PROPOSAL_COLLECTION
12446
+ || collection == PROPOSAL_EVENT_COLLECTION
12447
+ || collection == CONVERGENCE_COLLECTION
12448
+ || collection == INTENT_COLLECTION
12449
+ || collection == INTENT_EVENT_COLLECTION
12450
+ {
11362
12451
  return Err(ApiError(
11363
12452
  StatusCode::FORBIDDEN,
11364
12453
  "system collection requires proposal service".into(),