@feltdb/core 0.7.4 → 0.8.1

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 (127) hide show
  1. package/README.md +25 -0
  2. package/dist/application-development.d.ts +10 -3
  3. package/dist/application-development.d.ts.map +1 -1
  4. package/dist/application-development.js +11 -1
  5. package/dist/application-manifest.d.ts +2 -0
  6. package/dist/application-manifest.d.ts.map +1 -1
  7. package/dist/application-manifest.js +1 -1
  8. package/dist/application-understanding.d.ts +76 -0
  9. package/dist/application-understanding.d.ts.map +1 -0
  10. package/dist/application-understanding.js +282 -0
  11. package/dist/canonical-application.d.ts +1 -0
  12. package/dist/canonical-application.d.ts.map +1 -1
  13. package/dist/canonical-application.js +3 -2
  14. package/dist/cli/ai-model-config.js +3 -3
  15. package/dist/cli/cli.js +1 -1
  16. package/dist/cli/commands.js +321 -45
  17. package/dist/cli/index.js +1 -1
  18. package/dist/cli/managed-environment.js +6 -0
  19. package/dist/cli/managed-project.js +6 -0
  20. package/dist/cli/proposal-client.js +16 -3
  21. package/dist/cli/publish-engine.js +8 -5
  22. package/dist/cli/repository-evidence.js +55 -0
  23. package/dist/cli/source-sync.js +220 -61
  24. package/dist/convergence.d.ts +55 -0
  25. package/dist/convergence.d.ts.map +1 -0
  26. package/dist/convergence.js +63 -0
  27. package/dist/create/cli.js +10 -1
  28. package/dist/create/create.js +10 -12
  29. package/dist/create/managed-account.js +72 -7
  30. package/dist/create/package-versions.js +1 -1
  31. package/dist/create/server-source/crates/feltdb/src/application.rs +14 -0
  32. package/dist/create/server-source/crates/feltdb/src/lib.rs +20 -2
  33. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +12 -24
  34. package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +11 -3
  35. package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +0 -1
  36. package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +23 -22
  37. package/dist/create/server-source/crates/feltdb-server/src/connections.rs +14 -0
  38. package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +5 -15
  39. package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +13 -28
  40. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +79 -14
  41. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +28 -7
  42. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +5 -5
  43. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1929 -261
  44. package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +10 -30
  45. package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +12 -4
  46. package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +8 -6
  47. package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +10 -14
  48. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +191 -22
  49. package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +9 -21
  50. package/dist/create/server-source/crates/feltdb-server/src/transaction_idempotency.rs +5 -3
  51. package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +9 -8
  52. package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +1 -4
  53. package/dist/create/template/default-project/feltdb.flow +5 -0
  54. package/dist/create/template/default-project/src/context/AuthContext.tsx +9 -1
  55. package/dist/create/template/default-project/src/pages/SignUp.tsx +1 -1
  56. package/dist/flowspec.d.ts +1 -0
  57. package/dist/flowspec.d.ts.map +1 -1
  58. package/dist/flowspec.js +51 -12
  59. package/dist/http-client.d.ts +166 -14
  60. package/dist/http-client.d.ts.map +1 -1
  61. package/dist/http-client.js +91 -31
  62. package/dist/http-db.d.ts +3 -0
  63. package/dist/http-db.d.ts.map +1 -1
  64. package/dist/http-db.js +28 -3
  65. package/dist/index-core.d.ts +5 -0
  66. package/dist/index-core.d.ts.map +1 -1
  67. package/dist/index-core.js +5 -0
  68. package/dist/intent.d.ts +98 -0
  69. package/dist/intent.d.ts.map +1 -0
  70. package/dist/intent.js +343 -0
  71. package/dist/module-intent.d.ts +51 -0
  72. package/dist/module-intent.d.ts.map +1 -0
  73. package/dist/module-intent.js +230 -0
  74. package/dist/module.d.ts +105 -0
  75. package/dist/module.d.ts.map +1 -0
  76. package/dist/module.js +566 -0
  77. package/dist/proposal.d.ts +92 -4
  78. package/dist/proposal.d.ts.map +1 -1
  79. package/dist/proposal.js +150 -6
  80. package/dist/studio/app.d.ts +3 -1
  81. package/dist/studio/app.d.ts.map +1 -1
  82. package/dist/studio/components/AskFeltDB.d.ts +2 -1
  83. package/dist/studio/components/AskFeltDB.d.ts.map +1 -1
  84. package/dist/studio/components/ContractInspector.d.ts +7 -0
  85. package/dist/studio/components/ContractInspector.d.ts.map +1 -0
  86. package/dist/studio/components/ManagedInstancePanel.d.ts.map +1 -1
  87. package/dist/studio/components/ManagedInstancePanel.js +1 -5
  88. package/dist/studio/components/ModuleExplorer.d.ts +6 -0
  89. package/dist/studio/components/ModuleExplorer.d.ts.map +1 -0
  90. package/dist/studio/components/ProposalBrowser.d.ts +10 -1
  91. package/dist/studio/components/ProposalBrowser.d.ts.map +1 -1
  92. package/dist/studio/components/StateExplorer.d.ts +6 -7
  93. package/dist/studio/components/StateExplorer.d.ts.map +1 -1
  94. package/dist/studio/components/index.d.ts +2 -0
  95. package/dist/studio/components/index.d.ts.map +1 -1
  96. package/dist/studio/components/index.js +4 -4
  97. package/dist/studio/components-Duq2xQWt.js +2845 -0
  98. package/dist/studio/index.js +193 -147
  99. package/dist/studio/studio.css +1 -1
  100. package/dist/studio/utils/index.d.ts +2 -0
  101. package/dist/studio/utils/index.d.ts.map +1 -1
  102. package/dist/studio/utils/index.js +14 -12
  103. package/dist/studio/utils/managed-instance.d.ts +0 -4
  104. package/dist/studio/utils/managed-instance.d.ts.map +1 -1
  105. package/dist/studio/utils/managed-instance.js +0 -4
  106. package/dist/studio/utils/proposal-api.d.ts +56 -0
  107. package/dist/studio/utils/proposal-api.d.ts.map +1 -0
  108. package/dist/studio/utils/proposal-api.js +94 -0
  109. package/dist/studio/utils/service-api.d.ts +81 -0
  110. package/dist/studio/utils/service-api.d.ts.map +1 -0
  111. package/dist/studio/utils/service-api.js +51 -0
  112. package/dist/studio-app/assets/{feltdb_wasm-B-Ccgccb.js → feltdb_wasm-bIqcRzAr.js} +1 -1
  113. package/dist/studio-app/assets/feltdb_wasm_bg-BE79okwX.wasm +0 -0
  114. package/dist/studio-app/assets/{index-BRrmvaKw.css → index-Cd_NPw14.css} +1 -1
  115. package/dist/studio-app/assets/index-XGYdlElN.js +29 -0
  116. package/dist/studio-app/index.html +2 -2
  117. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  118. package/dist/workspace/local-development-authority.d.ts +2 -0
  119. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  120. package/dist/workspace/local-development-authority.js +128 -4
  121. package/package.json +1 -1
  122. package/dist/studio/components-Bm4sjhcX.js +0 -2162
  123. package/dist/studio-app/assets/dist-DA1MYSzp.js +0 -1
  124. package/dist/studio-app/assets/feltdb_wasm_bg-wH3enJIA.wasm +0 -0
  125. package/dist/studio-app/assets/index-CEIqowCh.js +0 -42
  126. package/dist/studio-app/assets/lib-B2_7fcn7.js +0 -69
  127. package/dist/studio-app/assets/worker-CARZ5g7K.js +0 -69
@@ -30,8 +30,8 @@ use axum::{
30
30
  use base64::Engine;
31
31
  use feltdb::{
32
32
  application::{
33
- diff_manifests, validate_manifest, ActionOperation, ApplicationManifest, ApplicationRevision,
34
- ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
33
+ diff_manifests, manifest_hash, validate_manifest, ActionOperation, ApplicationManifest,
34
+ ApplicationRevision, ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
35
35
  },
36
36
  application_runtime::{
37
37
  resolve_runtime, runtime_readiness, snapshot_state_namespace, validate_runtime,
@@ -42,12 +42,15 @@ use feltdb::{
42
42
  Grant, GrantSigner, GrantStore, Subject as GrantSubject,
43
43
  },
44
44
  cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
45
- AtomicMutation,
46
- policy_evaluation::{Actor, PolicyContext, PolicyEvaluator, PolicySubject},
45
+ policy_evaluation::{
46
+ Actor, AuthorizationState, PolicyContext, PolicyEvaluator, PolicySubject,
47
+ RecordAuthorizationContext,
48
+ },
47
49
  state_contract::{
48
50
  begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
49
- execute_transaction_with_collection_policies, schema_from_revision, validate_schema, AuthorizationContext, CanonicalQuery, QueryFilter,
50
- StateSchema, TransactionOperation, TransactionOperationKind, TransactionRequest,
51
+ execute_transaction_with_collection_policies, schema_from_revision, validate_schema,
52
+ AuthorizationContext, CanonicalQuery, QueryFilter, StateSchema, TransactionOperation,
53
+ TransactionOperationKind, TransactionRequest,
51
54
  },
52
55
  sync_contract::{
53
56
  DeviceIdentity, ReconciliationOutcome, SyncAck, SyncBatch, SyncConflict, SyncCursor,
@@ -58,7 +61,8 @@ use feltdb::{
58
61
  WorkerRegistration,
59
62
  },
60
63
  workload::{CreateWorkload, WorkloadStore},
61
- DatabaseSnapshot, FeltDb, JsonCasResult, Operation, PeerAdvertisement, PeerId, StoredRow, FlowError, RecordPrecondition,
64
+ AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
65
+ PeerAdvertisement, PeerId, RecordPrecondition, StoredRow,
62
66
  };
63
67
  use feltdb_server::{
64
68
  app_state::{AppState, BoundedQueryCursor},
@@ -110,7 +114,10 @@ impl ApiError {
110
114
  /// `api_error_body` already parses a message that happens to be JSON, so
111
115
  /// this is the existing mechanism named rather than a second one.
112
116
  fn structured(status: StatusCode, body: Value) -> Self {
113
- Self(status, serde_json::to_string(&body).unwrap_or_else(|_| body.to_string()))
117
+ Self(
118
+ status,
119
+ serde_json::to_string(&body).unwrap_or_else(|_| body.to_string()),
120
+ )
114
121
  }
115
122
  }
116
123
 
@@ -134,13 +141,30 @@ fn api_error_body(status: StatusCode, message: String) -> Value {
134
141
  _ if status.is_server_error() => "EXECUTION_FAILED",
135
142
  _ => "REQUEST_FAILED",
136
143
  };
137
- let parsed = serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "message": message }));
138
- let mut body = if parsed.is_object() { parsed } else { json!({ "message": parsed }) };
139
- let object = body.as_object_mut().expect("normalized error body is an object");
140
- let resolved_code = object.get("code").and_then(Value::as_str).unwrap_or(code).to_string();
144
+ let parsed =
145
+ serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "message": message }));
146
+ let mut body = if parsed.is_object() {
147
+ parsed
148
+ } else {
149
+ json!({ "message": parsed })
150
+ };
151
+ let object = body
152
+ .as_object_mut()
153
+ .expect("normalized error body is an object");
154
+ let resolved_code = object
155
+ .get("code")
156
+ .and_then(Value::as_str)
157
+ .unwrap_or(code)
158
+ .to_string();
141
159
  object.insert("error".into(), json!(resolved_code));
142
160
  object.insert("code".into(), json!(resolved_code));
143
- object.entry("message").or_insert_with(|| json!(if status == StatusCode::FORBIDDEN { "request is not authorized" } else { status.canonical_reason().unwrap_or("request failed") }));
161
+ object.entry("message").or_insert_with(|| {
162
+ json!(if status == StatusCode::FORBIDDEN {
163
+ "request is not authorized"
164
+ } else {
165
+ status.canonical_reason().unwrap_or("request failed")
166
+ })
167
+ });
144
168
  object.insert("request_id".into(), json!(request_id));
145
169
  body
146
170
  }
@@ -414,24 +438,112 @@ struct ApplicationDiscoveryRequest {
414
438
  environment: String,
415
439
  }
416
440
  #[derive(Deserialize)]
417
- struct ProposalScopeRequest { application_id: String, #[serde(default = "production_environment")] environment: String }
441
+ struct ProposalScopeRequest {
442
+ application_id: String,
443
+ #[serde(default = "production_environment")]
444
+ environment: String,
445
+ #[serde(default)]
446
+ status: Option<String>,
447
+ #[serde(default)]
448
+ limit: Option<usize>,
449
+ #[serde(default)]
450
+ cursor: Option<String>,
451
+ #[serde(default)]
452
+ current_evidence_hash: Option<String>,
453
+ }
454
+ #[derive(Deserialize)]
455
+ struct CreateProposalRequest {
456
+ application_id: String,
457
+ #[serde(default = "production_environment")]
458
+ environment: String,
459
+ base_contract_hash: String,
460
+ base_flow_hash: String,
461
+ proposed_flow: String,
462
+ #[serde(default)]
463
+ proposal_version: Option<u64>,
464
+ #[serde(default)]
465
+ contract_diff: Option<Value>,
466
+ #[serde(default)]
467
+ source_plan: Option<Value>,
468
+ summary: String,
469
+ #[serde(default)]
470
+ warnings: Vec<String>,
471
+ #[serde(default)]
472
+ metadata: Value,
473
+ parent_proposal_id: Option<String>,
474
+ expires_at: Option<String>,
475
+ #[serde(default)]
476
+ status: Option<String>,
477
+ #[serde(default)]
478
+ understanding: Option<Value>,
479
+ #[serde(default)]
480
+ evidence_hash: Option<String>,
481
+ #[serde(default)]
482
+ understanding_hash: Option<String>,
483
+ }
484
+ #[derive(Deserialize, Serialize)]
485
+ struct ExecuteIntentRequest {
486
+ application_id: String,
487
+ #[serde(default = "production_environment")]
488
+ environment: String,
489
+ #[serde(alias = "raw_request")]
490
+ request: String,
491
+ }
492
+ #[derive(Deserialize)]
493
+ struct IntentHashRequest {
494
+ application_id: String,
495
+ environment: String,
496
+ intent_hash: String,
497
+ }
498
+ #[derive(Deserialize)]
499
+ struct ProposalActorRequest {
500
+ application_id: String,
501
+ #[serde(default = "production_environment")]
502
+ environment: String,
503
+ proposed_manifest: Option<ApplicationManifest>,
504
+ }
418
505
  #[derive(Deserialize)]
419
- struct CreateProposalRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
420
- base_contract_hash: String, base_flow_hash: String, proposed_flow: String, summary: String,
421
- #[serde(default)] warnings: Vec<String>, #[serde(default)] metadata: Value,
422
- parent_proposal_id: Option<String>, expires_at: Option<String> }
506
+ struct ProposalStatusRequest {
507
+ application_id: String,
508
+ #[serde(default = "production_environment")]
509
+ environment: String,
510
+ status: String,
511
+ #[serde(default)]
512
+ approve_authorization: bool,
513
+ #[serde(default)]
514
+ reason: Option<String>,
515
+ }
423
516
  #[derive(Deserialize)]
424
- struct ProposalActorRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
425
- proposed_manifest: Option<ApplicationManifest> }
517
+ struct ProposalLifecycleRequest {
518
+ application_id: String,
519
+ #[serde(default = "production_environment")]
520
+ environment: String,
521
+ #[serde(default)]
522
+ approve_authorization: bool,
523
+ #[serde(default)]
524
+ reason: Option<String>,
525
+ }
426
526
  #[derive(Deserialize)]
