@feltdb/core 0.8.1 → 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 (25) hide show
  1. package/dist/create/package-versions.js +1 -1
  2. package/dist/create/server-source/crates/feltdb/src/application.rs +19 -0
  3. package/dist/create/server-source/crates/feltdb/src/authority.rs +349 -0
  4. package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +32 -0
  5. package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority.rs +19 -0
  6. package/dist/create/server-source/crates/feltdb/src/lib.rs +444 -189
  7. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +244 -51
  8. package/dist/create/server-source/crates/feltdb/tests/authority_process.rs +297 -0
  9. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +41 -11
  10. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +8 -3
  11. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1164 -225
  12. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +134 -0
  13. package/dist/http-db.d.ts +3 -0
  14. package/dist/http-db.d.ts.map +1 -1
  15. package/dist/http-db.js +2 -1
  16. package/dist/state-contract.d.ts +4 -1
  17. package/dist/state-contract.d.ts.map +1 -1
  18. package/dist/state-contract.js +1 -1
  19. package/dist/studio-app/assets/{feltdb_wasm-bIqcRzAr.js → feltdb_wasm-DB8cX151.js} +1 -1
  20. package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
  21. package/dist/studio-app/assets/{index-XGYdlElN.js → index-B0k4UAlI.js} +1 -1
  22. package/dist/studio-app/index.html +1 -1
  23. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  24. package/package.json +1 -1
  25. package/dist/studio-app/assets/feltdb_wasm_bg-BE79okwX.wasm +0 -0
@@ -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>,
233
239
  }
234
240
  #[derive(Deserialize)]
241
+ struct QualificationDestroyRequest {
242
+ proof_run_id: String,
243
+ }
244
+ #[derive(Deserialize)]
245
+ #[serde(deny_unknown_fields)]
235
246
  struct UpdateApplicationRequest {
236
247
  name: String,
237
248
  }
@@ -2348,7 +2359,12 @@ async fn create_application_control(
2348
2359
  ) -> Result<(StatusCode, Json<Value>), ApiError> {
2349
2360
  let app = state
2350
2361
  .tenancy
2351
- .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
+ )
2352
2368
  .map_err(control_error)?;
2353
2369
  Ok((StatusCode::CREATED, Json(json!(app))))
2354
2370
  }
@@ -2378,6 +2394,23 @@ async fn delete_application_control(
2378
2394
  Ok(StatusCode::NO_CONTENT)
2379
2395
  }
2380
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
+
2381
2414
  async fn delete_certification_fixture(
2382
2415
  State(state): State<AppState>,
2383
2416
  Path(application_id): Path<String>,
@@ -5083,52 +5116,292 @@ const INTENT_COLLECTION: &str = "_feltdb_intents";
5083
5116
  const INTENT_EVENT_COLLECTION: &str = "_feltdb_intent_events";
5084
5117
 
5085
5118
  fn require_intent_service(headers: &HeaderMap) -> Result<(), ApiError> {
5086
- let expected = std::env::var("FELTDB_INTENT_SERVICE_SECRET").map_err(|_| inspection_error(StatusCode::SERVICE_UNAVAILABLE, "intent_service_unconfigured", "Intent Engine service authentication is not configured"))?;
5087
- let supplied = headers.get("x-feltdb-intent-service").and_then(|value| value.to_str().ok()).unwrap_or_default();
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();
5088
5130
  if Sha256::digest(supplied.as_bytes()) != Sha256::digest(expected.as_bytes()) {
5089
- return Err(inspection_error(StatusCode::FORBIDDEN, "forbidden", "invalid Intent Engine service credential"));
5131
+ return Err(inspection_error(
5132
+ StatusCode::FORBIDDEN,
5133
+ "forbidden",
5134
+ "invalid Intent Engine service credential",
5135
+ ));
5090
5136
  }
5091
5137
  Ok(())
5092
5138
  }
5093
5139
 
5094
5140
  async fn execute_intent(
5095
- State(state): State<AppState>, Extension(principal): Extension<Principal>, headers: HeaderMap,
5141
+ State(state): State<AppState>,
5142
+ Extension(principal): Extension<Principal>,
5143
+ headers: HeaderMap,
5096
5144
  Json(input): Json<ExecuteIntentRequest>,
5097
5145
  ) -> Result<Json<Value>, ApiError> {
5098
- application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
5099
- if input.request.trim().is_empty() { return Err(inspection_error(StatusCode::BAD_REQUEST, "invalid_intent", "request is required")); }
5100
- let endpoint = std::env::var("FELTDB_INTENT_ENGINE_URL").map_err(|_| inspection_error(StatusCode::SERVICE_UNAVAILABLE, "intent_engine_unavailable", "managed Intent Engine is not configured"))?;
5101
- let secret = std::env::var("FELTDB_INTENT_SERVICE_SECRET").map_err(|_| inspection_error(StatusCode::SERVICE_UNAVAILABLE, "intent_engine_unavailable", "managed Intent Engine authentication is not configured"))?;
5102
- let mut request = state.peer_client.post(format!("{}/internal/v1/intents", endpoint.trim_end_matches('/'))).header("x-feltdb-intent-service", secret).json(&input);
5103
- if let Some(value) = headers.get("idempotency-key") { request = request.header("idempotency-key", value); }
5104
- let response = request.send().await.map_err(|error| inspection_error(StatusCode::BAD_GATEWAY, "intent_engine_unavailable", error.to_string()))?;
5105
- let status = response.status(); let value: Value = response.json().await.map_err(|error| inspection_error(StatusCode::BAD_GATEWAY, "invalid_intent_engine_response", error.to_string()))?;
5106
- if !status.is_success() { let republish=value.get("error").and_then(Value::as_str)==Some("APPLICATION_REPUBLISH_REQUIRED");return Err(inspection_error(if republish {StatusCode::CONFLICT}else{StatusCode::BAD_GATEWAY},if republish{"application_republish_required"}else{"intent_execution_failed"},value.get("message").and_then(Value::as_str).unwrap_or("Intent Engine failed"))); }
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
+ }
5107
5219
  Ok(Json(value))
5108
5220
  }
5109
5221
 
5110
- async fn list_intents(State(state): State<AppState>, Extension(principal): Extension<Principal>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
5111
- application_scope(&state,&principal.key_id,&input.application_id,"application:read")?;
5112
- let mut intents=state.db.list_collection(INTENT_COLLECTION)?.into_iter().map(|row|row.value).filter(|value|proposal_scope_matches(value,&input.application_id,&input.environment)).collect::<Vec<_>>();
5113
- intents.sort_by(|a,b|a.get("created_at").and_then(Value::as_str).cmp(&b.get("created_at").and_then(Value::as_str)));for value in &mut intents{if let Some(object)=value.as_object_mut(){object.remove("execution_result");}redact_inspection_value(value);}Ok(Json(json!({"intents":intents})))
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))
5114
5301
  }
5115
- async fn get_intent(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path((id,operation)): Path<(String,String)>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>,ApiError>{
5116
- application_scope(&state,&principal.key_id,&input.application_id,"application:read")?;let intent=required_value(&state,INTENT_COLLECTION,&id,"intent not found")?;if !proposal_scope_matches(&intent,&input.application_id,&input.environment){return Err(ApiError(StatusCode::NOT_FOUND,"intent not found".into()));}
5117
- let value=match operation.as_str(){"understanding"=>intent.get("understanding").cloned().unwrap_or(Value::Null),"plan"=>intent.get("plan").cloned().unwrap_or(Value::Null),"proposal"=>intent.get("proposal_id").and_then(Value::as_str).map(|id|proposal_value(&state,id)).transpose()?.unwrap_or(Value::Null),"history"=>{let mut events=state.db.list_collection(INTENT_EVENT_COLLECTION)?.into_iter().map(|row|row.value).filter(|value|value.get("intent_id").and_then(Value::as_str)==Some(id.as_str())).collect::<Vec<_>>();events.sort_by(|a,b|a.get("created_at").and_then(Value::as_str).cmp(&b.get("created_at").and_then(Value::as_str)));json!({"events":events})},_=>return Err(ApiError(StatusCode::NOT_FOUND,"intent resource not found".into()))};Ok(Json(value))
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)))
5118
5383
  }
5119
- async fn get_intent_root(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id):Path<String>, Query(input):Query<ProposalScopeRequest>)->Result<Json<Value>,ApiError>{application_scope(&state,&principal.key_id,&input.application_id,"application:read")?;let mut value=required_value(&state,INTENT_COLLECTION,&id,"intent not found")?;if !proposal_scope_matches(&value,&input.application_id,&input.environment){return Err(ApiError(StatusCode::NOT_FOUND,"intent not found".into()));}if let Some(object)=value.as_object_mut(){object.remove("execution_result");}Ok(Json(value))}
5120
- async fn internal_find_intent(State(state):State<AppState>,headers:HeaderMap,Query(input):Query<IntentHashRequest>)->Result<Json<Value>,ApiError>{require_intent_service(&headers)?;let value=state.db.list_collection(INTENT_COLLECTION)?.into_iter().map(|row|row.value).find(|value|proposal_scope_matches(value,&input.application_id,&input.environment)&&value.get("intent_hash").and_then(Value::as_str)==Some(input.intent_hash.as_str())).unwrap_or(Value::Null);Ok(Json(value))}
5121
- async fn internal_create_intent(State(state):State<AppState>,headers:HeaderMap,Json(value):Json<Value>)->Result<(StatusCode,Json<Value>),ApiError>{require_intent_service(&headers)?;let id=value.get("id").and_then(Value::as_str).ok_or_else(||inspection_error(StatusCode::BAD_REQUEST,"invalid_intent","intent id is required"))?;insert_canonical(&state,INTENT_COLLECTION,id,value.clone())?;Ok((StatusCode::CREATED,Json(value)))}
5122
- async fn internal_replace_intent(State(state):State<AppState>,headers:HeaderMap,Path(id):Path<String>,Json(value):Json<Value>)->Result<Json<Value>,ApiError>{require_intent_service(&headers)?;let _=put_canonical(&state,INTENT_COLLECTION,&id,value.clone())?;Ok(Json(value))}
5123
- async fn internal_create_intent_event(State(state):State<AppState>,headers:HeaderMap,Json(value):Json<Value>)->Result<(StatusCode,Json<Value>),ApiError>{require_intent_service(&headers)?;let id=value.get("id").and_then(Value::as_str).ok_or_else(||inspection_error(StatusCode::BAD_REQUEST,"invalid_intent_event","event id is required"))?;insert_canonical(&state,INTENT_EVENT_COLLECTION,id,value.clone())?;Ok((StatusCode::CREATED,Json(value)))}
5124
5384
 
