diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1e571..11c0954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`ephemeral.*` namespace + `discoverable` annotation pilot (apcore RFC `docs/spec/rfc-ephemeral-modules.md`, [#25](https://github.com/aiperceivable/apcore-python/issues/25))** — Pilot implementation **ahead of upstream RFC acceptance** (RFC is in `Draft / RFC` state). Reserves the `ephemeral.*` namespace for programmatically-registered modules synthesized at runtime by LLM-agent pipelines (e.g. ToolMaker, ACL 2025, arXiv 2502.11705). Filesystem discovery now refuses to register any ID that falls under `ephemeral.*` and raises `InvalidInputError` with code `INVALID_MODULE_ID`; the namespace is reachable only via `Registry.register()`. New `discoverable: bool = True` field on `ModuleAnnotations` — when `False`, the module is excluded from `Registry.list()` (default behaviour; `include_hidden=True` returns the full set), `Registry.iter()`, `Registry.module_ids`, and downstream manifest export, while remaining callable via `Registry.get()` / `Executor.execute()`. New `Registry.set_event_emitter(emitter)` opt-in: when wired, `ephemeral.*` registrations / unregistrations emit canonical `apcore.registry.module_registered` / `apcore.registry.module_unregistered` events whose `data` payload mirrors the D-35 contextual-audit shape (`caller_id` defaulting to `"@external"`, plus a redacted `identity` snapshot when `context.identity` is set). Without an emitter the same audit information is logged at INFO so it never silently disappears. `Registry.register()` / `Registry.unregister()` accept an optional `context=` keyword used solely to enrich those audit payloads. A soft `logging.warning(...)` fires when an `ephemeral.*` module is registered without `requires_approval=True` (per the RFC). Lifecycle is caller-managed via `Registry.unregister()`; TTL/GC sweeper and host-side sandboxing are deliberately out of scope for the v1 pilot. New top-level constant `EPHEMERAL_NAMESPACE_PREFIX` exported from `apcore.registry.registry`. **Pilot disclaimer:** the upstream RFC is not yet accepted; downstream SDKs (`apcore-typescript`, `apcore-rust`) will follow once Python pilot findings are reported back. + +### Changed + +- **iter-11 alignment with upstream apcore RFC `rfc-ephemeral-modules.md` (apcore commit [`81df336`](https://github.com/aiperceivable/apcore/commit/81df336))** — Tightens the `ephemeral.*` pilot against two new normative rules added during the RFC iter-11 reconciliation round: + 1. **Audit-event single-emit rule** (RFC §"Audit-event single-emit rule"). The legacy `_bridge_registry_events` callback in `apcore.sys_modules.registration` now short-circuits for `ephemeral.*` module IDs so that exactly **one** `apcore.registry.module_registered` / `apcore.registry.module_unregistered` event is emitted per registration — the rich registry-side direct emit carrying the full D-35 contextual payload — instead of being followed by a second empty-payload copy from the bridge. Non-ephemeral registrations are unaffected (legacy bridge behaviour preserved for backwards compatibility). + 2. **`register_internal()` rejection** (RFC §"`register_internal()` interaction"). `Registry.register_internal()` now raises `ValueError` when called with an `ephemeral.*` module ID, directing the caller to `Registry.register()`. Rationale: namespace → registration-mechanism is a 1:1 mapping; mixing the two paths blurs the audit-trail distinction between framework-emitted (`system.*`) and caller-emitted (`ephemeral.*`) modules. + ## [0.20.0] - 2026-05-05 ### Added diff --git a/src/apcore/module.py b/src/apcore/module.py index b0b3f5d..0eae2ec 100644 --- a/src/apcore/module.py +++ b/src/apcore/module.py @@ -68,6 +68,7 @@ def execute(self, inputs: dict[str, Any], context: Context) -> dict[str, Any]: . "cache_key_fields", "paginated", "pagination_style", + "discoverable", "extra", } @@ -88,6 +89,12 @@ class ModuleAnnotations: cache_key_fields: Input fields used to compute the cache key (None = all). paginated: Whether the module supports paginated results. pagination_style: Pagination strategy (default "cursor"). Accepts any string. + discoverable: Whether the module appears in enumeration surfaces + (``Registry.list``, manifest export, etc.). Default ``True``. + ``ephemeral.*`` modules SHOULD set this to ``False`` per the + ephemeral-modules RFC pilot. Hidden modules remain callable + through ``Registry.get`` / ``Executor.execute`` when the caller + already knows the module ID. extra: Extension dictionary for ecosystem package metadata. """ @@ -102,6 +109,7 @@ class ModuleAnnotations: cache_key_fields: tuple[str, ...] | None = None paginated: bool = False pagination_style: str = "cursor" + discoverable: bool = True extra: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: diff --git a/src/apcore/registry/registry.py b/src/apcore/registry/registry.py index 379f493..edc3955 100644 --- a/src/apcore/registry/registry.py +++ b/src/apcore/registry/registry.py @@ -119,6 +119,15 @@ def validate(self, module: Any) -> list[str]: RESERVED_WORDS = frozenset({"system", "internal", "core", "apcore", "plugin", "schema", "acl"}) +# Namespace reserved for programmatically-registered modules synthesized at +# runtime (see apcore RFC ``docs/spec/rfc-ephemeral-modules.md``). IDs in this +# namespace MUST be registered through :meth:`Registry.register` only; the +# filesystem discoverer rejects matching IDs because the namespace has no +# directory-rooted source of truth. The trailing dot is required so module IDs +# whose first segment merely *starts with* ``ephemeral`` (e.g. ``ephemerals``) +# are not falsely classified. +EPHEMERAL_NAMESPACE_PREFIX = "ephemeral." + __all__ = [ "Registry", "REGISTRY_EVENTS", @@ -126,11 +135,17 @@ def validate(self, module: Any) -> list[str]: "MAX_MODULE_ID_LENGTH", "DEFAULT_MODULE_VERSION", "RESERVED_WORDS", + "EPHEMERAL_NAMESPACE_PREFIX", "Discoverer", "ModuleValidator", ] +def _is_ephemeral(module_id: str) -> bool: + """Return True when ``module_id`` belongs to the reserved ephemeral.* namespace.""" + return module_id == "ephemeral" or module_id.startswith(EPHEMERAL_NAMESPACE_PREFIX) + + def _validate_module_id(module_id: str, *, allow_reserved: bool = False) -> None: """Validate a module ID against PROTOCOL_SPEC §2.7 in canonical order. @@ -330,6 +345,12 @@ def __init__( self._custom_discoverer: Discoverer | None = None self._custom_validator: ModuleValidator | None = None + # Optional EventEmitter wired in by callers that want registry-level + # audit events for ``ephemeral.*`` registrations (RFC pilot). When + # unset, ephemeral audit events are emitted to the standard logger + # instead so they are never silently dropped. + self._event_emitter: Any = None + # Safe hot-reload state (F09 / Algorithm A21) self._ref_counts: dict[str, int] = {} self._draining: set[str] = set() @@ -349,6 +370,25 @@ def set_validator(self, validator: ModuleValidator) -> None: """Set a custom module validator.""" self._custom_validator = validator + def set_event_emitter(self, emitter: Any) -> None: + """Wire an :class:`apcore.events.EventEmitter` for registry audit events. + + When set, ``ephemeral.*`` registrations (and their unregistrations) + emit ``apcore.registry.module_registered`` / + ``apcore.registry.module_unregistered`` events whose ``data`` payload + mirrors the D-35 contextual-auditing shape used by + ``system.control.*`` modules: ``caller_id`` (defaulting to + ``"@external"`` when no identity is attached) plus a redacted + identity snapshot when ``context.identity`` is set on the call site. + + Pilot scope (apcore RFC ``docs/spec/rfc-ephemeral-modules.md``): + only ``ephemeral.*`` registrations trigger registry-side emits. The + ``_bridge_registry_events`` helper in ``apcore.sys_modules.registration`` + continues to emit canonical events for all module registrations via + the callback pathway. + """ + self._event_emitter = emitter + # ----- Discovery ----- def discover( @@ -538,16 +578,44 @@ def _scan_roots(self, max_depth: int, follow_symlinks: bool) -> list[Any]: """Stage 1 — walk extension root(s) and return DiscoveredModule entries.""" has_namespace = any("namespace" in r for r in self._extension_roots) if len(self._extension_roots) > 1 or has_namespace: - return scan_multi_root( + discovered = scan_multi_root( roots=self._extension_roots, max_depth=max_depth, follow_symlinks=follow_symlinks, ) - root_path = Path(self._extension_roots[0]["root"]) - return scan_extensions( - root=root_path, - max_depth=max_depth, - follow_symlinks=follow_symlinks, + else: + root_path = Path(self._extension_roots[0]["root"]) + discovered = scan_extensions( + root=root_path, + max_depth=max_depth, + follow_symlinks=follow_symlinks, + ) + self._reject_ephemeral_discoveries(discovered) + return discovered + + @staticmethod + def _reject_ephemeral_discoveries(discovered: list[Any]) -> None: + """Reject filesystem-derived IDs that fall in the reserved ``ephemeral.*`` namespace. + + Per the apcore ephemeral-modules RFC pilot, ``ephemeral.*`` is reserved + for programmatically-registered modules synthesized at runtime. Any + filesystem layout that produces such an ID is a configuration error — + either the directory is misnamed or the namespace prefix is being + misused. + """ + offenders = [dm for dm in discovered if _is_ephemeral(getattr(dm, "canonical_id", ""))] + if not offenders: + return + ids = sorted({dm.canonical_id for dm in offenders}) + raise InvalidInputError( + message=( + "Filesystem discovery produced module ID(s) in the reserved " + f"'ephemeral.*' namespace: {ids}. The ephemeral.* namespace is " + "reserved for programmatically-registered modules and may only " + "be used via Registry.register(). Rename the offending " + "directory or extension namespace." + ), + code=ErrorCodes.INVALID_MODULE_ID, ) def _apply_path_filter( @@ -861,6 +929,7 @@ def register( version: str | None = None, metadata: dict[str, Any] | None = None, *, + context: Any = None, _skip_custom_validator: bool = False, ) -> None: """Manually register a module instance. @@ -870,6 +939,12 @@ def register( module: Module instance to register. version: Optional semver version string for versioned registration. metadata: Optional metadata dict (may include x-compatible-versions, x-deprecation). + context: Optional execution context. Used solely to enrich audit + events emitted for ``ephemeral.*`` registrations (RFC pilot). + When the context exposes ``caller_id`` / ``identity`` they + are folded into the audit-event payload (D-35 shape); when + absent the sentinel ``"@external"`` is used. Ignored for + non-ephemeral modules. _skip_custom_validator: Internal flag — when True, bypass the ``_custom_validator.validate`` call. Used by ``_discover_custom`` (which already validates inline at line @@ -889,6 +964,14 @@ def register( → duplicate. """ _validate_module_id(module_id, allow_reserved=False) + # ``ephemeral.*`` registrations only land via this programmatic + # entry point — the filesystem discoverer rejects matching IDs + # earlier (see ``_reject_ephemeral_discoveries``). When invoked + # here the RFC pilot recommends ``requires_approval=true`` so a + # human gates execution of agent-synthesized code. + ephemeral = _is_ephemeral(module_id) + if ephemeral: + self._warn_if_missing_approval(module_id, module) _ensure_schema_adapter(module) @@ -962,10 +1045,18 @@ def register( raise self._trigger_event("register", module_id, module) + if ephemeral: + self._emit_ephemeral_audit("apcore.registry.module_registered", module_id, context) - def unregister(self, module_id: str) -> bool: + def unregister(self, module_id: str, *, context: Any = None) -> bool: """Remove a module from the registry. + Args: + module_id: ID of the module to remove. + context: Optional execution context. Used solely to enrich the + audit event emitted for ``ephemeral.*`` unregistrations + (RFC pilot). Ignored for non-ephemeral modules. + Returns False if module was not registered. """ with self._lock: @@ -991,6 +1082,8 @@ def unregister(self, module_id: str) -> bool: logger.error("on_unload() failed for module '%s': %s", module_id, e) self._trigger_event("unregister", module_id, module) + if _is_ephemeral(module_id): + self._emit_ephemeral_audit("apcore.registry.module_unregistered", module_id, context) return True # ----- Query Methods ----- @@ -1050,14 +1143,36 @@ def has(self, module_id: str) -> bool: with self._lock: return module_id in self._modules - def list(self, tags: list[str] | None = None, prefix: str | None = None) -> list[str]: - """Return sorted list of unique registered module IDs, optionally filtered.""" + def list( + self, + tags: list[str] | None = None, + prefix: str | None = None, + *, + include_hidden: bool = False, + ) -> list[str]: + """Return sorted list of unique registered module IDs, optionally filtered. + + Args: + tags: When supplied, only modules carrying *all* of the given tags + are returned. + prefix: When supplied, only IDs starting with the prefix are returned. + include_hidden: When ``True``, modules whose + :class:`ModuleAnnotations` set ``discoverable=False`` are also + returned. Defaults to ``False`` so the apcore RFC's + ``discoverable`` annotation is honored on all enumeration + surfaces. Callers that legitimately need to see every module + ID (introspection tools, debug consoles) can pass + ``include_hidden=True``. + """ with self._lock: snapshot = dict(self._modules) meta_snapshot = dict(self._module_meta) ids = list(snapshot.keys()) + if not include_hidden: + ids = [mid for mid in ids if self._is_discoverable(mid, snapshot, meta_snapshot)] + if prefix is not None: ids = [mid for mid in ids if mid.startswith(prefix)] @@ -1078,23 +1193,76 @@ def has_all_tags(mid: str) -> bool: return sorted(ids) - def iter(self) -> Iterator[tuple[str, Any]]: - """Return an iterator of (module_id, module) tuples (snapshot-based).""" + def iter(self, *, include_hidden: bool = False) -> Iterator[tuple[str, Any]]: + """Return an iterator of (module_id, module) tuples (snapshot-based). + + Modules annotated ``discoverable=False`` are excluded by default. + Pass ``include_hidden=True`` to enumerate every registered module. + """ with self._lock: items = list(self._modules.items()) + meta_snapshot = dict(self._module_meta) + snapshot = dict(items) + if not include_hidden: + items = [(mid, mod) for mid, mod in items if self._is_discoverable(mid, snapshot, meta_snapshot)] return iter(items) @property def count(self) -> int: - """Number of registered modules.""" + """Number of registered modules (including hidden ones).""" with self._lock: return len(self._modules) @property def module_ids(self) -> list[str]: - """Sorted list of registered module IDs.""" + """Sorted list of registered module IDs (excludes ``discoverable=False`` modules). + + Modules annotated ``discoverable=False`` (per the apcore RFC pilot) + are filtered out. Use :meth:`list` with ``include_hidden=True`` when + the full set is required. + """ with self._lock: - return sorted(self._modules.keys()) + snapshot = dict(self._modules) + meta_snapshot = dict(self._module_meta) + return sorted(mid for mid in snapshot if self._is_discoverable(mid, snapshot, meta_snapshot)) + + @staticmethod + def _is_discoverable( + module_id: str, + modules: dict[str, Any], + module_meta: dict[str, dict[str, Any]], + ) -> bool: + """Return False when the module's annotations declare ``discoverable=False``. + + Resolution order matches :func:`merge_module_metadata`: + 1. The merged ``annotations`` slot in ``_module_meta`` (which + already accounts for YAML > code precedence). + 2. The module instance's ``annotations`` attribute (covers paths + that bypassed the merge, e.g. ``register_internal``). + + Anything other than an explicit ``False`` keeps the module visible — + the default ``True`` preserves backward compatibility. + """ + meta = module_meta.get(module_id) + if meta is not None: + ann = meta.get("annotations") + if ann is not None: + discoverable = getattr(ann, "discoverable", None) + if discoverable is None and isinstance(ann, dict): + discoverable = ann.get("discoverable") + if discoverable is False: + return False + if discoverable is not None: + return True + module = modules.get(module_id) + if module is None: + return True + ann = getattr(module, "annotations", None) + if ann is None: + return True + if isinstance(ann, dict): + return ann.get("discoverable", True) is not False + return getattr(ann, "discoverable", True) is not False def get_definition(self, module_id: str, version_hint: str | None = None) -> ModuleDescriptor | None: """Get a ModuleDescriptor for a registered module. Returns None if not found. @@ -1359,6 +1527,109 @@ def _trigger_event(self, event: str, module_id: str, module: Any) -> None: }, ) + # ----- Ephemeral namespace pilot (apcore RFC: rfc-ephemeral-modules) ----- + + @staticmethod + def _warn_if_missing_approval(module_id: str, module: Any) -> None: + """Soft-warn when an ephemeral.* module is registered without ``requires_approval=True``. + + Per the ephemeral-modules RFC pilot, agent-synthesized modules SHOULD + declare ``requires_approval: true`` so a human gates execution. The + registry only warns; it does not refuse the registration. The check + inspects both a code-level ``ModuleAnnotations`` instance and a + dict-style annotations attribute so all common shapes are caught. + """ + annotations = getattr(module, "annotations", None) + requires_approval = False + if isinstance(annotations, dict): + requires_approval = bool(annotations.get("requires_approval", False)) + elif annotations is not None: + requires_approval = bool(getattr(annotations, "requires_approval", False)) + if not requires_approval: + logger.warning( + "ephemeral.* module '%s' registered without requires_approval=True. " + "The apcore RFC docs/spec/rfc-ephemeral-modules.md recommends " + "setting ModuleAnnotations(requires_approval=True) so agent-" + "synthesized code does not run unattended.", + module_id, + ) + + def _build_ephemeral_audit_payload(self, context: Any) -> dict[str, Any]: + """Build the ``caller_id`` (+ optional ``identity``) D-35 payload fragment. + + Mirrors ``apcore.sys_modules.control._audit_payload_extras`` so audit + consumers can apply the same redaction rules they already use for + ``system.control.*`` events. + """ + # Lazy import to avoid pulling sys_modules at registry import time. + from apcore.sys_modules.control import _audit_payload_extras + + return _audit_payload_extras(context) + + def _emit_ephemeral_audit( + self, + event_type: str, + module_id: str, + context: Any, + ) -> None: + """Emit an audit event for an ephemeral.* register/unregister. + + When :meth:`set_event_emitter` has wired an emitter, the canonical + ``apcore.registry.module_registered`` / + ``apcore.registry.module_unregistered`` event is emitted with the + D-35 contextual-audit payload. When no emitter is wired the event + is logged at INFO so it never silently disappears — useful for the + v1 pilot where most callers will not have an emitter attached. + """ + try: + payload = self._build_ephemeral_audit_payload(context) + except Exception as e: # defensive: identity extraction must never break registration + logger.warning( + "Failed to extract audit payload for ephemeral '%s': %s. " + "Falling back to caller_id='@external'.", + module_id, + e, + ) + payload = {"caller_id": "@external"} + + emitter = self._event_emitter + if emitter is None: + logger.info( + "ephemeral audit event %s module_id=%s payload=%s " + "(no EventEmitter wired; call Registry.set_event_emitter to capture)", + event_type, + module_id, + payload, + ) + return + + # Lazy import — events package is optional at the registry layer. + try: + from datetime import datetime, timezone + + from apcore.events.emitter import ApCoreEvent + except Exception as e: # pragma: no cover - extremely defensive + logger.warning("Cannot import ApCoreEvent for ephemeral audit emit: %s", e) + return + + try: + emitter.emit( + ApCoreEvent( + event_type=event_type, + module_id=module_id, + timestamp=datetime.now(timezone.utc).isoformat(), + severity="info", + data=payload, + ) + ) + except Exception as e: + logger.error( + "EventEmitter.emit failed for ephemeral audit event %s on '%s': %s", + event_type, + module_id, + e, + ) + def get_callback_errors(self, event: str | None = None) -> dict[str, int] | int: """Return callback-exception counts per event. @@ -1690,8 +1961,22 @@ def register_internal(self, module_id: str, module: Any) -> None: Raises: InvalidInputError: If module_id is empty, malformed, exceeds the length limit, or is already registered. + ValueError: If module_id falls under the ``ephemeral.*`` namespace + (must use :meth:`register` instead). RuntimeError: If module.on_load() fails (propagated). """ + # Per apcore RFC docs/spec/rfc-ephemeral-modules.md + # "register_internal() interaction": ephemeral.* IDs MUST be rejected + # here. Namespace → registration-mechanism is a 1:1 mapping; mixing + # blurs the audit-trail distinction between framework-emitted + # (system.*) and caller-emitted (ephemeral.*) modules. + if module_id.startswith(EPHEMERAL_NAMESPACE_PREFIX): + raise ValueError( + f"ephemeral.* module IDs must be registered via Registry.register(), " + f"not register_internal() (got: {module_id!r}). See apcore " + f"docs/spec/rfc-ephemeral-modules.md " + f"§'register_internal() interaction' for rationale." + ) _validate_module_id(module_id, allow_reserved=True) _ensure_schema_adapter(module) diff --git a/src/apcore/sys_modules/registration.py b/src/apcore/sys_modules/registration.py index a4488bb..6789b58 100644 --- a/src/apcore/sys_modules/registration.py +++ b/src/apcore/sys_modules/registration.py @@ -660,6 +660,14 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat() def on_register(module_id: str, module: Any) -> None: + # Single-emit rule for ephemeral.* registrations: the registry-side + # direct emit (Registry.set_event_emitter / _emit_ephemeral_audit) + # already fires the canonical event with the full D-35 contextual + # payload. Skipping the empty-payload bridge emit here avoids dual + # emission for the same event_type. See apcore RFC + # docs/spec/rfc-ephemeral-modules.md "Audit-event single-emit rule". + if module_id.startswith("ephemeral."): + return ts = _now() emitter.emit( ApCoreEvent( @@ -682,6 +690,10 @@ def on_register(module_id: str, module: Any) -> None: ) def on_unregister(module_id: str, module: Any) -> None: + # Mirror the single-emit rule for unregistrations. See apcore RFC + # docs/spec/rfc-ephemeral-modules.md "Audit-event single-emit rule". + if module_id.startswith("ephemeral."): + return ts = _now() emitter.emit( ApCoreEvent( diff --git a/tests/registry/test_ephemeral_modules.py b/tests/registry/test_ephemeral_modules.py new file mode 100644 index 0000000..0d02de6 --- /dev/null +++ b/tests/registry/test_ephemeral_modules.py @@ -0,0 +1,518 @@ +"""Tests for the ``ephemeral.*`` namespace + ``discoverable`` annotation pilot. + +Pilots the apcore RFC ``docs/spec/rfc-ephemeral-modules.md``. Covers: + +* Filesystem discovery rejection of ``ephemeral.*`` IDs. +* ``Registry.register()`` happy path for ``ephemeral.*`` IDs. +* Soft warning when ``requires_approval`` is missing on registration. +* ``discoverable=False`` filtered out of ``list`` / ``module_ids`` / ``iter``. +* Audit-event emission on register / unregister with D-35 contextual payload. + +The RFC is in ``Draft / RFC`` state; these tests ratchet the implementation +against the proposed semantics and will be promoted to canonical conformance +fixtures after the upstream RFC is accepted. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +import pytest +from pydantic import BaseModel + +from apcore.errors import InvalidInputError +from apcore.events.emitter import ApCoreEvent, EventEmitter +from apcore.module import ModuleAnnotations +from apcore.registry.registry import EPHEMERAL_NAMESPACE_PREFIX, Registry + + +# --------------------------------------------------------------------------- +# Helper schemas + module classes +# --------------------------------------------------------------------------- + + +class _In(BaseModel): + value: str = "" + + +class _Out(BaseModel): + result: str = "" + + +class _EphemeralModuleApproved: + """Minimal ephemeral module with the recommended ``requires_approval=True``.""" + + input_schema = _In + output_schema = _Out + description = "Approved ephemeral module" + annotations = ModuleAnnotations(requires_approval=True, discoverable=False) + + def execute(self, inputs: dict[str, Any], _ctx: Any = None) -> dict[str, Any]: + return {"result": inputs.get("value", "")} + + +class _EphemeralModuleNoApproval: + """Ephemeral module that omits ``requires_approval`` — should trigger warning.""" + + input_schema = _In + output_schema = _Out + description = "Ephemeral without approval" + annotations = ModuleAnnotations(discoverable=False) + + def execute(self, inputs: dict[str, Any], _ctx: Any = None) -> dict[str, Any]: + return {"result": "ok"} + + +class _DiscoverableHidden: + """Non-ephemeral module that opts out of enumeration.""" + + input_schema = _In + output_schema = _Out + description = "Internal-only utility" + annotations = ModuleAnnotations(discoverable=False) + + def execute(self, inputs: dict[str, Any], _ctx: Any = None) -> dict[str, Any]: + return {"result": "hidden"} + + +class _DiscoverableVisible: + """Standard module — discoverable=True by default.""" + + input_schema = _In + output_schema = _Out + description = "Standard visible module" + + def execute(self, inputs: dict[str, Any], _ctx: Any = None) -> dict[str, Any]: + return {"result": "visible"} + + +# --------------------------------------------------------------------------- +# Capturing subscriber for audit-event tests +# --------------------------------------------------------------------------- + + +@dataclass +class _CapturingSubscriber: + events: list[ApCoreEvent] = field(default_factory=list) + + async def on_event(self, event: ApCoreEvent) -> None: + self.events.append(event) + + +# --------------------------------------------------------------------------- +# Identity stub for D-35 payload tests +# --------------------------------------------------------------------------- + + +class _IdentityStub: + def __init__(self, **kwargs: Any) -> None: + self.id = kwargs.get("id") + self.type = kwargs.get("type") + self.roles = kwargs.get("roles", []) + self.attrs = kwargs.get("attrs", {}) + + +class _ContextStub: + def __init__(self, caller_id: str | None = None, identity: Any = None) -> None: + self.caller_id = caller_id + self.identity = identity + + +# =========================================================================== +# 1. Namespace reservation — filesystem discovery +# =========================================================================== + + +class TestNamespaceReservation: + def test_filesystem_discovery_rejects_ephemeral_root(self, tmp_path: Any) -> None: + """A directory named ``ephemeral/`` produces canonical IDs ``ephemeral.`` — + the registry MUST refuse to register them via the discovery path.""" + root = tmp_path / "extensions" + eph_dir = root / "ephemeral" + eph_dir.mkdir(parents=True) + # Minimal valid module file + (eph_dir / "synth_tool.py").write_text( + "from pydantic import BaseModel\n" + "class _I(BaseModel):\n" + " value: str = ''\n" + "class _O(BaseModel):\n" + " result: str = ''\n" + "class SynthTool:\n" + " input_schema = _I\n" + " output_schema = _O\n" + " description = 'fs-derived'\n" + " def execute(self, inputs, ctx=None):\n" + " return {'result': 'x'}\n", + encoding="utf-8", + ) + registry = Registry(extensions_dir=str(root)) + with pytest.raises(InvalidInputError, match="ephemeral"): + registry.discover() + + def test_filesystem_discovery_rejects_ephemeral_via_namespace_prefix(self, tmp_path: Any) -> None: + """A multi-root extension with ``namespace='ephemeral'`` would also produce + forbidden IDs and MUST be rejected.""" + root = tmp_path / "agent_tools" + root.mkdir() + (root / "tool.py").write_text( + "from pydantic import BaseModel\n" + "class _I(BaseModel):\n" + " value: str = ''\n" + "class _O(BaseModel):\n" + " result: str = ''\n" + "class Tool:\n" + " input_schema = _I\n" + " output_schema = _O\n" + " description = 'ns-prefixed'\n" + " def execute(self, inputs, ctx=None):\n" + " return {'result': 'y'}\n", + encoding="utf-8", + ) + registry = Registry(extensions_dirs=[{"root": str(root), "namespace": "ephemeral"}]) + with pytest.raises(InvalidInputError, match="ephemeral"): + registry.discover() + + def test_register_accepts_ephemeral_id(self) -> None: + """``Registry.register()`` MUST accept ``ephemeral.*`` IDs (programmatic-only path).""" + registry = Registry() + mod = _EphemeralModuleApproved() + registry.register("ephemeral.synth_tool_v1", mod) + assert registry.get("ephemeral.synth_tool_v1") is mod + + def test_unregister_accepts_ephemeral_id(self) -> None: + """Caller-managed lifecycle: ``unregister`` removes ephemeral modules.""" + registry = Registry() + registry.register("ephemeral.tmp", _EphemeralModuleApproved()) + assert registry.unregister("ephemeral.tmp") is True + assert registry.get("ephemeral.tmp") is None + + def test_namespace_prefix_constant_uses_trailing_dot(self) -> None: + """Sanity: ``ephemerals.*`` must NOT be classified as the reserved namespace.""" + assert EPHEMERAL_NAMESPACE_PREFIX == "ephemeral." + + +# =========================================================================== +# 2. discoverable annotation +# =========================================================================== + + +class TestDiscoverableAnnotation: + def test_default_annotation_is_discoverable_true(self) -> None: + """Backward compat: ``ModuleAnnotations()`` must default to ``discoverable=True``.""" + ann = ModuleAnnotations() + assert ann.discoverable is True + + def test_list_excludes_hidden_modules(self) -> None: + registry = Registry() + registry.register("v.visible", _DiscoverableVisible()) + registry.register("h.hidden", _DiscoverableHidden()) + ids = registry.list() + assert "v.visible" in ids + assert "h.hidden" not in ids + + def test_list_include_hidden_returns_all(self) -> None: + registry = Registry() + registry.register("v.visible", _DiscoverableVisible()) + registry.register("h.hidden", _DiscoverableHidden()) + ids = registry.list(include_hidden=True) + assert "v.visible" in ids + assert "h.hidden" in ids + + def test_module_ids_excludes_hidden(self) -> None: + registry = Registry() + registry.register("v.visible", _DiscoverableVisible()) + registry.register("h.hidden", _DiscoverableHidden()) + assert "h.hidden" not in registry.module_ids + assert "v.visible" in registry.module_ids + + def test_iter_excludes_hidden_by_default(self) -> None: + registry = Registry() + registry.register("v.visible", _DiscoverableVisible()) + registry.register("h.hidden", _DiscoverableHidden()) + ids = {mid for mid, _ in registry.iter()} + assert "h.hidden" not in ids + assert "v.visible" in ids + + def test_iter_include_hidden_returns_all(self) -> None: + registry = Registry() + registry.register("v.visible", _DiscoverableVisible()) + registry.register("h.hidden", _DiscoverableHidden()) + ids = {mid for mid, _ in registry.iter(include_hidden=True)} + assert "h.hidden" in ids + assert "v.visible" in ids + + def test_hidden_module_remains_callable_via_get(self) -> None: + """Per the RFC, ``discoverable=False`` only hides from enumeration — + ``Registry.get()`` MUST still resolve when the caller knows the ID.""" + registry = Registry() + mod = _DiscoverableHidden() + registry.register("h.hidden", mod) + assert registry.get("h.hidden") is mod + assert registry.has("h.hidden") is True + + def test_count_includes_hidden(self) -> None: + """``count`` is total registrations, not the visible subset.""" + registry = Registry() + registry.register("v.visible", _DiscoverableVisible()) + registry.register("h.hidden", _DiscoverableHidden()) + assert registry.count == 2 + + def test_yaml_metadata_can_set_discoverable(self) -> None: + """YAML override: ``annotations: {discoverable: false}`` hides a normally-visible module.""" + registry = Registry() + registry.register( + "v.optedout", + _DiscoverableVisible(), + metadata={"annotations": {"discoverable": False}}, + ) + assert "v.optedout" not in registry.list() + assert "v.optedout" in registry.list(include_hidden=True) + + +# =========================================================================== +# 3. requires_approval soft warning +# =========================================================================== + + +class TestRequiresApprovalWarning: + def test_warning_emitted_when_approval_missing(self, caplog: pytest.LogCaptureFixture) -> None: + registry = Registry() + with caplog.at_level(logging.WARNING, logger="apcore.registry.registry"): + registry.register("ephemeral.no_approval", _EphemeralModuleNoApproval()) + assert any("requires_approval" in rec.getMessage() for rec in caplog.records), ( + "expected a WARN about missing requires_approval=True" + ) + + def test_no_warning_when_approval_present(self, caplog: pytest.LogCaptureFixture) -> None: + registry = Registry() + with caplog.at_level(logging.WARNING, logger="apcore.registry.registry"): + registry.register("ephemeral.with_approval", _EphemeralModuleApproved()) + assert not any("requires_approval" in rec.getMessage() for rec in caplog.records) + + def test_no_warning_for_non_ephemeral(self, caplog: pytest.LogCaptureFixture) -> None: + """Non-ephemeral modules without ``requires_approval`` are NOT warned (regular workflow).""" + registry = Registry() + with caplog.at_level(logging.WARNING, logger="apcore.registry.registry"): + registry.register("normal.module", _DiscoverableVisible()) + assert not any("requires_approval" in rec.getMessage() for rec in caplog.records) + + def test_register_does_not_raise_when_approval_missing(self) -> None: + """Soft warning, not error: registration MUST succeed.""" + registry = Registry() + # Should not raise. + registry.register("ephemeral.no_approval", _EphemeralModuleNoApproval()) + assert registry.get("ephemeral.no_approval") is not None + + def test_warning_handles_dict_annotations(self, caplog: pytest.LogCaptureFixture) -> None: + """A module exposing ``annotations`` as a plain dict is also covered.""" + + class _DictAnn: + input_schema = _In + output_schema = _Out + description = "dict-style annotations, missing approval" + annotations = {"requires_approval": False, "discoverable": False} + + def execute(self, inputs: dict[str, Any], _ctx: Any = None) -> dict[str, Any]: + return {"result": ""} + + registry = Registry() + with caplog.at_level(logging.WARNING, logger="apcore.registry.registry"): + registry.register("ephemeral.dict_ann", _DictAnn()) + assert any("requires_approval" in rec.getMessage() for rec in caplog.records) + + +# =========================================================================== +# 4. Audit events +# =========================================================================== + + +class TestEphemeralAuditEvents: + def _make_emitter(self) -> tuple[EventEmitter, _CapturingSubscriber]: + emitter = EventEmitter() + sub = _CapturingSubscriber() + emitter.subscribe(sub) + return emitter, sub + + def _drain(self, emitter: EventEmitter, sub: _CapturingSubscriber, count: int = 1) -> None: + """Block until ``sub`` has received ``count`` events (with timeout).""" + import time + + deadline = time.monotonic() + 1.0 + while len(sub.events) < count and time.monotonic() < deadline: + time.sleep(0.02) + + def test_register_emits_canonical_event(self) -> None: + emitter, sub = self._make_emitter() + registry = Registry() + registry.set_event_emitter(emitter) + registry.register("ephemeral.audit_v1", _EphemeralModuleApproved()) + self._drain(emitter, sub, 1) + emitter.shutdown() + + register_events = [e for e in sub.events if e.event_type == "apcore.registry.module_registered"] + assert len(register_events) == 1 + ev = register_events[0] + assert ev.module_id == "ephemeral.audit_v1" + assert ev.severity == "info" + # Default audit payload — no context = "@external" sentinel + assert ev.data.get("caller_id") == "@external" + assert "identity" not in ev.data # no context.identity provided + + def test_unregister_emits_canonical_event(self) -> None: + emitter, sub = self._make_emitter() + registry = Registry() + registry.set_event_emitter(emitter) + registry.register("ephemeral.bye", _EphemeralModuleApproved()) + self._drain(emitter, sub, 1) + registry.unregister("ephemeral.bye") + self._drain(emitter, sub, 2) + emitter.shutdown() + + unreg = [e for e in sub.events if e.event_type == "apcore.registry.module_unregistered"] + assert len(unreg) == 1 + assert unreg[0].module_id == "ephemeral.bye" + + def test_audit_payload_includes_caller_id_and_identity(self) -> None: + emitter, sub = self._make_emitter() + registry = Registry() + registry.set_event_emitter(emitter) + identity = _IdentityStub( + id="user-42", + type="human", + roles=["operator"], + attrs={"display_name": "Tess Tester", "bearer_token": "secret-xyz"}, + ) + ctx = _ContextStub(caller_id="orchestrator.api", identity=identity) + registry.register("ephemeral.with_ctx", _EphemeralModuleApproved(), context=ctx) + self._drain(emitter, sub, 1) + emitter.shutdown() + + ev = next(e for e in sub.events if e.event_type == "apcore.registry.module_registered") + assert ev.data["caller_id"] == "orchestrator.api" + assert ev.data["identity"]["id"] == "user-42" + assert ev.data["identity"]["type"] == "human" + assert ev.data["identity"]["roles"] == ["operator"] + assert ev.data["identity"]["display_name"] == "Tess Tester" + # Sensitive attribute MUST be redacted (matches D-35 behaviour). + assert ev.data["identity"]["bearer_token"] == "" + + def test_no_event_for_non_ephemeral_module(self) -> None: + """Non-ephemeral registrations MUST NOT trigger the registry-side audit emit.""" + emitter, sub = self._make_emitter() + registry = Registry() + registry.set_event_emitter(emitter) + registry.register("normal.module", _DiscoverableVisible()) + # Briefly wait — no event should ever appear. + import time + + time.sleep(0.1) + emitter.shutdown() + assert all(e.module_id != "normal.module" for e in sub.events), ( + "non-ephemeral registrations must not produce registry-side audit events" + ) + + def test_unregister_audit_uses_context_payload(self) -> None: + emitter, sub = self._make_emitter() + registry = Registry() + registry.set_event_emitter(emitter) + registry.register("ephemeral.unreg_ctx", _EphemeralModuleApproved()) + self._drain(emitter, sub, 1) + ctx = _ContextStub(caller_id="cleanup.daemon") + assert registry.unregister("ephemeral.unreg_ctx", context=ctx) is True + self._drain(emitter, sub, 2) + emitter.shutdown() + unreg = next(e for e in sub.events if e.event_type == "apcore.registry.module_unregistered") + assert unreg.data["caller_id"] == "cleanup.daemon" + + def test_no_emitter_falls_back_to_log(self, caplog: pytest.LogCaptureFixture) -> None: + """When no emitter is wired the audit info MUST still surface (INFO log).""" + registry = Registry() # no set_event_emitter + with caplog.at_level(logging.INFO, logger="apcore.registry.registry"): + registry.register("ephemeral.logged", _EphemeralModuleApproved()) + assert any( + "ephemeral audit event" in rec.getMessage() and "ephemeral.logged" in rec.getMessage() + for rec in caplog.records + ), "expected an INFO log fallback when no EventEmitter is wired" + + def test_bridge_does_not_dual_emit_for_ephemeral(self) -> None: + """Single-emit rule (apcore RFC ``rfc-ephemeral-modules.md``, + "Audit-event single-emit rule"): when both the registry-side direct + emit (``set_event_emitter``) and the ``_bridge_registry_events`` + callback bridge are wired, an ``ephemeral.*`` registration MUST + produce exactly ONE ``apcore.registry.module_registered`` event — + the rich one with the contextual payload, not a second empty-payload + copy from the bridge. + """ + from apcore.sys_modules.registration import _bridge_registry_events + + emitter, sub = self._make_emitter() + registry = Registry() + registry.set_event_emitter(emitter) + # Wire the legacy bridge as well — this is what would have produced + # the second empty-payload emit before the single-emit guard landed. + _bridge_registry_events(registry, emitter) + + registry.register("ephemeral.single_emit", _EphemeralModuleApproved()) + self._drain(emitter, sub, 1) + # Give the bridge a chance to fire if the guard regresses. + import time + + time.sleep(0.1) + emitter.shutdown() + + register_events = [ + e for e in sub.events + if e.event_type == "apcore.registry.module_registered" + and e.module_id == "ephemeral.single_emit" + ] + assert len(register_events) == 1, ( + f"expected exactly one apcore.registry.module_registered event for " + f"ephemeral.single_emit, got {len(register_events)}: {register_events}" + ) + # And it MUST be the rich one (caller_id present), not the empty bridge emit. + assert register_events[0].data.get("caller_id") == "@external" + # The legacy bare alias `module_registered` MUST also be suppressed for + # ephemeral.* (single-emit rule applies to both canonical and legacy). + legacy_events = [ + e for e in sub.events + if e.event_type == "module_registered" + and e.module_id == "ephemeral.single_emit" + ] + assert legacy_events == [], ( + "single-emit rule: legacy `module_registered` alias must also be " + "suppressed for ephemeral.* IDs" + ) + + +# =========================================================================== +# 5. register_internal() rejection of ephemeral.* IDs +# =========================================================================== + + +class TestRegisterInternalRejectsEphemeral: + """Per apcore RFC ``rfc-ephemeral-modules.md`` §"register_internal() + interaction": ``register_internal()`` MUST reject ``ephemeral.*`` IDs + and direct the caller to ``Registry.register()``. Rationale: namespace → + registration-mechanism is a 1:1 mapping; mixing blurs the audit-trail + distinction between framework-emitted (``system.*``) and caller-emitted + (``ephemeral.*``) modules. + """ + + def test_register_internal_rejects_ephemeral_id(self) -> None: + registry = Registry() + mod = _EphemeralModuleApproved() + with pytest.raises(ValueError, match="ephemeral"): + registry.register_internal("ephemeral.test_v1", mod) + # And the rejection MUST short-circuit before any state mutation. + assert registry.get("ephemeral.test_v1") is None + + def test_register_internal_error_mentions_register(self) -> None: + """The error message MUST point the caller to ``Registry.register()``.""" + registry = Registry() + with pytest.raises(ValueError) as exc_info: + registry.register_internal("ephemeral.bad", _EphemeralModuleApproved()) + msg = str(exc_info.value) + assert "Registry.register()" in msg + assert "ephemeral" in msg diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 84a2ea6..a119a6d 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -835,6 +835,9 @@ def test_annotations_extra_round_trip(case: dict[str, Any]) -> None: serialized["cache_key_fields"] = None elif isinstance(serialized.get("cache_key_fields"), (tuple, list)): serialized["cache_key_fields"] = list(serialized["cache_key_fields"]) or None + # Pilot field: see note in standard-case branch below. + if "discoverable" not in case["expected_reserialized"]: + serialized.pop("discoverable", None) assert serialized == case["expected_reserialized"], ( f"[{case_id}] re-serialized annotations mismatch: " f"got {serialized!r}, expected {case['expected_reserialized']!r}" @@ -858,6 +861,12 @@ def test_annotations_extra_round_trip(case: dict[str, Any]) -> None: if ckf is not None: serialized["cache_key_fields"] = list(ckf) if ckf else None expected = dict(case["expected_serialized"]) + # Pilot field: ``discoverable`` was added ahead of upstream RFC + # acceptance (see CHANGELOG). Strip it from the serialized form when + # the fixture predates the field so the cross-language conformance + # check stays meaningful for every other key. + if "discoverable" not in expected: + serialized.pop("discoverable", None) assert serialized == expected, ( f"[{case_id}] serialized annotations mismatch: " f"got {serialized!r}, expected {expected!r}" )