Skip to content

Latest commit

 

History

History
1665 lines (1271 loc) · 62.2 KB

File metadata and controls

1665 lines (1271 loc) · 62.2 KB
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.

System Modules

Type: Implementation guide. Normative spec: PROTOCOL_SPEC §5 Module Specification (system module conventions).

Overview

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).

Requirements

Health Monitoring

  • 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.

Manifest & Discovery

  • system.manifest.module — Single module introspection (schema, annotations, tags, source path).
  • system.manifest.full — Full registry manifest with filtering by tags and prefix.

Usage Analytics

  • system.usage.summary — Usage statistics across all modules with trend detection.
  • system.usage.module — Per-module usage detail with caller breakdown and hourly distribution.

Runtime Control

  • 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.

Module Reference

system.health.summary

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

Contract: system.health.summary

Inputs

  • error_rate_threshold: float, optional, default 0.01
  • include_healthy: bool, optional, default true

Errors

  • No errors under normal operation — standard input-schema validation applies to malformed field types

Returns

  • On success: dict{project, summary, modules[]} per the Output shape above

Properties

  • 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

system.health.module

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"
    }
  ]
}

Contract: system.health.module

Inputs

  • module_id: string, required
    • reject_with: ModuleNotFoundError if module_id is not registered
  • error_limit: int, optional, default 10

Errors

  • ModuleNotFoundErrormodule_id is not registered

Returns

  • On success: dict — per the Output shape above

Properties

  • idempotent: true
  • thread_safe: true
  • async: false
  • pure: true — read-only lookup over already-collected call statistics
  • reentrant: true

system.manifest.module

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": {}
}

Contract: system.manifest.module

Inputs

  • module_id: string, required
    • reject_with: ModuleNotFoundError if module_id is not registered

Errors

  • ModuleNotFoundErrormodule_id is not registered

Returns

  • On success: dict — per the Output shape above

Properties

  • idempotent: true
  • thread_safe: true
  • async: false
  • pure: true — read-only lookup over registry metadata
  • reentrant: true

system.manifest.full

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": [ ... ]
}

Contract: system.manifest.full

Inputs

  • include_schemas: bool, optional, default true
  • include_source_paths: bool, optional, default true
  • prefix: string, optional — filters by module ID prefix
  • tags: list[string], optional — filters by tags; all listed tags must match

Errors

  • No errors under normal operation

Returns

  • On success: dict{project_name: str, module_count: int, modules: [...]}

Properties

  • idempotent: true
  • thread_safe: true
  • async: false
  • pure: true — read-only lookup over registry metadata
  • reentrant: true

system.usage.summary

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.

period is a filter, not an echo. total_calls, total_errors and every field of every modules[] entry MUST be computed over [now − period, now]. Echoing period back 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 in schemas/sys-usage-summary.schema.json.

Contract: system.usage.summary

Inputs

  • period: string, optional, default "24h"
    • validation: MUST match ^[1-9][0-9]*[hd]$
    • reject_with: SCHEMA_VALIDATION_ERROR — malformed value fails input_schema pattern validation

Errors

  • SCHEMA_VALIDATION_ERRORperiod does not match the required pattern

Returns

  • 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)

Properties

  • idempotent: true
  • thread_safe: true
  • async: false
  • pure: true — reads already-collected usage data and computes trend classification; no mutation
  • reentrant: true

system.usage.module

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):

  • hour is the UTC bucket key YYYY-MM-DDTHHnot YYYY-MM-DDTHH:00:00Z. This is the key UsageCollector produces in every SDK; the module layer MUST NOT reformat it.
  • Exactly 24 entries, covering now−23h .. now, ascending by hour, 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. period filters 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.

Contract: system.usage.module

Inputs

  • module_id: string, required
    • reject_with: ModuleNotFoundError if module_id is not registered
  • period: string, optional, default "24h"
    • validation: MUST match ^[1-9][0-9]*[hd]$ — same grammar and filter semantics as system.usage.summary
    • reject_with: SCHEMA_VALIDATION_ERROR

Errors

  • ModuleNotFoundErrormodule_id is not registered
  • SCHEMA_VALIDATION_ERRORperiod does not match the required pattern

Returns

  • On success: dict — per the Output shape above, including the fixed 24-entry hourly_distribution and nearest-rank p99_latency_ms

Properties

  • idempotent: true
  • thread_safe: true
  • async: false
  • pure: true — read-only over already-collected usage data
  • reentrant: true

system.control.update_config

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.updated event.

Contract: system.control.update_config

Inputs

  • 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")

Preconditions

  • key must not be in the restricted keys set (currently: sys_modules.enabled)
    • reject_with: ModuleError(code=CONFIG_KEY_RESTRICTED)
  • If key has a registered constraint, value must satisfy it; checked immediately after Config.set
    • reject_with: ConfigErrorConfig is rolled back to old_value before raising

Side Effects (ordered)

  1. Read current value of key from Config (captures old_value)
  2. Set key to value in Config (in-memory only; not persisted to YAML)
  3. Validate constraint for key if one exists; on failure, roll back and raise ConfigError
  4. Emit apcore.config.updated event via EventEmitter (values masked for sensitive keys)
  5. Log change at INFO level (values masked for sensitive keys)

Postconditions

  • On success: config.get(key) returns value
  • On ConfigError: config.get(key) returns the original old_value (atomically rolled back)

Errors

  • InvalidInputErrorkey is absent or empty; or reason is absent or empty
  • ModuleError(code=CONFIG_KEY_RESTRICTED)key is in the restricted set
  • ConfigErrorvalue violates a registered constraint; Config rolled back before raising

Returns

  • On success: dict{success: true, key: str, old_value: any, new_value: any}
    • old_value and new_value replaced with redaction sentinel for sensitive key segments

Properties

  • idempotent: false — repeated calls with different values produce different state
  • thread_safe: false — Config.set is not internally locked; concurrent callers must serialize
  • async: false
  • pure: false — mutates Config state and emits an event
  • reentrant: false

system.control.reload_module

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.

Contract: system.control.reload_module

Inputs

  • 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")

Preconditions

  • module_id must be present in the Registry before reload begins
    • reject_with: ModuleNotFoundError(module_id=module_id)

Side Effects (ordered)

  1. Read current module from Registry to capture previous_version
  2. Call module.on_suspend() if the method is defined — captures suspended state; errors are logged at ERROR and suppressed (best-effort)
  3. Call registry.safe_unregister(module_id) — drains in-flight calls then removes the module
  4. Call registry.discover() to reload module source from disk; if module_id is absent after discovery, raise ReloadFailedError
  5. Call registry.register_internal(module_id, new_module) to re-register the freshly loaded module
  6. 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)
  7. Emit apcore.module.reloaded event via EventEmitter
  8. Log reload at INFO level

Postconditions

  • On success: registry.get(module_id) returns a freshly loaded module instance
  • If step 4 raises, module_id is unregistered and callers must handle the partial state

Errors

  • InvalidInputErrormodule_id or reason is absent, wrong type, or empty
  • ModuleNotFoundErrormodule_id is not registered before reload begins
  • ReloadFailedErrorregistry.discover() raised or module_id was absent after discovery

Returns

  • On success: dict{success: true, module_id: str, previous_version: str, new_version: str, reload_duration_ms: float}

Properties

  • 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

system.control.toggle_feature

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.

Contract: system.control.toggle_feature

Inputs

  • 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 bool instance (not None, not a string or integer)
    • reject_with: InvalidInputError(message="'enabled' is required and must be a boolean")
  • reason: string, required
    • validation: non-empty string
    • reject_with: InvalidInputError(message="'reason' is required and must be a non-empty string")

Preconditions

  • module_id must be registered in the Registry
    • reject_with: ModuleNotFoundError(module_id=module_id)

Side Effects (ordered)

  1. Query Registry.has(module_id) (read-only existence check)
  2. Acquire internal lock on ToggleState._lock
  3. Mutate ToggleState._disabled set: add module_id when enabled=false; discard it when enabled=true
  4. Release ToggleState._lock
  5. Emit apcore.module.toggled event via EventEmitter
  6. Log toggle at INFO level

Postconditions

  • When enabled=false: is_module_disabled(module_id) returns true; calls raise ModuleDisabledError(code=MODULE_DISABLED)
  • When enabled=true: is_module_disabled(module_id) returns false; module calls proceed normally
  • Toggle state persists across module reload of the same instance (held by the owning APCore instance's ToggleState, external to Registry); it is isolated to that instance, not shared process-globally (issue #71)

Errors

  • InvalidInputErrormodule_id is absent/empty, enabled is absent/non-boolean, or reason is absent/empty
  • ModuleNotFoundErrormodule_id is not registered in the Registry

Returns

  • On success: dict{success: true, module_id: str, enabled: bool}

Properties

  • idempotent: true — toggling to the current state produces the same outcome
  • thread_safe: true — ToggleState uses threading.Lock to serialize all mutations
  • async: false
  • pure: false — mutates ToggleState, emits an event, writes a log entry
  • reentrant: false

Registration & Setup

Auto-Registration via register_sys_modules()

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:

  1. Check config.get("sys_modules.enabled") — exit if false.
  2. Create ErrorHistory and register ErrorHistoryMiddleware.
  3. Create UsageCollector and register UsageMiddleware.
  4. Register health, manifest, and usage modules.
  5. If sys_modules.events.enabled:
    • Create EventEmitter and PlatformNotifyMiddleware.
    • Register control modules.
    • Instantiate event subscribers from config.
    • Bridge registry events to EventEmitter.

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
}

Via APCore Client (Recommended)

=== "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"))?;
```

Configuration

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"

Dependencies

Internal

  • 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.

Permissions

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) 6system.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` |

Contract: checkModuleDisabled / check_module_disabled

Inputs

Parameter Type Required Description
module_id str Yes Fully-qualified module ID to inspect.
registry Registry Yes Registry that holds toggle state.

Errors

Code Condition
MODULE_DISABLED The module's current ToggleState is DISABLED.

Returns

None — raises/throws on disabled; returns normally when enabled.

Properties

  • Pure: Yes — reads registry state only, no side effects.
  • Throws: ModuleDisabledError (code MODULE_DISABLED).

Contract: isModuleDisabled / is_module_disabled

Inputs

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).

Errors

  • None — this function never raises; returns false for unknown module IDs.

Returns

booltrue if disabled, false if enabled or toggle state not set.

Properties

  • Pure: Yes — reads registry state only, no side effects.
  • Does not throw.

Testing Strategy

  • 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.

