create-feltdb 0.11.3 → 0.11.7

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.
@@ -0,0 +1,363 @@
1
+ //! Semantic query composition contract — boundary tests.
2
+ //!
3
+ //! `docs/reference/semantic-query.md` fixes the semantics a FeltDB query
4
+ //! surface must honor when a semantic decision participates in a query. The
5
+ //! bounded authority query implements it (`tests/semantic_query.rs` proves the
6
+ //! page transformation; `e2e/semantic-query-composition.test.mjs` proves the
7
+ //! managed surface). These tests pin the boundaries the contract draws around
8
+ //! the rest of the crate: the canonical query has no semantic vocabulary but
9
+ //! recognizes and refuses a semantic clause rather than dropping it, the
10
+ //! decision transport stays record-scoped, runtime capabilities describe
11
+ //! decisions rather than composition, the record-scoped primitive itself has
12
+ //! no page boundary (the page boundary is `semantic_query`'s), and judgments
13
+ //! stay beside application state. Each test names the section it guards.
14
+
15
+ use feltdb::semantic_decision::{
16
+ decide_record, execute_decision_transport, DecisionDefinition, DecisionError, DecisionOptions,
17
+ DecisionRecordRef, DecisionRequest, DecisionResult, DecisionRuntime,
18
+ DecisionRuntimeCapabilities, DecisionRuntimeMetadata, DecisionTransportExecutionRequest,
19
+ DecisionTransportRuntime, DEFAULT_DECISION_COLLECTION,
20
+ };
21
+ use feltdb::state_contract::{query_hash, CanonicalQuery, QueryFilter};
22
+ use feltdb::FeltDb;
23
+ use serde_json::{json, Value};
24
+ use std::collections::BTreeSet;
25
+ use std::sync::atomic::{AtomicUsize, Ordering};
26
+ use std::sync::Arc;
27
+
28
+ fn memory_db(name: &str) -> FeltDb {
29
+ let path = std::env::temp_dir().join(format!(
30
+ "feltdb-semantic-query-contract-{name}-{}.log",
31
+ std::time::SystemTime::now()
32
+ .duration_since(std::time::UNIX_EPOCH)
33
+ .expect("time")
34
+ .as_nanos()
35
+ ));
36
+ FeltDb::open(path).expect("db")
37
+ }
38
+
39
+ /// A runtime that answers `score` decisions from an `urgency` field and counts
40
+ /// every invocation, so the tests can measure what reaches a provider.
41
+ #[derive(Clone)]
42
+ struct CountingRuntime {
43
+ invocations: Arc<AtomicUsize>,
44
+ }
45
+
46
+ impl CountingRuntime {
47
+ fn new() -> Self {
48
+ Self {
49
+ invocations: Arc::new(AtomicUsize::new(0)),
50
+ }
51
+ }
52
+
53
+ fn invocations(&self) -> usize {
54
+ self.invocations.load(Ordering::SeqCst)
55
+ }
56
+ }
57
+
58
+ impl DecisionRuntime for CountingRuntime {
59
+ fn metadata(&self) -> DecisionRuntimeMetadata {
60
+ DecisionRuntimeMetadata {
61
+ runtime: "deterministic".into(),
62
+ model: Some("deterministic-runtime".into()),
63
+ model_revision: Some("deterministic-v1".into()),
64
+ prompt_revision: Some("prompt-v1".into()),
65
+ execution_method: Some(feltdb::DecisionExecutionMethod::Logit),
66
+ decision_schema_revision: Some("semantic-decision-v1".into()),
67
+ }
68
+ }
69
+
70
+ fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
71
+ self.invocations.fetch_add(1, Ordering::SeqCst);
72
+ let urgency = request
73
+ .context()
74
+ .get("urgency")
75
+ .and_then(Value::as_str)
76
+ .unwrap_or("low")
77
+ .to_string();
78
+ let levels = ["low", "medium", "high"]
79
+ .into_iter()
80
+ .map(|level| feltdb::WeightedOption {
81
+ value: level.into(),
82
+ probability: if level == urgency { 0.9 } else { 0.05 },
83
+ })
84
+ .collect();
85
+ Ok(DecisionResult::Score {
86
+ selected: urgency,
87
+ levels,
88
+ option_mass: 0.9,
89
+ supporting_fields: vec!["urgency".into()],
90
+ })
91
+ }
92
+ }
93
+
94
+ fn urgency_definition() -> DecisionDefinition {
95
+ DecisionDefinition::Score {
96
+ question: "What is the urgency?".into(),
97
+ levels: vec!["low".into(), "medium".into(), "high".into()],
98
+ }
99
+ }
100
+
101
+ fn seed_tickets(db: &FeltDb, count: usize) {
102
+ for index in 0..count {
103
+ let urgency = ["low", "medium", "high"][index % 3];
104
+ db.insert(
105
+ &format!("tickets:t-{index}"),
106
+ json!({
107
+ "id": format!("t-{index}"),
108
+ "__version": 1,
109
+ "title": format!("Ticket {index}"),
110
+ "urgency": urgency,
111
+ }),
112
+ )
113
+ .expect("insert");
114
+ }
115
+ }
116
+
117
+ /// §7 / §11.6 — the canonical `QueryFilter` vocabulary has no semantic
118
+ /// operator. A filter naming one is refused at deserialization, so no
119
+ /// canonical query can currently express semantic filtering at all.
120
+ #[test]
121
+ fn canonical_query_filter_has_no_semantic_operator() {
122
+ let semantic = json!({
123
+ "operator": "semantic",
124
+ "field": "urgency",
125
+ "decision": { "kind": "binary", "predicate": "is urgent" },
126
+ });
127
+ let error = serde_json::from_value::<QueryFilter>(semantic).expect_err("no semantic operator");
128
+ assert!(
129
+ error.to_string().contains("semantic"),
130
+ "unexpected rejection reason: {error}"
131
+ );
132
+
133
+ // The deterministic vocabulary the contract composes with is intact.
134
+ let deterministic = serde_json::from_value::<QueryFilter>(json!({
135
+ "operator": "eq",
136
+ "field": "urgency",
137
+ "value": "high",
138
+ }))
139
+ .expect("deterministic filter");
140
+ assert!(matches!(deterministic, QueryFilter::Eq { .. }));
141
+ }
142
+
143
+ /// §11.6 — the canonical query recognizes a semantic clause it does not
144
+ /// compose. The clause is carried into the query rather than lost as an
145
+ /// unknown key, so the query's identity changes with it and `execute_query`
146
+ /// can refuse it with `SEMANTIC_UNSUPPORTED` (`state_contract` tests prove
147
+ /// the refusal; `e2e/semantic-query-composition.test.mjs` proves it over
148
+ /// `/v1/query`). The bounded authority query implements the same clause.
149
+ #[test]
150
+ fn canonical_query_recognizes_a_semantic_clause_it_does_not_compose() {
151
+ let plain = json!({
152
+ "collection": "tickets",
153
+ "filter": { "operator": "eq", "field": "urgency", "value": "high" },
154
+ "limit": 10,
155
+ });
156
+ let mut with_clause = plain.clone();
157
+ with_clause["semantic"] = json!({
158
+ "decision": { "kind": "binary", "predicate": "is urgent" },
159
+ "filter": { "is": true },
160
+ });
161
+
162
+ let plain: CanonicalQuery = serde_json::from_value(plain).expect("plain query");
163
+ let with_clause: CanonicalQuery =
164
+ serde_json::from_value(with_clause).expect("the clause is carried, not refused here");
165
+
166
+ assert!(plain.semantic.is_none());
167
+ assert!(with_clause.semantic.is_some());
168
+ assert_ne!(
169
+ query_hash(&plain).expect("hash"),
170
+ query_hash(&with_clause).expect("hash"),
171
+ "the semantic clause is part of query identity"
172
+ );
173
+ // The same clause is exactly what the bounded authority query accepts.
174
+ let clause: feltdb::SemanticQueryClause =
175
+ serde_json::from_value(with_clause.semantic.clone().unwrap()).expect("bounded clause");
176
+ feltdb::validate_semantic_clause(&clause).expect("valid for the bounded query");
177
+ }
178
+
179
+ /// §3 / §5 — the semantic-decision transport addresses exactly one durable
180
+ /// record. There is no candidate-set, page, or collection-level target: a
181
+ /// target without a record id does not deserialize, and the transport refuses
182
+ /// both "no target" and "two targets".
183
+ #[test]
184
+ fn decision_transport_targets_exactly_one_durable_record() {
185
+ let collection_only = serde_json::from_value::<DecisionRecordRef>(json!({
186
+ "collection": "tickets",
187
+ }))
188
+ .expect_err("a collection is not a decision target");
189
+ assert!(
190
+ collection_only.to_string().contains("record_id"),
191
+ "unexpected rejection reason: {collection_only}"
192
+ );
193
+
194
+ let db = memory_db("transport-target");
195
+ seed_tickets(&db, 1);
196
+ let runtime = DecisionTransportRuntime::Recording {
197
+ metadata: CountingRuntime::new().metadata(),
198
+ result: DecisionResult::Score {
199
+ selected: "low".into(),
200
+ levels: vec![],
201
+ option_mass: 0.9,
202
+ supporting_fields: vec![],
203
+ },
204
+ };
205
+ let request = |target: Option<DecisionRecordRef>, context: Option<Value>| {
206
+ DecisionTransportExecutionRequest {
207
+ target,
208
+ context,
209
+ definition: urgency_definition(),
210
+ options: DecisionOptions::default(),
211
+ authorization: None,
212
+ runtime: runtime.clone(),
213
+ }
214
+ };
215
+
216
+ let neither = execute_decision_transport(&db, &request(None, None)).expect_err("no target");
217
+ assert_eq!(neither.code, "DECISION_INVALID_RESULT");
218
+
219
+ let both = execute_decision_transport(
220
+ &db,
221
+ &request(
222
+ Some(DecisionRecordRef {
223
+ collection: "tickets".into(),
224
+ record_id: "t-0".into(),
225
+ }),
226
+ Some(json!({ "urgency": "high" })),
227
+ ),
228
+ )
229
+ .expect_err("two targets");
230
+ assert_eq!(both.code, "DECISION_INVALID_RESULT");
231
+ }
232
+
233
+ /// §14 — runtime capabilities describe decision kinds and batching only. No
234
+ /// capability speaks about candidate sets, ordering, filtering, or cursors,
235
+ /// so a runtime cannot currently advertise query composition and a query
236
+ /// surface cannot currently negotiate it.
237
+ #[test]
238
+ fn runtime_capabilities_declare_no_query_composition() {
239
+ let capabilities =
240
+ serde_json::to_value(DecisionRuntimeCapabilities::default()).expect("capabilities");
241
+ let declared = capabilities
242
+ .as_object()
243
+ .expect("object")
244
+ .keys()
245
+ .cloned()
246
+ .collect::<BTreeSet<_>>();
247
+ let expected = [
248
+ "batch",
249
+ "binary",
250
+ "choice",
251
+ "execution_methods",
252
+ "probability_distribution",
253
+ "score",
254
+ ]
255
+ .into_iter()
256
+ .map(String::from)
257
+ .collect::<BTreeSet<_>>();
258
+ assert_eq!(declared, expected);
259
+ }
260
+
261
+ /// §8 / §13 — the record-scoped primitive has no page boundary of its own:
262
+ /// evaluating N records is N calls, nothing in `semantic_decision` filters or
263
+ /// orders judgments, and results come back in request order. The page boundary,
264
+ /// filtering, and ordering belong to `semantic_query`, which consumes this
265
+ /// primitive and never re-implements it. Reuse is the primitive's own bound:
266
+ /// a second pass costs nothing.
267
+ #[test]
268
+ fn record_scoped_evaluation_has_no_page_boundary_and_no_ordering() {
269
+ let db = memory_db("page-boundary");
270
+ seed_tickets(&db, 6);
271
+ let runtime = CountingRuntime::new();
272
+ let ids = (0..6).map(|index| format!("t-{index}")).collect::<Vec<_>>();
273
+
274
+ let first_pass = ids
275
+ .iter()
276
+ .map(|id| {
277
+ decide_record(
278
+ &db,
279
+ &DecisionRecordRef {
280
+ collection: "tickets".into(),
281
+ record_id: id.clone(),
282
+ },
283
+ &urgency_definition(),
284
+ &runtime,
285
+ &DecisionOptions::default(),
286
+ None,
287
+ )
288
+ .expect("evaluation")
289
+ })
290
+ .collect::<Vec<_>>();
291
+ assert_eq!(runtime.invocations(), ids.len());
292
+ assert!(first_pass.iter().all(|evaluation| !evaluation.cached));
293
+
294
+ let selected = first_pass
295
+ .iter()
296
+ .map(|evaluation| match &evaluation.result {
297
+ DecisionResult::Score { selected, .. } => selected.clone(),
298
+ other => panic!("unexpected result {other:?}"),
299
+ })
300
+ .collect::<Vec<_>>();
301
+ assert_eq!(selected, ["low", "medium", "high", "low", "medium", "high"]);
302
+ let mut ordered = selected.clone();
303
+ ordered.sort_by_key(|level| ["low", "medium", "high"].iter().position(|l| l == level));
304
+ assert_ne!(
305
+ selected, ordered,
306
+ "results come back in request order; nothing in the crate ranks them"
307
+ );
308
+
309
+ for id in &ids {
310
+ let again = decide_record(
311
+ &db,
312
+ &DecisionRecordRef {
313
+ collection: "tickets".into(),
314
+ record_id: id.clone(),
315
+ },
316
+ &urgency_definition(),
317
+ &runtime,
318
+ &DecisionOptions::default(),
319
+ None,
320
+ )
321
+ .expect("reuse");
322
+ assert!(again.cached);
323
+ assert_eq!(again.provenance.source, "cache");
324
+ }
325
+ assert_eq!(runtime.invocations(), ids.len());
326
+ }
327
+
328
+ /// §6 — a judgment is durable derived state in the decision collection. The
329
+ /// application record it judged is untouched: same value, same version. A
330
+ /// semantic query must keep this separation; it may write judgments and
331
+ /// nothing else.
332
+ #[test]
333
+ fn judgments_live_beside_application_state_and_never_inside_it() {
334
+ let db = memory_db("derived-judgment");
335
+ seed_tickets(&db, 1);
336
+ let before: Value = db.get("tickets:t-0").expect("get").expect("record");
337
+
338
+ let evaluation = decide_record(
339
+ &db,
340
+ &DecisionRecordRef {
341
+ collection: "tickets".into(),
342
+ record_id: "t-0".into(),
343
+ },
344
+ &urgency_definition(),
345
+ &CountingRuntime::new(),
346
+ &DecisionOptions::default(),
347
+ None,
348
+ )
349
+ .expect("evaluation");
350
+
351
+ let after: Value = db.get("tickets:t-0").expect("get").expect("record");
352
+ assert_eq!(before, after, "the judged record is not modified");
353
+
354
+ let judgments = db
355
+ .capability(DEFAULT_DECISION_COLLECTION)
356
+ .find(|_: &feltdb::DecisionJudgmentRecord| true)
357
+ .expect("judgments");
358
+ assert_eq!(judgments.len(), 1);
359
+ assert_eq!(judgments[0].id, evaluation.provenance.cache_key);
360
+ assert_eq!(judgments[0].collection, "tickets");
361
+ assert_eq!(judgments[0].record_id, "t-0");
362
+ assert_eq!(judgments[0].state.reference, "tickets:t-0");
363
+ }
@@ -87,6 +87,11 @@ pub struct AppState {
87
87
  pub mesh: Arc<std::sync::Mutex<WorkerMeshStore>>,
88
88
  pub readiness_probe: Arc<PathBuf>,
89
89
  pub bounded_query_cursors: Arc<std::sync::Mutex<HashMap<String, BoundedQueryCursor>>>,
90
+ /// The deployment's semantic runtime binding for bounded semantic query
91
+ /// composition, or `None` when the deployment configures no runtime. A
92
+ /// query never names its runtime; this binding's identity is part of
93
+ /// every judgment the query stage produces or reuses.
94
+ pub semantic_runtime: Option<Arc<dyn feltdb::DecisionRuntime + Send + Sync>>,
90
95
  /// Bounds how many requests may be in flight at once.
91
96
  ///
92
97
  /// Every read and write serializes on one lock inside the database, so
@@ -7303,6 +7303,7 @@ async fn run_runtime_query(
7303
7303
  group_by: vec![],
7304
7304
  aggregates: vec![],
7305
7305
  references: vec![],
7306
+ semantic: None,
7306
7307
  };
7307
7308
  let context = feltdb::state_contract::begin_query_read(
7308
7309
  &state.db,
@@ -7555,6 +7556,7 @@ fn state_contract_error(error: feltdb::state_contract::StateFailure) -> ApiError
7555
7556
  "AUTHORIZATION_DENIED" => StatusCode::FORBIDDEN,
7556
7557
  "PRECONDITION_FAILED" | "CONFLICT" | "IDEMPOTENCY_CONFLICT" => StatusCode::CONFLICT,
7557
7558
  "UNKNOWN_COLLECTION" => StatusCode::NOT_FOUND,
7559
+ "SEMANTIC_UNSUPPORTED" => StatusCode::NOT_IMPLEMENTED,
7558
7560
  "STORAGE_FAILURE" | "STATE_UNAVAILABLE" => StatusCode::SERVICE_UNAVAILABLE,
7559
7561
  _ => StatusCode::UNPROCESSABLE_ENTITY,
7560
7562
  };
@@ -8675,6 +8677,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8675
8677
  mesh: Arc::new(std::sync::Mutex::new(mesh_store)),
8676
8678
  readiness_probe: Arc::new(readiness_probe),
8677
8679
  bounded_query_cursors: Arc::new(std::sync::Mutex::new(HashMap::new())),
8680
+ semantic_runtime: configured_semantic_runtime()?,
8678
8681
  };
