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.
- package/dist/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/lib.rs +10 -1
- package/dist/server-source/crates/feltdb/src/semantic_decision.rs +124 -12
- package/dist/server-source/crates/feltdb/src/semantic_query.rs +599 -0
- package/dist/server-source/crates/feltdb/src/state_contract.rs +222 -0
- package/dist/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +1 -0
- package/dist/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +1 -0
- package/dist/server-source/crates/feltdb/tests/semantic_query.rs +767 -0
- package/dist/server-source/crates/feltdb/tests/semantic_query_contract.rs +363 -0
- package/dist/server-source/crates/feltdb-server/src/app_state.rs +5 -0
- package/dist/server-source/crates/feltdb-server/src/main.rs +151 -3
- package/package.json +1 -1
|
@@ -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 =
|
|
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<
|
|
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
|
|