5125
- fn proposal_contract_snapshot(
5126
- revision: &ApplicationRevision,
5127
- environment: &str,
5128
- ) -> Value {
5129
- let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
5130
- let flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash").cloned();
5131
- let dsl_version = revision.manifest.metadata.labels.get("feltdb.dsl_version").and_then(|value| value.parse::<u32>().ok()).unwrap_or(1);
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);
5132
5405
  json!({
5133
5406
  "application":{"application_id":revision.application_id,"environment":environment},
5134
5407
  "contract_version":revision.revision_number,"contract_hash":contract_hash,"flow_hash":flow_hash,
@@ -5140,11 +5413,18 @@ fn proposal_contract_snapshot(
5140
5413
  }
5141
5414
 
5142
5415
  fn proposal_hash(value: &Value) -> String {
5143
- 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
+ )
5144
5420
  }
5145
5421
 
5146
5422
  fn preview_ttl_seconds() -> i64 {
5147
- 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)
5148
5428
  }
5149
5429
 
5150
5430
  fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
@@ -5153,7 +5433,10 @@ fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
5153
5433
 
5154
5434
  fn preview_available(value: &Value) -> bool {
5155
5435
  value.get("status").and_then(Value::as_str) == Some("ready")
5156
- && 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())
5157
5440
  }
5158
5441
 
5159
5442
  fn proposal_event(
@@ -5190,15 +5473,33 @@ async fn create_proposal(
5190
5473
  Json(input): Json<CreateProposalRequest>,
5191
5474
  ) -> Result<(StatusCode, Json<Value>), ApiError> {
5192
5475
  if input.status.is_some() {
5193
- 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
+ ));
5194
5481
  }
5195
5482
  if input.proposal_version.unwrap_or(1) != 1 {
5196
- 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
+ ));
5197
5488
  }
5198
- if input.contract_diff.as_ref().is_none_or(|value| !value.is_array())
5199
- || 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))
5200
5497
  {
5201
- 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
+ ));
5202
5503
  }
5203
5504
  let tenant = application_scope(
5204
5505
  &state,
@@ -5243,19 +5544,41 @@ async fn create_proposal(
5243
5544
  if input.metadata.get("created_from").and_then(Value::as_str) == Some("module-intent")
5244
5545
  && input.understanding.is_none()
5245
5546
  {
5246
- return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "understanding_required", "module proposals require an evidence-backed understanding snapshot"));
5547
+ return Err(inspection_error(
5548
+ StatusCode::UNPROCESSABLE_ENTITY,
5549
+ "understanding_required",
5550
+ "module proposals require an evidence-backed understanding snapshot",
5551
+ ));
5247
5552
  }
5248
5553
  if input.understanding.is_some()
5249
- && (input.evidence_hash.as_deref().is_none_or(|value| !value.starts_with("sha256:"))
5250
- || input.understanding_hash.as_deref().is_none_or(|value| !value.starts_with("sha256:")))
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:")))
5251
5562
  {
5252
- return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_understanding", "understanding requires evidence_hash and understanding_hash fingerprints"));
5563
+ return Err(inspection_error(
5564
+ StatusCode::UNPROCESSABLE_ENTITY,
5565
+ "invalid_understanding",
5566
+ "understanding requires evidence_hash and understanding_hash fingerprints",
5567
+ ));
5253
5568
  }
5254
5569
  if let Some(understanding) = input.understanding.as_ref() {
5255
- if understanding.get("evidence_hash").and_then(Value::as_str) != input.evidence_hash.as_deref()
5256
- || understanding.get("understanding_hash").and_then(Value::as_str) != input.understanding_hash.as_deref()
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()
5257
5576
  {
5258
- return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_understanding", "understanding fingerprints must match the Proposal fingerprints"));
5577
+ return Err(inspection_error(
5578
+ StatusCode::UNPROCESSABLE_ENTITY,
5579
+ "invalid_understanding",
5580
+ "understanding fingerprints must match the Proposal fingerprints",
5581
+ ));
5259
5582
  }
5260
5583
  }
5261
5584
  let id = format!("p_{}", uuid::Uuid::new_v4().simple());
@@ -5291,35 +5614,92 @@ async fn list_proposals(
5291
5614
  )?;
5292
5615
  let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
5293
5616
  if limit == 0 || limit > MAX_INSPECTION_LIMIT {
5294
- 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
+ ));
5295
5622
  }
5296
- if input.status.as_ref().is_some_and(|status| !matches!(status.as_str(), "proposed"|"validated"|"previewed"|"approved"|"applied"|"rejected"|"expired")) {
5297
- 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
+ ));
5298
5640
  }
5299
- 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
+ );
5300
5647
  let state_version = state.db.sequence()?;
5301
- 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()?;
5302
5655
  let mut accepted = Vec::new();
5303
5656
  let mut exhausted = false;
5304
5657
  while accepted.len() <= limit && !exhausted {
5305
- let page = state.db.list_collection_page(PROPOSAL_COLLECTION, after.as_deref(), 256)?;
5306
- if page.len() < 256 { exhausted = true; }
5307
- 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
+ }
5308
5667
  for row in page {
5309
5668
  after = Some(row.key.clone());
5310
- if !proposal_scope_matches(&row.value, &input.application_id, &input.environment) { continue; }
5311
- 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
+ }
5312
5677
  let mut value = row.value;
5313
5678
  redact_inspection_value(&mut value);
5314
5679
  accepted.push((row.key, value));
5315
- if accepted.len() > limit { break; }
5680
+ if accepted.len() > limit {
5681
+ break;
5682
+ }
5316
5683
  }
5317
5684
  }
5318
5685
  let has_more = accepted.len() > limit;
5319
- let last_key = accepted.get(limit.saturating_sub(1)).map(|entry| entry.0.clone());
5320
- let proposals = accepted.into_iter().take(limit).map(|entry| entry.1).collect::<Vec<_>>();
5321
- let next_cursor = if has_more { last_key.map(|key| inspection_cursor(PROPOSAL_COLLECTION, state_version, &key, &filter_hash)) } else { None };
5322
- 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
+ ))
5323
5703
  }
5324
5704
  async fn get_proposal(
5325
5705
  State(state): State<AppState>,
@@ -5361,8 +5741,19 @@ async fn proposal_history(
5361
5741
  .map(|row| row.value)
5362
5742
  .filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str()))
5363
5743
  .collect();
5364
- 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))));
5365
- 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
+ }
5366
5757
  Ok(Json(json!({"events":events})))
5367
5758
  }
5368
5759
  fn move_proposal(
@@ -5412,7 +5803,10 @@ fn move_proposal(
5412
5803
  }
5413
5804
 
5414
5805
  fn proposal_is_expired(value: &Value) -> bool {
5415
- 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())
5416
5810
  }
5417
5811
 
5418
5812
  fn proposal_readiness_value(
@@ -5421,26 +5815,89 @@ fn proposal_readiness_value(
5421
5815
  value: &Value,
5422
5816
  current_evidence_hash: Option<&str>,
5423
5817
  ) -> Result<Value, ApiError> {
5424
- let application_id = value.get("application_id").and_then(Value::as_str).unwrap_or("");
5425
- let environment = value.get("environment").and_then(Value::as_str).unwrap_or("production");
5426
- 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"))?;
5427
- 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"))?;
5428
- let current_contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash).clone();
5429
- let current_flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash").cloned();
5430
- let proposal_contract_hash = value.get("base_contract_hash").and_then(Value::as_str).unwrap_or("");
5431
- 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("");
5432
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<_>>();
5433
5870
  let contract_changed = current_contract_hash != proposal_contract_hash;
5434
- let flow_changed = current_flow_hash.as_deref().is_some_and(|hash| hash != proposal_flow_hash);
5435
- let modules_changed = value.get("base_module_versions").and_then(Value::as_array).is_some_and(|modules| modules != &current_modules);
5436
- let evidence_changed = current_evidence_hash.is_some_and(|current| value.get("evidence_hash").and_then(Value::as_str).is_some_and(|base| base != current));
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
+ });
5437
5884
  let expired = proposal_is_expired(value);
5438
5885
  let mut blockers = Vec::new();
5439
- if contract_changed { blockers.push("contract_changed"); }
5440
- if flow_changed { blockers.push("flow_changed"); }
5441
- if modules_changed { blockers.push("module_versions_changed"); }
5442
- if evidence_changed { blockers.push("evidence_changed"); }
5443
- if expired { blockers.push("expired"); }
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
+ }
5444
5901
  let stale = contract_changed || flow_changed || modules_changed || evidence_changed;
5445
5902
  Ok(json!({
5446
5903
  "status":value.get("status"), "readiness":if blockers.is_empty(){"ready"}else{"blocked"},
@@ -5449,41 +5906,163 @@ fn proposal_readiness_value(
5449
5906
  "proposal_flow_hash":proposal_flow_hash, "stale":stale, "expires_at":value.get("expires_at")
5450
5907
  }))
5451
5908
  }
5452
-
5453
- 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(
5454
6029
  State(state): State<AppState>,
5455
6030
  Extension(principal): Extension<Principal>,
5456
6031
  Path(id): Path<String>,
5457
6032
  Query(input): Query<ProposalScopeRequest>,
5458
6033
  ) -> Result<Json<Value>, ApiError> {
5459
- let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
5460
- let mut value = proposal_value(&state, &id)?;
5461
- if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5462
- 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
+ ));
5463
6047
  }
5464
- Ok(Json(proposal_readiness_value(&state, &tenant, &value, input.current_evidence_hash.as_deref())?))
5465
- }
5466
- async fn record_proposal_convergence(
5467
- State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(mut input): Json<Value>,
5468
- ) -> Result<(StatusCode, Json<Value>), ApiError> {
5469
- let application_id=input.get("application_id").and_then(Value::as_str).ok_or_else(||inspection_error(StatusCode::BAD_REQUEST,"invalid_convergence","application_id is required"))?.to_string();
5470
- let environment=input.get("environment").and_then(Value::as_str).unwrap_or("production").to_string();
5471
- application_scope(&state,&principal.key_id,&application_id,"application:write")?;let proposal=proposal_value(&state,&id)?;
5472
- if !proposal_scope_matches(&proposal,&application_id,&environment){return Err(inspection_error(StatusCode::NOT_FOUND,"proposal_not_found","proposal not found"));}
5473
- if proposal.get("status").and_then(Value::as_str)!=Some("applied"){return Err(inspection_error(StatusCode::CONFLICT,"proposal_not_applied","only an applied Proposal can record convergence"));}
5474
- if input.get("proposal_id").and_then(Value::as_str)!=Some(id.as_str()){return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY,"invalid_convergence","proposal_id must match the route"));}
5475
- let valid_status=input.get("status").and_then(Value::as_str).is_some_and(|value|matches!(value,"pending"|"checking"|"converged"|"partially_converged"|"diverged"|"blocked"|"unverified"));
5476
- if !valid_status||input.get("findings").is_none_or(|value|!value.is_array())||input.get("evidence_hash").and_then(Value::as_str).is_none_or(|value|!value.starts_with("sha256:")){return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY,"invalid_convergence","status, findings, and evidence_hash are required"));}
5477
- let convergence_id=format!("cv_{}",uuid::Uuid::new_v4().simple());input["id"]=json!(convergence_id);input["created_at"]=json!(unix_seconds_i64());input["verifier_version"]=json!(1);
5478
- redact_inspection_value(&mut input);insert_canonical(&state,CONVERGENCE_COLLECTION,&convergence_id,input.clone())?;Ok((StatusCode::CREATED,Json(input)))
5479
- }
5480
- async fn proposal_convergence(
5481
- State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>,
5482
- ) -> Result<Json<Value>, ApiError> {
5483
- application_scope(&state,&principal.key_id,&input.application_id,"application:read")?;let proposal=proposal_value(&state,&id)?;
5484
- if !proposal_scope_matches(&proposal,&input.application_id,&input.environment){return Err(inspection_error(StatusCode::NOT_FOUND,"proposal_not_found","proposal not found"));}
5485
- let mut values=state.db.list_collection(CONVERGENCE_COLLECTION)?.into_iter().map(|row|row.value).filter(|value|value.get("proposal_id").and_then(Value::as_str)==Some(id.as_str())&&proposal_scope_matches(value,&input.application_id,&input.environment)).collect::<Vec<_>>();
5486
- values.sort_by_key(|value|value.get("created_at").and_then(Value::as_i64).unwrap_or_default());values.reverse();Ok(Json(json!({"convergence":values})))
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})))
5487
6066
  }
