khive_runtime/pack/request_identity.rs
1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use khive_gate::GateRef;
5use khive_storage::EventStore;
6use khive_types::Namespace;
7use serde_json::Value;
8
9use crate::error::RuntimeError;
10use crate::runtime::NamespaceToken;
11
12use super::{
13 DispatchHook, EndpointKind, HandlerDef, PackByIdResolver, PackRuntime, SPECIAL_RELATIONS,
14};
15
16/// Immutable registry that dispatches verb calls to registered packs.
17///
18/// Clone is cheap (Arc-wrapped). Constructed via `VerbRegistryBuilder`.
19#[derive(Clone)]
20pub struct VerbRegistry {
21 pub(super) packs: std::sync::Arc<Vec<Box<dyn PackRuntime>>>,
22 /// Pack-level by-ID resolvers, in registration order.
23 pub(super) resolvers: std::sync::Arc<Vec<(String, Box<dyn PackByIdResolver>)>>,
24 /// Read-only KG lookup topology; never used to redirect a pack write.
25 pub(super) kg_read_resolver: Option<Arc<crate::kg_read::KgReadResolver>>,
26 pub(super) gate: GateRef,
27 pub(super) default_namespace: String,
28 /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
29 ///
30 /// On the default (no explicit `namespace=` param) dispatch path, reads fan
31 /// out over `['local'] ∪ visible_namespaces`. Writes are unaffected — they
32 /// still pin to `'local'`. An explicit `namespace=` request param is a
33 /// precise single-namespace escape and is not widened by this set.
34 pub(super) visible_namespaces: Vec<Namespace>,
35 /// Configured actor identity label (ADR-057). When `Some`, dispatch mints
36 /// tokens carrying this actor so that `comm.inbox` applies the `to_actor`
37 /// filter. When `None`, tokens carry `ActorRef::anonymous()` (party-line).
38 pub(super) actor_id: Option<String>,
39 /// Audit event sink — `None` means tracing-only (v0.2 default).
40 pub(super) event_store: Option<Arc<dyn EventStore>>,
41 /// Distinguishes ordinary tracing-only construction from a sink omitted
42 /// deliberately because its configured backend is read-only.
43 pub(super) audit_store_read_only: bool,
44 /// Post-dispatch hook: `None` means no real-time observation.
45 pub(super) dispatch_hook: Option<Arc<dyn DispatchHook>>,
46 /// Names of all `Visibility::Verb` handlers across all packs, precomputed
47 /// once at `build()` time. Used only to render the unknown-verb error
48 /// message — the pack set is fixed after construction, so there is no
49 /// need to re-scan every pack's handlers on every miss.
50 pub(super) available_verbs: Arc<Vec<&'static str>>,
51 /// Static handler metadata indexed once at build time. Duplicate internal
52 /// subhandler names retain the first pack's declaration, as before.
53 pub(super) handler_by_name: Arc<HashMap<&'static str, &'static HandlerDef>>,
54 /// Verbs eligible for admission-pressure audit degradation, precomputed
55 /// once at `build()` time from registration-time pack trust plus each
56 /// handler's declared category and
57 /// [`VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS`]. See
58 /// [`VerbRegistry::admission_degrade_safe`].
59 pub(super) degrade_safe_verbs: Arc<HashSet<&'static str>>,
60 /// Trusted canonical public handlers classified Read by the shared effects table.
61 pub(super) read_replay_safe_verbs: Arc<HashSet<&'static str>>,
62 /// Recently-referenced ring (unified-verb draft ADR, Slice 1). Daemon-warm,
63 /// actor-scoped, never persisted — see `crate::reference_ring`. Shared
64 /// across every clone of this registry via the `Arc`, so admissions made
65 /// by one dispatch are visible to the next on the same warm daemon.
66 pub(super) reference_ring: Arc<crate::reference_ring::ReferenceRing>,
67 /// ADR-133 audit-batch seam. `None` exactly when `event_store` is
68 /// `None` — no store configured means no seam to construct, and every
69 /// audit call site falls back to its pre-ADR-133 tracing-only/no-op
70 /// path.
71 pub(super) audit_batch: Option<Arc<crate::audit_batch::AuditBatch>>,
72}
73
74/// Result of an operation handled outside normal pack dispatch, paired with
75/// typed transport metadata that must survive the gate/audit boundary.
76///
77/// The canonical `result` remains the value used for audit accounting. The
78/// metadata is returned to the intercepting transport without being smuggled
79/// through a mutex side channel or folded into the verb's public result shape.
80#[derive(Debug, Clone, PartialEq)]
81pub struct InterceptedDispatchResult<M> {
82 /// Canonical verb result used for audit and resource accounting.
83 pub result: Value,
84 /// Transport-owned metadata that must accompany the canonical result.
85 pub metadata: M,
86}
87
88impl<M> InterceptedDispatchResult<M> {
89 /// Pair a canonical result with its typed transport metadata.
90 pub fn new(result: Value, metadata: M) -> Self {
91 Self { result, metadata }
92 }
93}
94
95/// Per-request identity context that overrides a [`VerbRegistry`]'s
96/// construction-baked `default_namespace` / `actor_id` / `visible_namespaces`
97/// for exactly one [`VerbRegistry::dispatch_with_identity`] call (ADR-096
98/// Fork 1 — warm-daemon per-request identity).
99///
100/// A single warm registry is built once with a baked identity, but must be
101/// able to serve requests whose caller resolved a *different* attribution
102/// identity (e.g. a different project-local `[actor]`) without a cold
103/// fallback and without mis-stamping writes under the registry's own baked
104/// actor. Supplying `Some(RequestIdentity { .. })` threads the caller's
105/// identity through token minting for that one call; the registry's fields
106/// (and every other in-flight call) are untouched. `None` is exactly
107/// [`VerbRegistry::dispatch`] — the baked scalars apply, unchanged from
108/// before this type existed.
109#[derive(Debug, Clone, Default)]
110pub struct RequestIdentity {
111 /// Storage/gate default namespace for this request (used when the verb's
112 /// own params carry no explicit `namespace` field). Overrides
113 /// `VerbRegistry::default_namespace`.
114 pub namespace: String,
115 /// Write-stamp / gate actor label for this request (ADR-057). Overrides
116 /// `VerbRegistry::actor_id`. `None` mints `ActorRef::anonymous()`, same
117 /// as an unconfigured baked `actor_id`.
118 pub actor_id: Option<String>,
119 /// Extra read-visibility namespaces for this request (ADR-007 Rev 4 Rule
120 /// 3b). Overrides `VerbRegistry::visible_namespaces`. Entries that fail
121 /// `Namespace::parse` are skipped with a `tracing::warn!` rather than
122 /// failing the whole request — a single malformed visibility entry from a
123 /// caller-supplied frame must not block dispatch.
124 pub visible_namespaces: Vec<String>,
125 /// Opaque process provenance resolved by the originating request process.
126 /// `None` means the origin did not set one; a warm daemon must not replace
127 /// it with its own process environment. This field is attribution-only and
128 /// never participates in the gate or token authority.
129 pub process_ref: Option<String>,
130 /// Caller-supplied correlation id for this request (khive#948), carried
131 /// unchanged from the daemon frame's `request_id` field. Every operation
132 /// in one batch or chain receives the same value: it is a request-group
133 /// selector, never an operation-unique id. Stamped into the audit event's
134 /// `resource.request_id` on every outcome (success, error, and denied) so
135 /// a client can join its own pre-send sample to all server-side audit rows
136 /// for that request. `None` means the caller
137 /// supplied no id (a pre-#948 client, or an internal/non-benchmark
138 /// caller) — the audit row then carries no `request_id` key at all.
139 pub request_id: Option<u64>,
140}
141
142impl RequestIdentity {
143 /// Reconstruct the effective principal and namespace scope carried by an
144 /// already-authorized token for a nested registry dispatch.
145 ///
146 /// Cross-pack calls must still pass through the registry Gate, but using
147 /// the registry's construction-baked identity would silently replace a
148 /// warm daemon request's actor and visibility (ADR-096). This projection
149 /// preserves the token's exact primary namespace, actor, and read-visible
150 /// namespaces, and the origin's process provenance rider. Nested calls
151 /// intentionally use `request_id: None` even when the token retains the
152 /// ingress id for audit rows within its originating dispatch; `process_ref` IS carried by
153 /// the token (ADR-096: an absent value stays absent, a present origin
154 /// rider survives nested dispatch without reading the daemon
155 /// environment).
156 pub fn from_token(token: &NamespaceToken) -> Self {
157 Self {
158 namespace: token.namespace().as_str().to_string(),
159 actor_id: token.actor().binding_id().map(str::to_string),
160 visible_namespaces: token
161 .visible_namespaces()
162 .iter()
163 .map(|namespace| namespace.as_str().to_string())
164 .collect(),
165 process_ref: token.process_ref().map(str::to_owned),
166 request_id: None,
167 }
168 }
169}
170
171/// A non-blank, out-of-band authenticated principal for [`VerbRegistry::dispatch_as`].
172///
173/// Embedding hosts authenticate a principal through their own channel (not the
174/// request DSL) and then need that principal to become the effective actor
175/// for one dispatch. The constructor rejects an empty or whitespace-only
176/// identifier so an authentication-integration failure (an empty subject)
177/// fails closed at construction time instead of silently resolving to the
178/// anonymous/local actor at dispatch time — see [`crate::actor_identity::resolve_actor`].
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct VerifiedActor(String);
181
182impl VerifiedActor {
183 /// Validate and wrap a verified principal identifier.
184 ///
185 /// Returns `RuntimeError::InvalidInput` when `id` is empty or contains
186 /// only whitespace.
187 pub fn new(id: impl Into<String>) -> Result<Self, RuntimeError> {
188 let id = id.into();
189 if id.trim().is_empty() {
190 return Err(RuntimeError::InvalidInput(
191 "VerifiedActor: identifier must not be empty or whitespace-only".to_string(),
192 ));
193 }
194 Ok(Self(id))
195 }
196
197 /// Borrow the validated identifier.
198 pub fn as_str(&self) -> &str {
199 &self.0
200 }
201
202 pub(super) fn into_inner(self) -> String {
203 self.0
204 }
205}
206
207/// Error returned by [`VerbRegistry::apply_schema_plans_with_map`] when two
208/// packs on the same backend declare the same auxiliary table (ADR-028 §7).
209#[derive(Debug)]
210pub struct PackSchemaCollisionError {
211 /// First pack to declare the table.
212 pub pack_a: &'static str,
213 /// Second pack that collides with `pack_a`.
214 pub pack_b: &'static str,
215 /// Table name or DDL error description.
216 pub table: String,
217}
218
219impl std::fmt::Display for PackSchemaCollisionError {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 if self.pack_a == self.pack_b {
222 write!(
223 f,
224 "pack schema boot failure for pack {:?}: {}",
225 self.pack_a, self.table
226 )
227 } else {
228 write!(
229 f,
230 "pack schema collision: packs {:?} and {:?} both declare table {:?} \
231 on the same backend — move one pack to a separate backend or rename the table",
232 self.pack_a, self.pack_b, self.table
233 )
234 }
235 }
236}
237
238impl std::error::Error for PackSchemaCollisionError {}
239
240/// Extract table names from a single DDL statement.
241///
242/// Handles SQL trivia, SQLite identifier quoting, optional TEMP/VIRTUAL and a
243/// `main.` qualifier. Index and other non-table DDL return no table names.
244pub(super) fn extract_table_names(stmt: &str) -> Vec<String> {
245 enum SqlToken {
246 Bare(String),
247 Quoted(String),
248 Punctuation(char),
249 }
250
251 let mut tokens = Vec::new();
252 let mut chars = stmt.chars().peekable();
253 while let Some(ch) = chars.next() {
254 if ch.is_whitespace() {
255 continue;
256 }
257 if ch == '-' && chars.peek() == Some(&'-') {
258 chars.next();
259 for next in chars.by_ref() {
260 if next == '\n' {
261 break;
262 }
263 }
264 continue;
265 }
266 if ch == '/' && chars.peek() == Some(&'*') {
267 chars.next();
268 let mut previous = '\0';
269 for next in chars.by_ref() {
270 if previous == '*' && next == '/' {
271 break;
272 }
273 previous = next;
274 }
275 continue;
276 }
277 if matches!(ch, '"' | '`' | '[' | '\'') {
278 let closing = if ch == '[' { ']' } else { ch };
279 let mut token = String::new();
280 while let Some(next) = chars.next() {
281 if next == closing {
282 if chars.peek() == Some(&closing) {
283 chars.next();
284 token.push(closing);
285 } else {
286 break;
287 }
288 } else {
289 token.push(next);
290 }
291 }
292 tokens.push(SqlToken::Quoted(token));
293 continue;
294 }
295 if matches!(ch, '.' | '(' | ';') {
296 tokens.push(SqlToken::Punctuation(ch));
297 continue;
298 }
299 let mut token = ch.to_string();
300 while let Some(next) = chars.peek().copied() {
301 let begins_comment = (next == '-' && chars.clone().nth(1) == Some('-'))
302 || (next == '/' && chars.clone().nth(1) == Some('*'));
303 if next.is_whitespace()
304 || matches!(next, '.' | '(' | ';' | '"' | '`' | '[' | '\'')
305 || begins_comment
306 {
307 break;
308 }
309 token.push(next);
310 chars.next();
311 }
312 tokens.push(SqlToken::Bare(token));
313 }
314
315 let keyword = |index: usize, word: &str| matches!(tokens.get(index), Some(SqlToken::Bare(token)) if token.eq_ignore_ascii_case(word));
316 if !keyword(0, "CREATE") {
317 return Vec::new();
318 }
319 let mut index = 1;
320 if keyword(index, "TEMP") || keyword(index, "TEMPORARY") {
321 index += 1;
322 }
323 if keyword(index, "VIRTUAL") {
324 index += 1;
325 }
326 if !keyword(index, "TABLE") {
327 return Vec::new();
328 }
329 index += 1;
330 if keyword(index, "IF") && keyword(index + 1, "NOT") && keyword(index + 2, "EXISTS") {
331 index += 3;
332 }
333 let main_qualifier = matches!(
334 tokens.get(index),
335 Some(SqlToken::Bare(name) | SqlToken::Quoted(name)) if name.eq_ignore_ascii_case("main")
336 );
337 if main_qualifier && matches!(tokens.get(index + 1), Some(SqlToken::Punctuation('.'))) {
338 index += 2;
339 }
340 match tokens.get(index) {
341 Some(SqlToken::Bare(name) | SqlToken::Quoted(name)) if !name.is_empty() => {
342 vec![name.to_ascii_lowercase()]
343 }
344 _ => Vec::new(),
345 }
346}
347
348/// Render an [`EndpointKind`] as the `"<substrate>:<kind>"` label used in
349/// `link(help=true)`'s `endpoint_rules` table.
350fn endpoint_kind_label(kind: &EndpointKind) -> String {
351 match kind {
352 EndpointKind::EntityOfKind(k) => format!("entity:{k}"),
353 EndpointKind::NoteOfKind(k) => format!("note:{k}"),
354 EndpointKind::EntityOfType { kind, entity_type } => {
355 format!("entity:{kind}({entity_type})")
356 }
357 }
358}
359
360pub(crate) fn is_special_relation(relation: khive_types::EdgeRelation) -> bool {
361 SPECIAL_RELATIONS.contains(&relation)
362}
363
364/// Compose the full per-relation endpoint allowlist surfaced by
365/// `link(help=true)` (issue #964).
366///
367/// Combines the base entity-to-entity endpoint contract
368/// (`operations::base_entity_endpoint_rules`) with every loaded pack's
369/// additive `EDGE_RULES`, the unconditional `note -> note` allowance for the
370/// three special relations (`supersedes` / `supports` / `refutes` —
371/// `operations.rs`'s dedicated special-relation branch), and the
372/// `annotates` note-to-any special case — the exact same sources
373/// `valid_relations_for_entity_pair` (`khive-pack-kg`) consults when
374/// enriching a rejected `link` call, so a caller reading this table cannot
375/// diverge from what the validator itself accepts.
376///
377/// Pack `EDGE_RULES` for a special relation are deliberately excluded: the
378/// validator's special-relation branch returns before `pack_rule_allows` is
379/// ever reached (`operations.rs`), so advertising such a rule here would
380/// claim enforcement that never actually happens.
381pub(super) fn edge_endpoint_table(packs: &[Box<dyn PackRuntime>]) -> Vec<Value> {
382 let mut rows: Vec<Value> = crate::operations::base_entity_endpoint_rules()
383 .iter()
384 .map(|(src, rel, tgt)| {
385 serde_json::json!({
386 "relation": rel.as_str(),
387 "source": format!("entity:{src}"),
388 "target": format!("entity:{tgt}"),
389 })
390 })
391 .collect();
392
393 for rel in SPECIAL_RELATIONS {
394 rows.push(serde_json::json!({
395 "relation": rel.as_str(),
396 "source": "note:*",
397 "target": "note:*",
398 }));
399 }
400
401 for pack in packs.iter() {
402 for rule in pack.edge_rules().iter() {
403 if is_special_relation(rule.relation) {
404 continue;
405 }
406 rows.push(serde_json::json!({
407 "relation": rule.relation.as_str(),
408 "source": endpoint_kind_label(&rule.source),
409 "target": endpoint_kind_label(&rule.target),
410 }));
411 }
412 }
413
414 rows.push(serde_json::json!({
415 "relation": "annotates",
416 "source": "note:*",
417 "target": "any (entity, note, edge, or event)",
418 }));
419
420 rows
421}