apcore 0.22.0

Schema-driven module standard for AI-perceivable interfaces
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
// APCore Protocol — Module trait and related types
// Spec reference: Module definition, annotations, preflight checks

use async_trait::async_trait;
use futures_core::Stream;
use serde::de::{MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::pin::Pin;
use std::sync::LazyLock;

use crate::context::Context;
use crate::errors::ModuleError;

/// A stream of output chunks from a streaming module.
///
/// Each item is a `Result<Value, ModuleError>` so a module can fail mid-stream.
/// The stream is `Send + 'static` (no borrows from the producing module) so the
/// executor can drive it across `.await` points without lifetime issues.
pub type ChunkStream = Pin<Box<dyn Stream<Item = Result<serde_json::Value, ModuleError>> + Send>>;

/// Core trait that all `APCore` modules must implement.
#[async_trait]
pub trait Module: Send + Sync {
    /// Returns the JSON Schema describing this module's input.
    fn input_schema(&self) -> serde_json::Value;

    /// Returns the JSON Schema describing this module's output.
    fn output_schema(&self) -> serde_json::Value;

    /// Returns a human-readable description of this module.
    fn description(&self) -> &str;

    /// Execute the module with the given inputs and context.
    async fn execute(
        &self,
        inputs: serde_json::Value,
        ctx: &Context<serde_json::Value>,
    ) -> Result<serde_json::Value, ModuleError>;

    /// Stream execution — returns an async `Stream` of output chunks.
    ///
    /// Returns `None` if the module does not support streaming, signaling
    /// the executor to fall back to `execute()`. Modules that support
    /// streaming should override this to yield chunks incrementally — each
    /// `yield` is delivered to the caller as soon as it is produced (true
    /// streaming, no buffering).
    ///
    /// Note: this method is *not* `async` even though it returns a stream.
    /// The returned `ChunkStream` is itself an async iterator; constructing
    /// it must be cheap and synchronous so the executor can wire it into
    /// its own pipeline before the first chunk is awaited.
    ///
    /// **Validation contract:** `Executor::stream` validates the module's
    /// *merged* output (all chunks deep-merged) against `output_schema` only
    /// *after* the stream is exhausted (Phase 3). Individual chunks are **not**
    /// validated as they are yielded. Callers performing incremental chunk
    /// processing must tolerate receiving chunks that may not independently
    /// satisfy `output_schema`. If per-chunk schema guarantees are required,
    /// validate each chunk inside this method before yielding it.
    fn stream(
        &self,
        _inputs: serde_json::Value,
        _ctx: &Context<serde_json::Value>,
    ) -> Option<ChunkStream> {
        None
    }

    /// Return a structured description of this module for AI/LLM consumption (spec §5.6).
    /// Default: builds description from `input_schema`, `output_schema`, and description.
    fn describe(&self) -> serde_json::Value {
        serde_json::json!({
            "description": self.description(),
            "input_schema": self.input_schema(),
            "output_schema": self.output_schema(),
        })
    }

    /// Run preflight checks before execution.
    ///
    /// Cross-language alignment (D11-009): mirrors apcore-python
    /// `Module.preflight(inputs, context) -> list[str]` and apcore-typescript
    /// `preflight(inputs, context): string[]`. Returns a list of advisory
    /// warning strings; an empty list means "no concerns". Modules that need
    /// to gate execution should return errors from `execute()` directly
    /// instead — preflight is non-fatal.
    ///
    /// `ctx` is `Option<&Context>` because `Executor::validate(module_id,
    /// inputs, ctx)` accepts a `None` context for call-chain-free preflight
    /// (matching Python's `executor.validate(..., context=None)`); modules
    /// that need a context for their checks must handle the `None` case.
    ///
    /// The default implementation returns an empty warning list. Modules
    /// override this to inspect inputs (e.g., flag oversize payloads, warn
    /// on deprecated argument shapes) without rejecting the call.
    fn preflight(
        &self,
        _inputs: &serde_json::Value,
        _ctx: Option<&Context<serde_json::Value>>,
    ) -> Vec<String> {
        Vec::new()
    }

    /// Module-instance tags (D11-003).
    ///
    /// Cross-language alignment with apcore-python (`registry.py:1027`,
    /// reads `getattr(mod, 'tags', [])`) and apcore-typescript
    /// (`registry.ts:689`, reads `mod['tags']`). Modules MAY override
    /// this to participate in `Registry::list(tags=...)` filtering even
    /// when registered without an explicit `ModuleDescriptor` (e.g. via
    /// `register_module(name, module)`). The Rust `Registry::list`
    /// unions these instance tags with `descriptor.tags`.
    ///
    /// The default returns an empty Vec.
    fn tags(&self) -> Vec<String> {
        Vec::new()
    }

    /// Optional preview hook — return a structured prediction of changes.
    ///
    /// Per the apcore RFC `docs/spec/rfc-preview-method.md` (Accepted, target
    /// v0.21.0), called by `Executor::validate()` *after* the standard
    /// validation pipeline has been processed. The default implementation
    /// returns `None` (= the module declines to predict).
    ///
    /// **Contract (RFC §"Optional preview() method"):**
    /// - MUST NOT have side effects.
    /// - Returning `None` (or omitting the method) means "no predicted changes".
    /// - Panicking is treated as advisory and does NOT fail validation; the
    ///   error is surfaced as a warning via the `module_preview` check.
    ///
    /// Cross-language alignment: matches apcore-python `Module.preview` and
    /// apcore-typescript `Module.preview?`.
    fn preview(
        &self,
        _inputs: &serde_json::Value,
        _ctx: Option<&Context<serde_json::Value>>,
    ) -> Option<PreviewResult> {
        None
    }

    /// Called after the module is registered.
    ///
    /// Returns `Err` to signal that the module failed to initialise; the
    /// registry rolls back the insertion so no half-initialised module remains
    /// registered. Aligns with `apcore-python Registry._invoke_on_load`.
    ///
    /// Default: no-op (`Ok(())`).
    fn on_load(&self) -> Result<(), ModuleError> {
        Ok(())
    }

    /// Called before the module is unregistered. Default: no-op.
    fn on_unload(&self) {}

    /// Called before hot-reload to capture state. Returns state dict for `on_resume()`.
    /// Default: returns None (no state to preserve).
    fn on_suspend(&self) -> Option<serde_json::Value> {
        None
    }

    /// Called after hot-reload to restore state from `on_suspend()`.
    /// Default: no-op.
    fn on_resume(&self, _state: serde_json::Value) {}

    /// Return a typed streaming handle for adapter/bridge code.
    ///
    /// Returns `None` for non-streaming modules (default). Streaming modules
    /// override this to return `Some(self)`.
    ///
    /// **Consistency invariant:** `Some(_)` here IFF `stream()` returns `Some(_)`.
    /// `Registry::register` enforces this by returning
    /// `Err(StreamingInterfaceMismatch)` when `annotations.streaming = true`
    /// but `as_streaming()` returns `None`.
    fn as_streaming(&self) -> Option<&dyn StreamingModule> {
        None
    }
}

/// Typed streaming interface for modules that support incremental output.
///
/// **Consistency invariant:** a module implementing `StreamingModule` MUST also
/// return `Some(_)` from `Module::stream()`. `Registry::register` enforces this
/// by returning `Err(StreamingInterfaceMismatch)` when `annotations.streaming =
/// true` but `as_streaming()` returns `None`.
pub trait StreamingModule: Module {
    fn stream_typed(
        &self,
        inputs: serde_json::Value,
        context: &crate::context::Context<serde_json::Value>,
    ) -> ChunkStream;
}

/// Metadata annotations attached to a module.
/// Describes behavioral characteristics of the module.
///
/// **Wire format (`PROTOCOL_SPEC` §4.4.1):** the `extra` field is serialized as a
/// nested JSON object under the key `"extra"`. Extension keys MUST NOT be
/// flattened to the annotations root. The custom `Deserialize` impl below
/// accepts both the canonical nested form and the legacy flattened form
/// (apcore-rust ≤ 0.17.1) for one MINOR backward-compat cycle.
#[derive(Debug, Clone, Serialize)]
#[allow(clippy::struct_excessive_bools)] // spec-defined annotation flags; consolidating into bitflags would break the public API
pub struct ModuleAnnotations {
    pub readonly: bool,
    pub destructive: bool,
    pub idempotent: bool,
    pub requires_approval: bool,
    pub open_world: bool,
    pub streaming: bool,
    pub cacheable: bool,
    pub cache_ttl: u64,
    pub cache_key_fields: Option<Vec<String>>,
    pub paginated: bool,
    pub pagination_style: String, // "cursor" | "offset" | "page"
    /// Whether this module is enumerated by `Registry::list()` /
    /// `Registry::iter()` / `Registry::module_ids()` by default.
    ///
    /// Per the apcore RFC `docs/spec/rfc-ephemeral-modules.md` (Accepted,
    /// target v0.21.0): defaults to `true`. Setting `discoverable: false`
    /// hides the module from default enumeration without unregistering it;
    /// callers that legitimately need to see every module ID can pass
    /// `include_hidden=true` to the relevant Registry method.
    ///
    /// Cross-language alignment: matches `ModuleAnnotations.discoverable`
    /// in apcore-python and apcore-typescript.
    pub discoverable: bool,
    /// Extension map for ecosystem package metadata.
    /// Serialized as a nested `"extra"` object per spec §4.4.1.
    pub extra: HashMap<String, serde_json::Value>,
    // Legacy fields moved to ModuleDescriptor:
    // name, version, author, description, tags, category, deprecated,
    // deprecated_message, since, hidden, examples, dependencies, metadata
}

impl<'de> Deserialize<'de> for ModuleAnnotations {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct AnnotationsVisitor;

        impl<'de> Visitor<'de> for AnnotationsVisitor {
            type Value = ModuleAnnotations;

            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                f.write_str("a ModuleAnnotations JSON object")
            }

            fn visit_map<M>(self, mut map: M) -> Result<ModuleAnnotations, M::Error>
            where
                M: MapAccess<'de>,
            {
                let mut ann = ModuleAnnotations::default();
                let mut explicit_extra: Option<HashMap<String, serde_json::Value>> = None;
                let mut overflow: HashMap<String, serde_json::Value> = HashMap::new();

                while let Some(key) = map.next_key::<String>()? {
                    match key.as_str() {
                        "readonly" => ann.readonly = map.next_value()?,
                        "destructive" => ann.destructive = map.next_value()?,
                        "idempotent" => ann.idempotent = map.next_value()?,
                        "requires_approval" => ann.requires_approval = map.next_value()?,
                        "open_world" => ann.open_world = map.next_value()?,
                        "streaming" => ann.streaming = map.next_value()?,
                        "cacheable" => ann.cacheable = map.next_value()?,
                        "cache_ttl" => ann.cache_ttl = map.next_value()?,
                        "cache_key_fields" => ann.cache_key_fields = map.next_value()?,
                        "paginated" => ann.paginated = map.next_value()?,
                        "pagination_style" => ann.pagination_style = map.next_value()?,
                        "discoverable" => ann.discoverable = map.next_value()?,
                        "extra" => {
                            // Tolerate `null` extra → empty map.
                            let v: serde_json::Value = map.next_value()?;
                            explicit_extra = Some(match v {
                                serde_json::Value::Null => HashMap::new(),
                                serde_json::Value::Object(obj) => obj.into_iter().collect(),
                                _ => {
                                    return Err(serde::de::Error::custom(
                                        "ModuleAnnotations.extra must be an object",
                                    ))
                                }
                            });
                        }
                        _ => {
                            // Legacy flattened form (apcore-rust ≤ 0.17.1):
                            // unknown keys at the root are captured into overflow
                            // and later normalized into `extra`.
                            let v: serde_json::Value = map.next_value()?;
                            overflow.insert(key, v);
                        }
                    }
                }

                // §4.4.1 rule 7: nested explicit `extra` wins over legacy
                // top-level overflow. Build the merged map by writing overflow
                // first and then explicit_extra so the latter overwrites on
                // collision.
                let mut merged = overflow;
                if let Some(ex) = explicit_extra {
                    for (k, v) in ex {
                        merged.insert(k, v);
                    }
                }
                ann.extra = merged;
                Ok(ann)
            }
        }

        deserializer.deserialize_map(AnnotationsVisitor)
    }
}