5488
6067
  async fn validate_proposal(
5489
6068
  State(state): State<AppState>,
@@ -5620,7 +6199,11 @@ async fn validate_proposal(
5620
6199
  value["proposed_contract"] = serde_json::to_value(&proposed.manifest)
5621
6200
  .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
5622
6201
  }
5623
- if !value.pointer("/source_plan/files").and_then(Value::as_array).is_some_and(|files| !files.is_empty()) {
6202
+ if !value
6203
+ .pointer("/source_plan/files")
6204
+ .and_then(Value::as_array)
6205
+ .is_some_and(|files| !files.is_empty())
6206
+ {
5624
6207
  value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
5625
6208
  }
5626
6209
  Ok(Json(move_proposal(
@@ -5647,7 +6230,8 @@ async fn preview_proposal(
5647
6230
  if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5648
6231
  return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
5649
6232
  }
5650
- 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");
5651
6235
  if replacing_expired_preview {
5652
6236
  if let Some(preview_id) = value.get("preview_id").and_then(Value::as_str) {
5653
6237
  let mut artifact = preview_artifact(&state, preview_id)?;
@@ -5655,42 +6239,131 @@ async fn preview_proposal(
5655
6239
  let mut proposal = value.clone();
5656
6240
  redact_inspection_value(&mut proposal);
5657
6241
  redact_inspection_value(&mut artifact);
5658
- 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
+ }
5659
6245
  artifact["proposal"] = proposal;
5660
6246
  return Ok(Json(artifact));
5661
6247
  }
5662
6248
  }
5663
6249
  }
5664
- if !replacing_expired_preview && value.get("status").and_then(Value::as_str) != Some("validated") {
5665
- 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
+ ));
5666
6258
  }
5667
- 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()))?;
5668
- 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"))?;
5669
- 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"))?;
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
+ })?;
5670
6296
  let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
5671
6297
  if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
5672
- 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
+ ));
5673
6302
  }
5674
6303
  let preview_id = format!("pv_{}", uuid::Uuid::new_v4().simple());
5675
6304
  let preview_environment = format!("preview/proposal/{preview_id}");
5676
- let proposed_manifest_hash = manifest_hash(&proposed_manifest).map_err(|error| inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "preview_contract_invalid", error))?;
5677
- let proposed_revision = ApplicationRevision { revision_id:format!("proposal://{id}"), application_id:input.application_id.clone(), tenant_id:tenant,
5678
- revision_number:value.get("base_contract_version").and_then(Value::as_u64).unwrap_or(current_revision.revision_number)+1,
5679
- parent_revision_id:Some(current_revision.revision_id.clone()), manifest_hash:proposed_manifest_hash, manifest:proposed_manifest,
5680
- created_by:principal.key_id.clone(), created_at:unix_seconds_i64() as u64, status:RevisionStatus::Committed };
5681
- 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
+ })?;
5682
6339
  let current_contract = proposal_contract_snapshot(&current_revision, &input.environment);
5683
6340
  let mut proposed_contract = proposal_contract_snapshot(&proposed_revision, &input.environment);
5684
6341
  proposed_contract["contract_hash"] = json!(proposal_hash(&proposed_contract));
5685
- 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
+ ));
5686
6353
  let namespace = runtime.environment.state_namespace.clone();
5687
6354
  let mut seeded_records = Vec::new();
5688
6355
  for collection in &proposed_revision.manifest.collections {
5689
- if collection.name.starts_with("_feltdb") { continue; }
6356
+ if collection.name.starts_with("_feltdb") {
6357
+ continue;
6358
+ }
5690
6359
  let record_id = format!("preview_{}", collection.name.to_ascii_lowercase());
5691
6360
  let record = json!({"id":record_id,"_preview_data":true,"_preview_source":"synthetic","label":format!("Synthetic {} preview",collection.name)});
5692
- state.db.capability(&format!("{}:{}", namespace, collection.name)).insert_with_key(&record_id, record)?;
5693
- 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}));
5694
6367
  }
5695
6368
  let now = unix_seconds_i64();
5696
6369
  let mut artifact = json!({
@@ -5706,7 +6379,15 @@ async fn preview_proposal(
5706
6379
  "seeded_records":seeded_records,"data_classification":"synthetic_preview","external_side_effects":"simulated","readiness":readiness
5707
6380
  });
5708
6381
  redact_inspection_value(&mut artifact);
5709
- 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
+ )?;
5710
6391
  value["preview_id"] = artifact.get("preview_id").cloned().unwrap_or(Value::Null);
5711
6392
  value["preview_expires_at"] = artifact.get("expires_at").cloned().unwrap_or(Value::Null);
5712
6393
  let mut stored_proposal = if replacing_expired_preview {
@@ -5716,76 +6397,209 @@ async fn preview_proposal(
5716
6397
  move_proposal(&state, &id, value, "previewed", &principal.key_id)?
5717
6398
  };
5718
6399
  redact_inspection_value(&mut stored_proposal);
5719
- 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
+ }
5720
6403
  artifact["proposal"] = stored_proposal;
5721
6404
  Ok(Json(artifact))
5722
6405
  }
5723
6406
 
5724
6407
  async fn get_proposal_preview(
5725
- 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>,
5726
6411
  Query(input): Query<ProposalScopeRequest>,
5727
6412
  ) -> Result<Json<Value>, ApiError> {
5728
- 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
+ )?;
5729
6419
  let mut artifact = preview_artifact(&state, &id)?;
5730
6420
  if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
5731
- 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
+ ));
5732
6426
  }
5733
6427
  if !preview_available(&artifact) {
5734
- 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
+ ));
5735
6433
  }
5736
- 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
+ )?;
5737
6441
  if proposal.get("preview_id") != artifact.get("preview_id") {
5738
- 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
+ ));
5739
6447
  }
5740
- if proposal_readiness_value(&state, &tenant, &proposal, None)?.get("stale").and_then(Value::as_bool) == Some(true) {
5741
- 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
+ ));
5742
6458
  }
5743
6459
  redact_inspection_value(&mut artifact);
5744
- 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
+ }
5745
6463
  Ok(Json(artifact))
5746
6464
  }
5747
6465
 
5748
6466
  async fn inspect_proposal_preview_data(
5749
- 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)>,
5750
6470
  Query(input): Query<ProposalScopeRequest>,
5751
6471
  ) -> Result<Json<Value>, ApiError> {
5752
- 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
+ )?;
5753
6478
  let artifact = preview_artifact(&state, &id)?;
5754
6479
  if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
5755
- 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
+ ));
5756
6492
  }
5757
- if !preview_available(&artifact) { return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired")); }
5758
- 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))) {
5759
- 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
+ ));
5760
6508
  }
5761
6509
  let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
5762
- 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
+ }
5763
6517
  let state_version = state.db.sequence()?;
5764
6518
  let filter_hash = format!("{id}:{collection}");
5765
- let after = input.cursor.as_deref().map(|cursor| parse_inspection_cursor(cursor, &filter_hash, state_version, "preview-data")).transpose()?;
5766
- let capability = format!("{}:{}", artifact.get("namespace").and_then(Value::as_str).unwrap_or(""), collection);
5767
- 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))?;
5768
6536
  let has_more = rows.len() > limit;
5769
6537
  rows.truncate(limit);
5770
6538
  let last_key = rows.last().map(|row| row.key.clone());
5771
6539
  let mut records = rows.into_iter().map(|row| row.value).collect::<Vec<_>>();
5772
- for record in &mut records { redact_inspection_value(record); }
5773
- let next_cursor = if has_more { last_key.map(|key| inspection_cursor(&filter_hash, state_version, &key, "preview-data")) } else { None };
5774
- 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
+ ))
5775
6551
  }
5776
6552
 
5777
6553
  async fn simulate_proposal_preview(
5778
- State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>,
5779
- 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>,
5780
6559
  ) -> Result<Json<Value>, ApiError> {
5781
- 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
+ )?;
5782
6566
  let artifact = preview_artifact(&state, &id)?;
5783
- if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) { return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found")); }
5784
- if !preview_available(&artifact) { return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired")); }
5785
- let known = artifact.get("events").and_then(Value::as_array).is_some_and(|events| events.iter().any(|event| event.as_str()==Some(&simulation.event)));
5786
- if !known { return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "unsupported_preview_event", "event is not part of the proposal")); }
5787
- let state_flow = artifact.get("state_flow").cloned().unwrap_or_else(||json!([]));
5788
- 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
+ ))
5789
6603
  }
5790
6604
  async fn transition_proposal(
5791
6605
  State(state): State<AppState>,
@@ -5814,29 +6628,70 @@ async fn transition_proposal(
5814
6628
  ));
5815
6629
  }
5816
6630
  if proposal_is_expired(&value) && input.status != "expired" {
5817
- 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
+ ));
5818
6636
  }
5819
6637
  if input.status == "approved" {
5820
6638
  let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
5821
6639
  if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
5822
- 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
+ ));
5823
6644
  }
5824
- 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
+ })?;
5825
6655
  let artifact = preview_artifact(&state, preview_id)?;
5826
6656
  let bound = preview_available(&artifact)
5827
6657
  && artifact.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())
5828
6658
  && artifact.get("proposal_contract_hash") == value.get("base_contract_hash")
5829
6659
  && artifact.get("proposal_flow_hash") == value.get("base_flow_hash")
5830
6660
  && artifact.get("module_versions") == value.get("module_versions");
5831
- 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
+ }
5832
6668
  }
5833
6669
  if input.status == "applied" {
5834
6670
  let configured = std::env::var("FELTDB_PROPOSAL_AUTHORITY_TOKEN").ok();
5835
- let supplied = headers.get("FeltDB-Proposal-Authority").and_then(|value| value.to_str().ok());
5836
- let repository_apply = headers.get("FeltDB-Repository-Apply").and_then(|value| value.to_str().ok()) == Some("1");
5837
- 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
+ });
5838
6689
  if !repository_apply || !authorized {
5839
- 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
+ ));
5840
6695
  }
5841
6696
  }
5842
6697
  if input.status == "approved"
@@ -5857,37 +6712,59 @@ async fn transition_proposal(
5857
6712
  ));
5858
6713
  }
5859
6714
  if input.status == "rejected" {
5860
- 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
+ }
5861
6718
  }
5862
- let mut moved = move_proposal(
5863
- &state,
5864
- &id,
5865
- value,
5866
- &input.status,
5867
- &principal.key_id,
5868
- )?;
6719
+ let mut moved = move_proposal(&state, &id, value, &input.status, &principal.key_id)?;
5869
6720
  redact_inspection_value(&mut moved);
5870
6721
  Ok(Json(moved))
5871
6722
  }
5872
6723
 
5873
6724
  async fn approve_proposal(
5874
- state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
5875
- 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>,
5876
6730
  ) -> Result<Json<Value>, ApiError> {
5877
- transition_proposal(state, principal, headers, path, Json(ProposalStatusRequest {
5878
- application_id: input.application_id, environment: input.environment, status: "approved".into(),
5879
- approve_authorization: input.approve_authorization, reason: input.reason,
5880
- })).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
5881
6745
  }
5882
6746
 
5883
6747
  async fn reject_proposal(
5884
- state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
5885
- 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>,
5886
6753
  ) -> Result<Json<Value>, ApiError> {
5887
- transition_proposal(state, principal, headers, path, Json(ProposalStatusRequest {
5888
- application_id: input.application_id, environment: input.environment, status: "rejected".into(),
5889
- approve_authorization: false, reason: input.reason,
5890
- })).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
5891
6768
  }
5892
6769
  fn auth_response(
5893
6770
  identity: feltdb_server::identity::Identity,
@@ -5916,17 +6793,18 @@ async fn auth_sign_up(
5916
6793
  "application not found".into(),
5917
6794
  ));
5918
6795
  }
5919
- let (identity, session, token) = match state
5920
- .identities
5921
- .sign_up(&input.email, &input.password, input.display_name)
5922
- {
5923
- Ok(created) => created,
5924
- Err(error) if error == "identity already exists" => state
6796
+ let (identity, session, token) =
6797
+ match state
5925
6798
  .identities
5926
- .sign_in(&input.email, &input.password)
5927
- .map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?,
5928
- Err(error) => return Err(ApiError(StatusCode::BAD_REQUEST, error)),
5929
- };
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
+ };
5930
6808
  state
5931
6809
  .tenancy
5932
6810
  .register_platform_user(
@@ -5950,7 +6828,10 @@ async fn auth_sign_in(
5950
6828
  .map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
5951
6829
  if let Some(application_id) = input.application_id {
5952
6830
  if state.tenancy.application_tenant(&application_id).is_none() {
5953
- return Err(ApiError(StatusCode::NOT_FOUND, "application not found".into()));
6831
+ return Err(ApiError(
6832
+ StatusCode::NOT_FOUND,
6833
+ "application not found".into(),
6834
+ ));
5954
6835
  }
5955
6836
  state
5956
6837
  .tenancy
@@ -7341,7 +8222,9 @@ fn validate_production_configuration(config: &Config) -> Result<(), Box<dyn std:
7341
8222
  Ok(())
7342
8223
  }
7343
8224
 
7344
- #[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)]
7345
8228
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
7346
8229
  tracing_subscriber::fmt()
7347
8230
  .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
@@ -7536,6 +8419,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7536
8419
  .patch(update_application_control)
7537
8420
  .delete(delete_application_control),
7538
8421
  )
8422
+ .route(
8423
+ "/api/applications/{application_id}/qualification/destroy",
8424
+ axum::routing::post(delete_qualification_application_control),
8425
+ )
7539
8426
  .route(
7540
8427
  "/api/applications/{application_id}/members",
7541
8428
  get(list_application_members),
@@ -7715,10 +8602,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7715
8602
  "/v1/proposals/{id}/status",
7716
8603
  get(proposal_status).patch(transition_proposal),
7717
8604
  )
7718
- .route("/v1/proposals/{id}/convergence", get(proposal_convergence).post(record_proposal_convergence))
8605
+ .route(
8606
+ "/v1/proposals/{id}/convergence",
8607
+ get(proposal_convergence).post(record_proposal_convergence),
8608
+ )
7719
8609
  .route("/v1/previews/{id}", get(get_proposal_preview))
7720
- .route("/v1/previews/{id}/data/{collection}", get(inspect_proposal_preview_data))
7721
- .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
+ )
7722
8618
  .route("/v1/auth/session", get(auth_session))
7723
8619
  .route("/v1/auth/signout", axum::routing::post(auth_sign_out))
7724
8620
  .route("/v1/events", get(events))
@@ -8103,9 +8999,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8103
8999
  .route("/v1/auth/signup", axum::routing::post(auth_sign_up))
