Skip to main content

oxicode_sdk/ports/
mod.rs

1//! Port traits for product-specific adapters.
2//!
3//! These traits define the **contract** between oxicode-sdk (composition layer) and
4//! the host product's infrastructure. Products like `oxicode-cli` and
5//! `oxios-kernel` provide their own implementations.
6//!
7//! # Pattern
8//!
9//! ```text
10//!   oxicode-sdk  (defines traits)
11//!      │
12//!      │ implements
13//!      ▼
14//!   Product layer
15//!   ├── oxicode-cli    → FileStateStore, FileAuthProvider, FileSkillLoader, FileModelCatalog
16//!   ├── oxios-kernel → OxiosStateStore, OxiosEventBus, OxiosMemoryStore
17//!   └── custom     → MyDbStateStore, MyAuthProvider, MyModelCatalog, etc.
18//! ```
19//!
20//! # Design Principles
21//!
22//! 1. **SDK defines contract, products implement** — no port is implemented
23//!    inside oxicode-sdk. `oxicode-sdk` ships only traits + noop fallbacks.
24//! 2. **Optional registration** — products register only the ports they use.
25//!    Unregistered ports get a noop default at the call site.
26//! 3. **Type-flexible payloads** — entries and values use `serde_json::Value`
27//!    so each product can use its own concrete types via (de)serialization.
28//! 4. **Async-first** — every port is async-aware because most
29//!    implementations touch the file system, network, or database.
30//!
31//! # Versioning
32//!
33//! Port traits are **additive**. New methods get default noop implementations,
34//! so adding a port or extending an existing one never breaks existing products.
35
36pub mod catalog;
37pub mod hooks;
38pub use hooks::{HookContext, HookEvent, HookOutcome, HookRunner, HookSpec, NoopHookRunner};
39
40use async_trait::async_trait;
41use serde::{Deserialize, Serialize};
42use std::future::Future;
43use std::path::{Path, PathBuf};
44use std::pin::Pin;
45use std::sync::Arc;
46
47use crate::error::SdkError;
48
49// ═══════════════════════════════════════════════════════════════════════════
50// Common types used across ports
51// ═══════════════════════════════════════════════════════════════════════════
52
53/// Identifier for a persisted entry (session, log line, etc.).
54///
55/// Opaque to the SDK — products may use UUID, hash, monotonic counter, or
56/// any other scheme. The trait requires only that it round-trips through
57/// `Display` + `FromStr`.
58pub type PortId = String;
59
60/// Generic key–value payload used by most ports.
61///
62/// Each product uses its own concrete types; the port contract only requires
63/// the value be JSON-serializable so products stay decoupled.
64pub type PortValue = serde_json::Value;
65
66/// How a provider passes its API key in HTTP headers.
67///
68/// Port-level enum (lives in oxicode-sdk so the catalog port's `default_auth()`
69/// can return it). Mirrors the existing `oxicode_ai::catalog::AuthMethod` and
70/// `oxicode_ai::providers::AuthMethod` — those will be reconciled in PR 4 when
71/// `BuiltinProviderEntry` is removed.
72#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
73#[serde(rename_all = "kebab-case")]
74pub enum AuthMethod {
75    /// `Authorization: Bearer <key>` — most OpenAI-compatible providers.
76    #[default]
77    Bearer,
78    /// `x-api-key: <key>` — Anthropic and Anthropic-compatible providers.
79    #[serde(rename = "x-api-key")]
80    XApiKey,
81    /// `api-key: <key>` — Azure OpenAI.
82    #[serde(rename = "api-key")]
83    ApiKey,
84    /// No API key header (uses other auth like OAuth, SigV4).
85    None,
86}
87
88/// An OAuth token bundle (subset of `oxicode_ai::oauth::TokenBundle`).
89///
90/// Defined here as a separate minimal type so ports don't need to depend
91/// on `oxicode-ai`. Products that already use `oxicode_ai::oauth::TokenBundle`
92/// can convert via `From`/`Into`.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct OAuthToken {
95    /// Bearer access token.
96    pub access_token: String,
97    /// Optional refresh token for renewal.
98    pub refresh_token: Option<String>,
99    /// Expiration timestamp (UTC).
100    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
101    /// Token type (typically `"Bearer"`).
102    pub token_type: Option<String>,
103    /// Granted scopes (provider-specific).
104    pub scope: Option<String>,
105}
106
107impl OAuthToken {
108    /// Construct a minimal bearer token.
109    pub fn bearer(access_token: impl Into<String>) -> Self {
110        Self {
111            access_token: access_token.into(),
112            refresh_token: None,
113            expires_at: None,
114            token_type: Some("Bearer".to_string()),
115            scope: None,
116        }
117    }
118}
119
120// ═══════════════════════════════════════════════════════════════════════════
121// Port 1 — StateStore: durable key-value / append-only log
122// ═══════════════════════════════════════════════════════════════════════════
123
124/// Append-only / key-value durable state.
125///
126/// Each entry is a `PortValue` (typically a JSON object). Implementations
127/// decide the storage backend (file, SQLite, Redis, S3, in-memory...).
128///
129/// # Use cases
130///
131/// - Persist session history (append)
132/// - Persist agent state snapshots
133/// - Persist skill registries
134/// - Persist audit chains
135///
136/// # Default
137///
138/// If not registered with the SDK, [`NoopStateStore`] is used. Calling
139/// `append` returns an error; calling `load` returns `Ok(None)`.
140pub trait StateStore: Send + Sync + 'static {
141    /// Persist an entry. Returns the assigned identifier.
142    fn append(
143        &self,
144        entry: PortValue,
145    ) -> Pin<Box<dyn Future<Output = Result<PortId, SdkError>> + Send + '_>>;
146
147    /// Load an entry by id.
148    fn load(
149        &self,
150        id: &PortId,
151    ) -> Pin<Box<dyn Future<Output = Result<Option<PortValue>, SdkError>> + Send + '_>>;
152
153    /// List all entry ids matching the given prefix (e.g. `"session:"`).
154    fn list(
155        &self,
156        prefix: &str,
157    ) -> Pin<Box<dyn Future<Output = Result<Vec<PortId>, SdkError>> + Send + '_>>;
158
159    /// Delete an entry by id.
160    fn delete(
161        &self,
162        id: &PortId,
163    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>>;
164
165    /// Optional bulk-load of entries for a prefix. Default: `None` (impl may
166    /// not support efficient bulk reads).
167    #[allow(clippy::type_complexity)]
168    fn load_all(
169        &self,
170        _prefix: &str,
171    ) -> Pin<Box<dyn Future<Output = Result<Vec<(PortId, PortValue)>, SdkError>> + Send + '_>> {
172        Box::pin(async { Ok(Vec::new()) })
173    }
174}
175
176/// Noop implementation: `append` errors, `load` returns None, `list` is empty.
177#[derive(Debug, Default, Clone, Copy)]
178pub struct NoopStateStore;
179
180impl StateStore for NoopStateStore {
181    fn append(
182        &self,
183        _entry: PortValue,
184    ) -> Pin<Box<dyn Future<Output = Result<PortId, SdkError>> + Send + '_>> {
185        Box::pin(async { Err(SdkError::PortNotConfigured { port: "StateStore" }) })
186    }
187    fn load(
188        &self,
189        _id: &PortId,
190    ) -> Pin<Box<dyn Future<Output = Result<Option<PortValue>, SdkError>> + Send + '_>> {
191        Box::pin(async { Ok(None) })
192    }
193    fn list(
194        &self,
195        _prefix: &str,
196    ) -> Pin<Box<dyn Future<Output = Result<Vec<PortId>, SdkError>> + Send + '_>> {
197        Box::pin(async { Ok(Vec::new()) })
198    }
199    fn delete(
200        &self,
201        _id: &PortId,
202    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
203        Box::pin(async { Ok(()) })
204    }
205}
206
207// ═══════════════════════════════════════════════════════════════════════════
208// Port 2 — ConfigStore: layered configuration
209// ═══════════════════════════════════════════════════════════════════════════
210
211/// Layered configuration source (defaults → global → project → env → CLI).
212///
213/// Synchronous because configuration should always be readable without I/O
214/// once the product is initialized.
215///
216/// # Use cases
217///
218/// - Read `~/.oxicode/settings.toml` or `~/.oxios/config.toml`
219/// - Merge per-project overrides
220/// - Apply environment variable fallbacks
221pub trait ConfigStore: Send + Sync + 'static {
222    /// Get a value by dotted key (e.g. `"model.provider"`).
223    fn get(&self, key: &str) -> Result<Option<PortValue>, SdkError>;
224
225    /// Set a value at runtime (in-memory layer). Persistence is impl-defined.
226    fn set(&self, key: &str, value: PortValue) -> Result<(), SdkError>;
227
228    /// List all keys (for diagnostics, dumping, validation).
229    fn list(&self) -> Result<Vec<(String, PortValue)>, SdkError>;
230
231    /// Returns the layer that supplied a key, for diagnostics.
232    fn source(&self, _key: &str) -> Option<String> {
233        None
234    }
235}
236
237/// Noop config: empty.
238#[derive(Debug, Default, Clone, Copy)]
239pub struct NoopConfigStore;
240
241impl ConfigStore for NoopConfigStore {
242    fn get(&self, _key: &str) -> Result<Option<PortValue>, SdkError> {
243        Ok(None)
244    }
245    fn set(&self, _key: &str, _value: PortValue) -> Result<(), SdkError> {
246        Ok(())
247    }
248    fn list(&self) -> Result<Vec<(String, PortValue)>, SdkError> {
249        Ok(Vec::new())
250    }
251}
252
253// ═══════════════════════════════════════════════════════════════════════════
254// Port 3 — AuthProvider: credentials (API key / OAuth)
255// ═══════════════════════════════════════════════════════════════════════════
256
257/// Credential provider for LLM providers.
258///
259/// Supports both API key (single string) and OAuth (token bundle) per
260/// provider. Storage is implementation-defined (file, keychain, env, etc.).
261pub trait AuthProvider: Send + Sync + 'static {
262    /// Read the API key for a provider.
263    fn get_api_key(
264        &self,
265        provider: &str,
266    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, SdkError>> + Send + '_>>;
267
268    /// Sync fast-path for reading the API key.
269    ///
270    /// Used by [`crate::Oxicode::create_provider`] when constructing a built-in
271    /// provider in a sync context (e.g. inside the agent loop's
272    /// `ProviderResolver::resolve_provider`). The default returns `Ok(None)`
273    /// (no sync source available); implementations with synchronous backing
274    /// stores (e.g. [`crate::ports::fs::FileAuthProvider`]) override this to
275    /// expose their already-synchronous read path without forcing callers
276    /// through `block_on`. This is the credential source the agent loop
277    /// consults at provider-construction time, replacing the old
278    /// `AgentConfig.api_key` injection (issue #40).
279    fn get_api_key_sync(&self, _provider: &str) -> Result<Option<String>, SdkError> {
280        Ok(None)
281    }
282
283    /// Write the API key for a provider.
284    fn set_api_key(
285        &self,
286        provider: &str,
287        key: &str,
288    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>>;
289
290    /// Delete the API key for a provider.
291    fn delete_api_key(
292        &self,
293        provider: &str,
294    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>>;
295
296    /// Read the OAuth token bundle for a provider.
297    fn get_oauth(
298        &self,
299        provider: &str,
300    ) -> Pin<Box<dyn Future<Output = Result<Option<OAuthToken>, SdkError>> + Send + '_>>;
301
302    /// Write the OAuth token bundle for a provider.
303    fn set_oauth(
304        &self,
305        provider: &str,
306        token: OAuthToken,
307    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>>;
308
309    /// List all providers that have credentials stored.
310    fn list_providers(
311        &self,
312    ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, SdkError>> + Send + '_>>;
313}
314
315/// Noop auth: nothing stored.
316#[derive(Debug, Default, Clone, Copy)]
317pub struct NoopAuthProvider;
318
319impl AuthProvider for NoopAuthProvider {
320    fn get_api_key(
321        &self,
322        _provider: &str,
323    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, SdkError>> + Send + '_>> {
324        Box::pin(async { Ok(None) })
325    }
326    fn set_api_key(
327        &self,
328        _provider: &str,
329        _key: &str,
330    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
331        Box::pin(async {
332            Err(SdkError::PortNotConfigured {
333                port: "AuthProvider",
334            })
335        })
336    }
337    fn delete_api_key(
338        &self,
339        _provider: &str,
340    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
341        Box::pin(async { Ok(()) })
342    }
343    fn get_oauth(
344        &self,
345        _provider: &str,
346    ) -> Pin<Box<dyn Future<Output = Result<Option<OAuthToken>, SdkError>> + Send + '_>> {
347        Box::pin(async { Ok(None) })
348    }
349    fn set_oauth(
350        &self,
351        _provider: &str,
352        _token: OAuthToken,
353    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
354        Box::pin(async {
355            Err(SdkError::PortNotConfigured {
356                port: "AuthProvider",
357            })
358        })
359    }
360    fn list_providers(
361        &self,
362    ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, SdkError>> + Send + '_>> {
363        Box::pin(async { Ok(Vec::new()) })
364    }
365}
366
367// ═══════════════════════════════════════════════════════════════════════════
368// Port 4 — EventBus: typed kernel-wide pub/sub
369// ═══════════════════════════════════════════════════════════════════════════
370
371/// Topic identifier (free-form string).
372pub type EventTopic = String;
373
374/// Event payload.
375pub type EventPayload = serde_json::Value;
376
377/// Kernel-wide pub/sub bus.
378///
379/// Products use this to broadcast agent lifecycle events, kernel state
380/// changes, inter-agent messages, and external triggers.
381///
382/// # Subscription model
383///
384/// `subscribe` returns a [`SubscriptionHandle`] that, when dropped or
385/// `unsubscribe`d, stops delivering events. Implementations may use
386/// channels, callbacks, polling, etc.
387pub trait EventBus: Send + Sync + 'static {
388    /// Publish a payload to a topic.
389    fn publish(
390        &self,
391        topic: &EventTopic,
392        payload: EventPayload,
393    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>>;
394
395    /// Subscribe to a topic (exact match or prefix match per impl).
396    fn subscribe(
397        &self,
398        topic: &EventTopic,
399    ) -> Pin<Box<dyn Future<Output = Result<SubscriptionHandle, SdkError>> + Send + '_>>;
400}
401
402/// Opaque handle for an active subscription. Drop to unsubscribe.
403pub struct SubscriptionHandle {
404    /// Cleanup closure called on drop.
405    _unsubscribe: Option<Box<dyn FnOnce() + Send + Sync>>,
406    /// Receiver of new events. `None` for noop bus.
407    receiver: Option<tokio::sync::mpsc::Receiver<(EventTopic, EventPayload)>>,
408}
409
410impl SubscriptionHandle {
411    /// Receive the next event. Returns `None` when the bus is closed.
412    pub async fn recv(&mut self) -> Option<(EventTopic, EventPayload)> {
413        match &mut self.receiver {
414            Some(rx) => rx.recv().await,
415            None => None,
416        }
417    }
418}
419
420impl std::fmt::Debug for SubscriptionHandle {
421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        f.debug_struct("SubscriptionHandle")
423            .field("active", &self.receiver.is_some())
424            .finish()
425    }
426}
427
428impl SubscriptionHandle {
429    /// Construct a subscription from an mpsc receiver. Used by port impls
430    /// (e.g. `oxicode_fs::InProcessEventBus`) — not part of the public SDK API
431    /// for end-users.
432    pub fn from_receiver(rx: tokio::sync::mpsc::Receiver<(EventTopic, EventPayload)>) -> Self {
433        Self {
434            _unsubscribe: None,
435            receiver: Some(rx),
436        }
437    }
438}
439
440/// In-memory bus for tests or small products.
441pub struct InMemoryEventBus {
442    tx: tokio::sync::broadcast::Sender<(EventTopic, EventPayload)>,
443}
444
445impl InMemoryEventBus {
446    /// Create a new in-memory bus with the given channel capacity.
447    pub fn new(capacity: usize) -> Arc<Self> {
448        let (tx, _) = tokio::sync::broadcast::channel(capacity);
449        Arc::new(Self { tx })
450    }
451}
452
453impl EventBus for InMemoryEventBus {
454    fn publish(
455        &self,
456        topic: &EventTopic,
457        payload: EventPayload,
458    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
459        // Best-effort: ignore NoActiveReceivers.
460        let _ = self.tx.send((topic.clone(), payload));
461        Box::pin(async { Ok(()) })
462    }
463    fn subscribe(
464        &self,
465        _topic: &EventTopic,
466    ) -> Pin<Box<dyn Future<Output = Result<SubscriptionHandle, SdkError>> + Send + '_>> {
467        let mut rx = self.tx.subscribe();
468        let (tx, rx2) = tokio::sync::mpsc::channel(64);
469        drop(tokio::spawn(async move {
470            while let Ok(event) = rx.recv().await {
471                if tx.send(event).await.is_err() {
472                    break;
473                }
474            }
475        }));
476        Box::pin(async {
477            Ok(SubscriptionHandle {
478                _unsubscribe: None,
479                receiver: Some(rx2),
480            })
481        })
482    }
483}
484
485/// Noop bus: nothing happens on publish, subscribers receive nothing.
486#[derive(Debug, Default, Clone, Copy)]
487pub struct NoopEventBus;
488
489impl EventBus for NoopEventBus {
490    fn publish(
491        &self,
492        _topic: &EventTopic,
493        _payload: EventPayload,
494    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
495        Box::pin(async { Ok(()) })
496    }
497    fn subscribe(
498        &self,
499        _topic: &EventTopic,
500    ) -> Pin<Box<dyn Future<Output = Result<SubscriptionHandle, SdkError>> + Send + '_>> {
501        Box::pin(async {
502            Ok(SubscriptionHandle {
503                _unsubscribe: None,
504                receiver: None,
505            })
506        })
507    }
508}
509
510// ═══════════════════════════════════════════════════════════════════════════
511// Port 5 — SkillLoader: discover & load skills
512// ═══════════════════════════════════════════════════════════════════════════
513
514/// Metadata about a discovered skill.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct SkillMeta {
517    /// Unique skill name (e.g. `"git-commit"`).
518    pub name: String,
519    /// Short description from the frontmatter.
520    pub description: String,
521    /// Absolute path to the SKILL.md file.
522    pub path: PathBuf,
523    /// Optional version string.
524    pub version: Option<String>,
525}
526
527/// Loaded skill (metadata + body).
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct Skill {
530    /// Metadata.
531    pub meta: SkillMeta,
532    /// Markdown body (without frontmatter).
533    pub body: String,
534}
535
536/// Discover and load skill files (SKILL.md) from a directory tree.
537pub trait SkillLoader: Send + Sync + 'static {
538    /// Scan the loader's configured roots and return all discovered skills.
539    fn list(&self) -> Pin<Box<dyn Future<Output = Result<Vec<SkillMeta>, SdkError>> + Send + '_>>;
540
541    /// Load a single skill by name.
542    fn load(
543        &self,
544        name: &str,
545    ) -> Pin<Box<dyn Future<Output = Result<Option<Skill>, SdkError>> + Send + '_>>;
546}
547
548/// Noop loader: no skills available.
549#[derive(Debug, Default, Clone, Copy)]
550pub struct NoopSkillLoader;
551
552impl SkillLoader for NoopSkillLoader {
553    fn list(&self) -> Pin<Box<dyn Future<Output = Result<Vec<SkillMeta>, SdkError>> + Send + '_>> {
554        Box::pin(async { Ok(Vec::new()) })
555    }
556    fn load(
557        &self,
558        _name: &str,
559    ) -> Pin<Box<dyn Future<Output = Result<Option<Skill>, SdkError>> + Send + '_>> {
560        Box::pin(async { Ok(None) })
561    }
562}
563
564// ═══════════════════════════════════════════════════════════════════════════
565// Port 6 — PersonaProvider: system prompt injection
566// ═══════════════════════════════════════════════════════════════════════════
567
568/// A persona (system prompt fragment + metadata).
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct Persona {
571    /// Persona name.
572    pub name: String,
573    /// System prompt body.
574    pub system_prompt: String,
575    /// Optional model preferences.
576    pub preferred_model: Option<String>,
577    /// Optional tool restrictions.
578    pub allowed_tools: Option<Vec<String>>,
579}
580
581/// Source of personas (system prompt fragments) selectable by name.
582pub trait PersonaProvider: Send + Sync + 'static {
583    /// List all known personas.
584    fn list(&self) -> Pin<Box<dyn Future<Output = Result<Vec<Persona>, SdkError>> + Send + '_>>;
585    /// Look up a single persona.
586    fn get(
587        &self,
588        name: &str,
589    ) -> Pin<Box<dyn Future<Output = Result<Option<Persona>, SdkError>> + Send + '_>>;
590}
591
592/// Noop provider: lists nothing, lookups return `None`.
593#[derive(Debug, Default, Clone, Copy)]
594pub struct NoopPersonaProvider;
595
596impl PersonaProvider for NoopPersonaProvider {
597    fn list(&self) -> Pin<Box<dyn Future<Output = Result<Vec<Persona>, SdkError>> + Send + '_>> {
598        Box::pin(async { Ok(Vec::new()) })
599    }
600    fn get(
601        &self,
602        _name: &str,
603    ) -> Pin<Box<dyn Future<Output = Result<Option<Persona>, SdkError>> + Send + '_>> {
604        Box::pin(async { Ok(None) })
605    }
606}
607
608// ═══════════════════════════════════════════════════════════════════════════
609// Port 7 — AccessGate: pre-execution policy check
610// ═══════════════════════════════════════════════════════════════════════════
611
612/// Description of a tool invocation about to be made.
613#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct ToolCallRequest {
615    /// Tool name (e.g. `"bash"`).
616    pub tool: String,
617    /// Free-form action label (e.g. `"rm -rf /tmp"`).
618    pub action: String,
619    /// Working directory.
620    pub cwd: PathBuf,
621    /// Subject identifier (agent id, user id, etc.).
622    pub subject: String,
623}
624
625/// Result of an access decision.
626#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
627pub enum AccessDecision {
628    /// Allow without conditions.
629    Allow,
630    /// Allow but emit an audit event.
631    AllowWithAudit,
632    /// Deny with reason.
633    Deny {
634        /// Why access was denied.
635        reason: String,
636    },
637    /// Pause and request human approval.
638    RequireApproval {
639        /// Why human approval is required.
640        reason: String,
641    },
642}
643
644/// Pre-execution policy check for tool invocations.
645pub trait AccessGate: Send + Sync + 'static {
646    /// Decide whether `request` may proceed.
647    fn check(
648        &self,
649        request: &ToolCallRequest,
650    ) -> Pin<Box<dyn Future<Output = Result<AccessDecision, SdkError>> + Send + '_>>;
651}
652
653/// Permissive gate: every request is `Allow`ed.
654#[derive(Debug, Default, Clone, Copy)]
655pub struct AllowAllAccessGate;
656
657impl AccessGate for AllowAllAccessGate {
658    fn check(
659        &self,
660        _request: &ToolCallRequest,
661    ) -> Pin<Box<dyn Future<Output = Result<AccessDecision, SdkError>> + Send + '_>> {
662        Box::pin(async { Ok(AccessDecision::Allow) })
663    }
664}
665
666// ═══════════════════════════════════════════════════════════════════════════
667// Port 8 — CapabilityResolver: which tools a subject may see
668// ═══════════════════════════════════════════════════════════════════════════
669
670/// Resolves the set of tools visible to a given subject.
671pub trait CapabilityResolver: Send + Sync + 'static {
672    /// Returns the set of tool names visible to `subject`.
673    fn visible_tools(
674        &self,
675        subject: &str,
676    ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, SdkError>> + Send + '_>>;
677}
678
679/// Resolver that exposes no tools to any subject.
680#[derive(Debug, Default, Clone, Copy)]
681pub struct EmptyCapabilityResolver;
682
683impl CapabilityResolver for EmptyCapabilityResolver {
684    fn visible_tools(
685        &self,
686        _subject: &str,
687    ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, SdkError>> + Send + '_>> {
688        Box::pin(async { Ok(Vec::new()) })
689    }
690}
691
692// ═══════════════════════════════════════════════════════════════════════════
693// Port 9 — MemoryStore: episodic / semantic memory
694// ═══════════════════════════════════════════════════════════════════════════
695
696/// A memory entry (episodic, semantic, or procedural).
697#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct MemoryEntry {
699    /// Identifier.
700    pub id: String,
701    /// Subject (agent id, user id, etc.).
702    pub subject: String,
703    /// Free-form kind (`"episodic"`, `"semantic"`, `"procedural"`).
704    pub kind: String,
705    /// Embedding (optional, dense vector).
706    pub embedding: Option<Vec<f32>>,
707    /// Free-form content.
708    pub content: PortValue,
709    /// Created-at timestamp.
710    pub created_at: chrono::DateTime<chrono::Utc>,
711}
712
713/// Episodic / semantic / procedural memory store with optional vector search.
714pub trait MemoryStore: Send + Sync + 'static {
715    /// Persist a memory entry.
716    fn put(
717        &self,
718        entry: MemoryEntry,
719    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>>;
720    /// Semantic search by embedding (cosine similarity). Returns top-k.
721    fn search(
722        &self,
723        _query: &[f32],
724        _k: usize,
725    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryEntry>, SdkError>> + Send + '_>> {
726        Box::pin(async { Ok(Vec::new()) })
727    }
728    /// List entries for a subject.
729    fn list(
730        &self,
731        subject: &str,
732    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryEntry>, SdkError>> + Send + '_>>;
733    /// Delete the entry with the given id.
734    ///
735    /// Default returns [`SdkError::PortNotConfigured`] — stores that cannot
736    /// delete (e.g. append-only audit logs) keep this default. Backends that
737    /// support deletion override it.
738    fn delete(&self, _id: &str) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
739        Box::pin(async {
740            Err(SdkError::PortNotConfigured {
741                port: "MemoryStore",
742            })
743        })
744    }
745}
746
747/// Noop store: `put` errors, `list` and `search` return empty.
748#[derive(Debug, Default, Clone, Copy)]
749pub struct NoopMemoryStore;
750
751impl MemoryStore for NoopMemoryStore {
752    fn put(
753        &self,
754        _entry: MemoryEntry,
755    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
756        Box::pin(async {
757            Err(SdkError::PortNotConfigured {
758                port: "MemoryStore",
759            })
760        })
761    }
762    fn list(
763        &self,
764        _subject: &str,
765    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryEntry>, SdkError>> + Send + '_>> {
766        Box::pin(async { Ok(Vec::new()) })
767    }
768}
769
770// ═══════════════════════════════════════════════════════════════════════════
771// Port 10 — CronScheduler: time-based triggers
772// ═══════════════════════════════════════════════════════════════════════════
773
774/// A scheduled job.
775#[derive(Debug, Clone, Serialize, Deserialize)]
776pub struct CronJob {
777    /// Job identifier.
778    pub id: String,
779    /// Cron expression (5-field, e.g. `"*/5 * * * *"`).
780    pub schedule: String,
781    /// Free-form action label (consumed by handler).
782    pub action: String,
783    /// Optional payload.
784    pub payload: Option<PortValue>,
785}
786
787/// Registers and introspects time-based jobs.
788pub trait CronScheduler: Send + Sync + 'static {
789    /// Register a new job (replaces any existing job with the same id).
790    fn register(
791        &self,
792        job: CronJob,
793    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>>;
794    /// Remove a previously registered job by id.
795    fn unregister(
796        &self,
797        id: &str,
798    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>>;
799    /// List all currently registered jobs.
800    fn list(&self) -> Pin<Box<dyn Future<Output = Result<Vec<CronJob>, SdkError>> + Send + '_>>;
801}
802
803/// Noop scheduler: `register` errors, `list` is empty.
804#[derive(Debug, Default, Clone, Copy)]
805pub struct NoopCronScheduler;
806
807impl CronScheduler for NoopCronScheduler {
808    fn register(
809        &self,
810        _job: CronJob,
811    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
812        Box::pin(async {
813            Err(SdkError::PortNotConfigured {
814                port: "CronScheduler",
815            })
816        })
817    }
818    fn unregister(
819        &self,
820        _id: &str,
821    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
822        Box::pin(async { Ok(()) })
823    }
824    fn list(&self) -> Pin<Box<dyn Future<Output = Result<Vec<CronJob>, SdkError>> + Send + '_>> {
825        Box::pin(async { Ok(Vec::new()) })
826    }
827}
828
829// ═══════════════════════════════════════════════════════════════════════════
830// Port 11 — ResourceMonitor: usage limits
831// ═══════════════════════════════════════════════════════════════════════════
832
833/// Current resource usage snapshot.
834#[derive(Debug, Clone, Default, Serialize, Deserialize)]
835pub struct ResourceUsage {
836    /// CPU usage percentage (0–100).
837    pub cpu_percent: f32,
838    /// Resident memory in bytes.
839    pub memory_bytes: u64,
840    /// Disk usage in bytes.
841    pub disk_bytes: u64,
842    /// Number of currently running agents.
843    pub active_agents: usize,
844    /// Total tokens consumed across all agents.
845    pub tokens_consumed: u64,
846}
847
848/// Reports current resource usage and whether the budget is exceeded.
849pub trait ResourceMonitor: Send + Sync + 'static {
850    /// Snapshot the current usage.
851    fn snapshot(
852        &self,
853    ) -> Pin<Box<dyn Future<Output = Result<ResourceUsage, SdkError>> + Send + '_>>;
854    /// Returns true if the current usage exceeds the configured budget.
855    fn is_over_budget(&self) -> Pin<Box<dyn Future<Output = Result<bool, SdkError>> + Send + '_>> {
856        Box::pin(async { Ok(false) })
857    }
858}
859
860/// Noop monitor: reports zero usage and never exceeds budget.
861#[derive(Debug, Default, Clone, Copy)]
862pub struct NoopResourceMonitor;
863
864impl ResourceMonitor for NoopResourceMonitor {
865    fn snapshot(
866        &self,
867    ) -> Pin<Box<dyn Future<Output = Result<ResourceUsage, SdkError>> + Send + '_>> {
868        Box::pin(async { Ok(ResourceUsage::default()) })
869    }
870}
871
872// ═══════════════════════════════════════════════════════════════════════════
873// Port 13 — InternalUrlRouter: protocol-scheme virtual path resolution.
874// ═══════════════════════════════════════════════════════════════════════════
875
876/// A resolved virtual URL result. Consumed by `read`/`search` tools.
877#[derive(Debug, Clone, Serialize, Deserialize)]
878pub struct ResolvedUrl {
879    /// Normalized original URL (debug/logging).
880    pub url: String,
881    /// Resolved text content.
882    pub content: String,
883    /// MIME type: "text/markdown" | "application/json" | "text/plain".
884    pub content_type: String,
885    /// Byte size (optional).
886    pub size: Option<usize>,
887    /// Debug source path (not exposed to model).
888    pub source_path: Option<String>,
889    /// Extra notes (resolution warnings, etc.).
890    pub notes: Vec<String>,
891    /// true → uneditable (hashline anchor suppression).
892    pub immutable: bool,
893}
894
895/// Router call context (identifies the calling session).
896#[derive(Debug, Clone, Default)]
897pub struct ResolveContext {
898    /// Working directory of the calling session.
899    pub cwd: Option<PathBuf>,
900    /// Identifier of the calling session.
901    pub session_id: Option<String>,
902}
903
904/// Resolves `scheme://path` URIs (issue://, pr://, agent://, etc.) into text.
905pub trait InternalUrlRouter: Send + Sync + 'static {
906    /// Resolve a `scheme://path` URI to text content.
907    fn resolve<'a>(
908        &'a self,
909        uri: &'a str,
910        ctx: &'a ResolveContext,
911    ) -> Pin<Box<dyn Future<Output = Result<ResolvedUrl, SdkError>> + Send + 'a>>;
912
913    /// Schemes this router handles. Empty = handles none.
914    fn schemes(&self) -> &[&str] {
915        &[]
916    }
917
918    /// Currently registered schemes (for diagnostics). Default: empty.
919    fn registered_schemes(&self) -> Vec<String> {
920        Vec::new()
921    }
922}
923
924/// Noop router: `resolve` always errors with `PortNotConfigured`.
925#[derive(Debug, Default, Clone, Copy)]
926pub struct NoopInternalUrlRouter;
927
928impl InternalUrlRouter for NoopInternalUrlRouter {
929    fn resolve<'a>(
930        &'a self,
931        _uri: &'a str,
932        _ctx: &'a ResolveContext,
933    ) -> Pin<Box<dyn Future<Output = Result<ResolvedUrl, SdkError>> + Send + 'a>> {
934        Box::pin(async {
935            Err(SdkError::PortNotConfigured {
936                port: "InternalUrlRouter",
937            })
938        })
939    }
940}
941
942/// Single-scheme handler contract. Products implement one per scheme
943/// (issue://, pr://, agent://, etc.) and register them with the router.
944#[async_trait]
945pub trait ProtocolHandler: Send + Sync {
946    /// Lowercase scheme this handler serves ("issue", "pr", …).
947    fn scheme(&self) -> &str;
948    /// When true, the resolved content is immutable (hashline anchor suppressed).
949    fn immutable(&self) -> bool {
950        false
951    }
952    /// Resolve a URL path (scheme already stripped) to text content.
953    async fn resolve(
954        &self,
955        url: &str,
956        selector: Option<&str>,
957        ctx: &ResolveContext,
958    ) -> Result<ResolvedUrl, SdkError>;
959}
960
961/// Auto-completion entry returned by a handler.
962#[derive(Debug, Clone, Serialize, Deserialize)]
963pub struct UrlCompletion {
964    /// Completion text to insert.
965    pub value: String,
966    /// Short label for the completion menu.
967    pub label: Option<String>,
968    /// Longer description shown alongside the label.
969    pub description: Option<String>,
970}
971
972/// Line map metadata for selector processing (read tool delegates to this).
973#[derive(Debug, Clone, Default)]
974pub struct LineMap {
975    /// Total number of lines in the source.
976    pub total_lines: u32,
977    /// 1-indexed displayable ranges (gaps represent elided regions).
978    pub displayable: Option<Vec<(u32, u32)>>,
979}
980
981// ═══════════════════════════════════════════════════════════════════════════
982// Port 14 — RuleRegistry: TTSR rules source.
983// ═══════════════════════════════════════════════════════════════════════════
984
985/// A TTSR rule. Condition is a regex matched against streaming output.
986#[derive(Debug, Clone)]
987pub struct Rule {
988    /// Rule name (unique identifier).
989    pub name: String,
990    /// Rule body injected into the system prompt when conditions match.
991    pub content: String,
992    /// Human-readable summary of what the rule does.
993    pub description: Option<String>,
994    /// Regex patterns to match against stream text.
995    pub condition: Vec<regex::Regex>,
996    /// Scope tokens limiting which stream sources trigger.
997    pub scope: Vec<ScopeToken>,
998    /// When (if ever) this rule interrupts the agent loop.
999    pub interrupt_mode: InterruptMode,
1000    /// File globs that further restrict the rule's applicability.
1001    pub globs: Vec<String>,
1002    /// If true, always included in system prompt.
1003    pub always_apply: bool,
1004    /// Where the rule was loaded from.
1005    pub source: RuleSource,
1006}
1007
1008/// Stream-source scope a TTSR rule can match against.
1009#[derive(Debug, Clone)]
1010pub enum ScopeToken {
1011    /// Matches assistant prose output.
1012    Text,
1013    /// Matches model thinking/reasoning output.
1014    Thinking,
1015    /// Matches tool-call arguments, optionally filtered by tool name and globs.
1016    Tool {
1017        /// Tool name to match.
1018        name: String,
1019        /// File globs that restrict which tool calls this scope matches.
1020        globs: Vec<String>,
1021    },
1022}
1023
1024/// When a TTSR rule fires relative to prose/tool output.
1025#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1026pub enum InterruptMode {
1027    /// Never interrupts.
1028    Never,
1029    /// Interrupts on prose output only.
1030    ProseOnly,
1031    /// Interrupts on tool output only.
1032    ToolOnly,
1033    /// Interrupts on any matching output.
1034    Always,
1035}
1036
1037/// Origin of a TTSR rule.
1038#[derive(Debug, Clone)]
1039pub enum RuleSource {
1040    /// Shipped with the SDK.
1041    BuiltinDefaults,
1042    /// Loaded from the project's rule files.
1043    Project,
1044    /// Loaded from the user's global rule files.
1045    User,
1046}
1047
1048/// Source of TTSR rules and injection bookkeeping.
1049pub trait RuleRegistry: Send + Sync + 'static {
1050    /// Return all currently active rules.
1051    fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>>;
1052    /// Record that `name` was injected on `turn` (dedup bookkeeping).
1053    fn mark_injected(&self, _name: &str, _turn: u64) {}
1054    /// Return all (name, turn) injection records.
1055    fn injected_records(&self) -> Vec<(String, u64)> {
1056        Vec::new()
1057    }
1058    /// Restore injection records (e.g. after compaction).
1059    fn restore(&self, _records: Vec<(String, u64)>) {}
1060}
1061
1062/// Noop registry: returns no rules.
1063#[derive(Default)]
1064pub struct NoopRuleRegistry;
1065
1066impl RuleRegistry for NoopRuleRegistry {
1067    fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>> {
1068        Box::pin(async { Vec::new() })
1069    }
1070}
1071
1072// ═══════════════════════════════════════════════════════════════════════════
1073// Port 15 — EmbeddingProvider: text → vector for semantic search.
1074// ═══════════════════════════════════════════════════════════════════════════
1075
1076/// Produces dense vector embeddings for semantic memory search.
1077pub trait EmbeddingProvider: Send + Sync + 'static {
1078    /// Produce a dense embedding vector for `text`.
1079    fn embed<'a>(
1080        &'a self,
1081        text: &'a str,
1082    ) -> Pin<Box<dyn Future<Output = Result<Vec<f32>, SdkError>> + Send + 'a>>;
1083}
1084
1085/// Noop provider: `embed` always errors with `PortNotConfigured`.
1086pub struct NoopEmbeddingProvider;
1087
1088impl EmbeddingProvider for NoopEmbeddingProvider {
1089    fn embed<'a>(
1090        &'a self,
1091        _text: &'a str,
1092    ) -> Pin<Box<dyn Future<Output = Result<Vec<f32>, SdkError>> + Send + 'a>> {
1093        Box::pin(async {
1094            Err(SdkError::PortNotConfigured {
1095                port: "EmbeddingProvider",
1096            })
1097        })
1098    }
1099}
1100
1101// Port 16 — HookRunner: user-configurable event→shell-command hooks.
1102// See `docs/superpowers/specs/2026-08-04-hooks-system-design.md`.
1103
1104// ═══════════════════════════════════════════════════════════════════════════
1105// Registry — a single Arc<dyn ...> set registered on Oxicode
1106// ═══════════════════════════════════════════════════════════════════════════
1107
1108/// Bundle of all registered ports. Products construct this and pass it to
1109/// `OxicodeBuilder::with_ports(...)`.
1110///
1111/// All fields default to noop impls so products can register only the
1112/// ports they care about.
1113#[derive(Clone)]
1114pub struct PortRegistry {
1115    /// State store.
1116    pub state: Arc<dyn StateStore>,
1117    /// Config store.
1118    pub config: Arc<dyn ConfigStore>,
1119    /// Auth provider.
1120    pub auth: Arc<dyn AuthProvider>,
1121    /// Event bus.
1122    pub event_bus: Arc<dyn EventBus>,
1123    /// Skill loader.
1124    pub skills: Arc<dyn SkillLoader>,
1125    /// Persona provider.
1126    pub personas: Arc<dyn PersonaProvider>,
1127    /// Access gate.
1128    pub access: Arc<dyn AccessGate>,
1129    /// Capability resolver.
1130    pub capabilities: Arc<dyn CapabilityResolver>,
1131    /// Memory store.
1132    pub memory: Arc<dyn MemoryStore>,
1133    /// Cron scheduler.
1134    pub cron: Arc<dyn CronScheduler>,
1135    /// Resource monitor.
1136    pub resources: Arc<dyn ResourceMonitor>,
1137    /// Model catalog — provider/model metadata source of truth.
1138    /// Default: [`catalog::NoopModelCatalog`] (empty results).
1139    pub catalog: Arc<dyn catalog::ModelCatalog>,
1140    /// Internal URL router — protocol-scheme dispatch.
1141    /// Default: [`NoopInternalUrlRouter`].
1142    pub url_router: Arc<dyn InternalUrlRouter>,
1143    /// Rule registry — TTSR rules.
1144    /// Default: [`NoopRuleRegistry`].
1145    pub rules: Arc<dyn RuleRegistry>,
1146    /// Embedding provider — text→vector for semantic search.
1147    /// Default: [`NoopEmbeddingProvider`].
1148    pub embeddings: Arc<dyn EmbeddingProvider>,
1149    /// Hook runner — user-configurable event→shell-command hooks.
1150    /// Default: [`NoopHookRunner`].
1151    pub hooks: Arc<dyn HookRunner>,
1152}
1153
1154impl std::fmt::Debug for PortRegistry {
1155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1156        f.debug_struct("PortRegistry")
1157            .field("state", &"<dyn StateStore>")
1158            .field("config", &"<dyn ConfigStore>")
1159            .field("auth", &"<dyn AuthProvider>")
1160            .field("event_bus", &"<dyn EventBus>")
1161            .field("skills", &"<dyn SkillLoader>")
1162            .field("personas", &"<dyn PersonaProvider>")
1163            .field("access", &"<dyn AccessGate>")
1164            .field("capabilities", &"<dyn CapabilityResolver>")
1165            .field("memory", &"<dyn MemoryStore>")
1166            .field("cron", &"<dyn CronScheduler>")
1167            .field("resources", &"<dyn ResourceMonitor>")
1168            .field("catalog", &"<dyn ModelCatalog>")
1169            .field("url_router", &"<dyn InternalUrlRouter>")
1170            .field("rules", &"<dyn RuleRegistry>")
1171            .field("embeddings", &"<dyn EmbeddingProvider>")
1172            .field("hooks", &"<dyn HookRunner>")
1173            .finish()
1174    }
1175}
1176
1177impl Default for PortRegistry {
1178    fn default() -> Self {
1179        Self::noop()
1180    }
1181}
1182
1183impl PortRegistry {
1184    /// All-noop registry. Useful for tests and products that only need
1185    /// agent execution without any persistence.
1186    pub fn noop() -> Self {
1187        Self {
1188            state: Arc::new(NoopStateStore),
1189            config: Arc::new(NoopConfigStore),
1190            auth: Arc::new(NoopAuthProvider),
1191            event_bus: Arc::new(NoopEventBus),
1192            skills: Arc::new(NoopSkillLoader),
1193            personas: Arc::new(NoopPersonaProvider),
1194            access: Arc::new(AllowAllAccessGate),
1195            capabilities: Arc::new(EmptyCapabilityResolver),
1196            memory: Arc::new(NoopMemoryStore),
1197            cron: Arc::new(NoopCronScheduler),
1198            resources: Arc::new(NoopResourceMonitor),
1199            catalog: catalog::NoopModelCatalog::new(),
1200            url_router: Arc::new(NoopInternalUrlRouter),
1201            rules: Arc::new(NoopRuleRegistry),
1202            embeddings: Arc::new(NoopEmbeddingProvider),
1203            hooks: Arc::new(NoopHookRunner),
1204        }
1205    }
1206
1207    /// Build a registry from a directory for file-based persistence.
1208    /// Convenience for the common case of "give me a registry backed by
1209    /// `~/.oxicode`" — products can also construct `PortRegistry` field-by-field.
1210    ///
1211    /// This is a free function: it requires concrete impls from a separate
1212    /// adapter crate (e.g. `oxicode-fs`). When no adapter is available, this
1213    /// returns `PortRegistry::noop()`.
1214    pub async fn from_directory(_dir: &Path) -> Self {
1215        // Adapter implementations are intentionally not part of oxicode-sdk itself.
1216        // Products wire concrete adapters via OxicodeBuilder::with_port_*(...) or
1217        // construct a PortRegistry directly.
1218        Self::noop()
1219    }
1220}
1221
1222// ═══════════════════════════════════════════════════════════════════════════
1223// Tests
1224// ═══════════════════════════════════════════════════════════════════════════
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229    use serde_json::json;
1230
1231    #[tokio::test]
1232    async fn noop_state_store_load_returns_none() {
1233        let s = NoopStateStore;
1234        assert!(s.load(&"x".into()).await.unwrap().is_none());
1235        assert!(s.list("").await.unwrap().is_empty());
1236    }
1237
1238    #[tokio::test]
1239    async fn noop_state_store_append_errors() {
1240        let s = NoopStateStore;
1241        let err = s.append(json!({})).await.unwrap_err();
1242        assert!(matches!(
1243            err,
1244            SdkError::PortNotConfigured { port: "StateStore" }
1245        ));
1246    }
1247
1248    #[test]
1249    fn noop_config_get_returns_none() {
1250        let c = NoopConfigStore;
1251        assert!(c.get("any").unwrap().is_none());
1252        assert!(c.list().unwrap().is_empty());
1253    }
1254
1255    #[tokio::test]
1256    async fn noop_auth_get_api_key_returns_none() {
1257        let a = NoopAuthProvider;
1258        assert!(a.get_api_key("anthropic").await.unwrap().is_none());
1259        assert!(a.list_providers().await.unwrap().is_empty());
1260    }
1261
1262    #[tokio::test]
1263    async fn in_memory_event_bus_round_trip() {
1264        let bus = InMemoryEventBus::new(8);
1265        bus.publish(&"test".to_string(), json!({"hello": "world"}))
1266            .await
1267            .unwrap();
1268        let mut sub = bus.subscribe(&"test".to_string()).await.unwrap();
1269        // Re-publish to ensure subscriber is registered.
1270        bus.publish(&"test".to_string(), json!({"k": 1}))
1271            .await
1272            .unwrap();
1273        let (topic, payload) = sub.recv().await.unwrap();
1274        assert_eq!(topic, "test");
1275        assert_eq!(payload, json!({"k": 1}));
1276    }
1277
1278    #[tokio::test]
1279    async fn noop_event_bus_publish_succeeds_but_subscribes_return_none() {
1280        let bus = NoopEventBus;
1281        bus.publish(&"x".to_string(), json!({})).await.unwrap();
1282        let mut sub = bus.subscribe(&"x".to_string()).await.unwrap();
1283        assert!(sub.recv().await.is_none());
1284    }
1285
1286    #[test]
1287    fn default_registry_is_noop() {
1288        let reg = PortRegistry::default();
1289        // Constructed without panic.
1290        assert!(Arc::strong_count(&reg.state) >= 1);
1291    }
1292
1293    #[test]
1294    fn oauth_token_bearer_constructor() {
1295        let t = OAuthToken::bearer("abc");
1296        assert_eq!(t.access_token, "abc");
1297        assert_eq!(t.token_type.as_deref(), Some("Bearer"));
1298    }
1299}
1300
1301// ═══════════════════════════════════════════════════════════════════════════
1302// Reference implementations
1303// ═══════════════════════════════════════════════════════════════════════════
1304//
1305// `fs`     — file-based adapters (JSON, TOML, SKILL.md, …)
1306// `inmem`  — in-process adapters (RAM-only, useful for tests and headless)
1307//
1308// All impls are part of the SDK. Products can import them directly or
1309// write their own — the port traits in this module are the contract.
1310
1311pub mod fs;
1312pub mod inmem;