Skip to main content

khive_runtime/
pack.rs

1// FILE SIZE JUSTIFICATION: pack.rs is the load-bearing dispatch core — VerbRegistry,
2// VerbRegistryBuilder, PackRuntime, DispatchHook, and their test scaffolding all
3// share internal state (packs Vec, gate, event_store) that cannot be cleanly split
4// without exposing private fields or duplicating the scaffolding. Inline tests cover
5// collision detection and dispatch path that require direct access to VerbRegistry
6// internals. Split plan: when the verb surface reaches a stable v1 API, extract
7// VerbRegistryBuilder into `pack/builder.rs` and gate/event logic into `pack/dispatch.rs`.
8//! Pack runtime trait and verb registry.
9//!
10//! `PackRuntime` mirrors `Pack`'s const associated items as methods for object safety.
11//! Build a [`VerbRegistry`] via `VerbRegistryBuilder::build()`; registration is builder-only.
12
13#[cfg(test)]
14use std::any::Any;
15use std::collections::HashMap;
16use std::sync::Arc;
17
18#[cfg(test)]
19use crate::operations::LinkSpec;
20#[cfg(test)]
21use crate::runtime::NamespaceToken;
22#[cfg(test)]
23use async_trait::async_trait;
24#[cfg(test)]
25use khive_gate::{AllowAllGate, GateRef};
26use khive_gate::{AuditEvent, GateDecision, GateRequest};
27#[cfg(test)]
28use khive_storage::EventView;
29use khive_storage::{Event, EventStore, SubstrateKind};
30use khive_types::{EventKind, EventOutcome, Namespace};
31use serde_json::Value;
32
33pub use khive_types::{
34    json_type_name, EdgeEndpointRule, EndpointKind, EntityTypeDef, HandlerDef, IdResolutionMode,
35    NoteEmbeddingPolicy, NoteEmbeddingPolicySpec, NoteKindSpec, NoteLifecycleSpec,
36    PackColumnAddition, PackColumnAffinity, PackSchemaPlan, ParamDef, VerbCategory,
37    VerbPresentationPolicy, Visibility, RESERVED_ENVELOPE_ARGS,
38};
39// Backward-compat re-export.
40#[allow(deprecated)]
41pub use khive_types::VerbDef;
42
43/// Name of the pack providing the shared CRUD verbs and the general-purpose
44/// note kinds those verbs exist to serve.
45///
46/// Its note kinds are the ones any caller may author freely through `create`
47/// and `update`; every other pack's note kinds are records maintained by that
48/// pack's own verbs. Used by
49/// [`VerbRegistry::pack_owned_note_kinds`].
50pub const GENERIC_CRUD_PACK: &str = "kg";
51
52/// Stable advisory code emitted when a successful inspection cannot persist
53/// its dispatch audit because the configured audit backend is read-only.
54pub const AUDIT_PERSISTENCE_SKIPPED_READ_ONLY: &str = "audit_persistence_skipped_read_only";
55
56const FULL_UUID_IDENTIFIER_HELP: &str = "A complete UUID spelling accepted by the consuming \
57    parameter directly names one globally unique record; direct UUID lookup is not a namespace \
58    search. Strict identifier responses use canonical lowercase dashed UUIDs.";
59const SHORT_PREFIX_IDENTIFIER_HELP: &str = "A short UUID prefix is at least 8 hexadecimal \
60    characters without dashes that do not parse as a complete UUID. It is a resolution, not a \
61    direct identifier; a 32-character compact UUID is complete input instead. Its lookup scope \
62    belongs to the consuming parameter — see `identifier_resolution.resolution_modes` for the \
63    exhaustive per-mode rule, and each `uuid`/`array of uuid` parameter's own description for \
64    which mode it uses. A prefix can be missing or ambiguous.";
65const IDENTIFIER_PARAMETER_HELP: &str = "A parameter that requires a full UUID rejects prefixes \
66    and explains the resolution consequence. Its corresponding response field remains a \
67    canonical full UUID so the value can be submitted again.";
68
69/// Single-source, per-[`IdResolutionMode`] contract text.
70///
71/// Every `uuid`/`array of uuid` [`ParamDef`] declares which of these modes its
72/// handler actually implements (see [`IdResolutionMode`]'s own doc comment).
73/// [`VerbRegistry::describe_verb`] renders the SAME text in two places: once
74/// per matching parameter's description, and once in the top-level
75/// `identifier_resolution.resolution_modes` map — so the wording can never
76/// drift between the two call sites, and a caller reading only the top-level
77/// envelope still sees every mode that exists on the wire, not just the ones
78/// this particular verb happens to use.
79///
80/// `None` for [`IdResolutionMode::NotApplicable`]: nothing is appended to a
81/// non-identifier parameter's description, and it is never listed in
82/// `resolution_modes`.
83fn resolution_mode_contract(mode: IdResolutionMode) -> Option<&'static str> {
84    match mode {
85        IdResolutionMode::NotApplicable => None,
86        IdResolutionMode::UnscopedById => Some(
87            "ID contract (unscoped by-ID, ADR-007 Rev 6): a full UUID and a short hex prefix \
88             (8+ hex chars) both resolve with no namespace filter — the caller already knows \
89             the specific record, and authorization is the Gate's seam, not resolution's. A \
90             prefix matching nothing or matching more than one record is rejected. Used by \
91             get/update/delete/merge/link (link's source_id/target_id resolve through the same \
92             unfiltered path as the four record-level by-ID verbs), GTD's lifecycle id \
93             parameters, and brain's feedback target_id.",
94        ),
95        IdResolutionMode::PrefixScopedToPrimary => Some(
96            "ID contract (prefix scoped to primary namespace): a full UUID resolves as given, \
97             with no namespace check performed by this resolver. A short hex prefix (8+ hex \
98             chars) is resolved by searching only the caller's primary namespace, and is \
99             rejected if it matches nothing or matches more than one record there.",
100        ),
101        IdResolutionMode::FullAndPrefixScopedToPrimary => Some(
102            "ID contract (full UUID and prefix both scoped to primary namespace): both a full \
103             UUID and a short hex prefix (8+ hex chars) are validated against the caller's \
104             primary namespace — a record that exists but belongs to a different namespace \
105             resolves as not found. A prefix matching more than one record in that namespace \
106             is rejected as ambiguous.",
107        ),
108        IdResolutionMode::FullUuidOnlyScopedToPrimary => Some(
109            "ID contract (full UUID only, scoped to primary namespace): only a complete UUID \
110             is accepted — a short hex prefix is rejected outright because this field stores \
111             an explicit stable reference — and the UUID is validated against the caller's own \
112             (primary) namespace; a record that exists in a different namespace resolves as \
113             not found.",
114        ),
115        IdResolutionMode::UnscopedFullUuidOnly => Some(
116            "ID contract (full UUID only, unscoped): only a complete UUID is accepted — a \
117             short hex prefix is rejected outright — and no namespace check is performed on \
118             this parameter itself; any namespace scoping comes from the enclosing operation, \
119             not from this identifier.",
120        ),
121        IdResolutionMode::EdgeOrEventTarget => Some(
122            "ID contract (list target by kind): kind=event accepts only a full subject UUID; \
123             prefixes and names are rejected without graph resolution. Event rows remain \
124             scoped to the authorized event namespace. For kind=edge, a full UUID resolves as \
125             given; a unique 8+ hex prefix or entity name resolves in the primary namespace.",
126        ),
127    }
128}
129
130/// Stable wire key for an [`IdResolutionMode`], used as the key under
131/// `identifier_resolution.resolution_modes`.
132fn resolution_mode_key(mode: IdResolutionMode) -> &'static str {
133    match mode {
134        IdResolutionMode::NotApplicable => "not_applicable",
135        IdResolutionMode::UnscopedById => "unscoped_by_id",
136        IdResolutionMode::PrefixScopedToPrimary => "prefix_scoped_to_primary",
137        IdResolutionMode::FullAndPrefixScopedToPrimary => "full_and_prefix_scoped_to_primary",
138        IdResolutionMode::FullUuidOnlyScopedToPrimary => "full_uuid_only_scoped_to_primary",
139        IdResolutionMode::UnscopedFullUuidOnly => "unscoped_full_uuid_only",
140        IdResolutionMode::EdgeOrEventTarget => "edge_or_event_target",
141    }
142}
143
144/// Shared identifier-resolution contract included in every operation help schema.
145pub fn identifier_resolution_help() -> Value {
146    let modes: serde_json::Map<String, Value> = [
147        IdResolutionMode::UnscopedById,
148        IdResolutionMode::PrefixScopedToPrimary,
149        IdResolutionMode::FullAndPrefixScopedToPrimary,
150        IdResolutionMode::FullUuidOnlyScopedToPrimary,
151        IdResolutionMode::UnscopedFullUuidOnly,
152        IdResolutionMode::EdgeOrEventTarget,
153    ]
154    .into_iter()
155    .map(|mode| {
156        (
157            resolution_mode_key(mode).to_string(),
158            Value::String(
159                resolution_mode_contract(mode)
160                    .expect("every non-NotApplicable mode has contract text")
161                    .to_string(),
162            ),
163        )
164    })
165    .collect();
166
167    serde_json::json!({
168        "full_uuid": FULL_UUID_IDENTIFIER_HELP,
169        "short_prefix": SHORT_PREFIX_IDENTIFIER_HELP,
170        "parameter_rule": IDENTIFIER_PARAMETER_HELP,
171        "resolution_modes": modes,
172    })
173}
174
175mod traits;
176pub use traits::{
177    DispatchHook, KindHook, NoteUpdateEffect, PackByIdResolver, PackRuntime, SchemaPlan,
178};
179
180#[cfg(test)]
181use crate::error::DispatchError;
182use crate::error::{AuditObligationFailure, RuntimeError};
183use crate::KhiveRuntime;
184
185mod builder;
186pub use builder::{PackMetadataRegistry, VerbRegistryBuilder};
187
188mod catalog;
189mod dispatch;
190mod registry_access;
191mod request_identity;
192pub(crate) use request_identity::is_special_relation;
193pub use request_identity::{
194    InterceptedDispatchResult, PackSchemaCollisionError, RequestIdentity, VerbRegistry,
195    VerifiedActor,
196};
197
198/// Relations `validate_edge_relation_endpoints`
199/// (`crates/khive-runtime/src/operations.rs`) resolves in its own dedicated
200/// branch — before the generic pack-rule branch (`pack_rule_allows`) is ever
201/// reached. For these three relations the validator additionally accepts
202/// any `note -> note` pair unconditionally, regardless of note kind
203/// (ADR-002 §"Versioning" and §"Epistemic"), and never consults pack
204/// `EDGE_RULES` at all, on either substrate.
205pub(crate) const SPECIAL_RELATIONS: &[khive_types::EdgeRelation] = &[
206    khive_types::EdgeRelation::Supersedes,
207    khive_types::EdgeRelation::Supports,
208    khive_types::EdgeRelation::Refutes,
209];
210
211mod loading;
212pub use loading::{
213    ChannelIngestCapability, IngestAuditStore, PackFactory, PackInstall, PackLoadError,
214    PackRegistration, PackRegistry,
215};
216
217/// Pack names entitled to a [`ChannelIngestCapability`] grant at registration.
218pub(crate) const CHANNEL_INGEST_CAPABLE_PACKS: &[&str] = &["comm"];
219
220mod audit;
221use audit::{
222    append_audit_event_best_effort, build_audit_storage_event, fold_audit_obligation,
223    link_audit_success_from_result, masked_audit_event, persist_git_digest_receipt,
224    GitDigestReceiptOutcome,
225};
226pub use audit::{
227    audit_admission_refused_obligation_count, audit_admission_refused_obligation_last_at_ms,
228    audit_admission_unresolved_obligation_count, audit_admission_unresolved_obligation_last_at_ms,
229    resolve_explicit_namespace,
230};
231pub(crate) use audit::{audit_append_failure_count, audit_obligation_append_failure_count};
232
233// INLINE TEST JUSTIFICATION: tests here exercise VerbRegistry collision detection,
234// gate enforcement, and dispatch ordering that depend on direct access to the
235// registry's private `packs` Vec and gate field. Moving them to tests/ would
236// require pub-exporting registry internals. Broad behavioral dispatch tests
237// live in tests/integration.rs.
238#[cfg(test)]
239#[path = "pack_tests.rs"]
240pub(crate) mod tests;
241
242// ---- Inter-pack dependency checking ----
243
244#[cfg(test)]
245#[path = "pack/dep_tests.rs"]
246mod dep_tests;
247
248// ── Note-update hook sequencing tests ───────────────────────────
249//
250// These tests exercise the DISPATCHER (`VerbRegistry::prepare_note_update_hook`),
251// not any one pack's hook. The probe below overrides `normalize_note_update`
252// and `validate_note_update`, which since #2956 are the only two halves a pack
253// can implement — there is no sequencing method on the trait — so the only way
254// both can run, in order, is through the registry's own sequencing.
255
256#[cfg(test)]
257#[path = "pack/note_update_sequencing_tests.rs"]
258mod note_update_sequencing_tests;
259
260// ── Dispatch hook tests ─────────────────────────────────────────
261
262#[cfg(test)]
263#[path = "pack/hook_tests.rs"]
264mod hook_tests;
265
266// ── help=true tests ──────────────────────────────────────────────
267
268#[cfg(test)]
269#[path = "pack/help_tests.rs"]
270mod help_tests;
271
272// ── ADR-103 allowlist copy ───────────────────────────────────────
273
274#[cfg(test)]
275#[path = "pack/admission_allowlist_adr_tests.rs"]
276mod admission_allowlist_adr_tests;
277
278#[cfg(test)]
279#[path = "gate_argument_contract_tests.rs"]
280mod gate_argument_contract_tests;