427
- struct ProposalStatusRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
428
- status: String, #[serde(default)] approve_authorization: bool }
527
+ struct PreviewSimulationRequest {
528
+ event: String,
529
+ }
429
530
  #[derive(Deserialize, Default)]
430
- struct EventScopeRequest { application_id: Option<String> }
531
+ struct EventScopeRequest {
532
+ application_id: Option<String>,
533
+ }
431
534
  #[derive(Deserialize)]
432
- struct SignUpRequest { application_id: String, email: String, password: String, display_name: Option<String> }
535
+ struct SignUpRequest {
536
+ application_id: String,
537
+ email: String,
538
+ password: String,
539
+ display_name: Option<String>,
540
+ }
433
541
  #[derive(Deserialize)]
434
- struct SignInRequest { email: String, password: String }
542
+ struct SignInRequest {
543
+ application_id: Option<String>,
544
+ email: String,
545
+ password: String,
546
+ }
435
547
  #[derive(Deserialize)]
436
548
  struct StateContractTarget {
437
549
  application_id: String,
@@ -2130,6 +2242,7 @@ async fn platform_overview(
2130
2242
  let (users, tenants, applications, tenant_memberships, application_memberships) =
2131
2243
  state.tenancy.platform_inventory().map_err(control_error)?;
2132
2244
  let identities = state.identities.list().map_err(control_error)?;
2245
+ let runtime_metrics = state.metrics.snapshot();
2133
2246
  Ok(Json(json!({
2134
2247
  "owner": principal.identity_id,
2135
2248
  "users": users,
@@ -2138,6 +2251,11 @@ async fn platform_overview(
2138
2251
  "tenant_memberships": tenant_memberships,
2139
2252
  "application_memberships": application_memberships,
2140
2253
  "identities": identities,
2254
+ "adoption": {
2255
+ "measured_at": unix_seconds_i64().max(0) as u64,
2256
+ "runtime_process_started_seconds_ago": state.started_at.elapsed().as_secs(),
2257
+ "runtime": runtime_metrics,
2258
+ },
2141
2259
  })))
2142
2260
  }
2143
2261
  async fn platform_update_user(
@@ -2279,7 +2397,10 @@ async fn delete_certification_fixture(
2279
2397
  || ring::constant_time::verify_slices_are_equal(expected.as_bytes(), provided.as_bytes())
2280
2398
  .is_err()
2281
2399
  {
2282
- return Err(ApiError(StatusCode::FORBIDDEN, "certification recovery denied".into()));
2400
+ return Err(ApiError(
2401
+ StatusCode::FORBIDDEN,
2402
+ "certification recovery denied".into(),
2403
+ ));
2283
2404
  }
2284
2405
  let tenant_id = state
2285
2406
  .tenancy
@@ -3701,6 +3822,9 @@ fn application_scope(
3701
3822
  StatusCode::NOT_FOUND,
3702
3823
  "application not found".into(),
3703
3824
  ))?;
3825
+ if std::env::var("FELTDB_INTENT_AUTHORITY_KEY_ID").as_deref() == Ok(actor) {
3826
+ return Ok(tenant_id);
3827
+ }
3704
3828
  if !state
3705
3829
  .tenancy
3706
3830
  .authorize_application(actor, &tenant_id, application_id, capability)
@@ -3907,9 +4031,8 @@ async fn commit_application_draft(
3907
4031
  .commit(&tenant, &application_id, &draft_id, &principal.key_id)
3908
4032
  .map_err(manifest_error)?;
3909
4033
  let encoded_revision = percent_encode_path_segment(&revision.revision_id);
3910
- let mut response = serde_json::to_value(revision).map_err(|error| {
3911
- ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
3912
- })?;
4034
+ let mut response = serde_json::to_value(revision)
4035
+ .map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
3913
4036
  response
3914
4037
  .as_object_mut()
3915
4038
  .expect("application revisions serialize as objects")
@@ -4358,13 +4481,13 @@ async fn import_application_draft(
4358
4481
 
4359
4482
  fn runtime_inventory(
4360
4483
  state: &AppState,
4361
- actor: &str,
4484
+ _actor: &str,
4362
4485
  application_id: &str,
4363
4486
  manifest: &ApplicationManifest,
4364
4487
  ) -> Result<RuntimeInventory, ApiError> {
4365
4488
  let connections = state
4366
4489
  .connections
4367
- .list_connections(actor, &state.tenancy, application_id)
4490
+ .application_connections(application_id)
4368
4491
  .map_err(control_error)?;
4369
4492
  let connection_ids = connections
4370
4493
  .into_iter()
@@ -4442,137 +4565,1335 @@ async fn get_active_application_runtime(
4442
4565
  revision,
4443
4566
  &input.environment,
4444
4567
  )?;
4445
- Ok(Json(
4446
- json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
4447
- ))
4568
+ Ok(Json(
4569
+ json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
4570
+ ))
4571
+ }
4572
+ async fn discover_application(
4573
+ State(state): State<AppState>,
4574
+ Extension(principal): Extension<Principal>,
4575
+ Query(input): Query<ApplicationDiscoveryRequest>,
4576
+ ) -> Result<Json<Value>, ApiError> {
4577
+ let tenant = application_scope(
4578
+ &state,
4579
+ &principal.key_id,
4580
+ &input.application_id,
4581
+ "application:read",
4582
+ )?;
4583
+ let revision_id = state
4584
+ .applications
4585
+ .pointers(&input.application_id)
4586
+ .get(&input.environment)
4587
+ .cloned()
4588
+ .ok_or(ApiError(
4589
+ StatusCode::NOT_FOUND,
4590
+ "application environment has no active contract".into(),
4591
+ ))?;
4592
+ let revision = state
4593
+ .applications
4594
+ .revision(&tenant, &input.application_id, &revision_id)
4595
+ .ok_or(ApiError(
4596
+ StatusCode::NOT_FOUND,
4597
+ "application revision not found".into(),
4598
+ ))?;
4599
+ let contract_hash = revision
4600
+ .manifest
4601
+ .metadata
4602
+ .labels
4603
+ .get("feltdb.contract_hash")
4604
+ .unwrap_or(&revision.manifest_hash);
4605
+ let dsl_version = revision
4606
+ .manifest
4607
+ .metadata
4608
+ .labels
4609
+ .get("feltdb.dsl_version")
4610
+ .and_then(|value| value.parse::<u32>().ok())
4611
+ .unwrap_or(1);
4612
+ let application_name = state
4613
+ .tenancy
4614
+ .application_for(&principal.key_id, &input.application_id)
4615
+ .map(|application| application.name);
4616
+ let flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash");
4617
+ Ok(Json(json!({
4618
+ "application_id": input.application_id,
4619
+ "application_name": application_name,
4620
+ "environment": input.environment,
4621
+ "version": revision.revision_number,
4622
+ "revision_id": revision.revision_id,
4623
+ "contract_hash": contract_hash,
4624
+ "flow_hash": flow_hash,
4625
+ "dsl_version": dsl_version,
4626
+ "snapshot": {
4627
+ "application": { "application_id": input.application_id, "environment": input.environment },
4628
+ "contract_version": revision.revision_number,
4629
+ "contract_hash": contract_hash,
4630
+ "dsl_version": dsl_version,
4631
+ "schema": revision.manifest.collections,
4632
+ "indexes": revision.manifest.indexes,
4633
+ "policies": revision.manifest.policies,
4634
+ "workflows": revision.manifest.workflows,
4635
+ "agents": revision.manifest.agents,
4636
+ "capabilities": revision.manifest.capabilities,
4637
+ "modules": revision.manifest.modules,
4638
+ "supported_primitives": ["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
4639
+ }
4640
+ })))
4641
+ }
4642
+
4643
+ const DEFAULT_INSPECTION_LIMIT: usize = 50;
4644
+ const MAX_INSPECTION_LIMIT: usize = 100;
4645
+ fn inspection_error(status: StatusCode, code: &str, message: impl Into<String>) -> ApiError {
4646
+ ApiError::structured(status, json!({"code":code,"message":message.into()}))
4647
+ }
4648
+ fn active_inspection_contract(
4649
+ state: &AppState,
4650
+ principal: &Principal,
4651
+ application_id: &str,
4652
+ environment: &str,
4653
+ ) -> Result<ApplicationRuntimeContract, ApiError> {
4654
+ application_scope(state, &principal.key_id, application_id, "state:read")?;
4655
+ let revision = state
4656
+ .applications
4657
+ .pointers(application_id)
4658
+ .get(environment)
4659
+ .cloned()
4660
+ .ok_or_else(|| {
4661
+ inspection_error(
4662
+ StatusCode::NOT_FOUND,
4663
+ "collection_not_found",
4664
+ "application environment has no active contract",
4665
+ )
4666
+ })?;
4667
+ revision_contract(
4668
+ state,
4669
+ &principal.key_id,
4670
+ application_id,
4671
+ &revision,
4672
+ environment,
4673
+ )
4674
+ }
4675
+ fn inspectable_collection<'a>(
4676
+ contract: &'a ApplicationRuntimeContract,
4677
+ collection: &str,
4678
+ ) -> Result<&'a feltdb::state_contract::CollectionSchema, ApiError> {
4679
+ if collection.starts_with("_feltdb") {
4680
+ return Err(inspection_error(
4681
+ StatusCode::FORBIDDEN,
4682
+ "system_collection",
4683
+ "system collections require their dedicated Service API",
4684
+ ));
4685
+ }
4686
+ contract
4687
+ .state
4688
+ .schema
4689
+ .collections
4690
+ .iter()
4691
+ .find(|value| value.name == collection)
4692
+ .ok_or_else(|| {
4693
+ inspection_error(
4694
+ StatusCode::NOT_FOUND,
4695
+ "collection_not_found",
4696
+ format!("collection {collection} is not part of the active application contract"),
4697
+ )
4698
+ })
4699
+ }
4700
+ fn secret_name(name: &str) -> bool {
4701
+ let value = name.to_ascii_lowercase();
4702
+ [
4703
+ "secret",
4704
+ "password",
4705
+ "token",
4706
+ "api_key",
4707
+ "apikey",
4708
+ "credential",
4709
+ "private_key",
4710
+ ]
4711
+ .iter()
4712
+ .any(|part| value.contains(part))
4713
+ }
4714
+ fn redact_inspection_value(value: &mut Value) {
4715
+ match value {
4716
+ Value::Object(object) => {
4717
+ for (key, child) in object.iter_mut() {
4718
+ if key.eq_ignore_ascii_case("secrets") && child.is_array() {
4719
+ redact_inspection_value(child)
4720
+ } else if secret_name(key) {
4721
+ *child = json!("[REDACTED]")
4722
+ } else {
4723
+ redact_inspection_value(child)
4724
+ }
4725
+ }
4726
+ }
4727
+ Value::Array(values) => {
4728
+ for child in values {
4729
+ redact_inspection_value(child)
4730
+ }
4731
+ }
4732
+ Value::String(text)
4733
+ if text.contains("sk_live_")
4734
+ || text.contains("sk_test_")
4735
+ || text.contains("whsec_")
4736
+ || text.contains("-----BEGIN PRIVATE KEY-----") =>
4737
+ {
4738
+ *value = json!("[REDACTED]")
4739
+ }
4740
+ _ => {}
4741
+ }
4742
+ }
4743
+ fn relationship_metadata(
4744
+ contract: &ApplicationRuntimeContract,
4745
+ collection: &str,
4746
+ record: &Value,
4747
+ ) -> Vec<Value> {
4748
+ let Some(definition) = contract
4749
+ .state
4750
+ .collections
4751
+ .iter()
4752
+ .find(|value| value.name == collection)
4753
+ else {
4754
+ return vec![];
4755
+ };
4756
+ definition.fields.iter().filter_map(|field|field.reference.as_ref().and_then(|target|record.get(&field.name).and_then(Value::as_str).map(|id|json!({"name":field.name,"source_collection":collection,"target_collection":target,"cardinality":"one","foreign_field":field.name,"reference":{"collection":target,"id":id}})))).collect()
4757
+ }
4758
+ fn inspection_cursor(
4759
+ collection: &str,
4760
+ state_version: u64,
4761
+ after: &str,
4762
+ filter_hash: &str,
4763
+ ) -> String {
4764
+ base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json!({"v":1,"state":state_version,"after":after,"query":format!("{:x}",Sha256::digest(format!("{collection}:{filter_hash}").as_bytes()))})).unwrap_or_default())
4765
+ }
4766
+ fn parse_inspection_cursor(
4767
+ cursor: &str,
4768
+ collection: &str,
4769
+ current_version: u64,
4770
+ filter_hash: &str,
4771
+ ) -> Result<String, ApiError> {
4772
+ let value = base64::engine::general_purpose::URL_SAFE_NO_PAD
4773
+ .decode(cursor)
4774
+ .ok()
4775
+ .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
4776
+ .ok_or_else(|| {
4777
+ inspection_error(
4778
+ StatusCode::UNPROCESSABLE_ENTITY,
4779
+ "invalid_cursor",
4780
+ "cursor is malformed",
4781
+ )
4782
+ })?;
4783
+ let expected = format!(
4784
+ "{:x}",
4785
+ Sha256::digest(format!("{collection}:{filter_hash}").as_bytes())
4786
+ );
4787
+ if value.get("v").and_then(Value::as_u64) != Some(1)
4788
+ || value.get("state").and_then(Value::as_u64) != Some(current_version)
4789
+ || value.get("query").and_then(Value::as_str) != Some(&expected)
4790
+ {
4791
+ return Err(inspection_error(
4792
+ StatusCode::UNPROCESSABLE_ENTITY,
4793
+ "invalid_cursor",
4794
+ "cursor is stale or belongs to another query",
4795
+ ));
4796
+ }
4797
+ value
4798
+ .get("after")
4799
+ .and_then(Value::as_str)
4800
+ .map(str::to_string)
4801
+ .ok_or_else(|| {
4802
+ inspection_error(
4803
+ StatusCode::UNPROCESSABLE_ENTITY,
4804
+ "invalid_cursor",
4805
+ "cursor position is invalid",
4806
+ )
4807
+ })
4808
+ }
4809
+ async fn inspect_application_schema(
4810
+ State(state): State<AppState>,
4811
+ Extension(principal): Extension<Principal>,
4812
+ Query(input): Query<ApplicationDiscoveryRequest>,
4813
+ ) -> Result<Json<Value>, ApiError> {
4814
+ let contract = active_inspection_contract(
4815
+ &state,
4816
+ &principal,
4817
+ &input.application_id,
4818
+ &input.environment,
4819
+ )?;
4820
+ let collections=contract.state.collections.iter().map(|collection|{let policy=matching_state_policy(&contract,&collection.name);
4821
+ let relationships=collection.fields.iter().filter_map(|field|field.reference.as_ref().map(|target|json!({"name":field.name,"source_collection":collection.name,"target_collection":target,"cardinality":"one","foreign_field":field.name}))).collect::<Vec<_>>();
4822
+ let reverse=contract.state.collections.iter().flat_map(|source|source.fields.iter().filter_map(|field|field.reference.as_ref().filter(|target|*target==&collection.name).map(|_|json!({"name":format!("{}_{}",source.name,field.name),"source_collection":source.name,"target_collection":collection.name,"cardinality":"many","foreign_field":field.name})))).collect::<Vec<_>>();
4823
+ json!({"collection":collection.name,"description":"","fields":collection.fields,"relationships":relationships.into_iter().chain(reverse).collect::<Vec<_>>(),"indexes":contract.state.indexes.iter().filter(|index|index.collection==collection.name).collect::<Vec<_>>(),"authorization":policy.map(|value|json!({"read":value.read,"write":value.write,"capabilities":value.capabilities}))})}).collect::<Vec<_>>();
4824
+ Ok(Json(
4825
+ json!({"application":{"application_id":input.application_id,"environment":input.environment},"contract_version":contract.revision_number,"collections":collections}),
4826
+ ))
4827
+ }
4828
+ async fn inspect_collection(
4829
+ State(state): State<AppState>,
4830
+ Extension(principal): Extension<Principal>,
4831
+ Path(collection): Path<String>,
4832
+ Query(params): Query<HashMap<String, String>>,
4833
+ ) -> Result<Json<Value>, ApiError> {
4834
+ let application_id = params.get("application_id").ok_or_else(|| {
4835
+ inspection_error(
4836
+ StatusCode::BAD_REQUEST,
4837
+ "inspection_not_allowed",
4838
+ "application_id is required",
4839
+ )
4840
+ })?;
4841
+ let environment = params
4842
+ .get("environment")
4843
+ .map(String::as_str)
4844
+ .unwrap_or("production");
4845
+ let contract = active_inspection_contract(&state, &principal, application_id, environment)?;
4846
+ let definition = inspectable_collection(&contract, &collection)?;
4847
+ let limit = params
4848
+ .get("limit")
4849
+ .map(|value| value.parse::<usize>())
4850
+ .transpose()
4851
+ .map_err(|_| {
4852
+ inspection_error(
4853
+ StatusCode::UNPROCESSABLE_ENTITY,
4854
+ "invalid_pagination",
4855
+ "limit must be an integer",
4856
+ )
4857
+ })?
4858
+ .unwrap_or(DEFAULT_INSPECTION_LIMIT);
4859
+ if limit == 0 || limit > MAX_INSPECTION_LIMIT {
4860
+ return Err(inspection_error(
4861
+ StatusCode::UNPROCESSABLE_ENTITY,
4862
+ "invalid_pagination",
4863
+ format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}"),
4864
+ ));
4865
+ }
4866
+ let reserved = ["application_id", "environment", "limit", "cursor"];
4867
+ let mut filters = Vec::new();
4868
+ for (field, value) in params
4869
+ .iter()
4870
+ .filter(|(key, _)| !reserved.contains(&key.as_str()))
4871
+ {
4872
+ if field.contains('[') || field.contains(']') {
4873
+ return Err(inspection_error(
4874
+ StatusCode::UNPROCESSABLE_ENTITY,
4875
+ "unsupported_filter",
4876
+ format!("unsupported filter operator in {field}"),
4877
+ ));
4878
+ }
4879
+ if !definition
4880
+ .fields
4881
+ .iter()
4882
+ .any(|candidate| candidate.name == *field)
4883
+ {
4884
+ return Err(inspection_error(
4885
+ StatusCode::UNPROCESSABLE_ENTITY,
4886
+ "field_not_found",
4887
+ format!("field {field} is not in collection {collection}"),
4888
+ ));
4889
+ }
4890
+ filters.push(QueryFilter::Eq {
4891
+ field: field.clone(),
4892
+ value: Value::String(value.clone()),
4893
+ });
4894
+ }
4895
+ filters.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}")));
4896
+ let filter_hash = format!(
4897
+ "{:x}",
4898
+ Sha256::digest(serde_json::to_vec(&filters).unwrap_or_default())
4899
+ );
4900
+ let state_version = state.db.sequence()?;
4901
+ let mut after = params
4902
+ .get("cursor")
4903
+ .map(|value| parse_inspection_cursor(value, &collection, state_version, &filter_hash))
4904
+ .transpose()?;
4905
+ let policy = matching_state_policy(&contract, &collection).ok_or_else(|| {
4906
+ inspection_error(
4907
+ StatusCode::FORBIDDEN,
4908
+ "inspection_not_allowed",
4909
+ "collection has no inspection policy",
4910
+ )
4911
+ })?;
4912
+ let read_policy = policy.read.as_deref().and_then(PolicySubject::from_str);
4913
+ if read_policy.is_none()
4914
+ && !policy
4915
+ .capabilities
4916
+ .iter()
4917
+ .any(|value| value == "state:read")
4918
+ {
4919
+ return Err(inspection_error(
4920
+ StatusCode::FORBIDDEN,
4921
+ "forbidden",
4922
+ "application policy denies collection inspection",
4923
+ ));
4924
+ }
4925
+ let auth_state = if matches!(read_policy, Some(PolicySubject::Member)) {
4926
+ Some(AuthorizationState::new(
4927
+ std::sync::Arc::new(contract.state.schema.clone()),
4928
+ contract.environment.state_namespace.clone(),
4929
+ std::sync::Arc::new(state.db.state_rows()?),
4930
+ ))
4931
+ } else {
4932
+ None
4933
+ };
4934
+ let capability = format!("{}:{}", contract.environment.state_namespace, collection);
4935
+ let mut accepted: Vec<(String, Value)> = Vec::new();
4936
+ let mut exhausted = false;
4937
+ while accepted.len() <= limit && !exhausted {
4938
+ let page = state
4939
+ .db
4940
+ .list_collection_page(&capability, after.as_deref(), 256)?;
4941
+ if page.len() < 256 {
4942
+ exhausted = true;
4943
+ }
4944
+ if page.is_empty() {
4945
+ break;
4946
+ }
4947
+ for row in page {
4948
+ after = Some(row.key.clone());
4949
+ if !filters.iter().all(|filter| match filter {
4950
+ QueryFilter::Eq { field, value } => row.value.get(field) == Some(value),
4951
+ _ => false,
4952
+ }) {
4953
+ continue;
4954
+ }
4955
+ if let Some(subject) = read_policy.clone() {
4956
+ let mut context = RecordAuthorizationContext::new(
4957
+ Some(Actor::new(&principal.key_id)),
4958
+ collection.clone(),
4959
+ row.key.clone(),
4960
+ row.value.clone(),
4961
+ );
4962
+ if let Some(snapshot) = auth_state.clone() {
4963
+ context = context.with_state(snapshot);
4964
+ }
4965
+ if PolicyEvaluator::evaluate_record(subject, &context).is_err() {
4966
+ continue;
4967
+ }
4968
+ }
4969
+ accepted.push((row.key, row.value));
4970
+ if accepted.len() > limit {
4971
+ break;
4972
+ }
4973
+ }
4974
+ }
4975
+ let has_more = accepted.len() > limit;
4976
+ let last_key = accepted
4977
+ .get(limit.saturating_sub(1))
4978
+ .map(|value| value.0.clone());
4979
+ let records = accepted
4980
+ .into_iter()
4981
+ .take(limit)
4982
+ .map(|(id, mut record)| {
4983
+ let relationships = relationship_metadata(&contract, &collection, &record);
4984
+ redact_inspection_value(&mut record);
4985
+ if let Some(object) = record.as_object_mut() {
4986
+ object.entry("id").or_insert(json!(id));
4987
+ object.insert("_relationships".into(), json!(relationships));
4988
+ }
4989
+ record
4990
+ })
4991
+ .collect::<Vec<_>>();
4992
+ Ok(Json(
4993
+ json!({"collection":collection,"records":records,"pagination":{"limit":limit,"nextCursor":if has_more{last_key.map(|key|inspection_cursor(&collection,state_version,&key,&filter_hash))}else{None}},"state_version":state_version}),
4994
+ ))
4995
+ }
4996
+ async fn inspect_record(
4997
+ State(state): State<AppState>,
4998
+ Extension(principal): Extension<Principal>,
4999
+ Path((collection, id)): Path<(String, String)>,
5000
+ Query(params): Query<HashMap<String, String>>,
5001
+ ) -> Result<Json<Value>, ApiError> {
5002
+ let application_id = params.get("application_id").ok_or_else(|| {
5003
+ inspection_error(
5004
+ StatusCode::BAD_REQUEST,
5005
+ "inspection_not_allowed",
5006
+ "application_id is required",
5007
+ )
5008
+ })?;
5009
+ let environment = params
5010
+ .get("environment")
5011
+ .map(String::as_str)
5012
+ .unwrap_or("production");
5013
+ let contract = active_inspection_contract(&state, &principal, application_id, environment)?;
5014
+ inspectable_collection(&contract, &collection)?;
5015
+ let policy = matching_state_policy(&contract, &collection).ok_or_else(|| {
5016
+ inspection_error(
5017
+ StatusCode::FORBIDDEN,
5018
+ "inspection_not_allowed",
5019
+ "collection has no inspection policy",
5020
+ )
5021
+ })?;
5022
+ let read_policy = policy.read.as_deref().and_then(PolicySubject::from_str);
5023
+ if read_policy.is_none()
5024
+ && !policy
5025
+ .capabilities
5026
+ .iter()
5027
+ .any(|value| value == "state:read")
5028
+ {
5029
+ return Err(inspection_error(
5030
+ StatusCode::FORBIDDEN,
5031
+ "forbidden",
5032
+ "application policy denies record inspection",
5033
+ ));
5034
+ }
5035
+ let capability = format!("{}:{}", contract.environment.state_namespace, collection);
5036
+ let row = state
5037
+ .db
5038
+ .get_collection_record(&capability, &id)?
5039
+ .ok_or_else(|| {
5040
+ inspection_error(
5041
+ StatusCode::NOT_FOUND,
5042
+ "record_not_found",
5043
+ "record not found or is not authorized",
5044
+ )
5045
+ })?;
5046
+ if let Some(subject) = read_policy {
5047
+ let mut context = RecordAuthorizationContext::new(
5048
+ Some(Actor::new(&principal.key_id)),
5049
+ collection.clone(),
5050
+ id.clone(),
5051
+ row.value.clone(),
5052
+ );
5053
+ if matches!(subject, PolicySubject::Member) {
5054
+ context = context.with_state(AuthorizationState::new(
5055
+ std::sync::Arc::new(contract.state.schema.clone()),
5056
+ contract.environment.state_namespace.clone(),
5057
+ std::sync::Arc::new(state.db.state_rows()?),
5058
+ ));
5059
+ }
5060
+ if PolicyEvaluator::evaluate_record(subject, &context).is_err() {
5061
+ return Err(inspection_error(
5062
+ StatusCode::NOT_FOUND,
5063
+ "record_not_found",
5064
+ "record not found or is not authorized",
5065
+ ));
5066
+ }
5067
+ }
5068
+ let mut record = row.value;
5069
+ let relationships = relationship_metadata(&contract, &collection, &record);
5070
+ redact_inspection_value(&mut record);
5071
+ if let Some(object) = record.as_object_mut() {
5072
+ object.insert("id".into(), json!(id));
5073
+ }
5074
+ Ok(Json(
5075
+ json!({"collection":collection,"record":record,"relationships":relationships}),
5076
+ ))
5077
+ }
5078
+ const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
5079
+ const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
5080
+ const PROPOSAL_PREVIEW_COLLECTION: &str = "_feltdb_proposal_previews";
5081
+ const CONVERGENCE_COLLECTION: &str = "_feltdb_convergence";
5082
+ const INTENT_COLLECTION: &str = "_feltdb_intents";
5083
+ const INTENT_EVENT_COLLECTION: &str = "_feltdb_intent_events";
5084
+
5085
+ 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();
5088
+ if Sha256::digest(supplied.as_bytes()) != Sha256::digest(expected.as_bytes()) {
5089
+ return Err(inspection_error(StatusCode::FORBIDDEN, "forbidden", "invalid Intent Engine service credential"));
5090
+ }
5091
+ Ok(())
5092
+ }
5093
+
5094
+ async fn execute_intent(
5095
+ State(state): State<AppState>, Extension(principal): Extension<Principal>, headers: HeaderMap,
5096
+ Json(input): Json<ExecuteIntentRequest>,
5097
+ ) -> 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"))); }
5107
+ Ok(Json(value))
5108
+ }
5109
+
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})))
5114
+ }
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))
5118
+ }
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
+
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);
5132
+ json!({
5133
+ "application":{"application_id":revision.application_id,"environment":environment},
5134
+ "contract_version":revision.revision_number,"contract_hash":contract_hash,"flow_hash":flow_hash,
5135
+ "dsl_version":dsl_version,"schema":revision.manifest.collections,"indexes":revision.manifest.indexes,
5136
+ "policies":revision.manifest.policies,"workflows":revision.manifest.workflows,"agents":revision.manifest.agents,
5137
+ "capabilities":revision.manifest.capabilities,"modules":revision.manifest.modules,
5138
+ "supported_primitives":["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
5139
+ })
5140
+ }
5141
+
5142
+ fn proposal_hash(value: &Value) -> String {
5143
+ format!("sha256:{:x}", Sha256::digest(serde_json::to_vec(value).unwrap_or_default()))
5144
+ }
5145
+
5146
+ 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)
5148
+ }
5149
+
5150
+ fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
5151
+ required_value(state, PROPOSAL_PREVIEW_COLLECTION, id, "preview not found")
5152
+ }
5153
+
5154
+ fn preview_available(value: &Value) -> bool {
5155
+ 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())
5157
+ }
5158
+
5159
+ fn proposal_event(
5160
+ state: &AppState,
5161
+ proposal_id: &str,
5162
+ from: Option<&str>,
5163
+ to: &str,
5164
+ actor: &str,
5165
+ ) -> Result<(), ApiError> {
5166
+ let id = format!("pe_{}", uuid::Uuid::new_v4().simple());
5167
+ let proposal = proposal_value(state, proposal_id).unwrap_or(Value::Null);
5168
+ let event_sequence = state.db.sequence()?.saturating_add(1);
5169
+ insert_canonical(
5170
+ state,
5171
+ PROPOSAL_EVENT_COLLECTION,
5172
+ &id,
5173
+ json!({ "id":id, "event_id":id, "proposal_id":proposal_id, "event_type":to,
5174
+ "from_status":from, "to_status":to, "actor":actor, "created_at":unix_seconds_i64(),
5175
+ "timestamp":unix_seconds_i64(), "event_sequence":event_sequence, "contract_hash":proposal.get("base_contract_hash"),
5176
+ "flow_hash":proposal.get("base_flow_hash"), "metadata":{} }),
5177
+ )?;
5178
+ Ok(())
5179
+ }
5180
+ fn proposal_value(state: &AppState, id: &str) -> Result<Value, ApiError> {
5181
+ required_value(state, PROPOSAL_COLLECTION, id, "proposal not found")
5182
+ }
5183
+ fn proposal_scope_matches(value: &Value, application_id: &str, environment: &str) -> bool {
5184
+ value.get("application_id").and_then(Value::as_str) == Some(application_id)
5185
+ && value.get("environment").and_then(Value::as_str) == Some(environment)
5186
+ }
5187
+ async fn create_proposal(
5188
+ State(state): State<AppState>,
5189
+ Extension(principal): Extension<Principal>,
5190
+ Json(input): Json<CreateProposalRequest>,
5191
+ ) -> Result<(StatusCode, Json<Value>), ApiError> {
5192
+ if input.status.is_some() {
5193
+ return Err(inspection_error(StatusCode::BAD_REQUEST, "invalid_proposal", "proposal lifecycle state is server-controlled"));
5194
+ }
5195
+ if input.proposal_version.unwrap_or(1) != 1 {
5196
+ return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", "proposal_version must be 1"));
5197
+ }
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))
5200
+ {
5201
+ return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", "contract_diff and source_plan.files are required arrays"));
5202
+ }
5203
+ let tenant = application_scope(
5204
+ &state,
5205
+ &principal.key_id,
5206
+ &input.application_id,
5207
+ "application:write",
5208
+ )?;
5209
+ let revision_id = state
5210
+ .applications
5211
+ .pointers(&input.application_id)
5212
+ .get(&input.environment)
5213
+ .cloned()
5214
+ .ok_or(ApiError(
5215
+ StatusCode::NOT_FOUND,
5216
+ "application environment has no active contract".into(),
5217
+ ))?;
5218
+ let revision = state
5219
+ .applications
5220
+ .revision(&tenant, &input.application_id, &revision_id)
5221
+ .ok_or(ApiError(
5222
+ StatusCode::NOT_FOUND,
5223
+ "application revision not found".into(),
5224
+ ))?;
5225
+ let contract_hash = revision
5226
+ .manifest
5227
+ .metadata
5228
+ .labels
5229
+ .get("feltdb.contract_hash")
5230
+ .unwrap_or(&revision.manifest_hash);
5231
+ if input.base_contract_hash != *contract_hash {
5232
+ return Err(ApiError(
5233
+ StatusCode::CONFLICT,
5234
+ "STALE_PROPOSAL: base contract does not match active contract".into(),
5235
+ ));
5236
+ }
5237
+ if input.proposed_flow.trim().is_empty() || input.base_flow_hash.trim().is_empty() {
5238
+ return Err(ApiError(
5239
+ StatusCode::BAD_REQUEST,
5240
+ "proposed_flow and base_flow_hash are required".into(),
5241
+ ));
5242
+ }
5243
+ if input.metadata.get("created_from").and_then(Value::as_str) == Some("module-intent")
5244
+ && input.understanding.is_none()
5245
+ {
5246
+ return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "understanding_required", "module proposals require an evidence-backed understanding snapshot"));
5247
+ }
5248
+ 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:")))
5251
+ {
5252
+ return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_understanding", "understanding requires evidence_hash and understanding_hash fingerprints"));
5253
+ }
5254
+ 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()
5257
+ {
5258
+ return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_understanding", "understanding fingerprints must match the Proposal fingerprints"));
5259
+ }
5260
+ }
5261
+ let id = format!("p_{}", uuid::Uuid::new_v4().simple());
5262
+ let now = unix_seconds_i64();
5263
+ let mut value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
5264
+ "status":"proposed", "proposal_version":1, "base_contract_version":revision.revision_number,
5265
+ "base_contract_hash":input.base_contract_hash, "base_flow_hash":input.base_flow_hash, "proposed_flow":input.proposed_flow,
5266
+ "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,
5267
+ "created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
5268
+ "parent_proposal_id":input.parent_proposal_id,
5269
+ "understanding":input.understanding, "evidence_hash":input.evidence_hash, "understanding_hash":input.understanding_hash,
5270
+ "base_module_versions": revision.manifest.modules.iter().filter_map(|module| Some(json!({
5271
+ "id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
5272
+ }))).collect::<Vec<_>>(),
5273
+ "module_versions": revision.manifest.modules.iter().filter_map(|module| Some(json!({
5274
+ "id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
5275
+ }))).collect::<Vec<_>>() });
5276
+ redact_inspection_value(&mut value);
5277
+ insert_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?;
5278
+ proposal_event(&state, &id, None, "proposed", &principal.key_id)?;
5279
+ Ok((StatusCode::CREATED, Json(value)))
5280
+ }
5281
+ async fn list_proposals(
5282
+ State(state): State<AppState>,
5283
+ Extension(principal): Extension<Principal>,
5284
+ Query(input): Query<ProposalScopeRequest>,
5285
+ ) -> Result<Json<Value>, ApiError> {
5286
+ application_scope(
5287
+ &state,
5288
+ &principal.key_id,
5289
+ &input.application_id,
5290
+ "application:read",
5291
+ )?;
5292
+ let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
5293
+ 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}")));
5295
+ }
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"));
5298
+ }
5299
+ let filter_hash = format!("{}:{}:{}", input.application_id, input.environment, input.status.as_deref().unwrap_or("*"));
5300
+ 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()?;
5302
+ let mut accepted = Vec::new();
5303
+ let mut exhausted = false;
5304
+ 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; }
5308
+ for row in page {
5309
+ 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; }
5312
+ let mut value = row.value;
5313
+ redact_inspection_value(&mut value);
5314
+ accepted.push((row.key, value));
5315
+ if accepted.len() > limit { break; }
5316
+ }
5317
+ }
5318
+ 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})))
5323
+ }
5324
+ async fn get_proposal(
5325
+ State(state): State<AppState>,
5326
+ Extension(principal): Extension<Principal>,
5327
+ Path(id): Path<String>,
5328
+ Query(input): Query<ProposalScopeRequest>,
5329
+ ) -> Result<Json<Value>, ApiError> {
5330
+ application_scope(
5331
+ &state,
5332
+ &principal.key_id,
5333
+ &input.application_id,
5334
+ "application:read",
5335
+ )?;
5336
+ let mut value = proposal_value(&state, &id)?;
5337
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5338
+ return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
5339
+ }
5340
+ let mut value = value;
5341
+ redact_inspection_value(&mut value);
5342
+ Ok(Json(value))
5343
+ }
5344
+ async fn proposal_history(
5345
+ State(state): State<AppState>,
5346
+ Extension(principal): Extension<Principal>,
5347
+ Path(id): Path<String>,
5348
+ Query(input): Query<ProposalScopeRequest>,
5349
+ ) -> Result<Json<Value>, ApiError> {
5350
+ let _ = get_proposal(
5351
+ State(state.clone()),
5352
+ Extension(principal),
5353
+ Path(id.clone()),
5354
+ Query(input),
5355
+ )
5356
+ .await?;
5357
+ let mut events: Vec<Value> = state
5358
+ .db
5359
+ .list_collection(PROPOSAL_EVENT_COLLECTION)?
5360
+ .into_iter()
5361
+ .map(|row| row.value)
5362
+ .filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str()))
5363
+ .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); }
5366
+ Ok(Json(json!({"events":events})))
5367
+ }
5368
+ fn move_proposal(
5369
+ state: &AppState,
5370
+ id: &str,
5371
+ mut value: Value,
5372
+ to: &str,
5373
+ actor: &str,
5374
+ ) -> Result<Value, ApiError> {
5375
+ let from = value
5376
+ .get("status")
5377
+ .and_then(Value::as_str)
5378
+ .unwrap_or("")
5379
+ .to_string();
5380
+ let allowed = matches!(
5381
+ (from.as_str(), to),
5382
+ ("proposed", "validated")
5383
+ | ("proposed", "rejected")
5384
+ | ("validated", "previewed")
5385
+ | ("validated", "rejected")
5386
+ | ("previewed", "approved")
5387
+ | ("previewed", "rejected")
5388
+ | ("approved", "applied")
5389
+ | ("approved", "expired")
5390
+ );
5391
+ if !allowed {
5392
+ return Err(ApiError(
5393
+ StatusCode::CONFLICT,
5394
+ format!("INVALID_PROPOSAL_TRANSITION: {from} -> {to}"),
5395
+ ));
5396
+ }
5397
+ value["status"] = json!(to);
5398
+ if to == "approved" {
5399
+ value["approved_by"] = json!(actor);
5400
+ value["approved_at"] = json!(unix_seconds_i64());
5401
+ }
5402
+ if to == "applied" {
5403
+ value["applied_at"] = json!(unix_seconds_i64());
5404
+ }
5405
+ if to == "rejected" {
5406
+ value["rejected_by"] = json!(actor);
5407
+ value["rejected_at"] = json!(unix_seconds_i64());
5408
+ }
5409
+ let _ = put_canonical(state, PROPOSAL_COLLECTION, id, value.clone())?;
5410
+ proposal_event(state, id, Some(&from), to, actor)?;
5411
+ Ok(value)
5412
+ }
5413
+
5414
+ 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())
5416
+ }
5417
+
5418
+ fn proposal_readiness_value(
5419
+ state: &AppState,
5420
+ tenant: &str,
5421
+ value: &Value,
5422
+ current_evidence_hash: Option<&str>,
5423
+ ) -> 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("");
5432
+ 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
+ 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));
5437
+ let expired = proposal_is_expired(value);
5438
+ 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"); }
5444
+ let stale = contract_changed || flow_changed || modules_changed || evidence_changed;
5445
+ Ok(json!({
5446
+ "status":value.get("status"), "readiness":if blockers.is_empty(){"ready"}else{"blocked"},
5447
+ "blockers":blockers, "notes":[], "current_contract_hash":current_contract_hash,
5448
+ "proposal_contract_hash":proposal_contract_hash, "current_flow_hash":current_flow_hash,
5449
+ "proposal_flow_hash":proposal_flow_hash, "stale":stale, "expires_at":value.get("expires_at")
5450
+ }))
5451
+ }
5452
+
5453
+ async fn proposal_status(
5454
+ State(state): State<AppState>,
5455
+ Extension(principal): Extension<Principal>,
5456
+ Path(id): Path<String>,
5457
+ Query(input): Query<ProposalScopeRequest>,
5458
+ ) -> 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"));
5463
+ }
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})))
5487
+ }
5488
+ async fn validate_proposal(
5489
+ State(state): State<AppState>,
5490
+ Extension(principal): Extension<Principal>,
5491
+ Path(id): Path<String>,
5492
+ Json(input): Json<ProposalActorRequest>,
5493
+ ) -> Result<Json<Value>, ApiError> {
5494
+ let tenant = application_scope(
5495
+ &state,
5496
+ &principal.key_id,
5497
+ &input.application_id,
5498
+ "application:write",
5499
+ )?;
5500
+ let mut value = proposal_value(&state, &id)?;
5501
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5502
+ return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
5503
+ }
5504
+ if value.get("status").and_then(Value::as_str) == Some("validated") {
5505
+ redact_inspection_value(&mut value);
5506
+ return Ok(Json(value));
5507
+ }
5508
+ let revision_id = state
5509
+ .applications
5510
+ .pointers(&input.application_id)
5511
+ .get(&input.environment)
5512
+ .cloned()
5513
+ .ok_or(ApiError(
5514
+ StatusCode::NOT_FOUND,
5515
+ "active contract not found".into(),
5516
+ ))?;
5517
+ let revision = state
5518
+ .applications
5519
+ .revision(&tenant, &input.application_id, &revision_id)
5520
+ .ok_or(ApiError(
5521
+ StatusCode::NOT_FOUND,
5522
+ "application revision not found".into(),
5523
+ ))?;
5524
+ let hash = revision
5525
+ .manifest
5526
+ .metadata
5527
+ .labels
5528
+ .get("feltdb.contract_hash")
5529
+ .unwrap_or(&revision.manifest_hash);
5530
+ if value.get("base_contract_hash").and_then(Value::as_str) != Some(hash) {
5531
+ return Err(ApiError(StatusCode::CONFLICT, "STALE_PROPOSAL".into()));
5532
+ }
5533
+ if let Some(manifest) = input.proposed_manifest {
5534
+ let report = validate_manifest(
5535
+ &manifest,
5536
+ &tenant,
5537
+ &input.application_id,
5538
+ Some(&revision.manifest),
5539
+ );
5540
+ if !report.valid {
5541
+ return Err(ApiError::structured(
5542
+ StatusCode::UNPROCESSABLE_ENTITY,
5543
+ json!({"code":"INVALID_PROPOSAL","validation":report}),
5544
+ ));
5545
+ }
5546
+ value["module_versions"] = json!(manifest.modules.iter().filter_map(|module| Some(json!({
5547
+ "id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
5548
+ }))).collect::<Vec<_>>());
5549
+ let module_ids = manifest
5550
+ .modules
5551
+ .iter()
5552
+ .filter_map(|module| module.get("id").and_then(Value::as_str))
5553
+ .collect::<Vec<_>>();
5554
+ let module_models = manifest
5555
+ .collections
5556
+ .iter()
5557
+ .flat_map(|collection| collection.fields.iter())
5558
+ .filter_map(|field| field.reference.as_deref())
5559
+ .filter(|reference| {
5560
+ module_ids
5561
+ .iter()
5562
+ .any(|id| reference.starts_with(&format!("{id}.")))
5563
+ })
5564
+ .collect::<Vec<_>>();
5565
+ let relationships = manifest
5566
+ .collections
5567
+ .iter()
5568
+ .flat_map(|collection| {
5569
+ collection.fields.iter().filter_map(|field| {
5570
+ field.reference.as_ref().map(|reference| {
5571
+ format!("{}.{} -> {}", collection.name, field.name, reference)
5572
+ })
5573
+ })
5574
+ })
5575
+ .collect::<Vec<_>>();
5576
+ let ownership = manifest
5577
+ .modules
5578
+ .iter()
5579
+ .flat_map(|module| {
5580
+ let id = module.get("id").and_then(Value::as_str).unwrap_or("");
5581
+ module
5582
+ .get("models")
5583
+ .and_then(Value::as_array)
5584
+ .into_iter()
5585
+ .flatten()
5586
+ .filter_map(move |model| {
5587
+ Some((
5588
+ format!("{}.{}", id, model.get("name")?.as_str()?),
5589
+ Value::String(model.get("ownership")?.as_str()?.to_string()),
5590
+ ))
5591
+ })
5592
+ })
5593
+ .collect::<serde_json::Map<String, Value>>();
5594
+ value["module_impact"] = json!({
5595
+ "modules":module_ids, "models":module_models, "relationships":relationships,
5596
+ "events":manifest.triggers.iter().filter(|trigger| module_ids.iter().any(|id| trigger.event.starts_with(&format!("{id}.")))).map(|trigger|trigger.event.clone()).collect::<Vec<_>>(),
5597
+ "workflows":manifest.workflows.iter().map(|workflow|workflow.name.clone()).collect::<Vec<_>>(),
5598
+ "capabilities":manifest.capabilities.iter().filter(|capability| capability.provider.as_deref().map(|provider|module_ids.contains(&provider)).unwrap_or(false)).map(|capability|capability.name.clone()).collect::<Vec<_>>(),
5599
+ "authorization":manifest.policies.iter().map(|policy|policy.name.clone()).collect::<Vec<_>>(),
5600
+ "configuration":manifest.modules.iter().flat_map(|module| module.get("configuration").and_then(Value::as_array).into_iter().flatten().filter_map(|item|item.get("name").cloned())).collect::<Vec<_>>(),
5601
+ "secrets":manifest.modules.iter().flat_map(|module| module.get("secrets").and_then(Value::as_array).into_iter().flatten().filter_map(|item|item.get("name").cloned())).collect::<Vec<_>>(),
5602
+ "ownership":ownership,
5603
+ "state_flow":manifest.modules.iter().flat_map(|module| module.get("reconciliation").and_then(Value::as_array).into_iter().flatten().filter_map(|item| Some(format!("{} -> {} -> {}",item.get("event")?.as_str()?,item.get("workflow")?.as_str()?,item.get("outcome")?.as_str()?)))).collect::<Vec<_>>()
5604
+ });
5605
+ let proposed = ApplicationRevision {
5606
+ revision_id: format!("proposal://{id}"),
5607
+ application_id: input.application_id.clone(),
5608
+ tenant_id: tenant,
5609
+ revision_number: revision.revision_number + 1,
5610
+ parent_revision_id: Some(revision.revision_id.clone()),
5611
+ manifest_hash: String::new(),
5612
+ manifest,
5613
+ created_by: principal.key_id.clone(),
5614
+ created_at: unix_seconds_i64() as u64,
5615
+ status: RevisionStatus::Committed,
5616
+ };
5617
+ value["contract_diff"] =
5618
+ serde_json::to_value(diff_manifests(Some(&revision), &proposed).changes)
5619
+ .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
5620
+ value["proposed_contract"] = serde_json::to_value(&proposed.manifest)
5621
+ .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
5622
+ }
5623
+ if !value.pointer("/source_plan/files").and_then(Value::as_array).is_some_and(|files| !files.is_empty()) {
5624
+ value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
5625
+ }
5626
+ Ok(Json(move_proposal(
5627
+ &state,
5628
+ &id,
5629
+ value,
5630
+ "validated",
5631
+ &principal.key_id,
5632
+ )?))
5633
+ }
5634
+ async fn preview_proposal(
5635
+ State(state): State<AppState>,
5636
+ Extension(principal): Extension<Principal>,
5637
+ Path(id): Path<String>,
5638
+ Json(input): Json<ProposalActorRequest>,
5639
+ ) -> Result<Json<Value>, ApiError> {
5640
+ let tenant = application_scope(
5641
+ &state,
5642
+ &principal.key_id,
5643
+ &input.application_id,
5644
+ "application:write",
5645
+ )?;
5646
+ let mut value = proposal_value(&state, &id)?;
5647
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5648
+ return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
5649
+ }
5650
+ let replacing_expired_preview = value.get("status").and_then(Value::as_str) == Some("previewed");
5651
+ if replacing_expired_preview {
5652
+ if let Some(preview_id) = value.get("preview_id").and_then(Value::as_str) {
5653
+ let mut artifact = preview_artifact(&state, preview_id)?;
5654
+ if preview_available(&artifact) {
5655
+ let mut proposal = value.clone();
5656
+ redact_inspection_value(&mut proposal);
5657
+ redact_inspection_value(&mut artifact);
5658
+ if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
5659
+ artifact["proposal"] = proposal;
5660
+ return Ok(Json(artifact));
5661
+ }
5662
+ }
5663
+ }
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"));
5666
+ }
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"))?;
5670
+ let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
5671
+ 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})));
5673
+ }
5674
+ let preview_id = format!("pv_{}", uuid::Uuid::new_v4().simple());
5675
+ 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})))?;
5682
+ let current_contract = proposal_contract_snapshot(&current_revision, &input.environment);
5683
+ let mut proposed_contract = proposal_contract_snapshot(&proposed_revision, &input.environment);
5684
+ 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())));
5686
+ let namespace = runtime.environment.state_namespace.clone();
5687
+ let mut seeded_records = Vec::new();
5688
+ for collection in &proposed_revision.manifest.collections {
5689
+ if collection.name.starts_with("_feltdb") { continue; }
5690
+ let record_id = format!("preview_{}", collection.name.to_ascii_lowercase());
5691
+ 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}));
5694
+ }
5695
+ let now = unix_seconds_i64();
5696
+ let mut artifact = json!({
5697
+ "preview_id":preview_id,"proposal_id":id,"application_id":input.application_id,"environment":input.environment,
5698
+ "proposal_hash":proposal_hash(&value),"proposal_contract_hash":value.get("base_contract_hash"),"proposal_flow_hash":value.get("base_flow_hash"),
5699
+ "module_versions":value.get("module_versions"),"created_at":now,"expires_at":now+preview_ttl_seconds(),"status":"ready",
5700
+ "namespace":namespace,"current":current_contract,"proposed":proposed_contract,"current_contract":current_contract,
5701
+ "proposed_contract":proposed_contract,"semantic_diff":value.get("contract_diff"),"state_flow":value.pointer("/module_impact/state_flow"),
5702
+ "capabilities":runtime.capabilities,"workflows":runtime.workflows,"authorization":runtime.policies,
5703
+ "configuration":value.pointer("/module_impact/configuration"),"secrets":value.pointer("/module_impact/secrets"),
5704
+ "modules":value.pointer("/module_impact/modules"),"ownership":value.pointer("/module_impact/ownership"),
5705
+ "relationships":value.pointer("/module_impact/relationships"),"events":value.pointer("/module_impact/events"),
5706
+ "seeded_records":seeded_records,"data_classification":"synthetic_preview","external_side_effects":"simulated","readiness":readiness
5707
+ });
5708
+ 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())?;
5710
+ value["preview_id"] = artifact.get("preview_id").cloned().unwrap_or(Value::Null);
5711
+ value["preview_expires_at"] = artifact.get("expires_at").cloned().unwrap_or(Value::Null);
5712
+ let mut stored_proposal = if replacing_expired_preview {
5713
+ let _ = put_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?;
5714
+ value
5715
+ } else {
5716
+ move_proposal(&state, &id, value, "previewed", &principal.key_id)?
5717
+ };
5718
+ redact_inspection_value(&mut stored_proposal);
5719
+ if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
5720
+ artifact["proposal"] = stored_proposal;
5721
+ Ok(Json(artifact))
5722
+ }
5723
+
5724
+ async fn get_proposal_preview(
5725
+ State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>,
5726
+ Query(input): Query<ProposalScopeRequest>,
5727
+ ) -> Result<Json<Value>, ApiError> {
5728
+ let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
5729
+ let mut artifact = preview_artifact(&state, &id)?;
5730
+ if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
5731
+ return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found"));
5732
+ }
5733
+ if !preview_available(&artifact) {
5734
+ return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired"));
5735
+ }
5736
+ let proposal = proposal_value(&state, artifact.get("proposal_id").and_then(Value::as_str).unwrap_or(""))?;
5737
+ if proposal.get("preview_id") != artifact.get("preview_id") {
5738
+ return Err(inspection_error(StatusCode::CONFLICT, "preview_invalidated", "preview has been replaced"));
5739
+ }
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"));
5742
+ }
5743
+ redact_inspection_value(&mut artifact);
5744
+ if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
5745
+ Ok(Json(artifact))
5746
+ }
5747
+
5748
+ async fn inspect_proposal_preview_data(
5749
+ State(state): State<AppState>, Extension(principal): Extension<Principal>, Path((id, collection)): Path<(String, String)>,
5750
+ Query(input): Query<ProposalScopeRequest>,
5751
+ ) -> Result<Json<Value>, ApiError> {
5752
+ application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
5753
+ let artifact = preview_artifact(&state, &id)?;
5754
+ if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
5755
+ return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found"));
5756
+ }
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"));
5760
+ }
5761
+ 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}"))); }
5763
+ let state_version = state.db.sequence()?;
5764
+ 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))?;
5768
+ let has_more = rows.len() > limit;
5769
+ rows.truncate(limit);
5770
+ let last_key = rows.last().map(|row| row.key.clone());
5771
+ 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}})))
5775
+ }
5776
+
5777
+ 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>,
5780
+ ) -> Result<Json<Value>, ApiError> {
5781
+ application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
5782
+ 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"})))
5789
+ }
5790
+ async fn transition_proposal(
5791
+ State(state): State<AppState>,
5792
+ Extension(principal): Extension<Principal>,
5793
+ headers: HeaderMap,
5794
+ Path(id): Path<String>,
5795
+ Json(input): Json<ProposalStatusRequest>,
5796
+ ) -> Result<Json<Value>, ApiError> {
5797
+ let tenant = application_scope(
5798
+ &state,
5799
+ &principal.key_id,
5800
+ &input.application_id,
5801
+ "application:write",
5802
+ )?;
5803
+ let mut value = proposal_value(&state, &id)?;
5804
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
5805
+ return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
5806
+ }
5807
+ if !matches!(
5808
+ input.status.as_str(),
5809
+ "approved" | "rejected" | "expired" | "applied"
5810
+ ) {
5811
+ return Err(ApiError(
5812
+ StatusCode::BAD_REQUEST,
5813
+ "unsupported proposal status".into(),
5814
+ ));
5815
+ }
5816
+ if proposal_is_expired(&value) && input.status != "expired" {
5817
+ return Err(inspection_error(StatusCode::CONFLICT, "proposal_expired", "proposal has expired"));
5818
+ }
5819
+ if input.status == "approved" {
5820
+ let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
5821
+ 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})));
5823
+ }
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"))?;
5825
+ let artifact = preview_artifact(&state, preview_id)?;
5826
+ let bound = preview_available(&artifact)
5827
+ && artifact.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())
5828
+ && artifact.get("proposal_contract_hash") == value.get("base_contract_hash")
5829
+ && artifact.get("proposal_flow_hash") == value.get("base_flow_hash")
5830
+ && 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")); }
5832
+ }
5833
+ if input.status == "applied" {
5834
+ 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());
5838
+ if !repository_apply || !authorized {
5839
+ return Err(inspection_error(StatusCode::FORBIDDEN, "repository_authority_required", "valid repository application authority is required"));
5840
+ }
5841
+ }
5842
+ if input.status == "approved"
5843
+ && value
5844
+ .get("warnings")
5845
+ .and_then(Value::as_array)
5846
+ .map(|items| {
5847
+ items
5848
+ .iter()
5849
+ .any(|item| item.as_str().unwrap_or("").starts_with("Authorization "))
5850
+ })
5851
+ .unwrap_or(false)
5852
+ && !input.approve_authorization
5853
+ {
5854
+ return Err(ApiError(
5855
+ StatusCode::FORBIDDEN,
5856
+ "AUTHORIZATION_APPROVAL_REQUIRED".into(),
5857
+ ));
5858
+ }
5859
+ if input.status == "rejected" {
5860
+ if let Some(reason) = input.reason { value["rejection_reason"] = json!(reason); }
5861
+ }
5862
+ let mut moved = move_proposal(
5863
+ &state,
5864
+ &id,
5865
+ value,
5866
+ &input.status,
5867
+ &principal.key_id,
5868
+ )?;
5869
+ redact_inspection_value(&mut moved);
5870
+ Ok(Json(moved))
4448
5871
  }
