exocortex_kernel/verbs.rs
1// verbs.rs — pack-registered Actions and Functions (PX2, palantir-expansion
2// PRD §3.2/§4.1). The `actions!`/`functions!`/`guidance!` sections of the
3// `pack!` macro expand into the types collected here.
4//
5// The split that makes this kernel-pure:
6// - SIGNATURES (name, ceiling, engine, typed input/output names, budgets)
7// land in `PackDef` and therefore in the compatibility fingerprint
8// (OC-PRD D1: meaning-bearing structure). Two components exchanging
9// data must agree on which verbs exist and what they may stamp.
10// - BODIES (Rust action bodies, Scheme function sources) live ONLY in
11// the `inventory` registrations below, never in `PackDef`, so patching
12// a body moves neither fingerprint level (§4.1: "signatures join the
13// compatibility hash, bodies stay out of it").
14use serde::{Deserialize, Serialize};
15use smol_str::SmolStr;
16
17use crate::{KernelError, MemoryId, Visibility};
18
19/// What a pack action body receives. The ceiling is the framework's
20/// enforcement point: every visibility the body produces is clamped (and
21/// the commit path rejects rows that would exceed it), so a pack author
22/// cannot stamp wider than the declared `min_visibility` no matter what
23/// the body does (P3: a compile-time AND framework-enforced property).
24#[derive(Clone, Copy, Debug)]
25pub struct ActionContext {
26 /// The declared visibility ceiling for this verb.
27 pub ceiling: Visibility,
28}
29
30impl ActionContext {
31 /// Clamp a requested visibility to the declared ceiling. Bodies call
32 /// this when stamping produced memories.
33 pub fn narrow(&self, requested: Visibility) -> Visibility {
34 requested.min(self.ceiling)
35 }
36}
37
38/// Where an action-produced edge points: a draft key within this action's
39/// product, or an existing memory id.
40#[derive(Clone, Debug)]
41pub enum ActionTarget {
42 /// A `draft_key` of another memory in the same product.
43 Draft(SmolStr),
44 /// An existing memory.
45 Memory(MemoryId),
46}
47
48/// One memory a pack action body produces. `memory_type` is the PACK-LOCAL
49/// u8 id (`MemoryType::X.id()` in the pack crate); the framework remaps it
50/// to the effective-ontology id through the pack's slot at commit.
51#[derive(Clone, Debug)]
52pub struct ActionMemory {
53 /// Producer-local key for in-batch edge linking.
54 pub draft_key: SmolStr,
55 /// Pack-local memory type id.
56 pub memory_type: u8,
57 /// 1..=200 chars (R-T5; enforced by the kernel validator at commit).
58 pub title: SmolStr,
59 /// Free-text content (R-T5: non-empty).
60 pub content: String,
61 /// <=500 chars (R-T5).
62 pub summary: Option<SmolStr>,
63 /// Requested visibility; the framework clamps to the declared ceiling.
64 pub visibility: Visibility,
65 /// Lowercase tags (normalized at commit).
66 pub tags: Vec<SmolStr>,
67}
68
69/// One edge a pack action body produces. `kind` is the kind display name
70/// (the stable identity surface shared with wire formats and rule
71/// sources); the framework resolves it through the effective ontology and
72/// rejects unknown or computed-only kinds.
73#[derive(Clone, Debug)]
74pub struct ActionEdge {
75 /// Source draft key within this product.
76 pub from_draft_key: SmolStr,
77 /// Target: draft key or existing memory.
78 pub to: ActionTarget,
79 /// Kind display name.
80 pub kind: &'static str,
81 /// `None` applies the `RelMeta` default strength.
82 pub strength: Option<f32>,
83}
84
85/// What a pack action body returns: the memories and edges to commit. The
86/// framework validates, provenance-stamps, audits, and commits them — the
87/// body cannot bypass any of it.
88#[derive(Clone, Debug, Default)]
89pub struct ActionProduct {
90 /// Memories to commit.
91 pub memories: Vec<ActionMemory>,
92 /// Edges to commit.
93 pub edges: Vec<ActionEdge>,
94}
95
96impl ActionProduct {
97 /// Start an empty product.
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 /// Add one memory (pack-local type id) and return the key handle for
103 /// chaining.
104 pub fn memory(
105 &mut self,
106 draft_key: &str,
107 memory_type: u8,
108 title: &str,
109 content: &str,
110 visibility: Visibility,
111 tags: &[&str],
112 ) -> &mut Self {
113 self.memories.push(ActionMemory {
114 draft_key: SmolStr::new(draft_key),
115 memory_type,
116 title: SmolStr::new(title),
117 content: content.to_owned(),
118 summary: None,
119 visibility,
120 tags: tags.iter().map(|t| SmolStr::new(*t)).collect(),
121 });
122 self
123 }
124
125 /// Attach an optional summary to the most recently added memory.
126 pub fn summary(&mut self, summary: &str) -> &mut Self {
127 if let Some(last) = self.memories.last_mut() {
128 last.summary = Some(SmolStr::new(summary));
129 }
130 self
131 }
132
133 /// Add one edge from a draft key to another draft key.
134 pub fn edge_drafts(&mut self, from: &str, to: &str, kind: &'static str) -> &mut Self {
135 self.edges.push(ActionEdge {
136 from_draft_key: SmolStr::new(from),
137 to: ActionTarget::Draft(SmolStr::new(to)),
138 kind,
139 strength: None,
140 });
141 self
142 }
143
144 /// Add one edge from a draft key to an existing memory.
145 pub fn edge_to_memory(&mut self, from: &str, to: MemoryId, kind: &'static str) -> &mut Self {
146 self.edges.push(ActionEdge {
147 from_draft_key: SmolStr::new(from),
148 to: ActionTarget::Memory(to),
149 kind,
150 strength: None,
151 });
152 self
153 }
154
155 /// Set the strength of the most recently added edge.
156 pub fn strength(&mut self, strength: f32) -> &mut Self {
157 if let Some(last) = self.edges.last_mut() {
158 last.strength = Some(strength);
159 }
160 self
161 }
162}
163
164/// Compile-time registration emitted by the `actions!` section (§4.3:
165/// macro-generated `inventory::submit!`, one registry — R-P1/R-P2 hold
166/// because the operation registry merges these into `entries()`).
167#[derive(Clone, Copy)]
168pub struct PackActionRegistration {
169 /// Owning pack name.
170 pub pack_name: &'static str,
171 /// Verb name (as declared).
172 pub verb_name: &'static str,
173 /// Declared visibility ceiling (`min_visibility`).
174 pub ceiling: Visibility,
175 /// JSON Schema of the typed input.
176 pub input_schema: fn() -> schemars::schema::RootSchema,
177 /// Deserialize the typed input and run the typed body. The generated
178 /// adapter type-checks in the pack crate (PX2-S1 outcome (a)).
179 pub run: fn(&ActionContext, serde_json::Value) -> Result<ActionProduct, KernelError>,
180}
181
182inventory::collect!(PackActionRegistration);
183
184/// Compile-time registration emitted by the `functions!` section. The body
185/// is verbatim source for the declared engine; v1 executes `scheme`
186/// bodies through the reasoning crate's embedded Steel interpreter
187/// (pure functions over their typed input — the graph-fed contract is
188/// recorded as the boundary in the master plan). `datalog` bodies are a
189/// pack-compile error: Crepe compiles at build time only, and an
190/// unexecutable registration would be a phantom surface.
191#[derive(Clone, Copy)]
192pub struct PackFunctionRegistration {
193 /// Owning pack name.
194 pub pack_name: &'static str,
195 /// Verb name (as declared).
196 pub verb_name: &'static str,
197 /// Engine tag; always `scheme` in v1.
198 pub engine: &'static str,
199 /// Verbatim body source (excluded from both fingerprint levels).
200 pub body: &'static str,
201 /// p50 latency budget in microseconds (R-Lat1; enforced by the
202 /// generated bench harness, not declared-and-forgotten).
203 pub p50_budget_us: u32,
204 /// p99 latency budget in microseconds.
205 pub p99_budget_us: u32,
206 /// JSON Schema of the typed input.
207 pub input_schema: fn() -> schemars::schema::RootSchema,
208 /// JSON Schema of the typed output.
209 pub output_schema: fn() -> schemars::schema::RootSchema,
210}
211
212inventory::collect!(PackFunctionRegistration);
213
214/// Every registered pack action, sorted by `(pack, verb)` — the
215/// deterministic enumeration the operation registry merges.
216pub fn registered_pack_actions() -> Vec<&'static PackActionRegistration> {
217 let mut all: Vec<&'static PackActionRegistration> = inventory::iter::<PackActionRegistration>
218 .into_iter()
219 .collect();
220 all.sort_by(|a, b| (a.pack_name, a.verb_name).cmp(&(b.pack_name, b.verb_name)));
221 all
222}
223
224/// Every registered pack function, sorted by `(pack, verb)`.
225pub fn registered_pack_functions() -> Vec<&'static PackFunctionRegistration> {
226 let mut all: Vec<&'static PackFunctionRegistration> =
227 inventory::iter::<PackFunctionRegistration>
228 .into_iter()
229 .collect();
230 all.sort_by(|a, b| (a.pack_name, a.verb_name).cmp(&(b.pack_name, b.verb_name)));
231 all
232}
233
234/// One structured agent-guidance entry declared in a `guidance!` section
235/// (§4.2). Keys and link names resolve against the declaring pack's own
236/// type/kind tables at `pack_def()` build time — an entry naming an
237/// unknown type or kind fails pack load, exactly as `type_triples!` does.
238#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
239pub struct GuidanceEntry {
240 /// The memory type or kind display name this entry advises on.
241 pub key: SmolStr,
242 /// When the guidance applies (<=160 chars; names nothing checkable).
243 pub when: Option<String>,
244 /// A caution (<=160 chars; the only other text slot).
245 pub caution: Option<String>,
246 /// Declared links: `(kind, direction, target-or-source type)` triples
247 /// the producer should mint. `=>` is outgoing (key —Kind→ target);
248 /// `<=` is incoming (source —Kind→ key).
249 pub links: Vec<GuidanceLink>,
250}
251
252impl GuidanceEntry {
253 /// Human-text budget per §4.2: the escape hatch cannot quietly become
254 /// a prose blob.
255 pub const MAX_TEXT_CHARS: usize = 160;
256}
257
258/// One declared guidance link.
259#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
260pub struct GuidanceLink {
261 /// Kind display name.
262 pub kind: SmolStr,
263 /// `true` for `=>` (outgoing from the key), `false` for `<=`.
264 pub outgoing: bool,
265 /// The other side's memory type name.
266 pub other: SmolStr,
267}
268
269/// Signature-level action descriptor carried in `PackDef` (and therefore
270/// the compatibility fingerprint). Bodies are deliberately absent.
271#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
272pub struct PackActionDef {
273 /// Verb name (as declared).
274 pub name: SmolStr,
275 /// Declared visibility ceiling.
276 pub ceiling: Visibility,
277 /// Stringified input type name (type-level signature identity).
278 pub input_type: SmolStr,
279 /// Stringified output type name.
280 pub output_type: SmolStr,
281}
282
283/// Signature-level function descriptor carried in `PackDef`. Budgets ride
284/// the build fingerprint (operational policy, not stored meaning) but not
285/// the compatibility summary's verb identity.
286#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
287pub struct PackFunctionDef {
288 /// Verb name (as declared).
289 pub name: SmolStr,
290 /// Engine tag (`scheme`).
291 pub engine: SmolStr,
292 /// Stringified input type name.
293 pub input_type: SmolStr,
294 /// Stringified output type name.
295 pub output_type: SmolStr,
296 /// p50 budget, microseconds.
297 pub p50_budget_us: u32,
298 /// p99 budget, microseconds.
299 pub p99_budget_us: u32,
300}
301
302/// Hidden helper the `actions!`/`functions!` munchers use for schema fns
303/// (function pointers must be const-constructible; a closure over
304/// `schemars::schema_for!` at the call site would capture the type path).
305#[doc(hidden)]
306pub fn __schema_of<T: schemars::JsonSchema>() -> schemars::schema::RootSchema {
307 schemars::schema_for!(T)
308}
309
310/// Hidden: decode a pack verb's typed input (the generated `run` adapter
311/// calls this so pack crates never need a direct `serde_json` path).
312#[doc(hidden)]
313pub fn __decode_input<T: serde::de::DeserializeOwned>(
314 value: serde_json::Value,
315) -> Result<T, KernelError> {
316 serde_json::from_value(value).map_err(|e| KernelError::InvalidActionInput(e.to_string()))
317}
318
319/// Hidden: one `guidance!` attribute, accumulated by the muncher and
320/// folded by [`__guidance_entry`].
321#[doc(hidden)]
322pub enum __GuidancePiece {
323 /// `when: "..."`.
324 When(&'static str),
325 /// `caution: "..."`.
326 Caution(&'static str),
327 /// `link: [Kind => Target]` (outgoing) or `[Kind <= Source]`.
328 Link(&'static str, bool, &'static str),
329}
330
331/// Hidden: fold guidance pieces into one entry, enforcing the §4.2
332/// text caps (<=160 chars) at pack-def build time.
333#[doc(hidden)]
334pub fn __guidance_entry(key: &'static str, pieces: Vec<__GuidancePiece>) -> GuidanceEntry {
335 let mut entry = GuidanceEntry {
336 key: SmolStr::new_static(key),
337 when: None,
338 caution: None,
339 links: Vec::new(),
340 };
341 for piece in pieces {
342 match piece {
343 __GuidancePiece::When(text) => {
344 assert!(
345 text.chars().count() <= GuidanceEntry::MAX_TEXT_CHARS,
346 "guidance! `when` for `{key}` exceeds {} chars",
347 GuidanceEntry::MAX_TEXT_CHARS
348 );
349 entry.when = Some(text.to_owned());
350 }
351 __GuidancePiece::Caution(text) => {
352 assert!(
353 text.chars().count() <= GuidanceEntry::MAX_TEXT_CHARS,
354 "guidance! `caution` for `{key}` exceeds {} chars",
355 GuidanceEntry::MAX_TEXT_CHARS
356 );
357 entry.caution = Some(text.to_owned());
358 }
359 __GuidancePiece::Link(kind, outgoing, other) => entry.links.push(GuidanceLink {
360 kind: SmolStr::new_static(kind),
361 outgoing,
362 other: SmolStr::new_static(other),
363 }),
364 }
365 }
366 entry
367}
368
369/// Kernel error for a pack verb whose typed input could not be decoded —
370/// surfaced through the framework as a `BadInput`-class rejection.
371impl KernelError {
372 /// True when this error is an action-input decode failure.
373 pub fn is_invalid_action_input(&self) -> bool {
374 matches!(self, KernelError::InvalidActionInput(_))
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn guidance_text_caps_are_enforced_at_build_time() {
384 let ok = __guidance_entry(
385 "K",
386 vec![
387 __GuidancePiece::When("short"),
388 __GuidancePiece::Link("A", true, "B"),
389 ],
390 );
391 assert_eq!(ok.when.as_deref(), Some("short"));
392 assert_eq!(ok.links.len(), 1);
393 assert!(ok.links[0].outgoing);
394
395 let long: &'static str = Box::leak(
396 "x".repeat(GuidanceEntry::MAX_TEXT_CHARS + 1)
397 .into_boxed_str(),
398 );
399 let result = std::panic::catch_unwind(|| {
400 __guidance_entry("K", vec![__GuidancePiece::Caution(long)])
401 });
402 assert!(result.is_err(), "over-length caution must fail pack build");
403 }
404
405 #[test]
406 fn action_context_narrows_to_the_declared_ceiling() {
407 let ctx = ActionContext {
408 ceiling: Visibility::Team,
409 };
410 assert_eq!(ctx.narrow(Visibility::Org), Visibility::Team);
411 assert_eq!(ctx.narrow(Visibility::Private), Visibility::Private);
412 }
413}