System Modules Hardening (Issue #45)

1.1 Config and Feature Toggle Persistence

Currently system.control.update_config and system.control.toggle_feature changes are in-memory only (see line 299).

Normative Rules

  • Implementations MUST support an optional overrides_path configuration field. When set, changes from system.control.update_config and system.control.toggle_feature MUST be persisted to overrides_path as 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 OverridesStore interface. Its surface is the whole map, not per key: load() → mapping and save(mapping) (decision D-47). A single-key change is a read-modify-write, which is what the system.control.* code paths do. An earlier revision of this line specified set(key, value) / get(key) / get_all() / delete(key); no SDK ever implemented it, and conformance/fixtures/overrides_store.json now pins the D-47 surface.
  • When no overrides_path or KV store is configured, the existing in-memory-only behavior MUST be preserved (backward compatible).

YAML Configuration

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.

Usage Examples

=== "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?;
```

Persistent Overrides — pluggable OverridesStore

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-jspackage.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.

Contract: OverridesStore.load

Inputs

  • No inputs

Errors

  • No error for a missing backing file/path — FileOverridesStore treats that as an empty store (see above). Other failure modes (malformed backing data, a remote-backend connection failure) are implementation-defined and not pinned by conformance/fixtures/overrides_store.json.

Returns

  • On success: mapping/dict/HashMap — the full stored overrides map; empty when the store has never been saved to

Properties

  • async: SDK-specific (see the OverridesStore protocol/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

Contract: OverridesStore.save

Inputs

  • 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 via load(), modify it, and save() the result (decision D-47).

Errors

  • 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

Returns

  • On success: void/None/() — FileOverridesStore creates the backing file lazily on the first save() call if it did not already exist

Properties

  • async: SDK-specific
  • thread_safe: not separately specified
  • pure: false — replaces the entire persisted overrides map
  • idempotent: true — saving the same mapping twice produces the same persisted state

1.2 Contextual Audit Trail

System control modules that modify state MUST record an audit entry for every change.

Normative Rules

  • system.control.update_config, system.control.reload_module, and system.control.toggle_feature MUST extract the caller identity from context.identity and 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 (from context.identity.id), actor_type (from context.identity.type), change (before/after for config; enabled/disabled for toggle; module_version for reload).
  • Implementations MUST support an AuditStore interface with append(entry) and query(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 Schema

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

Usage Examples

=== "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))
```

Contextual Auditing

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.

Normative rules

  • The event payload (event.data) MUST carry a caller_id string field.
  • When context.caller_id is None/null/"", the payload caller_id MUST be the literal string "@external".
  • When context.identity is set, the payload MUST include an identity object containing id, type, and (optionally) display_name. Any field marked x-sensitive: true in the Identity schema MUST be redacted before inclusion (replaced with "<redacted>").
  • When context.identity is None/null, the payload MUST NOT contain an identity field (omit, do not emit null).
  • 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.

Audit event payload shape

=== "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"
    }),
};
```

Relationship to §1.2 AuditStore

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.


1.3 Prometheus Exporter for UsageCollector

Normative Rules

  • When observability.prometheus.enabled: true, the UsageCollector MUST expose its data via the /metrics endpoint established in observability hardening (§ Observability Hardening 1.6).
  • The UsageCollector MUST emit these additional Prometheus metrics:
    • apcore_usage_calls_total{module_id, status} — counter
    • apcore_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 than export_timeout_ms (default 1000ms).

YAML Configuration

observability:
  prometheus:
    enabled: true
    export_timeout_ms: 1000   # default; controls UsageCollector export budget

Usage Examples

=== "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
```

1.4 Granular Reload via Path Filtering

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.

Normative Rules

  • Implementations MUST support an optional path_filter input field on system.control.reload_module that 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_filter and module_id MUST be mutually exclusive. If both are provided, implementations MUST raise a MODULE_RELOAD_CONFLICT error.
  • A path_filter that matches zero modules MUST be a no-op: reloaded_modules is the empty list and no error is raised.
  • When path_filter is omitted and module_id is omitted, implementations MUST raise InvalidInputError (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).

Updated Input for system.control.reload_module

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

Usage Examples

=== "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
```

Rust-only: Config::reload_from_disk()

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 to Config::load/Config::from_path and apply any configured sys_modules.control.overrides_path overlay 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_disk MUST emit apcore.config.reloaded via the EventEmitter (if events are enabled) on success.
  • The reload MUST NOT mutate sys_modules.enabled post-startup: that key is restricted (see system.control.update_config restrictions) and MUST be ignored if changed on disk after process start.
  • Python and TypeScript SDKs do not require this method; their callers re-construct Config and rebuild APCore to 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.

1.5 Startup Failure Handling

Normative Rules

  • Python: register_sys_modules() MUST accept a fail_on_error: bool = False parameter. When True, any system module registration failure MUST raise immediately. When False (default), failures MUST be logged at ERROR level but execution continues.
  • TypeScript: registerSysModules() MUST accept failOnError: boolean = false with the same behavior.
  • Rust: register_sys_modules() MUST return Result<SysModulesContext, SysModuleError> instead of returning Option or panicking. The caller MUST handle the Result. The Ok arm carries the same context the Python/TypeScript call returns — it is not unit.

Usage Examples

=== "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(&registry, &executor, &config)?;

// Explicit match for fine-grained handling
match register_sys_modules(&registry, &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);
    }
}
```

Contract: register_sys_modules

Inputs

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]

Errors

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).

Returns

  • On success: SysModulesContext — all system modules registered.
  • Rust: Result<SysModulesContext, SysModuleError> — the Ok arm carries the same SysModulesContext, so the return is uniform across all three SDKs.

Properties

  • 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