Skip to content

Commit a05c515

Browse files
committed
release(0.18.0): annotations.extra precedence + remove legacy event aliases
Aligned with apcore v0.18.0 PROTOCOL_SPEC §4.4.1 wire format and §9.16 event naming convention. Two breaking surfaces: 1. ModuleAnnotations.from_dict precedence inversion (§4.4.1 rule 7) - When the same key appears both in nested 'extra' and as a top-level overflow key, the nested value now wins (was: overflow won) - Observable only in pathological inputs that mix both forms — no conformant producer emits this - Top-level overflow keys are still tolerated and merged into extra for backward compatibility 2. Legacy event aliases removed (§9.16 cleanup, deadline was v0.16) - No longer emit 'module_health_changed' alongside 'apcore.module.toggled' - No longer emit 'module_health_changed' alongside 'apcore.health.recovered' - No longer emit 'config_changed' alongside 'apcore.config.updated' - No longer emit 'config_changed' alongside 'apcore.module.reloaded' - Listeners must subscribe to canonical names only; see MIGRATION-v0.18.md Other: - Renamed private _emit_config_changed → _emit_module_reloaded for clarity - Inline comment on legacy mode '0.16.0' baseline (intentional, not drift) - Updated tests to assert single canonical event instead of dual emission - README event mapping table dropped legacy column; added v0.18 warning Test suite: 2239 passed, 2 xfailed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: tercel <tercel.yi@gmail.com>
1 parent f0e918b commit a05c515

10 files changed

Lines changed: 112 additions & 110 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.18.0] - 2026-04-07
9+
10+
### Removed (BREAKING)
11+
12+
- **Legacy event aliases removed.** Per the §9.16 naming convention shipped in v0.15, the dual-emission transition period for `module_health_changed` and `config_changed` ended in this release (the original removal deadline was v0.16.0). Listeners that subscribed to these legacy names will no longer receive events. Migrate subscriptions to the canonical names:
13+
- `module_health_changed``apcore.module.toggled` (from `system.control.toggle_feature`) **or** `apcore.health.recovered` (from `PlatformNotifyMiddleware`)
14+
- `config_changed``apcore.config.updated` (from `system.control.update_config`) **or** `apcore.module.reloaded` (from `system.control.reload_module`)
15+
- **Renamed private method `_emit_config_changed``_emit_module_reloaded`** in `system.control.reload_module` to reflect the canonical event it emits. Private API, no public-surface impact.
16+
17+
### Fixed
18+
19+
- **`ModuleAnnotations.from_dict` precedence inversion** — Per PROTOCOL_SPEC §4.4.1 rule 7, when the same key appears both in a nested `extra` object and as a top-level overflow key, the **nested value now wins** (previously the top-level overflow would silently overwrite it). Behavior change is observable only in the pathological case where an input contains both forms of the same key — no conformant producer emits this. Top-level overflow keys are still tolerated and merged into `extra` for backward compatibility.
20+
821
## [0.17.1] - 2026-04-06
922

1023
### Added