8679
8682
  start_peer_sessions(&state, &config)?;
8680
8683
  start_membership_recovery(&state);
@@ -12745,7 +12748,7 @@ async fn get_revision(State(state): State<AppState>) -> Result<Json<RevisionResp
12745
12748
  }))
12746
12749
  }
12747
12750
 
12748
- const MAX_BOUNDED_QUERY_LIMIT: usize = 500;
12751
+ const MAX_BOUNDED_QUERY_LIMIT: usize = 1_000;
12749
12752
  const BOUNDED_CURSOR_TTL_SECONDS: u64 = 300;
12750
12753
 
12751
12754
  #[derive(Clone, Deserialize, Serialize)]
@@ -12759,6 +12762,11 @@ struct BoundedQueryRequest {
12759
12762
  limit: usize,
12760
12763
  #[serde(default)]
12761
12764
  cursor: Option<String>,
12765
+ /// Semantic composition over the deterministic page, per
12766
+ /// `docs/reference/semantic-query.md`. Bound into the request hash so a
12767
+ /// continuation cursor is valid only under the clause it was issued for.
12768
+ #[serde(default)]
12769
+ semantic: Option<feltdb::SemanticQueryClause>,
12762
12770
  }
12763
12771
 
12764
12772
  #[derive(Clone, Deserialize, Serialize)]