impl Default for ModuleAnnotations {
    fn default() -> Self {
        Self {
            readonly: false,
            destructive: false,
            idempotent: false,
            requires_approval: false,
            open_world: true,
            streaming: false,
            cacheable: false,
            cache_ttl: 0,
            cache_key_fields: None,
            paginated: false,
            pagination_style: "cursor".to_string(),
            discoverable: true,
            extra: HashMap::new(),
        }
    }
}

/// Default annotations instance — all fields at their spec defaults.
pub static DEFAULT_ANNOTATIONS: LazyLock<ModuleAnnotations> =
    LazyLock::new(ModuleAnnotations::default);

/// An example input/output pair for documentation.
///
/// Marked `#[non_exhaustive]` (issue #24) so future spec extensions can add
/// fields without breaking downstream struct-literal construction. Construct
/// via `..Default::default()` or a builder pattern.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ModuleExample {
    pub title: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    pub inputs: serde_json::Value,
    pub output: serde_json::Value,
}

/// Result of validating a single aspect (used by `SchemaValidator` and `ModuleValidator`).
///
/// Marked `#[non_exhaustive]` (issue #24) so future spec extensions can add
/// fields without breaking downstream struct-literal construction. Construct
/// via `..Default::default()` or a builder pattern.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ValidationResult {
    pub valid: bool,
    #[serde(default)]
    pub errors: Vec<String>,
    #[serde(default)]
    pub warnings: Vec<String>,
}

