diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76de2336..d1f1ac33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.06.91 (2026-06-10)
+
+### Added (plugins parity — parity initiative SP-11)
+
+- **Plugin lifecycle state model**: `PluginState` (LOADED/STARTED/STOPPED/FAILED) + a `PluginDescriptor`
+ (state, loaded_at, last_state_change, failed_reason) tracked per plugin by the `PluginManager`.
+- **Per-plugin lifecycle with dependency cascade**: `start_plugin(id)` starts the plugin's transitive
+ dependencies first (skipping already-started ones); `stop_plugin(id)` stops its dependents first; a
+ failing hook transitions the plugin to FAILED and raises a typed `PluginStartError`/`PluginStopError`.
+ `get_plugin(id)` returns the descriptor.
+- **Plugin exception hierarchy**: `PluginException` (over `PyFlyException`) + `PluginLoadError`,
+ `PluginStartError`, `PluginStopError`, `PluginStateError` (`PluginResolutionError` now extends it).
+- **`ExtensionRegistry.get_extension(point)`**: single highest-priority getter (raises clearly for an
+ unknown/empty point) alongside the existing list getter.
+- **`@plugin(name=, author=)`**: optional metadata fields (backward-compatible; `name` defaults to `id`).
+- End-to-end multi-plugin lifecycle test + `PluginsAutoConfiguration` test + docs.
+
## v26.06.90 (2026-06-10)
### Added / Fixed / Tested (notifications completeness — parity initiative SP-10)
diff --git a/README.md b/README.md
index 97675156..7d5ec000 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
diff --git a/docs/modules/plugins.md b/docs/modules/plugins.md
index 6acc2040..75d5e88f 100644
--- a/docs/modules/plugins.md
+++ b/docs/modules/plugins.md
@@ -71,3 +71,135 @@ leak in the registry:
`start_all()` runs each plugin's `init` then `start` hooks in dependency order;
`stop_all()` runs `stop` then `unload` in reverse order.
+
+---
+
+## PluginState and PluginDescriptor
+
+Every plugin tracked by `PluginManager` has a runtime `PluginDescriptor` that
+records its current lifecycle state:
+
+```python
+from pyfly.plugins import PluginDescriptor, PluginState
+
+desc: PluginDescriptor = await manager.get_plugin("my-plugin")
+print(desc.state) # PluginState.STARTED
+print(desc.loaded_at) # datetime of add()
+print(desc.last_state_change) # datetime of last transition
+print(desc.failed_reason) # str if FAILED, else None
+```
+
+`PluginState` values:
+
+| Value | Meaning |
+|-------|---------|
+| `LOADED` | Plugin registered via `add()` but not yet started |
+| `STARTED` | `start()` hook completed successfully |
+| `STOPPED` | `stop()` hook completed successfully |
+| `FAILED` | A lifecycle hook raised an exception |
+
+---
+
+## `@plugin(name=, author=)` — Optional Metadata
+
+Two new optional fields are available on `@plugin`:
+
+```python
+@plugin(id="auth", version="1.0.0", name="Auth Plugin", author="security-team")
+class AuthPlugin:
+ ...
+```
+
+- `name` defaults to `id` when omitted (backward-compatible).
+- `author` defaults to `""`.
+
+Both fields are accessible on `Plugin` and `PluginDescriptor.plugin`.
+
+---
+
+## Per-Plugin Lifecycle: `start_plugin` / `stop_plugin` / `get_plugin`
+
+Use these instead of `start_all` / `stop_all` when you want fine-grained control:
+
+### `start_plugin(plugin_id)`
+
+Starts the named plugin **and all its transitive dependencies**, in dependency
+order. Already-STARTED plugins are skipped. On hook failure the plugin's state
+is set to `FAILED` and a `PluginStartError` is raised.
+
+```python
+# Assuming C depends on B which depends on A:
+await manager.start_plugin("c")
+# → starts A, then B, then C
+```
+
+### `stop_plugin(plugin_id)`
+
+Stops the named plugin **and all plugins that (transitively) depend on it**, in
+reverse dependency order (dependents first). Already-STOPPED/LOADED plugins are
+skipped. On hook failure the state is set to `FAILED` and a `PluginStopError`
+is raised.
+
+```python
+await manager.stop_plugin("a")
+# → stops C, then B, then A
+```
+
+### `get_plugin(plugin_id)` → `PluginDescriptor | None`
+
+Returns the descriptor for the plugin, or `None` if no plugin with that id is
+registered.
+
+---
+
+## PluginError Exception Hierarchy
+
+All plugin exceptions extend `PyFlyException`:
+
+```
+PyFlyException
+└── PluginException # base for all plugin errors
+ ├── PluginLoadError # plugin could not be loaded/registered
+ ├── PluginStartError # start/init hook raised
+ ├── PluginStopError # stop/unload hook raised
+ ├── PluginStateError # invalid state transition or unknown plugin id
+ └── PluginResolutionError # missing dependency or cycle during topo-sort
+```
+
+Import from `pyfly.kernel.exceptions` (or `pyfly.kernel`):
+
+```python
+from pyfly.kernel.exceptions import PluginStartError, PluginStateError
+```
+
+---
+
+## `ExtensionRegistry.get_extension`
+
+`get()` returns a list of all extensions for a point. Use `get_extension()` when
+you only expect (and want) the single highest-priority one:
+
+```python
+processor = await registry.get_extension("processors")
+# raises ValueError if the point is unknown or has no extensions
+```
+
+---
+
+## PluginsAutoConfiguration
+
+The plugin system is auto-configured when `pyfly.plugins.enabled=true`:
+
+```yaml
+# application.yaml
+pyfly:
+ plugins:
+ enabled: true
+```
+
+`PluginsAutoConfiguration` registers two beans:
+
+| Bean type | Factory method |
+|-----------|---------------|
+| `ExtensionRegistry` | `extension_registry()` |
+| `PluginManager` | `plugin_manager(registry)` |
diff --git a/pyproject.toml b/pyproject.toml
index ad4bfd65..cb1e91ac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
-version = "26.6.90"
+version = "26.6.91"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py
index 8a793b57..d10380fd 100644
--- a/src/pyfly/__init__.py
+++ b/src/pyfly/__init__.py
@@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""
-__version__ = "26.06.90"
+__version__ = "26.06.91"
diff --git a/src/pyfly/kernel/__init__.py b/src/pyfly/kernel/__init__.py
index 2fc48d6b..adbf30a2 100644
--- a/src/pyfly/kernel/__init__.py
+++ b/src/pyfly/kernel/__init__.py
@@ -34,6 +34,11 @@
NotImplementedException,
OperationTimeoutException,
PayloadTooLargeException,
+ PluginException,
+ PluginLoadError,
+ PluginStartError,
+ PluginStateError,
+ PluginStopError,
PreconditionFailedException,
PyFlyException,
QuotaExceededException,
@@ -100,4 +105,10 @@
"BadGatewayException",
"GatewayTimeoutException",
"QuotaExceededException",
+ # Plugin
+ "PluginException",
+ "PluginLoadError",
+ "PluginStartError",
+ "PluginStopError",
+ "PluginStateError",
]
diff --git a/src/pyfly/kernel/exceptions.py b/src/pyfly/kernel/exceptions.py
index 3ff3fa4b..ad540e82 100644
--- a/src/pyfly/kernel/exceptions.py
+++ b/src/pyfly/kernel/exceptions.py
@@ -198,3 +198,29 @@ class GatewayTimeoutException(ExternalServiceException):
class QuotaExceededException(RateLimitException):
"""API or resource quota has been exceeded."""
+
+
+# =============================================================================
+# Plugin Exceptions
+# =============================================================================
+
+
+class PluginException(PyFlyException):
+ """Base exception for all plugin system errors."""
+
+
+class PluginLoadError(PluginException):
+ """A plugin could not be loaded or registered."""
+
+
+class PluginStartError(PluginException):
+ """A plugin's start/init hook raised an error."""
+
+
+class PluginStopError(PluginException):
+ """A plugin's stop/unload hook raised an error."""
+
+
+class PluginStateError(PluginException):
+ """An operation was attempted on a plugin in an incompatible state
+ (e.g. starting an already-started plugin, or referencing an unknown id)."""
diff --git a/src/pyfly/plugins/__init__.py b/src/pyfly/plugins/__init__.py
index bfda44ad..8260e56c 100644
--- a/src/pyfly/plugins/__init__.py
+++ b/src/pyfly/plugins/__init__.py
@@ -18,6 +18,7 @@
plugin,
)
from pyfly.plugins.manager import PluginManager
+from pyfly.plugins.models import PluginDescriptor, PluginState
from pyfly.plugins.registry import ExtensionRegistry
from pyfly.plugins.resolver import PluginDependencyResolver, PluginResolutionError
@@ -27,8 +28,10 @@
"ExtensionRegistry",
"Plugin",
"PluginDependencyResolver",
+ "PluginDescriptor",
"PluginManager",
"PluginResolutionError",
+ "PluginState",
"extension",
"extension_point",
"plugin",
diff --git a/src/pyfly/plugins/decorators.py b/src/pyfly/plugins/decorators.py
index 70442682..d2de37c5 100644
--- a/src/pyfly/plugins/decorators.py
+++ b/src/pyfly/plugins/decorators.py
@@ -14,6 +14,8 @@ class Plugin:
version: str = "0.0.0"
depends_on: tuple[str, ...] = ()
description: str = ""
+ name: str = "" # defaults to id if empty; see @plugin decorator
+ author: str = ""
@dataclass(frozen=True)
@@ -29,11 +31,22 @@ class Extension:
def plugin(
- *, id: str, version: str = "0.0.0", depends_on: tuple[str, ...] = (), description: str = ""
+ *,
+ id: str,
+ version: str = "0.0.0",
+ depends_on: tuple[str, ...] = (),
+ description: str = "",
+ name: str = "",
+ author: str = "",
) -> Callable[[type], type]:
def decorator(cls: type) -> type:
cls.__pyfly_plugin__ = Plugin( # type: ignore[attr-defined]
- id=id, version=version, depends_on=depends_on, description=description
+ id=id,
+ version=version,
+ depends_on=depends_on,
+ description=description,
+ name=name or id,
+ author=author,
)
return cls
diff --git a/src/pyfly/plugins/manager.py b/src/pyfly/plugins/manager.py
index 11173ac0..9c060cfd 100644
--- a/src/pyfly/plugins/manager.py
+++ b/src/pyfly/plugins/manager.py
@@ -5,10 +5,13 @@
from __future__ import annotations
import asyncio
+import datetime
import inspect
from typing import Any
+from pyfly.kernel.exceptions import PluginLoadError, PluginStartError, PluginStateError, PluginStopError
from pyfly.plugins.decorators import Plugin
+from pyfly.plugins.models import PluginDescriptor, PluginState
from pyfly.plugins.registry import ExtensionRegistry
from pyfly.plugins.resolver import PluginDependencyResolver
@@ -21,6 +24,7 @@ def __init__(self, registry: ExtensionRegistry | None = None) -> None:
# (point_id, instance) pairs each plugin registered, so unload can
# unregister exactly what it added (audit #219).
self._registered: dict[str, list[tuple[str, Any]]] = {}
+ self._descriptors: dict[str, PluginDescriptor] = {}
self._started = False
self._lock = asyncio.Lock()
@@ -32,11 +36,20 @@ async def add(self, plugin_class: type) -> None:
meta = getattr(plugin_class, "__pyfly_plugin__", None)
if meta is None:
msg = f"{plugin_class.__qualname__} is not @plugin-decorated"
- raise ValueError(msg)
+ raise PluginLoadError(msg)
instance = plugin_class()
+ now = datetime.datetime.now(tz=datetime.UTC)
+ descriptor = PluginDescriptor(
+ id=meta.id,
+ plugin=meta,
+ state=PluginState.LOADED,
+ loaded_at=now,
+ last_state_change=now,
+ )
async with self._lock:
self._plugins[meta.id] = meta
self._instances[meta.id] = instance
+ self._descriptors[meta.id] = descriptor
# Register @extension_point declarations first (the decorated class is the
# point's interface type) so extension type-validation can apply (#218).
@@ -58,12 +71,140 @@ async def add(self, plugin_class: type) -> None:
async with self._lock:
self._registered[meta.id] = registered
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _transitive_deps(self, plugin_id: str) -> set[str]:
+ """Return the set of all transitive dependency ids for *plugin_id*."""
+ visited: set[str] = set()
+ queue = list(self._plugins[plugin_id].depends_on)
+ while queue:
+ dep = queue.pop()
+ if dep in visited:
+ continue
+ visited.add(dep)
+ if dep in self._plugins:
+ queue.extend(self._plugins[dep].depends_on)
+ return visited
+
+ def _transitive_dependents(self, plugin_id: str) -> set[str]:
+ """Return the set of all plugins that (transitively) depend on *plugin_id*."""
+ # Start with direct dependents then fan out.
+ dependents: set[str] = set()
+ changed = True
+ while changed:
+ changed = False
+ for pid, plugin in self._plugins.items():
+ if pid in dependents:
+ continue
+ deps = set(plugin.depends_on)
+ if plugin_id in deps or deps & dependents:
+ dependents.add(pid)
+ changed = True
+ return dependents
+
+ async def _run_hooks(self, instance: Any, *hooks: str) -> None:
+ for hook in hooks:
+ fn = getattr(instance, hook, None)
+ if fn is None:
+ continue
+ result = fn()
+ if inspect.isawaitable(result):
+ await result
+
+ def _transition(self, plugin_id: str, state: PluginState, reason: str | None = None) -> None:
+ desc = self._descriptors[plugin_id]
+ desc.state = state
+ desc.last_state_change = datetime.datetime.now(tz=datetime.UTC)
+ if reason is not None:
+ desc.failed_reason = reason
+
+ # ------------------------------------------------------------------
+ # Per-plugin lifecycle
+ # ------------------------------------------------------------------
+
+ async def start_plugin(self, plugin_id: str) -> None:
+ """Start *plugin_id* and all its transitive dependencies (in dep order).
+
+ Skips plugins already in STARTED state. On hook failure, marks the
+ plugin FAILED and raises PluginStartError.
+ """
+ async with self._lock:
+ if plugin_id not in self._plugins:
+ msg = f"Unknown plugin id: {plugin_id!r}"
+ raise PluginStateError(msg)
+ full_order = PluginDependencyResolver.order(self._plugins)
+ subset = {plugin_id} | self._transitive_deps(plugin_id)
+ to_start = [pid for pid in full_order if pid in subset]
+
+ for pid in to_start:
+ async with self._lock:
+ desc = self._descriptors[pid]
+ if desc.state == PluginState.STARTED:
+ continue
+ instance = self._instances[pid]
+ try:
+ await self._run_hooks(instance, "init", "start")
+ except Exception as exc:
+ async with self._lock:
+ self._transition(pid, PluginState.FAILED, str(exc))
+ msg = f"Plugin {pid!r} failed to start: {exc}"
+ raise PluginStartError(msg) from exc
+ async with self._lock:
+ self._transition(pid, PluginState.STARTED)
+
+ async def stop_plugin(self, plugin_id: str) -> None:
+ """Stop *plugin_id* and all plugins that (transitively) depend on it.
+
+ Processes dependents first (reverse cascade), then this plugin.
+ Skips plugins already STOPPED or LOADED. On hook failure, marks FAILED
+ and raises PluginStopError.
+ """
+ async with self._lock:
+ if plugin_id not in self._plugins:
+ msg = f"Unknown plugin id: {plugin_id!r}"
+ raise PluginStateError(msg)
+ full_order = PluginDependencyResolver.order(self._plugins)
+ subset = {plugin_id} | self._transitive_dependents(plugin_id)
+ # Reverse so dependents are stopped before their dependencies.
+ to_stop = [pid for pid in reversed(full_order) if pid in subset]
+
+ for pid in to_stop:
+ async with self._lock:
+ desc = self._descriptors[pid]
+ if desc.state in (PluginState.STOPPED, PluginState.LOADED):
+ continue
+ instance = self._instances[pid]
+ try:
+ await self._run_hooks(instance, "stop", "unload")
+ except Exception as exc:
+ async with self._lock:
+ self._transition(pid, PluginState.FAILED, str(exc))
+ msg = f"Plugin {pid!r} failed to stop: {exc}"
+ raise PluginStopError(msg) from exc
+ async with self._lock:
+ self._transition(pid, PluginState.STOPPED)
+
+ async def get_plugin(self, plugin_id: str) -> PluginDescriptor | None:
+ """Return the PluginDescriptor for *plugin_id*, or None if not found."""
+ async with self._lock:
+ return self._descriptors.get(plugin_id)
+
+ # ------------------------------------------------------------------
+ # Bulk lifecycle
+ # ------------------------------------------------------------------
+
async def start_all(self) -> None:
async with self._lock:
if self._started:
return
order = PluginDependencyResolver.order(self._plugins)
for pid in order:
+ # Skip plugins already started via an earlier start_plugin() call so
+ # their init/start hooks don't run twice on a mixed start path.
+ if self._descriptors[pid].state == PluginState.STARTED:
+ continue
instance = self._instances[pid]
for hook in ("init", "start"):
fn = getattr(instance, hook, None)
@@ -72,6 +213,8 @@ async def start_all(self) -> None:
result = fn()
if inspect.isawaitable(result):
await result
+ async with self._lock:
+ self._transition(pid, PluginState.STARTED)
async with self._lock:
self._started = True
@@ -89,6 +232,8 @@ async def stop_all(self) -> None:
result = fn()
if inspect.isawaitable(result):
await result
+ async with self._lock:
+ self._transition(pid, PluginState.STOPPED)
async with self._lock:
self._started = False
@@ -118,6 +263,7 @@ async def remove(self, plugin_id: str) -> bool:
async with self._lock:
self._plugins.pop(plugin_id, None)
self._instances.pop(plugin_id, None)
+ self._descriptors.pop(plugin_id, None)
return True
async def unload_all(self) -> None:
diff --git a/src/pyfly/plugins/models.py b/src/pyfly/plugins/models.py
new file mode 100644
index 00000000..0b151b2e
--- /dev/null
+++ b/src/pyfly/plugins/models.py
@@ -0,0 +1,35 @@
+# Copyright 2026 Firefly Software Foundation.
+# Licensed under the Apache License, Version 2.0.
+"""PluginState and PluginDescriptor — runtime state tracking for plugins."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import StrEnum
+
+from pyfly.plugins.decorators import Plugin
+
+
+class PluginState(StrEnum):
+ """Lifecycle state of a registered plugin."""
+
+ LOADED = "LOADED"
+ STARTED = "STARTED"
+ STOPPED = "STOPPED"
+ FAILED = "FAILED"
+
+
+@dataclass
+class PluginDescriptor:
+ """Runtime descriptor for a single plugin — its metadata + current state.
+
+ Mutable so that ``PluginManager`` can transition ``state`` in place.
+ """
+
+ id: str
+ plugin: Plugin
+ state: PluginState
+ loaded_at: datetime
+ last_state_change: datetime
+ failed_reason: str | None = field(default=None)
diff --git a/src/pyfly/plugins/registry.py b/src/pyfly/plugins/registry.py
index 7dc9b25e..9b0040e7 100644
--- a/src/pyfly/plugins/registry.py
+++ b/src/pyfly/plugins/registry.py
@@ -59,6 +59,23 @@ async def get(self, point_id: str) -> list[Any]:
async with self._lock:
return [inst for _, inst in self._extensions.get(point_id, [])]
+ async def get_extension(self, point_id: str) -> Any:
+ """Return the single highest-priority extension for *point_id*.
+
+ Raises:
+ ValueError: if *point_id* is not a registered extension point or
+ has no registered extensions.
+ """
+ async with self._lock:
+ if point_id not in self._points:
+ msg = f"Extension point {point_id!r} is not registered"
+ raise ValueError(msg)
+ entries = self._extensions.get(point_id, [])
+ if not entries:
+ msg = f"Extension point {point_id!r} has no registered extensions"
+ raise ValueError(msg)
+ return entries[0][1]
+
async def points(self) -> list[str]:
async with self._lock:
return list(self._extensions.keys())
diff --git a/src/pyfly/plugins/resolver.py b/src/pyfly/plugins/resolver.py
index 676348c3..27af63c4 100644
--- a/src/pyfly/plugins/resolver.py
+++ b/src/pyfly/plugins/resolver.py
@@ -6,10 +6,11 @@
from collections import deque
+from pyfly.kernel.exceptions import PluginException
from pyfly.plugins.decorators import Plugin
-class PluginResolutionError(Exception):
+class PluginResolutionError(PluginException):
pass
diff --git a/tests/plugins/test_plugin_lifecycle.py b/tests/plugins/test_plugin_lifecycle.py
new file mode 100644
index 00000000..9afa0383
--- /dev/null
+++ b/tests/plugins/test_plugin_lifecycle.py
@@ -0,0 +1,253 @@
+# Copyright 2026 Firefly Software Foundation.
+# Licensed under the Apache License, Version 2.0.
+"""End-to-end lifecycle tests mirroring Java PluginSystemIntegrationTest."""
+
+from __future__ import annotations
+
+import pytest
+
+from pyfly.kernel.exceptions import PluginStartError, PluginStateError, PluginStopError
+from pyfly.plugins.decorators import extension, extension_point, plugin
+from pyfly.plugins.manager import PluginManager
+from pyfly.plugins.models import PluginDescriptor, PluginState
+
+# ---------------------------------------------------------------------------
+# Shared plugin definitions (A ← B ← C, with extension point on A)
+# ---------------------------------------------------------------------------
+
+started_order: list[str] = []
+stopped_order: list[str] = []
+
+
+@plugin(id="a", name="Plugin A", author="alice", description="root")
+class PluginA:
+ @extension_point(id="processors")
+ class ProcessorPoint: ...
+
+ @extension(point="processors", priority=10)
+ class DefaultProcessor(ProcessorPoint):
+ label = "default"
+
+ async def start(self) -> None:
+ started_order.append("a")
+
+ async def stop(self) -> None:
+ stopped_order.append("a")
+
+
+@plugin(id="b", depends_on=("a",), name="Plugin B", author="bob")
+class PluginB:
+ async def start(self) -> None:
+ started_order.append("b")
+
+ async def stop(self) -> None:
+ stopped_order.append("b")
+
+
+@plugin(id="c", depends_on=("b",), name="Plugin C", author="charlie")
+class PluginC:
+ async def start(self) -> None:
+ started_order.append("c")
+
+ async def stop(self) -> None:
+ stopped_order.append("c")
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_start_plugin_cascades_dependencies() -> None:
+ """start_plugin(C) should start A, then B, then C."""
+ started_order.clear()
+ mgr = PluginManager()
+ await mgr.add(PluginA)
+ await mgr.add(PluginB)
+ await mgr.add(PluginC)
+
+ await mgr.start_plugin("c")
+
+ assert started_order == ["a", "b", "c"]
+ for pid in ("a", "b", "c"):
+ desc = await mgr.get_plugin(pid)
+ assert desc is not None
+ assert desc.state == PluginState.STARTED
+
+
+@pytest.mark.asyncio
+async def test_start_plugin_skips_already_started() -> None:
+ """Starting A, then start_plugin(C) should not double-start A."""
+ started_order.clear()
+ mgr = PluginManager()
+ await mgr.add(PluginA)
+ await mgr.add(PluginB)
+ await mgr.add(PluginC)
+
+ await mgr.start_plugin("a") # start A first
+ started_order.clear() # reset recorder
+ await mgr.start_plugin("c") # now cascade from C
+
+ # A already started → skipped; only B and C should fire
+ assert started_order == ["b", "c"]
+
+
+@pytest.mark.asyncio
+async def test_stop_plugin_cascades_dependents() -> None:
+ """stop_plugin(A) should stop C first, then B, then A."""
+ stopped_order.clear()
+ mgr = PluginManager()
+ await mgr.add(PluginA)
+ await mgr.add(PluginB)
+ await mgr.add(PluginC)
+ await mgr.start_plugin("c")
+
+ stopped_order.clear()
+ await mgr.stop_plugin("a")
+
+ assert stopped_order == ["c", "b", "a"]
+ for pid in ("a", "b", "c"):
+ desc = await mgr.get_plugin(pid)
+ assert desc is not None
+ assert desc.state == PluginState.STOPPED
+
+
+@pytest.mark.asyncio
+async def test_get_extension_returns_highest_priority() -> None:
+ """After add, get_extension returns the single highest-priority extension."""
+ mgr = PluginManager()
+ await mgr.add(PluginA)
+
+ ext = await mgr.registry.get_extension("processors")
+ assert ext.label == "default"
+
+
+@pytest.mark.asyncio
+async def test_get_extension_raises_for_unknown_point() -> None:
+ mgr = PluginManager()
+ await mgr.add(PluginA)
+
+ with pytest.raises(ValueError, match="not registered"):
+ await mgr.registry.get_extension("nonexistent")
+
+
+@pytest.mark.asyncio
+async def test_get_extension_raises_for_empty_point() -> None:
+ """Registered point with no extensions → ValueError."""
+ from pyfly.plugins.registry import ExtensionRegistry
+
+ reg = ExtensionRegistry()
+ await reg.register_extension_point("empty-point", object)
+
+ with pytest.raises(ValueError, match="no registered extensions"):
+ await reg.get_extension("empty-point")
+
+
+@pytest.mark.asyncio
+async def test_failed_start_sets_failed_state() -> None:
+ """If a plugin's start() hook raises, state becomes FAILED."""
+
+ @plugin(id="bad-plugin")
+ class BadPlugin:
+ async def start(self) -> None:
+ raise RuntimeError("kaboom")
+
+ mgr = PluginManager()
+ await mgr.add(BadPlugin)
+
+ with pytest.raises(PluginStartError, match="kaboom"):
+ await mgr.start_plugin("bad-plugin")
+
+ desc = await mgr.get_plugin("bad-plugin")
+ assert desc is not None
+ assert desc.state == PluginState.FAILED
+ assert desc.failed_reason is not None
+ assert "kaboom" in desc.failed_reason
+
+
+@pytest.mark.asyncio
+async def test_get_plugin_returns_none_for_unknown() -> None:
+ mgr = PluginManager()
+ desc = await mgr.get_plugin("nope")
+ assert desc is None
+
+
+@pytest.mark.asyncio
+async def test_start_plugin_raises_state_error_for_unknown() -> None:
+ mgr = PluginManager()
+
+ with pytest.raises(PluginStateError):
+ await mgr.start_plugin("ghost")
+
+
+@pytest.mark.asyncio
+async def test_stop_plugin_raises_state_error_for_unknown() -> None:
+ mgr = PluginManager()
+
+ with pytest.raises(PluginStateError):
+ await mgr.stop_plugin("ghost")
+
+
+@pytest.mark.asyncio
+async def test_plugin_descriptor_captures_name_author() -> None:
+ """@plugin(name=, author=) fields are captured in the descriptor."""
+ mgr = PluginManager()
+ await mgr.add(PluginA)
+
+ desc = await mgr.get_plugin("a")
+ assert desc is not None
+ assert isinstance(desc, PluginDescriptor)
+ assert desc.plugin.name == "Plugin A"
+ assert desc.plugin.author == "alice"
+
+
+@pytest.mark.asyncio
+async def test_start_all_sets_started_state() -> None:
+ """start_all() marks every plugin STARTED."""
+
+ @plugin(id="s1")
+ class S1: ...
+
+ @plugin(id="s2", depends_on=("s1",))
+ class S2: ...
+
+ mgr = PluginManager()
+ await mgr.add(S1)
+ await mgr.add(S2)
+ await mgr.start_all()
+
+ for pid in ("s1", "s2"):
+ desc = await mgr.get_plugin(pid)
+ assert desc is not None
+ assert desc.state == PluginState.STARTED
+
+
+@pytest.mark.asyncio
+async def test_stop_all_sets_stopped_state() -> None:
+ """stop_all() marks every plugin STOPPED."""
+
+ @plugin(id="t1")
+ class T1: ...
+
+ @plugin(id="t2", depends_on=("t1",))
+ class T2: ...
+
+ mgr = PluginManager()
+ await mgr.add(T1)
+ await mgr.add(T2)
+ await mgr.start_all()
+ await mgr.stop_all()
+
+ for pid in ("t1", "t2"):
+ desc = await mgr.get_plugin(pid)
+ assert desc is not None
+ assert desc.state == PluginState.STOPPED
+
+
+@pytest.mark.asyncio
+async def test_stop_plugin_unused_raises_state_error() -> None:
+ """PluginStopError is importable and is a PluginException subclass."""
+ from pyfly.kernel.exceptions import PluginException
+
+ assert issubclass(PluginStopError, PluginException)
diff --git a/tests/plugins/test_plugins_auto_configuration.py b/tests/plugins/test_plugins_auto_configuration.py
new file mode 100644
index 00000000..3cc530fc
--- /dev/null
+++ b/tests/plugins/test_plugins_auto_configuration.py
@@ -0,0 +1,45 @@
+# Copyright 2026 Firefly Software Foundation.
+# Licensed under the Apache License, Version 2.0.
+"""Tests for PluginsAutoConfiguration bean wiring."""
+
+from __future__ import annotations
+
+from pyfly.plugins.auto_configuration import PluginsAutoConfiguration
+from pyfly.plugins.manager import PluginManager
+from pyfly.plugins.registry import ExtensionRegistry
+
+
+class TestPluginsAutoConfiguration:
+ def test_has_auto_configuration_marker(self) -> None:
+ assert getattr(PluginsAutoConfiguration, "__pyfly_auto_configuration__", False) is True
+
+ def test_has_configuration_stereotype(self) -> None:
+ assert getattr(PluginsAutoConfiguration, "__pyfly_stereotype__", None) == "configuration"
+
+ def test_has_on_property_condition(self) -> None:
+ conditions = getattr(PluginsAutoConfiguration, "__pyfly_conditions__", [])
+ types = [c["type"] for c in conditions]
+ assert "on_property" in types
+
+ def test_on_property_key_is_pyfly_plugins_enabled(self) -> None:
+ conditions = getattr(PluginsAutoConfiguration, "__pyfly_conditions__", [])
+ prop_cond = next(c for c in conditions if c["type"] == "on_property")
+ assert prop_cond["key"] == "pyfly.plugins.enabled"
+ assert prop_cond["having_value"] == "true"
+
+ def test_extension_registry_bean_produces_registry(self) -> None:
+ cfg = PluginsAutoConfiguration()
+ reg = cfg.extension_registry()
+ assert isinstance(reg, ExtensionRegistry)
+
+ def test_plugin_manager_bean_produces_manager(self) -> None:
+ cfg = PluginsAutoConfiguration()
+ reg = cfg.extension_registry()
+ mgr = cfg.plugin_manager(reg)
+ assert isinstance(mgr, PluginManager)
+
+ def test_plugin_manager_uses_provided_registry(self) -> None:
+ cfg = PluginsAutoConfiguration()
+ reg = cfg.extension_registry()
+ mgr = cfg.plugin_manager(reg)
+ assert mgr.registry is reg
diff --git a/tests/plugins/test_sp11_review_fixes.py b/tests/plugins/test_sp11_review_fixes.py
new file mode 100644
index 00000000..227c2440
--- /dev/null
+++ b/tests/plugins/test_sp11_review_fixes.py
@@ -0,0 +1,65 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Regressions for the SP-11 review: add() raises PluginLoadError; start_all() does not
+re-run hooks for plugins already started via start_plugin()."""
+
+from __future__ import annotations
+
+import pytest
+
+from pyfly.kernel.exceptions import PluginException, PluginLoadError
+from pyfly.plugins.decorators import plugin
+from pyfly.plugins.manager import PluginManager
+from pyfly.plugins.models import PluginState
+
+
+@pytest.mark.asyncio
+async def test_add_non_decorated_class_raises_plugin_load_error() -> None:
+ class NotAPlugin:
+ pass
+
+ manager = PluginManager()
+ with pytest.raises(PluginLoadError):
+ await manager.add(NotAPlugin)
+ assert issubclass(PluginLoadError, PluginException)
+
+
+@pytest.mark.asyncio
+async def test_start_all_does_not_double_run_already_started_plugin() -> None:
+ calls: list[str] = []
+
+ @plugin(id="p1")
+ class P1:
+ async def start(self) -> None:
+ calls.append("p1")
+
+ @plugin(id="p2", depends_on=("p1",))
+ class P2:
+ async def start(self) -> None:
+ calls.append("p2")
+
+ manager = PluginManager()
+ await manager.add(P1)
+ await manager.add(P2)
+
+ await manager.start_plugin("p1") # p1 STARTED individually
+ assert calls == ["p1"]
+
+ await manager.start_all() # must start only p2 (p1 already STARTED — no double-run)
+ assert calls == ["p1", "p2"]
+
+ p1_desc = await manager.get_plugin("p1")
+ p2_desc = await manager.get_plugin("p2")
+ assert p1_desc is not None and p1_desc.state == PluginState.STARTED
+ assert p2_desc is not None and p2_desc.state == PluginState.STARTED
diff --git a/uv.lock b/uv.lock
index f8266630..e22ada86 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2136,7 +2136,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.6.90"
+version = "26.6.91"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },