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