4449
- async fn discover_application(
4450
- State(state): State<AppState>,
4451
- Extension(principal): Extension<Principal>,
4452
- Query(input): Query<ApplicationDiscoveryRequest>,
5872
+
5873
+ async fn approve_proposal(
5874
+ state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
5875
+ path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
4453
5876
  ) -> Result<Json<Value>, ApiError> {
4454
- let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
4455
- let revision_id = state.applications.pointers(&input.application_id)
4456
- .get(&input.environment).cloned()
4457
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
4458
- let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
4459
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
4460
- let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
4461
- let dsl_version = revision.manifest.metadata.labels.get("feltdb.dsl_version").and_then(|value| value.parse::<u32>().ok()).unwrap_or(1);
4462
- Ok(Json(json!({
4463
- "application_id": input.application_id,
4464
- "environment": input.environment,
4465
- "version": revision.revision_number,
4466
- "revision_id": revision.revision_id,
4467
- "contract_hash": contract_hash,
4468
- "dsl_version": dsl_version,
4469
- "snapshot": {
4470
- "application": { "application_id": input.application_id, "environment": input.environment },
4471
- "contract_version": revision.revision_number,
4472
- "contract_hash": contract_hash,
4473
- "dsl_version": dsl_version,
4474
- "schema": revision.manifest.collections,
4475
- "indexes": revision.manifest.indexes,
4476
- "policies": revision.manifest.policies,
4477
- "workflows": revision.manifest.workflows,
4478
- "agents": revision.manifest.agents,
4479
- "capabilities": revision.manifest.capabilities,
4480
- "supported_primitives": ["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
4481
- }
4482
- })))
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
4483
5881
  }
4484
- const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
4485
- const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
4486
5882
 
4487
- fn proposal_event(state: &AppState, proposal_id: &str, from: Option<&str>, to: &str, actor: &str) -> Result<(), ApiError> {
4488
- let id = format!("pe_{}", uuid::Uuid::new_v4().simple());
4489
- insert_canonical(state, PROPOSAL_EVENT_COLLECTION, &id, json!({ "id":id, "proposal_id":proposal_id,
4490
- "from_status":from, "to_status":to, "actor":actor, "created_at":unix_seconds_i64(), "metadata":{} }))?;
4491
- Ok(())
4492
- }
4493
- fn proposal_value(state: &AppState, id: &str) -> Result<Value, ApiError> {
4494
- required_value(state, PROPOSAL_COLLECTION, id, "proposal not found")
4495
- }
4496
- fn proposal_scope_matches(value: &Value, application_id: &str, environment: &str) -> bool {
4497
- value.get("application_id").and_then(Value::as_str) == Some(application_id)
4498
- && value.get("environment").and_then(Value::as_str) == Some(environment)
4499
- }
4500
- async fn create_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Json(input): Json<CreateProposalRequest>) -> Result<(StatusCode, Json<Value>), ApiError> {
4501
- let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
4502
- let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned()
4503
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
4504
- let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
4505
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
4506
- let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
4507
- if input.base_contract_hash != *contract_hash { return Err(ApiError(StatusCode::CONFLICT, "STALE_PROPOSAL: base contract does not match active contract".into())); }
4508
- if input.proposed_flow.trim().is_empty() || input.base_flow_hash.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "proposed_flow and base_flow_hash are required".into())); }
4509
- let id = format!("p_{}", uuid::Uuid::new_v4().simple()); let now = unix_seconds_i64();
4510
- let value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
4511
- "status":"proposed", "proposal_version":1, "base_contract_version":revision.revision_number,
4512
- "base_contract_hash":input.base_contract_hash, "base_flow_hash":input.base_flow_hash, "proposed_flow":input.proposed_flow,
4513
- "contract_diff":[], "source_plan":{"files":[]}, "summary":input.summary, "warnings":input.warnings,
4514
- "created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
4515
- "parent_proposal_id":input.parent_proposal_id });
4516
- insert_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?; proposal_event(&state, &id, None, "proposed", &principal.key_id)?;
4517
- Ok((StatusCode::CREATED, Json(value)))
4518
- }
4519
- async fn list_proposals(State(state): State<AppState>, Extension(principal): Extension<Principal>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
4520
- application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
4521
- let proposals: Vec<Value> = state.db.list_collection(PROPOSAL_COLLECTION)?.into_iter().map(|row| row.value)
4522
- .filter(|value| proposal_scope_matches(value, &input.application_id, &input.environment)).collect();
4523
- Ok(Json(json!({"proposals":proposals})))
4524
- }
4525
- async fn get_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
4526
- application_scope(&state, &principal.key_id, &input.application_id, "application:read")?; let value = proposal_value(&state, &id)?;
4527
- if !proposal_scope_matches(&value, &input.application_id, &input.environment) { return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into())); }
4528
- Ok(Json(value))
4529
- }
4530
- async fn proposal_history(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
4531
- let _ = get_proposal(State(state.clone()), Extension(principal), Path(id.clone()), Query(input)).await?;
4532
- let events: Vec<Value> = state.db.list_collection(PROPOSAL_EVENT_COLLECTION)?.into_iter().map(|row| row.value)
4533
- .filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())).collect();
4534
- Ok(Json(json!({"events":events})))
5883
+ async fn reject_proposal(
5884
+ state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
5885
+ path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
5886
+ ) -> 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
4535
5891
  }
