diff --git a/CHANGELOG.md b/CHANGELOG.md
index c56e4d7..44bbd14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.09.04 (2026-09-10)
+
+### Fixed
+
+- **A restart reproduces a cold start.** `26.09.03` made `stop()` release the singletons it built, but
+ the REGISTRATIONS the previous `start()` created were left in place, so the next `start()` layered a
+ second pipeline on top of them instead of rebuilding. Two things went wrong, both silent:
+
+ - a `@bean` reachable under several keys — its concrete class and the protocol it satisfies — stopped
+ being one object. The first start aliased those keys to a single instance; the restart resolved each
+ key independently and called the factory once per key, so `get_bean(Protocol)` and
+ `get_bean(Concrete)` returned **different** singletons.
+ - the registration set drifted, because the `@conditional_on_*` passes re-evaluated against a registry
+ that already held the previous run's output rather than against the user's own definitions.
+
+ `start()` now records exactly which registrations its pipeline added and drops them at the next start,
+ so the registry a restart begins from is the one a cold start begins from. Two tests pin it: a bean
+ published under an interface stays one instance across a restart, and the registration set after a
+ restart is identical to the set after the cold start.
+
+ This completes the lifecycle trilogy of `26.09.02` (idempotent `start()`), `26.09.03` (`stop()`
+ releases what it destroyed) and this release, all found by one service that shares a module-level
+ application across test modules.
+
+---
+
## v26.09.03 (2026-09-10)
### Fixed
diff --git a/README.md b/README.md
index 5710fa7..9ea7446 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml
index eec1c5f..05b4dd7 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.9.3"
+version = "26.9.4"
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 7afe1ec..25f246c 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.09.03"
+__version__ = "26.09.04"
diff --git a/src/pyfly/context/application_context.py b/src/pyfly/context/application_context.py
index 9d8ef01..56fd734 100644
--- a/src/pyfly/context/application_context.py
+++ b/src/pyfly/context/application_context.py
@@ -73,6 +73,9 @@ def __init__(self, config: Config) -> None:
self._started = False
#: Instances the container was HANDED rather than built; stop() must not release them.
self._preexisting_instances: set[int] = set()
+ #: Registration keys the last start() pipeline added; dropped at the next start so a restart
+ #: rebuilds from the same registry a cold start sees.
+ self._pipeline_registrations: frozenset[Any] = frozenset()
self._infrastructure_adapters: list[Any] = []
self._task_scheduler: Any | None = None
self._background_tasks: list[asyncio.Task[Any]] = []
@@ -198,6 +201,22 @@ async def start(self) -> None:
async def _do_start(self) -> None:
"""Internal startup logic."""
+ # A RESTART MUST REPRODUCE A COLD START.
+ #
+ # stop() releases the singletons it built, but the REGISTRATIONS this pipeline created are
+ # still here, and layering a second pipeline on top of them is not a rebuild. Two things go
+ # wrong, both silent. A @bean reachable under several keys — its concrete class and the
+ # protocol it satisfies — stops being one object, because the first start aliased those keys to
+ # a single instance and a second pass resolves each key independently, calling the factory once
+ # per key. And the registry drifts, because the conditional passes re-evaluate against a
+ # registry that already holds the previous run's output rather than against the user's own
+ # definitions. Dropping what the last pipeline added puts the registry back to the state a cold
+ # start begins from.
+ for key in getattr(self, "_pipeline_registrations", frozenset()):
+ self._container._registrations.pop(key, None)
+
+ registrations_before = set(self._container._registrations.keys())
+
# Whatever already carries an instance was HANDED to the container, not built by it — the
# container's self-registration, the context's own, anything an embedder registered as a
# ready-made object. stop() releases what this start creates and leaves these alone, because
@@ -310,6 +329,10 @@ async def _do_start(self) -> None:
await self._event_bus.publish(ContextRefreshedEvent())
await self._event_bus.publish(ApplicationReadyEvent())
await self._invoke_runners()
+ # Everything this pipeline added, so the next start can drop it and begin from the same
+ # registry a cold start begins from.
+ self._pipeline_registrations = frozenset(self._container._registrations.keys()) - registrations_before
+
self._started = True
# Lazily-created singletons (built post-startup on first resolve) must still
# run the full init pipeline. Installed now so the batched startup passes
diff --git a/tests/context/test_application_context.py b/tests/context/test_application_context.py
index 06234f7..bd108f4 100644
--- a/tests/context/test_application_context.py
+++ b/tests/context/test_application_context.py
@@ -788,3 +788,83 @@ async def test_stop_releases_the_singletons_it_destroyed() -> None:
assert id(first) not in live, "the destroyed singleton is still registered after the restart"
await context.stop()
+
+
+# ---------------------------------------------------------------------------
+# A restart must reproduce a cold start
+#
+# stop() releases the singletons it built (26.09.03), but the REGISTRATIONS the
+# previous start created were left in place, so the next start layered a second
+# pipeline on top of them rather than rebuilding. Two things went wrong, and both
+# are silent:
+#
+# * a @bean reachable under several keys — its concrete class and the protocol
+# it satisfies — stopped being one object. The first start aliased the keys to
+# a single instance; the restart resolved each key independently and called the
+# factory once per key, so `get_bean(Protocol)` and `get_bean(Concrete)`
+# returned DIFFERENT singletons.
+# * the registration set drifted, because the conditional passes re-evaluated
+# against a registry that already held the previous run's output.
+# ---------------------------------------------------------------------------
+
+
+class _Port:
+ """The interface a bean is published under, alongside its concrete class."""
+
+
+class _Adapter(_Port):
+ pass
+
+
+@configuration
+class _PortConfiguration:
+ @bean
+ def adapter(self) -> _Port:
+ return _Adapter()
+
+
+@pytest.mark.asyncio
+async def test_a_restart_keeps_one_instance_per_bean_across_every_key() -> None:
+ context = ApplicationContext(Config({}))
+ context.register_bean(_PortConfiguration)
+
+ await context.start()
+ assert context.get_bean(_Port) is context.get_bean(_Port)
+ await context.stop()
+
+ await context.start()
+ by_port = context.get_bean(_Port)
+
+ reachable = {
+ id(reg.instance)
+ for reg in context._container._registrations.values()
+ if isinstance(getattr(reg, "instance", None), _Adapter)
+ }
+ assert len(reachable) == 1, (
+ f"after a restart the bean is registered as {len(reachable)} distinct instances; "
+ "a singleton reachable under several keys must stay one object"
+ )
+ assert id(by_port) in reachable
+
+ await context.stop()
+
+
+@pytest.mark.asyncio
+async def test_a_restart_does_not_drift_the_registration_set() -> None:
+ context = ApplicationContext(Config({}))
+ context.register_bean(_PortConfiguration)
+
+ await context.start()
+ first = set(context._container._registrations.keys())
+ await context.stop()
+
+ await context.start()
+ second = set(context._container._registrations.keys())
+
+ assert second == first, (
+ "a restart produced a different registry than the cold start: "
+ f"added {sorted(str(k) for k in second - first)}, "
+ f"lost {sorted(str(k) for k in first - second)}"
+ )
+
+ await context.stop()
diff --git a/uv.lock b/uv.lock
index e2dcc51..50a4fc6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2285,7 +2285,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.9.2"
+version = "26.9.3"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },