Skip to main content

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}