From b04cda774be6321469ddc2bcb900e6b95579adab Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 23 Aug 2026 15:30:12 +0200 Subject: [PATCH] fix(arch-014,arch-020): no contract lazy-resurrect after unregister; event bus dedupes handlers Also fixes ARCH-029/041: none-check before attribute access in trigger dispatcher. --- app/core/event_bus.py | 9 +++++++-- app/core/trigger_dispatcher.py | 5 ++++- app/plugins/builtins/contracts.py | 20 ++++++++++++++++++-- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/core/event_bus.py b/app/core/event_bus.py index 6eddec9..4fbdbc4 100644 --- a/app/core/event_bus.py +++ b/app/core/event_bus.py @@ -36,8 +36,13 @@ class EventBus: self._handlers: dict[str, list[EventHandler]] = defaultdict(list) def subscribe(self, event_name: str, handler: EventHandler) -> None: - """Subscribe a handler to an event.""" - self._handlers[event_name].append(handler) + """Subscribe a handler to an event. + + Idempotent: subscribing the same handler twice is a no-op + (ARCH-020) so double activation cannot fire handlers twice. + """ + if handler not in self._handlers[event_name]: + self._handlers[event_name].append(handler) def unsubscribe(self, event_name: str, handler: EventHandler) -> None: """Unsubscribe a handler from an event.""" diff --git a/app/core/trigger_dispatcher.py b/app/core/trigger_dispatcher.py index 51f5e0b..f60849f 100644 --- a/app/core/trigger_dispatcher.py +++ b/app/core/trigger_dispatcher.py @@ -121,11 +121,14 @@ class TriggerDispatcher: """Query DB for active automations matching *event_name* and dispatch.""" from app.core.db import get_session_factory from app.plugins.builtins.contracts import get_contract + # None-check FIRST — accessing attributes on the contract before the + # check crashed with AttributeError when automation was inactive + # (ARCH-029/041). automation_contract = get_contract("automation") - AutomationDefinition = automation_contract.Automation # noqa: N806 if automation_contract is None: logger.debug("Automation plugin not available — trigger skipped") return + AutomationDefinition = automation_contract.Automation # noqa: N806 factory = get_session_factory() tenant_id = payload.get("tenant_id") diff --git a/app/plugins/builtins/contracts.py b/app/plugins/builtins/contracts.py index 3e07cba..be1224c 100644 --- a/app/plugins/builtins/contracts.py +++ b/app/plugins/builtins/contracts.py @@ -59,20 +59,31 @@ class ContractRegistry: cls._instance = super().__new__(cls) cls._instance._contracts: dict[str, Any] = {} cls._instance._loaded: set[str] = set() + cls._instance._unregistered: set[str] = set() return cls._instance # ─── registration ─── def register(self, plugin_name: str, contract: Any) -> None: - """Register or replace a contract for a plugin.""" + """Register or replace a contract for a plugin. + + Clears the unregistered marker so a later deactivation can be + distinguished from a fresh lazy-load again (ARCH-014). + """ + self._unregistered.discard(plugin_name) self._contracts[plugin_name] = contract self._loaded.add(plugin_name) logger.debug("Contract registered for plugin '%s'", plugin_name) def unregister(self, plugin_name: str) -> None: - """Remove a contract (e.g. when the plugin is deactivated).""" + """Remove a contract (e.g. when the plugin is deactivated). + + Marks the plugin as explicitly unregistered so later ``get_contract`` + calls cannot resurrect the contract via lazy-loading (ARCH-014). + """ self._contracts.pop(plugin_name, None) self._loaded.discard(plugin_name) + self._unregistered.add(plugin_name) # ─── lookup ─── @@ -85,6 +96,11 @@ class ContractRegistry: if plugin_name in self._contracts: return self._contracts[plugin_name] + # Explicitly unregistered (deactivated): never resurrect via + # lazy-loading (ARCH-014) — the deactivated contract must stay gone. + if plugin_name in self._unregistered: + return None + if plugin_name not in self._loaded: self._try_lazy_load(plugin_name)