@@ -12781,6 +12789,63 @@ struct BoundedQueryPage {
12781
12789
  #[serde(skip_serializing_if = "Option::is_none")]
12782
12790
  next_cursor: Option<String>,
12783
12791
  exhausted: bool,
12792
+ /// Present exactly when the request carried a semantic clause. Its
12793
+ /// `annotations` align with `records`.
12794
+ #[serde(skip_serializing_if = "Option::is_none")]
12795
+ semantic: Option<BoundedQuerySemanticResult>,
12796
+ }
12797
+
12798
+ #[derive(Serialize)]
12799
+ struct BoundedQuerySemanticResult {
12800
+ complete: bool,
12801
+ stage: feltdb::SemanticStageSummary,
12802
+ annotations: Vec<Option<feltdb::SemanticAnnotation>>,
12803
+ unevaluated: Vec<feltdb::SemanticUnevaluated>,
12804
+ }
12805
+
12806
+ /// The deployment's semantic runtime binding, from `FELTDB_SEMANTIC_RUNTIME`.
12807
+ ///
12808
+ /// The variable names a JSON file in the semantic-decision transport's runtime
12809
+ /// shape (`{"kind": "deterministic", "metadata": {...}, "responses": {...}}`).
12810
+ /// A deployment that sets nothing has no binding, and every semantic query
12811
+ /// fails with `SEMANTIC_PROVIDER_UNAVAILABLE` rather than answering the
12812
+ /// deterministic part as if the clause had been honored. A file that cannot be
12813
+ /// read or parsed is a startup failure: a misconfigured binding must not look
12814
+ /// like an unconfigured one.
12815
+ fn configured_semantic_runtime(
12816
+ ) -> Result<Option<Arc<dyn feltdb::DecisionRuntime + Send + Sync>>, String> {
12817
+ let Ok(path) = std::env::var("FELTDB_SEMANTIC_RUNTIME") else {
12818
+ return Ok(None);
12819
+ };
12820
+ let source = std::fs::read_to_string(&path)
12821
+ .map_err(|error| format!("FELTDB_SEMANTIC_RUNTIME {path} is unreadable: {error}"))?;
12822
+ let runtime: feltdb::DecisionTransportRuntime =
12823
+ serde_json::from_str(&source).map_err(|error| {
12824
+ format!("FELTDB_SEMANTIC_RUNTIME {path} is not a runtime binding: {error}")
12825
+ })?;
12826
+ feltdb::configured_decision_runtime(&runtime)
12827
+ .map(Some)
12828
+ .map_err(|error| format!("FELTDB_SEMANTIC_RUNTIME {path} is refused: {error}"))
12829
+ }
12830
+
12831
+ fn semantic_query_error(error: feltdb::SemanticQueryError) -> ApiError {
12832
+ let status = match error.code.as_str() {
12833
+ "INVALID_QUERY" | "INVALID_CURSOR" => StatusCode::UNPROCESSABLE_ENTITY,
12834
+ "SEMANTIC_PROVIDER_UNAVAILABLE" | "DECISION_STORAGE_ERROR" => {
12835
+ StatusCode::SERVICE_UNAVAILABLE
12836
+ }
12837
+ "SEMANTIC_UNSUPPORTED" => StatusCode::NOT_IMPLEMENTED,
12838
+ "SEMANTIC_INVALID_RESULT" => StatusCode::BAD_GATEWAY,
12839
+ _ => StatusCode::BAD_REQUEST,
12840
+ };
12841
+ ApiError::structured(
12842
+ status,
12843
+ json!({
12844
+ "code": error.code,
12845
+ "message": error.message,
12846
+ "http_status": status.as_u16(),
12847
+ }),
12848
+ )
12784
12849
  }