README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,14 +170,16 @@ The longest-prefix-match dispatch algorithm ensures that `APCORE_OBSERVABILITY_T
170170

171171
Canonical event type names use dot-namespaced identifiers. `apcore.*` is reserved for core framework events; adapter packages use their own prefix (e.g., `apcore-mcp.*`).
172172

173-
| Canonical name | Replaces | Emitted by |
174-
|---------------|---------|-----------|
175-
| `apcore.module.toggled` | `module_health_changed` | `system.control.toggle_feature` |
176-
| `apcore.health.recovered` | `module_health_changed` | `PlatformNotifyMiddleware` (error rate recovery) |
177-
| `apcore.config.updated` | `config_changed` | `system.control.update_config` |
178-
| `apcore.module.reloaded` | `config_changed` | `system.control.reload_module` |
179-
180-
The legacy short-form names are still emitted alongside the canonical names during the transition period.
173+
| Canonical name | Emitted by |
174+
|---------------|-----------|
175+
| `apcore.module.toggled` | `system.control.toggle_feature` |
176+
| `apcore.health.recovered` | `PlatformNotifyMiddleware` (error rate recovery) |
177+
| `apcore.config.updated` | `system.control.update_config` |
178+
| `apcore.module.reloaded` | `system.control.reload_module` |
179+
180+
> **v0.18.0 — legacy aliases removed.** Listeners that previously subscribed to
181+
> `module_health_changed` or `config_changed` will no longer receive events.
182+
> Migrate subscriptions to the canonical names above.
181183
182184
## Documentation
183185

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "apcore"
7-
version = "0.17.1"
7+
version = "0.18.0"
88
description = "Schema-driven module standard for AI-perceivable interfaces"
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/apcore/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@
9595
}
9696

9797
#: Default configuration values.
98+
#:
99+
#: NOTE: ``version`` is the frozen baseline for legacy-mode configs (those
100+
#: that omit an explicit ``version`` field). It identifies the spec version
101+
#: whose semantics legacy mode parses against, NOT the current SDK version.
102+
#: Do not bump this with each spec MINOR — only when legacy-mode parsing
103+
#: semantics actually change.
98104
_DEFAULTS: dict[str, Any] = {
99105
"version": "0.16.0",
100106
"extensions": {

src/apcore/middleware/platform_notify.py

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class PlatformNotifyMiddleware(Middleware):
1717
1818
Emits ``error_threshold_exceeded`` when a module's error rate crosses the
1919
configured threshold, ``latency_threshold_exceeded`` when p99 latency
20-
exceeds the limit, and ``module_health_changed`` when a previously alerted
20+
exceeds the limit, and ``apcore.health.recovered`` when a previously alerted
2121
module recovers below ``threshold * 0.5``.
2222
2323
Hysteresis prevents repeated alerts until recovery is observed.
@@ -169,31 +169,19 @@ def _check_latency_threshold(self, module_id: str) -> None:
169169
self._alerted[module_id].add("latency")
170170

171171
def _check_error_recovery(self, module_id: str) -> None:
172-
"""Emit apcore.health.recovered (canonical) and module_health_changed (legacy alias)."""
172+
"""Emit ``apcore.health.recovered`` (canonical event) when a previously alerted module recovers."""
173173
error_rate = self._compute_error_rate(module_id)
174174
with self._alert_lock:
175175
if "error_rate" not in self._alerted.get(module_id, set()):
176176
return
177177
if error_rate < self._error_rate_threshold * 0.5:
178-
timestamp = datetime.now(timezone.utc).isoformat()
179-
data: dict[str, Any] = {"status": "recovered", "error_rate": error_rate}
180178
self._emitter.emit(
181179
ApCoreEvent(
182180
event_type="apcore.health.recovered",
183181
module_id=module_id,
184-
timestamp=timestamp,
185-
severity="info",
186-
data=data,
187-
)
188-
)
189-
# Legacy alias — remove in 0.16.0
190-
self._emitter.emit(
191-
ApCoreEvent(
192-
event_type="module_health_changed",
193-
module_id=module_id,
194-
timestamp=timestamp,
182+
timestamp=datetime.now(timezone.utc).isoformat(),
195183
severity="info",
196-
data=data,
184+
data={"status": "recovered", "error_rate": error_rate},
197185
)
198186
)
199187
self._alerted[module_id].discard("error_rate")

src/apcore/module.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,21 @@ def __post_init__(self) -> None:
106106

107107
@classmethod
108108
def from_dict(cls, data: dict[str, Any]) -> ModuleAnnotations:
109-
"""Deserialize from dict, capturing unknown keys into extra."""
109+
"""Deserialize from dict per PROTOCOL_SPEC §4.4.1 wire format.
110+
111+
- The canonical form carries extension data under a nested ``extra`` object.
112+
- Legacy top-level overflow keys (unknown keys at the annotations root) are
113+
tolerated for backward compatibility and merged into ``extra``.
114+
- When the same key appears in BOTH the nested ``extra`` AND as a top-level
115+
overflow key, the nested value wins (§4.4.1 rule 7).
116+
"""
110117
known = {k: v for k, v in data.items() if k in _CANONICAL_FIELDS}
111-
unknown = {k: v for k, v in data.items() if k not in _CANONICAL_FIELDS}
118+
overflow = {k: v for k, v in data.items() if k not in _CANONICAL_FIELDS}
112119
explicit_extra = known.pop("extra", {})
113120
if not isinstance(explicit_extra, dict):
114121
explicit_extra = {}
115-
extra: dict[str, Any] = {**explicit_extra, **unknown}
122+
# §4.4.1 rule 7: nested explicit `extra` wins over legacy top-level overflow.
123+
extra: dict[str, Any] = {**overflow, **explicit_extra}
116124
return cls(**known, extra=extra)
117125

118126

src/apcore/sys_modules/control.py

Lines changed: 21 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -175,27 +175,18 @@ def _validate_post_set(self, key: str, value: Any, old_value: Any) -> bool:
175175
return True
176176

177177
def _emit_event(self, key: str, old_value: Any, new_value: Any) -> None:
178-
"""Emit apcore.config.updated (canonical) and config_changed (legacy alias)."""
179-
payload = ApCoreEvent(
180-
event_type="apcore.config.updated",
181-
module_id="system.control.update_config",
182-
timestamp=datetime.now(timezone.utc).isoformat(),
183-
severity="info",
184-
data={
185-
"key": key,
186-
"old_value": old_value,
187-
"new_value": new_value,
188-
},
189-
)
190-
self._emitter.emit(payload)
191-
# Legacy alias — remove in 0.16.0
178+
"""Emit ``apcore.config.updated`` (canonical event)."""
192179
self._emitter.emit(
193180
ApCoreEvent(
194-
event_type="config_changed",
181+
event_type="apcore.config.updated",
195182
module_id="system.control.update_config",
196-
timestamp=payload.timestamp,
183+
timestamp=datetime.now(timezone.utc).isoformat(),
197184
severity="info",
198-
data=payload.data,
185+
data={
186+
"key": key,
187+
"old_value": old_value,
188+
"new_value": new_value,
189+
},
199190
)
200191
)
201192

@@ -290,7 +281,7 @@ def execute(self, inputs: dict[str, Any], context: Any) -> dict[str, Any]:
290281
elapsed_ms = (time.monotonic() - start) * 1000.0
291282

292283
new_version = getattr(new_module, "version", "1.0.0")
293-
self._emit_config_changed(module_id, previous_version, new_version)
284+
self._emit_module_reloaded(module_id, previous_version, new_version)
294285
self._log_reload(module_id, previous_version, new_version, reason)
295286

296287
return {
@@ -349,27 +340,18 @@ def _reregister_module(self, module_id: str, module: Any) -> None:
349340
"""
350341
self._registry.register_internal(module_id, module)
351342

352-
def _emit_config_changed(self, module_id: str, previous_version: str, new_version: str) -> None:
353-
"""Emit apcore.module.reloaded (canonical) and config_changed (legacy alias)."""
354-
payload = ApCoreEvent(
355-
event_type="apcore.module.reloaded",
356-
module_id=module_id,
357-
timestamp=datetime.now(timezone.utc).isoformat(),
358-
severity="info",
359-
data={
360-
"previous_version": previous_version,
361-
"new_version": new_version,
362-
},
363-
)
364-
self._emitter.emit(payload)
365-
# Legacy alias — remove in 0.16.0
343+
def _emit_module_reloaded(self, module_id: str, previous_version: str, new_version: str) -> None:
344+
"""Emit ``apcore.module.reloaded`` (canonical event)."""
366345
self._emitter.emit(
367346
ApCoreEvent(
368-
event_type="config_changed",
347+
event_type="apcore.module.reloaded",
369348
module_id=module_id,
370-
timestamp=payload.timestamp,
349+
timestamp=datetime.now(timezone.utc).isoformat(),
371350
severity="info",
372-
data=payload.data,
351+
data={
352+
"previous_version": previous_version,
353+
"new_version": new_version,
354+
},
373355
)
374356
)
375357

@@ -491,23 +473,14 @@ def _apply_toggle(self, module_id: str, enabled: bool) -> None:
491473
self._toggle_state.disable(module_id)
492474

493475
def _emit_event(self, module_id: str, enabled: bool) -> None:
494-
"""Emit apcore.module.toggled (canonical) and module_health_changed (legacy alias)."""
495-
payload = ApCoreEvent(
496-
event_type="apcore.module.toggled",
497-
module_id=module_id,
498-
timestamp=datetime.now(timezone.utc).isoformat(),
499-
severity="info",
500-
data={"enabled": enabled},
501-
)
502-
self._emitter.emit(payload)
503-
# Legacy alias — remove in 0.16.0
476+
"""Emit ``apcore.module.toggled`` (canonical event)."""
504477
self._emitter.emit(
505478
ApCoreEvent(
506-
event_type="module_health_changed",
479+
event_type="apcore.module.toggled",
507480
module_id=module_id,
508-
timestamp=payload.timestamp,
481+
timestamp=datetime.now(timezone.utc).isoformat(),
509482
severity="info",
510-
data=payload.data,
483+
data={"enabled": enabled},
511484
)
512485
)
513486

tests/sys_modules/test_control.py

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,14 @@ def test_update_config_emits_config_changed_event(self) -> None:
103103
{"key": "executor.default_timeout", "value": 5000, "reason": "event test"},
104104
None,
105105
)
106-
# Two events: canonical (apcore.config.updated) + legacy alias (config_changed)
107-
assert emitter.emit.call_count == 2
108-
events = [call[0][0] for call in emitter.emit.call_args_list]
109-
event_types = {e.event_type for e in events}
110-
assert "apcore.config.updated" in event_types
111-
assert "config_changed" in event_types
112-
canonical = next(e for e in events if e.event_type == "apcore.config.updated")
113-
assert canonical.data["key"] == "executor.default_timeout"
114-
assert canonical.data["old_value"] == 30000
115-
assert canonical.data["new_value"] == 5000
106+
# Single canonical event (apcore.config.updated). Legacy 'config_changed'
107+
# alias removed in v0.18.0 — see CHANGELOG and PROTOCOL_SPEC §9.16.
108+
assert emitter.emit.call_count == 1
109+
event = emitter.emit.call_args_list[0][0][0]
110+
assert event.event_type == "apcore.config.updated"
111+
assert event.data["key"] == "executor.default_timeout"
112+
assert event.data["old_value"] == 30000
113+
assert event.data["new_value"] == 5000
116114

117115

118116
class TestUpdateConfigRestrictedKeySysModulesEnabled:
@@ -386,15 +384,13 @@ def test_reload_module_emits_config_changed_event(self) -> None:
386384
context=None,
387385
)
388386

389-
# Two events: canonical (apcore.module.reloaded) + legacy alias (config_changed)
390-
assert mock_emit.call_count == 2
391-
events = [call[0][0] for call in mock_emit.call_args_list]
392-
assert all(isinstance(e, ApCoreEvent) for e in events)
393-
event_types = {e.event_type for e in events}
394-
assert "apcore.module.reloaded" in event_types
395-
assert "config_changed" in event_types
396-
canonical = next(e for e in events if e.event_type == "apcore.module.reloaded")
397-
assert canonical.module_id == "my.module"
387+
# Single canonical event (apcore.module.reloaded). Legacy 'config_changed'
388+
# alias removed in v0.18.0 — see CHANGELOG and PROTOCOL_SPEC §9.16.
389+
assert mock_emit.call_count == 1
390+
event = mock_emit.call_args_list[0][0][0]
391+
assert isinstance(event, ApCoreEvent)
392+
assert event.event_type == "apcore.module.reloaded"
393+
assert event.module_id == "my.module"
398394

399395

400396
class TestReloadModuleNoEventOnFailure:
@@ -616,9 +612,9 @@ def test_toggle_feature_module_not_found(self, _clear_disabled_modules: Any) ->
616612
assert exc_info.value.code == "MODULE_NOT_FOUND"
617613

618614

619-
class TestToggleFeatureEmitsModuleHealthChanged:
620-
def test_toggle_feature_emits_module_health_changed(self, _clear_disabled_modules: Any) -> None:
621-
"""Verify emit() is called with canonical and legacy events on toggle."""
615+
class TestToggleFeatureEmitsCanonicalEvent:
616+
def test_toggle_feature_emits_canonical_event(self, _clear_disabled_modules: Any) -> None:
617+
"""Verify emit() is called with the canonical apcore.module.toggled event on toggle."""
622618
registry, _ = _make_registry_with_module("my.module")
623619
emitter = EventEmitter()
624620
emitter.emit = MagicMock() # type: ignore[method-assign]
@@ -627,16 +623,14 @@ def test_toggle_feature_emits_module_health_changed(self, _clear_disabled_module
627623
{"module_id": "my.module", "enabled": False, "reason": "test"},
628624
context=None,
629625
)
630-
# Two events: canonical (apcore.module.toggled) + legacy alias (module_health_changed)
631-
assert emitter.emit.call_count == 2
632-
events = [call[0][0] for call in emitter.emit.call_args_list]
633-
assert all(isinstance(e, ApCoreEvent) for e in events)
634-
event_types = {e.event_type for e in events}
635-
assert "apcore.module.toggled" in event_types
636-
assert "module_health_changed" in event_types
637-
canonical = next(e for e in events if e.event_type == "apcore.module.toggled")
638-
assert canonical.module_id == "my.module"
639-
assert canonical.data["enabled"] is False
626+
# Single canonical event (apcore.module.toggled). Legacy
627+
# 'module_health_changed' alias removed in v0.18.0.
628+
assert emitter.emit.call_count == 1
629+
event = emitter.emit.call_args_list[0][0][0]
630+
assert isinstance(event, ApCoreEvent)
631+
assert event.event_type == "apcore.module.toggled"
632+
assert event.module_id == "my.module"
633+
assert event.data["enabled"] is False
640634

641635

642636
class TestToggleFeatureSurvivesReload:

tests/test_annotations.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,23 @@ def test_explicit_extra_merged_with_unknown(self) -> None:
119119
assert ann.extra["mcp.cat"] == "tools"
120120
assert ann.extra["new_field"] == "val"
121121

122+
def test_nested_extra_wins_over_top_level_collision(self) -> None:
123+
# PROTOCOL_SPEC §4.4.1 rule 7: when the same key appears both nested and
124+
# at the root, the nested value MUST win.
125+
data = {
126+
"mcp.category": "LEGACY_VALUE",
127+
"extra": {"mcp.category": "CANONICAL_VALUE"},
128+
}
129+
ann = ModuleAnnotations.from_dict(data)
130+
assert ann.extra["mcp.category"] == "CANONICAL_VALUE"
131+
132+
def test_legacy_flattened_form_still_accepted(self) -> None:
133+
# Backward compatibility: top-level overflow keys still normalize into extra.
134+
data = {"readonly": True, "mcp.category": "tools", "cli.approval_message": "ok?"}
135+
ann = ModuleAnnotations.from_dict(data)
136+
assert ann.readonly is True
137+
assert ann.extra == {"mcp.category": "tools", "cli.approval_message": "ok?"}
138+
122139
def test_missing_fields_use_defaults(self) -> None:
123140
ann = ModuleAnnotations.from_dict({})
124141
assert ann.readonly is False

tests/test_platform_notify_middleware.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,9 @@ def test_after_emits_recovery_event(self) -> None:
150150

151151
emitter.reset_mock()
152152
mw.after("mod.a", {}, {}, MagicMock())
153-
# Should emit module_health_changed with status=recovered
154-
recovery_calls = [c for c in emitter.emit.call_args_list if c[0][0].event_type == "module_health_changed"]
153+
# Should emit apcore.health.recovered with status=recovered.
154+
# Legacy 'module_health_changed' alias removed in v0.18.0.
155+
recovery_calls = [c for c in emitter.emit.call_args_list if c[0][0].event_type == "apcore.health.recovered"]
155156
assert len(recovery_calls) == 1
156157
event: ApCoreEvent = recovery_calls[0][0][0]
157158
assert event.data["status"] == "recovered"

0 commit comments

Comments
 (0)