| description | Built-in system.* control-plane modules for AI introspection: health summary/module, manifest discovery, usage analytics, approval-gated update_config, reload_module, toggle_feature. |
|---|
Type: Implementation guide. Normative spec: PROTOCOL_SPEC §5 Module Specification (system module conventions).
Built-in system.* modules provide AI bidirectional introspection — allowing AI agents to query, monitor, and control the apcore runtime. System modules are registered automatically when sys_modules.enabled: true in config, and use the reserved system.* namespace (see PROTOCOL_SPEC §2.5, §6.6).
system.health.summary— Aggregate health status across all modules with classification (healthy / degraded / error / unknown).system.health.module— Per-module health detail with latency metrics and recent errors.
system.manifest.module— Single module introspection (schema, annotations, tags, source path).system.manifest.full— Full registry manifest with filtering by tags and prefix.
system.usage.summary— Usage statistics across all modules with trend detection.system.usage.module— Per-module usage detail with caller breakdown and hourly distribution.
system.control.update_config— Hot-patch runtime config values with constraint validation.system.control.reload_module— Hot-reload a module from disk without restart.system.control.toggle_feature— Enable/disable modules at runtime with reason tracking.
Control modules require requires_approval: true and are only registered when sys_modules.enabled: true.
Aggregated health overview of all registered modules.
Annotations: readonly=True, idempotent=True
Input:
| Field | Type | Default | Description |
|---|---|---|---|
error_rate_threshold |
float | 0.01 | Threshold for healthy status (1%) |
include_healthy |
bool | true | Include healthy modules in output |
Output:
{
"project": { "name": "my-project" },
"summary": {
"total_modules": 12,
"healthy": 10,
"degraded": 1,
"error": 1,
"unknown": 0
},
"modules": [
{
"module_id": "math.add",
"status": "healthy",
"error_rate": 0.002,
"top_error": null
},
{
"module_id": "email.send",
"status": "degraded",
"error_rate": 0.05,
"top_error": {
"code": "MODULE_TIMEOUT",
"message": "Module timed out",
"ai_guidance": "consider increasing timeout",
"count": 3
}
}
]
}Health classification:
| Status | Condition |
|---|---|
| healthy | error rate < 1% (configurable via error_rate_threshold) |
| degraded | error rate 1% – 10% |
| error | error rate >= 10% |
| unknown | No calls recorded |
error_rate_threshold: float, optional, default0.01include_healthy: bool, optional, defaulttrue
- No errors under normal operation — standard input-schema validation applies to malformed field types
- On success:
dict—{project, summary, modules[]}per the Output shape above
- idempotent: true (declared via
annotations.idempotent=true) - thread_safe: true — read-only aggregation over already-collected call statistics
- async: false
- pure: true — no state mutation
- reentrant: true
Detailed health information for a single module.
Annotations: readonly=True, idempotent=True
Input:
| Field | Type | Default | Description |
|---|---|---|---|
module_id |
string | (required) | Module to query |
error_limit |
int | 10 | Max recent errors to return |
Output:
{
"module_id": "email.send",
"status": "degraded",
"total_calls": 1542,
"error_count": 77,
"error_rate": 0.05,
"avg_latency_ms": 245.3,
"p99_latency_ms": 1200.0,
"recent_errors": [
{
"code": "MODULE_TIMEOUT",
"message": "Module timed out",
"ai_guidance": "consider increasing timeout",
"count": 3,
"first_occurred": "2026-03-08T10:00:00Z",
"last_occurred": "2026-03-08T11:30:00Z"
}
]
}module_id: string, required- reject_with:
ModuleNotFoundErrorifmodule_idis not registered
- reject_with:
error_limit: int, optional, default10
ModuleNotFoundError—module_idis not registered
- On success:
dict— per the Output shape above
- idempotent: true
- thread_safe: true
- async: false
- pure: true — read-only lookup over already-collected call statistics
- reentrant: true
Full manifest for a single registered module.
Annotations: readonly=True, idempotent=True
Input:
| Field | Type | Default | Description |
|---|---|---|---|
module_id |
string | (required) | Module to describe |
Output:
{
"module_id": "math.add",
"description": "Add two numbers",
"documentation": "Adds two integers and returns the sum.",
"source_path": "extensions/math/add.py",
"input_schema": { "type": "object", "properties": { "a": { "type": "integer" }, "b": { "type": "integer" } } },
"output_schema": { "type": "object", "properties": { "sum": { "type": "integer" } } },
"annotations": {
"readonly": true,
"idempotent": true,
"requires_approval": false,
"destructive": false,
"discoverable": true
},
"tags": ["math", "utility"],
"dependencies": [],
"metadata": {}
}module_id: string, required- reject_with:
ModuleNotFoundErrorifmodule_idis not registered
- reject_with:
ModuleNotFoundError—module_idis not registered
- On success:
dict— per the Output shape above
- idempotent: true
- thread_safe: true
- async: false
- pure: true — read-only lookup over registry metadata
- reentrant: true
Complete system manifest with filtering.
Annotations: readonly=True, idempotent=True
Input:
| Field | Type | Default | Description |
|---|---|---|---|
include_schemas |
bool | true | Include input/output schemas |
include_source_paths |
bool | true | Include source file paths |
prefix |
string | (none) | Filter by module ID prefix |
tags |
list[string] | (none) | Filter by tags (all must match) |
Output:
{
"project_name": "my-project",
"module_count": 5,
"modules": [ ... ]
}include_schemas: bool, optional, defaulttrueinclude_source_paths: bool, optional, defaulttrueprefix: string, optional — filters by module ID prefixtags: list[string], optional — filters by tags; all listed tags must match
- No errors under normal operation
- On success:
dict—{project_name: str, module_count: int, modules: [...]}
- idempotent: true
- thread_safe: true
- async: false
- pure: true — read-only lookup over registry metadata
- reentrant: true
Usage overview with trend detection across all modules.
Annotations: readonly=True, idempotent=True
Input:
| Field | Type | Default | Description |
|---|---|---|---|
period |
string | "24h" | Time window, ^[1-9][0-9]*[hd]$ (e.g. 1h, 24h, 7d). Declared as a pattern in input_schema, so a malformed value fails input validation with SCHEMA_VALIDATION_ERROR. |
Output:
{
"period": "24h",
"total_calls": 15420,
"total_errors": 77,
"modules": [
{
"module_id": "math.add",
"call_count": 5000,
"error_count": 2,
"avg_latency_ms": 12.5,
"unique_callers": 8,
"trend": "stable"
}
]
}Modules sorted by call_count descending.
Trend values: stable, rising, declining, new, inactive — decided by the normative threshold table in PROTOCOL_SPEC §6.7.1.5, comparing the requested window against the preceding window of equal length.
periodis a filter, not an echo.total_calls,total_errorsand every field of everymodules[]entry MUST be computed over[now − period, now]. Echoingperiodback while computing over the full retained history is a conformance failure — and a silent one, because the response names the window it did not apply. See PROTOCOL_SPEC §6.7.1.1; canonical shape inschemas/sys-usage-summary.schema.json.
period: string, optional, default"24h"- validation: MUST match
^[1-9][0-9]*[hd]$ - reject_with:
SCHEMA_VALIDATION_ERROR— malformed value failsinput_schemapattern validation
- validation: MUST match
SCHEMA_VALIDATION_ERROR—perioddoes not match the required pattern
- On success:
dict— per the Output shape above; every field computed over[now − period, now], never over full retained history (PROTOCOL_SPEC §6.7.1.1)
- idempotent: true
- thread_safe: true
- async: false
- pure: true — reads already-collected usage data and computes trend classification; no mutation
- reentrant: true
Detailed usage for a single module with caller breakdown.
Annotations: readonly=True, idempotent=True
Input:
| Field | Type | Default | Description |
|---|---|---|---|
module_id |
string | (required) | Module to query |
period |
string | "24h" | Time window, ^[1-9][0-9]*[hd]$. Same grammar and same filter semantics as system.usage.summary. |
Output:
{
"module_id": "math.add",
"period": "24h",
"call_count": 5000,
"error_count": 2,
"avg_latency_ms": 12.5,
"p99_latency_ms": 45.0,
"trend": "stable",
"callers": [
{
"caller_id": "orchestrator.main",
"call_count": 3000,
"error_count": 1,
"avg_latency_ms": 11.2
}
],
"hourly_distribution": [
{ "hour": "2026-03-08T00", "call_count": 0, "error_count": 0 },
"... 21 more buckets, one per hour, none omitted ...",
{ "hour": "2026-03-08T10", "call_count": 200, "error_count": 0 },
{ "hour": "2026-03-08T11", "call_count": 350, "error_count": 1 }
]
}hourly_distribution invariants (PROTOCOL_SPEC §6.7.1.2):
houris the UTC bucket keyYYYY-MM-DDTHH— notYYYY-MM-DDTHH:00:00Z. This is the keyUsageCollectorproduces in every SDK; the module layer MUST NOT reformat it.- Exactly 24 entries, covering
now−23h .. now, ascending byhour, gaps zero-filled rather than omitted — so a consumer can index positionally. The two-entry array this example previously showed was not a valid response. - The 24-entry span is fixed.
periodfilters the counts inside each bucket; it does not change the array length.
p99_latency_ms is the nearest-rank 99th percentile — sorted[min(ceil(0.99·N), N) − 1], no interpolation, 0 when there are no samples. For 100 samples 1..100 the answer is 99, not 100 (PROTOCOL_SPEC §6.7.1.3).
callers[].caller_id is the literal "unknown" for a call recorded with no caller identity — never null, never omitted, never @external.
Canonical shape: schemas/sys-usage-module.schema.json.
module_id: string, required- reject_with:
ModuleNotFoundErrorifmodule_idis not registered
- reject_with:
period: string, optional, default"24h"- validation: MUST match
^[1-9][0-9]*[hd]$— same grammar and filter semantics assystem.usage.summary - reject_with:
SCHEMA_VALIDATION_ERROR
- validation: MUST match
ModuleNotFoundError—module_idis not registeredSCHEMA_VALIDATION_ERROR—perioddoes not match the required pattern
- On success:
dict— per the Output shape above, including the fixed 24-entryhourly_distributionand nearest-rankp99_latency_ms
- idempotent: true
- thread_safe: true
- async: false
- pure: true — read-only over already-collected usage data
- reentrant: true
Update a runtime configuration value by dot-path key.
Annotations: requires_approval=True
Input:
| Field | Type | Default | Description |
|---|---|---|---|
key |
string | (required) | Dot-path config key (e.g., executor.default_timeout) |
value |
any | (required) | New value |
reason |
string | (required) | Audit reason |
Output:
{
"success": true,
"key": "executor.default_timeout",
"old_value": 30000,
"new_value": 60000
}Restrictions:
- Cannot change
sys_modules.enabled(restricted key). - Sensitive keys (containing
token,secret,key,password,auth,credential) are logged with masked values. - Changes are in-memory only; not persisted to YAML.
- Emits
apcore.config.updatedevent.
key: string, required- validation: non-empty string
- reject_with:
InvalidInputError(message="'key' is required and must not be empty")
value: any, required- validation: none — any JSON-serializable value accepted; constraint checking applied post-set
reason: string, required- validation: non-empty string
- reject_with:
InvalidInputError(message="'reason' is required and must not be empty")
keymust not be in the restricted keys set (currently:sys_modules.enabled)- reject_with:
ModuleError(code=CONFIG_KEY_RESTRICTED)
- reject_with:
- If
keyhas a registered constraint,valuemust satisfy it; checked immediately afterConfig.set- reject_with:
ConfigError—Configis rolled back toold_valuebefore raising
- reject_with:
- Read current value of
keyfromConfig(capturesold_value) - Set
keytovalueinConfig(in-memory only; not persisted to YAML) - Validate constraint for
keyif one exists; on failure, roll back and raiseConfigError - Emit
apcore.config.updatedevent viaEventEmitter(values masked for sensitive keys) - Log change at INFO level (values masked for sensitive keys)
- On success:
config.get(key)returnsvalue - On
ConfigError:config.get(key)returns the originalold_value(atomically rolled back)
InvalidInputError—keyis absent or empty; orreasonis absent or emptyModuleError(code=CONFIG_KEY_RESTRICTED)—keyis in the restricted setConfigError—valueviolates a registered constraint;Configrolled back before raising
- On success:
dict—{success: true, key: str, old_value: any, new_value: any}old_valueandnew_valuereplaced with redaction sentinel for sensitive key segments
- idempotent: false — repeated calls with different values produce different state
- thread_safe: false —
Config.setis not internally locked; concurrent callers must serialize - async: false
- pure: false — mutates
Configstate and emits an event - reentrant: false
Hot-reload a module from disk without restart.
Annotations: requires_approval=True
Input:
| Field | Type | Default | Description |
|---|---|---|---|
module_id |
string | (required) | Module to reload |
reason |
string | (required) | Audit reason |
Output:
{
"success": true,
"module_id": "math.add",
"previous_version": "1.0.0",
"new_version": "1.1.0",
"reload_duration_ms": 45.2
}Process: safe_unregister() with drain → discover() re-load → re-register → emit apcore.module.reloaded event.
module_id: string, required- validation: non-empty string
- reject_with:
InvalidInputError
reason: string, required- validation: non-empty string
- reject_with:
InvalidInputError(message="'reason' is required and must be a non-empty string")
module_idmust be present in theRegistrybefore reload begins- reject_with:
ModuleNotFoundError(module_id=module_id)
- reject_with:
- Read current module from
Registryto captureprevious_version - Call
module.on_suspend()if the method is defined — captures suspended state; errors are logged at ERROR and suppressed (best-effort) - Call
registry.safe_unregister(module_id)— drains in-flight calls then removes the module - Call
registry.discover()to reload module source from disk; ifmodule_idis absent after discovery, raiseReloadFailedError - Call
registry.register_internal(module_id, new_module)to re-register the freshly loaded module - Call
new_module.on_resume(suspended_state)if the method is defined and state is non-None; errors are logged at ERROR and suppressed (best-effort) - Emit
apcore.module.reloadedevent viaEventEmitter - Log reload at INFO level
- On success:
registry.get(module_id)returns a freshly loaded module instance - If step 4 raises,
module_idis unregistered and callers must handle the partial state
InvalidInputError—module_idorreasonis absent, wrong type, or emptyModuleNotFoundError—module_idis not registered before reload beginsReloadFailedError—registry.discover()raised ormodule_idwas absent after discovery
- On success:
dict—{success: true, module_id: str, previous_version: str, new_version: str, reload_duration_ms: float}
- idempotent: false — each call unregisters and re-registers; invoking twice reloads twice
- thread_safe: false — concurrent reload calls are not serialized beyond
safe_unregister - async: false
- pure: false — mutates
Registry, performs file I/O, emits an event - reentrant: false
Disable or enable a module without unloading it.
Annotations: requires_approval=True
Input:
| Field | Type | Default | Description |
|---|---|---|---|
module_id |
string | (required) | Module to toggle |
enabled |
bool | (required) | true to enable, false to disable |
reason |
string | (required) | Audit reason |
Output:
{
"success": true,
"module_id": "risky.module",
"enabled": false
}Disabled modules remain registered but calls raise ModuleDisabledError. Toggle state is thread-safe (via ToggleState class), isolated to the owning APCore instance — disabling a module on one instance does not affect another instance in the same process — and survives reload of that instance. Emits apcore.module.toggled event.
module_id: string, required- validation: non-empty string
- reject_with:
InvalidInputError(message="'module_id' is required and must be a non-empty string")
enabled: bool, required- validation: must be a
boolinstance (notNone, not a string or integer) - reject_with:
InvalidInputError(message="'enabled' is required and must be a boolean")
- validation: must be a
reason: string, required- validation: non-empty string
- reject_with:
InvalidInputError(message="'reason' is required and must be a non-empty string")
module_idmust be registered in theRegistry- reject_with:
ModuleNotFoundError(module_id=module_id)
- reject_with:
- Query
Registry.has(module_id)(read-only existence check) - Acquire internal lock on
ToggleState._lock - Mutate
ToggleState._disabledset: addmodule_idwhenenabled=false; discard it whenenabled=true - Release
ToggleState._lock - Emit
apcore.module.toggledevent viaEventEmitter - Log toggle at INFO level
- When
enabled=false:is_module_disabled(module_id)returnstrue; calls raiseModuleDisabledError(code=MODULE_DISABLED) - When
enabled=true:is_module_disabled(module_id)returnsfalse; module calls proceed normally - Toggle state persists across module reload of the same instance (held by the owning
APCoreinstance'sToggleState, external toRegistry); it is isolated to that instance, not shared process-globally (issue #71)
InvalidInputError—module_idis absent/empty,enabledis absent/non-boolean, orreasonis absent/emptyModuleNotFoundError—module_idis not registered in theRegistry
- On success:
dict—{success: true, module_id: str, enabled: bool}
- idempotent: true — toggling to the current state produces the same outcome
- thread_safe: true —
ToggleStateusesthreading.Lockto serialize all mutations - async: false
- pure: false — mutates
ToggleState, emits an event, writes a log entry - reentrant: false
from apcore.sys_modules.registration import register_sys_modules
context = register_sys_modules(
registry=registry,
executor=executor,
config=config,
metrics_collector=None, # auto-created if needed
)Workflow:
- Check
config.get("sys_modules.enabled")— exit iffalse. - Create
ErrorHistoryand registerErrorHistoryMiddleware. - Create
UsageCollectorand registerUsageMiddleware. - Register health, manifest, and usage modules.
- If
sys_modules.events.enabled:- Create
EventEmitterandPlatformNotifyMiddleware. - Register control modules.
- Instantiate event subscribers from config.
- Bridge registry events to EventEmitter.
- Create
Return value:
{
"error_history": ErrorHistory,
"error_history_middleware": ErrorHistoryMiddleware,
"usage_collector": UsageCollector,
"usage_middleware": UsageMiddleware,
"event_emitter": EventEmitter, # if events enabled
"platform_notify_middleware": PlatformNotifyMiddleware, # if events enabled
}=== "Python"
```python
from apcore import APCore
from apcore.config import Config
config = Config.load("apcore.yaml")
client = APCore(config=config)
# System modules auto-registered! Query them directly:
health = client.call("system.health.summary", {})
usage = client.call("system.usage.summary", {"period": "24h"})
# Control via convenience methods:
client.disable("some.module", reason="maintenance")
client.enable("some.module", reason="done")
```
=== "TypeScript"
```typescript
import { APCore, Config } from 'apcore-js';
const config = Config.load('apcore.yaml');
const client = new APCore({ config });
// System modules auto-registered! Query them directly:
const health = await client.call('system.health.summary', {});
const usage = await client.call('system.usage.summary', { period: '24h' });
// Control via convenience methods:
await client.disable('some.module', 'maintenance');
await client.enable('some.module', 'done');
```
=== "Rust"
```rust
use apcore::APCore;
use serde_json::json;
let client = APCore::from_path("apcore.yaml")?;
// System modules auto-registered! Query them directly:
let health = client.call("system.health.summary", json!({}), None, None).await?;
let usage = client.call("system.usage.summary", json!({"period": "24h"}), None, None).await?;
// Control via convenience methods:
client.disable("some.module", Some("maintenance"))?;
client.enable("some.module", Some("done"))?;
```
sys_modules:
enabled: true
error_history:
max_entries_per_module: 50 # Ring buffer per-module capacity
max_total_entries: 1000 # Ring buffer total capacity
events:
enabled: true # Required for control modules
thresholds:
error_rate: 0.1 # 10% triggers apcore.health.error_threshold_exceeded
latency_p99_ms: 5000.0 # 5s triggers apcore.health.latency_threshold_exceeded
subscribers:
- type: "webhook"
url: "https://platform.example.com/events"
headers:
Authorization: "Bearer token"Registry— Module lookup and registration.Executor— Module execution and middleware management.Config— Configuration values and hot reload.MetricsCollector— Call counts and latency histograms for health modules.ErrorHistory— Recent error tracking for health modules.UsageCollector— Call tracking for usage modules.EventEmitter— Event dispatch for control modules.
System modules use the reserved system.* namespace. Registration bypasses reserved word checks via registry.register_internal(). See PROTOCOL_SPEC §6.6 for the defense-in-depth permission model.
Activation has two stages, not one (§6.6.3):
| Config | Modules registered |
|---|---|
sys_modules.enabled: false (default) |
0 |
sys_modules.enabled: true, sys_modules.events.enabled: false (default) |
6 — system.health.*, system.usage.*, system.manifest.* |
both true |
9 — the above plus the three system.control.* write modules |
The control modules live inside the events branch because their audit events need the EventEmitter. A registry holding the six read modules has no write surface at all, which is why anything reasoning about exposure should distinguish the two states rather than treat "system modules are on" as one.
Layers 2 and 3 are inactive by absence (§6.6.3.1): a missing acl/ path attaches no ACL (and MUST NOT synthesize an empty default-deny one), and a missing ApprovalHandler skips the gate with a warning unless ExecutionPolicy(strict=true) is set. Neither absence fails closed.
To observe what is actually gating a registry — rather than infer it from acl != null — read executor.governance_state() (§6.6.5).
??? 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.
| File | Purpose |
|------|---------|
| `src/apcore/sys_modules/registration.py` | `register_sys_modules()`, subscriber factory registry |
| `src/apcore/sys_modules/health.py` | `HealthSummaryModule`, `HealthModuleModule` |
| `src/apcore/sys_modules/manifest.py` | `ManifestModuleModule`, `ManifestFullModule` |
| `src/apcore/sys_modules/usage.py` | `UsageSummaryModule`, `UsageModuleModule` |
| `src/apcore/sys_modules/control.py` | `UpdateConfigModule`, `ReloadModuleModule`, `ToggleFeatureModule`, `ToggleState` |
| Parameter | Type | Required | Description |
|---|---|---|---|
module_id |
str |
Yes | Fully-qualified module ID to inspect. |
registry |
Registry |
Yes | Registry that holds toggle state. |
| Code | Condition |
|---|---|
MODULE_DISABLED |
The module's current ToggleState is DISABLED. |
None — raises/throws on disabled; returns normally when enabled.
- Pure: Yes — reads registry state only, no side effects.
- Throws:
ModuleDisabledError(codeMODULE_DISABLED).
| Parameter | Type | Required | Description |
|---|---|---|---|
module_id |
str |
Yes | Fully-qualified module ID to inspect. |
The free is_module_disabled(module_id) / isModuleDisabled(moduleId) function reads
from the process-global fallback ToggleState and takes module_id as its sole
argument — there is no registry parameter. This standalone function exists for callers
that hold no APCore instance; the execution pipeline itself consults the owning
instance's ToggleState (injected into the lookup step), so per-instance toggles are
honored on the call path (issue #71; see conformance/fixtures/toggle_state_isolation.json).
- None — this function never raises; returns
falsefor unknown module IDs.
bool — true if disabled, false if enabled or toggle state not set.
- Pure: Yes — reads registry state only, no side effects.
- Does not throw.
- Health modules: Verify status classification thresholds, error aggregation from ErrorHistory, latency metrics from MetricsCollector.
- Manifest modules: Verify schema/annotation extraction, prefix/tag filtering, source path computation.
- Usage modules: Verify call counting, trend computation, hourly distribution padding, per-caller breakdown.
- Control modules: Verify approval requirement, config update with constraint validation, module reload lifecycle, toggle state persistence across reload.
- Registration: Verify auto-registration workflow, config-driven subscriber creation, middleware ordering.
Currently system.control.update_config and system.control.toggle_feature changes are in-memory only (see line 299).
- Implementations MUST support an optional
overrides_pathconfiguration field. When set, changes fromsystem.control.update_configandsystem.control.toggle_featureMUST be persisted tooverrides_pathas YAML. - The overrides file MUST be loaded on startup and applied AFTER the base config, so manual restores of the base config do not erase runtime overrides.
- Implementations SHOULD support alternative backends (Redis, etcd, a remote config service) via the pluggable
OverridesStoreinterface. Its surface is the whole map, not per key:load() → mappingandsave(mapping)(decision D-47). A single-key change is a read-modify-write, which is what thesystem.control.*code paths do. An earlier revision of this line specifiedset(key, value)/get(key)/get_all()/delete(key); no SDK ever implemented it, andconformance/fixtures/overrides_store.jsonnow pins the D-47 surface. - When no
overrides_pathor KV store is configured, the existing in-memory-only behavior MUST be preserved (backward compatible).
sys_modules:
control:
overrides_path: "/etc/apcore/overrides.yaml"overrides_path is the only configuration key here. An alternative backend
is supplied programmatically, because there is nothing for YAML to name: this
document requires that SDKs MUST NOT ship Redis, Postgres or S3
implementations, so a
type: "redis" block would name a class the framework does not have.
=== "Python" ```python import json
from apcore import APCore
from apcore.sys_modules.registration import register_sys_modules
class RedisOverridesStore: # yours, against your own client
def __init__(self, client, prefix="apcore:overrides:"):
self._client, self._prefix = client, prefix
def load(self) -> dict:
raw = self._client.get(self._prefix + "all")
return json.loads(raw) if raw else {}
def save(self, overrides: dict) -> None:
self._client.set(self._prefix + "all", json.dumps(overrides))
client = APCore()
register_sys_modules(client, overrides_store=RedisOverridesStore(my_redis))
```
!!! note "An earlier revision showed this as YAML"
This block previously offered overrides_store: {type: "redis", url: …} as
an alternative to overrides_path. No SDK ever read it — overrides_store
is a register_sys_modules() parameter, not a configuration key — and no
SDK could have, since none ships a Redis backend to instantiate. Under
_config.strict (§9.10) that key is now rejected outright, which is what
surfaced it.
=== "Python"
```python
from apcore import APCore
from apcore.config import Config
# Startup: load base config, then apply overrides from disk
config = Config.load("apcore.yaml")
# overrides_path is declared in apcore.yaml under sys_modules.control
client = APCore(config=config)
# Runtime update — persisted to overrides_path automatically
await client.executor.call_async(
"system.control.update_config",
{"key": "executor.default_timeout", "value": 60000, "reason": "increase timeout"},
context,
)
```
=== "TypeScript"
```typescript
import { APCore, Config } from 'apcore-js';
// Startup: load base config, then apply overrides from disk
const config = Config.load('apcore.yaml');
// overrides_path is declared in apcore.yaml under sys_modules.control
const client = new APCore({ config });
// Runtime update — persisted to overrides_path automatically
await client.executor.call(
'system.control.update_config',
{ key: 'executor.default_timeout', value: 60000, reason: 'increase timeout' },
context,
);
```
=== "Rust"
```rust
use apcore::APCore;
use serde_json::json;
// Startup: load base config, then apply overrides from disk
// overrides_path is declared in apcore.yaml under sys_modules.control
let client = APCore::from_path("apcore.yaml")?;
// Runtime update — persisted to overrides_path automatically
client.executor().call(
"system.control.update_config",
json!({
"key": "executor.default_timeout",
"value": 60000,
"reason": "increase timeout"
}),
None,
None,
).await?;
```
All three SDKs ship a pluggable OverridesStore abstraction plus a FileOverridesStore implementation backed by overrides_path. The abstraction is exposed in the form idiomatic to each language (decision D-47):
| SDK | Form | Public symbol |
|---|---|---|
| Python | typing.Protocol (runtime-checkable) |
apcore.sys_modules.overrides.OverridesStore |
| TypeScript | interface |
OverridesStore (exported from the package root, apcore-js — package.json declares only . and ./context-keys as subpaths) |
| Rust | pub trait OverridesStore: Send + Sync |
apcore::sys_modules::overrides::OverridesStore |
FileOverridesStore and InMemoryOverridesStore are the bundled implementations; users MAY supply their own implementation (e.g. a Redis- or KMS-backed store) by satisfying the protocol/interface/trait directly.
The abstraction MUST expose the whole-map surface of decision D-47 — two methods, not four:
| Method | Behavior |
|---|---|
load() → mapping |
Snapshot of every persisted override (used at startup to apply on top of base config). MUST return an empty mapping, never an error, when the backing store is empty or absent |
save(mapping) |
Replace the entire override set with mapping. Persisting a single key is a read-modify-write over load() — which is what the system.control.* code paths do |
An earlier revision of this table specified a per-key surface (save(key, value) / get(key) / get_all() / delete(key)). No SDK ever implemented it: apcore-python sys_modules/overrides.py, apcore-typescript sys-modules/overrides.ts and apcore-rust sys_modules/overrides.rs all ship load() / save(mapping), and conformance/fixtures/overrides_store.json pins that surface.
Wiring during APCore construction:
=== "Python"
```python
from apcore import APCore
from apcore.config import Config
from apcore.sys_modules.overrides import (
OverridesStore, # the Protocol — implement to plug in a custom backend
FileOverridesStore, # default, YAML-backed
InMemoryOverridesStore, # default, in-memory (for tests)
)
config = Config.load("apcore.yaml")
# The store is a register_sys_modules() parameter, not an APCore ctor param.
client = APCore(config=config)
# Production: persist runtime overrides to disk
overrides_store: OverridesStore = FileOverridesStore(path="/etc/apcore/overrides.yaml")
register_sys_modules(
client.registry, client.executor, config, overrides_store=overrides_store
)
# Tests: in-memory only, no disk side effects
test_client = APCore(config=config)
register_sys_modules(
test_client.registry, test_client.executor, config,
overrides_store=InMemoryOverridesStore(),
)
```
=== "TypeScript"
```typescript
import { APCore, Config } from "apcore-js";
import {
OverridesStore, // the interface — implement to plug in a custom backend
FileOverridesStore, // default, YAML-backed
InMemoryOverridesStore, // default, in-memory (for tests)
registerSysModules,
} from "apcore-js";
const config = Config.load("apcore.yaml");
// The store is a registerSysModules() option, not an APCore ctor option.
const client = new APCore({ config });
// Production: persist runtime overrides to disk
const overridesStore = new FileOverridesStore("/etc/apcore/overrides.yaml");
registerSysModules(client.registry, client.executor, config, null, { overridesStore });
// Tests: in-memory only, no disk side effects
const testClient = new APCore({ config });
registerSysModules(testClient.registry, testClient.executor, config, null, {
overridesStore: new InMemoryOverridesStore(),
});
```
=== "Rust"
```rust
use apcore::APCore;
use apcore::sys_modules::overrides::{FileOverridesStore, InMemoryOverridesStore, OverridesStore};
use std::sync::Arc;
// Production: persist runtime overrides to disk
let store: Arc<dyn OverridesStore> = Arc::new(FileOverridesStore::new("/etc/apcore/overrides.yaml"));
let client = APCore::from_path("apcore.yaml")?
.with_overrides_store(store);
// Tests: in-memory only, no disk side effects
let test_store: Arc<dyn OverridesStore> = Arc::new(InMemoryOverridesStore::new());
let test_client = APCore::from_path("apcore.yaml")?
.with_overrides_store(test_store);
```
FileOverridesStore MUST treat a missing path on first run as an empty store (no error) — the file is created lazily on the first save() call. This makes first-run, fresh-install, and ephemeral CI environments behave identically to long-lived installations.
- No inputs
- No error for a missing backing file/path —
FileOverridesStoretreats that as an empty store (see above). Other failure modes (malformed backing data, a remote-backend connection failure) are implementation-defined and not pinned byconformance/fixtures/overrides_store.json.
- On success: mapping/dict/
HashMap— the full stored overrides map; empty when the store has never been saved to
- async: SDK-specific (see the
OverridesStoreprotocol/interface/trait table above) - thread_safe: not separately specified — a custom backend SHOULD document its own concurrency guarantees
- pure: true against a stable backing store — reads whatever the most recent
save()wrote, without mutating it - idempotent: true
mapping(mapping/dict/HashMap, required) — the entire overrides map to persist. The surface is the whole map, not a single key: a caller changing one key MUST read the current map viaload(), modify it, andsave()the result (decision D-47).
- Not normatively pinned beyond the D-47 surface fixed by
conformance/fixtures/overrides_store.json; a backend-specific failure (disk write error, remote-backend connection failure) is implementation-defined
- On success: void/None/() —
FileOverridesStorecreates the backing file lazily on the firstsave()call if it did not already exist
- async: SDK-specific
- thread_safe: not separately specified
- pure: false — replaces the entire persisted overrides map
- idempotent: true — saving the same
mappingtwice produces the same persisted state
System control modules that modify state MUST record an audit entry for every change.
system.control.update_config,system.control.reload_module, andsystem.control.toggle_featureMUST extract the caller identity fromcontext.identityand record it in a structured audit entry.- Each audit entry MUST contain:
timestamp,module_id(the target),action(update_config/reload_module/toggle_feature),actor_id(fromcontext.identity.id),actor_type(fromcontext.identity.type),change(before/after for config; enabled/disabled for toggle; module_version for reload). - Implementations MUST support an
AuditStoreinterface withappend(entry)andquery(module_id?, actor_id?, since?) → List[AuditEntry]. - When no AuditStore is configured, audit entries SHOULD be logged at INFO level and discarded (not stored).
AuditEntry:
timestamp: str # ISO 8601
action: enum # update_config | reload_module | toggle_feature
target_module_id: str
actor_id: str
actor_type: str # user | service | agent | api_key | system
trace_id: str
change:
before: any # previous value / null
after: any # new value / null=== "Python"
```python
from apcore import APCore
from apcore.config import Config
from apcore.sys_modules.audit import InMemoryAuditStore
config = Config.load("apcore.yaml")
audit_store = InMemoryAuditStore()
# The store is a register_sys_modules() parameter, not an APCore ctor param.
client = APCore(config=config)
register_sys_modules(client.registry, client.executor, config, audit_store=audit_store)
# After a control call, query the audit log
await client.executor.call_async(
"system.control.toggle_feature",
{"module_id": "risky.module", "enabled": False, "reason": "maintenance"},
context,
)
entries = audit_store.query(module_id="risky.module")
# entries[0].actor_id == context.identity.id
# entries[0].change == {"before": True, "after": False}
```
=== "TypeScript"
```typescript
import { APCore, Config, InMemoryAuditStore, registerSysModules } from 'apcore-js';
const config = Config.load('apcore.yaml');
const auditStore = new InMemoryAuditStore();
// The store is a registerSysModules() option, not an APCore ctor option.
const client = new APCore({ config });
registerSysModules(client.registry, client.executor, config, null, { auditStore });
// After a control call, query the audit log
await client.executor.call(
'system.control.toggle_feature',
{ module_id: 'risky.module', enabled: false, reason: 'maintenance' },
context,
);
const entries = await auditStore.query({ moduleId: 'risky.module' });
// entries[0].actorId === context.identity.id
// entries[0].change === { before: true, after: false }
```
=== "Rust"
```rust
use apcore::APCore;
use apcore::sys_modules::audit::InMemoryAuditStore;
use std::sync::Arc;
use serde_json::json;
let audit_store = Arc::new(InMemoryAuditStore::new());
// There is no APCore::with_audit_store — pass the store to
// register_sys_modules_with_options via SysModulesOptions.
let client = APCore::from_path("apcore.yaml")?;
// After a control call, query the audit log
client.executor().call(
"system.control.toggle_feature",
json!({ "module_id": "risky.module", "enabled": false, "reason": "maintenance" }),
None,
None,
).await?;
let entries = audit_store.query(Some("risky.module"), None, None)?;
// entries[0].actor_id == context.identity.id
// entries[0].change.before == Some(json!(true)), entries[0].change.after == Some(json!(false))
```
Control modules (system.control.update_config, system.control.toggle_feature, system.control.reload_module) MUST include caller_id and (if present) a redacted identity snapshot in their emitted audit events. When the caller is unauthenticated, caller_id MUST default to "@external".
This requirement complements the structured audit-store contract in §1.2. While §1.2 governs the persisted AuditEntry shape, this section governs the event payload that is published on the event bus (e.g., apcore.config.updated, apcore.module.toggled, apcore.module.reloaded) so that real-time subscribers see the same identity context the audit store retains.
- The event payload (
event.data) MUST carry acaller_idstring field. - When
context.caller_idisNone/null/"", the payloadcaller_idMUST be the literal string"@external". - When
context.identityis set, the payload MUST include anidentityobject containingid,type, and (optionally)display_name. Any field markedx-sensitive: truein the Identity schema MUST be redacted before inclusion (replaced with"<redacted>"). - When
context.identityisNone/null, the payload MUST NOT contain anidentityfield (omit, do not emitnull). - Implementations MUST NOT include raw bearer tokens, API keys, or other credential material in the audit event payload — only the redacted Identity snapshot is permitted.
=== "Python"
```python
# Emitted by system.control.update_config when context.caller_id and
# context.identity are populated.
event = ApCoreEvent(
event_type="apcore.config.updated",
module_id="system.control.update_config",
timestamp="2026-05-03T12:00:00Z",
severity="info",
data={
"key": "executor.default_timeout",
"old_value": 30000,
"new_value": 60000,
"reason": "increase timeout",
"caller_id": "ops.console",
"identity": {
"id": "user-42",
"type": "user",
"display_name": "alice@example.com",
},
},
)
# Unauthenticated caller — caller_id defaults to @external, identity omitted.
event = ApCoreEvent(
event_type="apcore.module.toggled",
module_id="system.control.toggle_feature",
timestamp="2026-05-03T12:00:00Z",
severity="info",
data={
"module_id": "risky.module",
"enabled": False,
"reason": "incident-1234",
"caller_id": "@external",
},
)
```
=== "TypeScript"
```typescript
// Emitted by system.control.update_config when context.caller_id and
// context.identity are populated.
// ApCoreEvent is an interface — this is the emitted payload shape.
const event: ApCoreEvent = {
eventType: "apcore.config.updated",
moduleId: "system.control.update_config",
timestamp: "2026-05-03T12:00:00Z",
severity: "info",
data: {
key: "executor.default_timeout",
old_value: 30000,
new_value: 60000,
reason: "increase timeout",
caller_id: "ops.console",
identity: {
id: "user-42",
type: "user",
display_name: "alice@example.com",
},
},
};
// Unauthenticated caller — caller_id defaults to @external, identity omitted.
const externalEvent: ApCoreEvent = {
eventType: "apcore.module.toggled",
moduleId: "system.control.toggle_feature",
timestamp: "2026-05-03T12:00:00Z",
severity: "info",
data: {
module_id: "risky.module",
enabled: false,
reason: "incident-1234",
caller_id: "@external",
},
};
```
=== "Rust"
```rust
use apcore::events::ApCoreEvent;
use serde_json::json;
// Emitted by system.control.update_config when context.caller_id and
// context.identity are populated.
let event = ApCoreEvent {
event_type: "apcore.config.updated".to_string(),
module_id: "system.control.update_config".to_string(),
timestamp: "2026-05-03T12:00:00Z".to_string(),
severity: "info".to_string(),
data: json!({
"key": "executor.default_timeout",
"old_value": 30000,
"new_value": 60000,
"reason": "increase timeout",
"caller_id": "ops.console",
"identity": {
"id": "user-42",
"type": "user",
"display_name": "alice@example.com"
}
}),
};
// Unauthenticated caller — caller_id defaults to @external, identity omitted.
let external_event = ApCoreEvent {
event_type: "apcore.module.toggled".to_string(),
module_id: "system.control.toggle_feature".to_string(),
timestamp: "2026-05-03T12:00:00Z".to_string(),
severity: "info".to_string(),
data: json!({
"module_id": "risky.module",
"enabled": false,
"reason": "incident-1234",
"caller_id": "@external"
}),
};
```
When both an AuditStore (§1.2) and the event bus are configured, implementations MUST populate both surfaces from the same in-memory snapshot of caller_id + identity so the persisted AuditEntry.actor_id and the event payload caller_id agree. Implementations MUST NOT drop the audit event when no AuditStore is configured — the event bus is the minimum surface for contextual auditing.
- When
observability.prometheus.enabled: true, the UsageCollector MUST expose its data via the/metricsendpoint established in observability hardening (§ Observability Hardening 1.6). - The UsageCollector MUST emit these additional Prometheus metrics:
apcore_usage_calls_total{module_id, status}— counterapcore_usage_error_rate{module_id}— gauge (0.0–1.0)apcore_usage_p50_latency_ms{module_id},apcore_usage_p95_latency_ms{module_id},apcore_usage_p99_latency_ms{module_id}— gauges
- The Prometheus exporter MUST call
collector.get_module_stats()and transform to the text format; MUST NOT block the HTTP handler for more thanexport_timeout_ms(default 1000ms).
observability:
prometheus:
enabled: true
export_timeout_ms: 1000 # default; controls UsageCollector export budget=== "Python"
```python
from apcore import APCore
from apcore.config import Config
config = Config.load("apcore.yaml")
# observability.prometheus.enabled: true in apcore.yaml
client = APCore(config=config)
# UsageCollector metrics are now included in GET /metrics:
# apcore_usage_calls_total{module_id="math.add",status="success"} 5000
# apcore_usage_error_rate{module_id="math.add"} 0.0004
# apcore_usage_p99_latency_ms{module_id="math.add"} 45.0
```
=== "TypeScript"
```typescript
import { APCore, Config } from 'apcore-js';
const config = Config.load('apcore.yaml');
// observability.prometheus.enabled: true in apcore.yaml
const client = new APCore({ config });
// UsageCollector metrics are now included in GET /metrics:
// apcore_usage_calls_total{module_id="math.add",status="success"} 5000
// apcore_usage_error_rate{module_id="math.add"} 0.0004
// apcore_usage_p99_latency_ms{module_id="math.add"} 45.0
```
=== "Rust"
```rust
use apcore::APCore;
// observability.prometheus.enabled: true in apcore.yaml
let client = APCore::from_path("apcore.yaml")?;
// UsageCollector metrics are now included in GET /metrics:
// apcore_usage_calls_total{module_id="math.add",status="success"} 5000
// apcore_usage_error_rate{module_id="math.add"} 0.0004
// apcore_usage_p99_latency_ms{module_id="math.add"} 45.0
```
Currently system.control.reload_module reloads a single module by ID. The optional path_filter input restricts re-discovery to module IDs matching a glob pattern, enabling partial reloads (e.g., only executor.email.*) instead of a full registry sweep.
- Implementations MUST support an optional
path_filterinput field onsystem.control.reload_modulethat accepts a glob pattern (e.g.,executor.*,analytics.reports.*). When specified, the module MUST restrict re-discovery to module IDs matching the pattern and reload only those modules. path_filterandmodule_idMUST be mutually exclusive. If both are provided, implementations MUST raise aMODULE_RELOAD_CONFLICTerror.- A
path_filterthat matches zero modules MUST be a no-op:reloaded_modulesis the empty list and no error is raised. - When
path_filteris omitted andmodule_idis omitted, implementations MUST raiseInvalidInputError(one of the two MUST be present). - Reload order for multiple matches MUST follow the dependency topological order (leaf modules first, then modules that depend on them).
| Field | Type | Default | Description |
|---|---|---|---|
module_id |
string | (one of required) | Single module to reload (mutually exclusive with path_filter) |
path_filter |
string | (one of required) | Glob pattern for bulk reload (mutually exclusive with module_id) |
reload_dependents |
bool | false |
When true, also reload modules that depend on matched modules |
reason |
string | (required) | Audit reason |
=== "Python"
```python
from apcore import APCore
from apcore.config import Config
config = Config.load("apcore.yaml")
client = APCore(config=config)
# Reload all executor modules
result = await client.executor.call_async(
"system.control.reload_module",
{"path_filter": "executor.*", "reload_dependents": False, "reason": "deploy"},
context,
)
# result["reloaded_modules"] == ["executor.email.send", "executor.math.add", ...]
# Reload a single module by ID (existing behavior unchanged)
result = await client.executor.call_async(
"system.control.reload_module",
{"module_id": "executor.email.send", "reason": "hotfix"},
context,
)
```
=== "TypeScript"
```typescript
import { APCore, Config } from 'apcore-js';
const config = Config.load('apcore.yaml');
const client = new APCore({ config });
// Reload all executor modules
const result = await client.executor.call(
'system.control.reload_module',
{ path_filter: 'executor.*', reload_dependents: false, reason: 'deploy' },
context,
);
// result.reloaded_modules === ['executor.email.send', 'executor.math.add', ...]
// Passing both fields raises MODULE_RELOAD_CONFLICT
// await client.executor.call('system.control.reload_module',
// { module_id: 'x', path_filter: 'y.*', reason: 'test' }, context);
// → throws ModuleReloadConflictError
```
=== "Rust"
```rust
use apcore::APCore;
use serde_json::json;
let client = APCore::from_path("apcore.yaml")?;
// Reload all executor modules
let result = client.executor().call(
"system.control.reload_module",
json!({ "path_filter": "executor.*", "reload_dependents": false, "reason": "deploy" }),
None,
None,
).await?;
// result["reloaded_modules"] contains the list of reloaded module IDs
```
The Rust SDK additionally exposes Config::reload_from_disk() for refreshing static configuration (the apcore.yaml base file plus any overrides_path overlay) without restarting the binary. This is distinct from system.control.reload_module, which reloads module code; reload_from_disk reloads only the configuration tree.
Rationale: Rust applications run as long-lived single binaries with no equivalent of Python's importlib reload or TypeScript's require.cache invalidation. Operators need a way to re-read the YAML files to pick up SRE-driven config edits (timeouts, feature flags, redaction rules) without taking the process down. Python and TypeScript can already achieve this by re-running Config.load() and re-attaching it to the client; Rust's borrow rules make that pattern awkward, so the SDK provides an explicit API.
Normative rules (Rust-only):
Config::reload_from_disk()MUST re-read the original YAML path passed toConfig::load/Config::from_pathand apply any configuredsys_modules.control.overrides_pathoverlay on top.- The reload MUST be atomic: either the new config fully replaces the old one or, on parse error, the old config remains active and the call MUST return
Err(ConfigError). reload_from_diskMUST emitapcore.config.reloadedvia theEventEmitter(if events are enabled) on success.- The reload MUST NOT mutate
sys_modules.enabledpost-startup: that key is restricted (seesystem.control.update_configrestrictions) and MUST be ignored if changed on disk after process start. - Python and TypeScript SDKs do not require this method; their callers re-construct
Configand rebuildAPCoreto achieve equivalent behavior.
use apcore::config::Config;
use apcore::APCore;
use std::sync::Arc;
let config = Arc::new(Config::from_path("apcore.yaml")?);
let client = APCore::new(config.clone())?;
// ... process runs; SRE edits apcore.yaml on disk ...
// Re-read YAML + overrides without restarting the binary.
config.reload_from_disk()?;
// Subscribers to apcore.config.reloaded see the new values.
// In-flight calls continue under the snapshot they started with.- Python:
register_sys_modules()MUST accept afail_on_error: bool = Falseparameter. WhenTrue, any system module registration failure MUST raise immediately. WhenFalse(default), failures MUST be logged at ERROR level but execution continues. - TypeScript:
registerSysModules()MUST acceptfailOnError: boolean = falsewith the same behavior. - Rust:
register_sys_modules()MUST returnResult<SysModulesContext, SysModuleError>instead of returningOptionor panicking. The caller MUST handle the Result. TheOkarm carries the same context the Python/TypeScript call returns — it is not unit.
=== "Python"
```python
from apcore.sys_modules.registration import register_sys_modules
# Default: log errors and continue
context = register_sys_modules(
registry=registry,
executor=executor,
config=config,
fail_on_error=False, # default — errors logged at ERROR, execution continues
)
# Strict: raise immediately on any failure
try:
context = register_sys_modules(
registry=registry,
executor=executor,
config=config,
fail_on_error=True,
)
except SysModuleRegistrationError as exc:
print(f"System module registration failed: {exc}")
raise SystemExit(1)
```
=== "TypeScript"
```typescript
import { registerSysModules, SysModuleRegistrationError } from 'apcore-js';
// Default: log errors and continue
const context = await registerSysModules({
registry,
executor,
config,
failOnError: false, // default — errors logged at ERROR, execution continues
});
// Strict: raise immediately on any failure
try {
const context = await registerSysModules({
registry,
executor,
config,
failOnError: true,
});
} catch (err) {
if (err instanceof SysModuleRegistrationError) {
console.error(`System module registration failed: ${err.message}`);
process.exit(1);
}
throw err;
}
```
=== "Rust"
```rust
use apcore::sys_modules::{register_sys_modules, SysModuleError};
// register_sys_modules always returns Result — caller MUST handle it
let context = register_sys_modules(®istry, &executor, &config)?;
// Explicit match for fine-grained handling
match register_sys_modules(®istry, &executor, &config) {
Ok(ctx) => {
// All system modules registered successfully
serve(ctx).await;
}
Err(SysModuleError::RegistrationFailed { module_id, source }) => {
eprintln!("Failed to register system module {module_id}: {source}");
std::process::exit(1);
}
}
```
| Parameter | Type | Required | Description |
|---|---|---|---|
executor |
Executor |
Yes | The executor to register modules on. |
registry |
Registry |
Yes | Registry to register system modules into. |
config |
Config |
Yes | Config instance; reads sys_modules.* keys. |
metrics_collector |
MetricsCollector | None |
No | If None, a new one is created and attached. |
fail_on_error |
bool |
No (default False) |
Whether to raise on registration failure. [Python/TypeScript only] |
| Code | Condition |
|---|---|
SYS_MODULE_REGISTRATION_FAILED |
A system module failed to register (only raised when fail_on_error=True in Python/TypeScript; always returned as Err in Rust). |
- On success:
SysModulesContext— all system modules registered. - Rust:
Result<SysModulesContext, SysModuleError>— theOkarm carries the sameSysModulesContext, so the return is uniform across all three SDKs.
- async: false
- thread_safe: false — call once at startup before serving requests
- pure: false — registers modules into executor
- idempotent: false — registering twice causes
MODULE_ALREADY_REGISTERED