8104
9000
  .route("/v1/auth/signin", axum::routing::post(auth_sign_in))
8105
9001
  .route("/internal/intents/by-hash", get(internal_find_intent))
8106
- .route("/internal/intents", axum::routing::post(internal_create_intent))
9002
+ .route(
9003
+ "/internal/intents",
9004
+ axum::routing::post(internal_create_intent),
9005
+ )
8107
9006
  .route("/internal/intents/{id}", put(internal_replace_intent))
8108
- .route("/internal/intent-events", axum::routing::post(internal_create_intent_event))
9007
+ .route(
9008
+ "/internal/intent-events",
9009
+ axum::routing::post(internal_create_intent_event),
9010
+ )
8109
9011
  .route("/ready", get(readiness))
8110
9012
  .route("/runtime", get(runtime))
8111
9013
  .route("/metrics", get(network_metrics))
@@ -8186,7 +9088,14 @@ async fn authenticate(
8186
9088
  .and_then(|header| header.to_str().ok())
8187
9089
  .and_then(|header| header.strip_prefix("Bearer "));
8188
9090
  let machine = token.and_then(|token| state.keys.authenticate(token, &state.namespace));
8189
- 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
+ };
8190
9099
  let human = request
8191
9100
  .headers()
8192
9101
  .get(COOKIE)
@@ -10787,10 +11696,9 @@ fn put_canonical(
10787
11696
 
10788
11697
  /// The authority's current committed-state revision.
10789
11698
  ///
10790
- /// This is the value `apply_atomic_transaction` advances and writes into each
10791
- /// durable transaction record as `state_after`. It already survived restart and
10792
- /// already refused to move backwards; the only thing missing was a way to read
10793
- /// 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.
10794
11702
  ///
10795
11703
  /// `scope` is the store's instance id. A revision is comparable only against
10796
11704
  /// another revision carrying the same scope, so a client that fails over to a
@@ -10804,7 +11712,7 @@ struct RevisionResponse {
10804
11712
 
10805
11713
  async fn get_revision(State(state): State<AppState>) -> Result<Json<RevisionResponse>, ApiError> {
10806
11714
  Ok(Json(RevisionResponse {
10807
- revision: state.db.sequence()?,
11715
+ revision: state.db.current_revision()?,
10808
11716
  scope: state.db.instance_id()?,
10809
11717
  }))
10810
11718
  }
@@ -11087,7 +11995,12 @@ async fn list_records(
11087
11995
  State(state): State<AppState>,
11088
11996
  Path(collection): Path<String>,
11089
11997
  ) -> Result<Json<Vec<RecordResponse>>, ApiError> {
11090
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_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
+ {
11091
12004
  return Err(ApiError(
11092
12005
  StatusCode::FORBIDDEN,
11093
12006
  "system collection requires proposal service".into(),
@@ -11102,7 +12015,12 @@ async fn get_record(
11102
12015
  State(state): State<AppState>,
11103
12016
  Path((collection, id)): Path<(String, String)>,
11104
12017
  ) -> Result<Json<RecordResponse>, ApiError> {
11105
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_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
+ {
11106
12024
  return Err(ApiError(
11107
12025
  StatusCode::FORBIDDEN,
11108
12026
  "system collection requires proposal service".into(),
@@ -11184,6 +12102,9 @@ struct AtomicTxRequest {
11184
12102
  #[serde(rename_all = "camelCase")]
11185
12103
  struct AtomicTxResponse {
11186
12104
  transaction_id: String,
12105
+ base_revision: u64,
12106
+ commit_revision: u64,
12107
+ status: feltdb::TransactionStatus,
11187
12108
  /// True when this id had already been committed; nothing was reapplied.
11188
12109
  duplicate: bool,
11189
12110
  operations: usize,
@@ -11335,6 +12256,9 @@ async fn commit_transaction(
11335
12256
  },
11336
12257
  Json(AtomicTxResponse {
11337
12258
  transaction_id: commit.transaction_id,
12259
+ base_revision: commit.base_revision,
12260
+ commit_revision: commit.commit_revision,
12261
+ status: commit.status,
11338
12262
  duplicate: commit.duplicate,
11339
12263
  operations,
11340
12264
  state_before: commit.state_before,
@@ -11348,7 +12272,12 @@ async fn create_record(
11348
12272
  Path(collection): Path<String>,
11349
12273
  Json(mut record): Json<CreateRecord>,
11350
12274
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
11351
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_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
+ {
11352
12281
  return Err(ApiError(
11353
12282
  StatusCode::FORBIDDEN,
11354
12283
  "system collection requires proposal service".into(),
@@ -11387,7 +12316,12 @@ async fn update_record(
11387
12316
  Path((collection, id)): Path<(String, String)>,
11388
12317
  Json(changes): Json<Map<String, Value>>,
11389
12318
  ) -> Result<Json<RecordResponse>, ApiError> {
11390
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_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
+ {
11391
12325
  return Err(ApiError(
11392
12326
  StatusCode::FORBIDDEN,
11393
12327
  "system collection requires proposal service".into(),
@@ -11508,7 +12442,12 @@ async fn delete_record(
11508
12442
  State(state): State<AppState>,
11509
12443
  Path((collection, id)): Path<(String, String)>,
11510
12444
  ) -> Result<StatusCode, ApiError> {
11511
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_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
+ {
11512
12451
  return Err(ApiError(
11513
12452
  StatusCode::FORBIDDEN,
11514
12453
  "system collection requires proposal service".into(),