/// Result of a single preflight check (spec §12.8.4).
///
/// Marked `#[non_exhaustive]` (issue #24) so future spec extensions can add
/// fields without breaking downstream struct-literal construction. Construct
/// via `..Default::default()` or a builder pattern.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PreflightCheckResult {
    /// Check name (e.g., "`module_id`", "`module_lookup`", "`call_chain`", "acl", "schema", "`module_preflight`").
    pub check: String,
    /// Whether the check passed.
    pub passed: bool,
    /// Error details when `passed` is false; None when passed is true.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<serde_json::Value>,
    /// Non-fatal advisory messages (default: empty).
    #[serde(default)]
    pub warnings: Vec<String>,
}

/// Aggregated preflight results returned by `Executor::validate()` (spec §12.8.3).
///
/// Marked `#[non_exhaustive]` (issue #24) so future spec extensions can add
/// fields without breaking downstream struct-literal construction. Construct
/// via `..Default::default()` or a builder pattern.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PreflightResult {
    /// True only if ALL checks passed.
    pub valid: bool,
    /// Ordered list of check results.
    pub checks: Vec<PreflightCheckResult>,
    /// True if the module has `requires_approval` annotation.
    #[serde(default)]
    pub requires_approval: bool,
    /// Optional structured prediction of changes from the module's
    /// `preview()` hook (RFC `docs/spec/rfc-preview-method.md`, target v0.21.0).
    ///
    /// - Empty when the module does not implement `preview()` or when
    ///   `preview()` returned `None`.
    /// - When `preview()` panics, this field is left empty and a
    ///   `module_preview` advisory check carries the warning, mirroring
    ///   `preflight()` semantics.
    ///
    /// Cross-language parity: matches `predicted_changes` /
    /// `predictedChanges` in apcore-python and apcore-typescript.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub predicted_changes: Vec<Change>,
}