4536
- fn move_proposal(state: &AppState, id: &str, mut value: Value, to: &str, actor: &str) -> Result<Value, ApiError> {
4537
- let from = value.get("status").and_then(Value::as_str).unwrap_or("").to_string();
4538
- let allowed = matches!((from.as_str(),to), ("proposed","validated")|("proposed","rejected")|("validated","previewed")|("validated","rejected")|("previewed","approved")|("previewed","rejected")|("approved","applied")|("approved","expired"));
4539
- if !allowed { return Err(ApiError(StatusCode::CONFLICT, format!("INVALID_PROPOSAL_TRANSITION: {from} -> {to}"))); }
4540
- value["status"] = json!(to); if to == "approved" { value["approved_by"] = json!(actor); value["approved_at"] = json!(unix_seconds_i64()); }
4541
- if to == "applied" { value["applied_at"] = json!(unix_seconds_i64()); }
4542
- let _ = put_canonical(state, PROPOSAL_COLLECTION, id, value.clone())?; proposal_event(state, id, Some(&from), to, actor)?; Ok(value)
4543
- }
4544
- async fn validate_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(input): Json<ProposalActorRequest>) -> Result<Json<Value>, ApiError> {
4545
- let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:write")?; let mut value = proposal_value(&state, &id)?;
4546
- if !proposal_scope_matches(&value, &input.application_id, &input.environment) { return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into())); }
4547
- let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned().ok_or(ApiError(StatusCode::NOT_FOUND,"active contract not found".into()))?;
4548
- let revision = state.applications.revision(&tenant,&input.application_id,&revision_id).ok_or(ApiError(StatusCode::NOT_FOUND,"application revision not found".into()))?;
4549
- let hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
4550
- if value.get("base_contract_hash").and_then(Value::as_str) != Some(hash) { return Err(ApiError(StatusCode::CONFLICT,"STALE_PROPOSAL".into())); }
4551
- if let Some(manifest) = input.proposed_manifest {
4552
- let report = validate_manifest(&manifest, &tenant, &input.application_id, Some(&revision.manifest));
4553
- if !report.valid { return Err(ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({"code":"INVALID_PROPOSAL","validation":report}))); }
4554
- let proposed = ApplicationRevision { revision_id: format!("proposal://{id}"), application_id:input.application_id.clone(), tenant_id:tenant,
4555
- revision_number:revision.revision_number + 1, parent_revision_id:Some(revision.revision_id.clone()), manifest_hash:String::new(),
4556
- manifest, created_by:principal.key_id.clone(), created_at:unix_seconds_i64() as u64, status:RevisionStatus::Committed };
4557
- value["contract_diff"] = serde_json::to_value(diff_manifests(Some(&revision), &proposed).changes).map_err(|e|ApiError(StatusCode::INTERNAL_SERVER_ERROR,e.to_string()))?;
4558
- }
4559
- value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
4560
- Ok(Json(move_proposal(&state,&id,value,"validated",&principal.key_id)?))
4561
- }
4562
- async fn preview_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(input): Json<ProposalActorRequest>) -> Result<Json<Value>, ApiError> {
4563
- application_scope(&state,&principal.key_id,&input.application_id,"application:write")?; let value=proposal_value(&state,&id)?;
4564
- if !proposal_scope_matches(&value,&input.application_id,&input.environment) { return Err(ApiError(StatusCode::NOT_FOUND,"proposal not found".into())); }
4565
- Ok(Json(json!({"proposal":move_proposal(&state,&id,value,"previewed",&principal.key_id)?})))
4566
- }
4567
- async fn transition_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, headers: HeaderMap, Path(id): Path<String>, Json(input): Json<ProposalStatusRequest>) -> Result<Json<Value>, ApiError> {
4568
- application_scope(&state,&principal.key_id,&input.application_id,"application:write")?; let value=proposal_value(&state,&id)?;
4569
- if !proposal_scope_matches(&value,&input.application_id,&input.environment) { return Err(ApiError(StatusCode::NOT_FOUND,"proposal not found".into())); }
4570
- if !matches!(input.status.as_str(),"approved"|"rejected"|"expired"|"applied") { return Err(ApiError(StatusCode::BAD_REQUEST,"unsupported proposal status".into())); }
4571
- if input.status == "applied" && headers.get("FeltDB-Repository-Apply").and_then(|v|v.to_str().ok()) != Some("1") { return Err(ApiError(StatusCode::FORBIDDEN,"REPOSITORY_AUTHORITY_REQUIRED".into())); }
4572
- if input.status == "approved" && value.get("warnings").and_then(Value::as_array).map(|items|items.iter().any(|item|item.as_str().unwrap_or("").starts_with("Authorization "))).unwrap_or(false) && !input.approve_authorization { return Err(ApiError(StatusCode::FORBIDDEN,"AUTHORIZATION_APPROVAL_REQUIRED".into())); }
4573
- Ok(Json(move_proposal(&state,&id,value,&input.status,&principal.key_id)?))
4574
- }
4575
- fn auth_response(identity: feltdb_server::identity::Identity, session: feltdb_server::identity::IdentitySession, token: String) -> Json<Value> {
5892
+ fn auth_response(
5893
+ identity: feltdb_server::identity::Identity,
5894
+ session: feltdb_server::identity::IdentitySession,
5895
+ token: String,
5896
+ ) -> Json<Value> {
4576
5897
  Json(json!({
4577
5898
  "access_token": token,
4578
5899
  "token_type": "Bearer",
@@ -4581,25 +5902,87 @@ fn auth_response(identity: feltdb_server::identity::Identity, session: feltdb_se
4581
5902
  "session": { "session_id": session.session_id, "issued_at": session.issued_at, "expires_at": session.expires_at }
4582
5903
  }))
4583
5904
  }
4584
- async fn auth_sign_up(State(state): State<AppState>, Json(input): Json<SignUpRequest>) -> Result<(StatusCode, Json<Value>), ApiError> {
4585
- if state.tenancy.application_tenant(&input.application_id).is_none() { return Err(ApiError(StatusCode::NOT_FOUND, "application not found".into())); }
4586
- let (identity, session, token) = state.identities.sign_up(&input.email, &input.password, input.display_name)
4587
- .map_err(|error| ApiError(if error == "identity already exists" { StatusCode::CONFLICT } else { StatusCode::BAD_REQUEST }, error))?;
4588
- state.tenancy.register_platform_user(&identity.subject_id, identity.email.as_deref().unwrap_or(&identity.identity_id)).map_err(control_error)?;
4589
- state.tenancy.register_application_actor(&identity.subject_id, &input.application_id).map_err(control_error)?;
5905
+ async fn auth_sign_up(
5906
+ State(state): State<AppState>,
5907
+ Json(input): Json<SignUpRequest>,
5908
+ ) -> Result<(StatusCode, Json<Value>), ApiError> {
5909
+ if state
5910
+ .tenancy
5911
+ .application_tenant(&input.application_id)
5912
+ .is_none()
5913
+ {
5914
+ return Err(ApiError(
5915
+ StatusCode::NOT_FOUND,
5916
+ "application not found".into(),
5917
+ ));
5918
+ }
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
5925
+ .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
+ };
5930
+ state
5931
+ .tenancy
5932
+ .register_platform_user(
5933
+ &identity.subject_id,
5934
+ identity.email.as_deref().unwrap_or(&identity.identity_id),
5935
+ )
5936
+ .map_err(control_error)?;
5937
+ state
5938
+ .tenancy
5939
+ .register_application_actor(&identity.subject_id, &input.application_id)
5940
+ .map_err(control_error)?;
4590
5941
  Ok((StatusCode::CREATED, auth_response(identity, session, token)))
4591
5942
  }
4592
- async fn auth_sign_in(State(state): State<AppState>, Json(input): Json<SignInRequest>) -> Result<Json<Value>, ApiError> {
4593
- let (identity, session, token) = state.identities.sign_in(&input.email, &input.password)
5943
+ async fn auth_sign_in(
5944
+ State(state): State<AppState>,
5945
+ Json(input): Json<SignInRequest>,
5946
+ ) -> Result<Json<Value>, ApiError> {
5947
+ let (identity, session, token) = state
5948
+ .identities
5949
+ .sign_in(&input.email, &input.password)
4594
5950
  .map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
5951
+ if let Some(application_id) = input.application_id {
5952
+ if state.tenancy.application_tenant(&application_id).is_none() {
5953
+ return Err(ApiError(StatusCode::NOT_FOUND, "application not found".into()));
5954
+ }
5955
+ state
5956
+ .tenancy
5957
+ .register_platform_user(
5958
+ &identity.subject_id,
5959
+ identity.email.as_deref().unwrap_or(&identity.identity_id),
5960
+ )
5961
+ .map_err(control_error)?;
5962
+ state
5963
+ .tenancy
5964
+ .register_application_actor(&identity.subject_id, &application_id)
5965
+ .map_err(control_error)?;
5966
+ }
4595
5967
  Ok(auth_response(identity, session, token))
4596
5968
  }
4597
5969
  async fn auth_session(Extension(principal): Extension<Principal>) -> Json<Value> {
4598
- Json(json!({ "actor": { "user_id": principal.key_id, "identity_id": principal.identity_id }, "session_id": principal.session_id }))
5970
+ Json(
5971
+ json!({ "actor": { "user_id": principal.key_id, "identity_id": principal.identity_id }, "session_id": principal.session_id }),
5972
+ )
4599
5973
  }
4600
- async fn auth_sign_out(State(state): State<AppState>, Extension(principal): Extension<Principal>) -> Result<StatusCode, ApiError> {
4601
- let session = principal.session_id.ok_or(ApiError(StatusCode::BAD_REQUEST, "request is not an actor session".into()))?;
4602
- state.identities.revoke_session(&principal.key_id, &session, "user_logout").map_err(control_error)?;
5974
+ async fn auth_sign_out(
5975
+ State(state): State<AppState>,
5976
+ Extension(principal): Extension<Principal>,
5977
+ ) -> Result<StatusCode, ApiError> {
5978
+ let session = principal.session_id.ok_or(ApiError(
5979
+ StatusCode::BAD_REQUEST,
5980
+ "request is not an actor session".into(),
5981
+ ))?;
5982
+ state
5983
+ .identities
5984
+ .revoke_session(&principal.key_id, &session, "user_logout")
5985
+ .map_err(control_error)?;
4603
5986
  Ok(StatusCode::NO_CONTENT)
4604
5987
  }
4605
5988
  async fn application_openapi() -> Json<Value> {
@@ -4624,6 +6007,13 @@ async fn application_openapi() -> Json<Value> {
4624
6007
  "/query": { "post": { "responses": { "200": { "description": "Bounded authorized query" } } } },
4625
6008
  "/transactions": { "post": { "responses": { "200": { "description": "Atomic idempotent mutation" } } } },
4626
6009
  "/events": { "get": { "responses": { "200": { "description": "Application event stream" } } } },
6010
+ "/intents": {
6011
+ "get": { "responses": { "200": { "description": "Durable application Intents" } } },
6012
+ "post": { "parameters": [{ "name":"Idempotency-Key", "in":"header", "required":false, "schema":{"type":"string"} }], "responses": { "200": { "description": "Canonical Intent execution result" } } }
6013
+ },
6014
+ "/intents/{id}": { "get": { "responses": { "200": { "description": "Canonical Intent" } } } },
6015
+ "/intents/{id}/{artifact}": { "get": { "responses": { "200": { "description": "Intent understanding, plan, Proposal, or history" } } } },
6016
+ "/proposals": { "get": { "responses": { "200": { "description": "Durable Proposals" } } }, "post": { "responses": { "201": { "description": "Created Proposal" } } } },
4627
6017
  "/workflows/{name}/run": { "post": { "responses": { "201": { "description": "Workflow execution" } } } },
4628
6018
  "/agents/{name}/run": { "post": { "responses": { "201": { "description": "Agent execution" } } } },
4629
6019
  "/capabilities/{name}/run": { "post": { "responses": { "200": { "description": "Public capability result" } } } }
@@ -5029,8 +6419,29 @@ async fn list_runtime_triggers(
5029
6419
  Ok(Json(json!(contract.triggers)))
5030
6420
  }
5031
6421
 
5032
- fn state_policy_resource_matches(resource:&str,collection:&str)->bool{resource==collection||resource.strip_suffix('*').is_some_and(|prefix|!prefix.contains('*')&&collection.starts_with(prefix))}
5033
- fn matching_state_policy<'a>(contract:&'a ApplicationRuntimeContract,collection:&str)->Option<&'a feltdb::application::PolicyDefinition>{contract.policies.definitions.iter().find(|p|p.resource==collection).or_else(||contract.policies.definitions.iter().find(|p|state_policy_resource_matches(&p.resource,collection)))}
6422
+ fn state_policy_resource_matches(resource: &str, collection: &str) -> bool {
6423
+ resource == collection
6424
+ || resource
6425
+ .strip_suffix('*')
6426
+ .is_some_and(|prefix| !prefix.contains('*') && collection.starts_with(prefix))
6427
+ }
6428
+ fn matching_state_policy<'a>(
6429
+ contract: &'a ApplicationRuntimeContract,
6430
+ collection: &str,
6431
+ ) -> Option<&'a feltdb::application::PolicyDefinition> {
6432
+ contract
6433
+ .policies
6434
+ .definitions
6435
+ .iter()
6436
+ .find(|p| p.resource == collection)
6437
+ .or_else(|| {
6438
+ contract
6439
+ .policies
6440
+ .definitions
6441
+ .iter()
6442
+ .find(|p| state_policy_resource_matches(&p.resource, collection))
6443
+ })
6444
+ }
5034
6445
  fn authorize_transaction_collections(
5035
6446
  principal: &Principal,
5036
6447
  contract: &ApplicationRuntimeContract,
@@ -5063,14 +6474,14 @@ fn state_authorization(
5063
6474
  ) -> AuthorizationContext {
5064
6475
  let subject = format!("{}:{}", principal.subject_type, principal.key_id);
5065
6476
 
5066
- let policy=matching_state_policy(contract,collection);
6477
+ let policy = matching_state_policy(contract, collection);
5067
6478
  let policy_subject = policy.and_then(|p| {
5068
- if operation == "read" {
5069
- p.read.as_ref().and_then(|s| PolicySubject::from_str(s))
5070
- } else {
5071
- p.write.as_ref().and_then(|s| PolicySubject::from_str(s))
5072
- }
5073
- });
6479
+ if operation == "read" {
6480
+ p.read.as_ref().and_then(|s| PolicySubject::from_str(s))
6481
+ } else {
6482
+ p.write.as_ref().and_then(|s| PolicySubject::from_str(s))
6483
+ }
6484
+ });
5074
6485
 
5075
6486
  // Evaluate policy if found
5076
6487
  let capabilities = if let Some(policy) = policy_subject {
@@ -5098,7 +6509,16 @@ fn state_authorization(
5098
6509
  BTreeSet::new()
5099
6510
  }
5100
6511
  }
5101
- } else if policy.is_some_and(|p|p.capabilities.iter().any(|capability|capability==if operation=="read"{"state:read"}else{"state:write"})) {
6512
+ } else if policy.is_some_and(|p| {
6513
+ p.capabilities.iter().any(|capability| {
6514
+ capability
6515
+ == if operation == "read" {
6516
+ "state:read"
6517
+ } else {
6518
+ "state:write"
6519
+ }
6520
+ })
6521
+ }) {
5102
6522
  let capability = if operation == "read" {
5103
6523
  "state:read"
5104
6524
  } else {
@@ -5434,16 +6854,25 @@ async fn execute_canonical_transaction(
5434
6854
  input.transaction.revision_id = contract.revision_id.clone();
5435
6855
  input.transaction.schema_version = contract.state.schema.schema_version;
5436
6856
  input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
5437
- let collections = input.transaction.operations.iter()
6857
+ let collections = input
6858
+ .transaction
6859
+ .operations
6860
+ .iter()
5438
6861
  .map(|operation| operation.collection.clone())
5439
6862
  .collect::<Vec<_>>();
5440
- let (authorized, write_policies) = authorize_transaction_collections(
6863
+ let (authorized, write_policies) =
6864
+ authorize_transaction_collections(&principal, &contract, &tenant, &collections);
6865
+ input.transaction.authorization = state_authorization_legacy(
5441
6866
  &principal,
5442
- &contract,
5443
6867
  &tenant,
5444
- &collections,
6868
+ &input.application_id,
6869
+ &contract.revision_id,
6870
+ if authorized {
6871
+ "state:write"
6872
+ } else {
6873
+ "state:denied"
6874
+ },
5445
6875
  );
5446
- input.transaction.authorization=state_authorization_legacy(&principal,&tenant,&input.application_id,&contract.revision_id,if authorized{"state:write"}else{"state:denied"});
5447
6876
  let result = execute_transaction_with_collection_policies(
5448
6877
  &state.db,
5449
6878
  &contract.state.schema,
@@ -6257,18 +7686,54 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6257
7686
  )
6258
7687
  .route("/v1/schema", get(get_state_schema))
6259
7688
  .route("/v1/application", get(discover_application))
7689
+ .route("/v1/application/schema", get(inspect_application_schema))
7690
+ .route("/v1/data/{collection}", get(inspect_collection))
7691
+ .route("/v1/data/{collection}/{id}", get(inspect_record))
7692
+ .route("/v1/intents", get(list_intents).post(execute_intent))
7693
+ .route("/v1/intents/{id}", get(get_intent_root))
7694
+ .route("/v1/intents/{id}/{operation}", get(get_intent))
6260
7695
  .route("/v1/proposals", get(list_proposals).post(create_proposal))
6261
7696
  .route("/v1/proposals/{id}", get(get_proposal))
6262
7697
  .route("/v1/proposals/{id}/history", get(proposal_history))
6263
- .route("/v1/proposals/{id}/validate", axum::routing::post(validate_proposal))
6264
- .route("/v1/proposals/{id}/preview", axum::routing::post(preview_proposal))
6265
- .route("/v1/proposals/{id}/status", axum::routing::patch(transition_proposal))
7698
+ .route(
7699
+ "/v1/proposals/{id}/validate",
7700
+ axum::routing::post(validate_proposal),
7701
+ )
7702
+ .route(
7703
+ "/v1/proposals/{id}/preview",
7704
+ axum::routing::post(preview_proposal),
7705
+ )
7706
+ .route(
7707
+ "/v1/proposals/{id}/approve",
7708
+ axum::routing::post(approve_proposal),
7709
+ )
7710
+ .route(
7711
+ "/v1/proposals/{id}/reject",
7712
+ axum::routing::post(reject_proposal),
7713
+ )
7714
+ .route(
7715
+ "/v1/proposals/{id}/status",
7716
+ get(proposal_status).patch(transition_proposal),
7717
+ )
7718
+ .route("/v1/proposals/{id}/convergence", get(proposal_convergence).post(record_proposal_convergence))
7719
+ .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))
6266
7722
  .route("/v1/auth/session", get(auth_session))
6267
7723
  .route("/v1/auth/signout", axum::routing::post(auth_sign_out))
6268
7724
  .route("/v1/events", get(events))
6269
- .route("/v1/workflows/{name}/run", axum::routing::post(start_workflow_run))
6270
- .route("/v1/agents/{name}/run", axum::routing::post(start_agent_run))
6271
- .route("/v1/capabilities/{name}/run", axum::routing::post(execute_application_capability))
7725
+ .route(
7726
+ "/v1/workflows/{name}/run",
7727
+ axum::routing::post(start_workflow_run),
7728
+ )
7729
+ .route(
7730
+ "/v1/agents/{name}/run",
7731
+ axum::routing::post(start_agent_run),
7732
+ )
7733
+ .route(
7734
+ "/v1/capabilities/{name}/run",
7735
+ axum::routing::post(execute_application_capability),
7736
+ )
6272
7737
  .route("/v1/state/version", get(get_state_version))
6273
7738
  .route("/v1/query", axum::routing::post(execute_canonical_query))
6274
7739
  .route(
@@ -6637,6 +8102,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6637
8102
  .route("/v1/openapi.json", get(application_openapi))
6638
8103
  .route("/v1/auth/signup", axum::routing::post(auth_sign_up))
6639
8104
  .route("/v1/auth/signin", axum::routing::post(auth_sign_in))
8105
+ .route("/internal/intents/by-hash", get(internal_find_intent))
8106
+ .route("/internal/intents", axum::routing::post(internal_create_intent))
8107
+ .route("/internal/intents/{id}", put(internal_replace_intent))
8108
+ .route("/internal/intent-events", axum::routing::post(internal_create_intent_event))
6640
8109
  .route("/ready", get(readiness))
6641
8110
  .route("/runtime", get(runtime))
6642
8111
  .route("/metrics", get(network_metrics))
@@ -6733,7 +8202,16 @@ async fn authenticate(
6733
8202
  (Some(principal), _, _) => principal,
6734
8203
  (None, Some(session), _) => Principal {
6735
8204
  key_id: session.subject_id,
6736
- scopes: vec!["application:read".into(), "application:revision:read".into(), "state:read".into(), "state:write".into(), "events:read".into(), "workflows:run".into(), "agents:run".into(), "capabilities:run".into()],
8205
+ scopes: vec![
8206
+ "application:read".into(),
8207
+ "application:revision:read".into(),
8208
+ "state:read".into(),
8209
+ "state:write".into(),
8210
+ "events:read".into(),
8211
+ "workflows:run".into(),
8212
+ "agents:run".into(),
8213
+ "capabilities:run".into(),
8214
+ ],
6737
8215
  subject_type: "human".into(),
6738
8216
  identity_id: Some(session.identity_id),
6739
8217
  session_id: Some(session.session_id),
@@ -6964,9 +8442,7 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
6964
8442
  })
6965
8443
  }
6966
8444
 
6967
- async fn managed_diagnostics(
6968
- State(state): State<AppState>,
6969
- ) -> Json<ManagedDiagnosticsResponse> {
8445
+ async fn managed_diagnostics(State(state): State<AppState>) -> Json<ManagedDiagnosticsResponse> {
6970
8446
  let recovery = match state.cluster.proposal().map(|value| value.phase) {
6971
8447
  Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
6972
8448
  Some(ProposalPhase::Aborted) => "attention-required",
@@ -6983,8 +8459,7 @@ async fn managed_diagnostics(
6983
8459
  storage_format: 1,
6984
8460
  promoted_revision: std::env::var("FELTDB_PROMOTED_REVISION")
6985
8461
  .unwrap_or_else(|_| "unknown".into()),
6986
- environment: std::env::var("FELTDB_ENVIRONMENT")
6987
- .unwrap_or_else(|_| "development".into()),
8462
+ environment: std::env::var("FELTDB_ENVIRONMENT").unwrap_or_else(|_| "development".into()),
6988
8463
  recovery: recovery.into(),
6989
8464
  uptime: format!("{uptime_seconds}s"),
6990
8465
  })
@@ -8733,7 +10208,12 @@ async fn execute_capability(
8733
10208
  Path(name): Path<String>,
8734
10209
  Json(input): Json<Value>,
8735
10210
  ) -> Result<Json<Value>, ApiError> {
8736
- if !principal.permits("capabilities:run") { return Err(ApiError(StatusCode::FORBIDDEN, "capability execution is not permitted".into())); }
10211
+ if !principal.permits("capabilities:run") {
10212
+ return Err(ApiError(
10213
+ StatusCode::FORBIDDEN,
10214
+ "capability execution is not permitted".into(),
10215
+ ));
10216
+ }
8737
10217
  validate_segment(&name)?;
8738
10218
  Ok(Json(execute_named_capability(&state, &name, &input)?))
8739
10219
  }
@@ -8745,15 +10225,42 @@ async fn execute_application_capability(
8745
10225
  Query(input): Query<ApplicationDiscoveryRequest>,
8746
10226
  Json(payload): Json<Value>,
8747
10227
  ) -> Result<Json<Value>, ApiError> {
8748
- let tenant = application_scope(&state, &principal.key_id, &input.application_id, "capabilities:run")?;
8749
- let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned()
8750
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
8751
- let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
8752
- .ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
8753
- let capability = revision.manifest.capabilities.iter().find(|value| value.name == name)
8754
- .ok_or(ApiError(StatusCode::NOT_FOUND, "capability not found".into()))?;
10228
+ let tenant = application_scope(
10229
+ &state,
10230
+ &principal.key_id,
10231
+ &input.application_id,
10232
+ "capabilities:run",
10233
+ )?;
10234
+ let revision_id = state
10235
+ .applications
10236
+ .pointers(&input.application_id)
10237
+ .get(&input.environment)
10238
+ .cloned()
10239
+ .ok_or(ApiError(
10240
+ StatusCode::NOT_FOUND,
10241
+ "application environment has no active contract".into(),
10242
+ ))?;
10243
+ let revision = state
10244
+ .applications
10245
+ .revision(&tenant, &input.application_id, &revision_id)
10246
+ .ok_or(ApiError(
10247
+ StatusCode::NOT_FOUND,
10248
+ "application revision not found".into(),
10249
+ ))?;
10250
+ let capability = revision
10251
+ .manifest
10252
+ .capabilities
10253
+ .iter()
10254
+ .find(|value| value.name == name)
10255
+ .ok_or(ApiError(
10256
+ StatusCode::NOT_FOUND,
10257
+ "capability not found".into(),
10258
+ ))?;
8755
10259
  if capability.visibility != "public" {
8756
- return Err(ApiError(StatusCode::FORBIDDEN, "capability is not externally invocable".into()));
10260
+ return Err(ApiError(
10261
+ StatusCode::FORBIDDEN,
10262
+ "capability is not externally invocable".into(),
10263
+ ));
8757
10264
  }
8758
10265
  Ok(Json(execute_named_capability(&state, &name, &payload)?))
8759
10266
  }
@@ -8947,7 +10454,12 @@ async fn start_workflow_run(
8947
10454
  Path(name): Path<String>,
8948
10455
  Json(request): Json<StartRun>,
8949
10456
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
8950
- if !principal.permits("workflows:run") { return Err(ApiError(StatusCode::FORBIDDEN, "workflow execution is not permitted".into())); }
10457
+ if !principal.permits("workflows:run") {
10458
+ return Err(ApiError(
10459
+ StatusCode::FORBIDDEN,
10460
+ "workflow execution is not permitted".into(),
10461
+ ));
10462
+ }
8951
10463
  validate_segment(&name)?;
8952
10464
  let definition = required_value(&state, "_flow_workflows", &name, "workflow not found")?;
8953
10465
  let steps = definition["steps"].as_array().ok_or_else(|| {
@@ -9109,7 +10621,12 @@ async fn start_agent_run(
9109
10621
  Path(name): Path<String>,
9110
10622
  Json(request): Json<StartAgentRun>,
9111
10623
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
9112
- if !principal.permits("agents:run") { return Err(ApiError(StatusCode::FORBIDDEN, "agent execution is not permitted".into())); }
10624
+ if !principal.permits("agents:run") {
10625
+ return Err(ApiError(
10626
+ StatusCode::FORBIDDEN,
10627
+ "agent execution is not permitted".into(),
10628
+ ));
10629
+ }
9113
10630
  validate_segment(&name)?;
9114
10631
  required_value(&state, "_flow_agents", &name, "agent not found")?;
9115
10632
  if request.goal.trim().is_empty() {
@@ -9331,10 +10848,13 @@ struct BoundedQueryPage {
9331
10848
  }
9332
10849
 
9333
10850
  fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
9334
- ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({
9335
- "code": code,
9336
- "message": message.into(),
9337
- }))
10851
+ ApiError::structured(
10852
+ StatusCode::UNPROCESSABLE_ENTITY,
10853
+ json!({
10854
+ "code": code,
10855
+ "message": message.into(),
10856
+ }),
10857
+ )
9338
10858
  }
9339
10859
 
9340
10860
  fn bounded_query_hash(request: &BoundedQueryRequest) -> Result<String, ApiError> {
@@ -9357,7 +10877,10 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
9357
10877
  (Some(Value::Null), Some(Value::Null)) => std::cmp::Ordering::Equal,
9358
10878
  (Some(Value::Null), Some(_)) => std::cmp::Ordering::Less,
9359
10879
  (Some(_), Some(Value::Null)) => std::cmp::Ordering::Greater,
9360
- (Some(Value::Number(a)), Some(Value::Number(b))) => a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(std::cmp::Ordering::Equal),
10880
+ (Some(Value::Number(a)), Some(Value::Number(b))) => a
10881
+ .as_f64()
10882
+ .partial_cmp(&b.as_f64())
10883
+ .unwrap_or(std::cmp::Ordering::Equal),
9361
10884
  (Some(Value::String(a)), Some(Value::String(b))) => a.cmp(b),
9362
10885
  (Some(Value::Bool(a)), Some(Value::Bool(b))) => a.cmp(b),
9363
10886
  (Some(a), Some(b)) => a.to_string().cmp(&b.to_string()),
@@ -9366,31 +10889,60 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
9366
10889
 
9367
10890
  fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Result<bool, ApiError> {
9368
10891
  if condition.operators.len() != 1 || condition.field.trim().is_empty() {
9369
- return Err(bounded_query_error("INVALID_QUERY", "each where condition requires a field and exactly one operator"));
10892
+ return Err(bounded_query_error(
10893
+ "INVALID_QUERY",
10894
+ "each where condition requires a field and exactly one operator",
10895
+ ));
9370
10896
  }
9371
- let (operator, expected) = condition.operators.iter().next().expect("validated operator");
10897
+ let (operator, expected) = condition
10898
+ .operators
10899
+ .iter()
10900
+ .next()
10901
+ .expect("validated operator");
9372
10902
  let actual = record.get(&condition.field);
9373
- if operator == "eq" { return Ok(actual == Some(expected)); }
9374
- if operator == "neq" { return Ok(actual != Some(expected)); }
9375
- let Some(actual) = actual else { return Ok(false) };
10903
+ if operator == "eq" {
10904
+ return Ok(actual == Some(expected));
10905
+ }
10906
+ if operator == "neq" {
10907
+ return Ok(actual != Some(expected));
10908
+ }
10909
+ let Some(actual) = actual else {
10910
+ return Ok(false);
10911
+ };
9376
10912
  let comparison = query_scalar_cmp(Some(actual), Some(expected));
9377
10913
  Ok(match operator.as_str() {
9378
10914
  "lt" => comparison.is_lt(),
9379
10915
  "lte" => !comparison.is_gt(),
9380
10916
  "gt" => comparison.is_gt(),
9381
10917
  "gte" => !comparison.is_lt(),
9382
- _ => return Err(bounded_query_error("INVALID_QUERY", format!("unsupported where operator: {operator}"))),
10918
+ _ => {
10919
+ return Err(bounded_query_error(
10920
+ "INVALID_QUERY",
10921
+ format!("unsupported where operator: {operator}"),
10922
+ ))
10923
+ }
9383
10924
  })
9384
10925
  }
9385
10926
 
9386
- fn issue_bounded_cursor(state: &AppState, mut cursor: BoundedQueryCursor) -> Result<String, ApiError> {
10927
+ fn issue_bounded_cursor(
10928
+ state: &AppState,
10929
+ mut cursor: BoundedQueryCursor,
10930
+ ) -> Result<String, ApiError> {
9387
10931
  let token = uuid::Uuid::new_v4().simple().to_string();
9388
10932
  let now = unix_seconds_i64().max(0) as u64;
9389
10933
  cursor.created_at = now;
9390
- let mut cursors = state.bounded_query_cursors.lock().map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "query cursor store unavailable".into()))?;
10934
+ let mut cursors = state.bounded_query_cursors.lock().map_err(|_| {
10935
+ ApiError(
10936
+ StatusCode::INTERNAL_SERVER_ERROR,
10937
+ "query cursor store unavailable".into(),
10938
+ )
10939
+ })?;
9391
10940
  cursors.retain(|_, value| now.saturating_sub(value.created_at) <= BOUNDED_CURSOR_TTL_SECONDS);
9392
10941
  if cursors.len() >= 1024 {
9393
- return Err(ApiError(StatusCode::SERVICE_UNAVAILABLE, "query cursor capacity exhausted".into()));
10942
+ return Err(ApiError(
10943
+ StatusCode::SERVICE_UNAVAILABLE,
10944
+ "query cursor capacity exhausted".into(),
10945
+ ));
9394
10946
  }
9395
10947
  cursors.insert(token.clone(), cursor);
9396
10948
  Ok(token)
@@ -9403,32 +10955,67 @@ async fn execute_bounded_query(
9403
10955
  ) -> Result<Json<BoundedQueryPage>, ApiError> {
9404
10956
  validate_segment(&request.collection)?;
9405
10957
  if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
9406
- return Err(bounded_query_error("INVALID_QUERY", format!("limit must be between 1 and {MAX_BOUNDED_QUERY_LIMIT}")));
10958
+ return Err(bounded_query_error(
10959
+ "INVALID_QUERY",
10960
+ format!("limit must be between 1 and {MAX_BOUNDED_QUERY_LIMIT}"),
10961
+ ));
9407
10962
  }
9408
10963
  if request.order_by.is_empty() {
9409
- return Err(bounded_query_error("INVALID_QUERY", "orderBy must contain at least one field"));
10964
+ return Err(bounded_query_error(
10965
+ "INVALID_QUERY",
10966
+ "orderBy must contain at least one field",
10967
+ ));
9410
10968
  }
9411
10969
  for order in &request.order_by {
9412
10970
  if order.field.trim().is_empty() || !matches!(order.direction.as_str(), "asc" | "desc") {
9413
- return Err(bounded_query_error("INVALID_QUERY", "orderBy directions must be asc or desc"));
10971
+ return Err(bounded_query_error(
10972
+ "INVALID_QUERY",
10973
+ "orderBy directions must be asc or desc",
10974
+ ));
9414
10975
  }
9415
10976
  }
9416
10977
  for condition in &request.conditions {
9417
- if condition.operators.len() != 1 || condition.field.trim().is_empty()
9418
- || !condition.operators.keys().all(|operator| matches!(operator.as_str(), "eq" | "neq" | "lt" | "lte" | "gt" | "gte")) {
9419
- return Err(bounded_query_error("INVALID_QUERY", "each where condition requires a field and exactly one operator"));
10978
+ if condition.operators.len() != 1
10979
+ || condition.field.trim().is_empty()
10980
+ || !condition.operators.keys().all(|operator| {
10981
+ matches!(
10982
+ operator.as_str(),
10983
+ "eq" | "neq" | "lt" | "lte" | "gt" | "gte"
10984
+ )
10985
+ })
10986
+ {
10987
+ return Err(bounded_query_error(
10988
+ "INVALID_QUERY",
10989
+ "each where condition requires a field and exactly one operator",
10990
+ ));
9420
10991
  }
9421
10992
  }
9422
10993
  let hash = bounded_query_hash(&request)?;
9423
10994
  let (records, position) = if let Some(token) = &request.cursor {
9424
- let cursor = state.bounded_query_cursors.lock()
9425
- .map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "query cursor store unavailable".into()))?
9426
- .get(token).cloned()
10995
+ let cursor = state
10996
+ .bounded_query_cursors
10997
+ .lock()
10998
+ .map_err(|_| {
10999
+ ApiError(
11000
+ StatusCode::INTERNAL_SERVER_ERROR,
11001
+ "query cursor store unavailable".into(),
11002
+ )
11003
+ })?
11004
+ .get(token)
11005
+ .cloned()
9427
11006
  .ok_or_else(|| bounded_query_error("INVALID_CURSOR", "cursor is invalid or expired"))?;
9428
- if cursor.query_hash != hash || cursor.principal_key_id != principal.key_id || cursor.namespace != state.namespace.as_ref() {
9429
- return Err(bounded_query_error("INVALID_CURSOR", "cursor does not match this query, principal, or namespace"));
11007
+ if cursor.query_hash != hash
11008
+ || cursor.principal_key_id != principal.key_id
11009
+ || cursor.namespace != state.namespace.as_ref()
11010
+ {
11011
+ return Err(bounded_query_error(
11012
+ "INVALID_CURSOR",
11013
+ "cursor does not match this query, principal, or namespace",
11014
+ ));
9430
11015
  }
9431
- if (unix_seconds_i64().max(0) as u64).saturating_sub(cursor.created_at) > BOUNDED_CURSOR_TTL_SECONDS {
11016
+ if (unix_seconds_i64().max(0) as u64).saturating_sub(cursor.created_at)
11017
+ > BOUNDED_CURSOR_TTL_SECONDS
11018
+ {
9432
11019
  return Err(bounded_query_error("INVALID_CURSOR", "cursor is expired"));
9433
11020
  }
9434
11021
  (cursor.records, cursor.position)
@@ -9437,13 +11024,21 @@ async fn execute_bounded_query(
9437
11024
  for row in state.db.list_collection(&request.collection)? {
9438
11025
  let mut value = row.value;
9439
11026
  if let Some(object) = value.as_object_mut() {
9440
- let id = row.key.split_once(':').map(|(_, id)| id).unwrap_or(&row.key);
11027
+ let id = row
11028
+ .key
11029
+ .split_once(':')
11030
+ .map(|(_, id)| id)
11031
+ .unwrap_or(&row.key);
9441
11032
  // `recordId` is authority metadata for this query surface, not
9442
11033
  // caller-controlled document data. It is the final total-order
9443
11034
  // tie-breaker even when a document contains a field by that name.
9444
11035
  object.insert("recordId".into(), Value::String(id.to_string()));
9445
11036
  }
9446
- if request.conditions.iter().all(|condition| condition_matches(&value, condition).unwrap_or(false)) {
11037
+ if request
11038
+ .conditions
11039
+ .iter()
11040
+ .all(|condition| condition_matches(&value, condition).unwrap_or(false))
11041
+ {
9447
11042
  records.push(value);
9448
11043
  }
9449
11044
  }
@@ -9452,7 +11047,13 @@ async fn execute_bounded_query(
9452
11047
  records.sort_by(|left, right| {
9453
11048
  for order in &request.order_by {
9454
11049
  let comparison = query_scalar_cmp(left.get(&order.field), right.get(&order.field));
9455
- if !comparison.is_eq() { return if order.direction == "desc" { comparison.reverse() } else { comparison }; }
11050
+ if !comparison.is_eq() {
11051
+ return if order.direction == "desc" {
11052
+ comparison.reverse()
11053
+ } else {
11054
+ comparison
11055
+ };
11056
+ }
9456
11057
  }
9457
11058
  query_scalar_cmp(left.get("recordId"), right.get("recordId"))
9458
11059
  });
@@ -9461,23 +11062,37 @@ async fn execute_bounded_query(
9461
11062
  let end = position.saturating_add(request.limit).min(records.len());
9462
11063
  let page = records[position.min(records.len())..end].to_vec();
9463
11064
  let next_cursor = if end < records.len() {
9464
- Some(issue_bounded_cursor(&state, BoundedQueryCursor {
9465
- query_hash: hash,
9466
- principal_key_id: principal.key_id,
9467
- namespace: state.namespace.to_string(),
9468
- records: records.clone(),
9469
- position: end,
9470
- created_at: 0,
9471
- })?)
9472
- } else { None };
9473
- Ok(Json(BoundedQueryPage { records: page, exhausted: next_cursor.is_none(), next_cursor }))
11065
+ Some(issue_bounded_cursor(
11066
+ &state,
11067
+ BoundedQueryCursor {
11068
+ query_hash: hash,
11069
+ principal_key_id: principal.key_id,
11070
+ namespace: state.namespace.to_string(),
11071
+ records: records.clone(),
11072
+ position: end,
11073
+ created_at: 0,
11074
+ },
11075
+ )?)
11076
+ } else {
11077
+ None
11078
+ };
11079
+ Ok(Json(BoundedQueryPage {
11080
+ records: page,
11081
+ exhausted: next_cursor.is_none(),
11082
+ next_cursor,
11083
+ }))
9474
11084
  }
9475
11085
 
9476
11086
  async fn list_records(
9477
11087
  State(state): State<AppState>,
9478
11088
  Path(collection): Path<String>,
9479
11089
  ) -> Result<Json<Vec<RecordResponse>>, ApiError> {
9480
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
11090
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_EVENT_COLLECTION {
11091
+ return Err(ApiError(
11092
+ StatusCode::FORBIDDEN,
11093
+ "system collection requires proposal service".into(),
11094
+ ));
11095
+ }
9481
11096
  validate_segment(&collection)?;
9482
11097
  let rows = state.db.list_collection(&collection)?;
9483
11098
  Ok(Json(rows.into_iter().map(record_response).collect()))
@@ -9487,7 +11102,12 @@ async fn get_record(
9487
11102
  State(state): State<AppState>,
9488
11103
  Path((collection, id)): Path<(String, String)>,
9489
11104
  ) -> Result<Json<RecordResponse>, ApiError> {
9490
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
11105
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_EVENT_COLLECTION {
11106
+ return Err(ApiError(
11107
+ StatusCode::FORBIDDEN,
11108
+ "system collection requires proposal service".into(),
11109
+ ));
11110
+ }
9491
11111
  let key = record_key(&collection, &id)?;
9492
11112
  let value = state
9493
11113
  .db
@@ -9708,7 +11328,11 @@ async fn commit_transaction(
9708
11328
  })?;
9709
11329
 
9710
11330
  Ok((
9711
- if commit.duplicate { StatusCode::OK } else { StatusCode::CREATED },
11331
+ if commit.duplicate {
11332
+ StatusCode::OK
11333
+ } else {
11334
+ StatusCode::CREATED
11335
+ },
9712
11336
  Json(AtomicTxResponse {
9713
11337
  transaction_id: commit.transaction_id,
9714
11338
  duplicate: commit.duplicate,
@@ -9724,7 +11348,12 @@ async fn create_record(
9724
11348
  Path(collection): Path<String>,
9725
11349
  Json(mut record): Json<CreateRecord>,
9726
11350
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
9727
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
11351
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_EVENT_COLLECTION {
11352
+ return Err(ApiError(
11353
+ StatusCode::FORBIDDEN,
11354
+ "system collection requires proposal service".into(),
11355
+ ));
11356
+ }
9728
11357
  validate_segment(&collection)?;
9729
11358
  let id = record
9730
11359
  .id
@@ -9758,7 +11387,12 @@ async fn update_record(
9758
11387
  Path((collection, id)): Path<(String, String)>,
9759
11388
  Json(changes): Json<Map<String, Value>>,
9760
11389
  ) -> Result<Json<RecordResponse>, ApiError> {
9761
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
11390
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_EVENT_COLLECTION {
11391
+ return Err(ApiError(
11392
+ StatusCode::FORBIDDEN,
11393
+ "system collection requires proposal service".into(),
11394
+ ));
11395
+ }
9762
11396
  let key = record_key(&collection, &id)?;
9763
11397
  let current = state
9764
11398
  .db
@@ -9874,7 +11508,12 @@ async fn delete_record(
9874
11508
  State(state): State<AppState>,
9875
11509
  Path((collection, id)): Path<(String, String)>,
9876
11510
  ) -> Result<StatusCode, ApiError> {
9877
- if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
11511
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_EVENT_COLLECTION {
11512
+ return Err(ApiError(
11513
+ StatusCode::FORBIDDEN,
11514
+ "system collection requires proposal service".into(),
11515
+ ));
11516
+ }
9878
11517
  let key = record_key(&collection, &id)?;
9879
11518
  if state.db.get_value(&key)?.is_none() {
9880
11519
  return Err(ApiError(
@@ -9891,8 +11530,15 @@ async fn events(
9891
11530
  Extension(principal): Extension<Principal>,
9892
11531
  Query(scope): Query<EventScopeRequest>,
9893
11532
  ) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
9894
- if !principal.permits("events:read") { return Err(ApiError(StatusCode::FORBIDDEN, "event access is not permitted".into())); }
9895
- if let Some(application_id) = scope.application_id { application_scope(&state, &principal.key_id, &application_id, "events:read")?; }
11533
+ if !principal.permits("events:read") {
11534
+ return Err(ApiError(
11535
+ StatusCode::FORBIDDEN,
11536
+ "event access is not permitted".into(),
11537
+ ));
11538
+ }
11539
+ if let Some(application_id) = scope.application_id {
11540
+ application_scope(&state, &principal.key_id, &application_id, "events:read")?;
11541
+ }
9896
11542
  let mut receiver = state.db.subscribe_changes();
9897
11543
  let namespace = state.namespace.clone();
9898
11544
  let actor = principal.key_id;
@@ -9982,7 +11628,10 @@ mod authority_gate_tests {
9982
11628
  use super::{api_error_body, authorize_transaction_collections, state_authorization};
9983
11629
  use axum::http::StatusCode;
9984
11630
  use feltdb::{
9985
- application::{manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition, RevisionStatus},
11631
+ application::{
11632
+ manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition,
11633
+ RevisionStatus,
11634
+ },
9986
11635
  application_runtime::{resolve_runtime, ApplicationRuntimeContract, RuntimeInventory},
9987
11636
  };
9988
11637
  use feltdb_server::auth::Principal;
@@ -10028,9 +11677,16 @@ mod authority_gate_tests {
10028
11677
  #[test]
10029
11678
  fn no_matching_policy_denies() {
10030
11679
  let contract = contract(vec![]);
10031
- assert!(state_authorization(&principal(), &contract, "secrets", "read").capabilities.is_empty());
11680
+ assert!(
11681
+ state_authorization(&principal(), &contract, "secrets", "read")
11682
+ .capabilities
11683
+ .is_empty()
11684
+ );
10032
11685
  let (allowed, _) = authorize_transaction_collections(
10033
- &principal(), &contract, "tenant", &["secrets".into()],
11686
+ &principal(),
11687
+ &contract,
11688
+ "tenant",
11689
+ &["secrets".into()],
10034
11690
  );
10035
11691
  assert!(!allowed);
10036
11692
  }
@@ -10039,7 +11695,10 @@ mod authority_gate_tests {
10039
11695
  fn terminal_wildcard_policy_allows_matching_collection() {
10040
11696
  let contract = contract(vec![allow("buzz_*")]);
10041
11697
  let (allowed, _) = authorize_transaction_collections(
10042
- &principal(), &contract, "tenant", &["buzz_rooms".into()],
11698
+ &principal(),
11699
+ &contract,
11700
+ "tenant",
11701
+ &["buzz_rooms".into()],
10043
11702
  );
10044
11703
  assert!(allowed);
10045
11704
  }
@@ -10048,10 +11707,16 @@ mod authority_gate_tests {
10048
11707
  fn multi_collection_transaction_authorizes_every_operation() {
10049
11708
  let contract = contract(vec![allow("alpha"), allow("beta")]);
10050
11709
  let (all_allowed, _) = authorize_transaction_collections(
10051
- &principal(), &contract, "tenant", &["alpha".into(), "beta".into()],
11710
+ &principal(),
11711
+ &contract,
11712
+ "tenant",
11713
+ &["alpha".into(), "beta".into()],
10052
11714
  );
10053
11715
  let (mixed_allowed, _) = authorize_transaction_collections(
10054
- &principal(), &contract, "tenant", &["alpha".into(), "secrets".into()],
11716
+ &principal(),
11717
+ &contract,
11718
+ "tenant",
11719
+ &["alpha".into(), "secrets".into()],
10055
11720
  );
10056
11721
  assert!(all_allowed);
10057
11722
  assert!(!mixed_allowed);
@@ -10061,7 +11726,10 @@ mod authority_gate_tests {
10061
11726
  fn cross_tenant_transaction_denies() {
10062
11727
  let contract = contract(vec![allow("alpha")]);
10063
11728
  let (allowed, _) = authorize_transaction_collections(
10064
- &principal(), &contract, "other-tenant", &["alpha".into()],
11729
+ &principal(),
11730
+ &contract,
11731
+ "other-tenant",
11732
+ &["alpha".into()],
10065
11733
  );
10066
11734
  assert!(!allowed);
10067
11735
  }