Skip to main content

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}