/// Structured prediction of a single side-effect produced by executing a
/// module call.
///
/// Per the apcore RFC `docs/spec/rfc-preview-method.md` (Accepted, target
/// v0.21.0). `action`, `target`, and `summary` are required; modules define
/// their own free-form taxonomy for `action` (e.g. "write", "delete", "send",
/// "charge", "publish") and `target` (e.g. "users.42", "smtp:user@example.com").
///
/// `before` / `after` are optional snapshots of prior / predicted state.
///
/// Extension fields with the `x-` prefix are permitted and round-trip via the
/// `extra` map. Any unknown key on the wire that does NOT start with `x-` is
/// rejected at deserialization time, matching the apcore-typescript TypeBox
/// schema (which sets `additionalProperties: false`).
///
/// Marked `#[non_exhaustive]` so future spec extensions can add fields without
/// breaking downstream struct-literal construction.
#[derive(Debug, Clone, Default, Serialize)]
#[non_exhaustive]
pub struct Change {
    /// Free-form verb describing the kind of change (e.g. "write", "delete").
    pub action: String,
    /// Free-form identifier of what is changed (e.g. "users.42").
    pub target: String,
    /// Required, human-readable single-line summary of the change.
    pub summary: String,
    /// Optional. Snapshot of the prior state, when observable.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub before: Option<serde_json::Value>,
    /// Optional. Predicted new state.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub after: Option<serde_json::Value>,
    /// `x-*` extension fields (PROTOCOL_SPEC §4.4 / §4.6). Keys MUST start
    /// with `x-`; deserialization rejects any other unknown keys.
    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

impl<'de> Deserialize<'de> for Change {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct ChangeVisitor;