12785
12850
 
12786
12851
  fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
@@ -12794,11 +12859,15 @@ fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
12794
12859
  }
12795
12860
 
12796
12861
  fn bounded_query_hash(request: &BoundedQueryRequest) -> Result<String, ApiError> {
12862
+ // The semantic clause is part of the request a cursor is bound to, so a
12863
+ // continuation presented under a different clause is `INVALID_CURSOR`. The
12864
+ // clause's identity, never its results, enters the hash.
12797
12865
  let context = json!({
12798
12866
  "collection": request.collection,
12799
12867
  "where": request.conditions,
12800
12868
  "orderBy": request.order_by,
12801
12869
  "limit": request.limit,
12870
+ "semantic": request.semantic.as_ref().map(feltdb::semantic_clause_hash),
12802
12871
  });
12803
12872
  let bytes = serde_json::to_vec(&context)
12804
12873
  .map_err(|error| bounded_query_error("INVALID_QUERY", error.to_string()))?;
@@ -13058,10 +13127,15 @@ async fn execute_bounded_query(
13058
13127
  State(state): State<AppState>,
13059
13128
  Extension(principal): Extension<Principal>,
13060
13129
  headers: HeaderMap,
13061
- Json(request): Json<BoundedQueryRequest>,
13130
+ Json(request): Json<Value>,
13062
13131
  ) -> Result<Json<BoundedQueryPage>, ApiError> {
13063
13132
  let _handler =
13064
13133
  feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
13134
+ // A request this surface cannot represent — including a semantic clause
13135
+ // with keys the contract does not define — is a structured `INVALID_QUERY`,
13136
+ // so a client can tell a refused clause from a transport failure.
13137
+ let request: BoundedQueryRequest = serde_json::from_value(request)
13138
+ .map_err(|error| bounded_query_error("INVALID_QUERY", error.to_string()))?;
13065
13139
  let execution = bounded_query_execution(&headers)?;
13066
13140
  validate_segment(&request.collection)?;
13067
13141
  if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
@@ -13100,6 +13174,10 @@ async fn execute_bounded_query(
13100
13174
  ));
13101
13175
  }
13102
13176
  }
13177
+ // A malformed semantic clause fails before any deterministic work runs.
13178
+ if let Some(clause) = &request.semantic {
13179
+ feltdb::validate_semantic_clause(clause).map_err(semantic_query_error)?;
13180
+ }
13103
13181
  let hash = bounded_query_hash(&request)?;
13104
13182
  let (records, position) = if let Some(token) = &request.cursor {
13105
13183
  let cursor = state
@@ -13187,7 +13265,7 @@ async fn execute_bounded_query(
13187
13265
  &state,
13188
13266
  BoundedQueryCursor {
13189
13267
  query_hash: hash,
13190
- principal_key_id: principal.key_id,
13268
+ principal_key_id: principal.key_id.clone(),
13191
13269
  namespace: state.namespace.to_string(),
13192
13270
  records: records.clone(),
13193
13271
  position: end,
@@ -13197,10 +13275,80 @@ async fn execute_bounded_query(
13197
13275
  } else {
13198
13276
  None
13199
13277
  };
13278
+ // The semantic stage transforms exactly this page. It runs after the
13279
+ // deterministic cursor is issued because continuation is by deterministic
13280
+ // position: the page may return fewer records than `limit` and still not
13281
+ // be exhausted.
13282
+ let semantic = match &request.semantic {
13283
+ None => None,
13284
+ Some(clause) => {
13285
+ let candidates = page
13286
+ .iter()
13287
+ .map(|record| feltdb::SemanticCandidate {
13288
+ record_id: record
13289
+ .get("recordId")
13290
+ .and_then(Value::as_str)
13291
+ .unwrap_or_default()
13292
+ .to_string(),
13293
+ record: record.clone(),
13294
+ })
13295
+ .collect();
13296
+ let composed = feltdb::compose_semantic_page(
13297
+ &state.db,
13298
+ &request.collection,
13299
+ candidates,
13300
+ clause,
13301
+ state
13302
+ .semantic_runtime
13303
+ .as_deref()
13304
+ .map(|runtime| runtime as &dyn feltdb::DecisionRuntime),
13305
+ clause.authorization.as_ref().map(|authorization| {
13306
+ let requested = authorization.principal.as_ref();
13307
+ feltdb::DecisionPrincipal {
13308
+ subject: decision_subject(&principal),
13309
+ tenant_id: requested
13310
+ .map(|value| value.tenant_id.clone())
13311
+ .unwrap_or_default(),
13312
+ application_id: requested
13313
+ .map(|value| value.application_id.clone())
13314
+ .unwrap_or_default(),
13315
+ capability: "state:read".into(),
13316
+ }
13317
+ }),
13318
+ )
13319
+ .map_err(semantic_query_error)?;
13320
+ audit(
13321
+ &state,
13322
+ &principal.key_id,
13323
+ "semantic_query.compose",
13324
+ &format!("{}@{}", request.collection, composed.stage.definition_hash),
13325
+ if composed.complete {
13326
+ "complete"
13327
+ } else {
13328
+ "incomplete"
13329
+ },
13330
+ 200,
13331
+ );
13332
+ Some(composed)
13333
+ }
13334
+ };
13335
+ let (page, semantic) = match semantic {
13336
+ None => (page, None),
13337
+ Some(composed) => (
13338
+ composed.records,
13339
+ Some(BoundedQuerySemanticResult {
13340
+ complete: composed.complete,
13341
+ stage: composed.stage,
13342
+ annotations: composed.annotations,
13343
+ unevaluated: composed.unevaluated,
13344
+ }),
13345
+ ),
13346
+ };
13200
13347
  Ok(Json(BoundedQueryPage {
13201
13348
  records: page,
13202
13349
  exhausted: next_cursor.is_none(),
13203
13350
  next_cursor,
13351
+ semantic,
13204
13352
  }))
13205
13353
  }
13206
13354
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "create-feltdb",
3
3
  "private": false,
4
4
  "description": "Create a new FeltDB application with one command",
5
- "version": "0.11.3",
5
+ "version": "0.11.7",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"