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}