        impl<'de> Visitor<'de> for ChangeVisitor {
            type Value = Change;

            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                f.write_str(
                    "a Change JSON object with action/target/summary and optional x-* extras",
                )
            }

            fn visit_map<M>(self, mut map: M) -> Result<Change, M::Error>
            where
                M: MapAccess<'de>,
            {
                let mut action: Option<String> = None;
                let mut target: Option<String> = None;
                let mut summary: Option<String> = None;
                let mut before: Option<serde_json::Value> = None;
                let mut after: Option<serde_json::Value> = None;
                let mut extra: HashMap<String, serde_json::Value> = HashMap::new();

                while let Some(key) = map.next_key::<String>()? {
                    match key.as_str() {
                        "action" => action = Some(map.next_value()?),
                        "target" => target = Some(map.next_value()?),
                        "summary" => summary = Some(map.next_value()?),
                        "before" => before = Some(map.next_value()?),
                        "after" => after = Some(map.next_value()?),
                        other => {
                            // Per RFC `rfc-preview-method.md` (cross-SDK
                            // schema-encoding table) any unknown key MUST
                            // start with `x-`. Reject anything else.
                            if !other.starts_with("x-") {
                                return Err(serde::de::Error::custom(format!(
                                    "Change has unknown key '{other}'; extension keys must start with 'x-'"
                                )));
                            }
                            let v: serde_json::Value = map.next_value()?;
                            extra.insert(other.to_string(), v);
                        }
                    }
                }

                Ok(Change {
                    action: action.ok_or_else(|| serde::de::Error::missing_field("action"))?,
                    target: target.ok_or_else(|| serde::de::Error::missing_field("target"))?,
                    summary: summary.ok_or_else(|| serde::de::Error::missing_field("summary"))?,
                    before,
                    after,
                    extra,
                })
            }
        }

        deserializer.deserialize_map(ChangeVisitor)
    }
}

/// Module's structured prediction of the changes that calling `execute()`
/// with the given inputs would produce. Returned by the optional
/// [`Module::preview`] hook; folded into [`PreflightResult::predicted_changes`]
/// by `Executor::validate()`.
///
/// Marked `#[non_exhaustive]` so future spec extensions can add fields.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PreviewResult {
    /// Ordered list of predicted side-effects.
    #[serde(default)]
    pub changes: Vec<Change>,
}

/// Canonical name for the `module_preview` preflight check (PROTOCOL_SPEC
/// §12.8.4 enum). Use this instead of the literal string to avoid drift with
/// other SDKs.
pub const MODULE_PREVIEW_CHECK_NAME: &str = "module_preview";

impl PreflightResult {
    /// Computed view: failed checks as typed refs (idiomatic Rust accessor).
    #[must_use]
    pub fn errors(&self) -> Vec<&PreflightCheckResult> {
        self.checks.iter().filter(|c| !c.passed).collect()
    }

    /// Computed view: failed checks serialized to JSON Value maps.
    ///
    /// Cross-language parity with apcore-python PreflightResult.errors
    /// (returns list[dict]) and apcore-typescript PreflightResult.errors
    /// (returns array of objects) — sync finding A-014.
    #[must_use]
    pub fn errors_as_json(&self) -> Vec<serde_json::Value> {
        self.checks
            .iter()
            .filter(|c| !c.passed)
            .filter_map(|c| serde_json::to_value(c).ok())
            .collect()
    }
}