Type: Implementation guide. Normative spec: PROTOCOL_SPEC §11 Extension Mechanism.
The Extension System provides a pluggable architecture for customizing and extending the apcore framework without modifying core source code. It defines a set of named extension points — each accepting one or multiple implementations — and an ExtensionManager that wires registered extensions into the Registry and Executor at startup. This enables third-party libraries and application code to inject custom discoverers, middleware, ACL providers, span exporters, module validators, and approval handlers.
- Define a fixed set of built-in extension points, each with a name, expected type, description, and cardinality (
multiple: true/false). - For single-cardinality points, only one extension can be registered at a time; re-registration replaces the previous one.
- For multi-cardinality points, multiple extensions can coexist and are all applied.
- Provide an
ExtensionManagerclass that manages extension registration, retrieval, and wiring. - Registration MUST perform type checking against the extension point's expected type.
- The
apply()method MUST wire all registered extensions into the provided Registry and Executor instances. - Span exporters registered as extensions MUST be composed into a single composite exporter when multiple are present.
- Each extension point declares an expected type (protocol/interface). Registration of a value that does not satisfy the expected type MUST raise an error.
- SDKs MAY use runtime type checking mechanisms appropriate to the language (e.g.,
isinstancein Python, duck-type guards in TypeScript, trait bounds in Rust).
| Name | Multiple | Expected Type | Description |
|---|---|---|---|
discoverer |
No | Discoverer protocol | Custom module discovery strategy |
middleware |
Yes | Middleware protocol | Execution middleware |
acl |
No | ACL protocol | Access control provider |
span_exporter |
Yes | SpanExporter protocol | Tracing span exporter |
module_validator |
No | ModuleValidator protocol | Custom module validation |
approval_handler |
No | ApprovalHandler protocol | Approval gate handler |
=== "Python" ```python from dataclasses import dataclass
@dataclass
class ExtensionPoint:
name: str # Slot name (e.g., "discoverer")
extension_type: type # Required type/protocol
description: str # Human-readable purpose
multiple: bool # Whether multiple can be registered
```
=== "TypeScript"
typescript interface ExtensionPoint { readonly name: string; readonly description: string; readonly multiple: boolean; }
=== "Rust"
rust pub struct ExtensionPoint { pub name: String, pub description: String, pub multiple: bool, }
=== "Python" ```python from apcore.extensions import ExtensionManager
manager = ExtensionManager()
# Register extensions
manager.register("discoverer", my_discoverer)
manager.register("middleware", logging_middleware)
manager.register("middleware", metrics_middleware)
manager.register("span_exporter", stdout_exporter)
manager.register("span_exporter", otlp_exporter)
manager.register("acl", my_acl)
manager.register("approval_handler", my_approval_handler)
manager.register("module_validator", my_validator)
# Retrieve extensions
discoverer = manager.get("discoverer") # Single or None
all_mw = manager.get_all("middleware") # List of all registered
# Unregister
manager.unregister("middleware", logging_middleware)
# List all extension points
points = manager.list_points() # List[ExtensionPoint]
# Wire everything into registry and executor
manager.apply(registry, executor)
```
=== "TypeScript" ```typescript import { ExtensionManager } from "apcore-js";
const manager = new ExtensionManager();
// Register extensions
manager.register("discoverer", myDiscoverer);
manager.register("middleware", loggingMiddleware);
manager.register("middleware", metricsMiddleware);
manager.register("span_exporter", stdoutExporter);
manager.register("span_exporter", otlpExporter);
manager.register("acl", myAcl);
manager.register("approval_handler", myApprovalHandler);
manager.register("module_validator", myValidator);
// Retrieve extensions
const discoverer = manager.get("discoverer"); // Single or null
const allMw = manager.getAll("middleware"); // Array of all registered
// Unregister
manager.unregister("middleware", loggingMiddleware);
// List all extension points
const points = manager.listPoints(); // ExtensionPoint[]
// Wire everything into registry and executor
manager.apply(registry, executor);
```
=== "Rust" ```rust use apcore::extensions::ExtensionManager;
let mut manager = ExtensionManager::new();
// Register extensions
manager.register("discoverer", Box::new(my_discoverer))?;
manager.register("module_validator", Box::new(my_validator))?;
manager.register("middleware", Box::new(logging_middleware))?;
manager.register("middleware", Box::new(metrics_middleware))?;
manager.register("span_exporter", Box::new(stdout_exporter))?;
manager.register("acl", Box::new(my_acl))?;
manager.register("approval_handler", Box::new(my_approval_handler))?;
// Wire everything into registry and executor
manager.apply(&mut registry, &mut executor)?;
```
When apply(registry, executor) is called, the manager performs the following in order:
- Discoverer →
registry.set_discoverer(ext)— replaces the default discovery strategy. - Module Validator →
registry.set_validator(ext)— replaces the default module validator. - ACL →
executor.set_acl(ext)— replaces the executor's ACL provider. - Approval Handler →
executor.set_approval_handler(ext)— replaces the executor's approval handler. - Middleware →
executor.use(mw)for each registered middleware — appends to the middleware chain. - Span Exporters → Locates the
TracingMiddlewarein the executor's middleware chain and sets the exporter:- If a single exporter is registered, it is set directly.
- If multiple exporters are registered, they are wrapped in a
CompositeExporterthat delegates to all underlying exporters. Failures in one exporter do not affect others.
When multiple span exporters are registered, they are composed into a single CompositeExporter:
class _CompositeExporter:
def __init__(self, exporters: list[SpanExporter]) -> None: ...
def export(self, span: Span) -> None:
"""Export span to all underlying exporters. Failures are logged but not raised."""
for exporter in self._exporters:
try:
exporter.export(span)
except Exception:
pass # Log and continuepoint_name(str/string/&str, required) — name of an existing extension point (e.g.,"discoverer","middleware"); unknown names raise an errorextension(Any/unknown/Box, required) — must satisfy the extension point's declared type; type checking is performed at registration time
ExtensionPointNotFoundError(orValueError/Err(ModuleError)) —point_nameis not a registered extension pointExtensionTypeError(orTypeError/Err(ModuleError)) —extensiondoes not satisfy the point's expected type/trait
- On success: void/None/() — extension is stored; for single-cardinality points, replaces any prior registration
- async: false
- thread_safe: false (do not call concurrently with
apply()or otherregister()calls) - pure: false (mutates internal extension store)
- idempotent: false for single-cardinality (replaces); accumulating for multi-cardinality (
middleware,span_exporter)
point_name(str/string/&str, required) — name of a single-cardinality extension point
- No errors raised; returns
None/null/Nonewhen nothing is registered
- On success: the registered extension object, or
None/null/None
- async: false
- thread_safe: true
- pure: true
point_name(str/string/&str, required) — name of a multi-cardinality extension point
- No errors raised; returns empty list when nothing is registered
- On success:
list/Array/Vecof all registered extensions in registration order
- async: false
- thread_safe: true
- pure: true
point_name(str/string/&str, required) — name of the extension pointextension(Any/unknown/ref, required) — the exact extension object to remove (identity comparison)
- No error if the extension is not found (silent no-op)
- On success: void/None/()
- async: false
- thread_safe: false
- pure: false (mutates extension store)
registry(Registry, required) — registry to wire extensions into (discoverer, module_validator)executor(Executor, required) — executor to wire extensions into (acl, approval_handler, middleware, span_exporter)
ExtensionApplyError(or propagated from Registry/Executor) — wiring a span exporter fails because noTracingMiddlewareis present in the executor chain; or Registry/Executor APIs raise
- On success: void/None/() — all registered extensions wired in the documented order (discoverer → module_validator → acl → approval_handler → middleware chain → span exporters)
registry.set_discoverer(ext)if discoverer registeredregistry.set_validator(ext)if module_validator registeredexecutor.set_acl(ext)if acl registeredexecutor.set_approval_handler(ext)if approval_handler registeredexecutor.use(mw)for each middleware in registration order- Locate
TracingMiddlewarein executor chain; set single exporter directly or wrap multiple inCompositeExporter
- async: false
- thread_safe: false (call once during startup, before concurrent request handling)
- pure: false (mutates registry and executor)
- idempotent: false (calling apply twice stacks middleware and re-wires other extensions)
A custom Discoverer replaces the default filesystem scan. discover() receives the configured extension roots and returns the discovered entries. The Registry then performs module-id validation (per PROTOCOL_SPEC §2.7) → duplicate check → optional custom-validator call → registration. Malformed or rejected entries are skipped with a warning; a single bad entry MUST NOT abort the batch.
=== "Python"
```python
from apcore import Registry
from apcore.registry.registry import Discoverer
class CustomDiscoverer(Discoverer):
"""Return a list of dicts, each with 'module_id' and 'module' keys."""
def discover(self, roots: list[str]) -> list[dict]:
return [
{"module_id": "custom.hello", "module": HelloModule()},
]
registry = Registry(extensions_dir="./extensions")
registry.set_discoverer(CustomDiscoverer())
registry.discover() # returns the count of successfully registered modules
```
=== "TypeScript"
```typescript
import { Registry, Discoverer } from "apcore";
const discoverer: Discoverer = {
async discover(roots: string[]) {
return [{ moduleId: "custom.hello", module: new HelloModule() }];
},
};
const registry = new Registry({ extensionsDir: "./extensions" });
registry.setDiscoverer(discoverer);
await registry.discover();
```
=== "Rust"
```rust
use apcore::registry::registry::{DiscoveredModule, Discoverer, Registry};
use apcore::errors::ModuleError;
use async_trait::async_trait;
use std::sync::Arc;
struct CustomDiscoverer;
#[async_trait]
impl Discoverer for CustomDiscoverer {
async fn discover(
&self,
_roots: &[String],
) -> Result<Vec<DiscoveredModule>, ModuleError> {
Ok(vec![DiscoveredModule {
name: "custom.hello".to_string(),
source: "in-memory".to_string(),
descriptor: /* build a ModuleDescriptor for this module */ todo!(),
module: Arc::new(HelloModule) as Arc<dyn apcore::module::Module>,
}])
}
}
let registry = Registry::new();
registry.set_discoverer(Box::new(CustomDiscoverer));
let count = registry.discover_internal().await?;
```
!!! tip "Out-of-process modules"
Discoverers for subprocess, RPC, or network-hosted modules wrap the external resource in a Module impl — for example, a SubprocessModule { executable: PathBuf, descriptor } whose execute spawns the binary and pipes JSON through stdin/stdout. The Registry then treats subprocess-backed and in-process modules identically: it runs the custom validator, calls on_load, and exposes the instance through registry.get(name).
Override the default structural conformance check with a stricter rule set:
from apcore import Registry, ModuleValidator
class StrictValidator(ModuleValidator):
def validate(self, module_class: Type[Module]) -> list[str]:
errors = super().validate(module_class)
if not module_class.tags:
errors.append("Module must have at least one tag")
if not module_class.__doc__ or len(module_class.__doc__) < 20:
errors.append("Module description must be at least 20 characters")
return errors
registry = Registry(extensions_dir="./extensions")
registry.set_validator(StrictValidator())
registry.discover()Available in apcore-python v0.5.1+ and apcore-typescript v0.3.0+.
- Registry — Extension points
discovererandmodule_validatorare wired into the Registry. - Executor — Extension points
acl,approval_handler, andmiddlewareare wired into the Executor. - Observability — Extension point
span_exporterintegrates withTracingMiddleware.
??? info "Python SDK reference"
The following table is not a protocol requirement — it documents the Python SDK's source layout for implementers/users of apcore-python.
**Source files:**
| File | Purpose |
|------|---------|
| `src/apcore/extensions.py` | `ExtensionManager`, `ExtensionPoint`, `_CompositeExporter`, built-in points |
- Registration tests verify that extensions of correct type are accepted and incorrect types are rejected.
- Cardinality tests confirm that single-cardinality points replace on re-registration and multi-cardinality points accumulate.
- Wiring tests confirm that
apply()correctly sets the discoverer, validator, ACL, approval handler, middleware, and span exporters on the Registry and Executor. - CompositeExporter tests verify fan-out delivery and error isolation between exporters.
- Unregistration tests verify that unregistered extensions are no longer returned by
get()/get_all()and are not applied on subsequentapply()calls.