diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a567d6..a4aa2b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.06.46 (2026-06-07)
+
+### Added (container — SESSION bean scope)
+
+- **`Scope.SESSION`** — register a bean with `scope=Scope.SESSION` to get one instance per
+ HTTP session (Spring's session scope). The instance lives as an `HttpSession` attribute, so
+ the `SessionFilter` must be active; it is persisted with the session, so it must be
+ serializable when a non-memory session store (e.g. Redis) is used.
+- The `SessionFilter` now exposes the active `HttpSession` to the container via the
+ `RequestContext`, mirroring how `REQUEST`-scoped beans are resolved.
+
+(A general custom-scope SPI remains on the roadmap; this adds the concrete SESSION scope.)
+
## v26.06.45 (2026-06-07)
### Added (security — role hierarchy)
diff --git a/README.md b/README.md
index 3669db1..6077ad0 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml
index 181b757..c801c44 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.45"
+version = "26.6.46"
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 62d4991..2c93c30 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.45"
+__version__ = "26.06.46"
diff --git a/src/pyfly/container/container.py b/src/pyfly/container/container.py
index e9d3e18..a1ec260 100644
--- a/src/pyfly/container/container.py
+++ b/src/pyfly/container/container.py
@@ -321,6 +321,11 @@ def _resolve_registration(self, reg: Registration) -> Any:
self._ensure_metrics(reg.impl_type).resolution_count += 1
return instance
+ if reg.scope == Scope.SESSION:
+ instance = self._resolve_session_scoped(reg)
+ self._ensure_metrics(reg.impl_type).resolution_count += 1
+ return instance
+
instance = self._create_instance(reg)
self._ensure_metrics(reg.impl_type).resolution_count += 1
return instance
@@ -346,6 +351,37 @@ def _resolve_request_scoped(self, reg: Registration) -> Any:
ctx.set(cache_key, instance)
return instance
+ def _resolve_session_scoped(self, reg: Registration) -> Any:
+ """Resolve a SESSION-scoped bean from the active HttpSession's attributes.
+
+ The bean lives as a session attribute, so (like Spring's session-scoped beans) it
+ is persisted with the session — it must be serializable when a non-memory session
+ store (e.g. Redis) is used.
+ """
+ from pyfly.context.request_context import HTTP_SESSION_KEY, RequestContext
+
+ ctx = RequestContext.current()
+ if ctx is None:
+ raise RuntimeError(
+ f"No active request context for SESSION-scoped bean "
+ f"{reg.impl_type.__name__}. Ensure a RequestContextFilter is active."
+ )
+ session = ctx.get(HTTP_SESSION_KEY)
+ if session is None:
+ raise RuntimeError(
+ f"No HTTP session for SESSION-scoped bean {reg.impl_type.__name__}. "
+ f"Ensure the session module (SessionFilter) is enabled."
+ )
+
+ cache_key = f"__pyfly_bean_{reg.impl_type.__qualname__}"
+ existing = session.get_attribute(cache_key)
+ if existing is not None:
+ return existing
+
+ instance = self._create_instance(reg)
+ session.set_attribute(cache_key, instance)
+ return instance
+
def _create_instance(self, reg: Registration) -> Any:
"""Create an instance, resolving constructor and field dependencies."""
if reg.impl_type in self._resolving:
diff --git a/src/pyfly/container/types.py b/src/pyfly/container/types.py
index 56c7c51..a94950d 100644
--- a/src/pyfly/container/types.py
+++ b/src/pyfly/container/types.py
@@ -22,3 +22,4 @@ class Scope(Enum):
SINGLETON = auto()
TRANSIENT = auto()
REQUEST = auto()
+ SESSION = auto()
diff --git a/src/pyfly/context/request_context.py b/src/pyfly/context/request_context.py
index beae002..947bb90 100644
--- a/src/pyfly/context/request_context.py
+++ b/src/pyfly/context/request_context.py
@@ -27,6 +27,10 @@
_request_context_var: ContextVar[RequestContext | None] = ContextVar("pyfly_request_context", default=None)
+# Attribute key under which the SessionFilter stashes the active HttpSession, so the
+# container can resolve SESSION-scoped beans from it.
+HTTP_SESSION_KEY = "__pyfly_http_session__"
+
class RequestContext:
"""Holds per-request state: request ID, security context, and custom attributes.
diff --git a/src/pyfly/session/filter.py b/src/pyfly/session/filter.py
index 6516182..5dc9ade 100644
--- a/src/pyfly/session/filter.py
+++ b/src/pyfly/session/filter.py
@@ -53,6 +53,12 @@ def __init__(
async def do_filter(self, request: Any, call_next: CallNext) -> Any:
session = await self._load_or_create_session(request)
request.state.session = session
+ # Expose the session to the container for SESSION-scoped bean resolution.
+ from pyfly.context.request_context import HTTP_SESSION_KEY, RequestContext
+
+ ctx = RequestContext.current()
+ if ctx is not None:
+ ctx.set(HTTP_SESSION_KEY, session)
try:
response = await call_next(request)
diff --git a/tests/container/test_session_scope.py b/tests/container/test_session_scope.py
new file mode 100644
index 0000000..fe591d4
--- /dev/null
+++ b/tests/container/test_session_scope.py
@@ -0,0 +1,72 @@
+# 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.
+"""SESSION-scoped beans (v26.06.46): one instance per HttpSession."""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from pyfly.container.container import Container
+from pyfly.container.types import Scope
+from pyfly.context.request_context import HTTP_SESSION_KEY, RequestContext
+from pyfly.session.session import HttpSession
+
+
+class SessionBean:
+ pass
+
+
+@pytest.fixture(autouse=True)
+def _clean_request_context() -> Iterator[None]:
+ RequestContext.clear()
+ yield
+ RequestContext.clear()
+
+
+def _container() -> Container:
+ container = Container()
+ container.register(SessionBean, scope=Scope.SESSION)
+ return container
+
+
+def test_same_instance_within_one_session() -> None:
+ container = _container()
+ ctx = RequestContext.init()
+ ctx.set(HTTP_SESSION_KEY, HttpSession("sid-1", {}))
+ assert container.resolve(SessionBean) is container.resolve(SessionBean)
+
+
+def test_distinct_instances_across_sessions() -> None:
+ container = _container()
+ ctx = RequestContext.init()
+ ctx.set(HTTP_SESSION_KEY, HttpSession("sid-1", {}))
+ first = container.resolve(SessionBean)
+ ctx.set(HTTP_SESSION_KEY, HttpSession("sid-2", {}))
+ second = container.resolve(SessionBean)
+ assert first is not second
+
+
+def test_requires_an_http_session() -> None:
+ container = _container()
+ RequestContext.init() # no session attached
+ with pytest.raises(RuntimeError, match="No HTTP session"):
+ container.resolve(SessionBean)
+
+
+def test_requires_a_request_context() -> None:
+ container = _container()
+ with pytest.raises(RuntimeError, match="No active request context"):
+ container.resolve(SessionBean)
diff --git a/uv.lock b/uv.lock
index 30fc00d..7992b2d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1981,7 +1981,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.6.45"
+version = "26.6.46"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },