1use std::any::Any;
14use std::collections::{HashMap, HashSet, VecDeque};
15use std::sync::Arc;
16use std::time::Instant;
17
18use crate::operations::{LinkSpec, Resolved};
19use crate::runtime::NamespaceToken;
20use async_trait::async_trait;
21use khive_gate::{AllowAllGate, AuditEvent, GateDecision, GateRef, GateRequest};
22use khive_storage::{Event, EventStore, EventView, SubstrateKind};
23use khive_types::{EventKind, EventOutcome, Namespace};
24use serde_json::Value;
25
26pub use khive_types::{
27 EdgeEndpointRule, EndpointKind, EntityTypeDef, HandlerDef, IdResolutionMode, NoteKindSpec,
28 NoteLifecycleSpec, PackColumnAddition, PackColumnAffinity, PackSchemaPlan, ParamDef,
29 VerbCategory, VerbPresentationPolicy, Visibility, RESERVED_ENVELOPE_ARGS,
30};
31#[allow(deprecated)]
33pub use khive_types::VerbDef;
34
35use crate::validation::ValidationRule;
36
37pub const GENERIC_CRUD_PACK: &str = "kg";
45
46pub const AUDIT_PERSISTENCE_SKIPPED_READ_ONLY: &str = "audit_persistence_skipped_read_only";
49
50const FULL_UUID_IDENTIFIER_HELP: &str = "A complete UUID spelling accepted by the consuming \
51 parameter directly names one globally unique record; direct UUID lookup is not a namespace \
52 search. Strict identifier responses use canonical lowercase dashed UUIDs.";
53const SHORT_PREFIX_IDENTIFIER_HELP: &str = "A short UUID prefix is at least 8 hexadecimal \
54 characters without dashes that do not parse as a complete UUID. It is a resolution, not a \
55 direct identifier; a 32-character compact UUID is complete input instead. Its lookup scope \
56 belongs to the consuming parameter — see `identifier_resolution.resolution_modes` for the \
57 exhaustive per-mode rule, and each `uuid`/`array of uuid` parameter's own description for \
58 which mode it uses. A prefix can be missing or ambiguous.";
59const IDENTIFIER_PARAMETER_HELP: &str = "A parameter that requires a full UUID rejects prefixes \
60 and explains the resolution consequence. Its corresponding response field remains a \
61 canonical full UUID so the value can be submitted again.";
62
63fn resolution_mode_contract(mode: IdResolutionMode) -> Option<&'static str> {
78 match mode {
79 IdResolutionMode::NotApplicable => None,
80 IdResolutionMode::UnscopedById => Some(
81 "ID contract (unscoped by-ID, ADR-007 Rev 6): a full UUID and a short hex prefix \
82 (8+ hex chars) both resolve with no namespace filter — the caller already knows \
83 the specific record, and authorization is the Gate's seam, not resolution's. A \
84 prefix matching nothing or matching more than one record is rejected. Used by \
85 get/update/delete/merge/link (link's source_id/target_id resolve through the same \
86 unfiltered path as the four record-level by-ID verbs), GTD's lifecycle id \
87 parameters, and brain's feedback target_id.",
88 ),
89 IdResolutionMode::PrefixScopedToPrimary => Some(
90 "ID contract (prefix scoped to primary namespace): a full UUID resolves as given, \
91 with no namespace check performed by this resolver. A short hex prefix (8+ hex \
92 chars) is resolved by searching only the caller's primary namespace, and is \
93 rejected if it matches nothing or matches more than one record there.",
94 ),
95 IdResolutionMode::FullAndPrefixScopedToPrimary => Some(
96 "ID contract (full UUID and prefix both scoped to primary namespace): both a full \
97 UUID and a short hex prefix (8+ hex chars) are validated against the caller's \
98 primary namespace — a record that exists but belongs to a different namespace \
99 resolves as not found. A prefix matching more than one record in that namespace \
100 is rejected as ambiguous.",
101 ),
102 IdResolutionMode::FullUuidOnlyScopedToPrimary => Some(
103 "ID contract (full UUID only, scoped to primary namespace): only a complete UUID \
104 is accepted — a short hex prefix is rejected outright because this field stores \
105 an explicit stable reference — and the UUID is validated against the caller's own \
106 (primary) namespace; a record that exists in a different namespace resolves as \
107 not found.",
108 ),
109 IdResolutionMode::UnscopedFullUuidOnly => Some(
110 "ID contract (full UUID only, unscoped): only a complete UUID is accepted — a \
111 short hex prefix is rejected outright — and no namespace check is performed on \
112 this parameter itself; any namespace scoping comes from the enclosing operation, \
113 not from this identifier.",
114 ),
115 IdResolutionMode::EdgeOrEventTarget => Some(
116 "ID contract (list target by kind): kind=event accepts only a full subject UUID; \
117 prefixes and names are rejected without graph resolution. Event rows remain \
118 scoped to the authorized event namespace. For kind=edge, a full UUID resolves as \
119 given; a unique 8+ hex prefix or entity name resolves in the primary namespace.",
120 ),
121 }
122}
123
124fn resolution_mode_key(mode: IdResolutionMode) -> &'static str {
127 match mode {
128 IdResolutionMode::NotApplicable => "not_applicable",
129 IdResolutionMode::UnscopedById => "unscoped_by_id",
130 IdResolutionMode::PrefixScopedToPrimary => "prefix_scoped_to_primary",
131 IdResolutionMode::FullAndPrefixScopedToPrimary => "full_and_prefix_scoped_to_primary",
132 IdResolutionMode::FullUuidOnlyScopedToPrimary => "full_uuid_only_scoped_to_primary",
133 IdResolutionMode::UnscopedFullUuidOnly => "unscoped_full_uuid_only",
134 IdResolutionMode::EdgeOrEventTarget => "edge_or_event_target",
135 }
136}
137
138fn identifier_resolution_help() -> Value {
139 let modes: serde_json::Map<String, Value> = [
140 IdResolutionMode::UnscopedById,
141 IdResolutionMode::PrefixScopedToPrimary,
142 IdResolutionMode::FullAndPrefixScopedToPrimary,
143 IdResolutionMode::FullUuidOnlyScopedToPrimary,
144 IdResolutionMode::UnscopedFullUuidOnly,
145 IdResolutionMode::EdgeOrEventTarget,
146 ]
147 .into_iter()
148 .map(|mode| {
149 (
150 resolution_mode_key(mode).to_string(),
151 Value::String(
152 resolution_mode_contract(mode)
153 .expect("every non-NotApplicable mode has contract text")
154 .to_string(),
155 ),
156 )
157 })
158 .collect();
159
160 serde_json::json!({
161 "full_uuid": FULL_UUID_IDENTIFIER_HELP,
162 "short_prefix": SHORT_PREFIX_IDENTIFIER_HELP,
163 "parameter_rule": IDENTIFIER_PARAMETER_HELP,
164 "resolution_modes": modes,
165 })
166}
167
168#[derive(Debug, Default, Clone)]
179pub struct SchemaPlan {
180 pub pack: &'static str,
182 pub statements: &'static [&'static str],
186}
187
188impl SchemaPlan {
189 pub const fn empty() -> Self {
194 Self {
195 pack: "",
196 statements: &[],
197 }
198 }
199
200 pub fn is_empty(&self) -> bool {
202 self.statements.is_empty()
203 }
204}
205
206#[async_trait]
213pub trait DispatchHook: Send + Sync {
214 async fn on_dispatch(&self, view: &EventView);
219}
220
221use crate::error::{
222 AuditObligationFailure, CircularPackDependency, DispatchError, MissingPackDependencies,
223 MissingPackDependency, RuntimeError,
224};
225use crate::KhiveRuntime;
226
227#[async_trait]
236pub trait PackRuntime: Send + Sync {
237 fn name(&self) -> &str;
239
240 fn host_state(&self) -> Option<Arc<dyn Any + Send + Sync>> {
244 None
245 }
246
247 fn validate_config(&self) -> Result<(), RuntimeError> {
250 Ok(())
251 }
252
253 fn note_kinds(&self) -> &'static [&'static str];
255
256 fn entity_kinds(&self) -> &'static [&'static str];
258
259 fn brain_consumer_kinds(&self) -> &'static [&'static str] {
262 &[]
263 }
264
265 async fn apply_profile_section_feedback(
268 &self,
269 _token: &NamespaceToken,
270 _profile_id: &str,
271 _section_signals: Value,
272 _target_attribution: Option<String>,
273 ) -> Result<Value, RuntimeError> {
274 Err(RuntimeError::InvalidInput(format!(
275 "pack {:?} does not support profile section feedback",
276 self.name()
277 )))
278 }
279
280 fn handlers(&self) -> &'static [HandlerDef];
282
283 fn input_schema(&self, _verb: &str) -> Option<Value> {
285 None
286 }
287
288 fn edge_rules(&self) -> &'static [EdgeEndpointRule] {
292 &[]
293 }
294
295 fn entity_types(&self) -> &'static [EntityTypeDef] {
299 &[]
300 }
301
302 fn requires(&self) -> &'static [&'static str] {
305 &[]
306 }
307
308 fn note_kind_specs(&self) -> &'static [NoteKindSpec] {
315 &[]
316 }
317
318 fn kind_hook(&self, _kind: &str) -> Option<Arc<dyn KindHook>> {
326 None
327 }
328
329 fn accept_channel_ingest_capability(&self, _capability: ChannelIngestCapability) {}
342
343 fn schema_plan(&self) -> SchemaPlan {
359 SchemaPlan::empty()
360 }
361
362 fn schema_column_additions(&self) -> &'static [PackColumnAddition] {
369 &[]
370 }
371
372 fn validation_rules(&self) -> &'static [ValidationRule] {
380 &[]
381 }
382
383 fn register_embedders(&self, _runtime: &KhiveRuntime) {}
389
390 fn register_entity_type_validator(&self, _runtime: &KhiveRuntime) {}
396
397 fn register_entity_type_validator_with_types(
404 &self,
405 runtime: &KhiveRuntime,
406 _pack_entity_types: &[EntityTypeDef],
407 ) {
408 self.register_entity_type_validator(runtime);
409 }
410
411 fn register_note_mutation_hook(&self, _runtime: &KhiveRuntime) {}
418
419 fn register_note_write_validator(&self, _runtime: &KhiveRuntime) {}
430
431 async fn warm(&self) {}
435
436 fn registered_embedding_model_names(&self) -> Vec<String> {
441 Vec::new()
442 }
443
444 fn mounted_namespace(&self) -> Option<&str> {
445 None
446 }
447
448 fn mounted_catalog_snapshot(&self) -> Vec<crate::mounted_verb::MountedVerb> {
450 Vec::new()
451 }
452
453 async fn mounted_catalog(&self) -> Result<Vec<crate::mounted_verb::MountedVerb>, RuntimeError> {
454 Ok(Vec::new())
455 }
456
457 async fn dispatch_mounted(
458 &self,
459 _definition: &crate::mounted_verb::MountedVerb,
460 verb: &str,
461 params: Value,
462 registry: &VerbRegistry,
463 token: &NamespaceToken,
464 ) -> Result<Value, RuntimeError> {
465 self.dispatch(verb, params, registry, token).await
466 }
467
468 async fn dispatch(
475 &self,
476 verb: &str,
477 params: Value,
478 registry: &VerbRegistry,
479 token: &NamespaceToken,
480 ) -> Result<Value, RuntimeError>;
481}
482
483#[async_trait]
597pub trait KindHook: Send + Sync + std::fmt::Debug {
598 async fn prepare_create(
604 &self,
605 runtime: &KhiveRuntime,
606 args: &mut Value,
607 ) -> Result<(), RuntimeError>;
608
609 async fn after_create(
618 &self,
619 runtime: &KhiveRuntime,
620 id: uuid::Uuid,
621 args: &Value,
622 ) -> Result<(), RuntimeError>;
623
624 fn validate_proposal_entity(
631 &self,
632 _entity: &khive_types::EntityDraft,
633 ) -> Result<(), RuntimeError> {
634 Ok(())
635 }
636
637 fn validate_proposal_note(&self, _note: &khive_types::NoteDraft) -> Result<(), RuntimeError> {
647 Ok(())
648 }
649
650 async fn normalize_note_update(
659 &self,
660 _runtime: &KhiveRuntime,
661 _token: &NamespaceToken,
662 _note: &khive_storage::Note,
663 _args: &mut Value,
664 ) -> Result<(), RuntimeError> {
665 Ok(())
666 }
667
668 async fn validate_note_update(
676 &self,
677 _runtime: &KhiveRuntime,
678 _token: &NamespaceToken,
679 _note: &khive_storage::Note,
680 _properties: Option<&Value>,
681 ) -> Result<(), RuntimeError> {
682 Ok(())
683 }
684
685 async fn note_update_effects(
693 &self,
694 _runtime: &KhiveRuntime,
695 _token: &NamespaceToken,
696 _note: &khive_storage::Note,
697 _patch: &crate::curation::NotePatch,
698 ) -> Result<Vec<NoteUpdateEffect>, RuntimeError> {
699 Ok(Vec::new())
700 }
701
702 fn note_update_null_clearing_properties(&self) -> &'static [&'static str] {
707 &[]
708 }
709
710 async fn validate_entity_update(
729 &self,
730 _runtime: &KhiveRuntime,
731 _token: &NamespaceToken,
732 _entity: &khive_storage::Entity,
733 _properties: Option<&Value>,
734 ) -> Result<(), RuntimeError> {
735 Ok(())
736 }
737
738 async fn validate_links(
743 &self,
744 _runtime: &KhiveRuntime,
745 _token: &NamespaceToken,
746 _links: &[crate::LinkSpec],
747 ) -> Result<(), RuntimeError> {
748 Ok(())
749 }
750}
751
752#[derive(Clone, Debug)]
755pub enum NoteUpdateEffect {
756 Link(LinkSpec),
758 DeleteEdge(khive_storage::types::Edge),
760 AssertLink(khive_storage::types::Edge),
763}
764
765#[async_trait]
773pub trait PackByIdResolver: Send + Sync {
774 async fn resolve_by_id(
784 &self,
785 id: uuid::Uuid,
786 ) -> Result<Option<crate::Resolved>, crate::RuntimeError>;
787
788 async fn resolve_by_id_including_deleted(
793 &self,
794 id: uuid::Uuid,
795 ) -> Result<Option<crate::Resolved>, crate::RuntimeError> {
796 self.resolve_by_id(id).await
797 }
798
799 async fn delete_by_id(
808 &self,
809 id: uuid::Uuid,
810 hard: bool,
811 ) -> Result<serde_json::Value, crate::RuntimeError>;
812
813 fn private_record_verbs(&self) -> &'static [&'static str] {
819 &[]
820 }
821}
822
823pub struct VerbRegistryBuilder {
828 packs: Vec<Box<dyn PackRuntime>>,
829 pack_trusted: Vec<bool>,
840 resolvers: Vec<(String, Box<dyn PackByIdResolver>)>,
841 kg_read_resolver: Option<Arc<crate::kg_read::KgReadResolver>>,
842 gate: GateRef,
843 default_namespace: String,
844 visible_namespaces: Vec<Namespace>,
852 actor_id: Option<String>,
855 event_store: Option<Arc<dyn EventStore>>,
862 runtime_event_store: Option<KhiveRuntime>,
864 audit_store_read_only: bool,
867 dispatch_hook: Option<Arc<dyn DispatchHook>>,
873 audit_batch_config: Option<crate::audit_batch::AuditBatchConfig>,
877}
878
879impl VerbRegistryBuilder {
880 pub fn new() -> Self {
882 Self {
883 packs: Vec::new(),
884 pack_trusted: Vec::new(),
885 resolvers: Vec::new(),
886 kg_read_resolver: None,
887 gate: std::sync::Arc::new(AllowAllGate),
888 default_namespace: Namespace::local().as_str().to_string(),
889 visible_namespaces: vec![],
890 actor_id: None,
891 event_store: None,
892 runtime_event_store: None,
893 audit_store_read_only: false,
894 dispatch_hook: None,
895 audit_batch_config: None,
896 }
897 }
898
899 pub fn with_visible_namespaces(&mut self, ns: Vec<Namespace>) -> &mut Self {
907 self.visible_namespaces = ns;
908 self
909 }
910
911 pub fn with_actor_id(&mut self, actor_id: Option<String>) -> &mut Self {
918 self.actor_id = actor_id;
919 self
920 }
921
922 pub fn register<P: khive_types::Pack + PackRuntime + 'static>(&mut self, pack: P) -> &mut Self {
932 self.packs.push(Box::new(pack));
933 self.pack_trusted.push(false);
934 self
935 }
936
937 pub(crate) fn register_boxed(&mut self, pack: Box<dyn PackRuntime>) -> &mut Self {
950 self.packs.push(pack);
951 self.pack_trusted.push(true);
952 self
953 }
954
955 pub fn register_mounted(
957 &mut self,
958 pack: Box<dyn PackRuntime>,
959 ) -> Result<&mut Self, RuntimeError> {
960 if pack.mounted_namespace() != Some(pack.name()) || !pack.handlers().is_empty() {
961 return Err(RuntimeError::InvalidInput(
962 "invalid mounted namespace registration".into(),
963 ));
964 }
965 self.packs.push(pack);
966 self.pack_trusted.push(false);
967 Ok(self)
968 }
969
970 #[cfg(any(test, feature = "test-internals"))]
979 pub fn register_trusted<P: khive_types::Pack + PackRuntime + 'static>(
980 &mut self,
981 pack: P,
982 ) -> &mut Self {
983 self.packs.push(Box::new(pack));
984 self.pack_trusted.push(true);
985 self
986 }
987
988 pub fn register_resolver(
993 &mut self,
994 name: impl Into<String>,
995 resolver: Box<dyn PackByIdResolver>,
996 ) -> &mut Self {
997 self.resolvers.push((name.into(), resolver));
998 self
999 }
1000
1001 pub fn with_gate(&mut self, gate: GateRef) -> &mut Self {
1007 self.gate = gate;
1008 self
1009 }
1010
1011 pub fn with_default_namespace(&mut self, ns: impl Into<String>) -> &mut Self {
1016 self.default_namespace = ns.into();
1017 self
1018 }
1019
1020 pub fn with_event_store(&mut self, store: Arc<dyn EventStore>) -> &mut Self {
1030 self.event_store = Some(store);
1031 self.runtime_event_store = None;
1032 self.audit_store_read_only = false;
1033 self
1034 }
1035
1036 pub fn with_runtime_event_store(
1051 &mut self,
1052 runtime: &KhiveRuntime,
1053 ) -> Result<&mut Self, RuntimeError> {
1054 self.event_store = None;
1055 self.runtime_event_store = Some(runtime.clone());
1056 self.audit_store_read_only = false;
1057 Ok(self)
1058 }
1059
1060 pub fn with_audit_batch_config(
1067 &mut self,
1068 config: crate::audit_batch::AuditBatchConfig,
1069 ) -> &mut Self {
1070 self.audit_batch_config = Some(config);
1071 self
1072 }
1073
1074 pub fn with_read_only_audit_store(&mut self) -> &mut Self {
1080 self.event_store = None;
1081 self.runtime_event_store = None;
1082 self.audit_store_read_only = true;
1083 self
1084 }
1085
1086 pub fn with_dispatch_hook(&mut self, hook: Arc<dyn DispatchHook>) -> &mut Self {
1097 self.dispatch_hook = Some(hook);
1098 self
1099 }
1100
1101 pub fn build(self) -> Result<VerbRegistry, RuntimeError> {
1107 self.build_registry(true)
1108 }
1109
1110 pub fn build_metadata(mut self) -> Result<PackMetadataRegistry, RuntimeError> {
1113 self.event_store = None;
1114 self.runtime_event_store = None;
1115 self.dispatch_hook = None;
1116 self.resolvers.clear();
1117 self.build_registry(false)
1118 .map(|registry| PackMetadataRegistry { registry })
1119 }
1120
1121 fn build_registry(self, activate: bool) -> Result<VerbRegistry, RuntimeError> {
1122 let packs = self.packs;
1123 let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(packs.len());
1124 for (idx, pack) in packs.iter().enumerate() {
1125 if let Some(prev_idx) = name_to_idx.insert(pack.name(), idx) {
1126 return Err(RuntimeError::PackRedeclared {
1127 name: pack.name().to_string(),
1128 first_idx: prev_idx,
1129 second_idx: idx,
1130 });
1131 }
1132 }
1133
1134 for mounted in packs
1135 .iter()
1136 .filter(|pack| pack.mounted_namespace().is_some())
1137 {
1138 let prefix = format!("{}.", mounted.name());
1139 if packs
1140 .iter()
1141 .flat_map(|pack| pack.handlers())
1142 .any(|handler| handler.name.starts_with(&prefix))
1143 {
1144 return Err(RuntimeError::InvalidInput(
1145 "mounted namespace collides with a native verb".into(),
1146 ));
1147 }
1148 }
1149
1150 for pack in &packs {
1154 for handler in pack.handlers() {
1155 for parameter in handler.params {
1156 if RESERVED_ENVELOPE_ARGS.contains(¶meter.name) {
1157 return Err(RuntimeError::ReservedEnvelopeParam {
1158 pack: pack.name().to_string(),
1159 verb: handler.name.to_string(),
1160 param: parameter.name.to_string(),
1161 });
1162 }
1163 }
1164 }
1165 }
1166
1167 let mut missing: Vec<MissingPackDependency> = Vec::new();
1168 let mut indegree = vec![0usize; packs.len()];
1169 let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); packs.len()];
1170
1171 for (idx, pack) in packs.iter().enumerate() {
1172 for &requires in pack.requires() {
1173 match name_to_idx.get(requires).copied() {
1174 Some(dep_idx) => {
1175 dependents[dep_idx].push(idx);
1176 indegree[idx] += 1;
1177 }
1178 None => missing.push(MissingPackDependency {
1179 from: pack.name().to_string(),
1180 requires: requires.to_string(),
1181 }),
1182 }
1183 }
1184 }
1185
1186 if !missing.is_empty() {
1187 return if missing.len() == 1 {
1188 Err(RuntimeError::MissingPackDependency(missing.remove(0)))
1189 } else {
1190 Err(RuntimeError::MissingPackDependencies(
1191 MissingPackDependencies { missing },
1192 ))
1193 };
1194 }
1195
1196 let mut ready: VecDeque<usize> = indegree
1197 .iter()
1198 .enumerate()
1199 .filter_map(|(idx, degree)| (*degree == 0).then_some(idx))
1200 .collect();
1201 let mut ordered_indices = Vec::with_capacity(packs.len());
1202
1203 while let Some(idx) = ready.pop_front() {
1204 ordered_indices.push(idx);
1205 for &dep_idx in &dependents[idx] {
1206 indegree[dep_idx] -= 1;
1207 if indegree[dep_idx] == 0 {
1208 ready.push_back(dep_idx);
1209 }
1210 }
1211 }
1212
1213 if ordered_indices.len() != packs.len() {
1214 let cycle_nodes: HashSet<usize> = indegree
1215 .iter()
1216 .enumerate()
1217 .filter_map(|(idx, degree)| (*degree > 0).then_some(idx))
1218 .collect();
1219 let cycle = find_pack_dependency_cycle(&packs, &name_to_idx, &cycle_nodes);
1220 return Err(RuntimeError::CircularPackDependency(
1221 CircularPackDependency { cycle },
1222 ));
1223 }
1224
1225 let mut pack_slots: Vec<Option<Box<dyn PackRuntime>>> =
1226 packs.into_iter().map(Some).collect();
1227 let mut trusted_slots: Vec<Option<bool>> =
1228 self.pack_trusted.into_iter().map(Some).collect();
1229 let mut ordered_packs: Vec<Box<dyn PackRuntime>> = Vec::with_capacity(pack_slots.len());
1230 let mut ordered_trusted: Vec<bool> = Vec::with_capacity(trusted_slots.len());
1231 for idx in ordered_indices {
1232 ordered_packs.push(
1233 pack_slots[idx]
1234 .take()
1235 .expect("topological index must exist"),
1236 );
1237 ordered_trusted.push(
1238 trusted_slots[idx]
1239 .take()
1240 .expect("topological index must exist"),
1241 );
1242 }
1243
1244 validate_unique_note_kinds(&ordered_packs)?;
1245 validate_unique_verb_names(&ordered_packs)?;
1246 validate_unique_entity_types(&ordered_packs)?;
1247 validate_brain_consumer_kinds(&ordered_packs)?;
1248 if activate {
1249 for pack in &ordered_packs {
1250 pack.validate_config()?;
1251 }
1252 }
1253
1254 let available_verbs: Vec<&'static str> = ordered_packs
1255 .iter()
1256 .flat_map(|p| p.handlers().iter())
1257 .filter(|h| matches!(h.visibility, Visibility::Verb))
1258 .map(|h| h.name)
1259 .collect();
1260
1261 let mut degrade_safe_verbs: HashSet<&'static str> = HashSet::new();
1273 let mut read_replay_safe_verbs = HashSet::new();
1274 for (pack, &trusted) in ordered_packs.iter().zip(ordered_trusted.iter()) {
1275 if !trusted {
1276 continue;
1277 }
1278 let pack_name = pack.name();
1279 for handler in pack.handlers() {
1280 let canonical_owner = handler
1281 .name
1282 .split_once('.')
1283 .map_or("kg", |(owner, _)| owner);
1284 if matches!(handler.visibility, Visibility::Verb)
1285 && pack_name == canonical_owner
1286 && crate::classify_operation(handler.name) == Some(crate::OperationAccess::Read)
1287 && !VerbRegistry::SIDE_EFFECTING_ASSERTIVE_VERBS.contains(&handler.name)
1288 {
1289 read_replay_safe_verbs.insert(handler.name);
1290 }
1291 if !matches!(handler.visibility, Visibility::Verb)
1292 || handler.category != VerbCategory::Assertive
1293 {
1294 continue;
1295 }
1296 let eligible = VerbRegistry::admission_degrade_safe_sorted()
1297 .binary_search_by(|&(p, v)| p.cmp(pack_name).then_with(|| v.cmp(handler.name)))
1298 .is_ok();
1299 if eligible {
1300 degrade_safe_verbs.insert(handler.name);
1301 }
1302 }
1303 }
1304
1305 let event_store = match self.runtime_event_store {
1317 Some(runtime) => Some(runtime.raw_events_for_namespace(&self.default_namespace)?),
1318 None => self.event_store,
1319 };
1320 if let Some(store) = &event_store {
1321 if !store.supports_idempotent_audit_batch() {
1322 return Err(RuntimeError::IncompatibleEventStore(
1323 "the configured EventStore does not implement ADR-133's \
1324 preflight_event/append_events_idempotent pair \
1325 (supports_idempotent_audit_batch() returned false); every \
1326 audited dispatch would silently lose its audit row while \
1327 still reporting success. Implement both methods and \
1328 override supports_idempotent_audit_batch() to opt in, or \
1329 do not call with_event_store() for this backend."
1330 .to_string(),
1331 ));
1332 }
1333 }
1334 let audit_batch = event_store.clone().map(|store| {
1335 crate::audit_batch::AuditBatch::new(
1336 store,
1337 self.audit_batch_config.clone().unwrap_or_default(),
1338 )
1339 });
1340
1341 Ok(VerbRegistry {
1342 packs: Arc::new(ordered_packs),
1343 resolvers: Arc::new(self.resolvers),
1344 kg_read_resolver: self.kg_read_resolver,
1345 gate: self.gate,
1346 default_namespace: self.default_namespace,
1347 visible_namespaces: self.visible_namespaces,
1348 actor_id: self.actor_id,
1349 event_store,
1350 audit_store_read_only: self.audit_store_read_only,
1351 dispatch_hook: self.dispatch_hook,
1352 available_verbs: Arc::new(available_verbs),
1353 degrade_safe_verbs: Arc::new(degrade_safe_verbs),
1354 read_replay_safe_verbs: Arc::new(read_replay_safe_verbs),
1355 reference_ring: Arc::new(crate::reference_ring::ReferenceRing::new()),
1356 audit_batch,
1357 })
1358 }
1359}
1360
1361fn validate_unique_note_kinds(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
1367 let mut seen: HashMap<&str, &str> = HashMap::new();
1368 for pack in packs {
1369 for &kind in pack.note_kinds() {
1370 if let Some(first_pack) = seen.insert(kind, pack.name()) {
1371 return Err(RuntimeError::InvalidInput(format!(
1372 "duplicate note kind {kind:?}: claimed by both {first_pack:?} and {:?}",
1373 pack.name()
1374 )));
1375 }
1376 }
1377 }
1378 Ok(())
1379}
1380
1381fn validate_brain_consumer_kinds(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
1388 for pack in packs {
1389 for &kind in pack.brain_consumer_kinds() {
1390 if kind == "*" || kind.trim().is_empty() || kind.trim() != kind {
1391 return Err(RuntimeError::InvalidInput(format!(
1392 "pack {:?} declares invalid brain consumer kind {kind:?}; declarations must be non-empty exact wire values and must not use the registry-owned \"*\" wildcard",
1393 pack.name()
1394 )));
1395 }
1396 }
1397 }
1398 Ok(())
1399}
1400
1401fn validate_unique_verb_names(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
1408 let mut seen: HashMap<&str, &str> = HashMap::new();
1409 for pack in packs {
1410 for handler in pack.handlers() {
1411 if !matches!(handler.visibility, Visibility::Verb) {
1412 continue;
1413 }
1414 if let Some(first_pack) = seen.insert(handler.name, pack.name()) {
1415 return Err(RuntimeError::VerbCollision {
1416 verb: handler.name.to_string(),
1417 first_pack: first_pack.to_string(),
1418 second_pack: pack.name().to_string(),
1419 });
1420 }
1421 }
1422 }
1423 Ok(())
1424}
1425
1426fn validate_unique_entity_types(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
1436 let owned_defs = packs
1437 .iter()
1438 .flat_map(|p| p.entity_types().iter().map(move |def| (p.name(), def)));
1439 khive_types::EntityTypeRegistry::check_extra_collisions(owned_defs)
1440 .map_err(RuntimeError::InvalidInput)
1441}
1442
1443fn find_pack_dependency_cycle(
1444 packs: &[Box<dyn PackRuntime>],
1445 name_to_idx: &HashMap<&str, usize>,
1446 cycle_nodes: &HashSet<usize>,
1447) -> Vec<String> {
1448 fn visit(
1449 idx: usize,
1450 packs: &[Box<dyn PackRuntime>],
1451 name_to_idx: &HashMap<&str, usize>,
1452 cycle_nodes: &HashSet<usize>,
1453 visiting: &mut Vec<usize>,
1454 visited: &mut HashSet<usize>,
1455 ) -> Option<Vec<String>> {
1456 if let Some(pos) = visiting.iter().position(|&seen| seen == idx) {
1457 let mut cycle: Vec<String> = visiting[pos..]
1458 .iter()
1459 .map(|&i| packs[i].name().to_string())
1460 .collect();
1461 cycle.push(packs[idx].name().to_string());
1462 return Some(cycle);
1463 }
1464 if !visited.insert(idx) {
1465 return None;
1466 }
1467 visiting.push(idx);
1468 for &req in packs[idx].requires() {
1469 let Some(&dep_idx) = name_to_idx.get(req) else {
1470 continue;
1471 };
1472 if cycle_nodes.contains(&dep_idx) {
1473 if let Some(cycle) =
1474 visit(dep_idx, packs, name_to_idx, cycle_nodes, visiting, visited)
1475 {
1476 return Some(cycle);
1477 }
1478 }
1479 }
1480 visiting.pop();
1481 None
1482 }
1483
1484 let mut visited = HashSet::new();
1485 for &idx in cycle_nodes {
1486 let mut visiting = Vec::new();
1487 if let Some(cycle) = visit(
1488 idx,
1489 packs,
1490 name_to_idx,
1491 cycle_nodes,
1492 &mut visiting,
1493 &mut visited,
1494 ) {
1495 return cycle;
1496 }
1497 }
1498 cycle_nodes
1499 .iter()
1500 .map(|&idx| packs[idx].name().to_string())
1501 .collect()
1502}
1503
1504impl Default for VerbRegistryBuilder {
1505 fn default() -> Self {
1506 Self::new()
1507 }
1508}
1509
1510pub struct PackMetadataRegistry {
1518 registry: VerbRegistry,
1519}
1520
1521impl PackMetadataRegistry {
1522 pub fn has_verb(&self, verb: &str) -> bool {
1523 self.registry.has_verb(verb)
1524 }
1525
1526 pub fn describe_verb(&self, verb: &str) -> Result<Value, RuntimeError> {
1527 self.registry.describe_verb(verb)
1528 }
1529
1530 pub fn all_handlers_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
1531 self.registry.all_handlers_with_names()
1532 }
1533
1534 pub fn all_verbs(&self) -> Vec<&'static HandlerDef> {
1535 self.registry.all_verbs()
1536 }
1537
1538 pub fn pack_names(&self) -> Vec<&str> {
1539 self.registry.pack_names()
1540 }
1541
1542 pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
1543 self.registry.pack_requires(name)
1544 }
1545
1546 pub fn pack_note_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
1547 self.registry.pack_note_kinds(name)
1548 }
1549
1550 pub fn pack_entity_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
1551 self.registry.pack_entity_kinds(name)
1552 }
1553
1554 pub fn pack_verbs(&self, name: &str) -> Option<&'static [HandlerDef]> {
1555 self.registry.pack_verbs(name)
1556 }
1557
1558 pub fn all_entity_kinds(&self) -> Vec<&'static str> {
1559 self.registry.all_entity_kinds()
1560 }
1561
1562 pub fn all_note_kinds(&self) -> Vec<&'static str> {
1563 self.registry.all_note_kinds()
1564 }
1565
1566 pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
1567 self.registry.all_edge_rules()
1568 }
1569}
1570
1571#[derive(Clone)]
1575pub struct VerbRegistry {
1576 packs: std::sync::Arc<Vec<Box<dyn PackRuntime>>>,
1577 resolvers: std::sync::Arc<Vec<(String, Box<dyn PackByIdResolver>)>>,
1579 kg_read_resolver: Option<Arc<crate::kg_read::KgReadResolver>>,
1581 gate: GateRef,
1582 default_namespace: String,
1583 visible_namespaces: Vec<Namespace>,
1590 actor_id: Option<String>,
1594 event_store: Option<Arc<dyn EventStore>>,
1596 audit_store_read_only: bool,
1599 dispatch_hook: Option<Arc<dyn DispatchHook>>,
1601 available_verbs: Arc<Vec<&'static str>>,
1606 degrade_safe_verbs: Arc<HashSet<&'static str>>,
1612 read_replay_safe_verbs: Arc<HashSet<&'static str>>,
1614 reference_ring: Arc<crate::reference_ring::ReferenceRing>,
1619 audit_batch: Option<Arc<crate::audit_batch::AuditBatch>>,
1624}
1625
1626#[derive(Debug, Clone, PartialEq)]
1633pub struct InterceptedDispatchResult<M> {
1634 pub result: Value,
1636 pub metadata: M,
1638}
1639
1640impl<M> InterceptedDispatchResult<M> {
1641 pub fn new(result: Value, metadata: M) -> Self {
1643 Self { result, metadata }
1644 }
1645}
1646
1647#[derive(Debug, Clone, Default)]
1662pub struct RequestIdentity {
1663 pub namespace: String,
1667 pub actor_id: Option<String>,
1671 pub visible_namespaces: Vec<String>,
1677 pub process_ref: Option<String>,
1682 pub request_id: Option<u64>,
1692}
1693
1694impl RequestIdentity {
1695 pub fn from_token(token: &NamespaceToken) -> Self {
1709 Self {
1710 namespace: token.namespace().as_str().to_string(),
1711 actor_id: token.actor().binding_id().map(str::to_string),
1712 visible_namespaces: token
1713 .visible_namespaces()
1714 .iter()
1715 .map(|namespace| namespace.as_str().to_string())
1716 .collect(),
1717 process_ref: token.process_ref().map(str::to_owned),
1718 request_id: None,
1719 }
1720 }
1721}
1722
1723#[derive(Debug, Clone, PartialEq, Eq)]
1732pub struct VerifiedActor(String);
1733
1734impl VerifiedActor {
1735 pub fn new(id: impl Into<String>) -> Result<Self, RuntimeError> {
1740 let id = id.into();
1741 if id.trim().is_empty() {
1742 return Err(RuntimeError::InvalidInput(
1743 "VerifiedActor: identifier must not be empty or whitespace-only".to_string(),
1744 ));
1745 }
1746 Ok(Self(id))
1747 }
1748
1749 pub fn as_str(&self) -> &str {
1751 &self.0
1752 }
1753
1754 fn into_inner(self) -> String {
1755 self.0
1756 }
1757}
1758
1759#[derive(Debug)]
1762pub struct PackSchemaCollisionError {
1763 pub pack_a: &'static str,
1765 pub pack_b: &'static str,
1767 pub table: String,
1769}
1770
1771impl std::fmt::Display for PackSchemaCollisionError {
1772 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1773 if self.pack_a == self.pack_b {
1774 write!(
1775 f,
1776 "pack schema boot failure for pack {:?}: {}",
1777 self.pack_a, self.table
1778 )
1779 } else {
1780 write!(
1781 f,
1782 "pack schema collision: packs {:?} and {:?} both declare table {:?} \
1783 on the same backend — move one pack to a separate backend or rename the table",
1784 self.pack_a, self.pack_b, self.table
1785 )
1786 }
1787 }
1788}
1789
1790impl std::error::Error for PackSchemaCollisionError {}
1791
1792fn extract_table_names(stmt: &str) -> Vec<String> {
1797 enum SqlToken {
1798 Bare(String),
1799 Quoted(String),
1800 Punctuation(char),
1801 }
1802
1803 let mut tokens = Vec::new();
1804 let mut chars = stmt.chars().peekable();
1805 while let Some(ch) = chars.next() {
1806 if ch.is_whitespace() {
1807 continue;
1808 }
1809 if ch == '-' && chars.peek() == Some(&'-') {
1810 chars.next();
1811 for next in chars.by_ref() {
1812 if next == '\n' {
1813 break;
1814 }
1815 }
1816 continue;
1817 }
1818 if ch == '/' && chars.peek() == Some(&'*') {
1819 chars.next();
1820 let mut previous = '\0';
1821 for next in chars.by_ref() {
1822 if previous == '*' && next == '/' {
1823 break;
1824 }
1825 previous = next;
1826 }
1827 continue;
1828 }
1829 if matches!(ch, '"' | '`' | '[') {
1830 let closing = if ch == '[' { ']' } else { ch };
1831 let mut token = String::new();
1832 while let Some(next) = chars.next() {
1833 if next == closing {
1834 if chars.peek() == Some(&closing) {
1835 chars.next();
1836 token.push(closing);
1837 } else {
1838 break;
1839 }
1840 } else {
1841 token.push(next);
1842 }
1843 }
1844 tokens.push(SqlToken::Quoted(token));
1845 continue;
1846 }
1847 if matches!(ch, '.' | '(' | ';') {
1848 tokens.push(SqlToken::Punctuation(ch));
1849 continue;
1850 }
1851 let mut token = ch.to_string();
1852 while let Some(next) = chars.peek().copied() {
1853 let begins_comment = (next == '-' && chars.clone().nth(1) == Some('-'))
1854 || (next == '/' && chars.clone().nth(1) == Some('*'));
1855 if next.is_whitespace()
1856 || matches!(next, '.' | '(' | ';' | '"' | '`' | '[')
1857 || begins_comment
1858 {
1859 break;
1860 }
1861 token.push(next);
1862 chars.next();
1863 }
1864 tokens.push(SqlToken::Bare(token));
1865 }
1866
1867 let keyword = |index: usize, word: &str| matches!(tokens.get(index), Some(SqlToken::Bare(token)) if token.eq_ignore_ascii_case(word));
1868 if !keyword(0, "CREATE") {
1869 return Vec::new();
1870 }
1871 let mut index = 1;
1872 if keyword(index, "TEMP") || keyword(index, "TEMPORARY") {
1873 index += 1;
1874 }
1875 if keyword(index, "VIRTUAL") {
1876 index += 1;
1877 }
1878 if !keyword(index, "TABLE") {
1879 return Vec::new();
1880 }
1881 index += 1;
1882 if keyword(index, "IF") && keyword(index + 1, "NOT") && keyword(index + 2, "EXISTS") {
1883 index += 3;
1884 }
1885 let main_qualifier = matches!(
1886 tokens.get(index),
1887 Some(SqlToken::Bare(name) | SqlToken::Quoted(name)) if name.eq_ignore_ascii_case("main")
1888 );
1889 if main_qualifier && matches!(tokens.get(index + 1), Some(SqlToken::Punctuation('.'))) {
1890 index += 2;
1891 }
1892 match tokens.get(index) {
1893 Some(SqlToken::Bare(name) | SqlToken::Quoted(name)) if !name.is_empty() => {
1894 vec![name.to_ascii_lowercase()]
1895 }
1896 _ => Vec::new(),
1897 }
1898}
1899
1900fn endpoint_kind_label(kind: &EndpointKind) -> String {
1903 match kind {
1904 EndpointKind::EntityOfKind(k) => format!("entity:{k}"),
1905 EndpointKind::NoteOfKind(k) => format!("note:{k}"),
1906 EndpointKind::EntityOfType { kind, entity_type } => {
1907 format!("entity:{kind}({entity_type})")
1908 }
1909 }
1910}
1911
1912pub(crate) const SPECIAL_RELATIONS: &[khive_types::EdgeRelation] = &[
1920 khive_types::EdgeRelation::Supersedes,
1921 khive_types::EdgeRelation::Supports,
1922 khive_types::EdgeRelation::Refutes,
1923];
1924
1925pub(crate) fn is_special_relation(relation: khive_types::EdgeRelation) -> bool {
1926 SPECIAL_RELATIONS.contains(&relation)
1927}
1928
1929fn edge_endpoint_table(packs: &[Box<dyn PackRuntime>]) -> Vec<Value> {
1947 let mut rows: Vec<Value> = crate::operations::base_entity_endpoint_rules()
1948 .iter()
1949 .map(|(src, rel, tgt)| {
1950 serde_json::json!({
1951 "relation": rel.as_str(),
1952 "source": format!("entity:{src}"),
1953 "target": format!("entity:{tgt}"),
1954 })
1955 })
1956 .collect();
1957
1958 for rel in SPECIAL_RELATIONS {
1959 rows.push(serde_json::json!({
1960 "relation": rel.as_str(),
1961 "source": "note:*",
1962 "target": "note:*",
1963 }));
1964 }
1965
1966 for pack in packs.iter() {
1967 for rule in pack.edge_rules().iter() {
1968 if is_special_relation(rule.relation) {
1969 continue;
1970 }
1971 rows.push(serde_json::json!({
1972 "relation": rule.relation.as_str(),
1973 "source": endpoint_kind_label(&rule.source),
1974 "target": endpoint_kind_label(&rule.target),
1975 }));
1976 }
1977 }
1978
1979 rows.push(serde_json::json!({
1980 "relation": "annotates",
1981 "source": "note:*",
1982 "target": "any (entity, note, edge, or event)",
1983 }));
1984
1985 rows
1986}
1987
1988impl VerbRegistry {
1989 pub async fn resolve_kg_read_by_id(
1996 &self,
1997 runtime: &KhiveRuntime,
1998 token: &NamespaceToken,
1999 id: uuid::Uuid,
2000 include_deleted: bool,
2001 ) -> Result<Option<crate::Resolved>, RuntimeError> {
2002 match &self.kg_read_resolver {
2003 Some(resolver) => resolver.by_id(token, id, include_deleted).await,
2004 None if include_deleted => runtime.resolve_by_id_including_deleted(token, id).await,
2005 None => runtime.resolve_by_id(token, id).await,
2006 }
2007 }
2008
2009 pub async fn resolve_entity_delete_runtime(
2013 &self,
2014 runtime: &KhiveRuntime,
2015 token: &NamespaceToken,
2016 id: uuid::Uuid,
2017 ) -> Result<Option<KhiveRuntime>, RuntimeError> {
2018 match &self.kg_read_resolver {
2019 Some(resolver) => resolver.entity_runtime(token, id).await,
2020 None => {
2021 let store = runtime.entities(token)?;
2022 let entity = store.get_entity_including_deleted(id).await?;
2023 Ok(entity.map(|_| runtime.clone()))
2024 }
2025 }
2026 }
2027
2028 pub async fn cleanup_deleted_entity_attachments(
2031 &self,
2032 runtime: &KhiveRuntime,
2033 token: &NamespaceToken,
2034 id: uuid::Uuid,
2035 ) -> Result<bool, RuntimeError> {
2036 if self
2037 .resolve_entity_delete_runtime(runtime, token, id)
2038 .await?
2039 .is_some()
2040 {
2041 return Ok(false);
2042 }
2043 runtime.delete_entity_attachments_on_core(id).await
2044 }
2045
2046 pub async fn authorize_effective_kg_read(
2052 &self,
2053 token: &NamespaceToken,
2054 verb: &str,
2055 mut effective_args: Value,
2056 effective_id: uuid::Uuid,
2057 ) -> Result<(), RuntimeError> {
2058 if let Some(namespace) = token.gate_explicit_namespace() {
2059 effective_args["namespace"] = Value::String(namespace.to_owned());
2060 }
2061 let gate_req = GateRequest::new(
2062 token.actor().clone(),
2063 token.gate_namespace().clone(),
2064 verb,
2065 effective_args,
2066 );
2067 let decision = khive_gate::check_with_mailbox_policy(self.gate.as_ref(), &gate_req);
2068 match decision {
2069 Ok(decision) => {
2070 let audit = masked_audit_event(&gate_req, &decision, self.gate.impl_name());
2071 tracing::info!(
2072 audit_event = %serde_json::to_string(&audit)
2073 .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
2074 effective_target_id = %effective_id,
2075 "gate.check"
2076 );
2077 let denied = matches!(&decision, GateDecision::Deny { .. });
2078 let receipt = if let Some(store) = &self.event_store {
2079 let event = build_audit_storage_event(
2080 &gate_req,
2081 &audit,
2082 if denied {
2083 EventOutcome::Denied
2084 } else {
2085 EventOutcome::Success
2086 },
2087 Some(crate::cost_unit::base_resource_payload(token.request_id())),
2088 )
2089 .with_target(effective_id);
2090 if denied {
2091 self.append_gate_denied_row(store, event, verb).await
2092 } else {
2093 let outcome = append_audit_event_best_effort(
2094 self.audit_batch.as_ref(),
2095 store,
2096 event,
2097 verb,
2098 crate::audit_batch::AuditProducer::EffectiveTargetCheck,
2099 false,
2100 )
2101 .await;
2102 fold_audit_obligation(Ok(()), outcome, |_| Value::Null)?;
2103 crate::error::DenialReceipt::no_store()
2104 }
2105 } else {
2106 crate::error::DenialReceipt::no_store()
2107 };
2108 match decision {
2109 GateDecision::Allow { .. } => Ok(()),
2110 GateDecision::Deny { reason } => Err(RuntimeError::PermissionDenied {
2111 verb: verb.to_string(),
2112 reason,
2113 receipt: Box::new(receipt),
2114 }),
2115 }
2116 }
2117 Err(error) => Err(self
2118 .gate_unavailable_error(&gate_req, &error, token.request_id(), Some(effective_id))
2119 .await),
2120 }
2121 }
2122
2123 pub async fn resolve_kg_read_prefix(
2129 &self,
2130 runtime: &KhiveRuntime,
2131 _token: &NamespaceToken,
2132 prefix: &str,
2133 include_deleted: bool,
2134 ) -> Result<Option<uuid::Uuid>, RuntimeError> {
2135 match &self.kg_read_resolver {
2136 Some(resolver) => resolver.prefix(prefix, include_deleted).await,
2137 None if include_deleted => {
2138 runtime
2139 .resolve_prefix_unfiltered_including_deleted(prefix)
2140 .await
2141 }
2142 None => runtime.resolve_prefix_unfiltered(prefix).await,
2143 }
2144 }
2145
2146 pub fn default_namespace(&self) -> &str {
2152 &self.default_namespace
2153 }
2154
2155 pub fn actor_id(&self) -> Option<&str> {
2159 self.actor_id.as_deref()
2160 }
2161
2162 pub fn visible_namespaces(&self) -> &[Namespace] {
2166 &self.visible_namespaces
2167 }
2168
2169 pub fn event_store(&self) -> Option<Arc<dyn EventStore>> {
2179 self.event_store.clone()
2180 }
2181
2182 pub fn audit_batch_metrics(&self) -> Option<khive_db::diagnostics::RuntimeAuditBatchMetrics> {
2199 self.audit_batch.as_ref().map(|batch| {
2200 let m = batch.health_metrics();
2201 khive_db::diagnostics::RuntimeAuditBatchMetrics {
2202 flush_failures: m.flush_failures,
2203 degraded_rows: m.degraded_rows,
2204 degraded: m.degraded,
2205 admission_refused_obligations: audit_admission_refused_obligation_count(),
2206 admission_refused_obligations_last_at_ms:
2207 audit_admission_refused_obligation_last_at_ms(),
2208 admission_unresolved_obligations: audit_admission_unresolved_obligation_count(),
2209 admission_unresolved_obligations_last_at_ms:
2210 audit_admission_unresolved_obligation_last_at_ms(),
2211 }
2212 })
2213 }
2214
2215 pub fn audit_batch_handle(&self) -> Option<Arc<crate::audit_batch::AuditBatch>> {
2222 self.audit_batch.clone()
2223 }
2224
2225 pub async fn shutdown_audit_batch(
2233 &self,
2234 ) -> Result<(), crate::audit_batch::AuditTerminalReason> {
2235 use crate::audit_batch::AuditBatchControl;
2236 match &self.audit_batch {
2237 Some(audit_batch) => audit_batch.close_and_drain().await,
2238 None => Ok(()),
2239 }
2240 }
2241
2242 pub fn audit_persistence_advisory(&self) -> Option<Value> {
2248 self.audit_store_read_only.then(|| {
2249 serde_json::json!({
2250 "code": AUDIT_PERSISTENCE_SKIPPED_READ_ONLY,
2251 "severity": "warning",
2252 "component": "audit_event_store",
2253 "reason": "read_only_backend",
2254 "message": "operation completed, but its dispatch audit event was not persisted because the audit backend is read-only",
2255 })
2256 })
2257 }
2258
2259 const ADMISSION_DEGRADE_SAFE_VERBS: &'static [(&'static str, &'static str)] = &[
2310 ("agent", "agent.observe"),
2312 ("exec", "exec.tree_get"),
2316 ("exec", "exec.tree_diff"),
2317 ("exec", "exec.receipt"),
2318 ("exec", "exec.runs"),
2319 ("exec", "exec.events"),
2320 ("exec", "exec.identity"),
2321 ("git", "git.receipts"),
2324 ("git", "git.gates"),
2325 ("git", "git.status"),
2326 ("git", "git.log"),
2327 ("git", "git.ingest_cursor"),
2329 ("blob", "blob.get"),
2331 ("blob", "blob.stat"),
2332 ("brain", "brain.event_counts"),
2334 ("brain", "brain.profiles"),
2335 ("brain", "brain.profile"),
2336 ("brain", "brain.resolve"),
2337 ("brain", "brain.bindings"),
2338 ("comm", "comm.delivered"),
2340 ("comm", "comm.inbox"),
2341 ("comm", "comm.unread"),
2342 ("comm", "comm.thread"),
2343 ("comm", "comm.health"),
2344 ("comm", "comm.probe"),
2345 ("gtd", "gtd.census"),
2347 ("gtd", "gtd.next"),
2348 ("gtd", "gtd.tasks"),
2349 ("kg", "get"),
2351 ("kg", "list"),
2352 ("kg", "stats"),
2353 ("kg", "search"),
2354 ("kg", "neighbors"),
2355 ("kg", "traverse"),
2356 ("kg", "context"),
2357 ("kg", "query"),
2358 ("kg", "resolve"),
2359 ("kg", "whoami"),
2360 ("kg", "scan"),
2363 ("kg", "verbs"),
2364 ("kg", "stream.read"),
2365 ("kg", "stream.stat"),
2366 ("knowledge", "knowledge.get"),
2368 ("knowledge", "knowledge.list"),
2369 ("knowledge", "knowledge.stats"),
2370 ("knowledge", "knowledge.fold"),
2371 ("knowledge", "knowledge.topic"),
2372 ("moodboard", "moodboard.model"),
2374 ("moodboard", "moodboard.search"),
2375 ("moodboard", "moodboard.preference"),
2376 ("schedule", "schedule.agenda"),
2378 ("session", "session.list"),
2380 ("session", "session.resume"),
2381 ("session", "session.export"),
2382 ("session", "session.search"),
2383 ("tool", "tool.suggest"),
2386 ("tool", "tool.describe"),
2387 ("tool", "tool.list"),
2388 ("tool", "tool.check"),
2389 ("tool", "tool.requests"),
2390 ("tool", "tool.policies"),
2391 ];
2392
2393 fn admission_degrade_safe_sorted() -> &'static [(&'static str, &'static str)] {
2402 static SORTED: std::sync::LazyLock<Vec<(&'static str, &'static str)>> =
2403 std::sync::LazyLock::new(|| {
2404 let mut pairs = VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS.to_vec();
2405 pairs.sort_unstable();
2406 pairs
2407 });
2408 &SORTED
2409 }
2410
2411 fn admission_degrade_safe(&self, verb: &str) -> bool {
2450 self.degrade_safe_verbs.contains(verb)
2451 }
2452
2453 pub fn is_read_replay_safe(&self, verb: &str) -> bool {
2459 self.read_replay_safe_verbs.contains(verb)
2460 }
2461
2462 #[cfg(any(test, feature = "test-internals"))]
2469 pub fn admission_degrade_safe_probe(&self, verb: &str) -> bool {
2470 self.admission_degrade_safe(verb)
2471 }
2472
2473 pub fn describe_verb(&self, verb: &str) -> Result<Value, RuntimeError> {
2492 for pack in self.packs.iter() {
2493 for handler in pack.handlers().iter() {
2494 if handler.name == verb {
2495 let category = format!("{:?}", handler.category);
2496 let params_arr: Vec<Value> = handler
2497 .params
2498 .iter()
2499 .map(|p| {
2500 let description = match resolution_mode_contract(p.resolution_mode) {
2501 Some(contract) => format!("{} {}", p.description, contract),
2502 None => p.description.to_string(),
2503 };
2504 serde_json::json!({
2505 "name": p.name,
2506 "type": p.param_type,
2507 "required": p.required,
2508 "description": description,
2509 })
2510 })
2511 .collect();
2512 if matches!(handler.visibility, Visibility::Subhandler) {
2517 return Ok(serde_json::json!({
2518 "verb": verb,
2519 "pack": pack.name(),
2520 "description": handler.description,
2521 "category": category,
2522 "params": params_arr,
2523 "identifier_resolution": identifier_resolution_help(),
2524 "visibility": "internal",
2525 "callable_via_mcp": false,
2526 "note": "This is an internal subhandler. Calling it via the MCP \
2527 request surface returns permission denied. It can only be \
2528 invoked by internal runtime callers.",
2529 }));
2530 }
2531 let mut envelope = serde_json::json!({
2532 "verb": verb,
2533 "pack": pack.name(),
2534 "description": handler.description,
2535 "category": category,
2536 "params": params_arr,
2537 "identifier_resolution": identifier_resolution_help(),
2538 });
2539 if let Some(schema) = pack.input_schema(verb) {
2544 envelope["input_schema"] = schema;
2545 } else {
2546 let described: Vec<(String, String)> = params_arr
2547 .iter()
2548 .map(|p| {
2549 (
2550 p["name"].as_str().unwrap_or_default().to_string(),
2551 p["description"].as_str().unwrap_or_default().to_string(),
2552 )
2553 })
2554 .collect();
2555 if let Some(schema) =
2556 crate::input_schema::derive_input_schema(handler.params, &described)
2557 {
2558 envelope["input_schema"] = schema;
2559 }
2560 }
2561 if verb == "link" {
2562 envelope["endpoint_rules"] = Value::Array(edge_endpoint_table(&self.packs));
2563 }
2564 return Ok(envelope);
2565 }
2566 }
2567 }
2568 Err(RuntimeError::UnknownVerb(format!(
2572 "unknown verb {verb:?}; available: {}",
2573 self.available_verbs.join(", ")
2574 )))
2575 }
2576
2577 pub fn authorize_namespace(&self, ns: Namespace) -> Result<(), RuntimeError> {
2587 let actor = crate::actor_identity::resolve_actor(self.actor_id.as_deref());
2588 let req = GateRequest::new(actor, ns, "authorize", serde_json::Value::Null);
2589 match self.gate.check(&req) {
2590 Ok(decision) if decision.is_allow() => Ok(()),
2591 Ok(GateDecision::Deny { reason }) => {
2592 Err(RuntimeError::permission_denied("authorize", reason))
2593 }
2594 Ok(_) => Err(RuntimeError::permission_denied("authorize", "gate denied")),
2595 Err(e) => {
2596 tracing::warn!(
2597 error = %crate::secret_gate::bounded_masked_log_text(&e.to_string()),
2598 "authorize_namespace: gate check failed (fail-closed)"
2599 );
2600 Err(RuntimeError::Internal(format!(
2601 "gate error: {}",
2602 e.wire_reason()
2603 )))
2604 }
2605 }
2606 }
2607
2608 pub async fn dispatch_intercepted_with_identity<F, Fut>(
2618 &self,
2619 verb: &str,
2620 params: &Value,
2621 identity: Option<&RequestIdentity>,
2622 dispatch: F,
2623 ) -> Result<Value, RuntimeError>
2624 where
2625 F: FnOnce(Namespace) -> Fut,
2626 Fut: std::future::Future<Output = Result<Value, RuntimeError>>,
2627 {
2628 self.dispatch_intercepted_with_metadata_with_identity(
2629 verb,
2630 params,
2631 identity,
2632 |namespace| async move {
2633 dispatch(namespace)
2634 .await
2635 .map(|result| InterceptedDispatchResult::new(result, ()))
2636 },
2637 )
2638 .await
2639 .map(|outcome| outcome.result)
2640 }
2641
2642 pub async fn dispatch_intercepted_with_metadata_with_identity<M, F, Fut>(
2649 &self,
2650 verb: &str,
2651 params: &Value,
2652 identity: Option<&RequestIdentity>,
2653 dispatch: F,
2654 ) -> Result<InterceptedDispatchResult<M>, RuntimeError>
2655 where
2656 F: FnOnce(Namespace) -> Fut,
2657 Fut: std::future::Future<Output = Result<InterceptedDispatchResult<M>, RuntimeError>>,
2658 {
2659 self.dispatch_intercepted_with_metadata_and_disposition(verb, params, identity, dispatch)
2660 .await
2661 .map_err(DispatchError::into_source)
2662 }
2663
2664 async fn append_gate_denied_row(
2667 &self,
2668 store: &Arc<dyn EventStore>,
2669 event: Event,
2670 verb: &str,
2671 ) -> crate::error::DenialReceipt {
2672 let audit_event_id = event.id;
2673 match append_audit_event_best_effort(
2674 self.audit_batch.as_ref(),
2675 store,
2676 event,
2677 verb,
2678 crate::audit_batch::AuditProducer::GateDenied,
2679 false,
2680 )
2681 .await
2682 {
2683 Ok(()) => crate::error::DenialReceipt {
2684 audit_event_id: Some(audit_event_id),
2685 audit_outcome: crate::error::DenialAuditOutcome::Committed,
2686 },
2687 Err(failure) => crate::error::DenialReceipt {
2688 audit_event_id: None,
2689 audit_outcome: crate::error::DenialAuditOutcome::NotCommitted(failure.wire_code()),
2690 },
2691 }
2692 }
2693
2694 pub async fn dispatch_intercepted_with_metadata_and_disposition<M, F, Fut>(
2697 &self,
2698 verb: &str,
2699 params: &Value,
2700 identity: Option<&RequestIdentity>,
2701 dispatch: F,
2702 ) -> Result<InterceptedDispatchResult<M>, DispatchError>
2703 where
2704 F: FnOnce(Namespace) -> Fut,
2705 Fut: std::future::Future<Output = Result<InterceptedDispatchResult<M>, RuntimeError>>,
2706 {
2707 let request_id = identity.and_then(|id| id.request_id);
2708 let gate_req = self
2709 .gate_request_with_identity(verb, params, identity)
2710 .map_err(DispatchError::before_dispatch)?;
2711 let gate_decision = khive_gate::check_with_mailbox_policy(self.gate.as_ref(), &gate_req);
2712 let mut deferred_audit = match gate_decision {
2713 Ok(decision) => {
2714 let audit = masked_audit_event(&gate_req, &decision, self.gate.impl_name());
2715 tracing::info!(
2716 audit_event = %serde_json::to_string(&audit)
2717 .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
2718 "gate.check"
2719 );
2720 if let GateDecision::Deny { reason } = decision {
2721 let receipt = match &self.event_store {
2722 Some(store) => {
2723 let event = build_audit_storage_event(
2724 &gate_req,
2725 &audit,
2726 EventOutcome::Denied,
2727 Some(crate::cost_unit::base_resource_payload(request_id)),
2728 );
2729 self.append_gate_denied_row(store, event, verb).await
2736 }
2737 None => crate::error::DenialReceipt::no_store(),
2738 };
2739 return Err(DispatchError::before_dispatch(
2740 RuntimeError::PermissionDenied {
2741 verb: verb.to_string(),
2742 reason,
2743 receipt: Box::new(receipt),
2744 },
2745 ));
2746 }
2747 Some(audit)
2748 }
2749 Err(err) => {
2750 return Err(DispatchError::before_dispatch(
2751 self.gate_unavailable_error(&gate_req, &err, request_id, None)
2752 .await,
2753 ));
2754 }
2755 };
2756
2757 let started = Instant::now();
2758 let mut result = dispatch(gate_req.namespace.clone()).await;
2759 let domain_succeeded = result.is_ok();
2760 let duration_us = started.elapsed().as_micros() as i64;
2761 let receipt_outcome = if verb == "git.digest" && result.is_ok() {
2762 let resource = result.as_ref().ok().map(|outcome| {
2763 crate::cost_unit::resource_payload(
2764 verb,
2765 &gate_req.args,
2766 &outcome.result,
2767 || 0,
2768 request_id,
2769 )
2770 });
2771 let mut receipt_result: Result<Value, RuntimeError> = match result.as_mut() {
2776 Ok(outcome) => Ok(std::mem::take(&mut outcome.result)),
2777 Err(_) => unreachable!("git.digest receipt path is guarded by result.is_ok()"),
2778 };
2779 let outcome = persist_git_digest_receipt(
2780 self.event_store.as_ref(),
2781 self.audit_batch.as_ref(),
2782 &gate_req,
2783 deferred_audit.as_ref(),
2784 &mut receipt_result,
2785 duration_us,
2786 resource,
2787 )
2788 .await;
2789 match receipt_result {
2790 Ok(receipted_result) => {
2791 if let Ok(intercepted) = &mut result {
2792 intercepted.result = receipted_result;
2793 }
2794 }
2795 Err(error) => result = Err(error),
2796 }
2797 Some(outcome)
2798 } else {
2799 None
2800 };
2801 if receipt_outcome.is_none()
2802 || receipt_outcome == Some(GitDigestReceiptOutcome::BuildRejected)
2803 {
2804 if let Some(audit) = deferred_audit.take() {
2805 let audit_outcome = self
2806 .persist_intercepted_audit(
2807 verb,
2808 &gate_req,
2809 audit,
2810 result.as_ref().map(|outcome| &outcome.result),
2811 duration_us,
2812 request_id,
2813 )
2814 .await;
2815 result = fold_audit_obligation(result, audit_outcome, |outcome| outcome.result);
2816 }
2817 }
2818 result.map_err(|error| DispatchError::after_handler(error, domain_succeeded))
2819 }
2820
2821 async fn persist_intercepted_audit(
2822 &self,
2823 verb: &str,
2824 gate_req: &GateRequest,
2825 audit: AuditEvent,
2826 result: Result<&Value, &RuntimeError>,
2827 duration_us: i64,
2828 request_id: Option<u64>,
2829 ) -> Result<(), AuditObligationFailure> {
2830 let Some(store) = &self.event_store else {
2831 return Ok(());
2832 };
2833 let event = match result {
2834 Ok(value) if verb == "link" && gate_req.args.get("links").is_none() => {
2835 let resource = crate::cost_unit::resource_payload(
2836 verb,
2837 &gate_req.args,
2838 value,
2839 || 0,
2840 request_id,
2841 );
2842 match link_audit_success_from_result(audit.clone(), value) {
2843 Some((edge_id, mut payload)) => {
2844 if let Value::Object(ref mut map) = payload {
2845 map.insert("resource".to_string(), resource);
2846 }
2847 Event::new(
2848 gate_req.namespace.as_str(),
2849 gate_req.verb.as_str(),
2850 EventKind::Audit,
2851 SubstrateKind::Event,
2852 format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
2853 )
2854 .with_outcome(EventOutcome::Success)
2855 .with_target(edge_id)
2856 .with_payload(payload)
2857 .with_payload_schema_version(2)
2858 .with_duration_us(duration_us)
2859 }
2860 None => build_audit_storage_event(
2861 gate_req,
2862 &audit,
2863 EventOutcome::Success,
2864 Some(resource),
2865 )
2866 .with_duration_us(duration_us),
2867 }
2868 }
2869 Ok(value) => build_audit_storage_event(
2870 gate_req,
2871 &audit,
2872 EventOutcome::Success,
2873 Some(crate::cost_unit::resource_payload(
2874 verb,
2875 &gate_req.args,
2876 value,
2877 || 0,
2878 request_id,
2879 )),
2880 )
2881 .with_duration_us(duration_us),
2882 Err(_) => build_audit_storage_event(
2883 gate_req,
2884 &audit,
2885 EventOutcome::Error,
2886 Some(crate::cost_unit::base_resource_payload(request_id)),
2887 )
2888 .with_duration_us(duration_us),
2889 };
2890 let producer = if result.is_ok() {
2891 crate::audit_batch::AuditProducer::DispatchSucceeded
2892 } else {
2893 crate::audit_batch::AuditProducer::DispatchFailed
2894 };
2895 append_audit_event_best_effort(
2896 self.audit_batch.as_ref(),
2897 store,
2898 event,
2899 verb,
2900 producer,
2901 self.admission_degrade_safe(verb),
2902 )
2903 .await
2904 }
2905
2906 pub fn allows_note_key_disclosure(
2908 &self,
2909 token: &NamespaceToken,
2910 kind: &str,
2911 key: &str,
2912 ) -> bool {
2913 let request = GateRequest::new(
2914 token.actor().clone(),
2915 token.namespace().clone(),
2916 "list",
2917 serde_json::json!({"kind":"note", "note_kind":kind, "key_prefix":key}),
2918 );
2919 self.gate
2920 .check(&request)
2921 .is_ok_and(|decision| decision.is_allow())
2922 }
2923
2924 fn gate_request_with_identity(
2925 &self,
2926 verb: &str,
2927 params: &Value,
2928 identity: Option<&RequestIdentity>,
2929 ) -> Result<GateRequest, RuntimeError> {
2930 let default_namespace = identity
2931 .map(|id| id.namespace.as_str())
2932 .unwrap_or(self.default_namespace.as_str());
2933 let namespace = resolve_explicit_namespace(params, default_namespace)?;
2934 let actor_id = identity
2935 .map(|id| id.actor_id.as_deref())
2936 .unwrap_or(self.actor_id.as_deref());
2937 let actor = crate::actor_identity::resolve_actor(actor_id);
2938 let req = GateRequest::new(actor, namespace, verb, params.clone());
2942 crate::mailbox_view::validate_mailbox_request(&req)?;
2943 Ok(req)
2944 }
2945
2946 async fn gate_unavailable_error(
2947 &self,
2948 gate_req: &GateRequest,
2949 error: &khive_gate::GateError,
2950 request_id: Option<u64>,
2951 effective_target: Option<uuid::Uuid>,
2952 ) -> RuntimeError {
2953 let audit = AuditEvent::gate_unavailable(gate_req, self.gate.impl_name())
2954 .with_operation_attribution(
2955 khive_storage::operation_context::current_operation_attribution(),
2956 );
2957 tracing::info!(
2958 audit_event = %serde_json::to_string(&audit)
2959 .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
2960 "gate.check"
2961 );
2962 tracing::warn!(
2963 verb = %gate_req.verb,
2964 error = %crate::secret_gate::bounded_masked_log_text(&error.to_string()),
2965 "gate check failed (fail-closed)"
2966 );
2967 if let Some(store) = &self.event_store {
2968 let mut event = build_audit_storage_event(
2969 gate_req,
2970 &audit,
2971 EventOutcome::Error,
2972 Some(crate::cost_unit::base_resource_payload(request_id)),
2973 );
2974 if let Some(target) = effective_target {
2975 event = event.with_target(target);
2976 }
2977 let _ = append_audit_event_best_effort(
2978 self.audit_batch.as_ref(),
2979 store,
2980 event,
2981 gate_req.verb.as_str(),
2982 crate::audit_batch::AuditProducer::GateUnavailable,
2983 false,
2984 )
2985 .await;
2986 }
2987 RuntimeError::GateUnavailable {
2988 verb: gate_req.verb.clone(),
2989 reason: error.wire_reason().to_string(),
2995 }
2996 }
2997
2998 pub async fn dispatch(&self, verb: &str, params: Value) -> Result<Value, RuntimeError> {
3008 self.dispatch_with_identity(verb, params, None).await
3009 }
3010
3011 pub async fn dispatch_with_identity(
3023 &self,
3024 verb: &str,
3025 params: Value,
3026 identity: Option<RequestIdentity>,
3027 ) -> Result<Value, RuntimeError> {
3028 self.dispatch_with_disposition(verb, params, identity)
3029 .await
3030 .map_err(DispatchError::into_source)
3031 }
3032
3033 pub async fn dispatch_with_disposition(
3036 &self,
3037 verb: &str,
3038 params: Value,
3039 identity: Option<RequestIdentity>,
3040 ) -> Result<Value, DispatchError> {
3041 if params.get("help").and_then(Value::as_bool) == Some(true) {
3043 let result = match self.describe_verb(verb) {
3044 Ok(value) => Ok(value),
3045 Err(error) => match self.mounted_verb_catalog().await {
3046 Ok(catalog) => catalog
3047 .into_iter()
3048 .find(|entry| entry["verb"] == verb)
3049 .ok_or(error),
3050 Err(error) => Err(error),
3051 },
3052 };
3053 return result.map_err(DispatchError::before_dispatch);
3054 }
3055 let explicit_namespace = params.get("namespace").is_some_and(Value::is_string);
3066 let request_id: Option<u64> = identity.as_ref().and_then(|id| id.request_id);
3070 let gate_req = self
3076 .gate_request_with_identity(verb, ¶ms, identity.as_ref())
3077 .map_err(DispatchError::before_dispatch)?;
3078 let ns = gate_req.namespace.clone();
3079 let resolved_actor = gate_req.actor.clone();
3080
3081 let gate_decision = khive_gate::check_with_mailbox_policy(self.gate.as_ref(), &gate_req);
3087 let (gate_blocked, mut deferred_audit) = match gate_decision {
3088 Ok(decision) => {
3089 let is_deny = matches!(decision, GateDecision::Deny { .. });
3090
3091 let audit = masked_audit_event(&gate_req, &decision, self.gate.impl_name());
3093 tracing::info!(
3094 audit_event = %serde_json::to_string(&audit)
3095 .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
3096 "gate.check"
3097 );
3098
3099 if let Some(store) = &self.event_store {
3113 if crate::config_ledger::PENDING
3114 .swap(false, std::sync::atomic::Ordering::AcqRel)
3115 {
3116 for (key, value) in crate::config_ledger::drain_config_locked() {
3117 let payload = serde_json::json!({ "key": key, "value": value });
3118 let storage_event = Event::new(
3119 gate_req.namespace.as_str(),
3120 "config.lock",
3121 EventKind::ConfigLocked,
3122 SubstrateKind::Event,
3123 format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
3124 )
3125 .with_payload(payload);
3126 let _ = append_audit_event_best_effort(
3130 self.audit_batch.as_ref(),
3131 store,
3132 storage_event,
3133 "config.lock",
3134 crate::audit_batch::AuditProducer::ConfigLocked,
3135 false,
3136 )
3137 .await;
3138 }
3139 }
3140 }
3141
3142 let defer_audit = !is_deny;
3159
3160 let reason = if is_deny {
3164 let reason = match decision {
3165 GateDecision::Deny { reason } => reason,
3166 _ => String::new(),
3167 };
3168 let receipt = match &self.event_store {
3169 Some(store) => {
3170 let storage_event = build_audit_storage_event(
3176 &gate_req,
3177 &audit,
3178 EventOutcome::Denied,
3179 Some(crate::cost_unit::base_resource_payload(request_id)),
3180 );
3181 self.append_gate_denied_row(store, storage_event, verb)
3186 .await
3187 }
3188 None => crate::error::DenialReceipt::no_store(),
3189 };
3190 Some((reason, receipt))
3191 } else {
3192 None
3193 };
3194 let deferred = if defer_audit { Some(audit) } else { None };
3195 (reason, deferred)
3196 }
3197 Err(err) => {
3198 return Err(DispatchError::before_dispatch(
3199 self.gate_unavailable_error(&gate_req, &err, request_id, None)
3200 .await,
3201 ));
3202 }
3203 };
3204
3205 if let Some((reason, receipt)) = gate_blocked {
3207 return Err(DispatchError::before_dispatch(
3208 RuntimeError::PermissionDenied {
3209 verb: verb.to_string(),
3210 reason,
3211 receipt: Box::new(receipt),
3212 },
3213 ));
3214 }
3215
3216 let token = if explicit_namespace {
3242 NamespaceToken::mint_with_visibility(ns.clone(), vec![], resolved_actor)
3244 } else {
3245 let primary = Namespace::local();
3247 let mut extra_visible: Vec<Namespace> = match identity.as_ref() {
3248 Some(id) => id
3249 .visible_namespaces
3250 .iter()
3251 .filter_map(|s| match Namespace::parse(s) {
3252 Ok(parsed) => Some(parsed),
3253 Err(e) => {
3254 tracing::warn!(
3255 namespace = %s,
3256 error = %e,
3257 "dispatch_with_identity: skipping invalid visible_namespace \
3258 entry from per-request identity"
3259 );
3260 None
3261 }
3262 })
3263 .collect(),
3264 None => self.visible_namespaces.clone(),
3265 };
3266 if let Some(actor_namespace) = resolved_actor
3272 .binding_id()
3273 .filter(|id| *id != Namespace::LOCAL)
3274 .and_then(|id| Namespace::parse(id).ok())
3275 {
3276 extra_visible.push(actor_namespace);
3277 }
3278 extra_visible.push(Namespace::local()); NamespaceToken::mint_with_visibility(primary, extra_visible, resolved_actor)
3280 }
3281 .with_gate_namespace(ns.clone())
3282 .with_gate_explicit_namespace(
3283 params
3284 .get("namespace")
3285 .and_then(Value::as_str)
3286 .map(str::to_owned),
3287 )
3288 .with_request_id(request_id)
3289 .with_process_ref(match identity.as_ref() {
3290 Some(id) => id.process_ref.clone(),
3291 None => crate::config::process_ref_from_env(),
3292 });
3293
3294 for pack in self.packs.iter() {
3295 let handler_def = pack.handlers().iter().find(|v| v.name == verb);
3296 let mounted_name = pack.mounted_namespace().and_then(|prefix| {
3297 verb.strip_prefix(prefix)
3298 .and_then(|suffix| suffix.strip_prefix('.'))
3299 });
3300 if handler_def.is_some() || mounted_name.is_some() {
3301 let definition = if let Some(name) = mounted_name {
3302 pack.mounted_catalog().await.and_then(|catalog| {
3303 catalog
3304 .into_iter()
3305 .find(|definition| definition.name == name)
3306 .map(Some)
3307 .ok_or_else(|| RuntimeError::UnknownVerb(verb.to_owned()))
3308 })
3309 } else {
3310 Ok(None)
3311 };
3312 let handler_accepts_namespace = handler_def
3322 .is_some_and(|h| h.params.iter().any(|p| p.name == "namespace"))
3323 || definition
3324 .as_ref()
3325 .ok()
3326 .and_then(|value| value.as_ref())
3327 .is_some_and(|definition| {
3328 definition
3329 .input_schema
3330 .get("properties")
3331 .is_some_and(|properties| properties.get("namespace").is_some())
3332 });
3333 let params = if !handler_accepts_namespace {
3334 if let Value::Object(mut map) = params {
3335 map.remove("namespace");
3336 Value::Object(map)
3337 } else {
3338 params
3339 }
3340 } else {
3341 params
3342 };
3343 let dispatch_start = Instant::now();
3344 let mounted_audit = definition.as_ref().ok().and_then(|v| v.as_ref()).map(|v| {
3345 serde_json::json!({"mount": pack.name(), "effect": v.effect, "generation": v.generation})
3346 });
3347 let mut result = match definition {
3348 Ok(Some(definition)) => {
3349 pack.dispatch_mounted(&definition, verb, params, self, &token)
3350 .await
3351 }
3352 Ok(None) => pack.dispatch(verb, params, self, &token).await,
3353 Err(error) => Err(error),
3354 };
3355 let domain_succeeded = result.is_ok();
3356 let dispatch_us = dispatch_start.elapsed().as_micros() as i64;
3357
3358 let git_digest_receipt_outcome = if verb == "git.digest" && result.is_ok() {
3365 let resource = result.as_ref().ok().map(|value| {
3366 crate::cost_unit::resource_payload(
3367 verb,
3368 &gate_req.args,
3369 value,
3370 || pack.registered_embedding_model_names().len() as i64,
3371 request_id,
3372 )
3373 });
3374 Some(
3375 persist_git_digest_receipt(
3376 self.event_store.as_ref(),
3377 self.audit_batch.as_ref(),
3378 &gate_req,
3379 deferred_audit.as_ref(),
3380 &mut result,
3381 dispatch_us,
3382 resource,
3383 )
3384 .await,
3385 )
3386 } else {
3387 None
3388 };
3389
3390 let needs_generic_audit = git_digest_receipt_outcome.is_none()
3399 || git_digest_receipt_outcome == Some(GitDigestReceiptOutcome::BuildRejected);
3400 if let (true, Some(audit)) = (needs_generic_audit, deferred_audit.take()) {
3401 if let Some(store) = &self.event_store {
3402 let is_link_singleton =
3403 verb == "link" && gate_req.args.get("links").is_none();
3404 let audit_outcome: Result<(), AuditObligationFailure> = match &result {
3409 Ok(ok_val) if is_link_singleton => {
3410 let resource = crate::cost_unit::resource_payload(
3418 verb,
3419 &gate_req.args,
3420 ok_val,
3421 || pack.registered_embedding_model_names().len() as i64,
3422 request_id,
3423 );
3424 match link_audit_success_from_result(audit.clone(), ok_val) {
3425 Some((edge_id, mut payload)) => {
3426 if let Value::Object(ref mut map) = payload {
3427 map.insert("resource".to_string(), resource);
3428 }
3429 let storage_event = Event::new(
3430 gate_req.namespace.as_str(),
3431 gate_req.verb.as_str(),
3432 EventKind::Audit,
3433 SubstrateKind::Event,
3434 format!(
3435 "{}:{}",
3436 gate_req.actor.kind, gate_req.actor.id
3437 ),
3438 )
3439 .with_outcome(EventOutcome::Success)
3440 .with_target(edge_id)
3441 .with_payload(payload)
3442 .with_payload_schema_version(2)
3443 .with_duration_us(dispatch_us);
3444 append_audit_event_best_effort(
3445 self.audit_batch.as_ref(),
3446 store,
3447 storage_event,
3448 verb,
3449 crate::audit_batch::AuditProducer::DispatchSucceeded,
3450 self.admission_degrade_safe(verb),
3451 )
3452 .await
3453 }
3454 None => {
3455 tracing::warn!(
3456 verb,
3457 "link audit v2 enrichment parse failed; \
3458 falling back to v1 audit shape"
3459 );
3460 let storage_event = build_audit_storage_event(
3461 &gate_req,
3462 &audit,
3463 EventOutcome::Success,
3464 Some(resource),
3465 )
3466 .with_duration_us(dispatch_us);
3467 append_audit_event_best_effort(
3468 self.audit_batch.as_ref(),
3469 store,
3470 storage_event,
3471 verb,
3472 crate::audit_batch::AuditProducer::DispatchSucceeded,
3473 self.admission_degrade_safe(verb),
3474 )
3475 .await
3476 }
3477 }
3478 }
3479 _ => {
3480 let (outcome, resource) = match &result {
3500 Ok(ok_val) => (
3501 EventOutcome::Success,
3502 Some(crate::cost_unit::resource_payload(
3503 verb,
3504 &gate_req.args,
3505 ok_val,
3506 || pack.registered_embedding_model_names().len() as i64,
3507 request_id,
3508 )),
3509 ),
3510 Err(_) => (
3511 EventOutcome::Error,
3512 Some(crate::cost_unit::base_resource_payload(request_id)),
3513 ),
3514 };
3515 let producer = if result.is_ok() {
3516 crate::audit_batch::AuditProducer::DispatchSucceeded
3517 } else {
3518 crate::audit_batch::AuditProducer::DispatchFailed
3519 };
3520 let mut storage_event =
3521 build_audit_storage_event(&gate_req, &audit, outcome, resource)
3522 .with_duration_us(dispatch_us);
3523 if let Some(metadata) = &mounted_audit {
3524 storage_event.payload["mounted_tool"] = metadata.clone();
3525 }
3526 append_audit_event_best_effort(
3527 self.audit_batch.as_ref(),
3528 store,
3529 storage_event,
3530 verb,
3531 producer,
3532 self.admission_degrade_safe(verb),
3533 )
3534 .await
3535 }
3536 };
3537 result =
3543 fold_audit_obligation(result, audit_outcome, std::convert::identity);
3544 }
3545 }
3546
3547 if let (Ok(ref ok_val), Some(hook)) = (&result, &self.dispatch_hook) {
3549 let mut dispatch_event = Event::new(
3550 ns.as_str(),
3551 verb,
3552 EventKind::Audit,
3553 SubstrateKind::Event,
3554 pack.name(),
3555 )
3556 .with_outcome(EventOutcome::Success)
3557 .with_duration_us(dispatch_us);
3558
3559 if verb == "memory.recall" {
3565 let first_result =
3566 ok_val.as_array().and_then(|arr| arr.first()).or_else(|| {
3567 ok_val
3568 .get("results")
3569 .and_then(Value::as_array)
3570 .and_then(|arr| arr.first())
3571 });
3572 let first_note_id = first_result
3573 .and_then(|v| v.get("id"))
3574 .and_then(|v| v.as_str())
3575 .and_then(|s| s.parse::<uuid::Uuid>().ok());
3576 if let Some(note_id) = first_note_id {
3577 dispatch_event = dispatch_event.with_target(note_id);
3578 }
3579 let mut payload = serde_json::Map::new();
3580 if let Some(profile_id) = first_result
3581 .and_then(|v| v.get("served_by_profile_id"))
3582 .and_then(Value::as_str)
3583 {
3584 payload.insert(
3585 "served_by_profile_id".to_string(),
3586 Value::String(profile_id.to_string()),
3587 );
3588 }
3589 if let Some(attribution) = first_result
3590 .and_then(|v| v.get("serve_attribution"))
3591 .and_then(Value::as_str)
3592 {
3593 payload.insert(
3594 "serve_attribution".to_string(),
3595 Value::String(attribution.to_string()),
3596 );
3597 }
3598 dispatch_event = dispatch_event.with_payload(Value::Object(payload));
3599 }
3602
3603 let dispatch_view = EventView {
3604 event: dispatch_event,
3605 observations: Vec::new(),
3606 };
3607 let hook = Arc::clone(hook);
3608 hook.on_dispatch(&dispatch_view).await;
3609 }
3610
3611 if let Ok(ref ok_val) = result {
3627 let admissions = crate::reference_ring::ring_admissions_for(verb, ok_val);
3628 if !admissions.is_empty() {
3629 let actor_key = format!("{}:{}", gate_req.actor.kind, gate_req.actor.id);
3630 for (id, name) in admissions {
3631 self.reference_ring.admit(
3632 token.namespace().as_str(),
3633 &actor_key,
3634 id,
3635 name,
3636 );
3637 }
3638 }
3639 }
3640
3641 return result
3642 .map_err(|error| DispatchError::after_handler(error, domain_succeeded));
3643 }
3644 }
3645
3646 if let Some(audit) = deferred_audit.take() {
3652 if let Some(store) = &self.event_store {
3653 let storage_event = build_audit_storage_event(
3659 &gate_req,
3660 &audit,
3661 EventOutcome::Error,
3662 Some(crate::cost_unit::base_resource_payload(request_id)),
3663 );
3664 let _ = append_audit_event_best_effort(
3668 self.audit_batch.as_ref(),
3669 store,
3670 storage_event,
3671 verb,
3672 crate::audit_batch::AuditProducer::UnknownVerb,
3673 false,
3674 )
3675 .await;
3676 }
3677 }
3678
3679 Err(DispatchError::before_dispatch(RuntimeError::UnknownVerb(
3683 format!(
3684 "unknown verb {verb:?}; available: {}",
3685 self.available_verbs.join(", ")
3686 ),
3687 )))
3688 }
3689
3690 pub async fn dispatch_as(
3708 &self,
3709 verb: &str,
3710 params: Value,
3711 verified_actor: VerifiedActor,
3712 ) -> Result<Value, RuntimeError> {
3713 let identity = RequestIdentity {
3714 namespace: self.default_namespace.clone(),
3715 actor_id: Some(verified_actor.into_inner()),
3716 visible_namespaces: self
3717 .visible_namespaces
3718 .iter()
3719 .map(|ns| ns.as_str().to_string())
3720 .collect(),
3721 process_ref: crate::config::process_ref_from_env(),
3722 request_id: None,
3723 };
3724 self.dispatch_with_identity(verb, params, Some(identity))
3725 .await
3726 }
3727
3728 pub fn resolvers(&self) -> &[(String, Box<dyn PackByIdResolver>)] {
3734 &self.resolvers
3735 }
3736
3737 pub fn reference_ring(&self) -> &Arc<crate::reference_ring::ReferenceRing> {
3742 &self.reference_ring
3743 }
3744
3745 pub fn find_kind_hook(&self, kind: &str) -> Option<Arc<dyn KindHook>> {
3752 for pack in self.packs.iter() {
3753 let owns = pack.note_kinds().contains(&kind) || pack.entity_kinds().contains(&kind);
3754 if owns {
3755 if let Some(hook) = pack.kind_hook(kind) {
3756 return Some(hook);
3757 }
3758 }
3759 }
3760 None
3761 }
3762
3763 pub fn entity_kind_hooks(&self) -> crate::runtime::EntityKindHooks {
3778 let mut hooks = Vec::new();
3779 for pack in self.packs.iter() {
3780 for kind in pack.entity_kinds().iter().copied() {
3781 if let Some(hook) = pack.kind_hook(kind) {
3782 hooks.push((kind.to_string(), hook));
3783 }
3784 }
3785 }
3786 hooks
3787 }
3788
3789 pub async fn prepare_note_update_hook(
3800 &self,
3801 runtime: &KhiveRuntime,
3802 token: &NamespaceToken,
3803 note: &khive_storage::Note,
3804 args: &mut Value,
3805 ) -> Result<(), RuntimeError> {
3806 self.prepare_note_update_policy(runtime, token, note, args)
3807 .await
3808 .map(|_| ())
3809 }
3810
3811 pub async fn prepare_note_update_policy(
3816 &self,
3817 runtime: &KhiveRuntime,
3818 token: &NamespaceToken,
3819 note: &khive_storage::Note,
3820 args: &mut Value,
3821 ) -> Result<crate::NoteUpdatePolicy, RuntimeError> {
3822 crate::curation::normalize_note_update_tags(args)?;
3823 if let Some(hook) = self.find_kind_hook(¬e.kind) {
3824 hook.normalize_note_update(runtime, token, note, args)
3825 .await?;
3826 let properties = args.get("properties").filter(|value| !value.is_null());
3827 hook.validate_note_update(runtime, token, note, properties)
3828 .await?;
3829 return Ok(crate::NoteUpdatePolicy::for_kind(
3830 ¬e.kind,
3831 hook.note_update_null_clearing_properties(),
3832 ));
3833 }
3834 Ok(crate::NoteUpdatePolicy::default())
3835 }
3836
3837 pub async fn validate_note_update_hook(
3848 &self,
3849 runtime: &KhiveRuntime,
3850 token: &NamespaceToken,
3851 note: &khive_storage::Note,
3852 properties: Option<&Value>,
3853 ) -> Result<(), RuntimeError> {
3854 if let Some(hook) = self.find_kind_hook(¬e.kind) {
3855 hook.validate_note_update(runtime, token, note, properties)
3856 .await?;
3857 }
3858 Ok(())
3859 }
3860
3861 pub async fn validate_link_hooks(
3868 &self,
3869 runtime: &KhiveRuntime,
3870 token: &NamespaceToken,
3871 specs: &[LinkSpec],
3872 ) -> Result<(), RuntimeError> {
3873 let mut specs_by_kind: HashMap<String, Vec<LinkSpec>> = HashMap::new();
3874 for spec in specs {
3875 let Some(Resolved::Note(source)) = runtime.resolve_by_id(token, spec.source_id).await?
3876 else {
3877 continue;
3878 };
3879 specs_by_kind
3880 .entry(source.kind)
3881 .or_default()
3882 .push(spec.clone());
3883 }
3884 for (kind, kind_specs) in specs_by_kind {
3885 if let Some(hook) = self.find_kind_hook(&kind) {
3886 hook.validate_links(runtime, token, &kind_specs).await?;
3887 }
3888 }
3889 Ok(())
3890 }
3891
3892 pub fn has_verb(&self, verb: &str) -> bool {
3898 self.packs
3899 .iter()
3900 .flat_map(|p| p.handlers().iter())
3901 .any(|h| h.name == verb)
3902 }
3903
3904 pub fn mounted_verb_snapshot(&self) -> Vec<Value> {
3906 self.packs
3907 .iter()
3908 .flat_map(|pack| {
3909 pack.mounted_catalog_snapshot()
3910 .into_iter()
3911 .map(|verb| verb.describe(pack.name()))
3912 })
3913 .collect()
3914 }
3915
3916 pub async fn mounted_verb_catalog(&self) -> Result<Vec<Value>, RuntimeError> {
3917 let mut catalog = Vec::new();
3918 for pack in self.packs.iter() {
3919 for definition in pack.mounted_catalog().await? {
3920 catalog.push(definition.describe(pack.name()));
3921 }
3922 }
3923 Ok(catalog)
3924 }
3925
3926 pub async fn apply_profile_section_feedback(
3930 &self,
3931 token: &NamespaceToken,
3932 profile_id: &str,
3933 section_signals: Value,
3934 target_attribution: Option<String>,
3935 ) -> Result<Value, RuntimeError> {
3936 let brain = self
3937 .packs
3938 .iter()
3939 .find(|pack| pack.name() == "brain")
3940 .ok_or_else(|| {
3941 RuntimeError::InvalidInput(
3942 "profile section feedback requires the brain pack".into(),
3943 )
3944 })?;
3945 brain
3946 .apply_profile_section_feedback(token, profile_id, section_signals, target_attribution)
3947 .await
3948 }
3949
3950 pub fn all_verbs(&self) -> Vec<&'static HandlerDef> {
3956 self.packs
3957 .iter()
3958 .flat_map(|p| p.handlers().iter())
3959 .filter(|h| matches!(h.visibility, Visibility::Verb))
3960 .collect()
3961 }
3962
3963 pub fn all_verbs_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
3970 self.packs
3971 .iter()
3972 .flat_map(|p| p.handlers().iter().map(move |v| (p.name(), v)))
3973 .filter(|(_, h)| matches!(h.visibility, Visibility::Verb))
3974 .collect()
3975 }
3976
3977 pub fn all_handlers_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
3983 self.packs
3984 .iter()
3985 .flat_map(|p| p.handlers().iter().map(move |v| (p.name(), v)))
3986 .collect()
3987 }
3988
3989 pub fn all_note_kinds(&self) -> Vec<&'static str> {
3992 let mut seen = std::collections::HashSet::new();
3993 self.packs
3994 .iter()
3995 .flat_map(|p| p.note_kinds().iter().copied())
3996 .filter(|k| seen.insert(*k))
3997 .collect()
3998 }
3999
4000 pub fn pack_owned_note_kinds(&self) -> Vec<&'static str> {
4012 let generic: std::collections::HashSet<&'static str> = self
4013 .packs
4014 .iter()
4015 .filter(|p| p.name() == GENERIC_CRUD_PACK)
4016 .flat_map(|p| p.note_kinds().iter().copied())
4017 .collect();
4018 let mut seen = std::collections::HashSet::new();
4019 self.packs
4020 .iter()
4021 .filter(|p| p.name() != GENERIC_CRUD_PACK)
4022 .flat_map(|p| p.note_kinds().iter().copied())
4023 .filter(|k| !generic.contains(k) && seen.insert(*k))
4024 .collect()
4025 }
4026
4027 pub fn all_entity_kinds(&self) -> Vec<&'static str> {
4030 let mut seen = std::collections::HashSet::new();
4031 self.packs
4032 .iter()
4033 .flat_map(|p| p.entity_kinds().iter().copied())
4034 .filter(|k| seen.insert(*k))
4035 .collect()
4036 }
4037
4038 pub fn all_brain_consumer_kinds(&self) -> Vec<&'static str> {
4041 let mut seen = std::collections::HashSet::new();
4042 self.packs
4043 .iter()
4044 .flat_map(|p| p.brain_consumer_kinds().iter().copied())
4045 .filter(|kind| seen.insert(*kind))
4046 .collect()
4047 }
4048
4049 pub fn pack_names(&self) -> Vec<&str> {
4051 self.packs.iter().map(|p| p.name()).collect()
4052 }
4053
4054 pub fn pack_host_state<T: Any + Send + Sync>(&self, name: &str) -> Option<Arc<T>> {
4057 self.packs
4058 .iter()
4059 .find(|pack| pack.name() == name)?
4060 .host_state()?
4061 .downcast::<T>()
4062 .ok()
4063 }
4064
4065 pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
4067 self.packs
4068 .iter()
4069 .find(|p| p.name() == name)
4070 .map(|p| p.requires())
4071 }
4072
4073 pub fn pack_note_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
4078 self.packs
4079 .iter()
4080 .find(|p| p.name() == name)
4081 .map(|p| p.note_kinds())
4082 }
4083
4084 pub fn pack_entity_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
4089 self.packs
4090 .iter()
4091 .find(|p| p.name() == name)
4092 .map(|p| p.entity_kinds())
4093 }
4094
4095 pub fn pack_verbs(&self, name: &str) -> Option<&'static [HandlerDef]> {
4100 self.packs
4101 .iter()
4102 .find(|p| p.name() == name)
4103 .map(|p| p.handlers())
4104 }
4105
4106 pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
4112 self.packs
4113 .iter()
4114 .flat_map(|p| p.edge_rules().iter().copied())
4115 .collect()
4116 }
4117
4118 pub fn all_entity_types(&self) -> Vec<EntityTypeDef> {
4125 self.packs
4126 .iter()
4127 .flat_map(|p| p.entity_types().iter().cloned())
4128 .collect()
4129 }
4130
4131 pub fn all_note_kind_specs(&self) -> Vec<&'static NoteKindSpec> {
4135 self.packs
4136 .iter()
4137 .flat_map(|p| p.note_kind_specs().iter())
4138 .collect()
4139 }
4140
4141 pub fn all_validation_rules(&self) -> Vec<&'static ValidationRule> {
4147 self.packs
4148 .iter()
4149 .flat_map(|p| p.validation_rules().iter())
4150 .collect()
4151 }
4152
4153 pub fn all_schema_plans(&self) -> Vec<SchemaPlan> {
4161 self.packs.iter().map(|p| p.schema_plan()).collect()
4162 }
4163
4164 pub fn all_schema_plans_with_columns(
4169 &self,
4170 ) -> Vec<(SchemaPlan, &'static [PackColumnAddition])> {
4171 self.packs
4172 .iter()
4173 .map(|pack| (pack.schema_plan(), pack.schema_column_additions()))
4174 .collect()
4175 }
4176
4177 pub fn call_register_embedders(&self, runtime: &KhiveRuntime) {
4187 for pack in self.packs.iter() {
4188 pack.register_embedders(runtime);
4189 }
4190 }
4191
4192 pub fn call_register_entity_type_validators(&self, runtime: &KhiveRuntime) {
4206 let entity_types = self.all_entity_types();
4207 for pack in self.packs.iter() {
4208 pack.register_entity_type_validator_with_types(runtime, &entity_types);
4209 }
4210 }
4211
4212 pub fn call_register_note_mutation_hooks(&self, runtime: &KhiveRuntime) {
4223 for pack in self.packs.iter() {
4224 pack.register_note_mutation_hook(runtime);
4225 }
4226 }
4227
4228 pub fn call_register_note_write_validators(&self, runtime: &KhiveRuntime) {
4236 for pack in self.packs.iter() {
4237 pack.register_note_write_validator(runtime);
4238 }
4239 }
4240
4241 pub async fn call_warm_all(&self) {
4245 for pack in self.packs.iter() {
4246 pack.warm().await;
4247 }
4248 }
4249
4250 pub fn presentation_policy_for(&self, verb: &str) -> khive_types::VerbPresentationPolicy {
4257 for pack in self.packs.iter() {
4258 if let Some(handler) = pack.handlers().iter().find(|h| h.name == verb) {
4259 return handler.presentation_policy();
4260 }
4261 }
4262 khive_types::VerbPresentationPolicy::Standard
4263 }
4264
4265 pub fn verb_category(&self, verb: &str) -> Option<VerbCategory> {
4273 self.packs
4274 .iter()
4275 .find_map(|pack| pack.handlers().iter().find(|h| h.name == verb))
4276 .map(|handler| handler.category)
4277 }
4278
4279 pub const SIDE_EFFECTING_ASSERTIVE_VERBS: &'static [&'static str] =
4298 &["memory.recall", "search", "telemetry.emit"];
4299
4300 pub fn is_retry_safe_after_frame_omission(&self, verb: &str) -> bool {
4310 matches!(self.verb_category(verb), Some(VerbCategory::Assertive))
4311 && !Self::SIDE_EFFECTING_ASSERTIVE_VERBS.contains(&verb)
4312 }
4313
4314 pub fn is_subhandler_verb(&self, verb: &str) -> bool {
4321 for pack in self.packs.iter() {
4322 if let Some(handler) = pack.handlers().iter().find(|h| h.name == verb) {
4323 return matches!(handler.visibility, Visibility::Subhandler);
4324 }
4325 }
4326 false
4327 }
4328
4329 pub fn apply_schema_plans(&self, backend: &khive_db::StorageBackend) {
4342 if backend.is_read_only() {
4343 tracing::info!(
4344 "skipping pack schema plans because the backend is read-only; snapshot schema is used as-is"
4345 );
4346 return;
4347 }
4348 for (plan, additions) in self.all_schema_plans_with_columns() {
4349 if plan.is_empty() && additions.is_empty() {
4350 continue;
4351 }
4352 if let Err(e) =
4353 backend.apply_pack_ddl_statements_with_columns(plan.statements, additions)
4354 {
4355 tracing::warn!(
4356 pack = plan.pack,
4357 error = %e,
4358 "failed to apply pack schema plan at startup (non-fatal)"
4359 );
4360 }
4361 }
4362 }
4363
4364 pub fn all_schema_plans_named(&self) -> Vec<(&'static str, SchemaPlan)> {
4372 self.packs
4373 .iter()
4374 .map(|p| {
4375 let plan = p.schema_plan();
4376 (plan.pack, plan)
4377 })
4378 .collect()
4379 }
4380
4381 pub fn apply_schema_plans_with_map(
4396 &self,
4397 backend_for_pack: &HashMap<&str, &khive_db::StorageBackend>,
4398 default_backend: &khive_db::StorageBackend,
4399 ) -> Result<(), crate::PackSchemaCollisionError> {
4400 let mut claimed: HashMap<(*const (), String), &'static str> = HashMap::new();
4403
4404 let plans = self.all_schema_plans_with_columns();
4405 for (plan, additions) in &plans {
4408 if plan.is_empty() && additions.is_empty() {
4409 continue;
4410 }
4411 let pack_name = plan.pack;
4412 let backend = backend_for_pack
4413 .get(pack_name)
4414 .copied()
4415 .unwrap_or(default_backend);
4416 let backend_ptr = std::sync::Arc::as_ptr(&backend.pool_arc()) as *const ();
4417
4418 for stmt in plan.statements {
4420 for table_name in extract_table_names(stmt) {
4421 let key = (backend_ptr, table_name.clone());
4422 match claimed.entry(key) {
4423 std::collections::hash_map::Entry::Vacant(e) => {
4424 e.insert(pack_name);
4425 }
4426 std::collections::hash_map::Entry::Occupied(e) => {
4427 let prior_pack = *e.get();
4428 return Err(crate::PackSchemaCollisionError {
4429 pack_a: prior_pack,
4430 pack_b: pack_name,
4431 table: table_name,
4432 });
4433 }
4434 }
4435 }
4436 }
4437 for addition in *additions {
4438 let table_name = addition.table.to_ascii_lowercase();
4439 let key = (backend_ptr, table_name.clone());
4440 match claimed.entry(key) {
4441 std::collections::hash_map::Entry::Vacant(entry) => {
4442 entry.insert(pack_name);
4443 }
4444 std::collections::hash_map::Entry::Occupied(entry) => {
4445 let prior_pack = *entry.get();
4446 if prior_pack != pack_name {
4449 return Err(crate::PackSchemaCollisionError {
4450 pack_a: prior_pack,
4451 pack_b: pack_name,
4452 table: table_name,
4453 });
4454 }
4455 }
4456 }
4457 }
4458 }
4459
4460 for (plan, additions) in plans {
4461 if plan.is_empty() && additions.is_empty() {
4462 continue;
4463 }
4464 let pack_name = plan.pack;
4465 let backend = backend_for_pack
4466 .get(pack_name)
4467 .copied()
4468 .unwrap_or(default_backend);
4469 if backend.is_read_only() {
4470 backend.validate_pack_schema_columns(additions).map_err(|error| {
4471 crate::PackSchemaCollisionError {
4472 pack_a: pack_name,
4473 pack_b: pack_name,
4474 table: format!("read-only schema validation failed: {error}; open the database writable to apply the pack schema upgrade"),
4475 }
4476 })?;
4477 continue;
4478 }
4479
4480 backend
4481 .apply_pack_ddl_statements_with_columns(plan.statements, additions)
4482 .map_err(|e| crate::PackSchemaCollisionError {
4483 pack_a: pack_name,
4484 pack_b: pack_name,
4485 table: format!("DDL error: {e}"),
4486 })?;
4487 }
4488 Ok(())
4489 }
4490}
4491
4492pub struct PackInstall {
4500 pub runtime: Box<dyn PackRuntime>,
4502 pub resolver: Option<Box<dyn PackByIdResolver>>,
4504 pub dispatch_hook: Option<Arc<dyn DispatchHook>>,
4506}
4507
4508pub struct ChannelIngestCapability {
4536 pub(crate) _sealed: (),
4537}
4538
4539impl ChannelIngestCapability {
4540 pub fn grant_for_direct_composition() -> Self {
4548 Self { _sealed: () }
4549 }
4550}
4551
4552pub(crate) const CHANNEL_INGEST_CAPABLE_PACKS: &[&str] = &["comm"];
4554
4555pub trait PackFactory: Send + Sync + 'static {
4556 fn name(&self) -> &'static str;
4558
4559 fn requires(&self) -> &'static [&'static str] {
4566 &[]
4567 }
4568
4569 fn intentionally_verbless(&self) -> bool {
4576 false
4577 }
4578
4579 fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime>;
4581
4582 fn create_install(&self, runtime: KhiveRuntime) -> PackInstall {
4591 let resolver = self.create_resolver(runtime.clone());
4592 PackInstall {
4593 runtime: self.create(runtime),
4594 resolver,
4595 dispatch_hook: None,
4596 }
4597 }
4598
4599 fn create_resolver(&self, _runtime: KhiveRuntime) -> Option<Box<dyn PackByIdResolver>> {
4605 None
4606 }
4607}
4608
4609pub struct PackRegistration(pub &'static dyn PackFactory);
4613
4614inventory::collect!(PackRegistration);
4615
4616#[derive(Debug)]
4618pub enum PackLoadError {
4619 UnknownPack(String),
4621 MissingDependency {
4623 pack: String,
4625 dep: String,
4627 },
4628 NoPublicVerbs {
4631 pack: String,
4633 },
4634}
4635
4636impl std::fmt::Display for PackLoadError {
4637 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4638 match self {
4639 PackLoadError::UnknownPack(name) => write!(f, "unknown pack {name:?}"),
4640 PackLoadError::MissingDependency { pack, dep } => write!(
4641 f,
4642 "pack {pack:?} requires {dep:?}, which is not in the requested pack list; \
4643 add --pack {dep} before --pack {pack}"
4644 ),
4645 PackLoadError::NoPublicVerbs { pack } => write!(
4646 f,
4647 "declared pack {pack:?} registers no public verbs; if this pack is \
4648 intentionally vocabulary- or ontology-only, its factory must declare \
4649 intentionally_verbless() = true"
4650 ),
4651 }
4652 }
4653}
4654
4655impl std::error::Error for PackLoadError {}
4656
4657fn check_pack_has_public_verbs(
4661 factory: &dyn PackFactory,
4662 install: &PackInstall,
4663 name: &str,
4664) -> Result<(), PackLoadError> {
4665 if !factory.intentionally_verbless()
4666 && !install
4667 .runtime
4668 .handlers()
4669 .iter()
4670 .any(|handler| matches!(handler.visibility, Visibility::Verb))
4671 {
4672 return Err(PackLoadError::NoPublicVerbs {
4673 pack: name.to_string(),
4674 });
4675 }
4676 Ok(())
4677}
4678
4679pub struct PackRegistry;
4684
4685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4688pub enum IngestAuditStore {
4689 Attach,
4694 Detach,
4697}
4698
4699impl PackRegistry {
4700 pub fn discovered_names() -> Vec<&'static str> {
4702 inventory::iter::<PackRegistration>
4703 .into_iter()
4704 .map(|r| r.0.name())
4705 .collect()
4706 }
4707
4708 pub fn register_packs(
4721 names: &[String],
4722 runtime: KhiveRuntime,
4723 builder: &mut VerbRegistryBuilder,
4724 ) -> Result<(), PackLoadError> {
4725 let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
4727 .into_iter()
4728 .map(|r| r.0)
4729 .collect();
4730 let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
4731 all.iter().copied().find(|f| f.name() == name)
4732 };
4733
4734 let requested: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
4736 for name in names {
4737 factory_for(name.as_str()).ok_or_else(|| PackLoadError::UnknownPack(name.clone()))?;
4738 }
4739
4740 for name in names {
4743 let factory = factory_for(name.as_str()).unwrap(); for &dep in factory.requires() {
4745 if !requested.contains(dep) {
4746 return Err(PackLoadError::MissingDependency {
4747 pack: name.clone(),
4748 dep: dep.to_string(),
4749 });
4750 }
4751 }
4752 }
4753
4754 for name in names {
4757 let factory = factory_for(name.as_str()).unwrap(); let install = factory.create_install(runtime.clone());
4759 check_pack_has_public_verbs(factory, &install, name)?;
4760 if CHANNEL_INGEST_CAPABLE_PACKS.contains(&name.as_str()) {
4761 install
4762 .runtime
4763 .accept_channel_ingest_capability(ChannelIngestCapability { _sealed: () });
4764 }
4765 builder.register_boxed(install.runtime);
4766 if let Some(resolver) = install.resolver {
4767 builder.register_resolver(name.clone(), resolver);
4768 }
4769 if let Some(hook) = install.dispatch_hook {
4770 builder.with_dispatch_hook(hook);
4771 }
4772 }
4773
4774 Ok(())
4775 }
4776
4777 pub fn build_ingest_registry(
4795 runtime: &KhiveRuntime,
4796 audit_store: IngestAuditStore,
4797 ) -> Result<VerbRegistry, RuntimeError> {
4798 let mut builder = VerbRegistryBuilder::new();
4799 builder.with_gate(runtime.config().gate.clone());
4800 builder.with_default_namespace(runtime.config().default_namespace.as_str());
4801 builder.with_visible_namespaces(runtime.config().visible_namespaces.clone());
4802 builder.with_actor_id(runtime.config().actor_id.clone());
4803 if audit_store == IngestAuditStore::Attach {
4804 if runtime.is_read_only() {
4805 builder.with_read_only_audit_store();
4806 } else {
4807 builder.with_runtime_event_store(runtime)?;
4809 }
4810 }
4811 Self::register_packs(
4812 &runtime.config().packs.clone(),
4813 runtime.clone(),
4814 &mut builder,
4815 )
4816 .map_err(|e| RuntimeError::Internal(format!("pack registration failed: {e:?}")))?;
4817 let registry = builder.build()?;
4818 runtime.install_edge_rules(registry.all_edge_rules());
4819 Ok(registry)
4820 }
4821
4822 pub fn register_packs_with_runtimes(
4832 names: &[String],
4833 runtimes: &HashMap<String, KhiveRuntime>,
4834 default_runtime: &KhiveRuntime,
4835 builder: &mut VerbRegistryBuilder,
4836 ) -> Result<(), PackLoadError> {
4837 let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
4838 .into_iter()
4839 .map(|r| r.0)
4840 .collect();
4841 Self::register_packs_with_runtimes_from(&all, names, runtimes, default_runtime, builder)
4842 }
4843
4844 pub fn register_packs_with_runtimes_with_extra_factories(
4864 extra_factories: &[&'static dyn PackFactory],
4865 names: &[String],
4866 runtimes: &HashMap<String, KhiveRuntime>,
4867 default_runtime: &KhiveRuntime,
4868 builder: &mut VerbRegistryBuilder,
4869 ) -> Result<(), PackLoadError> {
4870 let mut all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
4871 .into_iter()
4872 .map(|r| r.0)
4873 .collect();
4874 all.extend(extra_factories.iter().copied());
4875 Self::register_packs_with_runtimes_from(&all, names, runtimes, default_runtime, builder)
4876 }
4877
4878 fn register_packs_with_runtimes_from(
4885 factories: &[&'static dyn PackFactory],
4886 names: &[String],
4887 runtimes: &HashMap<String, KhiveRuntime>,
4888 default_runtime: &KhiveRuntime,
4889 builder: &mut VerbRegistryBuilder,
4890 ) -> Result<(), PackLoadError> {
4891 let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
4892 factories.iter().copied().find(|f| f.name() == name)
4893 };
4894
4895 let requested: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
4896 for name in names {
4897 factory_for(name.as_str()).ok_or_else(|| PackLoadError::UnknownPack(name.clone()))?;
4898 }
4899
4900 for name in names {
4901 let factory = factory_for(name.as_str()).unwrap();
4902 for &dep in factory.requires() {
4903 if !requested.contains(dep) {
4904 return Err(PackLoadError::MissingDependency {
4905 pack: name.clone(),
4906 dep: dep.to_string(),
4907 });
4908 }
4909 }
4910 }
4911
4912 builder.kg_read_resolver = Some(Arc::new(crate::kg_read::KgReadResolver::new(
4913 default_runtime,
4914 runtimes,
4915 )));
4916
4917 for name in names {
4918 let factory = factory_for(name.as_str()).unwrap();
4919 let runtime = runtimes
4920 .get(name.as_str())
4921 .cloned()
4922 .unwrap_or_else(|| default_runtime.clone());
4923 let install = factory.create_install(runtime);
4924 check_pack_has_public_verbs(factory, &install, name)?;
4925 if CHANNEL_INGEST_CAPABLE_PACKS.contains(&name.as_str()) {
4926 install
4927 .runtime
4928 .accept_channel_ingest_capability(ChannelIngestCapability { _sealed: () });
4929 }
4930 builder.register_boxed(install.runtime);
4931 if let Some(resolver) = install.resolver {
4932 builder.register_resolver(name.clone(), resolver);
4933 }
4934 if let Some(hook) = install.dispatch_hook {
4935 builder.with_dispatch_hook(hook);
4936 }
4937 }
4938
4939 Ok(())
4940 }
4941}
4942
4943fn target_id_from_args(args: &serde_json::Value) -> Option<uuid::Uuid> {
4944 args.get("target_id")
4945 .and_then(serde_json::Value::as_str)
4946 .and_then(|s| s.parse::<uuid::Uuid>().ok())
4947}
4948
4949fn masked_audit_event(
4967 gate_req: &GateRequest,
4968 decision: &GateDecision,
4969 gate_impl: &str,
4970) -> AuditEvent {
4971 let mut audit = AuditEvent::from_check(gate_req, decision, gate_impl)
4972 .with_operation_attribution(
4973 khive_storage::operation_context::current_operation_attribution(),
4974 );
4975 if let Some(reason) = audit.deny_reason.take() {
4976 audit.deny_reason = Some(crate::secret_gate::bounded_masked_log_text(&reason));
4977 }
4978 audit
4979}
4980
4981fn build_audit_storage_event(
4984 gate_req: &GateRequest,
4985 audit: &AuditEvent,
4986 outcome: EventOutcome,
4987 resource: Option<Value>,
4988) -> Event {
4989 let mut audit_data = serde_json::to_value(audit).unwrap_or_else(|e| {
4990 tracing::warn!(error = %e, "failed to serialize AuditEvent for EventStore");
4991 serde_json::Value::Null
4992 });
4993 if let Some(resource) = resource {
4994 if let Value::Object(ref mut map) = audit_data {
4995 map.insert("resource".to_string(), resource);
4996 }
4997 }
4998 let mut storage_event = Event::new(
4999 gate_req.namespace.as_str(),
5000 gate_req.verb.as_str(),
5001 EventKind::Audit,
5002 SubstrateKind::Event,
5003 format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
5004 )
5005 .with_outcome(outcome)
5006 .with_payload(audit_data);
5007 storage_event.op_index = audit.op_index;
5008 storage_event.ref_resolution = audit.ref_resolution;
5009 if let Some(target_id) = target_id_from_args(&gate_req.args) {
5010 storage_event = storage_event.with_target(target_id);
5011 }
5012 storage_event
5013}
5014
5015static AUDIT_APPEND_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
5023
5024pub(crate) fn audit_append_failure_count() -> u64 {
5025 AUDIT_APPEND_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
5026}
5027
5028static AUDIT_OBLIGATION_APPEND_FAILURES: std::sync::atomic::AtomicU64 =
5040 std::sync::atomic::AtomicU64::new(0);
5041
5042pub(crate) fn audit_obligation_append_failure_count() -> u64 {
5045 AUDIT_OBLIGATION_APPEND_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
5046}
5047
5048static AUDIT_ADMISSION_REFUSED_OBLIGATIONS: std::sync::atomic::AtomicU64 =
5084 std::sync::atomic::AtomicU64::new(0);
5085
5086static AUDIT_ADMISSION_REFUSED_OBLIGATIONS_LAST_MS: std::sync::atomic::AtomicU64 =
5091 std::sync::atomic::AtomicU64::new(0);
5092
5093pub fn audit_admission_refused_obligation_count() -> u64 {
5094 AUDIT_ADMISSION_REFUSED_OBLIGATIONS.load(std::sync::atomic::Ordering::Relaxed)
5095}
5096
5097pub fn audit_admission_refused_obligation_last_at_ms() -> Option<u64> {
5099 match AUDIT_ADMISSION_REFUSED_OBLIGATIONS_LAST_MS.load(std::sync::atomic::Ordering::Relaxed) {
5100 0 => None,
5101 at => Some(at),
5102 }
5103}
5104
5105static AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS: std::sync::atomic::AtomicU64 =
5132 std::sync::atomic::AtomicU64::new(0);
5133
5134static AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS_LAST_MS: std::sync::atomic::AtomicU64 =
5137 std::sync::atomic::AtomicU64::new(0);
5138
5139pub fn audit_admission_unresolved_obligation_count() -> u64 {
5140 AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS.load(std::sync::atomic::Ordering::Relaxed)
5141}
5142
5143pub fn audit_admission_unresolved_obligation_last_at_ms() -> Option<u64> {
5145 match AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS_LAST_MS.load(std::sync::atomic::Ordering::Relaxed)
5146 {
5147 0 => None,
5148 at => Some(at),
5149 }
5150}
5151
5152fn mark_admission_obligation_counter(mark: &std::sync::atomic::AtomicU64) {
5158 let now = chrono::Utc::now().timestamp_millis();
5159 let now = u64::try_from(now).unwrap_or(1).max(1);
5160 mark.store(now, std::sync::atomic::Ordering::Relaxed);
5161}
5162
5163const GIT_DIGEST_RECEIPT_FAILURE: &str =
5164 "git_digest_receipt_persist_failed: git.digest writes may have committed, but no durable \
5165 success receipt was confirmed; inspect ingest state before retrying";
5166
5167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5170enum GitDigestReceiptOutcome {
5171 Persisted,
5173 BuildRejected,
5177 PersistenceUnavailable,
5181}
5182
5183fn fail_git_digest_receipt(
5184 result: &mut Result<Value, RuntimeError>,
5185 failure: AuditObligationFailure,
5186) {
5187 let Ok(value) = result else {
5188 return;
5189 };
5190 let domain_result = std::mem::take(value);
5191 *result = Err(RuntimeError::AuditObligation {
5192 failure: Box::new(failure),
5193 domain_result,
5194 });
5195}
5196
5197async fn persist_git_digest_receipt(
5208 store: Option<&Arc<dyn EventStore>>,
5209 audit_batch: Option<&Arc<crate::audit_batch::AuditBatch>>,
5210 gate_req: &GateRequest,
5211 audit: Option<&AuditEvent>,
5212 result: &mut Result<Value, RuntimeError>,
5213 duration_us: i64,
5214 resource: Option<Value>,
5215) -> GitDigestReceiptOutcome {
5216 let Ok(report) = result else {
5217 return GitDigestReceiptOutcome::PersistenceUnavailable;
5218 };
5219 let Some(store) = store else {
5220 tracing::error!(
5221 verb = "git.digest",
5222 "durable receipt store is not configured"
5223 );
5224 fail_git_digest_receipt(
5225 result,
5226 AuditObligationFailure::git_digest_receipt("event store is not configured"),
5227 );
5228 return GitDigestReceiptOutcome::PersistenceUnavailable;
5229 };
5230 let Some(audit) = audit else {
5231 tracing::error!(
5232 verb = "git.digest",
5233 "durable receipt cannot be built because the gate produced no audit decision"
5234 );
5235 fail_git_digest_receipt(
5236 result,
5237 AuditObligationFailure::git_digest_receipt("gate audit decision is absent"),
5238 );
5239 return GitDigestReceiptOutcome::PersistenceUnavailable;
5240 };
5241
5242 let Some(report_object) = report.as_object_mut() else {
5243 tracing::error!(
5244 verb = "git.digest",
5245 "digest handler returned a non-object report"
5246 );
5247 fail_git_digest_receipt(
5248 result,
5249 AuditObligationFailure::git_digest_receipt("handler report is not an object"),
5250 );
5251 return GitDigestReceiptOutcome::BuildRejected;
5252 };
5253 let Some(project_id) = report_object
5254 .get("project_id")
5255 .and_then(Value::as_str)
5256 .and_then(|raw| raw.parse::<uuid::Uuid>().ok())
5257 else {
5258 tracing::error!(
5259 verb = "git.digest",
5260 "digest handler report omitted a valid project_id"
5261 );
5262 fail_git_digest_receipt(
5263 result,
5264 AuditObligationFailure::git_digest_receipt("handler report has no valid project_id"),
5265 );
5266 return GitDigestReceiptOutcome::BuildRejected;
5267 };
5268
5269 let mut event = Event::new(
5272 gate_req.namespace.as_str(),
5273 gate_req.verb.as_str(),
5274 EventKind::Audit,
5275 SubstrateKind::Event,
5276 format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
5277 )
5278 .with_outcome(EventOutcome::Success)
5279 .with_target(project_id)
5280 .with_payload_schema_version(2)
5281 .with_duration_us(duration_us);
5282 let receipt_id = event.id;
5283 report_object.insert(
5284 "receipt_id".to_string(),
5285 Value::String(receipt_id.to_string()),
5286 );
5287
5288 let mut payload = serde_json::to_value(audit).unwrap_or_else(|serialize_err| {
5289 tracing::error!(
5290 verb = "git.digest",
5291 error = %serialize_err,
5292 "failed to serialize gate audit for durable digest receipt"
5293 );
5294 Value::Null
5295 });
5296 let Value::Object(payload_object) = &mut payload else {
5297 tracing::error!(
5298 verb = "git.digest",
5299 "gate audit serialization did not produce an object"
5300 );
5301 fail_git_digest_receipt(
5302 result,
5303 AuditObligationFailure::git_digest_receipt("gate audit payload is not an object"),
5304 );
5305 return GitDigestReceiptOutcome::BuildRejected;
5306 };
5307 if let Some(resource) = resource {
5308 payload_object.insert("resource".to_string(), resource);
5309 }
5310 payload_object.insert("result".to_string(), report.clone());
5311 event.payload = payload;
5312
5313 let submit_result = if let Some(audit_batch) = audit_batch {
5320 audit_batch
5321 .submit_until_resolved(crate::audit_batch::PreparedAuditRow {
5322 event,
5323 producer: crate::audit_batch::AuditProducer::GitDigestReceipt,
5324 })
5325 .await
5326 .map(|_outcome| ())
5327 .map_err(|reason| AuditObligationFailure::new("git.digest", reason))
5328 } else {
5329 store
5330 .append_event(event)
5331 .await
5332 .map_err(|error| AuditObligationFailure::from_store("git.digest", error))
5333 };
5334 if let Err(mut failure) = submit_result {
5335 AUDIT_OBLIGATION_APPEND_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5340 tracing::error!(
5341 verb = "git.digest",
5342 error = %failure,
5343 receipt_id = %receipt_id,
5344 "durable digest receipt append failed"
5345 );
5346 failure.message = format!(
5347 "{GIT_DIGEST_RECEIPT_FAILURE}; audit submission failed ({})",
5348 failure.wire_code()
5349 );
5350 fail_git_digest_receipt(result, failure);
5351 return GitDigestReceiptOutcome::PersistenceUnavailable;
5352 }
5353 GitDigestReceiptOutcome::Persisted
5354}
5355
5356async fn append_audit_event_best_effort(
5407 audit_batch: Option<&Arc<crate::audit_batch::AuditBatch>>,
5408 store: &Arc<dyn EventStore>,
5409 event: Event,
5410 verb: &str,
5411 producer: crate::audit_batch::AuditProducer,
5412 degrade_allowlisted: bool,
5413) -> Result<(), AuditObligationFailure> {
5414 use crate::audit_batch::{
5415 classify, AuditBatchControl, AuditProducer, AuditProductionClass, AuditTerminalReason,
5416 };
5417
5418 let is_obligation = classify(producer) == AuditProductionClass::DispatchObligation;
5419 let admission_degrade_eligible =
5420 degrade_allowlisted && producer == AuditProducer::DispatchSucceeded;
5421 let enqueued_row_outlives_deadline = producer == AuditProducer::DispatchSucceeded;
5428
5429 if let Some(audit_batch) = audit_batch {
5430 let row = crate::audit_batch::PreparedAuditRow { event, producer };
5431 let submit_result =
5439 if producer == AuditProducer::DispatchSucceeded && !admission_degrade_eligible {
5440 audit_batch.submit_until_resolved(row).await
5441 } else {
5442 audit_batch.submit(row).await
5443 };
5444 if let Err(reason) = submit_result {
5445 if is_obligation {
5446 if enqueued_row_outlives_deadline
5462 && reason == AuditTerminalReason::AdmissionDeadlineExpired
5463 {
5464 AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS
5465 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5466 mark_admission_obligation_counter(
5467 &AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS_LAST_MS,
5468 );
5469 tracing::warn!(
5470 verb,
5471 reason = ?reason,
5472 degrade_allowlisted,
5473 "audit obligation row was still enqueued and unresolved when \
5474 the caller's admission wait deadline elapsed; its generation \
5475 commits it independently of this response. Dispatch reports \
5476 its own committed result (non-fatal)"
5477 );
5478 return Ok(());
5479 }
5480 if admission_degrade_eligible
5481 && reason == AuditTerminalReason::QueueAdmissionExhausted
5482 {
5483 AUDIT_ADMISSION_REFUSED_OBLIGATIONS
5484 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5485 mark_admission_obligation_counter(&AUDIT_ADMISSION_REFUSED_OBLIGATIONS_LAST_MS);
5486 tracing::warn!(
5487 verb,
5488 reason = ?reason,
5489 "read verb's audit obligation row was refused before \
5490 enqueue under audit-lane admission pressure; dispatch \
5491 still reports its own result (non-fatal)"
5492 );
5493 return Ok(());
5494 }
5495 AUDIT_OBLIGATION_APPEND_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5496 tracing::error!(
5497 verb,
5498 reason = ?reason,
5499 "audit obligation batch submission failed; failing dispatch"
5500 );
5501 return Err(AuditObligationFailure::new(verb, reason));
5502 }
5503 AUDIT_APPEND_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5504 tracing::warn!(
5505 verb,
5506 reason = ?reason,
5507 "audit event batch submission failed (non-fatal)"
5508 );
5509 }
5510 return Ok(());
5511 }
5512
5513 if let Err(store_err) = store.append_event(event).await {
5514 if is_obligation {
5515 AUDIT_OBLIGATION_APPEND_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5516 tracing::error!(
5517 verb,
5518 error = %store_err,
5519 "audit obligation store write failed; failing dispatch"
5520 );
5521 return Err(AuditObligationFailure::from_store(verb, store_err));
5522 }
5523 AUDIT_APPEND_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5524 tracing::warn!(
5525 verb,
5526 error = %store_err,
5527 "audit event store write failed (non-fatal)"
5528 );
5529 }
5530 Ok(())
5531}
5532
5533fn fold_audit_obligation<T>(
5541 result: Result<T, RuntimeError>,
5542 audit_outcome: Result<(), AuditObligationFailure>,
5543 domain_value: impl FnOnce(T) -> Value,
5544) -> Result<T, RuntimeError> {
5545 match (result, audit_outcome) {
5546 (Ok(value), Ok(())) => Ok(value),
5547 (Ok(value), Err(failure)) => Err(RuntimeError::AuditObligation {
5548 failure: Box::new(failure),
5549 domain_result: domain_value(value),
5550 }),
5551 (Err(err), _) => Err(err),
5552 }
5553}
5554
5555#[derive(Debug, Clone, serde::Serialize)]
5558struct LinkAuditSuccessV2 {
5559 #[serde(flatten)]
5560 audit: AuditEvent,
5561 edge_id: uuid::Uuid,
5562 source_id: uuid::Uuid,
5563 target_id: uuid::Uuid,
5564 relation: String,
5565 weight: f64,
5566}
5567
5568fn link_audit_success_from_result(
5572 audit: AuditEvent,
5573 result: &serde_json::Value,
5574) -> Option<(uuid::Uuid, serde_json::Value)> {
5575 let edge_id = result.get("id")?.as_str()?.parse::<uuid::Uuid>().ok()?;
5576 let source_id = result
5577 .get("source_id")?
5578 .as_str()?
5579 .parse::<uuid::Uuid>()
5580 .ok()?;
5581 let target_id = result
5582 .get("target_id")?
5583 .as_str()?
5584 .parse::<uuid::Uuid>()
5585 .ok()?;
5586 let relation = result.get("relation")?.as_str()?.to_string();
5587 let weight = result.get("weight")?.as_f64()?;
5588 let enriched = LinkAuditSuccessV2 {
5589 audit,
5590 edge_id,
5591 source_id,
5592 target_id,
5593 relation,
5594 weight,
5595 };
5596 let payload = serde_json::to_value(&enriched).ok()?;
5597 Some((edge_id, payload))
5598}
5599
5600pub fn resolve_explicit_namespace(
5613 params: &Value,
5614 default_namespace: &str,
5615) -> Result<Namespace, RuntimeError> {
5616 match params.get("namespace") {
5617 None => Namespace::parse(default_namespace)
5618 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}"))),
5619 Some(Value::String(ns_str)) => Namespace::parse(ns_str)
5620 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace {ns_str:?}: {e}"))),
5621 Some(other) => Err(RuntimeError::InvalidInput(format!(
5622 "invalid namespace: expected string when present, got {}",
5623 json_type_name(other),
5624 ))),
5625 }
5626}
5627
5628pub fn json_type_name(v: &Value) -> &'static str {
5631 match v {
5632 Value::Null => "null",
5633 Value::Bool(_) => "boolean",
5634 Value::Number(_) => "number",
5635 Value::String(_) => "string",
5636 Value::Array(_) => "array",
5637 Value::Object(_) => "object",
5638 }
5639}
5640
5641#[cfg(test)]
5647pub(crate) mod tests {
5648 use super::*;
5649 use crate::ActorRef;
5650 use khive_types::Pack;
5651
5652 mod disposition {
5653 include!("pack_disposition_tests.rs");
5654 }
5655
5656 #[tokio::test]
5657 async fn pack_host_state_shares_the_registered_dispatch_instance_across_clones() {
5658 struct HostStatePack(Arc<AtomicUsize>);
5659
5660 #[async_trait]
5661 impl PackRuntime for HostStatePack {
5662 fn name(&self) -> &str {
5663 "host_state"
5664 }
5665 fn host_state(&self) -> Option<Arc<dyn Any + Send + Sync>> {
5666 Some(self.0.clone())
5667 }
5668 fn note_kinds(&self) -> &'static [&'static str] {
5669 &[]
5670 }
5671 fn entity_kinds(&self) -> &'static [&'static str] {
5672 &[]
5673 }
5674 fn handlers(&self) -> &'static [HandlerDef] {
5675 &[HandlerDef {
5676 name: "host_state.touch",
5677 description: "shared state fixture",
5678 visibility: Visibility::Verb,
5679 category: VerbCategory::Commissive,
5680 params: &[],
5681 }]
5682 }
5683 async fn dispatch(
5684 &self,
5685 _verb: &str,
5686 _params: Value,
5687 _registry: &VerbRegistry,
5688 _token: &NamespaceToken,
5689 ) -> Result<Value, RuntimeError> {
5690 self.0.fetch_add(1, Ordering::SeqCst);
5691 Ok(Value::Null)
5692 }
5693 }
5694
5695 let state = Arc::new(AtomicUsize::new(0));
5696 let mut builder = VerbRegistryBuilder::new();
5697 builder.register_boxed(Box::new(HostStatePack(state.clone())));
5698 builder.register(AlphaPack);
5699 let registry = builder.build().expect("registry");
5700 let host = registry
5701 .pack_host_state::<AtomicUsize>("host_state")
5702 .expect("registered state");
5703 let cloned_host = registry
5704 .clone()
5705 .pack_host_state::<AtomicUsize>("host_state")
5706 .expect("cloned registry state");
5707 assert!(Arc::ptr_eq(&state, &host));
5708 assert!(Arc::ptr_eq(&host, &cloned_host));
5709 registry
5710 .dispatch("host_state.touch", serde_json::json!({}))
5711 .await
5712 .expect("dispatch");
5713 assert_eq!(host.load(Ordering::SeqCst), 1);
5714 assert!(registry.pack_host_state::<String>("host_state").is_none());
5715 assert!(registry.pack_host_state::<AtomicUsize>("missing").is_none());
5716 assert!(registry.pack_host_state::<AtomicUsize>("alpha").is_none());
5717 }
5718
5719 const KNOWN_ADMISSION_UNSAFE_VERBS: &[&str] = &[
5730 "db_diagnostics",
5731 "git.checkout",
5732 "git.diff",
5733 "git.reconcile",
5734 "knowledge.compose",
5735 "knowledge.search",
5736 "knowledge.suggest",
5737 "memory.recall",
5738 "telemetry.channels",
5739 "telemetry.counts",
5740 "telemetry.emit",
5741 "telemetry.read",
5742 ];
5743
5744 #[derive(Debug, Clone, PartialEq, Eq)]
5752 enum HandlerDefOccurrence {
5753 Signature,
5757 StructLiteral {
5760 name: String,
5761 visibility: String,
5762 category: String,
5763 },
5764 Unclassified { first_field_line: String },
5768 }
5769
5770 fn classify_handler_def_occurrence(
5779 source: &str,
5780 match_start: usize,
5781 match_end: usize,
5782 ) -> HandlerDefOccurrence {
5783 let line_start = source[..match_start]
5786 .rfind('\n')
5787 .map(|i| i + 1)
5788 .unwrap_or(0);
5789 if source[line_start..match_start].contains("->") {
5790 return HandlerDefOccurrence::Signature;
5791 }
5792
5793 let next_marker = source[match_end..].find("HandlerDef {");
5794 let block_end = next_marker.map(|o| match_end + o).unwrap_or(source.len());
5795 let block = &source[match_end..block_end];
5796
5797 let Some(first_field_line) = block.lines().find(|line| !line.trim().is_empty()) else {
5805 return HandlerDefOccurrence::Unclassified {
5806 first_field_line: String::new(),
5807 };
5808 };
5809 let indent_len = first_field_line.len() - first_field_line.trim_start().len();
5810 let indent = &first_field_line[..indent_len];
5811 let name_prefix = format!("{indent}name: \"");
5812 let visibility_prefix = format!("{indent}visibility: ");
5813 let category_prefix = format!("{indent}category: ");
5814
5815 let name = block.lines().find_map(|line| {
5816 line.strip_prefix(name_prefix.as_str())
5817 .and_then(|rest| rest.strip_suffix("\","))
5818 });
5819 let visibility = block
5820 .lines()
5821 .find_map(|line| line.strip_prefix(visibility_prefix.as_str()));
5822 let category = block
5823 .lines()
5824 .find_map(|line| line.strip_prefix(category_prefix.as_str()));
5825
5826 match (name, visibility, category) {
5827 (Some(name), Some(visibility), Some(category)) => HandlerDefOccurrence::StructLiteral {
5828 name: name.to_string(),
5829 visibility: visibility.to_string(),
5830 category: category.to_string(),
5831 },
5832 _ => HandlerDefOccurrence::Unclassified {
5833 first_field_line: first_field_line.to_string(),
5834 },
5835 }
5836 }
5837
5838 #[test]
5853 fn classify_handler_def_occurrence_reports_unclassifiable_shapes() {
5854 let signature_snippet = "fn describe() -> &'static HandlerDef {\n HANDLER\n}\n";
5855 let match_start = signature_snippet.find("HandlerDef {").unwrap();
5856 let match_end = match_start + "HandlerDef {".len();
5857 assert_eq!(
5858 classify_handler_def_occurrence(signature_snippet, match_start, match_end),
5859 HandlerDefOccurrence::Signature
5860 );
5861
5862 let struct_literal_snippet = " HandlerDef {\n name: \"probe\",\n visibility: Visibility::Verb,\n category: VerbCategory::Assertive,\n }\n";
5863 let match_start = struct_literal_snippet.find("HandlerDef {").unwrap();
5864 let match_end = match_start + "HandlerDef {".len();
5865 assert_eq!(
5866 classify_handler_def_occurrence(struct_literal_snippet, match_start, match_end),
5867 HandlerDefOccurrence::StructLiteral {
5868 name: "probe".to_string(),
5869 visibility: "Visibility::Verb,".to_string(),
5870 category: "VerbCategory::Assertive,".to_string(),
5871 }
5872 );
5873
5874 let unclassifiable_snippet = " HandlerDef {\n name: \"probe\",\n visibility: Visibility::Verb,\n }\n";
5877 let match_start = unclassifiable_snippet.find("HandlerDef {").unwrap();
5878 let match_end = match_start + "HandlerDef {".len();
5879 assert!(
5880 matches!(
5881 classify_handler_def_occurrence(unclassifiable_snippet, match_start, match_end),
5882 HandlerDefOccurrence::Unclassified { .. }
5883 ),
5884 "a HandlerDef block missing an expected field must classify as Unclassified, not \
5885 silently fall through as a recognized shape"
5886 );
5887 }
5888
5889 #[test]
5927 fn admission_degrade_safe_assertive_census_matches_live_pack_sources() {
5928 use std::collections::{BTreeMap, BTreeSet};
5929 use std::path::{Path, PathBuf};
5930
5931 fn collect_rust_sources(dir: &Path, sources: &mut Vec<PathBuf>) {
5932 let entries = std::fs::read_dir(dir).unwrap_or_else(|e| {
5933 panic!("failed to read source directory {}: {e}", dir.display())
5934 });
5935 for entry in entries {
5936 let entry = entry.unwrap_or_else(|e| {
5937 panic!("failed to read entry under {}: {e}", dir.display())
5938 });
5939 let path = entry.path();
5940 let file_type = entry.file_type().unwrap_or_else(|e| {
5941 panic!("failed to stat source entry {}: {e}", path.display())
5942 });
5943 if file_type.is_dir() {
5944 collect_rust_sources(&path, sources);
5945 } else if path.extension().is_some_and(|extension| extension == "rs") {
5946 sources.push(path);
5947 }
5948 }
5949 }
5950
5951 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5952 let crates_dir = manifest_dir
5953 .parent()
5954 .expect("khive-runtime manifest must live under the workspace crates directory");
5955 let mut handler_sources = Vec::new();
5956 let crate_entries = std::fs::read_dir(crates_dir).unwrap_or_else(|e| {
5957 panic!(
5958 "failed to enumerate pack crates under {}: {e}",
5959 crates_dir.display()
5960 )
5961 });
5962 for entry in crate_entries {
5963 let entry = entry.unwrap_or_else(|e| {
5964 panic!("failed to read entry under {}: {e}", crates_dir.display())
5965 });
5966 if !entry
5967 .file_type()
5968 .unwrap_or_else(|e| {
5969 panic!("failed to stat crate entry {}: {e}", entry.path().display())
5970 })
5971 .is_dir()
5972 || !entry
5973 .file_name()
5974 .to_string_lossy()
5975 .starts_with("khive-pack-")
5976 {
5977 continue;
5978 }
5979 collect_rust_sources(&entry.path().join("src"), &mut handler_sources);
5980 }
5981 handler_sources.sort_unstable();
5982 assert!(
5983 !handler_sources.is_empty(),
5984 "cross-pack Assertive census found no pack source files"
5985 );
5986
5987 let mut live_assertive = BTreeMap::<String, (String, String)>::new();
5989 for path in handler_sources {
5990 let relative = path
5991 .strip_prefix(crates_dir)
5992 .expect("pack source must be inside the workspace crates directory");
5993 let relative_path = relative.display().to_string();
5994 let crate_dir_name = relative
5995 .components()
5996 .next()
5997 .map(|c| c.as_os_str().to_string_lossy().into_owned())
5998 .unwrap_or_default();
5999 let owning_pack = crate_dir_name
6000 .strip_prefix("khive-pack-")
6001 .unwrap_or_else(|| {
6002 panic!("{relative_path}: expected a khive-pack-<name> crate directory")
6003 })
6004 .to_string();
6005 let source = std::fs::read_to_string(&path)
6006 .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
6007
6008 let raw_token_count = source.matches("HandlerDef {").count();
6023 let mut signature_count = 0usize;
6024 let mut struct_literal_count = 0usize;
6025 let mut unclassified: Vec<String> = Vec::new();
6026 let mut search_from = 0usize;
6027 while let Some(rel_pos) = source[search_from..].find("HandlerDef {") {
6028 let match_start = search_from + rel_pos;
6029 let match_end = match_start + "HandlerDef {".len();
6030 search_from = match_end;
6031
6032 match classify_handler_def_occurrence(&source, match_start, match_end) {
6033 HandlerDefOccurrence::Signature => {
6034 signature_count += 1;
6035 }
6036 HandlerDefOccurrence::StructLiteral {
6037 name,
6038 visibility,
6039 category,
6040 } => {
6041 struct_literal_count += 1;
6042 if !visibility.contains("Visibility::Verb")
6043 || !category.contains("VerbCategory::Assertive")
6044 {
6045 continue;
6046 }
6047
6048 let prior = live_assertive
6049 .insert(name.clone(), (owning_pack.clone(), relative_path.clone()));
6050 assert!(
6051 prior.is_none(),
6052 "public Assertive verb {name:?} is declared in both {prior:?} and \
6053 ({owning_pack:?}, {relative_path:?}); the registry surface must \
6054 remain collision-free"
6055 );
6056 }
6057 HandlerDefOccurrence::Unclassified { first_field_line } => {
6058 unclassified.push(format!(
6059 "byte {match_start} (first field line {first_field_line:?})"
6060 ));
6061 }
6062 }
6063 }
6064 assert_eq!(
6065 signature_count + struct_literal_count,
6066 raw_token_count,
6067 "{relative_path}: found {raw_token_count} occurrences of the `HandlerDef {{` \
6068 token but classified {signature_count} as signatures and \
6069 {struct_literal_count} as struct literals; unclassified: {unclassified:?} — \
6070 extend this census's parser to handle the shape instead of silently excluding it"
6071 );
6072 }
6073
6074 let safe: BTreeSet<(&str, &str)> = VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS
6075 .iter()
6076 .copied()
6077 .collect();
6078 assert_eq!(
6079 safe.len(),
6080 VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS.len(),
6081 "ADMISSION_DEGRADE_SAFE_VERBS contains duplicate (pack, verb) pairs"
6082 );
6083 let safe_verbs: BTreeSet<&str> = safe.iter().map(|&(_, v)| v).collect();
6084 assert_eq!(
6085 safe_verbs.len(),
6086 safe.len(),
6087 "ADMISSION_DEGRADE_SAFE_VERBS names the same verb under two different packs; a verb \
6088 belongs to exactly one pack"
6089 );
6090 for &(pack, verb) in &safe {
6091 let live_owner = live_assertive
6092 .get(verb)
6093 .map(|(owning_pack, _)| owning_pack.as_str());
6094 assert_eq!(
6095 live_owner,
6096 Some(pack),
6097 "ADMISSION_DEGRADE_SAFE_VERBS claims {verb:?} is owned by pack {pack:?}, but its \
6098 live declaration says otherwise (found: {live_owner:?})"
6099 );
6100 }
6101 let incidental: BTreeSet<&str> = KNOWN_ADMISSION_UNSAFE_VERBS.iter().copied().collect();
6102 assert!(
6103 safe_verbs.is_disjoint(&incidental),
6104 "a public Assertive verb cannot be both admission-degrade-safe and admission-unsafe: {:?}",
6105 safe_verbs.intersection(&incidental).collect::<Vec<_>>()
6106 );
6107
6108 let classified: BTreeSet<&str> = safe_verbs.union(&incidental).copied().collect();
6109 let live: BTreeSet<&str> = live_assertive.keys().map(String::as_str).collect();
6110 assert_eq!(
6111 classified, live,
6112 "every public Assertive handler must be classified exactly once after a live-source \
6113 effect review; live declarations: {live_assertive:#?}"
6114 );
6115 }
6116
6117 struct CountingHandlersPack {
6121 name: &'static str,
6122 handlers: &'static [HandlerDef],
6123 calls: Arc<AtomicUsize>,
6124 }
6125
6126 impl Pack for CountingHandlersPack {
6127 const NAME: &'static str = "counting";
6128 const NOTE_KINDS: &'static [&'static str] = &[];
6129 const ENTITY_KINDS: &'static [&'static str] = &[];
6130 const HANDLERS: &'static [HandlerDef] = &[];
6131 }
6132
6133 #[async_trait]
6134 impl PackRuntime for CountingHandlersPack {
6135 fn name(&self) -> &str {
6136 self.name
6137 }
6138 fn note_kinds(&self) -> &'static [&'static str] {
6139 &[]
6140 }
6141 fn entity_kinds(&self) -> &'static [&'static str] {
6142 &[]
6143 }
6144 fn handlers(&self) -> &'static [HandlerDef] {
6145 self.calls.fetch_add(1, Ordering::SeqCst);
6146 self.handlers
6147 }
6148 async fn dispatch(
6149 &self,
6150 verb: &str,
6151 _params: Value,
6152 _registry: &VerbRegistry,
6153 _token: &NamespaceToken,
6154 ) -> Result<Value, RuntimeError> {
6155 Ok(serde_json::json!({ "pack": self.name, "verb": verb }))
6156 }
6157 }
6158
6159 #[test]
6172 fn admission_degrade_safe_is_a_build_time_lookup_with_no_per_call_pack_scan() {
6173 static KG_HANDLERS: [HandlerDef; 1] = [HandlerDef {
6174 name: "list",
6175 description: "list widgets",
6176 visibility: Visibility::Verb,
6177 category: VerbCategory::Assertive,
6178 params: &[],
6179 }];
6180
6181 let calls = Arc::new(AtomicUsize::new(0));
6182 let mut builder = VerbRegistryBuilder::new();
6183 builder.register_trusted(CountingHandlersPack {
6184 name: "kg",
6185 handlers: &KG_HANDLERS,
6186 calls: calls.clone(),
6187 });
6188 let registry = builder.build().expect("registry builds");
6189
6190 let after_build = calls.load(Ordering::SeqCst);
6191 assert!(
6192 after_build > 0,
6193 "build() is expected to read handlers() at least once (unique-name validation, \
6194 available_verbs, and the degrade-safe precompute all do); a count of 0 means this \
6195 test's premise (build-time reads happen) is wrong, not that the property under \
6196 test holds"
6197 );
6198
6199 assert!(
6200 registry.admission_degrade_safe_probe("list"),
6201 "\"list\" is Assertive and (\"kg\", \"list\") is allowlisted under trusted \
6202 registration, so this must be a hit"
6203 );
6204 assert_eq!(
6205 calls.load(Ordering::SeqCst),
6206 after_build,
6207 "a hit must not re-scan any pack's handlers() — eligibility was already decided at \
6208 build() time"
6209 );
6210
6211 assert!(
6212 !registry.admission_degrade_safe_probe("not-a-real-verb"),
6213 "an unregistered verb name is never eligible"
6214 );
6215 assert_eq!(
6216 calls.load(Ordering::SeqCst),
6217 after_build,
6218 "a miss must not re-scan any pack's handlers() either"
6219 );
6220 }
6221
6222 #[test]
6223 fn read_replay_requires_trusted_owning_pack_for_every_opted_in_verb() {
6224 static HANDLERS: [HandlerDef; 5] = [
6225 HandlerDef {
6226 name: "stats",
6227 description: "replay eligibility fixture",
6228 visibility: Visibility::Verb,
6229 category: VerbCategory::Assertive,
6230 params: &[],
6231 },
6232 HandlerDef {
6233 name: "comm.thread",
6234 description: "replay eligibility fixture",
6235 visibility: Visibility::Verb,
6236 category: VerbCategory::Assertive,
6237 params: &[],
6238 },
6239 HandlerDef {
6240 name: "comm.inbox",
6241 description: "replay eligibility fixture",
6242 visibility: Visibility::Verb,
6243 category: VerbCategory::Assertive,
6244 params: &[],
6245 },
6246 HandlerDef {
6247 name: "comm.unread",
6248 description: "replay eligibility fixture",
6249 visibility: Visibility::Verb,
6250 category: VerbCategory::Assertive,
6251 params: &[],
6252 },
6253 HandlerDef {
6254 name: "comm.delivered",
6255 description: "replay eligibility fixture",
6256 visibility: Visibility::Verb,
6257 category: VerbCategory::Assertive,
6258 params: &[],
6259 },
6260 ];
6261
6262 for (owner, handlers) in [("kg", &HANDLERS[..1]), ("comm", &HANDLERS[1..])] {
6263 for (name, trusted, expected) in [
6264 (owner, true, true),
6265 (owner, false, false),
6266 ("custom-impostor", true, false),
6267 ] {
6268 let mut builder = VerbRegistryBuilder::new();
6269 let pack = CountingHandlersPack {
6270 name,
6271 handlers,
6272 calls: Arc::new(AtomicUsize::new(0)),
6273 };
6274 if trusted {
6275 builder.register_trusted(pack);
6276 } else {
6277 builder.register(pack);
6278 }
6279 let registry = builder.build().expect("replay fixture registry");
6280 for handler in handlers {
6281 assert_eq!(
6282 registry.is_read_replay_safe(handler.name),
6283 expected,
6284 "verb={}, owner={name}, trusted={trusted}",
6285 handler.name,
6286 );
6287 }
6288 assert!(!registry.is_read_replay_safe("unknown.read"));
6289 }
6290 }
6291 }
6292
6293 #[test]
6294 fn read_replay_excludes_reads_with_fresh_persisted_serve_or_search_rows() {
6295 for (owner, verb) in [("memory", "memory.recall"), ("kg", "search")] {
6296 let handler = Box::leak(Box::new([HandlerDef {
6297 name: verb,
6298 description: "side-effecting replay fixture",
6299 visibility: Visibility::Verb,
6300 category: VerbCategory::Assertive,
6301 params: &[],
6302 }]));
6303 let mut builder = VerbRegistryBuilder::new();
6304 builder.register_trusted(CountingHandlersPack {
6305 name: owner,
6306 handlers: handler,
6307 calls: Arc::new(AtomicUsize::new(0)),
6308 });
6309 let registry = builder.build().expect("side-effecting read fixture");
6310 assert!(!registry.is_read_replay_safe(verb), "{verb}");
6311 }
6312 }
6313
6314 #[test]
6315 fn read_replay_uses_effect_classification_instead_of_speech_act_category() {
6316 static HANDLERS: [HandlerDef; 3] = [
6317 HandlerDef {
6318 name: "stats",
6319 description: "category cannot override the reviewed operation effects",
6320 visibility: Visibility::Verb,
6321 category: VerbCategory::Commissive,
6322 params: &[],
6323 },
6324 HandlerDef {
6325 name: "create",
6326 description: "an Assertive category cannot make a Write replayable",
6327 visibility: Visibility::Verb,
6328 category: VerbCategory::Assertive,
6329 params: &[],
6330 },
6331 HandlerDef {
6332 name: "unclassified_read",
6333 description: "an unknown operation remains ineligible",
6334 visibility: Visibility::Verb,
6335 category: VerbCategory::Assertive,
6336 params: &[],
6337 },
6338 ];
6339 let mut builder = VerbRegistryBuilder::new();
6340 builder.register_trusted(CountingHandlersPack {
6341 name: "kg",
6342 handlers: &HANDLERS,
6343 calls: Arc::new(AtomicUsize::new(0)),
6344 });
6345 let registry = builder.build().expect("mutating fixture registry");
6346 assert!(registry.is_read_replay_safe("stats"));
6347 assert!(!registry.is_read_replay_safe("create"));
6348 assert!(!registry.is_read_replay_safe("unclassified_read"));
6349 }
6350
6351 #[test]
6361 fn side_effecting_assertive_verbs_are_registered_assertive() {
6362 let sources: &[(&str, &str)] = &[
6363 ("search", "/../khive-pack-kg/src/handler_defs.rs"),
6364 ("memory.recall", "/../khive-pack-memory/src/pack.rs"),
6365 ("telemetry.emit", "/../khive-pack-telemetry/src/pack.rs"),
6366 ];
6367 assert_eq!(
6368 sources.len(),
6369 VerbRegistry::SIDE_EFFECTING_ASSERTIVE_VERBS.len(),
6370 "every entry in SIDE_EFFECTING_ASSERTIVE_VERBS needs a source-file mapping in \
6371 this census, or a newly added verb would go unchecked"
6372 );
6373 for (verb, rel_path) in sources {
6374 assert!(
6375 VerbRegistry::SIDE_EFFECTING_ASSERTIVE_VERBS.contains(verb),
6376 "census source table lists {verb:?}, which is missing from \
6377 SIDE_EFFECTING_ASSERTIVE_VERBS; keep the table and the list in sync"
6378 );
6379 let path = format!("{}{rel_path}", env!("CARGO_MANIFEST_DIR"));
6380 let source = std::fs::read_to_string(&path)
6381 .unwrap_or_else(|e| panic!("failed to read {path}: {e}"));
6382 let needle = format!("\n name: \"{verb}\",");
6383 let name_pos = source.find(&needle).unwrap_or_else(|| {
6384 panic!(
6385 "side-effecting-assertive verb {verb:?} has no top-level `HandlerDef` \
6386 in {path}; update SIDE_EFFECTING_ASSERTIVE_VERBS's source table or \
6387 this census"
6388 )
6389 });
6390 let block_end = source[name_pos..]
6391 .find("HandlerDef {")
6392 .map(|offset| name_pos + offset)
6393 .unwrap_or(source.len());
6394 let block = &source[name_pos..block_end];
6395 assert!(
6396 block.contains("VerbCategory::Assertive"),
6397 "side-effecting-assertive verb {verb:?} is declared in {path} but is not \
6398 VerbCategory::Assertive; is_retry_safe_after_frame_omission's exclusion \
6399 list only needs to cover verbs the category check would otherwise wave \
6400 through"
6401 );
6402 }
6403 }
6404
6405 static COMM_PROBE_GRANTED: std::sync::atomic::AtomicBool =
6406 std::sync::atomic::AtomicBool::new(false);
6407 static OTHER_PROBE_GRANTED: std::sync::atomic::AtomicBool =
6408 std::sync::atomic::AtomicBool::new(false);
6409
6410 struct CommProbeFactory;
6414 struct OtherProbeFactory;
6415 struct AccidentalZeroVerbFactory;
6416
6417 fn probe_pack(
6418 _runtime: KhiveRuntime,
6419 grant_flag: &'static std::sync::atomic::AtomicBool,
6420 ) -> Box<dyn PackRuntime> {
6421 struct ProbePack {
6422 grant_flag: &'static std::sync::atomic::AtomicBool,
6423 }
6424 #[async_trait::async_trait]
6425 impl PackRuntime for ProbePack {
6426 fn name(&self) -> &str {
6427 "probe"
6428 }
6429 fn note_kinds(&self) -> &'static [&'static str] {
6430 &[]
6431 }
6432 fn entity_kinds(&self) -> &'static [&'static str] {
6433 &[]
6434 }
6435 fn handlers(&self) -> &'static [HandlerDef] {
6436 &[]
6437 }
6438 fn accept_channel_ingest_capability(&self, _capability: ChannelIngestCapability) {
6439 self.grant_flag
6440 .store(true, std::sync::atomic::Ordering::SeqCst);
6441 }
6442 async fn dispatch(
6443 &self,
6444 _verb: &str,
6445 _params: serde_json::Value,
6446 _registry: &VerbRegistry,
6447 _token: &NamespaceToken,
6448 ) -> Result<serde_json::Value, crate::RuntimeError> {
6449 Err(crate::RuntimeError::InvalidInput("probe".into()))
6450 }
6451 }
6452 Box::new(ProbePack { grant_flag })
6453 }
6454
6455 impl PackFactory for CommProbeFactory {
6456 fn name(&self) -> &'static str {
6457 "comm"
6458 }
6459 fn intentionally_verbless(&self) -> bool {
6460 true
6461 }
6462 fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime> {
6463 probe_pack(runtime, &COMM_PROBE_GRANTED)
6464 }
6465 }
6466
6467 impl PackFactory for OtherProbeFactory {
6468 fn name(&self) -> &'static str {
6469 "grant-probe-other"
6470 }
6471 fn intentionally_verbless(&self) -> bool {
6472 true
6473 }
6474 fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime> {
6475 probe_pack(runtime, &OTHER_PROBE_GRANTED)
6476 }
6477 }
6478
6479 impl PackFactory for AccidentalZeroVerbFactory {
6480 fn name(&self) -> &'static str {
6481 "accidental-zero-verb"
6482 }
6483 fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime> {
6484 probe_pack(runtime, &OTHER_PROBE_GRANTED)
6485 }
6486 }
6487
6488 inventory::submit! { PackRegistration(&CommProbeFactory) }
6489 inventory::submit! { PackRegistration(&OtherProbeFactory) }
6490 inventory::submit! { PackRegistration(&AccidentalZeroVerbFactory) }
6491
6492 #[test]
6493 fn channel_ingest_grant_reaches_only_allowlisted_pack_names() {
6494 let runtime = KhiveRuntime::memory().unwrap();
6495 let mut builder = VerbRegistryBuilder::new();
6496 PackRegistry::register_packs(
6497 &["comm".to_string(), "grant-probe-other".to_string()],
6498 runtime,
6499 &mut builder,
6500 )
6501 .expect("probe registration succeeds");
6502 assert!(
6503 COMM_PROBE_GRANTED.load(std::sync::atomic::Ordering::SeqCst),
6504 "the comm-named factory must receive the channel-ingest grant"
6505 );
6506 assert!(
6507 !OTHER_PROBE_GRANTED.load(std::sync::atomic::Ordering::SeqCst),
6508 "a factory outside CHANNEL_INGEST_CAPABLE_PACKS must never be granted"
6509 );
6510 }
6511
6512 #[test]
6513 fn declared_zero_verb_pack_requires_explicit_intent_metadata() {
6514 let runtime = KhiveRuntime::memory().unwrap();
6515 let mut builder = VerbRegistryBuilder::new();
6516 let error = PackRegistry::register_packs(
6517 &["accidental-zero-verb".to_string()],
6518 runtime,
6519 &mut builder,
6520 )
6521 .expect_err("an unmarked zero-verb pack must fail registration");
6522
6523 assert!(matches!(
6524 error,
6525 PackLoadError::NoPublicVerbs { ref pack } if pack == "accidental-zero-verb"
6526 ));
6527 assert!(
6528 error
6529 .to_string()
6530 .contains("intentionally_verbless() = true"),
6531 "operator error must name the explicit exemption: {error}"
6532 );
6533 }
6534
6535 #[test]
6536 fn multi_backend_loader_enforces_zero_verb_intent_metadata() {
6537 let runtime = KhiveRuntime::memory().unwrap();
6538 let mut builder = VerbRegistryBuilder::new();
6539 let error = PackRegistry::register_packs_with_runtimes(
6540 &["accidental-zero-verb".to_string()],
6541 &HashMap::new(),
6542 &runtime,
6543 &mut builder,
6544 )
6545 .expect_err("multi-backend registration must enforce the same invariant");
6546
6547 assert!(matches!(
6548 error,
6549 PackLoadError::NoPublicVerbs { ref pack } if pack == "accidental-zero-verb"
6550 ));
6551 }
6552
6553 #[test]
6554 fn from_token_preserves_process_ref() {
6555 let with_ref = NamespaceToken::mint_authorized(
6556 Namespace::local(),
6557 ActorRef::new("agent", "provenance-carrier"),
6558 )
6559 .with_process_ref(Some("proc:origin-abc123".to_string()));
6560 let identity = RequestIdentity::from_token(&with_ref);
6561 assert_eq!(identity.process_ref.as_deref(), Some("proc:origin-abc123"));
6562
6563 let without_ref = NamespaceToken::mint_authorized(
6564 Namespace::local(),
6565 ActorRef::new("agent", "provenance-absent"),
6566 );
6567 let identity = RequestIdentity::from_token(&without_ref);
6568 assert_eq!(identity.process_ref, None);
6569 }
6570
6571 struct AlphaPack;
6572
6573 impl Pack for AlphaPack {
6574 const NAME: &'static str = "alpha";
6575 const NOTE_KINDS: &'static [&'static str] = &["memo", "log"];
6576 const ENTITY_KINDS: &'static [&'static str] = &["widget"];
6577 const BRAIN_CONSUMER_KINDS: &'static [&'static str] = &["recall", "search"];
6578 const HANDLERS: &'static [HandlerDef] = &[
6579 HandlerDef {
6580 name: "create",
6581 description: "create a widget",
6582 visibility: Visibility::Verb,
6583 category: VerbCategory::Commissive,
6584 params: &[],
6585 },
6586 HandlerDef {
6587 name: "list",
6588 description: "list widgets",
6589 visibility: Visibility::Verb,
6590 category: VerbCategory::Assertive,
6591 params: &[],
6592 },
6593 ];
6594 }
6595
6596 #[async_trait]
6597 impl PackRuntime for AlphaPack {
6598 fn name(&self) -> &str {
6599 AlphaPack::NAME
6600 }
6601 fn note_kinds(&self) -> &'static [&'static str] {
6602 AlphaPack::NOTE_KINDS
6603 }
6604 fn entity_kinds(&self) -> &'static [&'static str] {
6605 AlphaPack::ENTITY_KINDS
6606 }
6607 fn brain_consumer_kinds(&self) -> &'static [&'static str] {
6608 AlphaPack::BRAIN_CONSUMER_KINDS
6609 }
6610 fn handlers(&self) -> &'static [HandlerDef] {
6611 AlphaPack::HANDLERS
6612 }
6613 async fn dispatch(
6614 &self,
6615 verb: &str,
6616 _params: Value,
6617 _registry: &VerbRegistry,
6618 _token: &NamespaceToken,
6619 ) -> Result<Value, RuntimeError> {
6620 Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
6621 }
6622 }
6623
6624 #[derive(Debug)]
6625 struct GateErrorTrackingPack {
6626 invoked: Arc<AtomicUsize>,
6627 }
6628
6629 impl Pack for GateErrorTrackingPack {
6630 const NAME: &'static str = "gate_error_tracking";
6631 const NOTE_KINDS: &'static [&'static str] = &[];
6632 const ENTITY_KINDS: &'static [&'static str] = &[];
6633 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
6634 name: "guarded",
6635 description: "track whether gate-error dispatch reaches the handler",
6636 visibility: Visibility::Verb,
6637 category: VerbCategory::Assertive,
6638 params: &[],
6639 }];
6640 }
6641
6642 #[async_trait]
6643 impl PackRuntime for GateErrorTrackingPack {
6644 fn name(&self) -> &str {
6645 Self::NAME
6646 }
6647
6648 fn note_kinds(&self) -> &'static [&'static str] {
6649 Self::NOTE_KINDS
6650 }
6651
6652 fn entity_kinds(&self) -> &'static [&'static str] {
6653 Self::ENTITY_KINDS
6654 }
6655
6656 fn handlers(&self) -> &'static [HandlerDef] {
6657 Self::HANDLERS
6658 }
6659
6660 async fn dispatch(
6661 &self,
6662 _verb: &str,
6663 _params: Value,
6664 _registry: &VerbRegistry,
6665 _token: &NamespaceToken,
6666 ) -> Result<Value, RuntimeError> {
6667 self.invoked.fetch_add(1, Ordering::SeqCst);
6668 Ok(serde_json::json!({"invoked": true}))
6669 }
6670 }
6671
6672 struct SleepingPack;
6676
6677 impl Pack for SleepingPack {
6678 const NAME: &'static str = "sleeping";
6679 const NOTE_KINDS: &'static [&'static str] = &[];
6680 const ENTITY_KINDS: &'static [&'static str] = &[];
6681 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
6682 name: "slow_op",
6683 description: "sleeps before returning",
6684 visibility: Visibility::Verb,
6685 category: VerbCategory::Assertive,
6686 params: &[],
6687 }];
6688 }
6689
6690 #[async_trait]
6691 impl PackRuntime for SleepingPack {
6692 fn name(&self) -> &str {
6693 SleepingPack::NAME
6694 }
6695 fn note_kinds(&self) -> &'static [&'static str] {
6696 SleepingPack::NOTE_KINDS
6697 }
6698 fn entity_kinds(&self) -> &'static [&'static str] {
6699 SleepingPack::ENTITY_KINDS
6700 }
6701 fn handlers(&self) -> &'static [HandlerDef] {
6702 SleepingPack::HANDLERS
6703 }
6704 async fn dispatch(
6705 &self,
6706 verb: &str,
6707 _params: Value,
6708 _registry: &VerbRegistry,
6709 _token: &NamespaceToken,
6710 ) -> Result<Value, RuntimeError> {
6711 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6712 Ok(serde_json::json!({ "pack": "sleeping", "verb": verb }))
6713 }
6714 }
6715
6716 struct BetaPack;
6717
6718 impl Pack for BetaPack {
6719 const NAME: &'static str = "beta";
6720 const NOTE_KINDS: &'static [&'static str] = &["alert"];
6721 const ENTITY_KINDS: &'static [&'static str] = &["widget", "gadget"];
6722 const BRAIN_CONSUMER_KINDS: &'static [&'static str] = &["search", "knowledge_compose"];
6723 const HANDLERS: &'static [HandlerDef] = &[
6724 HandlerDef {
6725 name: "notify",
6726 description: "send alert",
6727 visibility: Visibility::Verb,
6728 category: VerbCategory::Commissive,
6729 params: &[],
6730 },
6731 HandlerDef {
6735 name: "create",
6736 description: "beta internal create (subhandler)",
6737 visibility: Visibility::Subhandler,
6738 category: VerbCategory::Commissive,
6739 params: &[],
6740 },
6741 ];
6742 }
6743
6744 fn build_registry() -> VerbRegistry {
6750 let mut builder = VerbRegistryBuilder::new();
6751 builder.register(AlphaPack);
6752 builder.register(BetaPack);
6753 builder.build().expect("registry builds without collision")
6754 }
6755
6756 struct CollidingPack;
6759
6760 impl Pack for CollidingPack {
6761 const NAME: &'static str = "colliding";
6762 const NOTE_KINDS: &'static [&'static str] = &[];
6763 const ENTITY_KINDS: &'static [&'static str] = &[];
6764 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
6765 name: "create",
6766 description: "duplicate Verb-visibility create",
6767 visibility: Visibility::Verb,
6768 category: VerbCategory::Commissive,
6769 params: &[],
6770 }];
6771 }
6772
6773 #[async_trait]
6774 impl PackRuntime for CollidingPack {
6775 fn name(&self) -> &str {
6776 Self::NAME
6777 }
6778 fn note_kinds(&self) -> &'static [&'static str] {
6779 Self::NOTE_KINDS
6780 }
6781 fn entity_kinds(&self) -> &'static [&'static str] {
6782 Self::ENTITY_KINDS
6783 }
6784 fn handlers(&self) -> &'static [HandlerDef] {
6785 Self::HANDLERS
6786 }
6787 async fn dispatch(
6788 &self,
6789 verb: &str,
6790 _params: Value,
6791 _registry: &VerbRegistry,
6792 _token: &NamespaceToken,
6793 ) -> Result<Value, RuntimeError> {
6794 Ok(serde_json::json!({ "pack": "colliding", "verb": verb }))
6795 }
6796 }
6797
6798 struct ReservedEnvelopeParamPack;
6799
6800 impl Pack for ReservedEnvelopeParamPack {
6801 const NAME: &'static str = "reserved-envelope-param";
6802 const NOTE_KINDS: &'static [&'static str] = &[];
6803 const ENTITY_KINDS: &'static [&'static str] = &[];
6804 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
6805 name: "broken.serve",
6806 description: "declares a transport-owned argument",
6807 visibility: Visibility::Verb,
6808 category: VerbCategory::Commissive,
6809 params: &[ParamDef {
6810 name: "presentation",
6811 param_type: "object",
6812 required: false,
6813 description: "invalid collision with the request envelope",
6814 resolution_mode: IdResolutionMode::NotApplicable,
6815 }],
6816 }];
6817 }
6818
6819 #[async_trait]
6820 impl PackRuntime for ReservedEnvelopeParamPack {
6821 fn name(&self) -> &str {
6822 Self::NAME
6823 }
6824 fn note_kinds(&self) -> &'static [&'static str] {
6825 Self::NOTE_KINDS
6826 }
6827 fn entity_kinds(&self) -> &'static [&'static str] {
6828 Self::ENTITY_KINDS
6829 }
6830 fn handlers(&self) -> &'static [HandlerDef] {
6831 Self::HANDLERS
6832 }
6833 async fn dispatch(
6834 &self,
6835 _verb: &str,
6836 _params: Value,
6837 _registry: &VerbRegistry,
6838 _token: &NamespaceToken,
6839 ) -> Result<Value, RuntimeError> {
6840 unreachable!("invalid handler metadata must fail before dispatch")
6841 }
6842 }
6843
6844 #[async_trait]
6845 impl PackRuntime for BetaPack {
6846 fn name(&self) -> &str {
6847 BetaPack::NAME
6848 }
6849 fn note_kinds(&self) -> &'static [&'static str] {
6850 BetaPack::NOTE_KINDS
6851 }
6852 fn entity_kinds(&self) -> &'static [&'static str] {
6853 BetaPack::ENTITY_KINDS
6854 }
6855 fn brain_consumer_kinds(&self) -> &'static [&'static str] {
6856 BetaPack::BRAIN_CONSUMER_KINDS
6857 }
6858 fn handlers(&self) -> &'static [HandlerDef] {
6859 BetaPack::HANDLERS
6860 }
6861 async fn dispatch(
6862 &self,
6863 verb: &str,
6864 _params: Value,
6865 _registry: &VerbRegistry,
6866 _token: &NamespaceToken,
6867 ) -> Result<Value, RuntimeError> {
6868 Ok(serde_json::json!({ "pack": "beta", "verb": verb }))
6869 }
6870 }
6871
6872 #[tokio::test]
6873 async fn dispatch_routes_to_correct_pack() {
6874 let reg = build_registry();
6875
6876 let res = reg.dispatch("list", Value::Null).await.unwrap();
6877 assert_eq!(res["pack"], "alpha");
6878
6879 let res = reg.dispatch("notify", Value::Null).await.unwrap();
6880 assert_eq!(res["pack"], "beta");
6881 }
6882
6883 #[test]
6887 fn verb_collision_is_boot_time_error() {
6888 let mut builder = VerbRegistryBuilder::new();
6889 builder.register(AlphaPack);
6890 builder.register(CollidingPack);
6891 let err = builder
6892 .build()
6893 .err()
6894 .expect("duplicate Verb-visibility handler must be rejected at build time");
6895 assert!(
6896 matches!(err, RuntimeError::VerbCollision { ref verb, .. } if verb == "create"),
6897 "expected VerbCollision for 'create', got {err:?}"
6898 );
6899 let msg = err.to_string();
6900 assert!(
6901 msg.contains("create"),
6902 "error must name the colliding verb: {msg}"
6903 );
6904 assert!(
6905 msg.contains("alpha") || msg.contains("colliding"),
6906 "error must name one of the conflicting packs: {msg}"
6907 );
6908 }
6909
6910 #[test]
6911 fn reserved_request_envelope_param_is_boot_time_error() {
6912 let mut builder = VerbRegistryBuilder::new();
6913 builder.register(ReservedEnvelopeParamPack);
6914 let error = builder
6915 .build()
6916 .err()
6917 .expect("transport-owned parameter names must fail registry construction");
6918 assert!(
6919 matches!(
6920 error,
6921 RuntimeError::ReservedEnvelopeParam {
6922 ref pack,
6923 ref verb,
6924 ref param,
6925 } if pack == "reserved-envelope-param"
6926 && verb == "broken.serve"
6927 && param == "presentation"
6928 ),
6929 "unexpected error: {error:?}"
6930 );
6931 }
6932
6933 #[test]
6934 fn reserved_request_envelope_param_is_boot_time_error_for_subhandler() {
6935 struct ReservedEnvelopeSubhandlerParamPack;
6936
6937 impl Pack for ReservedEnvelopeSubhandlerParamPack {
6938 const NAME: &'static str = "reserved-envelope-subhandler-param";
6939 const NOTE_KINDS: &'static [&'static str] = &[];
6940 const ENTITY_KINDS: &'static [&'static str] = &[];
6941 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
6942 name: "broken.internal",
6943 description: "declares a transport-owned argument on an internal handler",
6944 visibility: Visibility::Subhandler,
6945 category: VerbCategory::Assertive,
6946 params: &[ParamDef {
6947 name: "presentation_per_op",
6948 param_type: "string",
6949 required: false,
6950 description: "invalid collision with the request envelope",
6951 resolution_mode: IdResolutionMode::NotApplicable,
6952 }],
6953 }];
6954 }
6955
6956 #[async_trait]
6957 impl PackRuntime for ReservedEnvelopeSubhandlerParamPack {
6958 fn name(&self) -> &str {
6959 Self::NAME
6960 }
6961 fn note_kinds(&self) -> &'static [&'static str] {
6962 Self::NOTE_KINDS
6963 }
6964 fn entity_kinds(&self) -> &'static [&'static str] {
6965 Self::ENTITY_KINDS
6966 }
6967 fn handlers(&self) -> &'static [HandlerDef] {
6968 Self::HANDLERS
6969 }
6970 async fn dispatch(
6971 &self,
6972 _verb: &str,
6973 _params: Value,
6974 _registry: &VerbRegistry,
6975 _token: &NamespaceToken,
6976 ) -> Result<Value, RuntimeError> {
6977 unreachable!("invalid handler metadata must fail before dispatch")
6978 }
6979 }
6980
6981 let mut builder = VerbRegistryBuilder::new();
6982 builder.register(ReservedEnvelopeSubhandlerParamPack);
6983 let error = builder
6984 .build()
6985 .err()
6986 .expect("transport-owned parameter names must fail registry construction");
6987 assert!(
6988 matches!(
6989 error,
6990 RuntimeError::ReservedEnvelopeParam {
6991 ref pack,
6992 ref verb,
6993 ref param,
6994 } if pack == "reserved-envelope-subhandler-param"
6995 && verb == "broken.internal"
6996 && param == "presentation_per_op"
6997 ),
6998 "unexpected error: {error:?}"
6999 );
7000 }
7001
7002 #[test]
7006 fn subhandler_same_name_across_packs_is_not_a_collision() {
7007 struct SubhandlerPack;
7008 impl Pack for SubhandlerPack {
7009 const NAME: &'static str = "subhandler_pack";
7010 const NOTE_KINDS: &'static [&'static str] = &[];
7011 const ENTITY_KINDS: &'static [&'static str] = &[];
7012 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
7013 name: "create",
7014 description: "internal create",
7015 visibility: Visibility::Subhandler,
7016 category: VerbCategory::Commissive,
7017 params: &[],
7018 }];
7019 }
7020 #[async_trait]
7021 impl PackRuntime for SubhandlerPack {
7022 fn name(&self) -> &str {
7023 Self::NAME
7024 }
7025 fn note_kinds(&self) -> &'static [&'static str] {
7026 Self::NOTE_KINDS
7027 }
7028 fn entity_kinds(&self) -> &'static [&'static str] {
7029 Self::ENTITY_KINDS
7030 }
7031 fn handlers(&self) -> &'static [HandlerDef] {
7032 Self::HANDLERS
7033 }
7034 async fn dispatch(
7035 &self,
7036 verb: &str,
7037 _: Value,
7038 _: &VerbRegistry,
7039 _: &NamespaceToken,
7040 ) -> Result<Value, RuntimeError> {
7041 Ok(serde_json::json!({"pack": "subhandler_pack", "verb": verb}))
7042 }
7043 }
7044 let mut builder = VerbRegistryBuilder::new();
7045 builder.register(AlphaPack); builder.register(SubhandlerPack); builder
7048 .build()
7049 .expect("subhandler same name must NOT be a collision");
7050 }
7051
7052 #[tokio::test]
7053 async fn dispatch_unknown_verb_returns_error() {
7054 let reg = build_registry();
7055
7056 let err = reg.dispatch("explode", Value::Null).await.unwrap_err();
7057 let msg = err.to_string();
7058 assert!(msg.contains("explode"));
7059 assert!(msg.contains("create"));
7060 }
7061
7062 #[test]
7067 fn all_verbs_aggregates_across_packs_excludes_subhandlers() {
7068 let reg = build_registry();
7069 let verbs: Vec<&str> = reg.all_verbs().iter().map(|v| v.name).collect();
7070 assert_eq!(verbs, vec!["create", "list", "notify"]);
7072 }
7073
7074 #[test]
7075 fn all_verbs_with_names_pairs_pack_name_excludes_subhandlers() {
7076 let reg = build_registry();
7077 let pairs: Vec<(&str, &str)> = reg
7078 .all_verbs_with_names()
7079 .iter()
7080 .map(|(pack, v)| (*pack, v.name))
7081 .collect();
7082 assert_eq!(
7084 pairs,
7085 vec![("alpha", "create"), ("alpha", "list"), ("beta", "notify"),]
7086 );
7087 }
7088
7089 #[test]
7090 fn all_handlers_with_names_includes_subhandlers() {
7091 let reg = build_registry();
7092 let pairs: Vec<(&str, &str)> = reg
7093 .all_handlers_with_names()
7094 .iter()
7095 .map(|(pack, v)| (*pack, v.name))
7096 .collect();
7097 assert_eq!(
7099 pairs,
7100 vec![
7101 ("alpha", "create"),
7102 ("alpha", "list"),
7103 ("beta", "notify"),
7104 ("beta", "create"),
7105 ]
7106 );
7107 }
7108
7109 #[test]
7110 fn note_kinds_are_ordered() {
7111 let reg = build_registry();
7112 let kinds = reg.all_note_kinds();
7113 assert_eq!(kinds, vec!["memo", "log", "alert"]);
7114 }
7115
7116 #[test]
7117 fn brain_consumer_kinds_are_ordered_and_deduplicated() {
7118 let reg = build_registry();
7119 assert_eq!(
7120 reg.all_brain_consumer_kinds(),
7121 vec!["recall", "search", "knowledge_compose"]
7122 );
7123 }
7124
7125 #[test]
7126 fn brain_consumer_kind_wildcard_is_rejected_at_build_time() {
7127 struct WildcardConsumerPack;
7128
7129 impl khive_types::Pack for WildcardConsumerPack {
7130 const NAME: &'static str = "wildcard-consumer";
7131 const NOTE_KINDS: &'static [&'static str] = &[];
7132 const ENTITY_KINDS: &'static [&'static str] = &[];
7133 const BRAIN_CONSUMER_KINDS: &'static [&'static str] = &["*"];
7134 const HANDLERS: &'static [HandlerDef] = &[];
7135 }
7136
7137 #[async_trait]
7138 impl PackRuntime for WildcardConsumerPack {
7139 fn name(&self) -> &str {
7140 Self::NAME
7141 }
7142 fn note_kinds(&self) -> &'static [&'static str] {
7143 Self::NOTE_KINDS
7144 }
7145 fn entity_kinds(&self) -> &'static [&'static str] {
7146 Self::ENTITY_KINDS
7147 }
7148 fn brain_consumer_kinds(&self) -> &'static [&'static str] {
7149 Self::BRAIN_CONSUMER_KINDS
7150 }
7151 fn handlers(&self) -> &'static [HandlerDef] {
7152 Self::HANDLERS
7153 }
7154 async fn dispatch(
7155 &self,
7156 _verb: &str,
7157 _params: Value,
7158 _registry: &VerbRegistry,
7159 _token: &NamespaceToken,
7160 ) -> Result<Value, RuntimeError> {
7161 Ok(Value::Null)
7162 }
7163 }
7164
7165 let mut builder = VerbRegistryBuilder::new();
7166 builder.register(WildcardConsumerPack);
7167 let Err(RuntimeError::InvalidInput(message)) = builder.build() else {
7168 panic!("registry must reject a pack-declared brain wildcard");
7169 };
7170 assert!(message.contains("wildcard-consumer"), "{message}");
7171 assert!(message.contains("registry-owned"), "{message}");
7172 }
7173
7174 #[test]
7175 fn note_kind_duplicate_rejected_at_build_time() {
7176 struct DupPack;
7177
7178 impl khive_types::Pack for DupPack {
7179 const NAME: &'static str = "dup";
7180 const NOTE_KINDS: &'static [&'static str] = &["memo"];
7182 const ENTITY_KINDS: &'static [&'static str] = &[];
7183 const HANDLERS: &'static [HandlerDef] = &[];
7184 }
7185
7186 #[async_trait]
7187 impl PackRuntime for DupPack {
7188 fn name(&self) -> &str {
7189 Self::NAME
7190 }
7191 fn note_kinds(&self) -> &'static [&'static str] {
7192 Self::NOTE_KINDS
7193 }
7194 fn entity_kinds(&self) -> &'static [&'static str] {
7195 Self::ENTITY_KINDS
7196 }
7197 fn handlers(&self) -> &'static [HandlerDef] {
7198 Self::HANDLERS
7199 }
7200 async fn dispatch(
7201 &self,
7202 _verb: &str,
7203 _params: Value,
7204 _registry: &VerbRegistry,
7205 _token: &NamespaceToken,
7206 ) -> Result<Value, RuntimeError> {
7207 Ok(Value::Null)
7208 }
7209 }
7210
7211 let mut builder = VerbRegistryBuilder::new();
7212 builder.register(AlphaPack);
7213 builder.register(DupPack);
7214 let err = builder
7215 .build()
7216 .err()
7217 .expect("duplicate note kind must be rejected");
7218 let msg = err.to_string();
7219 assert!(
7220 msg.contains("memo"),
7221 "error must name the duplicate kind: {msg}"
7222 );
7223 assert!(
7224 msg.contains("alpha") || msg.contains("dup"),
7225 "error must name one of the conflicting packs: {msg}"
7226 );
7227 }
7228
7229 #[test]
7230 fn entity_kinds_are_deduplicated() {
7231 let reg = build_registry();
7232 let kinds = reg.all_entity_kinds();
7233 assert_eq!(kinds, vec!["widget", "gadget"]);
7234 }
7235
7236 struct GammaPack;
7239
7240 impl Pack for GammaPack {
7241 const NAME: &'static str = "gamma";
7242 const NOTE_KINDS: &'static [&'static str] = &[];
7243 const ENTITY_KINDS: &'static [&'static str] = &[];
7244 const HANDLERS: &'static [HandlerDef] = &[];
7245 const ENTITY_TYPES: &'static [EntityTypeDef] = &[EntityTypeDef {
7246 kind: khive_types::EntityKind::Document,
7247 type_name: "gamma_report",
7248 aliases: &["gamma_rep"],
7249 }];
7250 }
7251
7252 #[async_trait]
7253 impl PackRuntime for GammaPack {
7254 fn name(&self) -> &str {
7255 Self::NAME
7256 }
7257 fn note_kinds(&self) -> &'static [&'static str] {
7258 Self::NOTE_KINDS
7259 }
7260 fn entity_kinds(&self) -> &'static [&'static str] {
7261 Self::ENTITY_KINDS
7262 }
7263 fn handlers(&self) -> &'static [HandlerDef] {
7264 Self::HANDLERS
7265 }
7266 fn entity_types(&self) -> &'static [EntityTypeDef] {
7267 Self::ENTITY_TYPES
7268 }
7269 async fn dispatch(
7270 &self,
7271 verb: &str,
7272 _params: Value,
7273 _registry: &VerbRegistry,
7274 _token: &NamespaceToken,
7275 ) -> Result<Value, RuntimeError> {
7276 Ok(serde_json::json!({ "pack": "gamma", "verb": verb }))
7277 }
7278 }
7279
7280 #[test]
7284 fn all_entity_types_empty_when_no_pack_declares_extras() {
7285 let reg = build_registry(); assert!(reg.all_entity_types().is_empty());
7287 let composed = khive_types::EntityTypeRegistry::with_extra(reg.all_entity_types());
7288 let resolved = composed
7289 .resolve(khive_types::EntityKind::Document, Some("paper"))
7290 .expect("builtin paper subtype must still resolve");
7291 assert_eq!(resolved.entity_type.as_deref(), Some("paper"));
7292 }
7293
7294 #[test]
7297 fn pack_declared_entity_type_validates_through_composed_registry() {
7298 let mut builder = VerbRegistryBuilder::new();
7299 builder.register(AlphaPack);
7300 builder.register(GammaPack);
7301 let reg = builder.build().expect("registry builds");
7302
7303 let extras = reg.all_entity_types();
7304 assert_eq!(extras.len(), 1);
7305
7306 let composed = khive_types::EntityTypeRegistry::with_extra(extras);
7307 let resolved = composed
7308 .resolve(khive_types::EntityKind::Document, Some("gamma_rep"))
7309 .expect("pack-declared alias must resolve through the composed registry");
7310 assert_eq!(resolved.entity_type.as_deref(), Some("gamma_report"));
7311
7312 let builtin_resolved = composed
7313 .resolve(khive_types::EntityKind::Document, Some("paper"))
7314 .expect("builtin subtype must remain resolvable when a pack adds extras");
7315 assert_eq!(builtin_resolved.entity_type.as_deref(), Some("paper"));
7316
7317 composed
7318 .resolve(khive_types::EntityKind::Document, Some("nonexistent_type"))
7319 .expect_err("undeclared entity_type must still be rejected");
7320 }
7321
7322 #[test]
7328 fn overlapping_pack_declared_entity_types_reject_at_boot() {
7329 struct DeltaPack;
7330 impl Pack for DeltaPack {
7331 const NAME: &'static str = "delta";
7332 const NOTE_KINDS: &'static [&'static str] = &[];
7333 const ENTITY_KINDS: &'static [&'static str] = &[];
7334 const HANDLERS: &'static [HandlerDef] = &[];
7335 const ENTITY_TYPES: &'static [EntityTypeDef] = &[EntityTypeDef {
7336 kind: khive_types::EntityKind::Document,
7337 type_name: "gamma_report",
7338 aliases: &["gamma_rep"],
7339 }];
7340 }
7341 #[async_trait]
7342 impl PackRuntime for DeltaPack {
7343 fn name(&self) -> &str {
7344 Self::NAME
7345 }
7346 fn note_kinds(&self) -> &'static [&'static str] {
7347 Self::NOTE_KINDS
7348 }
7349 fn entity_kinds(&self) -> &'static [&'static str] {
7350 Self::ENTITY_KINDS
7351 }
7352 fn handlers(&self) -> &'static [HandlerDef] {
7353 Self::HANDLERS
7354 }
7355 fn entity_types(&self) -> &'static [EntityTypeDef] {
7356 Self::ENTITY_TYPES
7357 }
7358 async fn dispatch(
7359 &self,
7360 verb: &str,
7361 _params: Value,
7362 _registry: &VerbRegistry,
7363 _token: &NamespaceToken,
7364 ) -> Result<Value, RuntimeError> {
7365 Ok(serde_json::json!({ "pack": "delta", "verb": verb }))
7366 }
7367 }
7368
7369 let mut builder = VerbRegistryBuilder::new();
7370 builder.register(GammaPack);
7371 builder.register(DeltaPack);
7372 let err = builder.build().err().expect(
7373 "overlapping ENTITY_TYPES declarations must fail at build, not silently compose",
7374 );
7375
7376 let msg = err.to_string();
7377 assert!(
7378 msg.contains("gamma") && msg.contains("delta"),
7379 "collision error must name both contributing packs: {msg}"
7380 );
7381 assert!(
7382 msg.contains("gamma_report"),
7383 "collision error must name the colliding entity_type key: {msg}"
7384 );
7385 }
7386
7387 use khive_gate::{Gate, GateError};
7390 use std::sync::atomic::{AtomicUsize, Ordering};
7391 use std::sync::Arc;
7392
7393 #[derive(Default, Debug)]
7394 struct CountingGate {
7395 calls: AtomicUsize,
7396 deny_verb: Option<&'static str>,
7397 }
7398
7399 impl Gate for CountingGate {
7400 fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
7401 self.calls.fetch_add(1, Ordering::SeqCst);
7402 if Some(req.verb.as_str()) == self.deny_verb {
7403 Ok(GateDecision::deny(format!("test deny for {}", req.verb)))
7404 } else {
7405 Ok(GateDecision::allow())
7406 }
7407 }
7408 }
7409
7410 #[tokio::test]
7411 async fn dispatch_consults_the_gate() {
7412 let gate = Arc::new(CountingGate::default());
7413 let mut builder = VerbRegistryBuilder::new();
7414 builder.register(AlphaPack);
7415 builder.with_gate(gate.clone());
7416 let reg = builder.build().expect("registry builds");
7417
7418 reg.dispatch("list", Value::Null).await.unwrap();
7419 reg.dispatch("create", Value::Null).await.unwrap();
7420 assert_eq!(
7421 gate.calls.load(Ordering::SeqCst),
7422 2,
7423 "gate should be consulted once per dispatch"
7424 );
7425 }
7426
7427 #[tokio::test]
7428 async fn dispatch_returns_permission_denied_on_deny_v03() {
7429 let gate = Arc::new(CountingGate {
7430 calls: AtomicUsize::new(0),
7431 deny_verb: Some("create"),
7432 });
7433 let mut builder = VerbRegistryBuilder::new();
7434 builder.register(AlphaPack);
7435 builder.with_gate(gate.clone());
7436 let reg = builder.build().expect("registry builds");
7437
7438 let err = reg.dispatch("create", Value::Null).await.unwrap_err();
7440 assert!(
7441 matches!(err, RuntimeError::PermissionDenied { ref verb, .. } if verb == "create"),
7442 "expected PermissionDenied, got {err:?}"
7443 );
7444 let msg = err.to_string();
7445 assert!(
7446 msg.contains("create"),
7447 "error message must name the verb: {msg}"
7448 );
7449 assert!(
7450 msg.contains("test deny for create"),
7451 "error message must carry the deny reason: {msg}"
7452 );
7453 assert_eq!(gate.calls.load(Ordering::SeqCst), 1);
7454 }
7455
7456 #[tokio::test]
7457 #[serial_test::serial(config_ledger)]
7458 async fn denied_dispatch_returns_the_id_of_its_committed_gate_denied_row() {
7459 let gate = Arc::new(CountingGate {
7460 calls: AtomicUsize::new(0),
7461 deny_verb: Some("create"),
7462 });
7463 let store = Arc::new(MemoryEventStore::default());
7464 let mut builder = VerbRegistryBuilder::new();
7465 builder.register(AlphaPack);
7466 builder.with_gate(gate);
7467 builder.with_event_store(store.clone());
7468 let reg = builder.build().expect("registry builds");
7469
7470 let err = reg.dispatch("create", Value::Null).await.unwrap_err();
7471 let RuntimeError::PermissionDenied { receipt, .. } = err else {
7472 panic!("expected PermissionDenied, got {err:?}");
7473 };
7474 assert_eq!(
7475 receipt.audit_outcome,
7476 crate::error::DenialAuditOutcome::Committed
7477 );
7478 let audit_event_id = receipt
7479 .audit_event_id
7480 .expect("a committed row carries its id");
7481 let events = store.events.lock().unwrap();
7482 let row = events
7483 .iter()
7484 .find(|e| e.id == audit_event_id)
7485 .expect("the receipt names a row the store holds");
7486 assert_eq!(row.outcome, EventOutcome::Denied);
7487 assert_eq!(row.kind, EventKind::Audit);
7488 assert_eq!(row.verb, "create");
7489 }
7490
7491 #[tokio::test]
7492 #[serial_test::serial(config_ledger)]
7493 async fn denied_dispatch_masks_secret_shaped_deny_reason_in_stored_event() {
7494 #[derive(Debug)]
7501 struct SecretDenyGate;
7502 impl Gate for SecretDenyGate {
7503 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
7504 let reason = "postgres://svc:not-a-real-secret@internal-host in denied request"; Ok(GateDecision::deny(reason))
7506 }
7507 }
7508
7509 let store = Arc::new(MemoryEventStore::default());
7510 let mut builder = VerbRegistryBuilder::new();
7511 builder.register(AlphaPack);
7512 builder.with_gate(Arc::new(SecretDenyGate));
7513 builder.with_event_store(store.clone());
7514 let reg = builder.build().expect("registry builds");
7515
7516 let _ = reg.dispatch("list", Value::Null).await.unwrap_err();
7517
7518 let events = store.events.lock().unwrap();
7519 assert_eq!(events.len(), 1, "exactly one denial row must commit");
7520 let stored_reason = events[0].payload["deny_reason"]
7521 .as_str()
7522 .expect("deny_reason must be a string on the stored row");
7523 assert!(!stored_reason.is_empty());
7526 assert!(
7527 stored_reason.contains("in denied request"),
7528 "non-secret prose must survive masking: {stored_reason:?}"
7529 );
7530 assert!(
7531 !stored_reason.contains("not-a-real-secret"),
7532 "the durable row must never carry the raw credential: {stored_reason:?}"
7533 );
7534 assert!(
7535 stored_reason.contains("***MASKED***"),
7536 "the durable row must record that a credential was redacted: {stored_reason:?}"
7537 );
7538 }
7539
7540 #[tokio::test]
7541 #[serial_test::serial(config_ledger)]
7542 async fn denied_dispatch_without_an_event_store_reports_no_store() {
7543 let gate = Arc::new(CountingGate {
7544 calls: AtomicUsize::new(0),
7545 deny_verb: Some("create"),
7546 });
7547 let mut builder = VerbRegistryBuilder::new();
7548 builder.register(AlphaPack);
7549 builder.with_gate(gate);
7550 let reg = builder.build().expect("registry builds");
7551
7552 let err = reg.dispatch("create", Value::Null).await.unwrap_err();
7553 assert!(
7554 matches!(
7555 err,
7556 RuntimeError::PermissionDenied { ref receipt, .. }
7557 if **receipt == crate::error::DenialReceipt::no_store()
7558 ),
7559 "expected a no-store receipt, got {err:?}"
7560 );
7561 }
7562
7563 #[tokio::test]
7564 #[serial_test::serial(config_ledger)]
7565 #[serial_test::serial(audit_append_failures)]
7566 #[serial_test::serial(audit_obligation_append_failures)]
7567 async fn denied_dispatch_whose_row_fails_to_commit_still_refuses_and_names_no_row() {
7568 let gate = Arc::new(CountingGate {
7569 calls: AtomicUsize::new(0),
7570 deny_verb: Some("create"),
7571 });
7572 let store = Arc::new(MemoryEventStore {
7573 fail_appends: true,
7574 ..MemoryEventStore::default()
7575 });
7576 let mut builder = VerbRegistryBuilder::new();
7577 builder.register(AlphaPack);
7578 builder.with_gate(gate);
7579 builder.with_event_store(store.clone());
7580 let reg = builder.build().expect("registry builds");
7581
7582 let err = reg.dispatch("create", Value::Null).await.unwrap_err();
7583 let RuntimeError::PermissionDenied { verb, receipt, .. } = err else {
7584 panic!("expected PermissionDenied, got {err:?}");
7585 };
7586 assert_eq!(verb, "create");
7587 assert_eq!(
7588 receipt.audit_event_id, None,
7589 "a row that did not commit is not cited"
7590 );
7591 assert_eq!(
7592 receipt.audit_outcome,
7593 crate::error::DenialAuditOutcome::NotCommitted("store_failure")
7594 );
7595 assert!(store.events.lock().unwrap().is_empty());
7596 }
7597
7598 #[tokio::test]
7599 async fn dispatch_allow_verb_succeeds_even_with_deny_gate_for_other_verb() {
7600 let gate = Arc::new(CountingGate {
7602 calls: AtomicUsize::new(0),
7603 deny_verb: Some("create"),
7604 });
7605 let mut builder = VerbRegistryBuilder::new();
7606 builder.register(AlphaPack);
7607 builder.with_gate(gate.clone());
7608 let reg = builder.build().expect("registry builds");
7609
7610 let res = reg.dispatch("list", Value::Null).await.unwrap();
7611 assert_eq!(res["pack"], "alpha");
7612 }
7613
7614 #[tokio::test]
7615 async fn dispatch_uses_allow_all_gate_by_default() {
7616 let reg = build_registry();
7618 let res = reg.dispatch("list", Value::Null).await.unwrap();
7619 assert_eq!(res["pack"], "alpha");
7620 }
7621
7622 #[derive(Default, Debug)]
7625 struct NamespaceCapturingGate {
7626 seen: std::sync::Mutex<Vec<String>>,
7627 }
7628
7629 impl Gate for NamespaceCapturingGate {
7630 fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
7631 self.seen
7632 .lock()
7633 .unwrap()
7634 .push(req.namespace.as_str().to_string());
7635 Ok(GateDecision::allow())
7636 }
7637 }
7638
7639 #[tokio::test]
7640 async fn dispatch_propagates_params_namespace_to_gate() {
7641 let gate = Arc::new(NamespaceCapturingGate::default());
7642 let mut builder = VerbRegistryBuilder::new();
7643 builder.register(AlphaPack);
7644 builder.with_gate(gate.clone());
7645 builder.with_default_namespace("tenant-x");
7646 let reg = builder.build().expect("registry builds");
7647
7648 reg.dispatch("list", serde_json::json!({"namespace": "tenant-y"}))
7650 .await
7651 .unwrap();
7652 reg.dispatch("list", Value::Null).await.unwrap();
7654 let err = reg
7656 .dispatch("list", serde_json::json!({"namespace": ""}))
7657 .await
7658 .unwrap_err();
7659 assert!(
7660 matches!(err, RuntimeError::InvalidInput(_)),
7661 "empty namespace must return InvalidInput, got {err:?}"
7662 );
7663
7664 let seen = gate.seen.lock().unwrap().clone();
7665 assert_eq!(seen, vec!["tenant-y", "tenant-x"]);
7666 }
7667
7668 #[tokio::test]
7669 async fn dispatch_falls_back_to_local_when_no_default_set() {
7670 let gate = Arc::new(NamespaceCapturingGate::default());
7672 let mut builder = VerbRegistryBuilder::new();
7673 builder.register(AlphaPack);
7674 builder.with_gate(gate.clone());
7675 let reg = builder.build().expect("registry builds");
7676
7677 reg.dispatch("list", Value::Null).await.unwrap();
7678 let seen = gate.seen.lock().unwrap().clone();
7679 assert_eq!(seen, vec!["local"]);
7680 }
7681
7682 #[tokio::test]
7688 async fn namespace_null_rejected_not_coerced() {
7689 let cases: Vec<(&str, Value)> = vec![
7690 ("null", Value::Null),
7691 ("number", serde_json::json!(42)),
7692 ("boolean", serde_json::json!(true)),
7693 ("array", serde_json::json!(["local"])),
7694 ("object", serde_json::json!({"ns": "local"})),
7695 ];
7696
7697 for (label, ns_value) in cases {
7698 let gate = Arc::new(NamespaceCapturingGate::default());
7699 let mut builder = VerbRegistryBuilder::new();
7700 builder.register(AlphaPack);
7701 builder.with_gate(gate.clone());
7702 builder.with_default_namespace("tenant-x");
7703 let reg = builder.build().expect("registry builds");
7704
7705 let err = reg
7706 .dispatch("list", serde_json::json!({"namespace": ns_value}))
7707 .await
7708 .unwrap_err();
7709 assert!(
7710 matches!(err, RuntimeError::InvalidInput(_)),
7711 "case {label}: expected InvalidInput, got {err:?}"
7712 );
7713
7714 let seen = gate.seen.lock().unwrap().clone();
7718 assert!(
7719 seen.is_empty(),
7720 "case {label}: gate must not be consulted for malformed namespace, saw {seen:?}"
7721 );
7722 }
7723 }
7724
7725 use khive_gate::{AuditDecision, AuditEvent, Obligation};
7728
7729 #[derive(Default, Debug)]
7731 struct AuditCapturingGate {
7732 events: std::sync::Mutex<Vec<AuditEvent>>,
7733 deny_verb: Option<&'static str>,
7734 }
7735
7736 impl Gate for AuditCapturingGate {
7737 fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
7738 let decision = if Some(req.verb.as_str()) == self.deny_verb {
7739 GateDecision::deny("test deny")
7740 } else {
7741 GateDecision::allow_with(vec![Obligation::Audit {
7742 tag: format!("{}.check", req.verb),
7743 }])
7744 };
7745 let ev = AuditEvent::from_check(req, &decision, self.impl_name());
7747 self.events.lock().unwrap().push(ev);
7748 Ok(decision)
7749 }
7750
7751 fn impl_name(&self) -> &'static str {
7752 "AuditCapturingGate"
7753 }
7754 }
7755
7756 #[tokio::test]
7757 async fn dispatch_emits_one_audit_event_per_call() {
7758 let gate = Arc::new(AuditCapturingGate::default());
7759 let mut builder = VerbRegistryBuilder::new();
7760 builder.register(AlphaPack);
7761 builder.with_gate(gate.clone());
7762 let reg = builder.build().expect("registry builds");
7763
7764 reg.dispatch("list", Value::Null).await.unwrap();
7765 reg.dispatch("create", Value::Null).await.unwrap();
7766
7767 let evs = gate.events.lock().unwrap();
7768 assert_eq!(evs.len(), 2, "exactly one audit event per dispatch call");
7769 }
7770
7771 #[tokio::test]
7772 async fn dispatch_audit_event_allow_carries_obligations() {
7773 let gate = Arc::new(AuditCapturingGate::default());
7774 let mut builder = VerbRegistryBuilder::new();
7775 builder.register(AlphaPack);
7776 builder.with_gate(gate.clone());
7777 let reg = builder.build().expect("registry builds");
7778
7779 reg.dispatch("list", Value::Null).await.unwrap();
7780
7781 let evs = gate.events.lock().unwrap();
7782 let ev = &evs[0];
7783 assert_eq!(ev.verb, "list");
7784 assert_eq!(ev.decision, AuditDecision::Allow);
7785 assert!(ev.deny_reason.is_none());
7786 assert_eq!(ev.obligations.len(), 1);
7787 assert_eq!(ev.gate_impl, "AuditCapturingGate");
7788 }
7789
7790 #[tokio::test]
7791 async fn dispatch_audit_event_deny_carries_reason() {
7792 let gate = Arc::new(AuditCapturingGate {
7793 events: Default::default(),
7794 deny_verb: Some("create"),
7795 });
7796 let mut builder = VerbRegistryBuilder::new();
7797 builder.register(AlphaPack);
7798 builder.with_gate(gate.clone());
7799 let reg = builder.build().expect("registry builds");
7800
7801 let err = reg.dispatch("create", Value::Null).await.unwrap_err();
7804 assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
7805
7806 let evs = gate.events.lock().unwrap();
7807 let ev = &evs[0];
7808 assert_eq!(ev.verb, "create");
7809 assert_eq!(ev.decision, AuditDecision::Deny);
7810 assert_eq!(ev.deny_reason.as_deref(), Some("test deny"));
7811 assert!(ev.obligations.is_empty());
7812 }
7813
7814 #[tokio::test]
7815 async fn dispatch_audit_event_fields_match_gate_request() {
7816 let gate = Arc::new(AuditCapturingGate::default());
7817 let mut builder = VerbRegistryBuilder::new();
7818 builder.register(AlphaPack);
7819 builder.with_gate(gate.clone());
7820 builder.with_default_namespace("tenant-z");
7821 let reg = builder.build().expect("registry builds");
7822
7823 reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
7824 .await
7825 .unwrap();
7826
7827 let evs = gate.events.lock().unwrap();
7828 let ev = &evs[0];
7829 assert_eq!(ev.namespace, "tenant-q");
7831 assert_eq!(ev.verb, "list");
7832 assert_eq!(ev.actor.kind, "anonymous");
7833 }
7834
7835 #[derive(Default, Debug)]
7839 struct ActorCapturingGate {
7840 requests: std::sync::Mutex<Vec<GateRequest>>,
7841 }
7842
7843 impl Gate for ActorCapturingGate {
7844 fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
7845 self.requests.lock().unwrap().push(req.clone());
7846 Ok(GateDecision::allow())
7847 }
7848 }
7849
7850 #[tokio::test]
7854 async fn gate_request_carries_configured_actor_when_actor_id_is_set() {
7855 let gate = Arc::new(ActorCapturingGate::default());
7856 let mut builder = VerbRegistryBuilder::new();
7857 builder.register(AlphaPack);
7858 builder.with_gate(gate.clone());
7859 builder.with_actor_id(Some("team-abc:implementer".to_string()));
7860 let reg = builder.build().expect("registry builds");
7861
7862 reg.dispatch("list", Value::Null).await.unwrap();
7863
7864 let reqs = gate.requests.lock().unwrap();
7865 assert_eq!(reqs.len(), 1);
7866 let req = &reqs[0];
7867 assert_eq!(
7868 req.actor.kind, "actor",
7869 "gate request must carry kind='actor' when actor_id is configured"
7870 );
7871 assert_eq!(
7872 req.actor.id, "team-abc:implementer",
7873 "gate request must carry the configured actor id"
7874 );
7875 }
7876
7877 #[tokio::test]
7880 async fn gate_request_carries_anonymous_when_no_actor_id_configured() {
7881 let gate = Arc::new(ActorCapturingGate::default());
7882 let mut builder = VerbRegistryBuilder::new();
7883 builder.register(AlphaPack);
7884 builder.with_gate(gate.clone());
7885 let reg = builder.build().expect("registry builds");
7887
7888 reg.dispatch("list", Value::Null).await.unwrap();
7889
7890 let reqs = gate.requests.lock().unwrap();
7891 assert_eq!(reqs.len(), 1);
7892 let req = &reqs[0];
7893 assert_eq!(
7894 req.actor.kind, "anonymous",
7895 "gate request must carry anonymous actor when no actor_id is configured"
7896 );
7897 assert_eq!(req.actor.id, "local");
7898 }
7899
7900 struct TokenCapturingPack {
7903 actors: Arc<std::sync::Mutex<Vec<khive_gate::ActorRef>>>,
7904 }
7905
7906 impl Pack for TokenCapturingPack {
7907 const NAME: &'static str = "alpha";
7908 const NOTE_KINDS: &'static [&'static str] = &[];
7909 const ENTITY_KINDS: &'static [&'static str] = &[];
7910 const HANDLERS: &'static [HandlerDef] = AlphaPack::HANDLERS;
7911 }
7912
7913 #[async_trait]
7914 impl PackRuntime for TokenCapturingPack {
7915 fn name(&self) -> &str {
7916 Self::NAME
7917 }
7918 fn note_kinds(&self) -> &'static [&'static str] {
7919 Self::NOTE_KINDS
7920 }
7921 fn entity_kinds(&self) -> &'static [&'static str] {
7922 Self::ENTITY_KINDS
7923 }
7924 fn handlers(&self) -> &'static [HandlerDef] {
7925 Self::HANDLERS
7926 }
7927 async fn dispatch(
7928 &self,
7929 verb: &str,
7930 _params: Value,
7931 _registry: &VerbRegistry,
7932 token: &NamespaceToken,
7933 ) -> Result<Value, RuntimeError> {
7934 self.actors.lock().unwrap().push(token.actor().clone());
7935 Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
7936 }
7937 }
7938
7939 #[tokio::test]
7948 async fn gate_actor_and_token_actor_are_identical_when_actor_id_is_set() {
7949 let gate = Arc::new(ActorCapturingGate::default());
7950 let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
7951 let pack = TokenCapturingPack {
7952 actors: actors.clone(),
7953 };
7954 let mut builder = VerbRegistryBuilder::new();
7955 builder.register(pack);
7956 builder.with_gate(gate.clone());
7957 builder.with_actor_id(Some("actor-alpha".to_string()));
7958 let reg = builder.build().expect("registry builds");
7959
7960 reg.dispatch("list", Value::Null).await.unwrap();
7961
7962 let reqs = gate.requests.lock().unwrap();
7963 let gate_actor = reqs[0].actor.clone();
7964 drop(reqs);
7965
7966 let captured = actors.lock().unwrap();
7967 let token_actor = captured[0].clone();
7968
7969 assert_eq!(
7970 gate_actor.kind, token_actor.kind,
7971 "gate request actor and storage token actor must carry the same kind"
7972 );
7973 assert_eq!(
7974 gate_actor.id, token_actor.id,
7975 "gate request actor and storage token actor must carry the same id"
7976 );
7977 assert_eq!(gate_actor.id, "actor-alpha");
7978 }
7979
7980 struct VisibilityCapturingPack {
7981 visible: Arc<std::sync::Mutex<Vec<Vec<String>>>>,
7982 }
7983
7984 impl Pack for VisibilityCapturingPack {
7985 const NAME: &'static str = "alpha";
7986 const NOTE_KINDS: &'static [&'static str] = &[];
7987 const ENTITY_KINDS: &'static [&'static str] = &[];
7988 const HANDLERS: &'static [HandlerDef] = AlphaPack::HANDLERS;
7989 }
7990
7991 #[async_trait]
7992 impl PackRuntime for VisibilityCapturingPack {
7993 fn name(&self) -> &str {
7994 Self::NAME
7995 }
7996 fn note_kinds(&self) -> &'static [&'static str] {
7997 Self::NOTE_KINDS
7998 }
7999 fn entity_kinds(&self) -> &'static [&'static str] {
8000 Self::ENTITY_KINDS
8001 }
8002 fn handlers(&self) -> &'static [HandlerDef] {
8003 Self::HANDLERS
8004 }
8005 async fn dispatch(
8006 &self,
8007 verb: &str,
8008 _params: Value,
8009 _registry: &VerbRegistry,
8010 token: &NamespaceToken,
8011 ) -> Result<Value, RuntimeError> {
8012 self.visible.lock().unwrap().push(
8013 token
8014 .visible_namespace_strs()
8015 .into_iter()
8016 .map(str::to_string)
8017 .collect(),
8018 );
8019 Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
8020 }
8021 }
8022
8023 #[tokio::test]
8029 async fn dispatch_with_identity_folds_the_actor_namespace_into_default_reads() {
8030 let visible = Arc::new(std::sync::Mutex::new(Vec::new()));
8031 let mut builder = VerbRegistryBuilder::new();
8032 builder.register(VisibilityCapturingPack {
8033 visible: visible.clone(),
8034 });
8035 let reg = builder.build().expect("registry builds");
8036 let identity = |actor: Option<&str>, listed: &[&str]| RequestIdentity {
8037 namespace: "local".to_string(),
8038 actor_id: actor.map(str::to_string),
8039 visible_namespaces: listed.iter().map(|ns| ns.to_string()).collect(),
8040 ..Default::default()
8041 };
8042
8043 reg.dispatch_with_identity(
8044 "list",
8045 Value::Null,
8046 Some(identity(Some("lambda:probe"), &[])),
8047 )
8048 .await
8049 .unwrap();
8050 reg.dispatch_with_identity(
8051 "list",
8052 Value::Null,
8053 Some(identity(Some("lambda:probe"), &["lambda:probe"])),
8054 )
8055 .await
8056 .unwrap();
8057 reg.dispatch_with_identity("list", Value::Null, Some(identity(None, &[])))
8058 .await
8059 .unwrap();
8060 reg.dispatch_with_identity(
8061 "list",
8062 serde_json::json!({"namespace": "lambda:probe"}),
8063 Some(identity(Some("lambda:probe"), &[])),
8064 )
8065 .await
8066 .unwrap();
8067
8068 let captured = visible.lock().unwrap();
8069 let count = |set: &Vec<String>, ns: &str| set.iter().filter(|s| s.as_str() == ns).count();
8070 assert_eq!(
8071 count(&captured[0], "lambda:probe"),
8072 1,
8073 "empty list: {:?}",
8074 captured[0]
8075 );
8076 assert_eq!(
8077 count(&captured[0], "local"),
8078 1,
8079 "empty list: {:?}",
8080 captured[0]
8081 );
8082 assert_eq!(
8083 count(&captured[1], "lambda:probe"),
8084 1,
8085 "listed once: {:?}",
8086 captured[1]
8087 );
8088 assert_eq!(
8089 captured[2],
8090 vec!["local".to_string()],
8091 "anonymous keeps exactly local"
8092 );
8093 assert_eq!(
8094 captured[3],
8095 vec!["lambda:probe".to_string()],
8096 "explicit namespace is a precise scope, never widened"
8097 );
8098 }
8099
8100 #[tokio::test]
8103 async fn gate_actor_and_token_actor_are_identical_when_anonymous() {
8104 let gate = Arc::new(ActorCapturingGate::default());
8105 let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
8106 let pack = TokenCapturingPack {
8107 actors: actors.clone(),
8108 };
8109 let mut builder = VerbRegistryBuilder::new();
8110 builder.register(pack);
8111 builder.with_gate(gate.clone());
8112 let reg = builder.build().expect("registry builds");
8113
8114 reg.dispatch("list", Value::Null).await.unwrap();
8115
8116 let reqs = gate.requests.lock().unwrap();
8117 let gate_actor = reqs[0].actor.clone();
8118 drop(reqs);
8119
8120 let captured = actors.lock().unwrap();
8121 let token_actor = captured[0].clone();
8122
8123 assert_eq!(gate_actor.kind, token_actor.kind);
8124 assert_eq!(gate_actor.id, token_actor.id);
8125 assert_eq!(gate_actor.id, "local");
8126 }
8127
8128 #[tokio::test]
8135 async fn dispatch_as_threads_verified_actor_into_token() {
8136 let gate = Arc::new(ActorCapturingGate::default());
8137 let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
8138 let pack = TokenCapturingPack {
8139 actors: actors.clone(),
8140 };
8141 let mut builder = VerbRegistryBuilder::new();
8142 builder.register(pack);
8143 builder.with_gate(gate.clone());
8144 let reg = builder.build().expect("registry builds");
8145
8146 reg.dispatch_as(
8147 "list",
8148 Value::Null,
8149 VerifiedActor::new("gateway:principal-42").unwrap(),
8150 )
8151 .await
8152 .unwrap();
8153
8154 let reqs = gate.requests.lock().unwrap();
8155 assert_eq!(reqs[0].actor.kind, "actor");
8156 assert_eq!(reqs[0].actor.id, "gateway:principal-42");
8157 drop(reqs);
8158
8159 let captured = actors.lock().unwrap();
8160 assert_eq!(captured[0].kind, "actor");
8161 assert_eq!(
8162 captured[0].id, "gateway:principal-42",
8163 "the storage token actor must be the verified_actor supplied to dispatch_as, \
8164 matching exactly what pack handlers read as the acting principal"
8165 );
8166 }
8167
8168 #[tokio::test]
8173 async fn dispatch_as_does_not_change_plain_dispatch_behavior() {
8174 let gate = Arc::new(ActorCapturingGate::default());
8175 let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
8176 let pack = TokenCapturingPack {
8177 actors: actors.clone(),
8178 };
8179 let mut builder = VerbRegistryBuilder::new();
8180 builder.register(pack);
8181 builder.with_gate(gate.clone());
8182 builder.with_actor_id(Some("baked-actor".to_string()));
8183 let reg = builder.build().expect("registry builds");
8184
8185 reg.dispatch_as(
8186 "list",
8187 Value::Null,
8188 VerifiedActor::new("verified-actor").unwrap(),
8189 )
8190 .await
8191 .unwrap();
8192 reg.dispatch("list", Value::Null).await.unwrap();
8193
8194 let captured = actors.lock().unwrap();
8195 assert_eq!(captured.len(), 2);
8196 assert_eq!(captured[0].id, "verified-actor", "dispatch_as call");
8197 assert_eq!(
8198 captured[1].id, "baked-actor",
8199 "a later plain dispatch() call must still use the registry's baked \
8200 actor_id, unaffected by the prior dispatch_as call"
8201 );
8202 }
8203
8204 #[tokio::test]
8211 async fn dispatch_as_ignores_actor_key_in_params() {
8212 let gate = Arc::new(ActorCapturingGate::default());
8213 let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
8214 let pack = TokenCapturingPack {
8215 actors: actors.clone(),
8216 };
8217 let mut builder = VerbRegistryBuilder::new();
8218 builder.register(pack);
8219 builder.with_gate(gate.clone());
8220 let reg = builder.build().expect("registry builds");
8221
8222 reg.dispatch_as(
8223 "list",
8224 serde_json::json!({"actor": "spoofed-actor"}),
8225 VerifiedActor::new("verified-actor").unwrap(),
8226 )
8227 .await
8228 .unwrap();
8229
8230 let captured = actors.lock().unwrap();
8231 assert_eq!(
8232 captured[0].id, "verified-actor",
8233 "an 'actor' key inside params must never override the verified_actor \
8234 argument threaded through dispatch_as"
8235 );
8236 }
8237
8238 #[test]
8241 fn verified_actor_rejects_empty_identifier() {
8242 let err = VerifiedActor::new("").unwrap_err();
8243 assert!(
8244 matches!(err, RuntimeError::InvalidInput(_)),
8245 "expected InvalidInput, got {err:?}"
8246 );
8247 }
8248
8249 #[test]
8252 fn verified_actor_rejects_whitespace_only_identifier() {
8253 let err = VerifiedActor::new(" ").unwrap_err();
8254 assert!(
8255 matches!(err, RuntimeError::InvalidInput(_)),
8256 "expected InvalidInput, got {err:?}"
8257 );
8258 }
8259
8260 #[tokio::test]
8274 async fn rego_gate_missing_entrypoint_returns_permission_denied() {
8275 use khive_gate_rego::RegoGate;
8276
8277 let policy = r#"
8284 package khive.gate
8285 import rego.v1
8286 verdict := "allow"
8287 "#;
8288 let gate = Arc::new(RegoGate::from_policy_str(policy).expect("policy compiles"));
8289
8290 let mut builder = VerbRegistryBuilder::new();
8291 builder.register(AlphaPack);
8292 builder.with_gate(gate);
8293 let reg = builder.build().expect("registry builds");
8294
8295 let err = reg.dispatch("create", Value::Null).await.unwrap_err();
8296 assert!(
8297 matches!(err, RuntimeError::PermissionDenied { ref verb, ref reason, .. }
8298 if verb == "create" && reason == "policy evaluation failed"),
8299 "expected PermissionDenied with the static classified reason for a missing rego entrypoint, got {err:?}"
8300 );
8301 }
8302
8303 use std::sync::{Mutex as StdMutex, Once, OnceLock};
8315
8316 use serial_test::serial;
8317 use tracing::field::{Field, Visit};
8318
8319 #[derive(Clone, Debug, Default)]
8320 struct CapturedEvent {
8321 message: Option<String>,
8322 audit_event: Option<String>,
8323 into_id: Option<String>,
8324 budget_rows: Option<u64>,
8325 }
8326
8327 #[derive(Default)]
8328 struct CapturedEventVisitor(CapturedEvent);
8329
8330 impl Visit for CapturedEventVisitor {
8331 fn record_str(&mut self, field: &Field, value: &str) {
8332 match field.name() {
8333 "message" => self.0.message = Some(value.to_string()),
8334 "audit_event" => self.0.audit_event = Some(value.to_string()),
8335 _ => {}
8336 }
8337 }
8338
8339 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
8340 let formatted = format!("{value:?}");
8346 let cleaned = formatted
8347 .trim_start_matches('"')
8348 .trim_end_matches('"')
8349 .to_string();
8350 match field.name() {
8351 "message" => self.0.message = Some(cleaned),
8352 "audit_event" => self.0.audit_event = Some(cleaned),
8353 "into_id" => self.0.into_id = Some(cleaned),
8354 _ => {}
8355 }
8356 }
8357
8358 fn record_u64(&mut self, field: &Field, value: u64) {
8359 if field.name() == "budget_rows" {
8360 self.0.budget_rows = Some(value);
8361 }
8362 }
8363 }
8364
8365 struct CaptureSubscriber {
8378 events: Arc<StdMutex<Vec<CapturedEvent>>>,
8379 }
8380
8381 impl CaptureSubscriber {
8382 fn new(events: Arc<StdMutex<Vec<CapturedEvent>>>) -> Self {
8383 Self { events }
8384 }
8385 }
8386
8387 impl tracing::Subscriber for CaptureSubscriber {
8388 fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
8389 true
8390 }
8391 fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
8392 tracing::span::Id::from_u64(1)
8393 }
8394 fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
8395 fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
8396 fn event(&self, event: &tracing::Event<'_>) {
8397 let mut visitor = CapturedEventVisitor::default();
8398 event.record(&mut visitor);
8399 let captured = visitor.0;
8400 if let (Some(message), Some(into_id)) = (&captured.message, &captured.into_id) {
8404 if message.ends_with("transaction materialization budget") {
8405 budget_events_sink()
8406 .lock()
8407 .unwrap()
8408 .push(CapturedBudgetLog {
8409 message: message.clone(),
8410 into_id: into_id.clone(),
8411 budget_rows: captured.budget_rows.unwrap_or(0),
8412 });
8413 }
8414 }
8415 self.events.lock().unwrap().push(captured);
8416 }
8417 fn enter(&self, _: &tracing::span::Id) {}
8418 fn exit(&self, _: &tracing::span::Id) {}
8419 }
8420
8421 static GLOBAL_CAPTURE: OnceLock<Arc<StdMutex<Vec<CapturedEvent>>>> = OnceLock::new();
8431 static GLOBAL_INIT: Once = Once::new();
8432
8433 #[derive(Clone)]
8435 pub(crate) struct CapturedBudgetLog {
8436 pub(crate) message: String,
8437 pub(crate) into_id: String,
8438 pub(crate) budget_rows: u64,
8439 }
8440
8441 static BUDGET_EVENTS: OnceLock<Arc<StdMutex<Vec<CapturedBudgetLog>>>> = OnceLock::new();
8445
8446 fn budget_events_sink() -> Arc<StdMutex<Vec<CapturedBudgetLog>>> {
8447 Arc::clone(BUDGET_EVENTS.get_or_init(|| Arc::new(StdMutex::new(Vec::new()))))
8448 }
8449
8450 pub(crate) fn budget_log_events() -> Arc<StdMutex<Vec<CapturedBudgetLog>>> {
8455 let _ = global_capture();
8456 budget_events_sink()
8457 }
8458
8459 fn global_capture() -> Arc<StdMutex<Vec<CapturedEvent>>> {
8460 GLOBAL_INIT.call_once(|| {
8461 let buffer = Arc::new(StdMutex::new(Vec::new()));
8462 let subscriber = CaptureSubscriber::new(Arc::clone(&buffer));
8463 let _ = tracing::subscriber::set_global_default(subscriber);
8468 let _ = GLOBAL_CAPTURE.set(buffer);
8469 });
8470 Arc::clone(GLOBAL_CAPTURE.get().expect("global capture initialized"))
8471 }
8472
8473 fn capture_dispatch_events<Fut>(future: Fut) -> Vec<CapturedEvent>
8478 where
8479 Fut: std::future::Future<Output = ()>,
8480 {
8481 let buffer = global_capture();
8482 buffer.lock().unwrap().clear();
8483
8484 let rt = tokio::runtime::Builder::new_current_thread()
8485 .enable_all()
8486 .build()
8487 .expect("build current-thread tokio runtime");
8488 rt.block_on(future);
8489
8490 let result = buffer.lock().unwrap().clone();
8491 result
8492 }
8493
8494 fn gate_check_events_for(events: &[CapturedEvent], gate_impl: &str) -> Vec<CapturedEvent> {
8501 events
8502 .iter()
8503 .filter(|e| e.message.as_deref() == Some("gate.check"))
8504 .filter(|e| {
8505 e.audit_event
8506 .as_deref()
8507 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
8508 .and_then(|v| {
8509 v.get("gate_impl")
8510 .and_then(|g| g.as_str().map(|s| s.to_string()))
8511 })
8512 .as_deref()
8513 == Some(gate_impl)
8514 })
8515 .cloned()
8516 .collect()
8517 }
8518
8519 #[test]
8520 #[serial]
8521 fn dispatch_tracing_emits_one_gate_check_event_on_allow() {
8522 #[derive(Debug)]
8523 struct TracingAllowGate;
8524 impl Gate for TracingAllowGate {
8525 fn check(&self, _: &GateRequest) -> Result<GateDecision, GateError> {
8526 Ok(GateDecision::allow())
8527 }
8528 fn impl_name(&self) -> &'static str {
8529 "TracingAllowGate"
8530 }
8531 }
8532
8533 let events = capture_dispatch_events(async {
8534 let mut builder = VerbRegistryBuilder::new();
8535 builder.register(AlphaPack);
8536 builder.with_gate(Arc::new(TracingAllowGate));
8537 builder.with_default_namespace("tenant-default");
8538 let reg = builder.build().expect("registry builds");
8539 reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
8540 .await
8541 .unwrap();
8542 });
8543
8544 let gate_events = gate_check_events_for(&events, "TracingAllowGate");
8545 assert_eq!(
8546 gate_events.len(),
8547 1,
8548 "exactly one gate.check tracing event per dispatch (allow); got {gate_events:?}"
8549 );
8550 let payload = gate_events[0]
8551 .audit_event
8552 .as_ref()
8553 .expect("gate.check event must carry an audit_event field");
8554 let audit: khive_gate::AuditEvent =
8555 serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
8556 assert_eq!(audit.decision, AuditDecision::Allow);
8557 assert_eq!(audit.verb, "list");
8558 assert_eq!(audit.namespace, "tenant-q");
8559 assert_eq!(audit.gate_impl, "TracingAllowGate");
8560 assert!(
8561 audit.deny_reason.is_none(),
8562 "deny_reason must be None on Allow"
8563 );
8564 }
8565
8566 #[test]
8567 #[serial]
8568 fn dispatch_tracing_emits_one_gate_check_event_when_gate_is_unavailable() {
8569 #[derive(Debug)]
8570 struct TracingUnavailableGate;
8571 impl Gate for TracingUnavailableGate {
8572 fn check(&self, _: &GateRequest) -> Result<GateDecision, GateError> {
8573 Err(GateError::Internal("tracing gate broken".into()))
8574 }
8575
8576 fn impl_name(&self) -> &'static str {
8577 "TracingUnavailableGate"
8578 }
8579 }
8580
8581 let events = capture_dispatch_events(async {
8582 let mut builder = VerbRegistryBuilder::new();
8583 builder.register(AlphaPack);
8584 builder.with_gate(Arc::new(TracingUnavailableGate));
8585 let reg = builder.build().expect("registry builds");
8586 let error = reg
8587 .dispatch("list", Value::Null)
8588 .await
8589 .expect_err("gate outage must refuse dispatch");
8590 assert!(matches!(error, RuntimeError::GateUnavailable { .. }));
8591 });
8592
8593 let gate_events = gate_check_events_for(&events, "TracingUnavailableGate");
8594 assert_eq!(
8595 gate_events.len(),
8596 1,
8597 "exactly one gate.check tracing event per gate outage; got {gate_events:?}"
8598 );
8599 let payload = gate_events[0]
8600 .audit_event
8601 .as_ref()
8602 .expect("gate outage trace must carry an audit_event field");
8603 let audit: AuditEvent =
8604 serde_json::from_str(payload).expect("audit_event payload must decode");
8605 assert_eq!(audit.decision, AuditDecision::GateUnavailable);
8606 assert!(audit.deny_reason.is_none());
8607 assert!(audit.obligations.is_empty());
8608 assert_eq!(audit.gate_impl, "TracingUnavailableGate");
8609 }
8610
8611 use crate::runtime::NamespaceToken;
8614 use async_trait::async_trait;
8615 use khive_storage::{
8616 BatchWriteSummary, Event, EventFilter, EventStore, Page, PageRequest, SubstrateKind,
8617 };
8618 use khive_types::EventOutcome;
8619
8620 struct GitDigestResultPack {
8624 project_id: uuid::Uuid,
8625 }
8626
8627 impl Pack for GitDigestResultPack {
8628 const NAME: &'static str = "git";
8629 const NOTE_KINDS: &'static [&'static str] = &[];
8630 const ENTITY_KINDS: &'static [&'static str] = &[];
8631 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
8632 name: "git.digest",
8633 description: "return a deterministic digest report",
8634 visibility: Visibility::Verb,
8635 category: VerbCategory::Assertive,
8636 params: &[],
8637 }];
8638 }
8639
8640 #[async_trait]
8641 impl PackRuntime for GitDigestResultPack {
8642 fn name(&self) -> &str {
8643 Self::NAME
8644 }
8645 fn note_kinds(&self) -> &'static [&'static str] {
8646 Self::NOTE_KINDS
8647 }
8648 fn entity_kinds(&self) -> &'static [&'static str] {
8649 Self::ENTITY_KINDS
8650 }
8651 fn handlers(&self) -> &'static [HandlerDef] {
8652 Self::HANDLERS
8653 }
8654 async fn dispatch(
8655 &self,
8656 _verb: &str,
8657 _params: Value,
8658 _registry: &VerbRegistry,
8659 _token: &NamespaceToken,
8660 ) -> Result<Value, RuntimeError> {
8661 Ok(serde_json::json!({
8662 "project_id": self.project_id,
8663 "project_created": false,
8664 "commits_ingested": 2,
8665 "commits_skipped_existing": 1,
8666 "issues_ingested": 3,
8667 "issues_skipped_existing": 4,
8668 "prs_ingested": 5,
8669 "prs_skipped_existing": 6,
8670 "done": true,
8671 "history_exhausted": true,
8672 "sources": {
8673 "commits": {"state": "completed"},
8674 "issues": {"state": "completed"},
8675 "pull_requests": {"state": "completed"}
8676 },
8677 "warnings": []
8678 }))
8679 }
8680 }
8681
8682 struct MalformedGitDigestResultPack;
8686
8687 impl Pack for MalformedGitDigestResultPack {
8688 const NAME: &'static str = "malformed-git";
8689 const NOTE_KINDS: &'static [&'static str] = &[];
8690 const ENTITY_KINDS: &'static [&'static str] = &[];
8691 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
8692 name: "git.digest",
8693 description: "return a malformed digest report",
8694 visibility: Visibility::Verb,
8695 category: VerbCategory::Assertive,
8696 params: &[],
8697 }];
8698 }
8699
8700 #[async_trait]
8701 impl PackRuntime for MalformedGitDigestResultPack {
8702 fn name(&self) -> &str {
8703 Self::NAME
8704 }
8705 fn note_kinds(&self) -> &'static [&'static str] {
8706 Self::NOTE_KINDS
8707 }
8708 fn entity_kinds(&self) -> &'static [&'static str] {
8709 Self::ENTITY_KINDS
8710 }
8711 fn handlers(&self) -> &'static [HandlerDef] {
8712 Self::HANDLERS
8713 }
8714 async fn dispatch(
8715 &self,
8716 _verb: &str,
8717 _params: Value,
8718 _registry: &VerbRegistry,
8719 _token: &NamespaceToken,
8720 ) -> Result<Value, RuntimeError> {
8721 Ok(serde_json::json!({
8722 "project_id": "not-a-uuid",
8723 "done": true,
8724 }))
8725 }
8726 }
8727
8728 #[derive(Debug, Clone, PartialEq)]
8733 enum TraceEntry {
8734 Effect { name: String },
8736 Audit {
8739 kind: EventKind,
8740 outcome: EventOutcome,
8741 verb: String,
8742 cost_unit: Option<Value>,
8745 },
8746 }
8747
8748 #[derive(Default, Debug)]
8750 struct MemoryEventStore {
8751 events: std::sync::Mutex<Vec<Event>>,
8752 fail_appends: bool,
8753 fail_kind: Option<EventKind>,
8759 trace: Option<Arc<std::sync::Mutex<Vec<TraceEntry>>>>,
8772 append_started: Option<Arc<tokio::sync::Notify>>,
8774 append_release: Option<Arc<tokio::sync::Notify>>,
8775 }
8776
8777 impl MemoryEventStore {
8778 fn trace_submission(&self, events: &[Event]) {
8798 if let Some(trace) = &self.trace {
8799 let mut trace = trace.lock().expect("trace lock");
8800 for event in events {
8801 let cost_unit = event
8802 .payload
8803 .get("resource")
8804 .and_then(|resource| resource.get("cost_unit"))
8805 .cloned();
8806 trace.push(TraceEntry::Audit {
8807 kind: event.kind,
8808 outcome: event.outcome,
8809 verb: event.verb.clone(),
8810 cost_unit,
8811 });
8812 }
8813 }
8814 }
8815 }
8816
8817 #[async_trait]
8818 impl EventStore for MemoryEventStore {
8819 async fn append_event(&self, event: Event) -> khive_storage::StorageResult<()> {
8820 self.trace_submission(std::slice::from_ref(&event));
8821 if self.fail_appends || self.fail_kind == Some(event.kind) {
8822 return Err(khive_storage::StorageError::Internal(
8823 "injected audit append failure".to_string(),
8824 ));
8825 }
8826 self.events.lock().unwrap().push(event);
8827 Ok(())
8828 }
8829 async fn append_events(
8830 &self,
8831 events: Vec<Event>,
8832 ) -> khive_storage::StorageResult<BatchWriteSummary> {
8833 self.trace_submission(&events);
8834 let attempted = events.len() as u64;
8835 let affected = attempted;
8836 self.events.lock().unwrap().extend(events);
8837 Ok(BatchWriteSummary {
8838 attempted,
8839 affected,
8840 ..BatchWriteSummary::default()
8841 })
8842 }
8843 async fn get_event(&self, id: uuid::Uuid) -> khive_storage::StorageResult<Option<Event>> {
8844 Ok(self
8845 .events
8846 .lock()
8847 .unwrap()
8848 .iter()
8849 .find(|e| e.id == id)
8850 .cloned())
8851 }
8852 async fn query_events(
8853 &self,
8854 _filter: EventFilter,
8855 _page: PageRequest,
8856 ) -> khive_storage::StorageResult<Page<Event>> {
8857 let items = self.events.lock().unwrap().clone();
8858 let total = items.len() as u64;
8859 Ok(Page {
8860 items,
8861 total: Some(total),
8862 })
8863 }
8864 async fn count_events(&self, _filter: EventFilter) -> khive_storage::StorageResult<u64> {
8865 Ok(self.events.lock().unwrap().len() as u64)
8866 }
8867
8868 fn preflight_event(&self, _event: &Event) -> khive_storage::StorageResult<()> {
8869 Ok(())
8870 }
8871
8872 async fn append_events_idempotent(
8873 &self,
8874 events: Vec<Event>,
8875 ) -> khive_storage::StorageResult<khive_storage::event::IdempotentEventBatchResult>
8876 {
8877 if let Some(started) = &self.append_started {
8878 started.notify_one();
8879 }
8880 if let Some(release) = &self.append_release {
8881 release.notified().await;
8882 }
8883 self.trace_submission(&events);
8884 if self.fail_appends
8885 || self
8886 .fail_kind
8887 .is_some_and(|kind| events.iter().any(|e| e.kind == kind))
8888 {
8889 return Err(khive_storage::StorageError::Internal(
8890 "injected audit append failure".to_string(),
8891 ));
8892 }
8893 let mut store = self.events.lock().unwrap();
8894 let mut rows = Vec::with_capacity(events.len());
8895 for event in events {
8896 if let Some(existing) = store.iter().find(|e| e.id == event.id) {
8897 if *existing == event {
8898 rows.push(
8899 khive_storage::event::EventAppendDisposition::AlreadyPresentIdentical,
8900 );
8901 } else {
8902 rows.push(khive_storage::event::EventAppendDisposition::IdentityConflict);
8903 }
8904 } else {
8905 store.push(event);
8906 rows.push(khive_storage::event::EventAppendDisposition::Inserted);
8907 }
8908 }
8909 Ok(khive_storage::event::IdempotentEventBatchResult { rows })
8910 }
8911
8912 fn supports_idempotent_audit_batch(&self) -> bool {
8913 true
8914 }
8915 }
8916
8917 fn collect_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
8919 let Ok(entries) = std::fs::read_dir(dir) else {
8920 return;
8921 };
8922 for entry in entries.filter_map(Result::ok) {
8923 let path = entry.path();
8924 if path.is_dir() {
8925 collect_rust_files(&path, out);
8926 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
8927 out.push(path);
8928 }
8929 }
8930 }
8931
8932 #[test]
8955 fn converted_crates_keep_their_sql_out_of_rust() {
8956 const CONVERTED: &[&str] = &[
8958 "khive-pack-brain",
8959 "khive-pack-git",
8960 "khive-pack-kg",
8961 "kkernel",
8962 ];
8963 const STILL_INLINE: &str = "khive-db";
8967
8968 fn strip_test_modules(text: &str) -> String {
8969 let bytes = text.as_bytes();
8970 let mut out = String::with_capacity(text.len());
8971 let mut cursor = 0usize;
8972 while let Some(found) = text[cursor..].find("#[cfg(test)]") {
8973 let start = cursor + found;
8974 let after = &text[start..];
8977 let Some(brace_rel) = after.find('{') else {
8978 out.push_str(&text[cursor..]);
8979 return out;
8980 };
8981 if !after[..brace_rel].contains("mod ") {
8982 out.push_str(&text[cursor..start + brace_rel]);
8983 cursor = start + brace_rel;
8984 continue;
8985 }
8986 out.push_str(&text[cursor..start]);
8987 let mut depth = 0usize;
8988 let mut i = start + brace_rel;
8989 while i < bytes.len() {
8990 match bytes[i] {
8991 b'{' => depth += 1,
8992 b'}' => {
8993 depth -= 1;
8994 if depth == 0 {
8995 i += 1;
8996 break;
8997 }
8998 }
8999 _ => {}
9000 }
9001 i += 1;
9002 }
9003 cursor = i;
9004 }
9005 out.push_str(&text[cursor..]);
9006 out
9007 }
9008
9009 fn literal_body(text: &str, open: usize) -> Option<&str> {
9013 let bytes = text.as_bytes();
9014 let mut i = open + 1;
9015 while i < bytes.len() {
9016 match bytes[i] {
9017 b'\\' => i += 2,
9018 b'"' => return text.get(open + 1..i),
9019 _ => i += 1,
9020 }
9021 }
9022 None
9023 }
9024
9025 fn flatten(body: &str) -> String {
9032 let mut out = String::with_capacity(body.len());
9033 let mut chars = body.chars();
9034 while let Some(c) = chars.next() {
9035 if c != '\\' {
9036 out.push(c);
9037 continue;
9038 }
9039 match chars.clone().next() {
9040 Some('n' | 't' | 'r') => {
9042 chars.next();
9043 out.push(' ');
9044 }
9045 _ => out.push(' '),
9048 }
9049 }
9050 out.split_whitespace().collect::<Vec<_>>().join(" ")
9051 }
9052
9053 fn sql_literals(text: &str) -> Vec<String> {
9054 const SHAPES: [(&str, &[&str]); 10] = [
9061 ("WITH ", &[" AS ("]),
9067 ("SELECT ", &[" FROM "]),
9068 ("INSERT ", &["INSERT INTO ", "INSERT OR "]),
9069 ("UPDATE ", &[" SET "]),
9070 ("DELETE ", &["DELETE FROM "]),
9071 (
9072 "CREATE ",
9073 &[
9074 "CREATE TABLE",
9075 "CREATE INDEX",
9076 "CREATE UNIQUE",
9077 "CREATE VIEW",
9078 "CREATE VIRTUAL",
9079 "CREATE TRIGGER",
9080 ],
9081 ),
9082 (
9083 "DROP ",
9084 &["DROP TABLE", "DROP INDEX", "DROP VIEW", "DROP TRIGGER"],
9085 ),
9086 ("ALTER ", &["ALTER TABLE"]),
9087 ("PRAGMA ", &["PRAGMA "]),
9088 ("REPLACE ", &["REPLACE INTO "]),
9089 ];
9090 let mut found = Vec::new();
9091 for (index, _) in text.match_indices('"') {
9092 let Some(body) = literal_body(text, index) else {
9093 continue;
9094 };
9095 let flat = flatten(body);
9096 let Some((verb, seconds)) = SHAPES.iter().find(|(v, _)| flat.starts_with(*v))
9097 else {
9098 continue;
9099 };
9100 if !seconds.iter().any(|second| flat.contains(second)) {
9101 continue;
9102 }
9103 let snippet: String = flat.chars().take(70).collect();
9104 found.push(format!("{verb}… {snippet}"));
9105 }
9106 found
9107 }
9108
9109 let sources = workspace_rust_sources();
9110 assert!(
9111 !sources.is_empty(),
9112 "the workspace source walk returned nothing, so this census read no code"
9113 );
9114
9115 let mut scanned_files = 0usize;
9116 let mut offenders: Vec<String> = Vec::new();
9117 let mut control_hits = 0usize;
9118 for (path, text) in &sources {
9119 let display = path.display().to_string();
9120 if display.contains("/tests/") || display.contains("/benches/") {
9121 continue;
9122 }
9123 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
9124 if file_name == "tests.rs" || file_name.ends_with("_tests.rs") {
9125 continue;
9126 }
9127 let production = strip_test_modules(text);
9128 if display.contains(&format!("/{STILL_INLINE}/")) {
9129 control_hits += sql_literals(&production).len();
9130 continue;
9131 }
9132 let Some(crate_name) = CONVERTED
9133 .iter()
9134 .find(|name| display.contains(&format!("/{name}/")))
9135 else {
9136 continue;
9137 };
9138 scanned_files += 1;
9139 for literal in sql_literals(&production) {
9140 offenders.push(format!("{crate_name} {}: {literal}", path.display()));
9141 }
9142 }
9143
9144 let mut round_tripped = 0usize;
9153 let crates_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
9154 .parent()
9155 .expect("khive-runtime's Cargo.toml lives directly under crates/")
9156 .to_path_buf();
9157 for crate_name in CONVERTED {
9158 let sql_dir = crates_root.join(crate_name).join("sql");
9159 let entries = std::fs::read_dir(&sql_dir).unwrap_or_else(|e| {
9160 panic!("{crate_name} is on the converted list but {sql_dir:?} is unreadable: {e}")
9161 });
9162 for entry in entries.filter_map(Result::ok) {
9163 let file = entry.path();
9164 if file.extension().and_then(|e| e.to_str()) != Some("sql") {
9165 continue;
9166 }
9167 let statement =
9168 std::fs::read_to_string(&file).unwrap_or_else(|e| panic!("read {file:?}: {e}"));
9169 let body = statement
9175 .lines()
9176 .skip_while(|line| {
9177 let start = line.trim_start();
9178 start.is_empty() || start.starts_with("--")
9179 })
9180 .collect::<Vec<_>>()
9181 .join("\n");
9182 assert!(
9183 !body.trim().is_empty(),
9184 "{file:?} holds nothing but comments, so it declares no statement for \
9185 the census to protect"
9186 );
9187 let statement = body.trim().replace('"', "\\\"");
9190 let shapes = [
9191 (
9192 "one line",
9193 statement.split_whitespace().collect::<Vec<_>>().join(" "),
9194 ),
9195 ("escaped newlines", statement.replace('\n', "\\n")),
9196 (
9197 "line continuations",
9198 statement.replace('\n', " \\\n "),
9199 ),
9200 ];
9201 for (shape, rendered) in shapes {
9202 let snippet = format!("let statement = \"{rendered}\";");
9203 let seen = sql_literals(&snippet);
9204 assert_eq!(
9205 seen.len(),
9206 1,
9207 "must-fail control: {file:?} written back into Rust as {shape} was \
9208 seen {} time(s), so the census would not notice this statement \
9209 moving home",
9210 seen.len()
9211 );
9212 round_tripped += 1;
9213 }
9214 }
9215 }
9216 assert!(
9217 round_tripped > 0,
9218 "must-fail control ran on nothing: {CONVERTED:?} contributed no .sql files, so \
9219 its passing says only that the loop body never executed"
9220 );
9221
9222 assert!(
9223 control_hits > 0,
9224 "must-match control: {STILL_INLINE} still holds SQL in Rust, so a detector \
9225 finding none there is broken and its clean reading of {CONVERTED:?} means nothing"
9226 );
9227 assert!(
9228 scanned_files > 0,
9229 "no source file matched {CONVERTED:?}; the crate names in that list are how this \
9230 census finds its population, so an empty match reads clean for the wrong reason"
9231 );
9232 assert!(
9233 offenders.is_empty(),
9234 "SQL text belongs in sql/<name>.sql behind that crate's sql! macro; \
9235 {} offender(s) across {scanned_files} file(s): {offenders:#?}",
9236 offenders.len()
9237 );
9238 }
9239
9240 fn workspace_rust_sources() -> Vec<(std::path::PathBuf, String)> {
9241 let crates_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
9242 .parent()
9243 .expect("khive-runtime's Cargo.toml lives directly under crates/")
9244 .to_path_buf();
9245 let mut files = Vec::new();
9246 let Ok(crate_dirs) = std::fs::read_dir(&crates_root) else {
9247 return Vec::new();
9248 };
9249 for crate_dir in crate_dirs.filter_map(Result::ok) {
9250 let crate_dir = crate_dir.path();
9251 if !crate_dir.is_dir() {
9252 continue;
9253 }
9254 for sub in ["src", "tests"] {
9255 let sub_dir = crate_dir.join(sub);
9256 if sub_dir.is_dir() {
9257 collect_rust_files(&sub_dir, &mut files);
9258 }
9259 }
9260 }
9261 files
9262 .into_iter()
9263 .filter_map(|path| {
9264 let text = std::fs::read_to_string(&path).ok()?;
9265 Some((path, text))
9266 })
9267 .collect()
9268 }
9269
9270 fn fn_name_from_signature(sig_line: &str) -> Option<&str> {
9273 let mut rest = sig_line;
9274 for prefix in ["pub(crate) ", "pub(super) ", "pub "] {
9275 if let Some(stripped) = rest.strip_prefix(prefix) {
9276 rest = stripped;
9277 }
9278 }
9279 let rest = rest
9280 .strip_prefix("async fn ")
9281 .or_else(|| rest.strip_prefix("fn "))?;
9282 Some(rest.split(['(', '<', ' ']).next().unwrap_or(rest))
9283 }
9284
9285 fn is_fn_signature_line(trimmed: &str) -> bool {
9289 fn_name_from_signature(trimmed).is_some()
9290 }
9291
9292 fn strip_string_literals(text: &str) -> String {
9301 let mut out = String::with_capacity(text.len());
9302 let mut in_string = false;
9308 for line in text.lines() {
9309 let mut chars = line.chars();
9310 while let Some(c) = chars.next() {
9311 if in_string {
9312 if c == '\\' {
9313 out.push(' ');
9314 if chars.next().is_some() {
9315 out.push(' ');
9316 }
9317 continue;
9318 }
9319 if c == '"' {
9320 in_string = false;
9321 out.push('"');
9322 } else {
9323 out.push(' ');
9324 }
9325 } else {
9326 if c == '"' {
9327 in_string = true;
9328 }
9329 out.push(c);
9330 }
9331 }
9332 out.push('\n');
9333 }
9334 out
9335 }
9336
9337 fn calls_name(text: &str, name: &str) -> bool {
9353 fn is_ident_byte(b: u8) -> bool {
9354 b.is_ascii_alphanumeric() || b == b'_'
9355 }
9356 let text = strip_string_literals(text);
9357 let bytes = text.as_bytes();
9358 let mut search_from = 0usize;
9359 while let Some(rel) = text[search_from..].find(name) {
9360 let idx = search_from + rel;
9361 let before_ok = idx == 0 || !is_ident_byte(bytes[idx - 1]);
9362 let after = idx + name.len();
9363 let mut j = after;
9364 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
9365 j += 1;
9366 }
9367 let after_ok = j < bytes.len() && bytes[j] == b'(';
9368 let is_definition = text[..idx].ends_with("fn ");
9369 if before_ok && after_ok && !is_definition {
9370 return true;
9371 }
9372 search_from = idx + 1;
9373 }
9374 false
9375 }
9376
9377 #[test]
9382 fn calls_name_matches_across_a_newline_before_the_parenthesis() {
9383 let text = "async fn wraps_it() {\n with_event_store\n (store)\n}";
9384 assert!(calls_name(text, "with_event_store"));
9385 }
9386
9387 fn fn_bodies(text: &str) -> Vec<(String, String)> {
9396 let lines: Vec<&str> = text.lines().collect();
9397 let starts: Vec<usize> = lines
9398 .iter()
9399 .enumerate()
9400 .filter(|(_, line)| is_fn_signature_line(line.trim_start()))
9401 .map(|(index, _)| index)
9402 .collect();
9403 starts
9404 .iter()
9405 .enumerate()
9406 .filter_map(|(index, &start)| {
9407 let hard_limit = starts.get(index + 1).copied().unwrap_or(lines.len());
9408 let end = brace_bounded_fn_end(&lines, start, hard_limit);
9409 let name = fn_name_from_signature(lines[start].trim_start())?;
9410 Some((name.to_string(), lines[start..end].join("\n")))
9411 })
9412 .collect()
9413 }
9414
9415 fn brace_bounded_fn_end(lines: &[&str], sig_start: usize, hard_limit: usize) -> usize {
9433 let joined = lines[sig_start..hard_limit].join("\n");
9434 let stripped = strip_string_literals(&joined);
9435 let mut depth = 0i32;
9436 let mut opened = false;
9437 for (line_index, line) in stripped.lines().enumerate() {
9438 let code = match line.find("//") {
9439 Some(comment_at) => &line[..comment_at],
9440 None => line,
9441 };
9442 for ch in code.chars() {
9443 match ch {
9444 '{' => {
9445 depth += 1;
9446 opened = true;
9447 }
9448 '}' => depth -= 1,
9449 _ => {}
9450 }
9451 }
9452 if opened && depth <= 0 {
9453 return (sig_start + line_index + 1).min(hard_limit);
9454 }
9455 }
9456 hard_limit
9457 }
9458
9459 fn file_seam_names(text: &str, seed: &[&str]) -> Vec<String> {
9489 let bodies = fn_bodies(text);
9490 let mut name_counts: std::collections::HashMap<&str, usize> =
9491 std::collections::HashMap::new();
9492 for (name, _) in &bodies {
9493 *name_counts.entry(name.as_str()).or_insert(0) += 1;
9494 }
9495
9496 let mut known: Vec<String> = seed.iter().map(|s| s.to_string()).collect();
9497 loop {
9498 let mut grew = false;
9499 for (name, body) in &bodies {
9500 if known.iter().any(|k| k == name) {
9501 continue;
9502 }
9503 if name_counts.get(name.as_str()).copied().unwrap_or(0) != 1 {
9504 continue;
9505 }
9506 if known.iter().any(|seam| calls_name(body, seam)) {
9507 known.push(name.clone());
9508 grew = true;
9509 }
9510 }
9511 if !grew {
9512 break;
9513 }
9514 }
9515 known
9516 }
9517
9518 fn crate_seam_names(bodies_by_file: &[Vec<(String, String)>], seed: &[&str]) -> Vec<String> {
9546 let per_file_unique: Vec<Vec<(&str, &str)>> = bodies_by_file
9547 .iter()
9548 .map(|bodies| {
9549 let mut counts: std::collections::HashMap<&str, usize> =
9550 std::collections::HashMap::new();
9551 for (name, _) in bodies {
9552 *counts.entry(name.as_str()).or_insert(0) += 1;
9553 }
9554 bodies
9555 .iter()
9556 .filter(|(name, _)| counts.get(name.as_str()).copied() == Some(1))
9557 .map(|(name, body)| (name.as_str(), body.as_str()))
9558 .collect()
9559 })
9560 .collect();
9561
9562 let mut occurrences: std::collections::HashMap<&str, Vec<&str>> =
9563 std::collections::HashMap::new();
9564 for unique_in_file in &per_file_unique {
9565 for (name, body) in unique_in_file {
9566 occurrences.entry(name).or_default().push(body);
9567 }
9568 }
9569
9570 let mut known: Vec<String> = seed.iter().map(|s| s.to_string()).collect();
9571 loop {
9572 let mut grew = false;
9573 for (name, bodies) in &occurrences {
9574 if known.iter().any(|k| k == name) {
9575 continue;
9576 }
9577 if bodies
9578 .iter()
9579 .all(|body| known.iter().any(|seam| calls_name(body, seam)))
9580 {
9581 known.push((*name).to_string());
9582 grew = true;
9583 }
9584 }
9585 if !grew {
9586 break;
9587 }
9588 }
9589 known
9590 }
9591
9592 fn crate_key(path: &std::path::Path) -> Option<String> {
9595 let mut components = path.components();
9596 while let Some(component) = components.next() {
9597 if component.as_os_str() == "crates" {
9598 return components
9599 .next()
9600 .map(|c| c.as_os_str().to_string_lossy().into_owned());
9601 }
9602 }
9603 None
9604 }
9605
9606 fn find_handle_call(text: &str) -> Option<String> {
9609 fn is_ident_byte(b: u8) -> bool {
9610 b.is_ascii_alphanumeric() || b == b'_'
9611 }
9612 let bytes = text.as_bytes();
9613 let mut search_from = 0usize;
9614 while let Some(rel) = text[search_from..].find("handle_") {
9615 let start = search_from + rel;
9616 if start > 0 && is_ident_byte(bytes[start - 1]) {
9617 search_from = start + 1;
9618 continue;
9619 }
9620 let mut end = start + "handle_".len();
9621 while end < bytes.len() && is_ident_byte(bytes[end]) {
9622 end += 1;
9623 }
9624 let mut j = end;
9625 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
9626 j += 1;
9627 }
9628 if j < bytes.len() && bytes[j] == b'(' {
9629 return Some(text[start..end].to_string());
9630 }
9631 search_from = end.max(start + 1);
9632 }
9633 None
9634 }
9635
9636 fn dispatch_verb_handlers(text: &str) -> Vec<(String, String)> {
9655 let bytes = text.as_bytes();
9656 let mut arms: Vec<(String, usize, usize)> = Vec::new();
9657 let mut i = 0usize;
9658 while i < bytes.len() {
9659 if bytes[i] != b'"' {
9660 i += 1;
9661 continue;
9662 }
9663 let start = i + 1;
9664 let mut j = start;
9665 while j < bytes.len() && bytes[j] != b'"' && bytes[j] != b'\n' {
9666 j += 1;
9667 }
9668 if j >= bytes.len() || bytes[j] != b'"' {
9669 i += 1;
9670 continue;
9671 }
9672 let literal = &text[start..j];
9673 let verb_like = !literal.is_empty()
9674 && literal
9675 .chars()
9676 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.');
9677 let mut k = j + 1;
9678 while k < bytes.len() && bytes[k].is_ascii_whitespace() {
9679 k += 1;
9680 }
9681 if verb_like && k + 1 < bytes.len() && bytes[k] == b'=' && bytes[k + 1] == b'>' {
9682 arms.push((literal.to_string(), start - 1, k + 2));
9683 }
9684 i = j + 1;
9685 }
9686
9687 let mut out = Vec::new();
9688 for (index, (verb, _quote_start, arrow_end)) in arms.iter().enumerate() {
9689 let next_arm_start = arms.get(index + 1).map(|arm| arm.1).unwrap_or(bytes.len());
9690 let window_end = next_arm_start.min(arrow_end + 400).min(bytes.len());
9691 if window_end <= *arrow_end {
9692 continue;
9693 }
9694 if let Some(handler) = find_handle_call(&text[*arrow_end..window_end]) {
9695 out.push((verb.clone(), handler));
9696 }
9697 }
9698 out
9699 }
9700
9701 #[derive(Debug)]
9702 struct CensusTest {
9703 name: String,
9704 calls: std::collections::BTreeSet<String>,
9705 dispatch_verbs: std::collections::BTreeSet<String>,
9706 serial_keys: Vec<String>,
9707 }
9708
9709 fn census_call_sites(
9710 tokens: proc_macro2::TokenStream,
9711 calls: &mut std::collections::BTreeSet<String>,
9712 dispatch_verbs: &mut std::collections::BTreeSet<String>,
9713 ) {
9714 use proc_macro2::{Delimiter, TokenTree};
9715
9716 let tokens: Vec<_> = tokens.into_iter().collect();
9717 for (index, token) in tokens.iter().enumerate() {
9718 if let TokenTree::Ident(name) = token {
9719 let is_definition = index > 0
9720 && matches!(&tokens[index - 1], TokenTree::Ident(previous) if previous == "fn");
9721 if let Some(TokenTree::Group(args)) = tokens.get(index + 1) {
9722 if !is_definition && args.delimiter() == Delimiter::Parenthesis {
9723 calls.insert(name.to_string());
9724 if name == "dispatch" {
9725 if let Some(TokenTree::Literal(literal)) =
9726 args.stream().into_iter().next()
9727 {
9728 let literal =
9729 std::iter::once(TokenTree::Literal(literal)).collect();
9730 if let Ok(verb) = syn::parse2::<syn::LitStr>(literal) {
9731 dispatch_verbs.insert(verb.value());
9732 }
9733 }
9734 }
9735 }
9736 }
9737 }
9738 if let TokenTree::Group(group) = token {
9741 census_call_sites(group.stream(), calls, dispatch_verbs);
9742 }
9743 }
9744 }
9745
9746 fn attribute_path_matches(path: &syn::Path, expected: &[&str]) -> bool {
9747 path.segments.len() == expected.len()
9748 && path
9749 .segments
9750 .iter()
9751 .zip(expected)
9752 .all(|(segment, expected)| segment.ident == *expected)
9753 }
9754
9755 fn serial_attribute_keys(attrs: &[syn::Attribute]) -> syn::Result<Vec<String>> {
9756 fn parse_keys(
9757 input: syn::parse::ParseStream<'_>,
9758 ) -> syn::Result<syn::punctuated::Punctuated<syn::Ident, syn::Token![,]>> {
9759 use syn::ext::IdentExt;
9760
9761 syn::punctuated::Punctuated::parse_terminated_with(input, syn::Ident::parse_any)
9762 }
9763
9764 let mut acquired = Vec::new();
9765 for attr in attrs.iter().rev() {
9768 if !attribute_path_matches(attr.path(), &["serial"])
9769 && !attribute_path_matches(attr.path(), &["serial_test", "serial"])
9770 {
9771 continue;
9772 }
9773 let mut keys = match &attr.meta {
9774 syn::Meta::Path(_) => Vec::new(),
9775 syn::Meta::List(_) => attr
9776 .parse_args_with(parse_keys)
9777 .map_err(|error| {
9778 syn::Error::new_spanned(
9779 attr,
9780 format!("unsupported serial attribute arguments in census: {error}"),
9781 )
9782 })?
9783 .into_iter()
9784 .map(|key| key.to_string())
9785 .collect(),
9786 syn::Meta::NameValue(_) => {
9787 return Err(syn::Error::new_spanned(
9788 attr,
9789 "unsupported serial attribute arguments in census",
9790 ));
9791 }
9792 };
9793 if keys.is_empty() {
9796 keys.push(String::new());
9797 }
9798 keys.sort();
9799 for key in keys {
9800 if !acquired.contains(&key) {
9801 acquired.push(key);
9802 }
9803 }
9804 }
9805 Ok(acquired)
9806 }
9807
9808 fn census_tests(text: &str) -> syn::Result<Vec<CensusTest>> {
9809 use quote::ToTokens;
9810 use syn::visit::Visit;
9811
9812 #[derive(Default)]
9813 struct Collector {
9814 scope: Vec<String>,
9815 tests: Vec<syn::Result<CensusTest>>,
9816 }
9817 impl<'ast> Visit<'ast> for Collector {
9818 fn visit_item_mod(&mut self, item: &'ast syn::ItemMod) {
9819 self.scope.push(item.ident.to_string());
9820 syn::visit::visit_item_mod(self, item);
9821 self.scope.pop();
9822 }
9823
9824 fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) {
9825 self.scope.push(item.sig.ident.to_string());
9826 if item.attrs.iter().any(|attr| {
9827 attribute_path_matches(attr.path(), &["test"])
9828 || attribute_path_matches(attr.path(), &["tokio", "test"])
9829 }) {
9830 self.tests
9831 .push(serial_attribute_keys(&item.attrs).map(|serial_keys| {
9832 let mut calls = std::collections::BTreeSet::new();
9833 let mut dispatch_verbs = std::collections::BTreeSet::new();
9834 census_call_sites(
9835 item.block.to_token_stream(),
9836 &mut calls,
9837 &mut dispatch_verbs,
9838 );
9839 CensusTest {
9840 name: self.scope.join("::"),
9841 calls,
9842 dispatch_verbs,
9843 serial_keys,
9844 }
9845 }));
9846 }
9847 syn::visit::visit_item_fn(self, item);
9848 self.scope.pop();
9849 }
9850 }
9851
9852 let file = syn::parse_file(text)?;
9853 let mut collector = Collector::default();
9854 collector.visit_file(&file);
9855 collector.tests.into_iter().collect()
9856 }
9857
9858 #[derive(Default)]
9859 struct SerialLockOrders {
9860 pairs: std::collections::BTreeMap<(String, String), (bool, String)>,
9861 conflicts: Vec<String>,
9862 }
9863
9864 impl SerialLockOrders {
9865 fn record(&mut self, name: &str, keys: &[String]) {
9866 for (index, first) in keys.iter().enumerate() {
9867 for second in &keys[index + 1..] {
9868 let forward = first < second;
9869 let pair = if forward {
9870 (first.clone(), second.clone())
9871 } else {
9872 (second.clone(), first.clone())
9873 };
9874 if let Some((prior_forward, prior_name)) = self.pairs.get(&pair) {
9875 if *prior_forward != forward {
9876 self.conflicts.push(format!(
9877 "{name} acquires {first:?} before {second:?}, opposite to {prior_name}"
9878 ));
9879 }
9880 } else {
9881 self.pairs.insert(pair, (forward, name.to_owned()));
9882 }
9883 }
9884 }
9885 }
9886 }
9887
9888 fn serial_fixture_conflicts(source: &str) -> Vec<String> {
9889 let mut orders = SerialLockOrders::default();
9890 for test in census_tests(source).expect("valid fixture source") {
9891 orders.record(&test.name, &test.serial_keys);
9892 }
9893 orders.conflicts
9894 }
9895
9896 #[test]
9897 fn serial_census_accepts_complete_attributes_and_per_attribute_sorting() {
9898 let source = r#"
9899 #[serial]
9900 #[cfg(unix)]
9901 #[serial_test::serial(
9902 config_ledger,
9903 other,
9904 )]
9905 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9906 async fn first() { with_event_store(store); }
9907
9908 #[test]
9909 #[serial_test::serial()]
9910 #[serial(other, config_ledger)]
9911 fn second() { registry.dispatch("serial_fixture_verb", params); }
9912
9913 mod nested {
9914 #[serial_test::serial]
9915 #[test]
9916 #[serial(other)]
9917 #[serial(config_ledger)]
9918 fn third() {}
9919 }
9920 "#;
9921 let tests = census_tests(source).unwrap();
9922 assert_eq!(tests.len(), 3);
9923 for test in &tests {
9924 assert_eq!(test.serial_keys, ["config_ledger", "other", ""]);
9925 }
9926 assert_eq!(tests[2].name, "nested::third");
9927 assert!(tests[0].calls.contains("with_event_store"));
9928 assert!(tests[1].dispatch_verbs.contains("serial_fixture_verb"));
9929 assert!(serial_fixture_conflicts(source).is_empty());
9930 let keyword_keys =
9931 census_tests("#[test] #[serial(type, r#match)] fn keywords() {}").unwrap();
9932 assert_eq!(keyword_keys[0].serial_keys, ["r#match", "type"]);
9933 }
9934
9935 #[test]
9936 fn serial_census_rejects_each_hidden_stacked_order_reversal() {
9937 for (label, reverse_attrs) in [
9938 ("one-line", "#[serial(config_ledger)] #[serial]"),
9939 ("multi-key", "#[serial(config_ledger, other)] #[serial]"),
9940 (
9941 "multiline",
9942 "#[serial_test::serial(\nconfig_ledger,\n)]\n#[serial_test::serial]",
9943 ),
9944 (
9945 "before-test",
9946 "#[serial(config_ledger)] #[serial] #[cfg(unix)]",
9947 ),
9948 ] {
9949 let source = format!(
9950 "#[test] #[serial] #[serial(config_ledger)] fn first() {{}}\n\
9951 {reverse_attrs} #[test] fn reversed() {{}}"
9952 );
9953 let conflicts = serial_fixture_conflicts(&source);
9954 assert_eq!(conflicts.len(), 1, "{label}: {conflicts:?}");
9955 assert!(conflicts[0].contains("first"), "{label}: {conflicts:?}");
9956 assert!(conflicts[0].contains("reversed"), "{label}: {conflicts:?}");
9957 }
9958 }
9959
9960 #[test]
9961 fn serial_census_compares_third_keys_and_multi_key_lock_order() {
9962 let source = r#"
9963 #[test]
9964 #[serial(config_ledger)]
9965 #[serial(audit_append_failures)]
9966 #[serial(audit_obligation_append_failures)]
9967 fn first() {}
9968 #[test]
9969 #[serial(config_ledger)]
9970 #[serial(audit_obligation_append_failures)]
9971 #[serial(audit_append_failures)]
9972 fn reversed() {}
9973 "#;
9974 let conflicts = serial_fixture_conflicts(source);
9975 assert_eq!(conflicts.len(), 1, "{conflicts:?}");
9976 assert!(conflicts[0].contains("audit_append_failures"));
9977 assert!(conflicts[0].contains("audit_obligation_append_failures"));
9978 assert_eq!(
9979 serial_fixture_conflicts(
9980 "#[test] #[serial(beta, alpha)] fn first() {}\n\
9981 #[test] #[serial(alpha)] #[serial(beta)] fn reversed() {}"
9982 )
9983 .len(),
9984 1
9985 );
9986 }
9987
9988 #[test]
9989 fn serial_census_ignores_attribute_text_and_does_not_absorb_sibling_helpers() {
9990 let source = r##"
9991 // #[test] #[serial(config_ledger)] #[serial] fn comment() {}
9992 const TEXT: &str = r#"#[test] #[serial(config_ledger)] #[serial] fn string() {}"#;
9993 #[test] #[serial] #[serial(config_ledger)] fn actual() {}
9994 fn helper() { with_event_store(store); }
9995 "##;
9996 let tests = census_tests(source).unwrap();
9997 assert_eq!(tests.len(), 1);
9998 assert_eq!(tests[0].name, "actual");
9999 assert!(!tests[0].calls.contains("with_event_store"));
10000 assert!(serial_fixture_conflicts(source).is_empty());
10001 }
10002
10003 #[test]
10004 fn serial_census_body_calls_ignore_literals_and_preserve_macro_tokens() {
10005 let source = r###"
10006 #[test]
10007 fn literals() {
10008 let ordinary = "with_event_store(fake); registry.dispatch(\"context\", fake)";
10009 let raw = r#"with_event_store(fake); registry.dispatch("context", fake)"#;
10010 }
10011 #[test]
10012 fn actual() {
10013 assert!(with_event_store(store).is_ok());
10014 assert_eq!(registry.dispatch("context", params).await.unwrap(), expected);
10015 custom! { branch => registry.dispatch("memory.recall", params); with_event_store(store) }
10016 }
10017 "###;
10018 let tests = census_tests(source).unwrap();
10019 assert_eq!(tests.len(), 2);
10020 assert!(!tests[0].calls.contains("with_event_store"));
10021 assert!(!tests[0].calls.contains("dispatch"));
10022 assert!(tests[0].dispatch_verbs.is_empty());
10023 assert!(tests[1].calls.contains("with_event_store"));
10024 assert!(tests[1].calls.contains("dispatch"));
10025 assert_eq!(
10026 tests[1].dispatch_verbs,
10027 std::collections::BTreeSet::from(["context".into(), "memory.recall".into()])
10028 );
10029 }
10030
10031 #[test]
10032 fn serial_census_uses_first_acquisitions_for_reentrant_keys() {
10033 let source = "#[test] #[serial(alpha, beta)] #[serial(alpha)] fn first() {}\n\
10034 #[test] #[serial(beta)] #[serial(alpha)] fn second() {}";
10035 let tests = census_tests(source).unwrap();
10036 assert_eq!(tests[0].serial_keys, ["alpha", "beta"]);
10037 assert!(serial_fixture_conflicts(source).is_empty());
10038 }
10039
10040 #[test]
10041 fn serial_census_surfaces_source_and_serial_argument_parse_failures() {
10042 assert!(census_tests("#[test] fn broken(").is_err());
10043 let error = census_tests("#[test] #[serial(config_ledger, crate = wrapper)] fn test() {}")
10044 .unwrap_err();
10045 assert!(error
10046 .to_string()
10047 .contains("unsupported serial attribute arguments"));
10048 }
10049
10050 #[test]
10070 fn event_store_test_fixtures_are_config_ledger_serialized() {
10071 let sources = workspace_rust_sources();
10072 assert!(
10073 !sources.is_empty(),
10074 "the workspace source scan found no .rs files under crates/*/src or \
10075 crates/*/tests; the census's own file walk is broken, not the population \
10076 it walks"
10077 );
10078
10079 let base_seed = ["with_event_store", "record_config_locked"];
10080
10081 let mut crate_src_blobs: std::collections::HashMap<String, String> =
10093 std::collections::HashMap::new();
10094 let mut crate_src_bodies: std::collections::HashMap<String, Vec<Vec<(String, String)>>> =
10095 std::collections::HashMap::new();
10096 for (path, text) in &sources {
10097 if !path.components().any(|c| c.as_os_str() == "src") {
10098 continue;
10099 }
10100 let Some(key) = crate_key(path) else {
10101 continue;
10102 };
10103 let blob = crate_src_blobs.entry(key.clone()).or_default();
10104 blob.push_str(text);
10105 blob.push('\n');
10106 crate_src_bodies
10107 .entry(key)
10108 .or_default()
10109 .push(fn_bodies(text));
10110 }
10111 let mut crate_ledger_verbs: std::collections::HashMap<String, Vec<String>> =
10112 std::collections::HashMap::new();
10113 for (crate_name, blob) in &crate_src_blobs {
10114 let empty = Vec::new();
10115 let bodies_by_file = crate_src_bodies.get(crate_name).unwrap_or(&empty);
10116 let producers = crate_seam_names(bodies_by_file, &base_seed);
10117 let verbs: Vec<String> = dispatch_verb_handlers(blob)
10118 .into_iter()
10119 .filter(|(_, handler)| producers.iter().any(|p| p == handler))
10120 .map(|(verb, _)| verb)
10121 .collect();
10122 crate_ledger_verbs.insert(crate_name.clone(), verbs);
10123 }
10124
10125 let mut crate_all_bodies: std::collections::HashMap<String, Vec<Vec<(String, String)>>> =
10148 std::collections::HashMap::new();
10149 for (path, text) in &sources {
10150 let Some(key) = crate_key(path) else {
10151 continue;
10152 };
10153 let bodies: Vec<(String, String)> = fn_bodies(text)
10154 .into_iter()
10155 .filter(|(_, body)| dispatch_verb_handlers(body).len() <= 1)
10156 .collect();
10157 crate_all_bodies.entry(key).or_default().push(bodies);
10158 }
10159 let mut crate_direct_seams: std::collections::HashMap<String, Vec<String>> =
10160 std::collections::HashMap::new();
10161 for (crate_name, bodies_by_file) in &crate_all_bodies {
10162 crate_direct_seams.insert(
10163 crate_name.clone(),
10164 crate_seam_names(bodies_by_file, &base_seed),
10165 );
10166 }
10167
10168 let mut candidate_count = 0usize;
10169 let mut offenders = Vec::new();
10170 let mut serial_orders = SerialLockOrders::default();
10171
10172 for (path, text) in &sources {
10173 let seam_names = file_seam_names(text, &base_seed);
10174 let crate_key_for_path = crate_key(path);
10175 let crate_verbs = crate_key_for_path
10176 .as_deref()
10177 .and_then(|key| crate_ledger_verbs.get(key));
10178 let crate_seams = crate_key_for_path
10179 .as_deref()
10180 .and_then(|key| crate_direct_seams.get(key));
10181 let tests = census_tests(text)
10182 .unwrap_or_else(|error| panic!("{}: census parse failed: {error}", path.display()));
10183 for test in tests {
10184 let name = format!("{}:{}", path.display(), test.name);
10185 serial_orders.record(&name, &test.serial_keys);
10186
10187 let matched_direct = seam_names
10188 .iter()
10189 .chain(crate_seams.into_iter().flatten())
10190 .find(|seam| test.calls.contains(*seam));
10191 let matched: Option<String> = matched_direct.cloned().or_else(|| {
10192 crate_verbs.and_then(|verbs| {
10193 verbs
10194 .iter()
10195 .find(|verb| test.dispatch_verbs.contains(*verb))
10196 .map(|verb| format!("dispatch(\"{verb}\")"))
10197 })
10198 });
10199 let Some(matched) = matched else {
10200 continue;
10201 };
10202 candidate_count += 1;
10203
10204 let has_group = test.serial_keys.iter().any(|key| key == "config_ledger");
10205 if !has_group {
10206 offenders.push(format!(
10207 "{name} (reaches config-ledger seam via `{matched}`)"
10208 ));
10209 }
10210 }
10211 }
10212
10213 assert!(
10214 candidate_count > 0,
10215 "census found zero config-ledger-reaching test candidates across the whole \
10216 workspace scan ({} source files) — the scan is broken, not the \
10217 population it should have found (khive-runtime's own config-ledger \
10218 tests alone are known callers)",
10219 sources.len()
10220 );
10221 assert!(
10222 offenders.is_empty(),
10223 "config-ledger-reaching pack tests must use #[serial(config_ledger)]; \
10224 offenders: {offenders:?}"
10225 );
10226 assert!(
10227 serial_orders.conflicts.is_empty(),
10228 "serial_test lock pairs must have consistent acquisition order across tests; \
10229 keys sort within each attribute, while later attributes acquire first: {:?}",
10230 serial_orders.conflicts
10231 );
10232 }
10233
10234 fn only_git_digest_event(store: &MemoryEventStore) -> Event {
10235 let events: Vec<Event> = store
10236 .events
10237 .lock()
10238 .unwrap()
10239 .iter()
10240 .filter(|event| event.verb == "git.digest")
10241 .cloned()
10242 .collect();
10243 assert_eq!(
10244 events.len(),
10245 1,
10246 "expected exactly one git.digest receipt event"
10247 );
10248 events[0].clone()
10249 }
10250
10251 #[tokio::test]
10252 #[serial(config_ledger)]
10253 async fn git_digest_success_returns_complete_durable_receipt() {
10254 let project_id = uuid::Uuid::new_v4();
10255 let store = Arc::new(MemoryEventStore::default());
10256 let mut builder = VerbRegistryBuilder::new();
10257 builder.register(GitDigestResultPack { project_id });
10258 builder.with_event_store(store.clone());
10259 let registry = builder.build().expect("registry builds");
10260
10261 let result = registry
10262 .dispatch_with_identity(
10263 "git.digest",
10264 serde_json::json!({
10265 "source": "https://user:SECRET@example.invalid/org/repo",
10266 }),
10267 Some(RequestIdentity {
10268 namespace: Namespace::local().as_str().to_string(),
10269 request_id: Some(1_510),
10270 ..Default::default()
10271 }),
10272 )
10273 .await
10274 .expect("durably receipted digest succeeds");
10275 let receipt_id = result["receipt_id"]
10276 .as_str()
10277 .and_then(|raw| raw.parse::<uuid::Uuid>().ok())
10278 .expect("response has UUID receipt_id");
10279
10280 let event = only_git_digest_event(&store);
10281 assert_eq!(event.id, receipt_id);
10282 assert_eq!(event.target_id, Some(project_id));
10283 assert_eq!(event.verb, "git.digest");
10284 assert_eq!(event.outcome, EventOutcome::Success);
10285 assert_eq!(event.payload_schema_version, 2);
10286 assert_eq!(event.payload["result"], result);
10287 assert_eq!(event.payload["resource"]["request_id"], 1_510);
10288 assert_eq!(event.payload["result"]["commits_ingested"], 2);
10289 assert_eq!(event.payload["result"]["issues_ingested"], 3);
10290 assert_eq!(event.payload["result"]["prs_ingested"], 5);
10291 assert!(
10292 !event.payload.to_string().contains("SECRET"),
10293 "receipt must not persist the caller's source URL or credentials"
10294 );
10295 }
10296
10297 #[tokio::test]
10298 #[serial(config_ledger)]
10299 async fn malformed_git_digest_report_appends_one_generic_error_audit() {
10300 let store = Arc::new(MemoryEventStore::default());
10301 let mut builder = VerbRegistryBuilder::new();
10302 builder.register(MalformedGitDigestResultPack);
10303 builder.with_event_store(store.clone());
10304 let registry = builder.build().expect("registry builds");
10305
10306 let err = registry
10307 .dispatch("git.digest", serde_json::json!({}))
10308 .await
10309 .expect_err("malformed receipt identity must fail the response");
10310 assert!(matches!(
10311 err,
10312 RuntimeError::AuditObligation { ref failure, .. }
10313 if failure.message.starts_with("git_digest_receipt_persist_failed:")
10314 ));
10315
10316 let event = only_git_digest_event(&store);
10317 assert_eq!(event.outcome, EventOutcome::Error);
10318 assert_eq!(event.payload_schema_version, 1);
10319 assert!(
10320 event.payload.get("result").is_none(),
10321 "a generic Error audit must not masquerade as a success receipt"
10322 );
10323 let audit: AuditEvent =
10324 serde_json::from_value(event.payload).expect("generic payload remains an AuditEvent");
10325 assert_eq!(audit.verb, "git.digest");
10326 assert_eq!(audit.decision, AuditDecision::Allow);
10327 }
10328
10329 #[tokio::test]
10330 #[serial(config_ledger)]
10331 #[serial(audit_append_failures)]
10332 #[serial(audit_obligation_append_failures)]
10333 async fn git_digest_receipt_append_failure_never_returns_unqualified_success() {
10334 let before = audit_append_failure_count();
10335 let before_obligation = audit_obligation_append_failure_count();
10336 let store = Arc::new(MemoryEventStore {
10337 fail_appends: true,
10338 ..MemoryEventStore::default()
10339 });
10340 let mut builder = VerbRegistryBuilder::new();
10341 builder.register(GitDigestResultPack {
10342 project_id: uuid::Uuid::new_v4(),
10343 });
10344 builder.with_event_store(store);
10345 let registry = builder.build().expect("registry builds");
10346
10347 let err = registry
10348 .dispatch("git.digest", serde_json::json!({}))
10349 .await
10350 .expect_err("receipt persistence failure must fail the response");
10351 assert!(
10352 matches!(&err, RuntimeError::AuditObligation { failure, .. }
10353 if failure.message.starts_with("git_digest_receipt_persist_failed:")
10354 && failure.message.contains("writes may have committed")),
10355 "error is stable, safe, and retry-aware: {err}"
10356 );
10357 assert_eq!(audit_append_failure_count(), before);
10362 assert_eq!(
10363 audit_obligation_append_failure_count(),
10364 before_obligation + 1
10365 );
10366 let runtime = crate::KhiveRuntime::memory().expect("diagnostics runtime");
10369 let report = runtime
10370 .db_diagnostics()
10371 .await
10372 .expect("diagnostics after audit failure");
10373 assert_eq!(
10374 report.writer_contention.audit_obligation_append_failures,
10375 Some(before_obligation + 1)
10376 );
10377 assert_eq!(report.writer_contention.audit_append_failures, Some(before));
10378 assert!(report
10379 .writer_contention
10380 .audit_obligation_append_failures_unavailable_reason
10381 .is_none());
10382 let json = serde_json::to_value(report).expect("serialized diagnostics");
10383 assert_eq!(
10384 json["writer_contention"]["audit_obligation_append_failures"],
10385 before_obligation + 1
10386 );
10387 }
10388
10389 #[tokio::test]
10390 async fn git_digest_without_event_store_fails_safe_after_handler_success() {
10391 let mut builder = VerbRegistryBuilder::new();
10392 builder.register(GitDigestResultPack {
10393 project_id: uuid::Uuid::new_v4(),
10394 });
10395 let registry = builder.build().expect("registry builds");
10396
10397 let err = registry
10398 .dispatch("git.digest", serde_json::json!({}))
10399 .await
10400 .expect_err("a successful digest needs a durable store");
10401 assert!(matches!(
10402 err,
10403 RuntimeError::AuditObligation { ref failure, .. }
10404 if failure.message.starts_with("git_digest_receipt_persist_failed:")
10405 ));
10406 }
10407
10408 #[tokio::test]
10409 #[serial(config_ledger)]
10410 async fn git_digest_gate_unavailable_precedes_the_receipt_contract() {
10411 #[derive(Debug)]
10412 struct FailingGate;
10413 impl Gate for FailingGate {
10414 fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
10415 Err(khive_gate::GateError::Internal(
10416 "injected gate failure".into(),
10417 ))
10418 }
10419 }
10420
10421 let store = Arc::new(MemoryEventStore::default());
10422 let mut builder = VerbRegistryBuilder::new();
10423 builder.register(GitDigestResultPack {
10424 project_id: uuid::Uuid::new_v4(),
10425 });
10426 builder.with_gate(Arc::new(FailingGate));
10427 builder.with_event_store(store.clone());
10428 let registry = builder.build().expect("registry builds");
10429
10430 let err = registry
10431 .dispatch("git.digest", serde_json::json!({}))
10432 .await
10433 .expect_err("gate unavailability must refuse before the handler or receipt path");
10434 assert!(matches!(
10435 err,
10436 RuntimeError::GateUnavailable { ref verb, ref reason }
10437 if verb == "git.digest"
10438 && reason == "gate backend unavailable"
10439 && !reason.contains("injected gate failure")
10440 ));
10441 let event = only_git_digest_event(&store);
10442 assert_eq!(event.outcome, EventOutcome::Error);
10443 assert_eq!(event.payload["decision"], "gate_unavailable");
10444 assert!(event.payload.get("result").is_none());
10445 }
10446
10447 #[tokio::test]
10448 #[serial(config_ledger)]
10449 async fn intercepted_gate_error_returns_typed_refusal_without_invoking_operation() {
10450 #[derive(Debug)]
10451 struct FailingGate;
10452 impl Gate for FailingGate {
10453 fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
10454 Err(khive_gate::GateError::Internal(
10455 "intercepted gate broken".into(),
10456 ))
10457 }
10458 }
10459
10460 let _ = crate::config_ledger::drain_config_locked();
10466
10467 let invoked = Arc::new(AtomicUsize::new(0));
10468 let invoked_by_operation = Arc::clone(&invoked);
10469 let store = Arc::new(MemoryEventStore::default());
10470 let mut builder = VerbRegistryBuilder::new();
10471 builder.with_gate(Arc::new(FailingGate));
10472 builder.with_event_store(store.clone());
10473 let registry = builder.build().expect("registry builds");
10474 let identity = RequestIdentity {
10475 namespace: "identity-default".to_string(),
10476 request_id: Some(1_600),
10477 ..Default::default()
10478 };
10479
10480 let err = registry
10481 .dispatch_intercepted_with_identity(
10482 "list",
10483 &serde_json::json!({"namespace": "test-ns"}),
10484 Some(&identity),
10485 move |_namespace| {
10486 invoked_by_operation.fetch_add(1, Ordering::SeqCst);
10487 async move { Ok(serde_json::json!({"invoked": true})) }
10488 },
10489 )
10490 .await
10491 .expect_err("gate unavailability must refuse intercepted dispatch");
10492
10493 assert!(matches!(
10494 err,
10495 RuntimeError::GateUnavailable { ref verb, ref reason }
10496 if verb == "list"
10497 && reason == "gate backend unavailable"
10498 && !reason.contains("intercepted gate broken")
10499 ));
10500 assert_eq!(
10501 invoked.load(Ordering::SeqCst),
10502 0,
10503 "intercepted operation must not run after a gate infrastructure error"
10504 );
10505
10506 let events = store.events.lock().unwrap();
10507 assert_eq!(events.len(), 1);
10508 assert_eq!(events[0].verb, "list");
10509 assert_eq!(events[0].namespace, "test-ns");
10510 assert_eq!(events[0].outcome, EventOutcome::Error);
10511 assert_eq!(events[0].payload["decision"], "gate_unavailable");
10512 assert!(events[0].payload.get("deny_reason").is_none());
10513 assert_eq!(events[0].payload["resource"]["work_class"], "interactive");
10514 assert_eq!(events[0].payload["resource"]["request_id"], 1_600);
10515 assert!(events[0].payload["resource"].get("cost_unit").is_none());
10516 }
10517
10518 #[tokio::test]
10519 #[serial(config_ledger)]
10520 async fn intercepted_deny_remains_distinct_and_does_not_invoke_operation() {
10521 #[derive(Debug)]
10522 struct DenyingGate;
10523 impl Gate for DenyingGate {
10524 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
10525 Ok(GateDecision::deny("intercepted policy denied"))
10526 }
10527 }
10528
10529 let _ = crate::config_ledger::drain_config_locked();
10532
10533 let invoked = Arc::new(AtomicUsize::new(0));
10534 let invoked_by_operation = Arc::clone(&invoked);
10535 let store = Arc::new(MemoryEventStore::default());
10536 let mut builder = VerbRegistryBuilder::new();
10537 builder.with_gate(Arc::new(DenyingGate));
10538 builder.with_event_store(store.clone());
10539 let registry = builder.build().expect("registry builds");
10540
10541 let err = registry
10542 .dispatch_intercepted_with_identity("list", &Value::Null, None, move |_namespace| {
10543 invoked_by_operation.fetch_add(1, Ordering::SeqCst);
10544 async move { Ok(serde_json::json!({"invoked": true})) }
10545 })
10546 .await
10547 .expect_err("explicit gate denial must refuse intercepted dispatch");
10548
10549 let RuntimeError::PermissionDenied {
10550 verb,
10551 reason,
10552 receipt,
10553 } = err
10554 else {
10555 panic!("expected PermissionDenied, got {err:?}");
10556 };
10557 assert_eq!(verb, "list");
10558 assert_eq!(reason, "intercepted policy denied");
10559 assert_eq!(
10560 receipt.audit_outcome,
10561 crate::error::DenialAuditOutcome::Committed,
10562 "the intercepted path commits its denial row before refusing"
10563 );
10564 assert_eq!(invoked.load(Ordering::SeqCst), 0);
10565
10566 let events = store.events.lock().unwrap();
10567 assert_eq!(events.len(), 1);
10568 assert_eq!(
10569 Some(events[0].id),
10570 receipt.audit_event_id,
10571 "the receipt names the committed row"
10572 );
10573 assert_eq!(events[0].outcome, EventOutcome::Denied);
10574 assert_eq!(events[0].payload["decision"], "deny");
10575 assert_eq!(
10576 events[0].payload["deny_reason"],
10577 "intercepted policy denied"
10578 );
10579 }
10580
10581 #[tokio::test]
10582 #[serial(config_ledger)]
10583 async fn intercepted_denied_dispatch_masks_secret_shaped_deny_reason_in_stored_event() {
10584 #[derive(Debug)]
10593 struct InterceptedSecretDenyGate;
10594 impl Gate for InterceptedSecretDenyGate {
10595 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
10596 let reason = "postgres://svc:not-a-real-secret@internal-host in denied request"; Ok(GateDecision::deny(reason))
10598 }
10599 }
10600
10601 let store = Arc::new(MemoryEventStore::default());
10602 let mut builder = VerbRegistryBuilder::new();
10603 builder.with_gate(Arc::new(InterceptedSecretDenyGate));
10604 builder.with_event_store(store.clone());
10605 let registry = builder.build().expect("registry builds");
10606
10607 let _ = registry
10608 .dispatch_intercepted_with_identity(
10609 "list",
10610 &Value::Null,
10611 None,
10612 move |_namespace| async move { Ok(serde_json::json!({"invoked": true})) },
10613 )
10614 .await
10615 .expect_err("explicit gate denial must refuse intercepted dispatch");
10616
10617 let events = store.events.lock().unwrap();
10618 assert_eq!(events.len(), 1, "exactly one denial row must commit");
10619 let stored_reason = events[0].payload["deny_reason"]
10620 .as_str()
10621 .expect("deny_reason must be a string on the stored row");
10622 assert!(!stored_reason.is_empty());
10623 assert!(
10624 stored_reason.contains("in denied request"),
10625 "non-secret prose must survive masking: {stored_reason:?}"
10626 );
10627 assert!(
10628 !stored_reason.contains("not-a-real-secret"),
10629 "the durable row must never carry the raw credential: {stored_reason:?}"
10630 );
10631 assert!(
10632 stored_reason.contains("***MASKED***"),
10633 "the durable row must record that a credential was redacted: {stored_reason:?}"
10634 );
10635 }
10636
10637 #[tokio::test]
10638 #[serial(config_ledger)]
10639 async fn intercepted_git_digest_uses_the_same_receipt_contract() {
10640 let project_id = uuid::Uuid::new_v4();
10641 let store = Arc::new(MemoryEventStore::default());
10642 let mut builder = VerbRegistryBuilder::new();
10643 builder.with_event_store(store.clone());
10644 let registry = builder.build().expect("registry builds");
10645
10646 let result = registry
10647 .dispatch_intercepted_with_identity(
10648 "git.digest",
10649 &serde_json::json!({}),
10650 Some(&RequestIdentity {
10651 namespace: Namespace::local().as_str().to_string(),
10652 request_id: Some(1_647),
10653 ..Default::default()
10654 }),
10655 |_namespace| async move {
10656 Ok(serde_json::json!({
10657 "project_id": project_id,
10658 "commits_ingested": 7,
10659 "done": true,
10660 }))
10661 },
10662 )
10663 .await
10664 .expect("intercepted digest is durably receipted");
10665
10666 let event = only_git_digest_event(&store);
10667 assert_eq!(result["receipt_id"], serde_json::json!(event.id));
10668 assert_eq!(event.payload["result"], result);
10669 assert_eq!(event.payload["resource"]["request_id"], 1_647);
10670 }
10671
10672 #[tokio::test]
10673 #[serial(config_ledger)]
10674 async fn intercepted_git_digest_receipt_preserves_typed_metadata() {
10675 let project_id = uuid::Uuid::new_v4();
10676 let store = Arc::new(MemoryEventStore::default());
10677 let mut builder = VerbRegistryBuilder::new();
10678 builder.with_event_store(store.clone());
10679 let registry = builder.build().expect("registry builds");
10680
10681 let outcome = registry
10682 .dispatch_intercepted_with_metadata_with_identity(
10683 "git.digest",
10684 &serde_json::json!({}),
10685 None,
10686 |_namespace| async move {
10687 Ok(InterceptedDispatchResult::new(
10688 serde_json::json!({
10689 "project_id": project_id,
10690 "commits_ingested": 3,
10691 "done": true,
10692 }),
10693 vec!["backend-a".to_string(), "backend-b".to_string()],
10694 ))
10695 },
10696 )
10697 .await
10698 .expect("metadata-bearing digest is durably receipted");
10699
10700 let event = only_git_digest_event(&store);
10701 assert_eq!(outcome.result["receipt_id"], serde_json::json!(event.id));
10702 assert_eq!(event.payload["result"], outcome.result);
10703 assert_eq!(outcome.metadata, ["backend-a", "backend-b"]);
10704 }
10705
10706 #[tokio::test]
10707 #[serial(config_ledger)]
10708 async fn intercepted_malformed_git_digest_appends_one_generic_error_audit() {
10709 let store = Arc::new(MemoryEventStore::default());
10710 let mut builder = VerbRegistryBuilder::new();
10711 builder.with_event_store(store.clone());
10712 let registry = builder.build().expect("registry builds");
10713
10714 let err = registry
10715 .dispatch_intercepted_with_identity(
10716 "git.digest",
10717 &serde_json::json!({}),
10718 None,
10719 |_namespace| async {
10720 Ok(serde_json::json!({
10721 "project_id": "not-a-uuid",
10722 "done": true,
10723 }))
10724 },
10725 )
10726 .await
10727 .expect_err("malformed intercepted receipt must fail the response");
10728 assert!(matches!(
10729 err,
10730 RuntimeError::AuditObligation { ref failure, .. }
10731 if failure.message.starts_with("git_digest_receipt_persist_failed:")
10732 ));
10733
10734 let event = only_git_digest_event(&store);
10735 assert_eq!(event.outcome, EventOutcome::Error);
10736 assert_eq!(event.payload_schema_version, 1);
10737 assert!(event.payload.get("result").is_none());
10738 let audit: AuditEvent =
10739 serde_json::from_value(event.payload).expect("generic payload remains an AuditEvent");
10740 assert_eq!(audit.verb, "git.digest");
10741 assert_eq!(audit.decision, AuditDecision::Allow);
10742 }
10743
10744 #[tokio::test]
10745 async fn allow_all_gate_default_remains_backward_compatible() {
10746 let mut builder = VerbRegistryBuilder::new();
10748 builder.register(AlphaPack);
10749 let reg = builder.build().expect("registry builds");
10750
10751 let res = reg.dispatch("list", Value::Null).await.unwrap();
10752 assert_eq!(
10753 res["pack"], "alpha",
10754 "AllowAllGate must allow every verb — backward compat guarantee"
10755 );
10756 let res = reg.dispatch("create", Value::Null).await.unwrap();
10757 assert_eq!(res["pack"], "alpha");
10758 }
10759
10760 #[tokio::test]
10761 async fn deny_gate_returns_permission_denied_pack_never_invoked() {
10762 #[derive(Debug)]
10763 struct AlwaysDenyGate;
10764 impl Gate for AlwaysDenyGate {
10765 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
10766 Ok(GateDecision::deny("test: always deny"))
10767 }
10768 }
10769
10770 #[derive(Debug)]
10772 struct TrackedPack {
10773 invoked: Arc<AtomicUsize>,
10774 }
10775
10776 impl khive_types::Pack for TrackedPack {
10777 const NAME: &'static str = "tracked";
10778 const NOTE_KINDS: &'static [&'static str] = &[];
10779 const ENTITY_KINDS: &'static [&'static str] = &[];
10780 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
10781 name: "guarded",
10782 description: "a guarded verb",
10783 visibility: Visibility::Verb,
10784 category: VerbCategory::Assertive,
10785 params: &[],
10786 }];
10787 }
10788
10789 #[async_trait]
10790 impl PackRuntime for TrackedPack {
10791 fn name(&self) -> &str {
10792 Self::NAME
10793 }
10794 fn note_kinds(&self) -> &'static [&'static str] {
10795 Self::NOTE_KINDS
10796 }
10797 fn entity_kinds(&self) -> &'static [&'static str] {
10798 Self::ENTITY_KINDS
10799 }
10800 fn handlers(&self) -> &'static [HandlerDef] {
10801 Self::HANDLERS
10802 }
10803 async fn dispatch(
10804 &self,
10805 _verb: &str,
10806 _params: Value,
10807 _registry: &VerbRegistry,
10808 _token: &NamespaceToken,
10809 ) -> Result<Value, RuntimeError> {
10810 self.invoked.fetch_add(1, Ordering::SeqCst);
10811 Ok(serde_json::json!({"invoked": true}))
10812 }
10813 }
10814
10815 let invoked = Arc::new(AtomicUsize::new(0));
10816 let mut builder = VerbRegistryBuilder::new();
10817 builder.register(TrackedPack {
10818 invoked: invoked.clone(),
10819 });
10820 builder.with_gate(Arc::new(AlwaysDenyGate));
10821 let reg = builder.build().expect("registry builds");
10822
10823 let err = reg.dispatch("guarded", Value::Null).await.unwrap_err();
10824 assert!(
10825 matches!(err, RuntimeError::PermissionDenied { ref verb, ref reason, .. } if verb == "guarded" && reason.contains("always deny")),
10826 "expected PermissionDenied with verb=guarded and reason, got: {err:?}"
10827 );
10828 assert_eq!(
10829 invoked.load(Ordering::SeqCst),
10830 0,
10831 "pack dispatch MUST NOT be invoked when gate denies"
10832 );
10833 }
10834
10835 #[tokio::test]
10836 async fn update_denial_precedes_id_existence_resolution() {
10837 #[derive(Debug)]
10838 struct AlwaysDenyUpdateGate {
10839 checked: Arc<AtomicUsize>,
10840 }
10841 impl Gate for AlwaysDenyUpdateGate {
10842 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
10843 self.checked.fetch_add(1, Ordering::SeqCst);
10844 Ok(GateDecision::deny("caller has no update capability"))
10845 }
10846 }
10847
10848 #[derive(Debug)]
10849 struct ExistenceOracleUpdatePack {
10850 existing_id: String,
10851 invoked: Arc<AtomicUsize>,
10852 }
10853
10854 impl khive_types::Pack for ExistenceOracleUpdatePack {
10855 const NAME: &'static str = "existence_oracle";
10856 const NOTE_KINDS: &'static [&'static str] = &[];
10857 const ENTITY_KINDS: &'static [&'static str] = &[];
10858 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
10859 name: "update",
10860 description: "distinguish a present id from an absent id",
10861 visibility: Visibility::Verb,
10862 category: VerbCategory::Declaration,
10863 params: &[],
10864 }];
10865 }
10866
10867 #[async_trait]
10868 impl PackRuntime for ExistenceOracleUpdatePack {
10869 fn name(&self) -> &str {
10870 Self::NAME
10871 }
10872 fn note_kinds(&self) -> &'static [&'static str] {
10873 Self::NOTE_KINDS
10874 }
10875 fn entity_kinds(&self) -> &'static [&'static str] {
10876 Self::ENTITY_KINDS
10877 }
10878 fn handlers(&self) -> &'static [HandlerDef] {
10879 Self::HANDLERS
10880 }
10881 async fn dispatch(
10882 &self,
10883 _verb: &str,
10884 params: Value,
10885 _registry: &VerbRegistry,
10886 _token: &NamespaceToken,
10887 ) -> Result<Value, RuntimeError> {
10888 self.invoked.fetch_add(1, Ordering::SeqCst);
10889 match params.get("id").and_then(Value::as_str) {
10890 Some(id) if id == self.existing_id => Ok(serde_json::json!({"updated": id})),
10891 _ => Err(RuntimeError::NotFound("record".to_string())),
10892 }
10893 }
10894 }
10895
10896 let existing_id = uuid::Uuid::new_v4().to_string();
10897 let absent_id = uuid::Uuid::new_v4().to_string();
10898 let invoked = Arc::new(AtomicUsize::new(0));
10899 let checked = Arc::new(AtomicUsize::new(0));
10900
10901 let pack = || ExistenceOracleUpdatePack {
10902 existing_id: existing_id.clone(),
10903 invoked: Arc::clone(&invoked),
10904 };
10905
10906 let mut control_builder = VerbRegistryBuilder::new();
10907 control_builder.register(pack());
10908 let control = control_builder.build().expect("control registry builds");
10909 control
10910 .dispatch("update", serde_json::json!({"id": existing_id.clone()}))
10911 .await
10912 .expect("positive control resolves the present id");
10913 assert!(matches!(
10914 control
10915 .dispatch("update", serde_json::json!({"id": absent_id.clone()}))
10916 .await,
10917 Err(RuntimeError::NotFound(_))
10918 ));
10919 assert_eq!(invoked.load(Ordering::SeqCst), 2);
10920
10921 let mut denied_builder = VerbRegistryBuilder::new();
10922 denied_builder.register(pack());
10923 denied_builder.with_gate(Arc::new(AlwaysDenyUpdateGate {
10924 checked: Arc::clone(&checked),
10925 }));
10926 let denied = denied_builder.build().expect("denied registry builds");
10927
10928 let present_error = denied
10929 .dispatch("update", serde_json::json!({"id": existing_id.clone()}))
10930 .await
10931 .expect_err("denied present-id update must not resolve the id");
10932 let absent_error = denied
10933 .dispatch("update", serde_json::json!({"id": absent_id.clone()}))
10934 .await
10935 .expect_err("denied absent-id update must not resolve the id");
10936
10937 let denial = |error: RuntimeError| match error {
10938 RuntimeError::PermissionDenied { verb, reason, .. } => (verb, reason),
10939 other => panic!("expected gate refusal, got {other:?}"),
10940 };
10941 let present_denial = denial(present_error);
10942 let absent_denial = denial(absent_error);
10943 assert_eq!(present_denial.0, "update");
10944 assert_eq!(present_denial.1, "caller has no update capability");
10945 assert_eq!(present_denial, absent_denial);
10946 assert_eq!(
10947 checked.load(Ordering::SeqCst),
10948 2,
10949 "both denied requests must consult the configured gate"
10950 );
10951 assert_eq!(
10952 invoked.load(Ordering::SeqCst),
10953 2,
10954 "neither denied request may reach the existence oracle"
10955 );
10956 }
10957
10958 #[tokio::test]
10959 #[serial(config_ledger)]
10960 async fn runtime_audit_sink_uses_final_namespace_in_both_builder_orders() {
10961 for namespace_first in [true, false] {
10962 let runtime = KhiveRuntime::memory().expect("memory runtime");
10963 runtime
10964 .raw_events_for_namespace("local")
10965 .expect("local sink")
10966 .append_event(Event::new(
10967 "local",
10968 "list",
10969 EventKind::Audit,
10970 SubstrateKind::Event,
10971 "actor:unrelated",
10972 ))
10973 .await
10974 .expect("local control event");
10975
10976 let mut builder = VerbRegistryBuilder::new();
10977 builder.register(AlphaPack);
10978 builder.with_actor_id(Some("lambda:dispatcher".to_string()));
10979 if namespace_first {
10980 builder.with_default_namespace("audit-tenant");
10981 }
10982 builder
10983 .with_runtime_event_store(&runtime)
10984 .expect("configure runtime sink");
10985 if !namespace_first {
10986 builder.with_default_namespace("audit-tenant");
10987 }
10988 let registry = builder.build().expect("registry builds");
10989 registry
10990 .dispatch("list", serde_json::json!({}))
10991 .await
10992 .expect("dispatch persists its audit");
10993
10994 let page = registry
10997 .event_store()
10998 .expect("registry retains the sink")
10999 .query_events(
11000 EventFilter {
11001 verbs: vec!["list".to_string()],
11002 ..EventFilter::default()
11003 },
11004 PageRequest {
11005 limit: 10,
11006 offset: 0,
11007 },
11008 )
11009 .await
11010 .expect("query the registry's sink");
11011 assert_eq!(page.items.len(), 1, "namespace_first={namespace_first}");
11012 let event = &page.items[0];
11013 assert_eq!(event.namespace, "audit-tenant");
11014 assert_eq!(event.actor, "actor:lambda:dispatcher");
11015 assert_eq!(event.outcome, EventOutcome::Success);
11016 }
11017 }
11018
11019 #[tokio::test]
11020 #[serial(config_ledger)]
11021 async fn runtime_audit_sink_configuration_preserves_last_setter() {
11022 #[derive(Clone, Copy, Debug)]
11023 enum Sink {
11024 Runtime,
11025 Custom,
11026 ReadOnly,
11027 }
11028
11029 let runtime = KhiveRuntime::memory().expect("memory runtime");
11030 runtime
11031 .raw_events_for_namespace("audit-tenant")
11032 .expect("runtime sink")
11033 .append_event(Event::new(
11034 "audit-tenant",
11035 "audit.fixture",
11036 EventKind::Audit,
11037 SubstrateKind::Event,
11038 "actor:creator",
11039 ))
11040 .await
11041 .expect("runtime control event");
11042 let custom: Arc<dyn EventStore> = Arc::new(MemoryEventStore::default());
11043
11044 for (first, last) in [
11045 (Sink::Runtime, Sink::Custom),
11046 (Sink::Runtime, Sink::ReadOnly),
11047 (Sink::Custom, Sink::Runtime),
11048 (Sink::Custom, Sink::ReadOnly),
11049 (Sink::ReadOnly, Sink::Runtime),
11050 (Sink::ReadOnly, Sink::Custom),
11051 ] {
11052 let mut builder = VerbRegistryBuilder::new();
11053 builder.with_default_namespace("audit-tenant");
11054 for sink in [first, last] {
11055 match sink {
11056 Sink::Runtime => {
11057 builder
11058 .with_runtime_event_store(&runtime)
11059 .expect("configure runtime sink");
11060 }
11061 Sink::Custom => {
11062 builder.with_event_store(custom.clone());
11063 }
11064 Sink::ReadOnly => {
11065 builder.with_read_only_audit_store();
11066 }
11067 }
11068 }
11069 let registry = builder.build().expect("registry builds");
11070 match last {
11071 Sink::Runtime => {
11072 let store = registry.event_store().expect("runtime sink wins");
11073 assert!(!Arc::ptr_eq(&store, &custom));
11074 assert_eq!(
11075 store.count_events(EventFilter::default()).await.unwrap(),
11076 1,
11077 "runtime event remains readable after {first:?}"
11078 );
11079 assert!(registry.audit_persistence_advisory().is_none());
11080 assert!(registry.audit_batch_metrics().is_some());
11081 }
11082 Sink::Custom => {
11083 assert!(Arc::ptr_eq(
11084 ®istry.event_store().expect("custom sink wins"),
11085 &custom
11086 ));
11087 assert!(registry.audit_persistence_advisory().is_none());
11088 assert!(registry.audit_batch_metrics().is_some());
11089 }
11090 Sink::ReadOnly => {
11091 assert!(registry.event_store().is_none());
11092 assert!(registry.audit_persistence_advisory().is_some());
11093 assert!(registry.audit_batch_metrics().is_none());
11094 }
11095 }
11096 }
11097 }
11098
11099 #[test]
11100 #[serial(config_ledger)]
11101 fn runtime_audit_sink_is_not_bound_when_replaced_or_building_metadata() {
11102 let directory = tempfile::tempdir().expect("temporary directory");
11103 let runtime = KhiveRuntime::new(crate::runtime::RuntimeConfig {
11104 db_path: None,
11105 packs: vec![],
11106 brain_profile: None,
11107 actor_id: None,
11108 events_split: Some(crate::events_split::EventsSplitConfig {
11109 db_path: directory.path().to_path_buf(),
11110 socket_path: None,
11111 }),
11112 ..crate::runtime::RuntimeConfig::no_embeddings()
11113 })
11114 .expect("runtime creation does not open the events sink");
11115
11116 let mut metadata = VerbRegistryBuilder::new();
11117 metadata.register(AlphaPack);
11118 metadata
11119 .with_runtime_event_store(&runtime)
11120 .expect("configuration defers the invalid sink");
11121 let metadata = metadata.build_metadata().expect("metadata needs no sink");
11122 assert!(metadata.has_verb("list"));
11123 assert!(metadata.registry.event_store().is_none());
11124 assert!(metadata.registry.audit_batch_metrics().is_none());
11125
11126 let mut custom = VerbRegistryBuilder::new();
11127 custom.with_runtime_event_store(&runtime).unwrap();
11128 custom.with_event_store(Arc::new(MemoryEventStore::default()));
11129 assert!(custom
11130 .build()
11131 .expect("custom replaces runtime")
11132 .event_store()
11133 .is_some());
11134
11135 let mut read_only = VerbRegistryBuilder::new();
11136 read_only.with_runtime_event_store(&runtime).unwrap();
11137 read_only.with_read_only_audit_store();
11138 assert!(read_only
11139 .build()
11140 .expect("read-only replaces runtime")
11141 .event_store()
11142 .is_none());
11143
11144 let mut serving = VerbRegistryBuilder::new();
11145 serving.with_runtime_event_store(&runtime).unwrap();
11146 assert!(
11147 serving.build().is_err(),
11148 "serving build must surface the error opening a directory as an events database"
11149 );
11150 }
11151
11152 #[tokio::test]
11153 #[serial(config_ledger)]
11154 async fn audit_event_persists_to_event_store_on_allow() {
11155 let store = Arc::new(MemoryEventStore::default());
11156 let mut builder = VerbRegistryBuilder::new();
11157 builder.register(AlphaPack);
11158 builder.with_event_store(store.clone());
11159 let reg = builder.build().expect("registry builds");
11160
11161 reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
11162 .await
11163 .unwrap();
11164
11165 let count = store.count_events(EventFilter::default()).await.unwrap();
11166 assert_eq!(count, 1, "one audit event persisted to EventStore on allow");
11167
11168 let page = store
11169 .query_events(
11170 EventFilter::default(),
11171 PageRequest {
11172 limit: 10,
11173 offset: 0,
11174 },
11175 )
11176 .await
11177 .unwrap();
11178 let ev = &page.items[0];
11179 assert_eq!(ev.verb, "list");
11180 assert_eq!(ev.namespace, "test-ns");
11181 assert_eq!(ev.substrate, SubstrateKind::Event);
11182 assert_eq!(ev.outcome, EventOutcome::Success);
11183 }
11184
11185 #[tokio::test]
11186 #[serial(config_ledger)]
11187 #[serial(audit_append_failures)]
11188 #[serial(audit_obligation_append_failures)]
11189 async fn audit_append_failure_fails_an_obligation_bearing_dispatch() {
11190 let before = audit_append_failure_count();
11191 let before_obligation = audit_obligation_append_failure_count();
11192
11193 let successful_store = Arc::new(MemoryEventStore::default());
11194 let mut successful_builder = VerbRegistryBuilder::new();
11195 successful_builder.register(AlphaPack);
11196 successful_builder.with_event_store(successful_store);
11197 let successful_registry = successful_builder.build().expect("registry builds");
11198 successful_registry
11199 .dispatch("list", Value::Null)
11200 .await
11201 .expect("successful audit append must not affect dispatch");
11202 assert_eq!(
11203 audit_append_failure_count(),
11204 before,
11205 "successful audit appends must not increment the swallowed-failure counter"
11206 );
11207 assert_eq!(
11208 audit_obligation_append_failure_count(),
11209 before_obligation,
11210 "successful audit appends must not increment the obligation-failure counter"
11211 );
11212
11213 let failing_store = Arc::new(MemoryEventStore {
11219 fail_appends: true,
11220 ..MemoryEventStore::default()
11221 });
11222 let mut failing_builder = VerbRegistryBuilder::new();
11223 failing_builder.register(AlphaPack);
11224 failing_builder.with_event_store(failing_store);
11225 let failing_registry = failing_builder.build().expect("registry builds");
11226 let err = failing_registry
11227 .dispatch("list", Value::Null)
11228 .await
11229 .expect_err(
11230 "a persistent obligation-bearing audit commit failure must fail the dispatch",
11231 );
11232 assert!(
11233 matches!(&err, RuntimeError::AuditObligation { failure, .. }
11234 if failure.message.contains("audit obligation commit failed")),
11235 "error names the obligation failure so it is distinguishable from a handler error: {err}"
11236 );
11237
11238 assert_eq!(
11242 audit_append_failure_count(),
11243 before,
11244 "an obligation failure must never inflate the swallowed-failure counter"
11245 );
11246 assert_eq!(
11247 audit_obligation_append_failure_count(),
11248 before_obligation + 1,
11249 "the failed obligation append must remain visible to diagnostics"
11250 );
11251 }
11252
11253 #[tokio::test]
11288 #[serial(config_ledger)]
11289 #[serial(audit_append_failures)]
11290 #[serial(audit_obligation_append_failures)]
11291 async fn obligation_failure_reports_a_write_that_already_committed() {
11292 const RETURNED_TOTAL: u64 = 41;
11298
11299 #[derive(Debug)]
11300 struct RecordingWritePack {
11301 committed: Arc<std::sync::Mutex<Vec<TraceEntry>>>,
11302 }
11303
11304 impl Pack for RecordingWritePack {
11305 const NAME: &'static str = "recording_write";
11306 const NOTE_KINDS: &'static [&'static str] = &[];
11307 const ENTITY_KINDS: &'static [&'static str] = &[];
11308 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
11316 name: "knowledge.index",
11317 description: "record one committed write",
11318 visibility: Visibility::Verb,
11319 category: VerbCategory::Commissive,
11320 params: &[],
11321 }];
11322 }
11323
11324 #[async_trait]
11325 impl PackRuntime for RecordingWritePack {
11326 fn name(&self) -> &str {
11327 Self::NAME
11328 }
11329 fn note_kinds(&self) -> &'static [&'static str] {
11330 Self::NOTE_KINDS
11331 }
11332 fn entity_kinds(&self) -> &'static [&'static str] {
11333 Self::ENTITY_KINDS
11334 }
11335 fn handlers(&self) -> &'static [HandlerDef] {
11336 Self::HANDLERS
11337 }
11338 async fn dispatch(
11339 &self,
11340 _verb: &str,
11341 params: Value,
11342 _registry: &VerbRegistry,
11343 _token: &NamespaceToken,
11344 ) -> Result<Value, RuntimeError> {
11345 let name = params
11350 .get("name")
11351 .and_then(Value::as_str)
11352 .unwrap_or("unnamed")
11353 .to_string();
11354 self.committed
11355 .lock()
11356 .expect("committed lock")
11357 .push(TraceEntry::Effect { name: name.clone() });
11358 Ok(serde_json::json!({ "created": name, "total": RETURNED_TOTAL }))
11359 }
11360 }
11361
11362 let committed = Arc::new(std::sync::Mutex::new(Vec::new()));
11363 let failing_store = Arc::new(MemoryEventStore {
11364 fail_appends: true,
11365 trace: Some(Arc::clone(&committed)),
11366 ..MemoryEventStore::default()
11367 });
11368 let mut builder = VerbRegistryBuilder::new();
11369 builder.register(RecordingWritePack {
11370 committed: Arc::clone(&committed),
11371 });
11372 builder.with_event_store(failing_store);
11373 let registry = builder.build().expect("registry builds");
11374
11375 let err = registry
11376 .dispatch("knowledge.index", serde_json::json!({"name": "first"}))
11377 .await
11378 .expect_err("an obligation commit failure must fail the dispatch");
11379 assert!(
11380 matches!(&err, RuntimeError::AuditObligation { failure, .. }
11381 if failure.message.contains("audit obligation commit failed")),
11382 "the error must name the obligation failure, since that string is what tells a \
11383 caller the effect landed: {err}"
11384 );
11385
11386 let first_pass = committed.lock().expect("committed lock").clone();
11392 let first_effect = TraceEntry::Effect {
11393 name: "first".to_string(),
11394 };
11395 assert_eq!(
11396 first_pass.first(),
11397 Some(&first_effect),
11398 "the handler's effect must land FIRST: an implementation that submitted the \
11399 audit row before dispatching would satisfy every other assertion here"
11400 );
11401 let audit_rows: Vec<&TraceEntry> = first_pass
11402 .iter()
11403 .filter(|entry| matches!(entry, TraceEntry::Audit { .. }))
11404 .collect();
11405 assert!(
11406 !audit_rows.is_empty(),
11407 "an audit row must actually be SUBMITTED; without this assertion the test \
11408 passes against an implementation that returns the same error and never \
11409 builds a row at all, which is a different defect wearing the same error \
11410 string. Trace was {first_pass:?}"
11411 );
11412 let expected_cost_unit = serde_json::json!(RETURNED_TOTAL + 1);
11413 assert!(
11414 audit_rows.iter().any(|entry| matches!(
11415 entry,
11416 TraceEntry::Audit {
11417 outcome: EventOutcome::Success,
11418 verb,
11419 cost_unit: Some(cost_unit),
11420 ..
11421 } if verb.as_str() == "knowledge.index" && *cost_unit == expected_cost_unit
11422 )),
11423 "the submitted row must be the SUCCESS row for THIS verb, carrying a resource \
11424 computed FROM the handler's return value. The outcome alone is not enough: an \
11425 implementation that stamps Success on a row built without `ok_val` would \
11426 satisfy an outcome-only assertion while breaking the contract this test \
11427 exists for. The exact value is the discriminator, not the key's presence: \
11428 `resource_payload` inserts `cost_unit` unconditionally, so presence survives a \
11429 static `ok_val`, while {expected_cost_unit} is reachable only from the \
11430 returned `total` of {RETURNED_TOTAL} (`base_weight` 1 + `item_count` \
11431 {RETURNED_TOTAL} * `model_count` 1). Substituting a null or static result \
11432 collapses it to 1, and the error path's `base_resource_payload` omits the key \
11433 entirely (`cost_unit: None` here), so all three implementations are \
11434 distinguishable. Trace was {first_pass:?}"
11435 );
11436 assert_eq!(
11437 first_pass
11438 .iter()
11439 .filter(|entry| **entry == first_effect)
11440 .count(),
11441 1,
11442 "exactly one effect on the first pass"
11443 );
11444
11445 let _ = registry
11447 .dispatch("knowledge.index", serde_json::json!({"name": "first"}))
11448 .await
11449 .expect_err("the retry fails the same way");
11450 assert_eq!(
11451 committed
11452 .lock()
11453 .expect("committed lock")
11454 .iter()
11455 .filter(|entry| **entry == first_effect)
11456 .count(),
11457 2,
11458 "retrying this error double-writes; a caller must re-derive state instead of \
11459 resubmitting"
11460 );
11461 }
11462
11463 #[tokio::test]
11464 #[serial(config_ledger)]
11465 #[serial(audit_append_failures)]
11466 #[serial(audit_obligation_append_failures)]
11467 async fn config_locked_row_degrades_without_failing_the_dispatch_that_observed_it() {
11468 #[derive(Debug)]
11478 struct DenyGate;
11479 impl Gate for DenyGate {
11480 fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
11481 Ok(GateDecision::Deny {
11482 reason: "denied for test".to_string(),
11483 })
11484 }
11485 }
11486
11487 crate::config_ledger::record_config_locked("adr133_test_key", "adr133_test_value");
11488
11489 let store = Arc::new(MemoryEventStore {
11490 fail_appends: true,
11491 ..MemoryEventStore::default()
11492 });
11493 let mut builder = VerbRegistryBuilder::new();
11494 builder.register(AlphaPack);
11495 builder.with_gate(Arc::new(DenyGate));
11496 builder.with_event_store(store);
11497 let registry = builder.build().expect("registry builds");
11498
11499 let err = registry
11500 .dispatch("list", Value::Null)
11501 .await
11502 .expect_err("the gate denies every request");
11503 assert!(
11504 matches!(err, RuntimeError::PermissionDenied { .. }),
11505 "a pure-observability row's failure must never surface as the dispatch error: {err}"
11506 );
11507
11508 let metrics = registry
11509 .audit_batch_metrics()
11510 .expect("with_event_store configures the ADR-133 seam");
11511 assert!(
11512 metrics.degraded,
11513 "the config-locked row's commit failure must be visible as degradation"
11514 );
11515 assert!(metrics.degraded_rows >= 1);
11516 }
11517
11518 #[tokio::test]
11519 #[serial(config_ledger)]
11520 #[serial(audit_append_failures)]
11521 async fn config_locked_row_failure_never_fails_a_dispatch_that_would_otherwise_succeed() {
11522 crate::config_ledger::record_config_locked(
11529 "adr133_success_path_key",
11530 "adr133_success_path_value",
11531 );
11532
11533 let store = Arc::new(MemoryEventStore {
11534 fail_kind: Some(EventKind::ConfigLocked),
11535 ..MemoryEventStore::default()
11536 });
11537 let mut builder = VerbRegistryBuilder::new();
11538 builder.register(AlphaPack);
11539 builder.with_event_store(store);
11540 let registry = builder.build().expect("registry builds");
11541
11542 let result = registry
11543 .dispatch("list", Value::Null)
11544 .await
11545 .expect("a config-locked row's commit failure must never fail an unrelated dispatch");
11546 assert_eq!(
11547 result,
11548 serde_json::json!({ "pack": "alpha", "verb": "list" })
11549 );
11550
11551 let metrics = registry
11552 .audit_batch_metrics()
11553 .expect("with_event_store configures the ADR-133 seam");
11554 assert!(
11555 metrics.degraded_rows >= 1,
11556 "the config-locked row's failure must remain visible as degradation"
11557 );
11558 }
11559
11560 #[derive(Default)]
11565 struct LegacyEventStore {
11566 events: std::sync::Mutex<Vec<Event>>,
11567 }
11568
11569 #[async_trait]
11570 impl EventStore for LegacyEventStore {
11571 async fn append_event(&self, event: Event) -> khive_storage::StorageResult<()> {
11572 self.events.lock().unwrap().push(event);
11573 Ok(())
11574 }
11575 async fn append_events(
11576 &self,
11577 events: Vec<Event>,
11578 ) -> khive_storage::StorageResult<BatchWriteSummary> {
11579 let attempted = events.len() as u64;
11580 self.events.lock().unwrap().extend(events);
11581 Ok(BatchWriteSummary {
11582 attempted,
11583 affected: attempted,
11584 ..BatchWriteSummary::default()
11585 })
11586 }
11587 async fn get_event(&self, id: uuid::Uuid) -> khive_storage::StorageResult<Option<Event>> {
11588 Ok(self
11589 .events
11590 .lock()
11591 .unwrap()
11592 .iter()
11593 .find(|e| e.id == id)
11594 .cloned())
11595 }
11596 async fn query_events(
11597 &self,
11598 _filter: EventFilter,
11599 _page: PageRequest,
11600 ) -> khive_storage::StorageResult<Page<Event>> {
11601 let items = self.events.lock().unwrap().clone();
11602 let total = items.len() as u64;
11603 Ok(Page {
11604 items,
11605 total: Some(total),
11606 })
11607 }
11608 async fn count_events(&self, _filter: EventFilter) -> khive_storage::StorageResult<u64> {
11609 Ok(self.events.lock().unwrap().len() as u64)
11610 }
11611 }
11612
11613 #[test]
11614 #[serial(config_ledger)]
11615 fn build_rejects_a_configured_event_store_incompatible_with_the_audit_batch_seam() {
11616 let mut builder = VerbRegistryBuilder::new();
11617 builder.register(AlphaPack);
11618 builder.with_event_store(Arc::new(LegacyEventStore::default()));
11619 let err = match builder.build() {
11620 Ok(_) => {
11621 panic!("a store that cannot implement the seam must not build a healthy registry")
11622 }
11623 Err(err) => err,
11624 };
11625 assert!(
11626 matches!(&err, RuntimeError::IncompatibleEventStore(message)
11627 if message.contains("supports_idempotent_audit_batch")),
11628 "error names the missing capability so an operator can act on it: {err}"
11629 );
11630 }
11631
11632 #[tokio::test]
11633 #[serial(config_ledger)]
11634 #[serial(audit_append_failures)]
11635 #[serial(audit_obligation_append_failures)]
11636 async fn db_diagnostics_with_audit_metrics_reports_batch_failure_and_degradation() {
11637 crate::config_ledger::record_config_locked(
11644 "adr133_diag_test_key",
11645 "adr133_diag_test_value",
11646 );
11647 let store = Arc::new(MemoryEventStore {
11648 fail_appends: true,
11649 ..MemoryEventStore::default()
11650 });
11651 let mut builder = VerbRegistryBuilder::new();
11652 builder.register(AlphaPack);
11653 builder.with_event_store(store);
11654 let registry = builder.build().expect("registry builds");
11655 let _ = registry.dispatch("list", Value::Null).await;
11656
11657 let metrics = registry
11658 .audit_batch_metrics()
11659 .expect("with_event_store configures the ADR-133 seam");
11660 assert!(metrics.degraded, "the config-locked row must have degraded");
11661 assert!(metrics.degraded_rows >= 1);
11662 assert!(
11663 metrics.flush_failures >= 1,
11664 "the list dispatch's obligation row must count as a flush failure"
11665 );
11666
11667 let rt = KhiveRuntime::memory().expect("memory runtime should create");
11668 let report = rt
11669 .db_diagnostics_with_audit_metrics(Some(metrics))
11670 .await
11671 .expect("diagnostics succeed");
11672 assert_eq!(report.writer_contention.audit_degraded, Some(true));
11673 assert!(report.writer_contention.audit_degraded_rows.unwrap_or(0) >= 1);
11674 assert!(
11675 report
11676 .writer_contention
11677 .audit_batch_flush_failures
11678 .unwrap_or(0)
11679 >= 1
11680 );
11681 assert!(report
11682 .writer_contention
11683 .audit_batch_flush_failures_unavailable_reason
11684 .is_none());
11685 assert!(report
11686 .writer_contention
11687 .audit_degraded_unavailable_reason
11688 .is_none());
11689
11690 let bare_report = rt.db_diagnostics().await.expect("diagnostics succeed");
11696 assert!(bare_report.writer_contention.audit_degraded.is_none());
11697 assert!(bare_report
11698 .writer_contention
11699 .audit_degraded_unavailable_reason
11700 .is_some());
11701 assert!(bare_report
11702 .writer_contention
11703 .audit_admission_refused_obligations
11704 .is_none());
11705 assert!(bare_report
11706 .writer_contention
11707 .audit_admission_refused_obligations_unavailable_reason
11708 .is_some());
11709 assert!(bare_report
11710 .writer_contention
11711 .audit_admission_unresolved_obligations
11712 .is_none());
11713 assert!(bare_report
11714 .writer_contention
11715 .audit_admission_unresolved_obligations_unavailable_reason
11716 .is_some());
11717 }
11718
11719 #[tokio::test]
11720 #[serial(config_ledger)]
11721 async fn audit_event_duration_us_reflects_measured_dispatch_time() {
11722 let store = Arc::new(MemoryEventStore::default());
11728 let mut builder = VerbRegistryBuilder::new();
11729 builder.register(SleepingPack);
11730 builder.with_event_store(store.clone());
11731 let reg = builder.build().expect("registry builds");
11732
11733 reg.dispatch("slow_op", serde_json::json!({}))
11734 .await
11735 .unwrap();
11736
11737 let page = store
11738 .query_events(
11739 EventFilter::default(),
11740 PageRequest {
11741 limit: 10,
11742 offset: 0,
11743 },
11744 )
11745 .await
11746 .unwrap();
11747 assert_eq!(page.items.len(), 1);
11748 let ev = &page.items[0];
11749 assert!(
11750 ev.duration_us >= 10_000,
11751 "duration_us must reflect the ~20ms measured dispatch time, got {}",
11752 ev.duration_us
11753 );
11754 }
11755
11756 #[tokio::test]
11757 #[serial(config_ledger)]
11758 async fn dispatch_unknown_verb_allowed_by_gate_still_persists_audit_row() {
11759 let store = Arc::new(MemoryEventStore::default());
11764 let mut builder = VerbRegistryBuilder::new();
11765 builder.register(AlphaPack);
11766 builder.with_event_store(store.clone());
11767 let reg = builder.build().expect("registry builds");
11768
11769 let result = reg.dispatch("no_such_verb", serde_json::json!({})).await;
11770 assert!(result.is_err(), "unknown verb must still return an error");
11771
11772 let count = store.count_events(EventFilter::default()).await.unwrap();
11773 assert_eq!(
11774 count, 1,
11775 "an allowed-but-unknown verb must still persist one audit row"
11776 );
11777 let page = store
11778 .query_events(
11779 EventFilter::default(),
11780 PageRequest {
11781 limit: 10,
11782 offset: 0,
11783 },
11784 )
11785 .await
11786 .unwrap();
11787 assert_eq!(page.items[0].duration_us, 0);
11788 assert_eq!(page.items[0].outcome, EventOutcome::Error);
11792 }
11793
11794 #[tokio::test]
11795 #[serial(config_ledger)]
11796 async fn audit_event_persists_to_event_store_on_deny() {
11797 #[derive(Debug)]
11798 struct AlwaysDenyGate;
11799 impl Gate for AlwaysDenyGate {
11800 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
11801 Ok(GateDecision::deny("denied by test"))
11802 }
11803 }
11804
11805 let store = Arc::new(MemoryEventStore::default());
11806 let mut builder = VerbRegistryBuilder::new();
11807 builder.register(AlphaPack);
11808 builder.with_gate(Arc::new(AlwaysDenyGate));
11809 builder.with_event_store(store.clone());
11810 let reg = builder.build().expect("registry builds");
11811
11812 let err = reg
11814 .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
11815 .await
11816 .unwrap_err();
11817 assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
11818
11819 let count = store.count_events(EventFilter::default()).await.unwrap();
11820 assert_eq!(count, 1, "one audit event persisted to EventStore on deny");
11821
11822 let page = store
11823 .query_events(
11824 EventFilter::default(),
11825 PageRequest {
11826 limit: 10,
11827 offset: 0,
11828 },
11829 )
11830 .await
11831 .unwrap();
11832 let ev = &page.items[0];
11833 assert_eq!(ev.verb, "list");
11834 assert_eq!(ev.outcome, EventOutcome::Denied);
11835 }
11836
11837 #[derive(Debug)]
11838 struct MailboxTrackingPack {
11839 invoked: Arc<AtomicUsize>,
11840 }
11841
11842 impl Pack for MailboxTrackingPack {
11843 const NAME: &'static str = "mailbox_tracking";
11844 const NOTE_KINDS: &'static [&'static str] = &[];
11845 const ENTITY_KINDS: &'static [&'static str] = &[];
11846 const HANDLERS: &'static [HandlerDef] = &[
11847 HandlerDef {
11848 name: "comm.inbox",
11849 description: "mailbox admission probe",
11850 visibility: Visibility::Verb,
11851 category: VerbCategory::Assertive,
11852 params: &[],
11853 },
11854 HandlerDef {
11855 name: "comm.thread",
11856 description: "mailbox admission probe",
11857 visibility: Visibility::Verb,
11858 category: VerbCategory::Assertive,
11859 params: &[],
11860 },
11861 ];
11862 }
11863
11864 #[async_trait]
11865 impl PackRuntime for MailboxTrackingPack {
11866 fn name(&self) -> &str {
11867 "mailbox_tracking"
11868 }
11869 fn note_kinds(&self) -> &'static [&'static str] {
11870 &[]
11871 }
11872 fn entity_kinds(&self) -> &'static [&'static str] {
11873 &[]
11874 }
11875 fn handlers(&self) -> &'static [HandlerDef] {
11876 Self::HANDLERS
11877 }
11878 async fn dispatch(
11879 &self,
11880 _verb: &str,
11881 _params: Value,
11882 _registry: &VerbRegistry,
11883 token: &NamespaceToken,
11884 ) -> Result<Value, RuntimeError> {
11885 self.invoked.fetch_add(1, Ordering::SeqCst);
11886 Ok(serde_json::json!({"actor":token.actor().id}))
11887 }
11888 }
11889
11890 #[tokio::test]
11891 #[serial(config_ledger)]
11892 async fn gate_mailbox_normal_and_intercepted_default_deny_audit_real_caller() {
11893 let store = Arc::new(MemoryEventStore::default());
11894 let invoked = Arc::new(AtomicUsize::new(0));
11895 let mut builder = VerbRegistryBuilder::new();
11896 builder.register(MailboxTrackingPack {
11897 invoked: invoked.clone(),
11898 });
11899 builder.with_event_store(store.clone());
11900 builder.with_actor_id(Some("lambda:owner".into()));
11901 let registry = builder.build().unwrap();
11902 let identity = RequestIdentity {
11903 actor_id: Some("lambda:reader".into()),
11904 namespace: "local".into(),
11905 ..Default::default()
11906 };
11907 for verb in ["comm.inbox", "comm.thread"] {
11908 for namespace in ["local", "lambda:owner"] {
11909 let args = serde_json::json!({"mailbox_actor":"lambda:owner", "namespace":namespace, "actor":"lambda:owner"});
11910 let error = registry
11911 .dispatch_with_identity(verb, args.clone(), Some(identity.clone()))
11912 .await
11913 .unwrap_err();
11914 assert!(
11915 matches!(error, RuntimeError::PermissionDenied { reason, .. } if reason == "mailbox_read_not_granted")
11916 );
11917 let error = registry
11918 .dispatch_intercepted_with_metadata_and_disposition(
11919 verb,
11920 &args,
11921 Some(&identity),
11922 |_| async {
11923 invoked.fetch_add(1, Ordering::SeqCst);
11924 Ok(InterceptedDispatchResult::new(Value::Null, ()))
11925 },
11926 )
11927 .await
11928 .unwrap_err()
11929 .into_source();
11930 assert!(
11931 matches!(error, RuntimeError::PermissionDenied { reason, .. } if reason == "mailbox_read_not_granted")
11932 );
11933 }
11934 }
11935 assert_eq!(invoked.load(Ordering::SeqCst), 0);
11936 let events = store
11937 .query_events(
11938 EventFilter::default(),
11939 PageRequest {
11940 limit: 20,
11941 offset: 0,
11942 },
11943 )
11944 .await
11945 .unwrap()
11946 .items;
11947 assert_eq!(events.len(), 8);
11948 for event in events {
11949 assert_eq!(event.actor, "actor:lambda:reader");
11950 assert_eq!(event.outcome, EventOutcome::Denied);
11951 assert_eq!(event.payload["actor"]["id"], "lambda:reader");
11952 assert_eq!(event.payload["deny_reason"], "mailbox_read_not_granted");
11953 }
11954 let value = registry
11956 .dispatch_with_identity("comm.inbox", serde_json::json!({}), Some(identity.clone()))
11957 .await
11958 .unwrap();
11959 assert_eq!(value["actor"], "lambda:reader");
11960 assert_eq!(invoked.load(Ordering::SeqCst), 1);
11961 for args in [
11962 serde_json::json!({"mailbox_actor":null}),
11963 serde_json::json!({"mailbox_actor":"local"}),
11964 ] {
11965 assert!(matches!(
11966 registry
11967 .dispatch_with_identity("comm.inbox", args.clone(), Some(identity.clone()))
11968 .await,
11969 Err(RuntimeError::InvalidInput(_))
11970 ));
11971 let error = registry
11972 .dispatch_intercepted_with_metadata_and_disposition(
11973 "comm.inbox",
11974 &args,
11975 Some(&identity),
11976 |_| async {
11977 invoked.fetch_add(1, Ordering::SeqCst);
11978 Ok(InterceptedDispatchResult::new(Value::Null, ()))
11979 },
11980 )
11981 .await
11982 .unwrap_err()
11983 .into_source();
11984 assert!(matches!(error, RuntimeError::InvalidInput(_)));
11985 }
11986 assert_eq!(invoked.load(Ordering::SeqCst), 1);
11987 }
11988
11989 #[tokio::test]
11990 #[serial(config_ledger)]
11991 async fn gate_mailbox_granted_dispatch_keeps_caller_and_backend_error_fails_closed() {
11992 #[derive(Debug)]
11993 struct BrokenMailboxGate;
11994 impl Gate for BrokenMailboxGate {
11995 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
11996 Ok(GateDecision::allow())
11997 }
11998 fn check_mailbox_read(
11999 &self,
12000 _req: &GateRequest,
12001 _owner: &khive_gate::ActorRef,
12002 ) -> Result<GateDecision, GateError> {
12003 Err(GateError::Internal("private policy outage".into()))
12004 }
12005 }
12006 let identity = RequestIdentity {
12007 actor_id: Some("lambda:reader".into()),
12008 namespace: "local".into(),
12009 ..Default::default()
12010 };
12011 let args = serde_json::json!({"mailbox_actor":"lambda:owner"});
12012 let gate: GateRef = Arc::new(
12013 khive_gate::MailboxReadGate::new(
12014 Arc::new(AllowAllGate),
12015 khive_gate::ActorRef::new("actor", "lambda:owner"),
12016 vec![khive_gate::ActorRef::new("actor", "lambda:reader")],
12017 )
12018 .unwrap(),
12019 );
12020 for (gate, allowed) in [
12021 (gate, true),
12022 (Arc::new(BrokenMailboxGate) as GateRef, false),
12023 ] {
12024 let store = Arc::new(MemoryEventStore::default());
12025 let invoked = Arc::new(AtomicUsize::new(0));
12026 let mut builder = VerbRegistryBuilder::new();
12027 builder.register(MailboxTrackingPack {
12028 invoked: invoked.clone(),
12029 });
12030 builder.with_actor_id(Some("lambda:owner".into()));
12031 builder.with_gate(gate);
12032 builder.with_event_store(store.clone());
12033 let registry = builder.build().unwrap();
12034 let normal = registry
12035 .dispatch_with_identity("comm.inbox", args.clone(), Some(identity.clone()))
12036 .await;
12037 let intercepted = registry
12038 .dispatch_intercepted_with_metadata_and_disposition(
12039 "comm.thread",
12040 &args,
12041 Some(&identity),
12042 |_| async {
12043 invoked.fetch_add(1, Ordering::SeqCst);
12044 Ok(InterceptedDispatchResult::new(Value::Null, ()))
12045 },
12046 )
12047 .await
12048 .map_err(DispatchError::into_source);
12049 if allowed {
12050 assert_eq!(normal.unwrap()["actor"], "lambda:reader");
12051 intercepted.unwrap();
12052 assert_eq!(invoked.load(Ordering::SeqCst), 2);
12053 } else {
12054 assert!(
12055 matches!(normal.unwrap_err(), RuntimeError::GateUnavailable { reason, .. } if reason == "gate backend unavailable")
12056 );
12057 assert!(
12058 matches!(intercepted.unwrap_err(), RuntimeError::GateUnavailable { reason, .. } if reason == "gate backend unavailable")
12059 );
12060 assert_eq!(invoked.load(Ordering::SeqCst), 0);
12061 }
12062 let events = store
12063 .query_events(
12064 EventFilter::default(),
12065 PageRequest {
12066 limit: 10,
12067 offset: 0,
12068 },
12069 )
12070 .await
12071 .unwrap()
12072 .items;
12073 assert_eq!(events.len(), 2);
12074 for event in events {
12075 assert_eq!(event.actor, "actor:lambda:reader");
12076 assert_eq!(
12077 event.outcome,
12078 if allowed {
12079 EventOutcome::Success
12080 } else {
12081 EventOutcome::Error
12082 }
12083 );
12084 }
12085 }
12086 }
12087
12088 #[tokio::test]
12089 #[serial(config_ledger)]
12090 async fn gate_error_returns_typed_refusal_without_invoking_pack() {
12091 #[derive(Debug)]
12092 struct FailingGate;
12093 impl Gate for FailingGate {
12094 fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
12095 Err(khive_gate::GateError::Internal("gate broken".into()))
12096 }
12097 }
12098
12099 let store = Arc::new(MemoryEventStore::default());
12100 let invoked = Arc::new(AtomicUsize::new(0));
12101 let mut builder = VerbRegistryBuilder::new();
12102 builder.register(GateErrorTrackingPack {
12103 invoked: Arc::clone(&invoked),
12104 });
12105 builder.with_gate(Arc::new(FailingGate));
12106 builder.with_event_store(store.clone());
12107 let reg = builder.build().expect("registry builds");
12108
12109 let err = reg
12110 .dispatch("guarded", Value::Null)
12111 .await
12112 .expect_err("gate unavailability must refuse normal dispatch");
12113 assert!(matches!(
12114 err,
12115 RuntimeError::GateUnavailable { ref verb, ref reason }
12116 if verb == "guarded"
12117 && reason == "gate backend unavailable"
12118 && !reason.contains("gate broken")
12119 ));
12120 assert_eq!(
12121 invoked.load(Ordering::SeqCst),
12122 0,
12123 "pack handler must not run after a gate infrastructure error"
12124 );
12125
12126 let count = store.count_events(EventFilter::default()).await.unwrap();
12127 assert_eq!(count, 1, "gate infrastructure error must be audited");
12128 let page = store
12129 .query_events(
12130 EventFilter::default(),
12131 PageRequest {
12132 limit: 10,
12133 offset: 0,
12134 },
12135 )
12136 .await
12137 .unwrap();
12138 let event = &page.items[0];
12139 assert_eq!(event.verb, "guarded");
12140 assert_eq!(event.outcome, EventOutcome::Error);
12141 assert_eq!(event.payload["decision"], "gate_unavailable");
12142 assert!(event.payload.get("deny_reason").is_none());
12143 assert_eq!(event.payload["resource"]["work_class"], "interactive");
12144 assert!(event.payload["resource"].get("cost_unit").is_none());
12145 }
12146
12147 #[tokio::test]
12148 #[serial(config_ledger)]
12149 #[serial(audit_append_failures)]
12150 async fn gate_error_audit_failure_cannot_reopen_dispatch_or_replace_typed_error() {
12151 #[derive(Debug)]
12152 struct FailingGate;
12153 impl Gate for FailingGate {
12154 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12155 Err(GateError::Internal("gate still broken".into()))
12156 }
12157 }
12158
12159 let before = audit_append_failure_count();
12160 let invoked = Arc::new(AtomicUsize::new(0));
12161 let store = Arc::new(MemoryEventStore {
12162 fail_appends: true,
12163 ..MemoryEventStore::default()
12164 });
12165 let mut builder = VerbRegistryBuilder::new();
12166 builder.register(GateErrorTrackingPack {
12167 invoked: Arc::clone(&invoked),
12168 });
12169 builder.with_gate(Arc::new(FailingGate));
12170 builder.with_event_store(store);
12171 let registry = builder.build().expect("registry builds");
12172
12173 let error = registry
12174 .dispatch("guarded", Value::Null)
12175 .await
12176 .expect_err("audit persistence failure must not reopen dispatch");
12177
12178 assert!(matches!(
12179 error,
12180 RuntimeError::GateUnavailable { ref verb, ref reason }
12181 if verb == "guarded"
12182 && reason == "gate backend unavailable"
12183 && !reason.contains("gate still broken")
12184 ));
12185 assert_eq!(invoked.load(Ordering::SeqCst), 0);
12186 assert_eq!(
12187 audit_append_failure_count(),
12188 before + 1,
12189 "best-effort audit failure remains diagnostic without changing the refusal"
12190 );
12191 }
12192
12193 #[tokio::test]
12201 async fn gate_unavailable_reason_never_carries_backend_error_text() {
12202 const CANARY: &str = "postgres://svc:not-a-real-secret@internal-host";
12203
12204 #[derive(Debug)]
12205 struct FailingGate;
12206 impl Gate for FailingGate {
12207 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12208 Err(GateError::Internal(CANARY.to_string()))
12209 }
12210 }
12211
12212 let mut builder = VerbRegistryBuilder::new();
12213 builder.register(GateErrorTrackingPack {
12214 invoked: Arc::new(AtomicUsize::new(0)),
12215 });
12216 builder.with_gate(Arc::new(FailingGate));
12217 let registry = builder.build().expect("registry builds");
12218
12219 let err = registry
12220 .dispatch("guarded", Value::Null)
12221 .await
12222 .expect_err("gate unavailability must refuse dispatch");
12223
12224 let RuntimeError::GateUnavailable { reason, .. } = &err else {
12225 panic!("expected GateUnavailable, got {err:?}");
12226 };
12227 assert!(
12228 !reason.contains(CANARY),
12229 "caller-visible reason must not embed backend error text: {reason:?}"
12230 );
12231 assert!(
12232 !reason.contains("svc") && !reason.contains("internal-host"),
12233 "caller-visible reason must not embed backend error fragments: {reason:?}"
12234 );
12235 assert_eq!(reason, "gate backend unavailable");
12236
12237 let rendered = err.to_string();
12239 assert!(
12240 !rendered.contains(CANARY),
12241 "top-level Display must not embed backend error text either: {rendered:?}"
12242 );
12243 }
12244
12245 #[tokio::test]
12251 async fn gate_policy_error_classifies_distinctly_from_backend_unavailable() {
12252 #[derive(Debug)]
12253 struct PolicyBrokenGate;
12254 impl Gate for PolicyBrokenGate {
12255 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12256 Err(GateError::Policy(
12257 "rule set has no allow clause for this namespace".to_string(),
12258 ))
12259 }
12260 }
12261
12262 let mut builder = VerbRegistryBuilder::new();
12263 builder.register(GateErrorTrackingPack {
12264 invoked: Arc::new(AtomicUsize::new(0)),
12265 });
12266 builder.with_gate(Arc::new(PolicyBrokenGate));
12267 let registry = builder.build().expect("registry builds");
12268
12269 let err = registry
12270 .dispatch("guarded", Value::Null)
12271 .await
12272 .expect_err("gate unavailability must refuse dispatch");
12273
12274 assert!(matches!(
12275 err,
12276 RuntimeError::GateUnavailable { ref verb, ref reason }
12277 if verb == "guarded"
12278 && reason == "gate policy evaluation failed"
12279 && !reason.contains("rule set has no allow clause")
12280 ));
12281 }
12282
12283 #[tokio::test]
12284 async fn no_event_store_configured_tracing_only() {
12285 let mut builder = VerbRegistryBuilder::new();
12288 builder.register(AlphaPack);
12289 let reg = builder.build().expect("registry builds");
12290
12291 let res = reg.dispatch("list", Value::Null).await.unwrap();
12292 assert_eq!(res["pack"], "alpha");
12293 }
12294
12295 #[test]
12296 #[serial]
12297 fn dispatch_tracing_emits_gate_check_event_with_deny_payload() {
12298 #[derive(Debug)]
12299 struct TracingDenyGate;
12300 impl Gate for TracingDenyGate {
12301 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12302 Ok(GateDecision::deny("denied by test gate"))
12303 }
12304 fn impl_name(&self) -> &'static str {
12305 "TracingDenyGate"
12306 }
12307 }
12308
12309 let events = capture_dispatch_events(async {
12310 let mut builder = VerbRegistryBuilder::new();
12311 builder.register(AlphaPack);
12312 builder.with_gate(Arc::new(TracingDenyGate));
12313 let reg = builder.build().expect("registry builds");
12314 let _ = reg.dispatch("create", serde_json::Value::Null).await;
12317 });
12318
12319 let gate_events = gate_check_events_for(&events, "TracingDenyGate");
12320 assert_eq!(
12321 gate_events.len(),
12322 1,
12323 "exactly one gate.check tracing event per dispatch (deny); got {gate_events:?}"
12324 );
12325 let payload = gate_events[0]
12326 .audit_event
12327 .as_ref()
12328 .expect("gate.check event must carry an audit_event field on Deny");
12329 let audit: khive_gate::AuditEvent =
12330 serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
12331 assert_eq!(audit.decision, AuditDecision::Deny);
12332 assert_eq!(audit.deny_reason.as_deref(), Some("denied by test gate"));
12333 assert_eq!(audit.gate_impl, "TracingDenyGate");
12334 let payload_json: serde_json::Value =
12338 serde_json::from_str(payload).expect("payload must be valid JSON");
12339 assert_eq!(
12340 payload_json["obligations"],
12341 serde_json::Value::Array(Vec::new()),
12342 "obligations must be `[]` on Deny on the tracing payload, not omitted"
12343 );
12344 }
12345
12346 #[test]
12347 #[serial]
12348 fn dispatch_tracing_emits_gate_check_event_with_masked_deny_payload() {
12349 #[derive(Debug)]
12355 struct TracingSecretDenyGate;
12356 impl Gate for TracingSecretDenyGate {
12357 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12358 let reason = "postgres://svc:not-a-real-secret@internal-host in denied request"; Ok(GateDecision::deny(reason))
12360 }
12361 fn impl_name(&self) -> &'static str {
12362 "TracingSecretDenyGate"
12363 }
12364 }
12365
12366 let events = capture_dispatch_events(async {
12367 let mut builder = VerbRegistryBuilder::new();
12368 builder.register(AlphaPack);
12369 builder.with_gate(Arc::new(TracingSecretDenyGate));
12370 let reg = builder.build().expect("registry builds");
12371 let _ = reg.dispatch("create", serde_json::Value::Null).await;
12372 });
12373
12374 let gate_events = gate_check_events_for(&events, "TracingSecretDenyGate");
12375 assert_eq!(
12376 gate_events.len(),
12377 1,
12378 "exactly one gate.check tracing event per dispatch (deny); got {gate_events:?}"
12379 );
12380 let payload = gate_events[0]
12381 .audit_event
12382 .as_ref()
12383 .expect("gate.check event must carry an audit_event field on Deny");
12384 assert!(!payload.is_empty());
12388 let audit: khive_gate::AuditEvent =
12389 serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
12390 let masked_reason = audit
12391 .deny_reason
12392 .as_deref()
12393 .expect("deny_reason must be present on a Deny audit event");
12394 assert!(
12395 masked_reason.contains("in denied request"),
12396 "non-secret prose must survive masking: {masked_reason:?}"
12397 );
12398 assert!(
12399 !masked_reason.contains("not-a-real-secret"),
12400 "the process log must never carry the raw credential: {masked_reason:?}"
12401 );
12402 assert!(
12403 masked_reason.contains("***MASKED***"),
12404 "the log must record that a credential was redacted: {masked_reason:?}"
12405 );
12406 }
12407
12408 #[test]
12409 #[serial]
12410 fn intercepted_dispatch_tracing_emits_gate_check_event_with_masked_deny_payload() {
12411 #[derive(Debug)]
12419 struct InterceptedTracingSecretDenyGate;
12420 impl Gate for InterceptedTracingSecretDenyGate {
12421 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12422 let reason = "postgres://svc:not-a-real-secret@internal-host in denied request"; Ok(GateDecision::deny(reason))
12424 }
12425 fn impl_name(&self) -> &'static str {
12426 "InterceptedTracingSecretDenyGate"
12427 }
12428 }
12429
12430 let events = capture_dispatch_events(async {
12431 let mut builder = VerbRegistryBuilder::new();
12432 builder.with_gate(Arc::new(InterceptedTracingSecretDenyGate));
12433 let reg = builder.build().expect("registry builds");
12434 let _ = reg
12435 .dispatch_intercepted_with_identity(
12436 "list",
12437 &Value::Null,
12438 None,
12439 move |_namespace| async move { Ok(serde_json::json!({"invoked": true})) },
12440 )
12441 .await;
12442 });
12443
12444 let gate_events = gate_check_events_for(&events, "InterceptedTracingSecretDenyGate");
12445 assert_eq!(
12446 gate_events.len(),
12447 1,
12448 "exactly one gate.check tracing event per intercepted dispatch (deny); got {gate_events:?}"
12449 );
12450 let payload = gate_events[0]
12451 .audit_event
12452 .as_ref()
12453 .expect("gate.check event must carry an audit_event field on Deny");
12454 assert!(!payload.is_empty());
12455 let audit: khive_gate::AuditEvent =
12456 serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
12457 let masked_reason = audit
12458 .deny_reason
12459 .as_deref()
12460 .expect("deny_reason must be present on a Deny audit event");
12461 assert!(
12462 masked_reason.contains("in denied request"),
12463 "non-secret prose must survive masking: {masked_reason:?}"
12464 );
12465 assert!(
12466 !masked_reason.contains("not-a-real-secret"),
12467 "the process log must never carry the raw credential: {masked_reason:?}"
12468 );
12469 assert!(
12470 masked_reason.contains("***MASKED***"),
12471 "the log must record that a credential was redacted: {masked_reason:?}"
12472 );
12473 }
12474
12475 #[tokio::test]
12482 #[serial(config_ledger)]
12483 async fn audit_envelope_round_trips_deny_reason_and_gate_impl_through_event_store() {
12484 #[derive(Debug)]
12485 struct DenyGateWithName;
12486 impl Gate for DenyGateWithName {
12487 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12488 Ok(GateDecision::deny("policy: write forbidden for anon"))
12489 }
12490 fn impl_name(&self) -> &'static str {
12491 "DenyGateWithName"
12492 }
12493 }
12494
12495 let store = Arc::new(MemoryEventStore::default());
12496 let mut builder = VerbRegistryBuilder::new();
12497 builder.register(AlphaPack);
12498 builder.with_gate(Arc::new(DenyGateWithName));
12499 builder.with_event_store(store.clone());
12500 let reg = builder.build().expect("registry builds");
12501
12502 let err = reg
12504 .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
12505 .await
12506 .unwrap_err();
12507 assert!(
12508 matches!(err, RuntimeError::PermissionDenied { .. }),
12509 "expected PermissionDenied, got {err:?}"
12510 );
12511
12512 let page = store
12514 .query_events(
12515 EventFilter::default(),
12516 PageRequest {
12517 limit: 10,
12518 offset: 0,
12519 },
12520 )
12521 .await
12522 .unwrap();
12523 assert_eq!(
12524 page.items.len(),
12525 1,
12526 "one audit event must be persisted on deny"
12527 );
12528
12529 let ev = &page.items[0];
12530 assert_eq!(ev.outcome, EventOutcome::Denied);
12531
12532 let data = &ev.payload;
12534
12535 let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
12536 .expect("Event.payload must deserialize to AuditEvent");
12537
12538 assert_eq!(
12539 audit.deny_reason.as_deref(),
12540 Some("policy: write forbidden for anon"),
12541 "deny_reason must be preserved through EventStore"
12542 );
12543 assert_eq!(
12544 audit.gate_impl, "DenyGateWithName",
12545 "gate_impl must be preserved through EventStore"
12546 );
12547 assert_eq!(
12548 audit.decision,
12549 khive_gate::AuditDecision::Deny,
12550 "decision field must be preserved through EventStore"
12551 );
12552 }
12553
12554 #[tokio::test]
12555 #[serial(config_ledger)]
12556 async fn audit_envelope_round_trips_obligations_through_event_store() {
12557 use khive_gate::Obligation;
12558
12559 #[derive(Debug)]
12560 struct ObligationGate;
12561 impl Gate for ObligationGate {
12562 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12563 Ok(GateDecision::allow_with(vec![Obligation::Audit {
12564 tag: "billing.meter".into(),
12565 }]))
12566 }
12567 fn impl_name(&self) -> &'static str {
12568 "ObligationGate"
12569 }
12570 }
12571
12572 let store = Arc::new(MemoryEventStore::default());
12573 let mut builder = VerbRegistryBuilder::new();
12574 builder.register(AlphaPack);
12575 builder.with_gate(Arc::new(ObligationGate));
12576 builder.with_event_store(store.clone());
12577 let reg = builder.build().expect("registry builds");
12578
12579 reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
12580 .await
12581 .unwrap();
12582
12583 let page = store
12584 .query_events(
12585 EventFilter::default(),
12586 PageRequest {
12587 limit: 10,
12588 offset: 0,
12589 },
12590 )
12591 .await
12592 .unwrap();
12593 assert_eq!(page.items.len(), 1);
12594
12595 let ev = &page.items[0];
12596 assert_eq!(ev.outcome, EventOutcome::Success);
12597
12598 let data = &ev.payload;
12599
12600 let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
12601 .expect("Event.payload must deserialize to AuditEvent");
12602
12603 assert_eq!(audit.gate_impl, "ObligationGate");
12604 assert_eq!(
12605 audit.obligations.len(),
12606 1,
12607 "obligations must be preserved through EventStore"
12608 );
12609 match &audit.obligations[0] {
12610 Obligation::Audit { tag } => assert_eq!(tag, "billing.meter"),
12611 other => panic!("expected Audit obligation, got {other:?}"),
12612 }
12613 }
12614
12615 #[tokio::test]
12623 #[serial(config_ledger)]
12624 async fn sql_backed_audit_envelope_round_trips_deny_reason_gate_impl_and_obligations() {
12625 #[derive(Debug)]
12626 struct SqlTestDenyGate;
12627 impl Gate for SqlTestDenyGate {
12628 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12629 Ok(GateDecision::deny("sql-path: write denied"))
12630 }
12631 fn impl_name(&self) -> &'static str {
12632 "SqlTestDenyGate"
12633 }
12634 }
12635
12636 let rt = KhiveRuntime::memory().expect("in-memory runtime");
12640 let test_tok = NamespaceToken::for_namespace(Namespace::parse("test-ns").unwrap());
12641 let sql_store = rt
12642 .events(&test_tok)
12643 .expect("events_for_namespace must succeed");
12644
12645 let mut builder = VerbRegistryBuilder::new();
12646 builder.register(AlphaPack);
12647 builder.with_gate(Arc::new(SqlTestDenyGate));
12648 builder.with_event_store(sql_store.clone());
12649 let reg = builder.build().expect("registry builds");
12650
12651 let err = reg
12653 .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
12654 .await
12655 .unwrap_err();
12656 assert!(
12657 matches!(err, RuntimeError::PermissionDenied { .. }),
12658 "expected PermissionDenied, got {err:?}"
12659 );
12660
12661 let page = sql_store
12663 .query_events(
12664 EventFilter::default(),
12665 PageRequest {
12666 limit: 10,
12667 offset: 0,
12668 },
12669 )
12670 .await
12671 .unwrap();
12672 assert_eq!(
12673 page.items.len(),
12674 1,
12675 "one audit event must be persisted on deny through SqlEventStore"
12676 );
12677
12678 let ev = &page.items[0];
12679 assert_eq!(ev.outcome, EventOutcome::Denied);
12680
12681 let data = &ev.payload;
12685
12686 let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
12687 .expect("Event.payload must deserialize to AuditEvent after SQL round-trip");
12688
12689 assert_eq!(
12690 audit.deny_reason.as_deref(),
12691 Some("sql-path: write denied"),
12692 "deny_reason must survive the SQL text round-trip"
12693 );
12694 assert_eq!(
12695 audit.gate_impl, "SqlTestDenyGate",
12696 "gate_impl must survive the SQL text round-trip"
12697 );
12698 assert_eq!(
12699 audit.decision,
12700 khive_gate::AuditDecision::Deny,
12701 "decision field must survive the SQL text round-trip"
12702 );
12703 assert!(
12706 audit.obligations.is_empty(),
12707 "obligations must be preserved as empty [] through SQL round-trip"
12708 );
12709 }
12710
12711 #[tokio::test]
12723 #[serial(config_ledger)]
12724 async fn sql_backed_audit_envelope_round_trips_non_empty_obligations() {
12725 use khive_gate::Obligation;
12726
12727 #[derive(Debug)]
12728 struct SqlTestAllowWithObligationGate;
12729 impl Gate for SqlTestAllowWithObligationGate {
12730 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12731 Ok(GateDecision::allow_with(vec![Obligation::Audit {
12732 tag: "sql-path-billing.meter".into(),
12733 }]))
12734 }
12735 fn impl_name(&self) -> &'static str {
12736 "SqlTestAllowWithObligationGate"
12737 }
12738 }
12739
12740 let rt = KhiveRuntime::memory().expect("in-memory runtime");
12741 let test_tok = NamespaceToken::for_namespace(Namespace::parse("test-ns").unwrap());
12742 let sql_store = rt
12743 .events(&test_tok)
12744 .expect("events_for_namespace must succeed");
12745
12746 let mut builder = VerbRegistryBuilder::new();
12747 builder.register(AlphaPack);
12748 builder.with_gate(Arc::new(SqlTestAllowWithObligationGate));
12749 builder.with_event_store(sql_store.clone());
12750 let reg = builder.build().expect("registry builds");
12751
12752 reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
12754 .await
12755 .expect("dispatch must succeed when gate allows");
12756
12757 let page = sql_store
12759 .query_events(
12760 EventFilter::default(),
12761 PageRequest {
12762 limit: 10,
12763 offset: 0,
12764 },
12765 )
12766 .await
12767 .unwrap();
12768 assert_eq!(
12769 page.items.len(),
12770 1,
12771 "one audit event must be persisted on allow through SqlEventStore"
12772 );
12773
12774 let ev = &page.items[0];
12775 assert_eq!(ev.outcome, EventOutcome::Success);
12776
12777 let data = &ev.payload;
12778
12779 let obligations_raw = data
12784 .get("obligations")
12785 .expect("Event.data JSON must contain 'obligations' key");
12786 let obligations_arr = obligations_raw
12787 .as_array()
12788 .expect("'obligations' must be a JSON array");
12789 assert!(
12790 !obligations_arr.is_empty(),
12791 "raw Event.data['obligations'] must be non-empty after SQL round-trip"
12792 );
12793
12794 let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
12797 .expect("Event.data must deserialize to AuditEvent after SQL round-trip");
12798
12799 assert_eq!(
12800 audit.gate_impl, "SqlTestAllowWithObligationGate",
12801 "gate_impl must survive the SQL text round-trip"
12802 );
12803 assert_eq!(
12804 audit.decision,
12805 khive_gate::AuditDecision::Allow,
12806 "decision field must survive the SQL text round-trip"
12807 );
12808 assert_eq!(
12809 audit.obligations.len(),
12810 1,
12811 "obligations must be non-empty after SQL round-trip (not silently defaulted to [])"
12812 );
12813 match &audit.obligations[0] {
12814 Obligation::Audit { tag } => assert_eq!(
12815 tag, "sql-path-billing.meter",
12816 "Audit obligation tag must survive the SQL text round-trip"
12817 ),
12818 other => panic!("expected Audit obligation, got {other:?}"),
12819 }
12820 }
12821
12822 #[tokio::test]
12830 #[serial(config_ledger)]
12831 async fn audit_event_payload_shape_for_create_verb() {
12832 let store = Arc::new(MemoryEventStore::default());
12833 let mut builder = VerbRegistryBuilder::new();
12834 builder.register(AlphaPack);
12835 builder.with_event_store(store.clone());
12836 builder.with_default_namespace("test-ns");
12837 let reg = builder.build().expect("registry builds");
12838
12839 reg.dispatch("create", serde_json::json!({"namespace": "test-ns"}))
12842 .await
12843 .unwrap();
12844
12845 let count = store.count_events(EventFilter::default()).await.unwrap();
12846 assert_eq!(count, 1, "exactly one audit event for one dispatch");
12847
12848 let page = store
12849 .query_events(
12850 EventFilter::default(),
12851 PageRequest {
12852 limit: 10,
12853 offset: 0,
12854 },
12855 )
12856 .await
12857 .unwrap();
12858 let ev = &page.items[0];
12859
12860 assert_eq!(ev.verb, "create", "ev.verb must be the dispatched verb");
12862 assert_eq!(
12863 ev.outcome,
12864 EventOutcome::Success,
12865 "ev.outcome must be Success on allow"
12866 );
12867 assert_eq!(
12868 ev.namespace, "test-ns",
12869 "ev.namespace must match the dispatch namespace"
12870 );
12871
12872 let data = &ev.payload;
12874
12875 let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
12876 .expect("ev.payload must deserialize to AuditEvent");
12877
12878 assert_eq!(
12879 audit.decision,
12880 khive_gate::AuditDecision::Allow,
12881 "AuditEvent.decision must be Allow"
12882 );
12883 assert_eq!(audit.verb, "create", "AuditEvent.verb must be 'create'");
12884 assert_eq!(
12885 audit.namespace, "test-ns",
12886 "AuditEvent.namespace must be preserved"
12887 );
12888 assert_eq!(
12889 audit.gate_impl, "AllowAllGate",
12890 "AuditEvent.gate_impl must name the gate implementation"
12891 );
12892 assert!(
12893 audit.deny_reason.is_none(),
12894 "AuditEvent.deny_reason must be None on Allow"
12895 );
12896 let payload_json: serde_json::Value =
12898 serde_json::from_value(data.clone()).expect("data must be valid JSON");
12899 assert_eq!(
12900 payload_json["obligations"],
12901 serde_json::Value::Array(Vec::new()),
12902 "obligations must be [] on AllowAllGate"
12903 );
12904 }
12905
12906 struct EmbeddingAwarePack {
12913 models: Vec<String>,
12914 }
12915
12916 impl khive_types::Pack for EmbeddingAwarePack {
12917 const NAME: &'static str = "embedding_aware";
12918 const NOTE_KINDS: &'static [&'static str] = &[];
12919 const ENTITY_KINDS: &'static [&'static str] = &["widget"];
12920 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
12921 name: "create",
12922 description: "create a widget (embedding-aware stub)",
12923 visibility: Visibility::Verb,
12924 category: VerbCategory::Commissive,
12925 params: &[],
12926 }];
12927 }
12928
12929 #[async_trait]
12930 impl PackRuntime for EmbeddingAwarePack {
12931 fn name(&self) -> &str {
12932 Self::NAME
12933 }
12934 fn note_kinds(&self) -> &'static [&'static str] {
12935 Self::NOTE_KINDS
12936 }
12937 fn entity_kinds(&self) -> &'static [&'static str] {
12938 Self::ENTITY_KINDS
12939 }
12940 fn handlers(&self) -> &'static [HandlerDef] {
12941 Self::HANDLERS
12942 }
12943 fn registered_embedding_model_names(&self) -> Vec<String> {
12944 self.models.clone()
12945 }
12946 async fn dispatch(
12947 &self,
12948 verb: &str,
12949 _params: Value,
12950 _registry: &VerbRegistry,
12951 _token: &NamespaceToken,
12952 ) -> Result<Value, RuntimeError> {
12953 Ok(serde_json::json!({ "pack": "embedding_aware", "verb": verb }))
12954 }
12955 }
12956
12957 struct FailingProbePack;
12960
12961 impl khive_types::Pack for FailingProbePack {
12962 const NAME: &'static str = "failing_probe";
12963 const NOTE_KINDS: &'static [&'static str] = &[];
12964 const ENTITY_KINDS: &'static [&'static str] = &[];
12965 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
12966 name: "probe",
12967 description: "always fails",
12968 visibility: Visibility::Verb,
12969 category: VerbCategory::Assertive,
12970 params: &[],
12971 }];
12972 }
12973
12974 #[async_trait]
12975 impl PackRuntime for FailingProbePack {
12976 fn name(&self) -> &str {
12977 Self::NAME
12978 }
12979 fn note_kinds(&self) -> &'static [&'static str] {
12980 Self::NOTE_KINDS
12981 }
12982 fn entity_kinds(&self) -> &'static [&'static str] {
12983 Self::ENTITY_KINDS
12984 }
12985 fn handlers(&self) -> &'static [HandlerDef] {
12986 Self::HANDLERS
12987 }
12988 async fn dispatch(
12989 &self,
12990 _verb: &str,
12991 _params: Value,
12992 _registry: &VerbRegistry,
12993 _token: &NamespaceToken,
12994 ) -> Result<Value, RuntimeError> {
12995 Err(RuntimeError::InvalidInput("boom".into()))
12996 }
12997 }
12998
12999 #[tokio::test]
13000 #[serial(config_ledger)]
13001 async fn resource_cost_unit_present_on_non_embedding_successful_dispatch() {
13002 let store = Arc::new(MemoryEventStore::default());
13003 let mut builder = VerbRegistryBuilder::new();
13004 builder.register(AlphaPack);
13005 builder.with_event_store(store.clone());
13006 let reg = builder.build().expect("registry builds");
13007
13008 reg.dispatch("list", serde_json::json!({})).await.unwrap();
13009
13010 let page = store
13011 .query_events(
13012 EventFilter::default(),
13013 PageRequest {
13014 limit: 10,
13015 offset: 0,
13016 },
13017 )
13018 .await
13019 .unwrap();
13020 assert_eq!(page.items.len(), 1);
13021 assert_eq!(
13022 page.items[0].payload["resource"],
13023 serde_json::json!({"work_class": "interactive", "cost_unit": 1}),
13024 "non-embedding-bearing verb's resource.cost_unit must be base_weight(verb) alone"
13025 );
13026 }
13027
13028 #[tokio::test]
13029 #[serial(config_ledger)]
13030 async fn resource_cost_unit_scales_with_registered_model_count_for_create() {
13031 let store = Arc::new(MemoryEventStore::default());
13032 let mut builder = VerbRegistryBuilder::new();
13033 builder.register(EmbeddingAwarePack {
13034 models: vec!["all-minilm-l6-v2".into(), "paraphrase".into()],
13035 });
13036 builder.with_event_store(store.clone());
13037 let reg = builder.build().expect("registry builds");
13038
13039 reg.dispatch("create", serde_json::json!({"kind": "widget"}))
13040 .await
13041 .unwrap();
13042
13043 let page = store
13044 .query_events(
13045 EventFilter::default(),
13046 PageRequest {
13047 limit: 10,
13048 offset: 0,
13049 },
13050 )
13051 .await
13052 .unwrap();
13053 assert_eq!(
13055 page.items[0].payload["resource"],
13056 serde_json::json!({"work_class": "interactive", "cost_unit": 3}),
13057 );
13058 }
13059
13060 #[tokio::test]
13061 #[serial(config_ledger)]
13062 async fn resource_cost_unit_zero_registered_models_is_base_weight_only() {
13063 let store = Arc::new(MemoryEventStore::default());
13064 let mut builder = VerbRegistryBuilder::new();
13065 builder.register(EmbeddingAwarePack { models: vec![] });
13066 builder.with_event_store(store.clone());
13067 let reg = builder.build().expect("registry builds");
13068
13069 reg.dispatch("create", serde_json::json!({"kind": "widget"}))
13070 .await
13071 .unwrap();
13072
13073 let page = store
13074 .query_events(
13075 EventFilter::default(),
13076 PageRequest {
13077 limit: 10,
13078 offset: 0,
13079 },
13080 )
13081 .await
13082 .unwrap();
13083 assert_eq!(
13084 page.items[0].payload["resource"]["cost_unit"], 1,
13085 "zero registered embedding models must vanish the term, not error or omit"
13086 );
13087 }
13088
13089 #[tokio::test]
13090 #[serial(config_ledger)]
13091 async fn resource_work_class_present_cost_unit_absent_when_dispatch_returns_error() {
13092 let store = Arc::new(MemoryEventStore::default());
13093 let mut builder = VerbRegistryBuilder::new();
13094 builder.register(FailingProbePack);
13095 builder.with_event_store(store.clone());
13096 let reg = builder.build().expect("registry builds");
13097
13098 let err = reg
13099 .dispatch("probe", serde_json::json!({}))
13100 .await
13101 .unwrap_err();
13102 assert!(matches!(err, RuntimeError::InvalidInput(_)));
13103
13104 let page = store
13105 .query_events(
13106 EventFilter::default(),
13107 PageRequest {
13108 limit: 10,
13109 offset: 0,
13110 },
13111 )
13112 .await
13113 .unwrap();
13114 assert_eq!(page.items.len(), 1);
13115 assert_eq!(page.items[0].outcome, EventOutcome::Error);
13116 assert_eq!(
13121 page.items[0].payload["resource"],
13122 serde_json::json!({"work_class": "interactive"}),
13123 "resource must carry work_class with cost_unit OMITTED (never 0) on an \
13124 errored dispatch: {:?}",
13125 page.items[0].payload
13126 );
13127 }
13128
13129 #[tokio::test]
13130 #[serial(config_ledger)]
13131 async fn resource_work_class_present_cost_unit_absent_when_no_pack_owns_the_verb() {
13132 let store = Arc::new(MemoryEventStore::default());
13133 let mut builder = VerbRegistryBuilder::new();
13134 builder.register(AlphaPack);
13135 builder.with_event_store(store.clone());
13136 let reg = builder.build().expect("registry builds");
13137
13138 let _ = reg
13139 .dispatch("no_such_verb_resource_test", serde_json::json!({}))
13140 .await;
13141
13142 let page = store
13143 .query_events(
13144 EventFilter::default(),
13145 PageRequest {
13146 limit: 10,
13147 offset: 0,
13148 },
13149 )
13150 .await
13151 .unwrap();
13152 assert_eq!(page.items.len(), 1);
13153 assert_eq!(
13154 page.items[0].payload["resource"],
13155 serde_json::json!({"work_class": "interactive"})
13156 );
13157 }
13158
13159 #[tokio::test]
13160 #[serial(config_ledger)]
13161 async fn resource_work_class_present_cost_unit_absent_on_denied_dispatch() {
13162 #[derive(Debug)]
13163 struct AlwaysDenyGate;
13164 impl Gate for AlwaysDenyGate {
13165 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
13166 Ok(GateDecision::deny("test: always deny"))
13167 }
13168 }
13169 let store = Arc::new(MemoryEventStore::default());
13170 let mut builder = VerbRegistryBuilder::new();
13171 builder.register(AlphaPack);
13172 builder.with_gate(Arc::new(AlwaysDenyGate));
13173 builder.with_event_store(store.clone());
13174 let reg = builder.build().expect("registry builds");
13175
13176 let _ = reg.dispatch("list", serde_json::json!({})).await;
13177
13178 let page = store
13179 .query_events(
13180 EventFilter::default(),
13181 PageRequest {
13182 limit: 10,
13183 offset: 0,
13184 },
13185 )
13186 .await
13187 .unwrap();
13188 assert_eq!(page.items.len(), 1);
13189 assert_eq!(page.items[0].outcome, EventOutcome::Denied);
13190 assert_eq!(
13191 page.items[0].payload["resource"],
13192 serde_json::json!({"work_class": "interactive"})
13193 );
13194 }
13195
13196 #[tokio::test]
13197 #[serial(config_ledger)]
13198 async fn resource_cost_unit_present_on_link_singleton_success() {
13199 let store = Arc::new(MemoryEventStore::default());
13200 let edge_id = uuid::Uuid::new_v4();
13201 let source_id = uuid::Uuid::new_v4();
13202 let target_id = uuid::Uuid::new_v4();
13203 let edge_json = serde_json::json!({
13204 "id": edge_id,
13205 "namespace": "local",
13206 "source_id": source_id,
13207 "target_id": target_id,
13208 "relation": "depends_on",
13209 "weight": 1.0,
13210 });
13211 let mut builder = VerbRegistryBuilder::new();
13212 builder.register(LinkResultPack::ok(edge_json));
13213 builder.with_event_store(store.clone());
13214 let reg = builder.build().expect("registry builds");
13215
13216 reg.dispatch(
13217 "link",
13218 serde_json::json!({
13219 "source_id": source_id,
13220 "target_id": target_id,
13221 "relation": "depends_on",
13222 }),
13223 )
13224 .await
13225 .unwrap();
13226
13227 let page = store
13228 .query_events(
13229 EventFilter::default(),
13230 PageRequest {
13231 limit: 10,
13232 offset: 0,
13233 },
13234 )
13235 .await
13236 .unwrap();
13237 assert_eq!(
13238 page.items[0].payload["resource"],
13239 serde_json::json!({"work_class": "interactive", "cost_unit": 1}),
13240 "link has no embedding-bearing path -> base_weight(link) alone, even on the v2-enriched singleton path"
13241 );
13242 }
13243
13244 #[tokio::test]
13245 #[serial(config_ledger)]
13246 async fn resource_work_class_present_cost_unit_absent_on_link_dispatch_failure() {
13247 let store = Arc::new(MemoryEventStore::default());
13248 let mut builder = VerbRegistryBuilder::new();
13249 builder.register(LinkResultPack::err("target endpoint not found"));
13250 builder.with_event_store(store.clone());
13251 let reg = builder.build().expect("registry builds");
13252
13253 let _ = reg
13254 .dispatch(
13255 "link",
13256 serde_json::json!({
13257 "source_id": "note:alpha",
13258 "target_id": "note:missing",
13259 "relation": "depends_on",
13260 }),
13261 )
13262 .await;
13263
13264 let page = store
13265 .query_events(
13266 EventFilter::default(),
13267 PageRequest {
13268 limit: 10,
13269 offset: 0,
13270 },
13271 )
13272 .await
13273 .unwrap();
13274 assert_eq!(page.items.len(), 1);
13275 assert_eq!(
13276 page.items[0].payload["resource"],
13277 serde_json::json!({"work_class": "interactive"})
13278 );
13279 }
13280
13281 #[tokio::test]
13283 #[serial(config_ledger)]
13284 async fn audit_event_threads_target_id_from_dispatch_args() {
13285 let store = Arc::new(MemoryEventStore::default());
13286 let target = uuid::Uuid::new_v4();
13287 let mut builder = VerbRegistryBuilder::new();
13288 builder.register(AlphaPack);
13289 builder.with_event_store(store.clone());
13290 builder.with_default_namespace("test-ns");
13291 let reg = builder.build().expect("registry builds");
13292
13293 reg.dispatch(
13294 "create",
13295 serde_json::json!({"namespace": "test-ns", "target_id": target}),
13296 )
13297 .await
13298 .unwrap();
13299
13300 let page = store
13301 .query_events(
13302 EventFilter::default(),
13303 PageRequest {
13304 offset: 0,
13305 limit: 10,
13306 },
13307 )
13308 .await
13309 .unwrap();
13310 assert_eq!(
13311 page.items[0].target_id,
13312 Some(target),
13313 "#282: audit event must carry target_id from dispatch params"
13314 );
13315 }
13316
13317 struct LinkResultPack {
13323 result: std::sync::Mutex<Option<Result<Value, RuntimeError>>>,
13324 }
13325
13326 impl LinkResultPack {
13327 fn ok(value: Value) -> Self {
13328 Self {
13329 result: std::sync::Mutex::new(Some(Ok(value))),
13330 }
13331 }
13332 fn err(message: &str) -> Self {
13333 Self {
13334 result: std::sync::Mutex::new(Some(Err(RuntimeError::InvalidInput(
13335 message.to_string(),
13336 )))),
13337 }
13338 }
13339 }
13340
13341 impl khive_types::Pack for LinkResultPack {
13342 const NAME: &'static str = "kg";
13343 const NOTE_KINDS: &'static [&'static str] = &[];
13344 const ENTITY_KINDS: &'static [&'static str] = &[];
13345 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
13346 name: "link",
13347 description: "test link handler",
13348 visibility: Visibility::Verb,
13349 category: VerbCategory::Commissive,
13350 params: &[],
13351 }];
13352 }
13353
13354 #[async_trait]
13355 impl PackRuntime for LinkResultPack {
13356 fn name(&self) -> &str {
13357 Self::NAME
13358 }
13359 fn note_kinds(&self) -> &'static [&'static str] {
13360 Self::NOTE_KINDS
13361 }
13362 fn entity_kinds(&self) -> &'static [&'static str] {
13363 Self::ENTITY_KINDS
13364 }
13365 fn handlers(&self) -> &'static [HandlerDef] {
13366 Self::HANDLERS
13367 }
13368 async fn dispatch(
13369 &self,
13370 _verb: &str,
13371 _params: Value,
13372 _registry: &VerbRegistry,
13373 _token: &NamespaceToken,
13374 ) -> Result<Value, RuntimeError> {
13375 self.result
13376 .lock()
13377 .unwrap()
13378 .take()
13379 .expect("LinkResultPack dispatch called more than once in a test")
13380 }
13381 }
13382
13383 #[tokio::test]
13384 #[serial(config_ledger)]
13385 async fn link_audit_enriches_successful_singleton_with_edge_v2() {
13386 let store = Arc::new(MemoryEventStore::default());
13387 let edge_id = uuid::Uuid::new_v4();
13388 let source_id = uuid::Uuid::new_v4();
13389 let target_id = uuid::Uuid::new_v4();
13390 let edge_json = serde_json::json!({
13391 "id": edge_id,
13392 "namespace": "local",
13393 "source_id": source_id,
13394 "target_id": target_id,
13395 "relation": "depends_on",
13396 "weight": 1.0,
13397 });
13398 let mut builder = VerbRegistryBuilder::new();
13399 builder.register(LinkResultPack::ok(edge_json));
13400 builder.with_event_store(store.clone());
13401 builder.with_default_namespace("test-ns");
13402 let reg = builder.build().expect("registry builds");
13403
13404 reg.dispatch(
13405 "link",
13406 serde_json::json!({
13407 "source_id": source_id,
13408 "target_id": target_id,
13409 "relation": "depends_on",
13410 }),
13411 )
13412 .await
13413 .unwrap();
13414
13415 let count = store.count_events(EventFilter::default()).await.unwrap();
13416 assert_eq!(
13417 count, 1,
13418 "exactly one deferred audit row must be persisted for a successful singleton link"
13419 );
13420 let page = store
13421 .query_events(
13422 EventFilter::default(),
13423 PageRequest {
13424 limit: 10,
13425 offset: 0,
13426 },
13427 )
13428 .await
13429 .unwrap();
13430 let ev = &page.items[0];
13431 assert_eq!(ev.verb, "link");
13432 assert_eq!(ev.outcome, EventOutcome::Success);
13433 assert_eq!(
13434 ev.payload_schema_version, 2,
13435 "successful singleton link uses audit schema v2"
13436 );
13437 assert_eq!(
13438 ev.target_id,
13439 Some(edge_id),
13440 "target_id must be the created/resolved edge id, not a raw caller arg"
13441 );
13442 assert_eq!(ev.payload["edge_id"], serde_json::json!(edge_id));
13443 assert_eq!(ev.payload["source_id"], serde_json::json!(source_id));
13444 assert_eq!(ev.payload["target_id"], serde_json::json!(target_id));
13445 assert_eq!(ev.payload["relation"], "depends_on");
13446 assert_eq!(ev.payload["weight"], 1.0);
13447 assert_eq!(ev.payload["verb"], "link");
13449 assert_eq!(ev.payload["decision"], "allow");
13450 assert!(ev.payload.get("gate_impl").is_some());
13451 }
13452
13453 #[tokio::test]
13454 #[serial(config_ledger)]
13455 async fn link_audit_falls_back_to_v1_when_dispatch_fails() {
13456 let store = Arc::new(MemoryEventStore::default());
13457 let mut builder = VerbRegistryBuilder::new();
13458 builder.register(LinkResultPack::err("target endpoint not found"));
13459 builder.with_event_store(store.clone());
13460 builder.with_default_namespace("test-ns");
13461 let reg = builder.build().expect("registry builds");
13462
13463 let err = reg
13464 .dispatch(
13465 "link",
13466 serde_json::json!({
13467 "source_id": "note:alpha",
13468 "target_id": "note:missing",
13469 "relation": "depends_on",
13470 }),
13471 )
13472 .await
13473 .unwrap_err();
13474 assert!(
13475 matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("not found")),
13476 "the original dispatch error must be returned unchanged"
13477 );
13478
13479 let page = store
13480 .query_events(
13481 EventFilter::default(),
13482 PageRequest {
13483 limit: 10,
13484 offset: 0,
13485 },
13486 )
13487 .await
13488 .unwrap();
13489 assert_eq!(
13490 page.items.len(),
13491 1,
13492 "a v1 fallback audit row must still be persisted on dispatch failure"
13493 );
13494 let ev = &page.items[0];
13495 assert_eq!(
13496 ev.payload_schema_version, 1,
13497 "failed link keeps the v1 audit shape"
13498 );
13499 assert_eq!(
13502 ev.outcome,
13503 EventOutcome::Error,
13504 "outcome reflects the dispatch result (Err), not the gate decision (Allow)"
13505 );
13506 assert!(
13507 ev.duration_us >= 0,
13508 "duration_us must still be populated (measured, not the Event::new \
13509 default sentinel) on a failed dispatch"
13510 );
13511 assert!(
13512 ev.target_id.is_none(),
13513 "non-UUID caller-supplied ids do not spuriously populate target_id"
13514 );
13515 assert!(
13516 ev.payload.get("edge_id").is_none(),
13517 "v1 fallback must not carry edge enrichment fields"
13518 );
13519 let _: khive_gate::AuditEvent = serde_json::from_value(ev.payload.clone())
13520 .expect("v1 fallback payload must deserialize as AuditEvent");
13521 }
13522
13523 #[tokio::test]
13524 #[serial(config_ledger)]
13525 async fn link_audit_falls_back_to_v1_when_result_missing_edge_fields() {
13526 let store = Arc::new(MemoryEventStore::default());
13527 let target_arg = uuid::Uuid::new_v4();
13528 let mut builder = VerbRegistryBuilder::new();
13529 builder.register(LinkResultPack::ok(serde_json::json!({"ok": true})));
13530 builder.with_event_store(store.clone());
13531 builder.with_default_namespace("test-ns");
13532 let reg = builder.build().expect("registry builds");
13533
13534 reg.dispatch(
13535 "link",
13536 serde_json::json!({
13537 "source_id": uuid::Uuid::new_v4(),
13538 "target_id": target_arg,
13539 "relation": "depends_on",
13540 }),
13541 )
13542 .await
13543 .unwrap();
13544
13545 let page = store
13546 .query_events(
13547 EventFilter::default(),
13548 PageRequest {
13549 limit: 10,
13550 offset: 0,
13551 },
13552 )
13553 .await
13554 .unwrap();
13555 assert_eq!(page.items.len(), 1);
13556 let ev = &page.items[0];
13557 assert_eq!(
13558 ev.payload_schema_version, 1,
13559 "an unparsable success result falls back to v1 rather than dropping the audit row"
13560 );
13561 assert_eq!(ev.outcome, EventOutcome::Success);
13562 assert_eq!(
13563 ev.target_id,
13564 Some(target_arg),
13565 "v1 fallback still extracts target_id from the raw dispatch args"
13566 );
13567 assert!(ev.payload.get("edge_id").is_none());
13568 }
13569
13570 #[tokio::test]
13571 #[serial(config_ledger)]
13572 async fn link_audit_bulk_links_get_no_enrichment() {
13573 let store = Arc::new(MemoryEventStore::default());
13574 let mut builder = VerbRegistryBuilder::new();
13575 builder.register(LinkResultPack::ok(serde_json::json!({
13576 "attempted": 2, "created": 2, "skipped": 0, "failed": 0
13577 })));
13578 builder.with_event_store(store.clone());
13579 builder.with_default_namespace("test-ns");
13580 let reg = builder.build().expect("registry builds");
13581
13582 reg.dispatch(
13583 "link",
13584 serde_json::json!({
13585 "links": [
13586 {"source_id": "a", "target_id": "b", "relation": "depends_on"},
13587 {"source_id": "c", "target_id": "d", "relation": "depends_on"},
13588 ],
13589 }),
13590 )
13591 .await
13592 .unwrap();
13593
13594 let count = store.count_events(EventFilter::default()).await.unwrap();
13595 assert_eq!(
13596 count, 1,
13597 "bulk `links` gets exactly one v1 audit row (deferred until dispatch \
13598 resolves like every other Allow-outcome row since ADR-103 Stage 1, \
13599 but never v2-enriched — enrichment is singleton-`link`-only)"
13600 );
13601 let page = store
13602 .query_events(
13603 EventFilter::default(),
13604 PageRequest {
13605 limit: 10,
13606 offset: 0,
13607 },
13608 )
13609 .await
13610 .unwrap();
13611 let ev = &page.items[0];
13612 assert_eq!(
13613 ev.payload_schema_version, 1,
13614 "bulk link mode is out of scope for #676's events.target_id enrichment"
13615 );
13616 assert!(ev.target_id.is_none());
13617 }
13618
13619 #[test]
13620 fn link_audit_success_from_result_extracts_edge_fields() {
13621 let gate_req = GateRequest::new(
13622 ActorRef::anonymous(),
13623 Namespace::local(),
13624 "link",
13625 serde_json::json!({}),
13626 );
13627 let decision = GateDecision::Allow {
13628 obligations: vec![],
13629 };
13630 let audit = AuditEvent::from_check(&gate_req, &decision, "AllowAllGate");
13631
13632 let edge_id = uuid::Uuid::new_v4();
13633 let source_id = uuid::Uuid::new_v4();
13634 let target_id = uuid::Uuid::new_v4();
13635 let result = serde_json::json!({
13636 "id": edge_id,
13637 "source_id": source_id,
13638 "target_id": target_id,
13639 "relation": "depends_on",
13640 "weight": 0.5,
13641 });
13642
13643 let (returned_id, payload) = link_audit_success_from_result(audit, &result)
13644 .expect("well-formed edge JSON must produce an enriched payload");
13645 assert_eq!(returned_id, edge_id);
13646 assert_eq!(payload["edge_id"], serde_json::json!(edge_id));
13647 assert_eq!(payload["relation"], "depends_on");
13648 assert_eq!(payload["weight"], 0.5);
13649 assert_eq!(
13650 payload["verb"], "link",
13651 "v1 AuditEvent fields must flatten into the v2 payload"
13652 );
13653 }
13654
13655 #[test]
13656 fn link_audit_success_from_result_rejects_incomplete_or_malformed_result() {
13657 let gate_req = GateRequest::new(
13658 ActorRef::anonymous(),
13659 Namespace::local(),
13660 "link",
13661 serde_json::json!({}),
13662 );
13663 let decision = GateDecision::Allow {
13664 obligations: vec![],
13665 };
13666 let audit = AuditEvent::from_check(&gate_req, &decision, "AllowAllGate");
13667
13668 assert!(
13669 link_audit_success_from_result(
13670 audit.clone(),
13671 &serde_json::json!({"id": uuid::Uuid::new_v4()}),
13672 )
13673 .is_none(),
13674 "missing source_id/target_id/relation/weight must not enrich"
13675 );
13676 assert!(
13677 link_audit_success_from_result(audit, &serde_json::json!({"id": "not-a-uuid"}))
13678 .is_none(),
13679 "a non-UUID id must not enrich"
13680 );
13681 }
13682
13683 async fn first_event(store: &Arc<MemoryEventStore>) -> Event {
13693 let page = store
13694 .query_events(
13695 EventFilter::default(),
13696 PageRequest {
13697 limit: 10,
13698 offset: 0,
13699 },
13700 )
13701 .await
13702 .unwrap();
13703 assert_eq!(
13704 page.items.len(),
13705 1,
13706 "expected exactly one persisted audit event"
13707 );
13708 page.items[0].clone()
13709 }
13710
13711 fn clean_ledger_event_store() -> Arc<MemoryEventStore> {
13728 let _ = crate::config_ledger::drain_config_locked();
13729 Arc::new(MemoryEventStore::default())
13730 }
13731
13732 #[tokio::test]
13741 #[serial(config_ledger)]
13742 async fn first_event_is_immune_to_a_ledger_row_a_prior_test_never_drained() {
13743 let _ = crate::config_ledger::drain_config_locked();
13744 crate::config_ledger::record_config_locked("simulated_leaked_key", "leaked_value");
13745
13746 let store = clean_ledger_event_store();
13747 let mut builder = VerbRegistryBuilder::new();
13748 builder.register(AlphaPack);
13749 builder.with_event_store(store.clone());
13750 let reg = builder.build().expect("registry builds");
13751
13752 reg.dispatch_with_identity(
13753 "list",
13754 serde_json::json!({"namespace": "test-ns"}),
13755 Some(RequestIdentity {
13756 request_id: Some(9_001),
13757 ..Default::default()
13758 }),
13759 )
13760 .await
13761 .unwrap();
13762
13763 let ev = first_event(&store).await;
13764 assert_eq!(ev.outcome, EventOutcome::Success);
13765 }
13766
13767 #[tokio::test]
13768 #[serial(config_ledger)]
13769 async fn dispatch_with_identity_stamps_request_id_on_success() {
13770 let store = clean_ledger_event_store();
13771 let mut builder = VerbRegistryBuilder::new();
13772 builder.register(AlphaPack);
13773 builder.with_event_store(store.clone());
13774 let reg = builder.build().expect("registry builds");
13775
13776 reg.dispatch_with_identity(
13777 "list",
13778 serde_json::json!({"namespace": "test-ns"}),
13779 Some(RequestIdentity {
13780 request_id: Some(101),
13781 ..Default::default()
13782 }),
13783 )
13784 .await
13785 .unwrap();
13786
13787 let ev = first_event(&store).await;
13788 assert_eq!(ev.outcome, EventOutcome::Success);
13789 assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(101));
13790 }
13791
13792 #[tokio::test]
13793 #[serial(config_ledger)]
13794 async fn dispatch_with_identity_stamps_request_id_on_dispatch_error() {
13795 let store = clean_ledger_event_store();
13796 let mut builder = VerbRegistryBuilder::new();
13797 builder.register(FailingProbePack);
13798 builder.with_event_store(store.clone());
13799 let reg = builder.build().expect("registry builds");
13800
13801 let err = reg
13802 .dispatch_with_identity(
13803 "probe",
13804 serde_json::json!({"namespace": "test-ns"}),
13805 Some(RequestIdentity {
13806 request_id: Some(102),
13807 ..Default::default()
13808 }),
13809 )
13810 .await
13811 .unwrap_err();
13812 assert!(matches!(err, RuntimeError::InvalidInput(_)));
13813
13814 let ev = first_event(&store).await;
13815 assert_eq!(ev.outcome, EventOutcome::Error);
13816 assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(102));
13817 }
13818
13819 #[tokio::test]
13820 #[serial(config_ledger)]
13821 async fn dispatch_with_identity_stamps_request_id_on_denied() {
13822 #[derive(Debug)]
13823 struct AlwaysDenyGate;
13824 impl Gate for AlwaysDenyGate {
13825 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
13826 Ok(GateDecision::deny("denied by test"))
13827 }
13828 }
13829
13830 let store = clean_ledger_event_store();
13831 let mut builder = VerbRegistryBuilder::new();
13832 builder.register(AlphaPack);
13833 builder.with_gate(Arc::new(AlwaysDenyGate));
13834 builder.with_event_store(store.clone());
13835 let reg = builder.build().expect("registry builds");
13836
13837 let err = reg
13838 .dispatch_with_identity(
13839 "list",
13840 serde_json::json!({"namespace": "test-ns"}),
13841 Some(RequestIdentity {
13842 request_id: Some(103),
13843 ..Default::default()
13844 }),
13845 )
13846 .await
13847 .unwrap_err();
13848 assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
13849
13850 let ev = first_event(&store).await;
13851 assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(103));
13852 }
13853
13854 #[tokio::test]
13855 #[serial(config_ledger)]
13856 async fn dispatch_with_identity_stamps_request_id_on_link_v2_success() {
13857 let store = clean_ledger_event_store();
13858 let edge_id = uuid::Uuid::new_v4();
13859 let source_id = uuid::Uuid::new_v4();
13860 let target_id = uuid::Uuid::new_v4();
13861 let edge_json = serde_json::json!({
13862 "id": edge_id,
13863 "namespace": "local",
13864 "source_id": source_id,
13865 "target_id": target_id,
13866 "relation": "depends_on",
13867 "weight": 1.0,
13868 });
13869 let mut builder = VerbRegistryBuilder::new();
13870 builder.register(LinkResultPack::ok(edge_json));
13871 builder.with_event_store(store.clone());
13872 builder.with_default_namespace("test-ns");
13873 let reg = builder.build().expect("registry builds");
13874
13875 reg.dispatch_with_identity(
13876 "link",
13877 serde_json::json!({
13878 "source_id": source_id,
13879 "target_id": target_id,
13880 "relation": "depends_on",
13881 }),
13882 Some(RequestIdentity {
13883 namespace: "test-ns".to_string(),
13884 request_id: Some(104),
13885 ..Default::default()
13886 }),
13887 )
13888 .await
13889 .unwrap();
13890
13891 let ev = first_event(&store).await;
13892 assert_eq!(
13893 ev.payload_schema_version, 2,
13894 "successful singleton link uses audit schema v2"
13895 );
13896 assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(104));
13897 }
13898
13899 #[tokio::test]
13900 #[serial(config_ledger)]
13901 async fn dispatch_with_identity_stamps_request_id_on_link_v1_fallback() {
13902 let store = clean_ledger_event_store();
13903 let mut builder = VerbRegistryBuilder::new();
13904 builder.register(LinkResultPack::err("target endpoint not found"));
13905 builder.with_event_store(store.clone());
13906 builder.with_default_namespace("test-ns");
13907 let reg = builder.build().expect("registry builds");
13908
13909 let err = reg
13910 .dispatch_with_identity(
13911 "link",
13912 serde_json::json!({
13913 "source_id": "note:alpha",
13914 "target_id": "note:missing",
13915 "relation": "depends_on",
13916 }),
13917 Some(RequestIdentity {
13918 namespace: "test-ns".to_string(),
13919 request_id: Some(105),
13920 ..Default::default()
13921 }),
13922 )
13923 .await
13924 .unwrap_err();
13925 assert!(matches!(err, RuntimeError::InvalidInput(_)));
13926
13927 let ev = first_event(&store).await;
13928 assert_eq!(
13929 ev.payload_schema_version, 1,
13930 "failed link keeps the v1 audit shape"
13931 );
13932 assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(105));
13933 }
13934
13935 #[tokio::test]
13936 #[serial(config_ledger)]
13937 async fn dispatch_with_identity_stamps_request_id_on_unknown_verb() {
13938 let store = clean_ledger_event_store();
13939 let mut builder = VerbRegistryBuilder::new();
13940 builder.register(AlphaPack);
13941 builder.with_event_store(store.clone());
13942 let reg = builder.build().expect("registry builds");
13943
13944 let err = reg
13945 .dispatch_with_identity(
13946 "no_such_verb",
13947 serde_json::json!({}),
13948 Some(RequestIdentity {
13949 namespace: Namespace::local().as_str().to_string(),
13950 request_id: Some(106),
13951 ..Default::default()
13952 }),
13953 )
13954 .await
13955 .unwrap_err();
13956 assert!(matches!(err, RuntimeError::UnknownVerb(_)));
13957
13958 let ev = first_event(&store).await;
13959 assert_eq!(ev.outcome, EventOutcome::Error);
13960 assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(106));
13961 }
13962
13963 #[tokio::test]
13964 #[serial(config_ledger)]
13965 async fn dispatch_with_identity_omits_request_id_key_when_absent() {
13966 let store = clean_ledger_event_store();
13967 let mut builder = VerbRegistryBuilder::new();
13968 builder.register(AlphaPack);
13969 builder.with_event_store(store.clone());
13970 let reg = builder.build().expect("registry builds");
13971
13972 reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
13974 .await
13975 .unwrap();
13976
13977 let ev = first_event(&store).await;
13978 let resource = ev.payload["resource"]
13979 .as_object()
13980 .expect("resource must be an object");
13981 assert!(
13982 !resource.contains_key("request_id"),
13983 "request_id key must be entirely absent when no id is supplied, \
13984 not present as null or 0: got {resource:?}"
13985 );
13986 }
13987}
13988
13989#[cfg(test)]
13992mod dep_tests {
13993 use super::*;
13994 use async_trait::async_trait;
13995 use khive_types::Pack;
13996 use serde_json::Value;
13997
13998 struct KgDepPack;
13999 struct MemoryDepPack;
14000 struct ADepPack;
14001 struct BDepPack;
14002
14003 impl Pack for KgDepPack {
14004 const NAME: &'static str = "kg_dep";
14005 const NOTE_KINDS: &'static [&'static str] = &["observation"];
14006 const ENTITY_KINDS: &'static [&'static str] = &["concept"];
14007 const HANDLERS: &'static [HandlerDef] = &[];
14008 }
14009
14010 impl Pack for MemoryDepPack {
14011 const NAME: &'static str = "memory_dep";
14012 const NOTE_KINDS: &'static [&'static str] = &["memory"];
14013 const ENTITY_KINDS: &'static [&'static str] = &[];
14014 const HANDLERS: &'static [HandlerDef] = &[];
14015 const REQUIRES: &'static [&'static str] = &["kg_dep"];
14016 }
14017
14018 impl Pack for ADepPack {
14019 const NAME: &'static str = "pack_a";
14020 const NOTE_KINDS: &'static [&'static str] = &[];
14021 const ENTITY_KINDS: &'static [&'static str] = &[];
14022 const HANDLERS: &'static [HandlerDef] = &[];
14023 const REQUIRES: &'static [&'static str] = &["pack_b"];
14024 }
14025
14026 impl Pack for BDepPack {
14027 const NAME: &'static str = "pack_b";
14028 const NOTE_KINDS: &'static [&'static str] = &[];
14029 const ENTITY_KINDS: &'static [&'static str] = &[];
14030 const HANDLERS: &'static [HandlerDef] = &[];
14031 const REQUIRES: &'static [&'static str] = &["pack_a"];
14032 }
14033
14034 #[async_trait]
14035 impl PackRuntime for KgDepPack {
14036 fn name(&self) -> &str {
14037 Self::NAME
14038 }
14039 fn note_kinds(&self) -> &'static [&'static str] {
14040 Self::NOTE_KINDS
14041 }
14042 fn entity_kinds(&self) -> &'static [&'static str] {
14043 Self::ENTITY_KINDS
14044 }
14045 fn handlers(&self) -> &'static [HandlerDef] {
14046 Self::HANDLERS
14047 }
14048 async fn dispatch(
14049 &self,
14050 verb: &str,
14051 _: Value,
14052 _: &VerbRegistry,
14053 _: &NamespaceToken,
14054 ) -> Result<Value, RuntimeError> {
14055 Err(RuntimeError::InvalidInput(format!(
14056 "KgDepPack has no verbs: {verb}"
14057 )))
14058 }
14059 }
14060
14061 #[async_trait]
14062 impl PackRuntime for MemoryDepPack {
14063 fn name(&self) -> &str {
14064 Self::NAME
14065 }
14066 fn note_kinds(&self) -> &'static [&'static str] {
14067 Self::NOTE_KINDS
14068 }
14069 fn entity_kinds(&self) -> &'static [&'static str] {
14070 Self::ENTITY_KINDS
14071 }
14072 fn handlers(&self) -> &'static [HandlerDef] {
14073 Self::HANDLERS
14074 }
14075 fn requires(&self) -> &'static [&'static str] {
14076 Self::REQUIRES
14077 }
14078 async fn dispatch(
14079 &self,
14080 verb: &str,
14081 _: Value,
14082 _: &VerbRegistry,
14083 _: &NamespaceToken,
14084 ) -> Result<Value, RuntimeError> {
14085 Err(RuntimeError::InvalidInput(format!(
14086 "MemoryDepPack has no verbs: {verb}"
14087 )))
14088 }
14089 }
14090
14091 #[async_trait]
14092 impl PackRuntime for ADepPack {
14093 fn name(&self) -> &str {
14094 Self::NAME
14095 }
14096 fn note_kinds(&self) -> &'static [&'static str] {
14097 Self::NOTE_KINDS
14098 }
14099 fn entity_kinds(&self) -> &'static [&'static str] {
14100 Self::ENTITY_KINDS
14101 }
14102 fn handlers(&self) -> &'static [HandlerDef] {
14103 Self::HANDLERS
14104 }
14105 fn requires(&self) -> &'static [&'static str] {
14106 Self::REQUIRES
14107 }
14108 async fn dispatch(
14109 &self,
14110 verb: &str,
14111 _: Value,
14112 _: &VerbRegistry,
14113 _: &NamespaceToken,
14114 ) -> Result<Value, RuntimeError> {
14115 Err(RuntimeError::InvalidInput(format!(
14116 "ADepPack has no verbs: {verb}"
14117 )))
14118 }
14119 }
14120
14121 #[async_trait]
14122 impl PackRuntime for BDepPack {
14123 fn name(&self) -> &str {
14124 Self::NAME
14125 }
14126 fn note_kinds(&self) -> &'static [&'static str] {
14127 Self::NOTE_KINDS
14128 }
14129 fn entity_kinds(&self) -> &'static [&'static str] {
14130 Self::ENTITY_KINDS
14131 }
14132 fn handlers(&self) -> &'static [HandlerDef] {
14133 Self::HANDLERS
14134 }
14135 fn requires(&self) -> &'static [&'static str] {
14136 Self::REQUIRES
14137 }
14138 async fn dispatch(
14139 &self,
14140 verb: &str,
14141 _: Value,
14142 _: &VerbRegistry,
14143 _: &NamespaceToken,
14144 ) -> Result<Value, RuntimeError> {
14145 Err(RuntimeError::InvalidInput(format!(
14146 "BDepPack has no verbs: {verb}"
14147 )))
14148 }
14149 }
14150
14151 #[test]
14152 fn test_pack_deps_happy_path() {
14153 let mut builder = VerbRegistryBuilder::new();
14154 builder.register(MemoryDepPack);
14155 builder.register(KgDepPack);
14156 let reg = builder
14157 .build()
14158 .expect("kg_dep satisfies memory_dep dependency");
14159 assert_eq!(reg.pack_requires("memory_dep").unwrap(), &["kg_dep"]);
14160 let names = reg.pack_names();
14161 let kg_pos = names.iter().position(|&n| n == "kg_dep").unwrap();
14162 let mem_pos = names.iter().position(|&n| n == "memory_dep").unwrap();
14163 assert!(
14164 kg_pos < mem_pos,
14165 "kg_dep must be loaded before memory_dep; order: {names:?}"
14166 );
14167 }
14168
14169 #[test]
14170 fn test_pack_deps_missing() {
14171 let mut builder = VerbRegistryBuilder::new();
14172 builder.register(MemoryDepPack);
14173 let err = match builder.build() {
14174 Ok(_) => panic!("expected Err, got Ok"),
14175 Err(e) => e,
14176 };
14177 assert!(
14178 matches!(err, RuntimeError::MissingPackDependency(_)),
14179 "expected MissingPackDependency, got {err:?}"
14180 );
14181 let msg = err.to_string();
14182 assert!(
14183 msg.contains("memory_dep"),
14184 "error must name the dependent pack: {msg}"
14185 );
14186 assert!(
14187 msg.contains("kg_dep"),
14188 "error must name the missing dep: {msg}"
14189 );
14190 }
14191
14192 #[test]
14193 fn test_pack_deps_circular() {
14194 let mut builder = VerbRegistryBuilder::new();
14195 builder.register(ADepPack);
14196 builder.register(BDepPack);
14197 let err = match builder.build() {
14198 Ok(_) => panic!("expected Err, got Ok"),
14199 Err(e) => e,
14200 };
14201 assert!(
14202 matches!(err, RuntimeError::CircularPackDependency(_)),
14203 "expected CircularPackDependency, got {err:?}"
14204 );
14205 let msg = err.to_string();
14206 assert!(msg.contains("pack_a"), "error must name pack_a: {msg}");
14207 assert!(msg.contains("pack_b"), "error must name pack_b: {msg}");
14208 }
14209
14210 #[test]
14211 fn test_pack_deps_no_deps() {
14212 struct NoDepsA;
14213 struct NoDepsB;
14214
14215 impl Pack for NoDepsA {
14216 const NAME: &'static str = "no_deps_a";
14217 const NOTE_KINDS: &'static [&'static str] = &[];
14218 const ENTITY_KINDS: &'static [&'static str] = &[];
14219 const HANDLERS: &'static [HandlerDef] = &[];
14220 }
14221
14222 impl Pack for NoDepsB {
14223 const NAME: &'static str = "no_deps_b";
14224 const NOTE_KINDS: &'static [&'static str] = &[];
14225 const ENTITY_KINDS: &'static [&'static str] = &[];
14226 const HANDLERS: &'static [HandlerDef] = &[];
14227 }
14228
14229 #[async_trait]
14230 impl PackRuntime for NoDepsA {
14231 fn name(&self) -> &str {
14232 Self::NAME
14233 }
14234 fn note_kinds(&self) -> &'static [&'static str] {
14235 Self::NOTE_KINDS
14236 }
14237 fn entity_kinds(&self) -> &'static [&'static str] {
14238 Self::ENTITY_KINDS
14239 }
14240 fn handlers(&self) -> &'static [HandlerDef] {
14241 Self::HANDLERS
14242 }
14243 async fn dispatch(
14244 &self,
14245 verb: &str,
14246 _: Value,
14247 _: &VerbRegistry,
14248 _: &NamespaceToken,
14249 ) -> Result<Value, RuntimeError> {
14250 Err(RuntimeError::InvalidInput(format!("NoDepsA: {verb}")))
14251 }
14252 }
14253
14254 #[async_trait]
14255 impl PackRuntime for NoDepsB {
14256 fn name(&self) -> &str {
14257 Self::NAME
14258 }
14259 fn note_kinds(&self) -> &'static [&'static str] {
14260 Self::NOTE_KINDS
14261 }
14262 fn entity_kinds(&self) -> &'static [&'static str] {
14263 Self::ENTITY_KINDS
14264 }
14265 fn handlers(&self) -> &'static [HandlerDef] {
14266 Self::HANDLERS
14267 }
14268 async fn dispatch(
14269 &self,
14270 verb: &str,
14271 _: Value,
14272 _: &VerbRegistry,
14273 _: &NamespaceToken,
14274 ) -> Result<Value, RuntimeError> {
14275 Err(RuntimeError::InvalidInput(format!("NoDepsB: {verb}")))
14276 }
14277 }
14278
14279 let mut builder = VerbRegistryBuilder::new();
14280 builder.register(NoDepsA);
14281 builder.register(NoDepsB);
14282 let reg = builder.build().expect("packs with REQUIRES=&[] build");
14283 assert_eq!(reg.pack_requires("no_deps_a").unwrap(), &[] as &[&str]);
14284 assert_eq!(reg.pack_requires("no_deps_b").unwrap(), &[] as &[&str]);
14285 }
14286}
14287
14288#[cfg(test)]
14297mod note_update_sequencing_tests {
14298 use super::*;
14299 use khive_types::Pack;
14300 use std::sync::atomic::{AtomicUsize, Ordering};
14301 use std::sync::Mutex as StdMutex;
14302
14303 #[derive(Debug, Default)]
14310 struct SequencerProbeHook {
14311 normalize_calls: AtomicUsize,
14312 validate_calls: AtomicUsize,
14313 validate_saw_marker: StdMutex<Option<bool>>,
14314 effects_calls: AtomicUsize,
14315 effects: StdMutex<Vec<NoteUpdateEffect>>,
14316 }
14317
14318 #[async_trait]
14319 impl KindHook for SequencerProbeHook {
14320 async fn prepare_create(
14321 &self,
14322 _runtime: &KhiveRuntime,
14323 _args: &mut Value,
14324 ) -> Result<(), RuntimeError> {
14325 Ok(())
14326 }
14327
14328 async fn after_create(
14329 &self,
14330 _runtime: &KhiveRuntime,
14331 _id: uuid::Uuid,
14332 _args: &Value,
14333 ) -> Result<(), RuntimeError> {
14334 Ok(())
14335 }
14336
14337 async fn normalize_note_update(
14338 &self,
14339 _runtime: &KhiveRuntime,
14340 _token: &NamespaceToken,
14341 _note: &khive_storage::Note,
14342 args: &mut Value,
14343 ) -> Result<(), RuntimeError> {
14344 self.normalize_calls.fetch_add(1, Ordering::SeqCst);
14345 let Some(raw) = args.get("raw_marker").and_then(Value::as_bool) else {
14346 return Ok(());
14347 };
14348 let root = args.as_object_mut().expect("probe test args are an object");
14349 root.remove("raw_marker");
14350 let mut properties = serde_json::Map::new();
14351 properties.insert("marker".into(), Value::Bool(raw));
14352 root.insert("properties".into(), Value::Object(properties));
14353 Ok(())
14354 }
14355
14356 async fn validate_note_update(
14357 &self,
14358 _runtime: &KhiveRuntime,
14359 _token: &NamespaceToken,
14360 _note: &khive_storage::Note,
14361 properties: Option<&Value>,
14362 ) -> Result<(), RuntimeError> {
14363 self.validate_calls.fetch_add(1, Ordering::SeqCst);
14364 let marker = properties
14365 .and_then(|value| value.get("marker"))
14366 .and_then(Value::as_bool);
14367 *self.validate_saw_marker.lock().unwrap() = marker;
14368 if marker == Some(true) {
14369 return Err(RuntimeError::InvalidInput(
14370 "probe validator refuses marker=true".into(),
14371 ));
14372 }
14373 Ok(())
14374 }
14375 async fn note_update_effects(
14376 &self,
14377 _runtime: &KhiveRuntime,
14378 _token: &NamespaceToken,
14379 _note: &khive_storage::Note,
14380 _patch: &crate::NotePatch,
14381 ) -> Result<Vec<NoteUpdateEffect>, RuntimeError> {
14382 self.effects_calls.fetch_add(1, Ordering::SeqCst);
14383 Ok(self.effects.lock().unwrap().clone())
14384 }
14385 }
14386
14387 struct ProbePack(Arc<SequencerProbeHook>);
14388
14389 impl Pack for ProbePack {
14390 const NAME: &'static str = "sequencer-probe";
14391 const NOTE_KINDS: &'static [&'static str] = &["probe-note"];
14392 const ENTITY_KINDS: &'static [&'static str] = &[];
14393 const HANDLERS: &'static [HandlerDef] = &[];
14394 }
14395
14396 #[async_trait]
14397 impl PackRuntime for ProbePack {
14398 fn name(&self) -> &str {
14399 Self::NAME
14400 }
14401 fn note_kinds(&self) -> &'static [&'static str] {
14402 Self::NOTE_KINDS
14403 }
14404 fn entity_kinds(&self) -> &'static [&'static str] {
14405 Self::ENTITY_KINDS
14406 }
14407 fn handlers(&self) -> &'static [HandlerDef] {
14408 Self::HANDLERS
14409 }
14410 fn kind_hook(&self, kind: &str) -> Option<Arc<dyn KindHook>> {
14411 (kind == "probe-note").then(|| self.0.clone() as Arc<dyn KindHook>)
14412 }
14413 async fn dispatch(
14414 &self,
14415 verb: &str,
14416 _params: Value,
14417 _registry: &VerbRegistry,
14418 _token: &NamespaceToken,
14419 ) -> Result<Value, RuntimeError> {
14420 Err(RuntimeError::InvalidInput(format!(
14421 "ProbePack has no verbs: {verb}"
14422 )))
14423 }
14424 }
14425
14426 #[tokio::test]
14439 async fn prepare_note_update_hook_runs_normalize_before_validate_and_validate_can_refuse() {
14440 let runtime = KhiveRuntime::memory().expect("memory runtime");
14441 let token = runtime
14442 .authorize(Namespace::local())
14443 .expect("authorize local namespace");
14444 let hook = Arc::new(SequencerProbeHook::default());
14445 let mut builder = VerbRegistryBuilder::new();
14446 builder.register(ProbePack(hook.clone()));
14447 let registry = builder.build().expect("registry builds");
14448
14449 let note = khive_storage::Note::new("local", "probe-note", "body");
14450 let mut args = serde_json::json!({"raw_marker": true});
14451
14452 let result = registry
14453 .prepare_note_update_hook(&runtime, &token, ¬e, &mut args)
14454 .await;
14455
14456 let error = result.expect_err("the refusal must fire");
14457 assert!(
14458 error.to_string().contains("marker=true"),
14459 "the error must be the probe validator's own refusal: {error}"
14460 );
14461 assert_eq!(
14462 args["properties"]["marker"],
14463 serde_json::json!(true),
14464 "normalization must land in args even on the arm that ends in refusal"
14465 );
14466 assert_eq!(
14467 hook.normalize_calls.load(Ordering::SeqCst),
14468 1,
14469 "normalize must run even though validate goes on to refuse"
14470 );
14471 assert_eq!(
14472 hook.validate_calls.load(Ordering::SeqCst),
14473 1,
14474 "validate must run exactly once"
14475 );
14476 assert_eq!(
14477 *hook.validate_saw_marker.lock().unwrap(),
14478 Some(true),
14479 "validate must see the property normalize just wrote, not the caller's raw field"
14480 );
14481 }
14482
14483 #[tokio::test]
14492 async fn prepare_note_update_hook_runs_normalize_before_validate_and_validate_can_accept() {
14493 let runtime = KhiveRuntime::memory().expect("memory runtime");
14494 let token = runtime
14495 .authorize(Namespace::local())
14496 .expect("authorize local namespace");
14497 let hook = Arc::new(SequencerProbeHook::default());
14498 let mut builder = VerbRegistryBuilder::new();
14499 builder.register(ProbePack(hook.clone()));
14500 let registry = builder.build().expect("registry builds");
14501
14502 let note = khive_storage::Note::new("local", "probe-note", "body");
14503 let mut args = serde_json::json!({"raw_marker": false});
14504
14505 registry
14506 .prepare_note_update_hook(&runtime, &token, ¬e, &mut args)
14507 .await
14508 .expect("an accepting validator must not refuse");
14509
14510 assert_eq!(
14511 args["properties"]["marker"],
14512 serde_json::json!(false),
14513 "normalization must land in args even when validation accepts"
14514 );
14515 assert_eq!(hook.normalize_calls.load(Ordering::SeqCst), 1);
14516 assert_eq!(hook.validate_calls.load(Ordering::SeqCst), 1);
14517 assert_eq!(*hook.validate_saw_marker.lock().unwrap(), Some(false));
14518 }
14519 async fn effects_fixture() -> (
14520 KhiveRuntime,
14521 NamespaceToken,
14522 VerbRegistry,
14523 Arc<SequencerProbeHook>,
14524 khive_storage::Note,
14525 ) {
14526 let runtime = KhiveRuntime::memory().unwrap();
14527 let token = runtime.authorize(Namespace::local()).unwrap();
14528 let hook = Arc::new(SequencerProbeHook::default());
14529 let mut builder = VerbRegistryBuilder::new();
14530 builder.register(ProbePack(hook.clone()));
14531 let registry = builder.build().unwrap();
14532 let note = khive_storage::Note::new("local", "probe-note", "before");
14533 let id = note.id;
14534 runtime
14535 .notes(&token)
14536 .unwrap()
14537 .upsert_note(note)
14538 .await
14539 .unwrap();
14540 let note = runtime
14541 .notes(&token)
14542 .unwrap()
14543 .get_note(id)
14544 .await
14545 .unwrap()
14546 .unwrap();
14547 (runtime, token, registry, hook, note)
14548 }
14549
14550 #[tokio::test]
14551 async fn note_update_effects_are_not_computed_for_invalid_or_known_stale_patches() {
14552 use crate::atomic_prepare::prepare_update_from_note_snapshot;
14553 use serde_json::json;
14554 let (runtime, token, registry, hook, snapshot) = effects_fixture().await;
14555 let mut invalid = json!({"id": snapshot.id, "raw_marker": true});
14556 registry
14557 .prepare_note_update_hook(&runtime, &token, &snapshot, &mut invalid)
14558 .await
14559 .expect_err("kind validator refuses");
14560 assert_eq!(hook.effects_calls.load(Ordering::SeqCst), 0);
14561 for extra in [
14562 json!({"salience": 2.0}),
14563 json!({"expected_version": snapshot.version + 1}),
14564 ] {
14565 let mut args = json!({"id": snapshot.id, "raw_marker": false});
14566 args.as_object_mut()
14567 .unwrap()
14568 .extend(extra.as_object().unwrap().clone());
14569 let policy = registry
14570 .prepare_note_update_policy(&runtime, &token, &snapshot, &mut args)
14571 .await
14572 .unwrap();
14573 prepare_update_from_note_snapshot(
14574 &runtime,
14575 &token,
14576 &args,
14577 None,
14578 snapshot.clone(),
14579 policy,
14580 ®istry,
14581 )
14582 .await
14583 .expect_err("invalid/stale update must be refused before effects");
14584 assert_eq!(hook.effects_calls.load(Ordering::SeqCst), 0);
14585 assert_eq!(
14586 runtime
14587 .notes(&token)
14588 .unwrap()
14589 .get_note(snapshot.id)
14590 .await
14591 .unwrap()
14592 .unwrap(),
14593 snapshot
14594 );
14595 }
14596 runtime
14597 .update_note(
14598 &token,
14599 snapshot.id,
14600 crate::NotePatch::new(None, Some("winner".into()), None, None, None),
14601 )
14602 .await
14603 .unwrap();
14604 let current = runtime
14605 .notes(&token)
14606 .unwrap()
14607 .get_note(snapshot.id)
14608 .await
14609 .unwrap()
14610 .unwrap();
14611 let mut args = json!({"id": snapshot.id, "raw_marker": false});
14612 let policy = registry
14613 .prepare_note_update_policy(&runtime, &token, &snapshot, &mut args)
14614 .await
14615 .unwrap();
14616 prepare_update_from_note_snapshot(
14617 &runtime,
14618 &token,
14619 &args,
14620 None,
14621 snapshot.clone(),
14622 policy,
14623 ®istry,
14624 )
14625 .await
14626 .expect_err("known stale snapshot");
14627 assert_eq!(hook.effects_calls.load(Ordering::SeqCst), 0);
14628 assert_eq!(
14629 runtime
14630 .notes(&token)
14631 .unwrap()
14632 .get_note(snapshot.id)
14633 .await
14634 .unwrap()
14635 .unwrap(),
14636 current
14637 );
14638 }
14639
14640 #[tokio::test]
14641 async fn note_update_effects_missing_target_must_fail_without_note_changes() {
14642 use serde_json::json;
14643 let (runtime, token, registry, hook, snapshot) = effects_fixture().await;
14644 *hook.effects.lock().unwrap() = vec![NoteUpdateEffect::Link(LinkSpec {
14645 namespace: None,
14646 source_id: snapshot.id,
14647 target_id: uuid::Uuid::new_v4(),
14648 relation: khive_storage::EdgeRelation::Annotates,
14649 weight: 1.0,
14650 metadata: None,
14651 resurrect: false,
14652 })];
14653 let mut args = json!({"id": snapshot.id, "raw_marker": false});
14654 let policy = registry
14655 .prepare_note_update_policy(&runtime, &token, &snapshot, &mut args)
14656 .await
14657 .unwrap();
14658 let error = runtime
14659 .update_note_from_snapshot_with_kind_effects(
14660 &token,
14661 snapshot.clone(),
14662 &args,
14663 policy,
14664 ®istry,
14665 )
14666 .await
14667 .expect_err("missing link target must not become a successful note update");
14668 assert!(matches!(error, RuntimeError::NotFound(_)), "{error}");
14669 assert_eq!(hook.effects_calls.load(Ordering::SeqCst), 1);
14670 assert_eq!(
14671 runtime
14672 .notes(&token)
14673 .unwrap()
14674 .get_note(snapshot.id)
14675 .await
14676 .unwrap()
14677 .unwrap(),
14678 snapshot
14679 );
14680 }
14681
14682 #[tokio::test]
14683 async fn note_update_effects_do_not_replace_a_live_annotation_that_appeared_during_prepare() {
14684 use khive_storage::EdgeRelation;
14685 use serde_json::json;
14686 let (runtime, token, registry, hook, snapshot) = effects_fixture().await;
14687 let target = runtime
14688 .create_entity(&token, "concept", None, "new target", None, None, vec![])
14689 .await
14690 .unwrap();
14691 runtime
14692 .link(
14693 &token,
14694 snapshot.id,
14695 target.id,
14696 EdgeRelation::Annotates,
14697 0.3,
14698 Some(json!({"keep": true})),
14699 )
14700 .await
14701 .unwrap();
14702 let before = runtime
14703 .get_edge_by_natural_key_including_deleted(
14704 &token,
14705 "local",
14706 snapshot.id,
14707 target.id,
14708 EdgeRelation::Annotates,
14709 )
14710 .await
14711 .unwrap()
14712 .unwrap();
14713 *hook.effects.lock().unwrap() = vec![NoteUpdateEffect::Link(LinkSpec {
14716 namespace: None,
14717 source_id: snapshot.id,
14718 target_id: target.id,
14719 relation: EdgeRelation::Annotates,
14720 weight: 1.0,
14721 metadata: None,
14722 resurrect: true,
14723 })];
14724 let mut args = json!({"id": snapshot.id, "raw_marker": false});
14725 let policy = registry
14726 .prepare_note_update_policy(&runtime, &token, &snapshot, &mut args)
14727 .await
14728 .unwrap();
14729 let error = runtime
14730 .update_note_from_snapshot_with_kind_effects(
14731 &token,
14732 snapshot.clone(),
14733 &args,
14734 policy,
14735 ®istry,
14736 )
14737 .await
14738 .expect_err("typed create must not replace an intervening live edge");
14739 assert!(error.to_string().contains("live edge appeared"), "{error}");
14740 assert_eq!(
14741 runtime
14742 .notes(&token)
14743 .unwrap()
14744 .get_note(snapshot.id)
14745 .await
14746 .unwrap()
14747 .unwrap(),
14748 snapshot
14749 );
14750 let after = runtime
14751 .get_edge_by_natural_key_including_deleted(
14752 &token,
14753 "local",
14754 snapshot.id,
14755 target.id,
14756 EdgeRelation::Annotates,
14757 )
14758 .await
14759 .unwrap()
14760 .unwrap();
14761 assert_eq!(
14762 serde_json::to_value(after).unwrap(),
14763 serde_json::to_value(before).unwrap()
14764 );
14765 }
14766
14767 #[tokio::test]
14768 async fn note_update_effects_commit_guards_roll_back_note_and_prior_edge_effects() {
14769 use crate::atomic_prepare::prepare_update_from_note_snapshot;
14770 use crate::{run_atomic_unit, AtomicRunOutcome, EdgeListFilter};
14771 use khive_storage::EdgeRelation;
14772 use serde_json::json;
14773 for stale_note in [false, true] {
14774 let (runtime, token, registry, hook, snapshot) = effects_fixture().await;
14775 let a = runtime
14776 .create_entity(&token, "concept", None, "A", None, None, vec![])
14777 .await
14778 .unwrap();
14779 let b = runtime
14780 .create_entity(&token, "concept", None, "B", None, None, vec![])
14781 .await
14782 .unwrap();
14783 let a_id = a.id;
14784 let b_id = b.id;
14785 runtime
14786 .link(
14787 &token,
14788 snapshot.id,
14789 a_id,
14790 EdgeRelation::Annotates,
14791 1.0,
14792 None,
14793 )
14794 .await
14795 .unwrap();
14796 let old = runtime
14797 .get_edge_by_natural_key_including_deleted(
14798 &token,
14799 "local",
14800 snapshot.id,
14801 a_id,
14802 EdgeRelation::Annotates,
14803 )
14804 .await
14805 .unwrap()
14806 .unwrap();
14807 *hook.effects.lock().unwrap() = vec![
14808 NoteUpdateEffect::DeleteEdge(old.clone()),
14809 NoteUpdateEffect::Link(LinkSpec {
14810 namespace: None,
14811 source_id: snapshot.id,
14812 target_id: b_id,
14813 relation: EdgeRelation::Annotates,
14814 weight: 1.0,
14815 metadata: None,
14816 resurrect: false,
14817 }),
14818 ];
14819 let mut args = json!({"id": snapshot.id, "raw_marker": false});
14820 let policy = registry
14821 .prepare_note_update_policy(&runtime, &token, &snapshot, &mut args)
14822 .await
14823 .unwrap();
14824 let (_, plan) = prepare_update_from_note_snapshot(
14825 &runtime,
14826 &token,
14827 &args,
14828 None,
14829 snapshot.clone(),
14830 policy,
14831 ®istry,
14832 )
14833 .await
14834 .unwrap();
14835 let expected = if stale_note {
14836 runtime
14837 .update_note(
14838 &token,
14839 snapshot.id,
14840 crate::NotePatch::new(
14841 None,
14842 Some("concurrent winner".into()),
14843 None,
14844 None,
14845 None,
14846 ),
14847 )
14848 .await
14849 .unwrap()
14850 } else {
14851 runtime.delete_entity(&token, b_id, false).await.unwrap();
14852 snapshot.clone()
14853 };
14854 let outcome = run_atomic_unit(runtime.sql().as_ref(), vec![plan])
14855 .await
14856 .unwrap();
14857 assert!(
14858 matches!(
14859 outcome,
14860 AtomicRunOutcome::RolledBack {
14861 failed_op_index: 0,
14862 ..
14863 }
14864 ),
14865 "missing endpoint / stale note MUST refuse: {outcome:?}"
14866 );
14867 assert_eq!(
14868 runtime
14869 .notes(&token)
14870 .unwrap()
14871 .get_note(snapshot.id)
14872 .await
14873 .unwrap()
14874 .unwrap(),
14875 expected
14876 );
14877 let edges = runtime
14878 .list_edges(
14879 &token,
14880 EdgeListFilter {
14881 source_id: Some(snapshot.id),
14882 ..Default::default()
14883 },
14884 10,
14885 0,
14886 )
14887 .await
14888 .unwrap();
14889 assert_eq!(serde_json::to_value(edges).unwrap(), json!([old]));
14890 assert!(runtime
14891 .get_edge_by_natural_key_including_deleted(
14892 &token,
14893 "local",
14894 snapshot.id,
14895 b_id,
14896 EdgeRelation::Annotates,
14897 )
14898 .await
14899 .unwrap()
14900 .is_none());
14901 }
14902 }
14903}
14904
14905#[cfg(test)]
14908mod hook_tests {
14909 use super::*;
14910 use async_trait::async_trait;
14911 use khive_types::Pack;
14912 use std::sync::atomic::{AtomicUsize, Ordering};
14913 use std::sync::Mutex as StdMutex;
14914
14915 struct SimplePack;
14916
14917 impl Pack for SimplePack {
14918 const NAME: &'static str = "simple";
14919 const NOTE_KINDS: &'static [&'static str] = &[];
14920 const ENTITY_KINDS: &'static [&'static str] = &[];
14921 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
14922 name: "ping",
14923 description: "ping",
14924 visibility: Visibility::Verb,
14925 category: VerbCategory::Assertive,
14926 params: &[],
14927 }];
14928 }
14929
14930 #[async_trait]
14931 impl PackRuntime for SimplePack {
14932 fn name(&self) -> &str {
14933 SimplePack::NAME
14934 }
14935 fn note_kinds(&self) -> &'static [&'static str] {
14936 SimplePack::NOTE_KINDS
14937 }
14938 fn entity_kinds(&self) -> &'static [&'static str] {
14939 SimplePack::ENTITY_KINDS
14940 }
14941 fn handlers(&self) -> &'static [HandlerDef] {
14942 SimplePack::HANDLERS
14943 }
14944 async fn dispatch(
14945 &self,
14946 verb: &str,
14947 _params: Value,
14948 _registry: &VerbRegistry,
14949 _token: &NamespaceToken,
14950 ) -> Result<Value, RuntimeError> {
14951 Ok(serde_json::json!({ "verb": verb }))
14952 }
14953 }
14954
14955 struct RecallPack;
14956
14957 impl Pack for RecallPack {
14958 const NAME: &'static str = "memory";
14959 const NOTE_KINDS: &'static [&'static str] = &[];
14960 const ENTITY_KINDS: &'static [&'static str] = &[];
14961 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
14962 name: "memory.recall",
14963 description: "test recall",
14964 visibility: Visibility::Verb,
14965 category: VerbCategory::Assertive,
14966 params: &[],
14967 }];
14968 }
14969
14970 #[async_trait]
14971 impl PackRuntime for RecallPack {
14972 fn name(&self) -> &str {
14973 RecallPack::NAME
14974 }
14975 fn note_kinds(&self) -> &'static [&'static str] {
14976 RecallPack::NOTE_KINDS
14977 }
14978 fn entity_kinds(&self) -> &'static [&'static str] {
14979 RecallPack::ENTITY_KINDS
14980 }
14981 fn handlers(&self) -> &'static [HandlerDef] {
14982 RecallPack::HANDLERS
14983 }
14984 async fn dispatch(
14985 &self,
14986 _verb: &str,
14987 params: Value,
14988 _registry: &VerbRegistry,
14989 _token: &NamespaceToken,
14990 ) -> Result<Value, RuntimeError> {
14991 let hit = serde_json::json!({
14992 "id": uuid::Uuid::new_v4().to_string(),
14993 "served_by_profile_id": "custom-recall-v1",
14994 "serve_attribution": "profile",
14995 });
14996 if params.get("verbose").and_then(Value::as_bool) == Some(true) {
14997 Ok(serde_json::json!({"results": [hit]}))
14998 } else {
14999 Ok(serde_json::json!([hit]))
15000 }
15001 }
15002 }
15003
15004 #[derive(Default)]
15005 struct EventCapturingHook {
15006 event: StdMutex<Option<Event>>,
15007 }
15008
15009 #[async_trait]
15010 impl DispatchHook for EventCapturingHook {
15011 async fn on_dispatch(&self, view: &EventView) {
15012 *self.event.lock().unwrap() = Some(view.event.clone());
15013 }
15014 }
15015
15016 #[derive(Default)]
15018 struct CountingHook {
15019 calls: AtomicUsize,
15020 last_verb: StdMutex<String>,
15021 }
15022
15023 #[async_trait]
15024 impl DispatchHook for CountingHook {
15025 async fn on_dispatch(&self, view: &EventView) {
15026 self.calls.fetch_add(1, Ordering::SeqCst);
15027 *self.last_verb.lock().unwrap() = view.event.verb.clone();
15028 }
15029 }
15030
15031 #[tokio::test]
15032 async fn dispatch_hook_fires_on_successful_dispatch() {
15033 let hook = Arc::new(CountingHook::default());
15034 let mut builder = VerbRegistryBuilder::new();
15035 builder.register(SimplePack);
15036 builder.with_dispatch_hook(hook.clone());
15037 let reg = builder.build().expect("registry builds");
15038
15039 reg.dispatch("ping", Value::Null).await.unwrap();
15040
15041 assert_eq!(
15042 hook.calls.load(Ordering::SeqCst),
15043 1,
15044 "hook must fire once per successful dispatch"
15045 );
15046 assert_eq!(
15047 hook.last_verb.lock().unwrap().as_str(),
15048 "ping",
15049 "hook event must carry the dispatched verb"
15050 );
15051 }
15052
15053 #[tokio::test]
15054 async fn dispatch_hook_fires_multiple_times() {
15055 let hook = Arc::new(CountingHook::default());
15056 let mut builder = VerbRegistryBuilder::new();
15057 builder.register(SimplePack);
15058 builder.with_dispatch_hook(hook.clone());
15059 let reg = builder.build().expect("registry builds");
15060
15061 reg.dispatch("ping", Value::Null).await.unwrap();
15062 reg.dispatch("ping", Value::Null).await.unwrap();
15063 reg.dispatch("ping", Value::Null).await.unwrap();
15064
15065 assert_eq!(
15066 hook.calls.load(Ordering::SeqCst),
15067 3,
15068 "hook must fire once per successful dispatch"
15069 );
15070 }
15071
15072 #[tokio::test]
15073 async fn recall_hook_copies_serve_attribution_from_bare_and_verbose_results() {
15074 let hook = Arc::new(EventCapturingHook::default());
15075 let mut builder = VerbRegistryBuilder::new();
15076 builder.register(RecallPack);
15077 builder.with_dispatch_hook(hook.clone());
15078 let reg = builder.build().expect("registry builds");
15079
15080 for params in [serde_json::json!({}), serde_json::json!({"verbose": true})] {
15081 reg.dispatch("memory.recall", params)
15082 .await
15083 .expect("recall dispatch");
15084 let event = hook.event.lock().unwrap().clone().expect("hook event");
15085 assert!(
15086 event.target_id.is_some(),
15087 "first recall id must become target"
15088 );
15089 assert_eq!(
15090 event.payload["served_by_profile_id"],
15091 serde_json::json!("custom-recall-v1")
15092 );
15093 assert_eq!(
15094 event.payload["serve_attribution"],
15095 serde_json::json!("profile")
15096 );
15097 }
15098 }
15099
15100 #[tokio::test]
15101 async fn dispatch_hook_does_not_fire_on_unknown_verb() {
15102 let hook = Arc::new(CountingHook::default());
15103 let mut builder = VerbRegistryBuilder::new();
15104 builder.register(SimplePack);
15105 builder.with_dispatch_hook(hook.clone());
15106 let reg = builder.build().expect("registry builds");
15107
15108 let _ = reg.dispatch("nonexistent", Value::Null).await;
15109
15110 assert_eq!(
15111 hook.calls.load(Ordering::SeqCst),
15112 0,
15113 "hook must NOT fire for unknown verb (dispatch returns error)"
15114 );
15115 }
15116
15117 #[tokio::test]
15118 async fn dispatch_hook_does_not_fire_on_gate_deny() {
15119 use khive_gate::{Gate, GateDecision, GateError};
15120
15121 #[derive(Debug)]
15122 struct AlwaysDenyGate;
15123 impl Gate for AlwaysDenyGate {
15124 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
15125 Ok(GateDecision::deny("test deny"))
15126 }
15127 }
15128
15129 let hook = Arc::new(CountingHook::default());
15130 let mut builder = VerbRegistryBuilder::new();
15131 builder.register(SimplePack);
15132 builder.with_gate(Arc::new(AlwaysDenyGate));
15133 builder.with_dispatch_hook(hook.clone());
15134 let reg = builder.build().expect("registry builds");
15135
15136 let err = reg.dispatch("ping", Value::Null).await.unwrap_err();
15137 assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
15138
15139 assert_eq!(
15140 hook.calls.load(Ordering::SeqCst),
15141 0,
15142 "hook must NOT fire when gate denies dispatch"
15143 );
15144 }
15145
15146 #[tokio::test]
15147 async fn dispatch_hook_event_carries_namespace_from_params() {
15148 let hook = Arc::new(CountingHook::default());
15149
15150 #[derive(Default)]
15151 struct NsCapturingHook {
15152 ns: StdMutex<String>,
15153 }
15154
15155 #[async_trait]
15156 impl DispatchHook for NsCapturingHook {
15157 async fn on_dispatch(&self, view: &EventView) {
15158 *self.ns.lock().unwrap() = view.event.namespace.clone();
15159 }
15160 }
15161
15162 let ns_hook = Arc::new(NsCapturingHook::default());
15163 let mut builder = VerbRegistryBuilder::new();
15164 builder.register(SimplePack);
15165 builder.with_dispatch_hook(ns_hook.clone());
15166 let reg = builder.build().expect("registry builds");
15167
15168 reg.dispatch("ping", serde_json::json!({"namespace": "tenant-abc"}))
15169 .await
15170 .unwrap();
15171
15172 assert_eq!(
15173 ns_hook.ns.lock().unwrap().as_str(),
15174 "tenant-abc",
15175 "dispatch hook event must carry the resolved namespace"
15176 );
15177
15178 drop(hook);
15180 }
15181
15182 #[tokio::test]
15183 async fn no_dispatch_hook_configured_dispatch_succeeds() {
15184 let mut builder = VerbRegistryBuilder::new();
15186 builder.register(SimplePack);
15187 let reg = builder.build().expect("registry builds");
15189
15190 let res = reg.dispatch("ping", Value::Null).await.unwrap();
15191 assert_eq!(res["verb"], "ping");
15192 }
15193}
15194
15195#[cfg(test)]
15198mod help_tests {
15199 use super::*;
15200 use async_trait::async_trait;
15201 use khive_types::Pack;
15202 use std::sync::{
15203 atomic::{AtomicUsize, Ordering},
15204 Arc,
15205 };
15206
15207 static CREATE_PARAMS: [ParamDef; 2] = [
15212 ParamDef {
15213 name: "kind",
15214 param_type: "string",
15215 required: true,
15216 description: "Granular kind (concept | document | ...).",
15217 resolution_mode: IdResolutionMode::NotApplicable,
15218 },
15219 ParamDef {
15220 name: "name",
15221 param_type: "string",
15222 required: false,
15223 description: "Human-readable name.",
15224 resolution_mode: IdResolutionMode::NotApplicable,
15225 },
15226 ];
15227
15228 static RECALL_PARAMS: [ParamDef; 2] = [
15229 ParamDef {
15230 name: "query",
15231 param_type: "string",
15232 required: true,
15233 description: "Semantic recall query.",
15234 resolution_mode: IdResolutionMode::NotApplicable,
15235 },
15236 ParamDef {
15237 name: "limit",
15238 param_type: "integer",
15239 required: false,
15240 description: "Maximum memories to return.",
15241 resolution_mode: IdResolutionMode::NotApplicable,
15242 },
15243 ];
15244
15245 static EMBED_PARAMS: [ParamDef; 0] = [];
15248
15249 static GET_PARAMS: [ParamDef; 1] = [ParamDef {
15254 name: "id",
15255 param_type: "uuid",
15256 required: true,
15257 description: "UUID of the record to fetch.",
15258 resolution_mode: IdResolutionMode::UnscopedById,
15259 }];
15260
15261 static LINK_PARAMS: [ParamDef; 2] = [
15266 ParamDef {
15267 name: "source_id",
15268 param_type: "uuid",
15269 required: true,
15270 description: "Source node UUID.",
15271 resolution_mode: IdResolutionMode::UnscopedById,
15272 },
15273 ParamDef {
15274 name: "target_id",
15275 param_type: "uuid",
15276 required: true,
15277 description: "Target node UUID.",
15278 resolution_mode: IdResolutionMode::UnscopedById,
15279 },
15280 ];
15281
15282 struct HelpPack {
15283 invocations: Arc<AtomicUsize>,
15284 }
15285
15286 impl Pack for HelpPack {
15287 const NAME: &'static str = "helptest";
15288 const NOTE_KINDS: &'static [&'static str] = &[];
15289 const ENTITY_KINDS: &'static [&'static str] = &[];
15290 const HANDLERS: &'static [HandlerDef] = &[
15291 HandlerDef {
15292 name: "create",
15293 description: "Create an entity or note",
15294 visibility: Visibility::Verb,
15295 category: VerbCategory::Commissive,
15296 params: &CREATE_PARAMS,
15297 },
15298 HandlerDef {
15299 name: "recall",
15300 description: "Recall memory notes with decay-aware hybrid ranking",
15301 visibility: Visibility::Verb,
15302 category: VerbCategory::Assertive,
15303 params: &RECALL_PARAMS,
15304 },
15305 HandlerDef {
15308 name: "recall.embed",
15309 description: "Return the embedding vector used by memory recall",
15310 visibility: Visibility::Subhandler,
15311 category: VerbCategory::Assertive,
15312 params: &EMBED_PARAMS,
15313 },
15314 HandlerDef {
15315 name: "link",
15316 description: "Create a typed directed edge",
15317 visibility: Visibility::Verb,
15318 category: VerbCategory::Commissive,
15319 params: &LINK_PARAMS,
15320 },
15321 HandlerDef {
15322 name: "get",
15323 description: "Fetch a record by id",
15324 visibility: Visibility::Verb,
15325 category: VerbCategory::Assertive,
15326 params: &GET_PARAMS,
15327 },
15328 ];
15329 }
15330
15331 static HELP_EDGE_RULES: [EdgeEndpointRule; 2] = [
15339 EdgeEndpointRule {
15340 relation: khive_types::EdgeRelation::DependsOn,
15341 source: EndpointKind::NoteOfKind("task"),
15342 target: EndpointKind::NoteOfKind("task"),
15343 },
15344 EdgeEndpointRule {
15345 relation: khive_types::EdgeRelation::Supersedes,
15346 source: EndpointKind::NoteOfKind("task"),
15347 target: EndpointKind::NoteOfKind("task"),
15348 },
15349 ];
15350
15351 #[async_trait]
15352 impl PackRuntime for HelpPack {
15353 fn name(&self) -> &str {
15354 HelpPack::NAME
15355 }
15356 fn note_kinds(&self) -> &'static [&'static str] {
15357 HelpPack::NOTE_KINDS
15358 }
15359 fn entity_kinds(&self) -> &'static [&'static str] {
15360 HelpPack::ENTITY_KINDS
15361 }
15362 fn handlers(&self) -> &'static [HandlerDef] {
15363 HelpPack::HANDLERS
15364 }
15365 fn edge_rules(&self) -> &'static [EdgeEndpointRule] {
15366 &HELP_EDGE_RULES
15367 }
15368 async fn dispatch(
15369 &self,
15370 verb: &str,
15371 _params: Value,
15372 _registry: &VerbRegistry,
15373 _token: &NamespaceToken,
15374 ) -> Result<Value, RuntimeError> {
15375 self.invocations.fetch_add(1, Ordering::SeqCst);
15376 Ok(serde_json::json!({ "pack": "helptest", "verb": verb }))
15377 }
15378 }
15379
15380 fn build_help_registry(invocations: Arc<AtomicUsize>) -> VerbRegistry {
15381 let mut builder = VerbRegistryBuilder::new();
15382 builder.register(HelpPack { invocations });
15383 builder.build().expect("help registry builds")
15384 }
15385
15386 #[tokio::test]
15389 async fn test_help_true_returns_schema_for_kg_create() {
15390 let invocations = Arc::new(AtomicUsize::new(0));
15391 let reg = build_help_registry(invocations.clone());
15392
15393 let result = reg
15394 .dispatch("create", serde_json::json!({ "help": true }))
15395 .await
15396 .expect("help=true must succeed for a known verb");
15397
15398 assert_eq!(result["verb"], "create", "envelope must name the verb");
15400 assert_eq!(
15401 result["pack"], "helptest",
15402 "envelope must name the owning pack"
15403 );
15404 assert!(
15405 result["description"].as_str().is_some(),
15406 "description must be a string"
15407 );
15408
15409 let params = result["params"]
15411 .as_array()
15412 .expect("params must be a JSON array");
15413 assert!(!params.is_empty(), "params array must not be empty");
15414
15415 let kind_param = params.iter().find(|p| p["name"] == "kind");
15417 assert!(
15418 kind_param.is_some(),
15419 "params array must include the 'kind' parameter"
15420 );
15421 let kind_param = kind_param.unwrap();
15422 assert_eq!(
15423 kind_param["required"],
15424 serde_json::json!(true),
15425 "'kind' must be required"
15426 );
15427 assert_eq!(kind_param["type"], "string", "'kind' type must be 'string'");
15428
15429 let identifier_help = result["identifier_resolution"]
15430 .as_object()
15431 .expect("help=true must include the shared identifier contract");
15432 assert!(identifier_help["full_uuid"]
15433 .as_str()
15434 .is_some_and(|text| text.contains("globally unique")));
15435 assert!(identifier_help["short_prefix"]
15436 .as_str()
15437 .is_some_and(|text| text.contains("lookup scope belongs to the consuming parameter")));
15438 assert!(identifier_help["parameter_rule"]
15439 .as_str()
15440 .is_some_and(|text| text.contains("submitted again")));
15441 }
15442
15443 #[test]
15444 fn event_target_resolution_metadata_has_a_conditional_contract() {
15445 let mode = IdResolutionMode::EdgeOrEventTarget;
15446 let text = resolution_mode_contract(mode).unwrap();
15447 assert!(text.contains("kind=event accepts only a full subject UUID"));
15448 assert!(text.contains("prefixes and names are rejected without graph resolution"));
15449 assert!(text.contains("For kind=edge"));
15450 assert!(text.contains("prefix or entity name resolves in the primary namespace"));
15451 assert_eq!(resolution_mode_key(mode), "edge_or_event_target");
15452 assert_eq!(
15453 identifier_resolution_help()["resolution_modes"]["edge_or_event_target"],
15454 text
15455 );
15456 }
15457
15458 #[tokio::test]
15484 async fn test_help_true_uuid_param_carries_shared_id_contract() {
15485 let invocations = Arc::new(AtomicUsize::new(0));
15486 let reg = build_help_registry(invocations.clone());
15487
15488 let result = reg
15489 .dispatch("get", serde_json::json!({ "help": true }))
15490 .await
15491 .expect("help=true must succeed for a known verb");
15492
15493 let params = result["params"]
15494 .as_array()
15495 .expect("params must be a JSON array");
15496 let id_param = params
15497 .iter()
15498 .find(|p| p["name"] == "id")
15499 .expect("params array must include the 'id' parameter");
15500 let description = id_param["description"]
15501 .as_str()
15502 .expect("description must be a string");
15503
15504 assert!(
15506 description.contains("UUID of the record to fetch"),
15507 "shared contract must be appended, not replace, the verb-specific text; got: {description}"
15508 );
15509 assert!(
15514 description.contains("no namespace filter"),
15515 "id contract must state that by-ID prefix resolution applies NO namespace filter \
15516 (ADR-007 Rev 6, `resolve_prefix_unfiltered`); got: {description}"
15517 );
15518 assert!(
15519 !description.contains("namespace-scoped resolution"),
15520 "id contract must not claim prefix resolution is namespace-scoped — get/update/\
15521 delete/merge resolve prefixes unfiltered; got: {description}"
15522 );
15523 assert!(
15524 description.to_ascii_lowercase().contains("prefix"),
15525 "id contract must describe short-prefix semantics; got: {description}"
15526 );
15527
15528 let link_result = reg
15534 .dispatch("link", serde_json::json!({ "help": true }))
15535 .await
15536 .expect("help=true must succeed for link");
15537 let link_params = link_result["params"]
15538 .as_array()
15539 .expect("link params must be a JSON array");
15540 let source_id_param = link_params
15541 .iter()
15542 .find(|p| p["name"] == "source_id")
15543 .expect("link params must include 'source_id'");
15544 let source_id_description = source_id_param["description"]
15545 .as_str()
15546 .expect("description must be a string");
15547 assert!(
15548 source_id_description.contains("get/update/delete/merge/link"),
15549 "id contract's by-ID verb enumeration must include 'link' alongside get/update/\
15550 delete/merge, since link's source_id/target_id resolve through the same \
15551 unfiltered path; got: {source_id_description}"
15552 );
15553 }
15554
15555 #[tokio::test]
15557 async fn test_help_true_returns_schema_for_recall() {
15558 let invocations = Arc::new(AtomicUsize::new(0));
15559 let reg = build_help_registry(invocations.clone());
15560
15561 let result = reg
15562 .dispatch("recall", serde_json::json!({ "help": true }))
15563 .await
15564 .expect("help=true must succeed for recall");
15565
15566 assert_eq!(result["verb"], "recall");
15567 assert_eq!(result["pack"], "helptest");
15568
15569 let params = result["params"]
15570 .as_array()
15571 .expect("params must be a JSON array");
15572
15573 let query_param = params.iter().find(|p| p["name"] == "query");
15575 assert!(query_param.is_some(), "params must include 'query'");
15576 let query_param = query_param.unwrap();
15577 assert_eq!(
15578 query_param["required"],
15579 serde_json::json!(true),
15580 "'query' must be required"
15581 );
15582
15583 let limit_param = params.iter().find(|p| p["name"] == "limit");
15585 assert!(limit_param.is_some(), "params must include 'limit'");
15586 let limit_param = limit_param.unwrap();
15587 assert_eq!(
15588 limit_param["required"],
15589 serde_json::json!(false),
15590 "'limit' must be optional"
15591 );
15592 }
15593
15594 #[tokio::test]
15600 async fn test_link_help_true_exposes_endpoint_rules() {
15601 let invocations = Arc::new(AtomicUsize::new(0));
15602 let reg = build_help_registry(invocations.clone());
15603
15604 let result = reg
15605 .dispatch("link", serde_json::json!({ "help": true }))
15606 .await
15607 .expect("help=true must succeed for link");
15608
15609 assert_eq!(result["verb"], "link");
15610 let rules = result["endpoint_rules"]
15611 .as_array()
15612 .expect("link help must include an endpoint_rules array");
15613 assert!(!rules.is_empty(), "endpoint_rules must not be empty");
15614
15615 assert!(
15617 rules.iter().any(|r| r["relation"] == "contains"
15618 && r["source"] == "entity:concept"
15619 && r["target"] == "entity:concept"),
15620 "endpoint_rules must include the base 'contains' entity rule; got {rules:#?}"
15621 );
15622
15623 assert!(
15625 rules.iter().any(|r| r["relation"] == "depends_on"
15626 && r["source"] == "note:task"
15627 && r["target"] == "note:task"),
15628 "endpoint_rules must include the pack-declared depends_on rule; got {rules:#?}"
15629 );
15630
15631 assert!(
15633 rules
15634 .iter()
15635 .any(|r| r["relation"] == "annotates" && r["source"] == "note:*"),
15636 "endpoint_rules must document the annotates note-to-any rule; got {rules:#?}"
15637 );
15638
15639 assert_eq!(
15641 invocations.load(Ordering::SeqCst),
15642 0,
15643 "link(help=true) must not invoke pack dispatch"
15644 );
15645 }
15646
15647 #[tokio::test]
15666 async fn test_link_help_true_matches_special_relation_validator_set() {
15667 let invocations = Arc::new(AtomicUsize::new(0));
15668 let reg = build_help_registry(invocations.clone());
15669
15670 let result = reg
15671 .dispatch("link", serde_json::json!({ "help": true }))
15672 .await
15673 .expect("help=true must succeed for link");
15674
15675 let rules = result["endpoint_rules"]
15676 .as_array()
15677 .expect("link help must include an endpoint_rules array");
15678
15679 for relation in ["supersedes", "supports", "refutes"] {
15680 assert!(
15682 rules.iter().any(|r| r["relation"] == relation
15683 && r["source"] == "note:*"
15684 && r["target"] == "note:*"),
15685 "endpoint_rules must include the note:*->note:* row for '{relation}' \
15686 (validator accepts any note->note pair unconditionally); got {rules:#?}"
15687 );
15688
15689 assert!(
15694 !rules.iter().any(|r| r["relation"] == relation
15695 && r["source"] == "note:task"
15696 && r["target"] == "note:task"),
15697 "endpoint_rules must NOT advertise a pack EDGE_RULES row for special \
15698 relation '{relation}' — validate_edge_relation_endpoints never consults \
15699 pack_rule_allows for supersedes/supports/refutes; got {rules:#?}"
15700 );
15701 }
15702
15703 for (relation, kind) in [
15706 ("supersedes", "concept"),
15707 ("supports", "concept"),
15708 ("refutes", "concept"),
15709 ] {
15710 assert!(
15711 rules.iter().any(|r| r["relation"] == relation
15712 && r["source"] == format!("entity:{kind}")
15713 && r["target"] == "entity:concept"),
15714 "endpoint_rules must include the base entity:{kind}->entity:concept row \
15715 for '{relation}'; got {rules:#?}"
15716 );
15717 }
15718 }
15719
15720 #[test]
15721 fn special_relation_predicate_matches_the_dedicated_validator_set() {
15722 for relation in khive_types::EdgeRelation::ALL {
15723 assert_eq!(
15724 is_special_relation(relation),
15725 matches!(
15726 relation,
15727 khive_types::EdgeRelation::Supersedes
15728 | khive_types::EdgeRelation::Supports
15729 | khive_types::EdgeRelation::Refutes
15730 ),
15731 "unexpected special-relation classification for {relation}"
15732 );
15733 }
15734 }
15735
15736 #[tokio::test]
15739 async fn test_help_true_does_not_execute_the_verb() {
15740 let invocations = Arc::new(AtomicUsize::new(0));
15741 let reg = build_help_registry(invocations.clone());
15742
15743 reg.dispatch("create", serde_json::json!({ "help": true }))
15745 .await
15746 .expect("help=true must succeed");
15747 reg.dispatch("recall", serde_json::json!({ "help": true }))
15748 .await
15749 .expect("help=true must succeed");
15750
15751 assert_eq!(
15752 invocations.load(Ordering::SeqCst),
15753 0,
15754 "pack dispatch MUST NOT be invoked when help=true; \
15755 got {} invocation(s)",
15756 invocations.load(Ordering::SeqCst)
15757 );
15758
15759 reg.dispatch("create", serde_json::json!({}))
15761 .await
15762 .expect("normal dispatch must succeed");
15763 assert_eq!(
15764 invocations.load(Ordering::SeqCst),
15765 1,
15766 "pack dispatch must fire exactly once for a normal call"
15767 );
15768 }
15769
15770 #[tokio::test]
15779 async fn help_true_on_subhandler_returns_callable_via_mcp_false() {
15780 let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15781
15782 let result = reg
15783 .dispatch("recall.embed", serde_json::json!({ "help": true }))
15784 .await
15785 .expect("help=true on subhandler must succeed (no permission check on help path)");
15786
15787 assert_eq!(
15788 result["callable_via_mcp"],
15789 serde_json::json!(false),
15790 "subhandler help must carry callable_via_mcp: false"
15791 );
15792 assert_eq!(
15793 result["visibility"], "internal",
15794 "subhandler help must carry visibility: internal"
15795 );
15796 assert_eq!(result["verb"], "recall.embed");
15799 assert_eq!(result["pack"], "helptest");
15800 }
15801
15802 #[tokio::test]
15804 async fn help_true_on_public_verb_does_not_have_callable_via_mcp_false() {
15805 let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15806
15807 let result = reg
15808 .dispatch("create", serde_json::json!({ "help": true }))
15809 .await
15810 .expect("help=true on public verb must succeed");
15811
15812 assert_ne!(
15814 result.get("callable_via_mcp"),
15815 Some(&serde_json::json!(false)),
15816 "public verb help must NOT carry callable_via_mcp: false"
15817 );
15818 assert_ne!(
15820 result.get("visibility"),
15821 Some(&serde_json::json!("internal")),
15822 "public verb help must NOT carry visibility: internal"
15823 );
15824 }
15825
15826 #[tokio::test]
15828 async fn help_true_on_unknown_verb_returns_error() {
15829 let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15830
15831 let err = reg
15832 .dispatch("nonexistent_verb", serde_json::json!({ "help": true }))
15833 .await
15834 .unwrap_err();
15835
15836 assert!(
15837 matches!(err, RuntimeError::UnknownVerb(_)),
15838 "help=true on unknown verb must return UnknownVerb, got {err:?}"
15839 );
15840 let msg = err.to_string();
15841 assert!(
15842 msg.contains("nonexistent_verb"),
15843 "error must name the unknown verb: {msg}"
15844 );
15845 }
15846
15847 #[tokio::test]
15849 async fn help_true_on_subhandler_includes_params_field() {
15850 let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15851
15852 let result = reg
15853 .dispatch("recall.embed", serde_json::json!({ "help": true }))
15854 .await
15855 .expect("help=true on subhandler must succeed");
15856
15857 let params = result
15859 .get("params")
15860 .expect("subhandler help must include 'params' field");
15861 assert!(
15862 params.is_array(),
15863 "subhandler help params must be a JSON array"
15864 );
15865 }
15866
15867 #[tokio::test]
15872 async fn help_true_unknown_verb_available_list_excludes_subhandlers() {
15873 let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15874
15875 let err = reg
15876 .dispatch("not_a_verb", serde_json::json!({ "help": true }))
15877 .await
15878 .unwrap_err();
15879
15880 let msg = err.to_string();
15881 assert!(
15884 !msg.contains("recall.embed"),
15885 "unknown-verb help error must not advertise subhandler recall.embed: {msg}"
15886 );
15887 assert!(
15889 msg.contains("create"),
15890 "unknown-verb help error must still list public verb 'create': {msg}"
15891 );
15892 assert!(
15893 msg.contains("recall"),
15894 "unknown-verb help error must still list public verb 'recall': {msg}"
15895 );
15896 }
15897
15898 #[tokio::test]
15900 async fn dispatch_unknown_verb_available_list_excludes_subhandlers() {
15901 let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15902
15903 let err = reg
15904 .dispatch("not_a_verb", serde_json::json!({}))
15905 .await
15906 .unwrap_err();
15907
15908 let msg = err.to_string();
15909 assert!(
15912 !msg.contains("recall.embed"),
15913 "dispatch unknown-verb error must not advertise subhandler recall.embed: {msg}"
15914 );
15915 assert!(
15917 msg.contains("create"),
15918 "dispatch unknown-verb error must still list public verb 'create': {msg}"
15919 );
15920 assert!(
15921 msg.contains("recall"),
15922 "dispatch unknown-verb error must still list public verb 'recall': {msg}"
15923 );
15924 }
15925
15926 struct SchemaPack {
15930 pack_name: &'static str,
15931 statements: &'static [&'static str],
15932 column_additions: &'static [PackColumnAddition],
15933 }
15934
15935 impl Pack for SchemaPack {
15936 const NAME: &'static str = "schema-pack";
15937 const NOTE_KINDS: &'static [&'static str] = &[];
15938 const ENTITY_KINDS: &'static [&'static str] = &[];
15939 const HANDLERS: &'static [HandlerDef] = &[];
15940 }
15941
15942 #[async_trait]
15943 impl PackRuntime for SchemaPack {
15944 fn name(&self) -> &str {
15945 self.pack_name
15946 }
15947 fn note_kinds(&self) -> &'static [&'static str] {
15948 &[]
15949 }
15950 fn entity_kinds(&self) -> &'static [&'static str] {
15951 &[]
15952 }
15953 fn handlers(&self) -> &'static [HandlerDef] {
15954 &[]
15955 }
15956 fn schema_plan(&self) -> SchemaPlan {
15957 SchemaPlan {
15958 pack: self.pack_name,
15959 statements: self.statements,
15960 }
15961 }
15962 fn schema_column_additions(&self) -> &'static [PackColumnAddition] {
15963 self.column_additions
15964 }
15965 async fn dispatch(
15966 &self,
15967 verb: &str,
15968 _params: Value,
15969 _registry: &VerbRegistry,
15970 _token: &NamespaceToken,
15971 ) -> Result<Value, RuntimeError> {
15972 Ok(serde_json::json!({ "pack": self.pack_name, "verb": verb }))
15973 }
15974 }
15975
15976 #[test]
15979 fn all_schema_plans_named_returns_correct_pairs() {
15980 let mut builder = VerbRegistryBuilder::new();
15981 builder.register_boxed(Box::new(SchemaPack {
15982 pack_name: "alpha",
15983 statements: &["CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER PRIMARY KEY)"],
15984 column_additions: &[],
15985 }));
15986 builder.register_boxed(Box::new(SchemaPack {
15987 pack_name: "beta",
15988 statements: &[],
15989 column_additions: &[],
15990 }));
15991 let reg = builder.build().expect("registry builds");
15992
15993 let named = reg.all_schema_plans_named();
15994 assert_eq!(named.len(), 2);
15995
15996 let alpha_entry = named.iter().find(|(n, _)| *n == "alpha");
15997 let beta_entry = named.iter().find(|(n, _)| *n == "beta");
15998
15999 assert!(alpha_entry.is_some(), "alpha must appear in named plans");
16000 assert!(beta_entry.is_some(), "beta must appear in named plans");
16001
16002 let (_, alpha_plan) = alpha_entry.unwrap();
16003 assert_eq!(alpha_plan.statements.len(), 1);
16004 assert!(!alpha_plan.is_empty());
16005
16006 let (_, beta_plan) = beta_entry.unwrap();
16007 assert!(beta_plan.is_empty());
16008 }
16009
16010 #[tokio::test]
16027 async fn apply_schema_plans_with_map_routes_to_correct_backend() {
16028 use khive_storage::types::{SqlStatement, SqlValue};
16029
16030 let default_backend = khive_db::StorageBackend::memory().expect("default memory backend");
16031 let pack_backend =
16032 khive_db::StorageBackend::memory().expect("pack-specific memory backend");
16033
16034 let mut builder = VerbRegistryBuilder::new();
16035 builder.register_boxed(Box::new(SchemaPack {
16036 pack_name: "routed",
16037 statements: &["CREATE TABLE IF NOT EXISTS t_routed (id INTEGER PRIMARY KEY)"],
16038 column_additions: &[],
16039 }));
16040 let reg = builder.build().expect("registry builds");
16041
16042 let mut backend_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
16043 backend_map.insert("routed", &pack_backend);
16044
16045 reg.apply_schema_plans_with_map(&backend_map, &default_backend)
16046 .expect("schema application must not collide");
16047
16048 let mut writer = pack_backend.sql().writer().await.expect("writer");
16050 let result = writer
16051 .execute(SqlStatement {
16052 sql: "INSERT INTO t_routed (id) VALUES (?1)".into(),
16053 params: vec![SqlValue::Integer(1)],
16054 label: None,
16055 })
16056 .await;
16057 assert!(
16058 result.is_ok(),
16059 "t_routed must exist on pack_backend after routing: {result:?}"
16060 );
16061
16062 let mut default_writer = default_backend.sql().writer().await.expect("writer");
16064 let default_result = default_writer
16065 .execute(SqlStatement {
16066 sql: "INSERT INTO t_routed (id) VALUES (?1)".into(),
16067 params: vec![SqlValue::Integer(2)],
16068 label: None,
16069 })
16070 .await;
16071 assert!(
16072 default_result.is_err(),
16073 "t_routed must NOT exist on default_backend (table should not be there)"
16074 );
16075 }
16076
16077 #[tokio::test]
16080 async fn apply_schema_plans_with_map_falls_back_to_default_for_unmapped_packs() {
16081 use khive_storage::types::{SqlStatement, SqlValue};
16082
16083 let default_backend = khive_db::StorageBackend::memory().expect("default memory backend");
16084
16085 let mut builder = VerbRegistryBuilder::new();
16086 builder.register_boxed(Box::new(SchemaPack {
16087 pack_name: "unmapped",
16088 statements: &["CREATE TABLE IF NOT EXISTS t_unmapped (id INTEGER PRIMARY KEY)"],
16089 column_additions: &[],
16090 }));
16091 let reg = builder.build().expect("registry builds");
16092
16093 let backend_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
16094 reg.apply_schema_plans_with_map(&backend_map, &default_backend)
16095 .expect("schema application must not collide");
16096
16097 let mut writer = default_backend.sql().writer().await.expect("writer");
16099 let result = writer
16100 .execute(SqlStatement {
16101 sql: "INSERT INTO t_unmapped (id) VALUES (?1)".into(),
16102 params: vec![SqlValue::Integer(1)],
16103 label: None,
16104 })
16105 .await;
16106 assert!(
16107 result.is_ok(),
16108 "t_unmapped must exist on default_backend for unmapped pack: {result:?}"
16109 );
16110 }
16111
16112 #[test]
16117 fn apply_schema_plans_with_map_collision_is_an_error() {
16118 let backend = khive_db::StorageBackend::memory().expect("memory backend");
16119 let empty_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
16120
16121 let mut builder = VerbRegistryBuilder::new();
16122 builder.register_boxed(Box::new(SchemaPack {
16123 pack_name: "pack_alpha",
16124 statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
16125 column_additions: &[],
16126 }));
16127 builder.register_boxed(Box::new(SchemaPack {
16128 pack_name: "pack_beta",
16129 statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
16130 column_additions: &[],
16131 }));
16132 let registry = builder.build().expect("registry builds");
16133
16134 let result = registry.apply_schema_plans_with_map(&empty_map, &backend);
16135 assert!(
16136 result.is_err(),
16137 "two packs declaring the same table on the same backend must produce a collision error"
16138 );
16139 let err = result.unwrap_err();
16140 let msg = err.to_string();
16141 assert!(
16142 msg.contains("pack_alpha"),
16143 "collision error must name first pack; got: {msg}"
16144 );
16145 assert!(
16146 msg.contains("pack_beta"),
16147 "collision error must name second pack; got: {msg}"
16148 );
16149 assert!(
16150 msg.contains("collision_table"),
16151 "collision error must name the table; got: {msg}"
16152 );
16153 }
16154
16155 #[test]
16156 fn schema_collision_normalizes_sql_identifiers_before_any_ddl() {
16157 let spellings: [&'static [&'static str]; 7] = [
16158 &["CREATE TABLE IF NOT EXISTS \"shared\"(id INTEGER)"],
16159 &["-- pack table\nCREATE TABLE IF NOT EXISTS shared(id INTEGER)"],
16160 &["CREATE TABLE IF NOT EXISTS main.shared(id INTEGER)"],
16161 &["CREATE TEMP TABLE IF NOT EXISTS shared(id INTEGER)"],
16162 &["CREATE/**/TABLE IF NOT EXISTS shared(id INTEGER)"],
16163 &["CREATE TABLE IF NOT EXISTS/**/shared(id INTEGER)"],
16164 &["CREATE TABLE IF NOT EXISTS shared/**/(id INTEGER)"],
16165 ];
16166 for statements in spellings {
16167 let backend = khive_db::StorageBackend::memory().expect("memory backend");
16168 let mut builder = VerbRegistryBuilder::new();
16169 builder.register_boxed(Box::new(SchemaPack {
16170 pack_name: "pack_alpha",
16171 statements: &["CREATE TABLE IF NOT EXISTS shared (id INTEGER)"],
16172 column_additions: &[],
16173 }));
16174 builder.register_boxed(Box::new(SchemaPack {
16175 pack_name: "pack_beta",
16176 statements,
16177 column_additions: &[],
16178 }));
16179 let registry = builder.build().expect("registry builds");
16180 let error = registry
16181 .apply_schema_plans_with_map(&HashMap::new(), &backend)
16182 .expect_err("spelling must not evade table ownership");
16183 let message = error.to_string();
16184 assert!(message.contains("pack_alpha") && message.contains("pack_beta"));
16185 assert!(message.contains("shared"));
16186 let table_count: i64 = backend
16187 .pool()
16188 .reader()
16189 .expect("reader")
16190 .query_row(
16191 "SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = 'shared'",
16192 [],
16193 |row| row.get(0),
16194 )
16195 .expect("schema count");
16196 assert_eq!(table_count, 0, "collision must precede all pack DDL");
16197 }
16198 }
16199
16200 #[test]
16201 fn quoted_punctuation_table_names_cannot_evade_ownership() {
16202 let cases: [(&'static str, &'static [&'static str]); 3] = [
16203 (".", &[r#"CREATE TABLE IF NOT EXISTS "." (id INTEGER)"#]),
16204 ("(", &[r#"CREATE TABLE IF NOT EXISTS "(" (id INTEGER)"#]),
16205 (";", &[r#"CREATE TABLE IF NOT EXISTS ";" (id INTEGER)"#]),
16206 ];
16207 for (table, statements) in cases {
16208 let control = khive_db::StorageBackend::memory().expect("control backend");
16212 control
16213 .apply_pack_ddl_statements(statements)
16214 .expect("quoted punctuation is valid SQLite");
16215
16216 let backend = khive_db::StorageBackend::memory().expect("memory backend");
16217 let mut builder = VerbRegistryBuilder::new();
16218 for pack_name in ["pack_alpha", "pack_beta"] {
16219 builder.register_boxed(Box::new(SchemaPack {
16220 pack_name,
16221 statements,
16222 column_additions: &[],
16223 }));
16224 }
16225 let registry = builder.build().expect("registry builds");
16226 let error = registry
16227 .apply_schema_plans_with_map(&HashMap::new(), &backend)
16228 .expect_err("quoted punctuation must remain an owned table");
16229 let message = error.to_string();
16230 assert!(message.contains("pack_alpha") && message.contains("pack_beta"));
16231 let table_count: i64 = backend
16232 .pool()
16233 .reader()
16234 .expect("reader")
16235 .query_row(
16236 "SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = ?1",
16237 [table],
16238 |row| row.get(0),
16239 )
16240 .expect("schema count");
16241 assert_eq!(table_count, 0, "collision must precede all pack DDL");
16242 }
16243 }
16244
16245 #[test]
16246 fn apply_schema_plans_with_map_read_only_collision_is_an_error_without_writes() {
16247 let dir = tempfile::tempdir().expect("tempdir");
16248 let path = dir.path().join("read_only_schema_collision.db");
16249 {
16250 let writable =
16251 khive_db::StorageBackend::sqlite_for_test(&path).expect("writable backend");
16252 writable.prepare_core_schema().expect("current schema");
16253 }
16254 #[cfg(unix)]
16255 khive_storage::test_support::freeze_snapshot_sidecars(&path);
16256 let backend =
16257 khive_db::StorageBackend::sqlite_read_only_for_test(&path).expect("read-only backend");
16258 let empty_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
16259
16260 let mut builder = VerbRegistryBuilder::new();
16261 builder.register_boxed(Box::new(SchemaPack {
16262 pack_name: "pack_alpha",
16263 statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
16264 column_additions: &[],
16265 }));
16266 builder.register_boxed(Box::new(SchemaPack {
16267 pack_name: "pack_beta",
16268 statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
16269 column_additions: &[],
16270 }));
16271 let registry = builder.build().expect("registry builds");
16272 let writes_before = backend.pool().writer_acquisition_snapshot();
16273
16274 let result = registry.apply_schema_plans_with_map(&empty_map, &backend);
16275
16276 let err = result.expect_err(
16277 "read-only topology must reject the same cross-pack collision as writable topology",
16278 );
16279 let msg = err.to_string();
16280 assert!(
16281 msg.contains("pack_alpha"),
16282 "collision error must name first pack; got: {msg}"
16283 );
16284 assert!(
16285 msg.contains("pack_beta"),
16286 "collision error must name second pack; got: {msg}"
16287 );
16288 assert!(
16289 msg.contains("collision_table"),
16290 "collision error must name the table; got: {msg}"
16291 );
16292 assert_eq!(
16293 backend.pool().writer_acquisition_snapshot(),
16294 writes_before,
16295 "read-only collision validation must not acquire a writer"
16296 );
16297 }
16298
16299 fn column_schema_registry() -> VerbRegistry {
16300 let mut builder = VerbRegistryBuilder::new();
16301 builder.register_boxed(Box::new(SchemaPack {
16302 pack_name: "alpha",
16303 statements: &["CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER, revision TEXT)"],
16304 column_additions: &[PackColumnAddition {
16305 table: "t_alpha",
16306 column: "revision",
16307 affinity: PackColumnAffinity::Text,
16308 }],
16309 }));
16310 builder.register_boxed(Box::new(SchemaPack {
16311 pack_name: "beta",
16312 statements: &["CREATE TABLE IF NOT EXISTS t_beta (id INTEGER, epoch INTEGER)"],
16313 column_additions: &[PackColumnAddition {
16314 table: "t_beta",
16315 column: "epoch",
16316 affinity: PackColumnAffinity::Integer,
16317 }],
16318 }));
16319 builder.build().expect("registry builds")
16320 }
16321
16322 fn seed_column_schema(backend: &khive_db::StorageBackend) {
16323 backend
16324 .apply_pack_ddl_statements(&[
16325 "CREATE TABLE t_alpha (id INTEGER)",
16326 "CREATE TABLE t_beta (id INTEGER)",
16327 ])
16328 .expect("legacy schemas");
16329 }
16330
16331 fn column_schema_count(backend: &khive_db::StorageBackend, table: &str, column: &str) -> i64 {
16332 backend
16333 .pool()
16334 .reader()
16335 .unwrap()
16336 .query_row(
16337 "SELECT count(*) FROM pragma_table_xinfo(?1, 'main') WHERE name = ?2",
16338 [table, column],
16339 |row| row.get(0),
16340 )
16341 .unwrap()
16342 }
16343
16344 #[test]
16345 fn pack_column_upgrades_preserve_owner_metadata_and_apply_on_shared_backend() {
16346 let backend = khive_db::StorageBackend::memory().unwrap();
16347 seed_column_schema(&backend);
16348 let registry = column_schema_registry();
16349 let plans = registry.all_schema_plans_with_columns();
16350 assert_eq!(plans.len(), 2);
16351 let (_, alpha_columns) = plans.iter().find(|(plan, _)| plan.pack == "alpha").unwrap();
16352 assert_eq!(alpha_columns[0].table, "t_alpha");
16353 assert_eq!(alpha_columns[0].affinity, PackColumnAffinity::Text);
16354 let (_, beta_columns) = plans.iter().find(|(plan, _)| plan.pack == "beta").unwrap();
16355 assert_eq!(beta_columns[0].table, "t_beta");
16356 assert_eq!(beta_columns[0].affinity, PackColumnAffinity::Integer);
16357
16358 registry.apply_schema_plans(&backend);
16359 registry.apply_schema_plans(&backend);
16360 assert_eq!(column_schema_count(&backend, "t_alpha", "revision"), 1);
16361 assert_eq!(column_schema_count(&backend, "t_beta", "epoch"), 1);
16362 }
16363
16364 #[test]
16365 fn pack_column_upgrades_follow_assigned_backend_and_default_fallback() {
16366 let default_backend = khive_db::StorageBackend::memory().unwrap();
16367 let alpha_backend = khive_db::StorageBackend::memory().unwrap();
16368 seed_column_schema(&default_backend);
16369 seed_column_schema(&alpha_backend);
16370 let registry = column_schema_registry();
16371 let backend_map = HashMap::from([("alpha", &alpha_backend)]);
16372
16373 registry
16374 .apply_schema_plans_with_map(&backend_map, &default_backend)
16375 .unwrap();
16376 registry
16377 .apply_schema_plans_with_map(&backend_map, &default_backend)
16378 .unwrap();
16379 assert_eq!(
16380 column_schema_count(&alpha_backend, "t_alpha", "revision"),
16381 1
16382 );
16383 assert_eq!(
16384 column_schema_count(&default_backend, "t_alpha", "revision"),
16385 0
16386 );
16387 assert_eq!(column_schema_count(&default_backend, "t_beta", "epoch"), 1);
16388 assert_eq!(column_schema_count(&alpha_backend, "t_beta", "epoch"), 0);
16389 }
16390
16391 #[test]
16392 fn issue2768_pack_column_upgrades_refuse_read_only_old_schema_without_acquiring_writer() {
16393 let dir = tempfile::tempdir().unwrap();
16394 let path = dir.path().join("read_only_column_schema.db");
16395 {
16396 let writable = khive_db::StorageBackend::sqlite_for_test(&path).unwrap();
16397 writable.prepare_core_schema().unwrap();
16398 seed_column_schema(&writable);
16399 }
16400 #[cfg(unix)]
16401 khive_storage::test_support::freeze_snapshot_sidecars(&path);
16402 let backend = khive_db::StorageBackend::sqlite_read_only_for_test(&path).unwrap();
16403 let registry = column_schema_registry();
16404 let writes_before = backend.pool().writer_acquisition_snapshot();
16405
16406 let error = registry
16407 .apply_schema_plans_with_map(&HashMap::new(), &backend)
16408 .unwrap_err()
16409 .to_string();
16410 assert!(error.contains("alpha"), "{error}");
16411 assert!(error.contains("t_alpha.revision"), "{error}");
16412 assert!(
16413 error.contains("read-only schema validation failed"),
16414 "{error}"
16415 );
16416
16417 assert_eq!(backend.pool().writer_acquisition_snapshot(), writes_before);
16418 assert_eq!(column_schema_count(&backend, "t_alpha", "revision"), 0);
16419 assert_eq!(column_schema_count(&backend, "t_beta", "epoch"), 0);
16420 }
16421
16422 #[test]
16423 fn pack_column_upgrades_reject_cross_pack_addition_ownership_collision() {
16424 let backend = khive_db::StorageBackend::memory().unwrap();
16425 let mut builder = VerbRegistryBuilder::new();
16426 builder.register_boxed(Box::new(SchemaPack {
16427 pack_name: "alpha",
16428 statements: &["CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER)"],
16429 column_additions: &[],
16430 }));
16431 builder.register_boxed(Box::new(SchemaPack {
16432 pack_name: "beta",
16433 statements: &[],
16434 column_additions: &[PackColumnAddition {
16435 table: "t_alpha",
16436 column: "revision",
16437 affinity: PackColumnAffinity::Text,
16438 }],
16439 }));
16440 let registry = builder.build().unwrap();
16441 let error = registry
16442 .apply_schema_plans_with_map(&HashMap::new(), &backend)
16443 .unwrap_err();
16444 assert_eq!(error.pack_a, "alpha");
16445 assert_eq!(error.pack_b, "beta");
16446 assert_eq!(error.table, "t_alpha");
16447 assert_eq!(column_schema_count(&backend, "t_alpha", "revision"), 0);
16448 }
16449
16450 #[test]
16451 fn pack_column_upgrades_do_not_hide_duplicate_create_claims() {
16452 let backend = khive_db::StorageBackend::memory().unwrap();
16453 let mut builder = VerbRegistryBuilder::new();
16454 builder.register_boxed(Box::new(SchemaPack {
16455 pack_name: "alpha",
16456 statements: &[
16457 "CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER, revision TEXT)",
16458 "CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER, revision TEXT)",
16459 ],
16460 column_additions: &[PackColumnAddition {
16461 table: "t_alpha",
16462 column: "revision",
16463 affinity: PackColumnAffinity::Text,
16464 }],
16465 }));
16466 let registry = builder.build().unwrap();
16467 let error = registry
16468 .apply_schema_plans_with_map(&HashMap::new(), &backend)
16469 .unwrap_err();
16470 assert_eq!(error.pack_a, "alpha");
16471 assert_eq!(error.pack_b, "alpha");
16472 assert_eq!(error.table, "t_alpha");
16473 assert_eq!(column_schema_count(&backend, "t_alpha", "revision"), 0);
16474 }
16475}
16476
16477#[cfg(test)]
16478#[path = "gate_argument_contract_tests.rs"]
16479mod gate_argument_contract_tests;