openframe-core 3.7.0__tar.gz → 3.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {openframe_core-3.7.0 → openframe_core-3.8.0}/.github/CHANGELOG.md +34 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/PKG-INFO +1 -1
- openframe_core-3.8.0/docs/technical/architecture/adrs/adr-008-session-handler.md +113 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/system-architecture.md +1 -1
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/overview/roadmap.md +101 -6
- {openframe_core-3.7.0 → openframe_core-3.8.0}/mkdocs.yml +1 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/inbound/__init__.py +9 -0
- openframe_core-3.8.0/openframe/core/inbound/session.py +180 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/__init__.py +9 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/contracts/__init__.py +7 -4
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/contracts/inbound.py +94 -9
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/fakes/__init__.py +7 -4
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/fakes/inbound.py +80 -7
- {openframe_core-3.7.0 → openframe_core-3.8.0}/pyproject.toml +1 -1
- openframe_core-3.8.0/tests/test_inbound.py +296 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_inbound_contracts.py +86 -1
- openframe_core-3.8.0/tests/test_remote.py +66 -0
- openframe_core-3.7.0/tests/test_inbound.py +0 -158
- {openframe_core-3.7.0 → openframe_core-3.8.0}/.github/requirements/docs.txt +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/.github/requirements/test.txt +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/.github/workflows/app-test.yml +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/.github/workflows/auto-docs.yml +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/.github/workflows/python-build.yml +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/.gitignore +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/.vscode/settings.json +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/LICENSE +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/README.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/assets/images/banner.png +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/assets/images/favicon.png +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/assets/images/logo-dark.png +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/assets/images/logo-light.png +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/assets/javascripts/extra.js +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/assets/stylesheets/extra.css +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/cicd/pipeline.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/getting-started/index.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/config.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/exceptions.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/health.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/inbound.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/index.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/middleware.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/plugins.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/ports.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/runtime.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/telemetry.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/testing.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/code/modules/tracing.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/developer-guide/debugging.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/developer-guide/explore.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/developer-guide/first-change.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/developer-guide/how-it-works.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/developer-guide/local-dev.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/developer-guide/quick-start/index.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/index.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/adrs/adr-001-namespace.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/adrs/adr-002-hexagonal.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/adrs/adr-003-async.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/adrs/adr-004-pydantic.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/adrs/adr-005-middleware.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/adrs/adr-006-unified-port-lifecycle.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/adrs/adr-007-schema-contracts.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/adrs/index.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/capability-taxonomy.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/component-lifecycle.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/data-flows.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/design-decisions.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/error-taxonomy.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/package-journey/adapter-lifecycle.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/package-journey/error-propagation.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/package-journey/index.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/package-journey/template-wiring.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/package-journey/tracing-flow.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/deployment/cicd.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/deployment/environments.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/deployment/secrets-and-config.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/infrastructure/networking.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/infrastructure/platform.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/infrastructure/storage.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/operations/index.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/operations/monitoring.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/operations/runbooks/adapter-connection-failure.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/operations/runbooks/modal-cold-start-spike.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/operations/runbooks/postgres-pool-exhausted.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/operations/runbooks/queue-consumer-stuck.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/operations/runbooks/redis-connection-lost.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/operations/runbooks/telemetry-export-failing.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/overview/glossary.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/overview/system-overview.md +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/config/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/config/settings.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/exceptions/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/exceptions/adapter.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/exceptions/base.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/exceptions/codes.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/exceptions/plugin.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/inbound/command.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/inbound/context.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/inbound/query.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/inbound/usecase.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/middleware/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/middleware/telemetry.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/middleware/types.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/plugins/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/plugins/registry.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/capability.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/context.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/health.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/identity.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/lifecycle.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/outbound/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/outbound/consumer.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/outbound/producer.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/outbound/repository.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/ports/port.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/remote/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/remote/client.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/resilience/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/resilience/circuit_breaker.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/runtime/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/runtime/bootstrap.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/schemas/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/telemetry/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/telemetry/setup.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/contracts/consumer.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/contracts/graph_store.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/contracts/lifecycle.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/contracts/port.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/contracts/producer.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/contracts/remote.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/contracts/repository.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/contracts/vector_store.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/fakes/consumer.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/fakes/graph_store.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/fakes/producer.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/fakes/repository.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/fakes/vector_store.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/testing/fixtures.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/tracing/__init__.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/tracing/propagation.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/core/tracing/proxy.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/openframe/py.typed +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/conftest.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_config.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_exceptions.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_middleware.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_middleware_types.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_plugins.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_ports.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_ports_primitives.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_record_error.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_resilience.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_runtime.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_schemas.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_shutdown_telemetry.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_telemetry_sidecar.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_testing_fakes.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_tracing.py +0 -0
- {openframe_core-3.7.0 → openframe_core-3.8.0}/tests/test_tracing_propagation.py +0 -0
|
@@ -11,6 +11,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## v3.8.0 — 2026-10-17
|
|
15
|
+
|
|
16
|
+
### Added — `SessionHandler`/`Session`/`CloseReason` (ADR-008)
|
|
17
|
+
|
|
18
|
+
A new driving-side contract for long-lived connections (WebSocket, SSE),
|
|
19
|
+
sibling to `UseCase`/`CommandHandler`/`QueryHandler` (ADR-006). Built to
|
|
20
|
+
unblock `openframe-protocol`'s session-transport work, which found the
|
|
21
|
+
design spec's own `SessionHandler` sketch had zero code anywhere and left
|
|
22
|
+
one concrete question unresolved: how a handler sends events *out*.
|
|
23
|
+
|
|
24
|
+
- `openframe.core.inbound.Session[TOut]` — `@runtime_checkable Protocol`,
|
|
25
|
+
`context: RequestContext` (built once per session, not once per event)
|
|
26
|
+
plus `send(event)`/`close(reason)`. Push-based, not a pull-based
|
|
27
|
+
`AsyncIterator` return value — see ADR-008 for the full reasoning.
|
|
28
|
+
- `openframe.core.inbound.SessionHandler[TIn, TOut]` —
|
|
29
|
+
`on_open(session)`/`on_event(session, event)`/`on_close(session, reason)`.
|
|
30
|
+
`on_open` doubles as accept/reject: raising rejects, flowing through the
|
|
31
|
+
same `categorize`/`build_envelope` path (`openframe-protocol-base`)
|
|
32
|
+
every other inbound contract's failures already use.
|
|
33
|
+
- `openframe.core.inbound.CloseReason` — closed `Enum`:
|
|
34
|
+
`COMPLETED`/`CLIENT_DISCONNECT`/`SERVER_SHUTDOWN`/`ERROR`.
|
|
35
|
+
- `openframe.core.testing.SessionHandlerContractTests` and
|
|
36
|
+
`openframe.core.testing.InMemorySession` — same fixture-based subclass
|
|
37
|
+
pattern as the three existing inbound contract-test classes/fakes.
|
|
38
|
+
- Deliberately built directly in `openframe-core`, not incubated in
|
|
39
|
+
`openframe-protocol-base` first per the design spec's own stated
|
|
40
|
+
rule-of-three gate — documented as an explicit, conscious exception in
|
|
41
|
+
ADR-008, not a precedent for skipping that discipline generally.
|
|
42
|
+
- Purely additive — no existing contract changed. 445 tests passing (see
|
|
43
|
+
`tests/test_inbound.py`/`test_inbound_contracts.py` for the new
|
|
44
|
+
`SessionHandler`/`Session`/`InMemorySession` coverage).
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
14
48
|
## v3.7.0 — 2026-10-11
|
|
15
49
|
|
|
16
50
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openframe-core
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.0
|
|
4
4
|
Summary: OpenFrame Microservice Suite - platform kernel. Port contracts, testing utilities, plugin registry, and composition root.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-core
|
|
6
6
|
Project-URL: Documentation, https://furious-meteors.github.io/openframe-core/
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# ADR-008 — `SessionHandler`: a driving-side contract for long-lived connections
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted — v3.8.0
|
|
4
|
+
**Extends:** [ADR-006 — Unified Port + Lifecycle Contract](adr-006-unified-port-lifecycle.md)'s
|
|
5
|
+
`openframe.core.inbound` module (`UseCase`, `CommandHandler`, `QueryHandler`, `RequestContext`).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
`openframe-protocol` builds two request/response transports (`-http`, `-grpc`) on
|
|
12
|
+
`openframe.core.inbound`'s existing contracts: one input in, one output out, done. Neither shape
|
|
13
|
+
fits a long-lived connection — WebSocket (bidirectional) or Server-Sent Events (unidirectional,
|
|
14
|
+
server→client) — where a handler may receive zero or many inbound events and send zero or many
|
|
15
|
+
outbound events over the connection's lifetime.
|
|
16
|
+
|
|
17
|
+
`openframe-protocol`'s own design spec sketches a `SessionHandler[TEvent]` contract
|
|
18
|
+
(`on_open`/`on_event`/`on_close`) as the intended fix, with an explicit rule: incubate it in
|
|
19
|
+
`openframe-protocol-base` first, and only promote it to `openframe-core` once three independent
|
|
20
|
+
session transports have proven the shape against the same conformance suite — the same
|
|
21
|
+
rule-of-three discipline this repo already applies to `Capability` and `ErrorCategory` additions.
|
|
22
|
+
|
|
23
|
+
That sketch has zero lines of code anywhere, in either repo, and the design spec itself flags one
|
|
24
|
+
concrete unresolved question as a blocker: **how does a handler actually send events to the
|
|
25
|
+
client?** `on_open`/`on_event`/`on_close` describe the inbound (driving) half of the contract only
|
|
26
|
+
— receiving events and lifecycle notifications — but say nothing about the outbound half.
|
|
27
|
+
|
|
28
|
+
## Decision
|
|
29
|
+
|
|
30
|
+
**Build `SessionHandler` directly in `openframe.core.inbound`, not incubated in
|
|
31
|
+
`openframe-protocol-base` first** — a deliberate deviation from the stated rule-of-three gate (see
|
|
32
|
+
"Why skip incubation" below) — alongside a new `Session[TOut]` contract that resolves the
|
|
33
|
+
unanswered outbound-events question.
|
|
34
|
+
|
|
35
|
+
- **`Session[TOut]`** (`openframe/core/inbound/session.py`) — `@runtime_checkable Protocol`,
|
|
36
|
+
carries `context: RequestContext` (built once per session, not once per event — a session has
|
|
37
|
+
one correlation id for its whole lifetime) plus two methods:
|
|
38
|
+
- `async def send(self, event: TOut) -> None` — push one outbound event.
|
|
39
|
+
- `async def close(self, reason: CloseReason = CloseReason.COMPLETED) -> None` — end the
|
|
40
|
+
session. Idempotent.
|
|
41
|
+
|
|
42
|
+
**Push-based, not a pull-based `AsyncIterator[TOut]` return value.** A handler can call
|
|
43
|
+
`session.send()` from inside `on_event` (responding to an inbound message) *or* from a
|
|
44
|
+
background task it spawns at `on_open` (e.g. forwarding events from an unrelated source — a
|
|
45
|
+
Kafka consumer — into an already-open connection). A generator return value can't express the
|
|
46
|
+
latter without the handler building its own internal queue anyway, at which point that queue
|
|
47
|
+
*is* a `Session` object in a different shape — so `Session` names the thing directly instead of
|
|
48
|
+
leaving every handler to reinvent it.
|
|
49
|
+
|
|
50
|
+
- **`SessionHandler[TIn, TOut]`** (same module) — `@runtime_checkable Protocol` with three methods:
|
|
51
|
+
- `async def on_open(self, session: Session[TOut]) -> None` — accept (return normally) or reject
|
|
52
|
+
(raise) a new session. A raised exception flows through
|
|
53
|
+
`openframe.protocol.base.errors.categorize`/`build_envelope` — the exact same mapping path
|
|
54
|
+
every other inbound contract's failures already use — rather than a second return-value
|
|
55
|
+
channel a caller would need a separate check for.
|
|
56
|
+
- `async def on_event(self, session: Session[TOut], event: TIn) -> None` — called once per
|
|
57
|
+
inbound event, in arrival order. **Never called at all** for a one-directional transport (SSE,
|
|
58
|
+
which has no client→server events) — a valid, expected lifecycle, not a contract violation.
|
|
59
|
+
- `async def on_close(self, session: Session[TOut], reason: CloseReason) -> None` — called
|
|
60
|
+
exactly once, however the session ended.
|
|
61
|
+
|
|
62
|
+
- **`CloseReason`** (same module) — closed `Enum`: `COMPLETED`, `CLIENT_DISCONNECT`,
|
|
63
|
+
`SERVER_SHUTDOWN`, `ERROR`. Every session transport maps its own disconnect/shutdown signal onto
|
|
64
|
+
one of these four, mirroring how `ErrorCategory` gives every transport one shared vocabulary for
|
|
65
|
+
errors instead of each inventing its own.
|
|
66
|
+
|
|
67
|
+
- **Testing**: `SessionHandlerContractTests` (`testing/contracts/inbound.py`, alongside the
|
|
68
|
+
existing three inbound contract-test classes) and `InMemorySession`
|
|
69
|
+
(`testing/fakes/inbound.py`, alongside `EchoUseCase`/`SpyCommandHandler`/`SpyQueryHandler`) —
|
|
70
|
+
same fixture-based subclass pattern, same zero-pytest-at-module-level discipline.
|
|
71
|
+
|
|
72
|
+
### Why skip incubation in `-base`
|
|
73
|
+
|
|
74
|
+
The rule-of-three gate exists to de-risk a contract shape against real, demonstrated need before
|
|
75
|
+
it lands in the platform kernel. The one genuine open question that gate would have surfaced —
|
|
76
|
+
how outbound events are sent — is resolved by this decision itself (the `Session` push model
|
|
77
|
+
above), reasoned from first principles against both a one-directional transport (SSE) and a
|
|
78
|
+
bidirectional one (WebSocket) rather than discovered incrementally across three separate builds.
|
|
79
|
+
Incubating a second time in `-base` before any transport exists to prove it against would add a
|
|
80
|
+
layer of indirection with no remaining uncertainty left to burn down. This is recorded here as an
|
|
81
|
+
explicit, conscious exception — not a precedent for skipping the rule-of-three discipline
|
|
82
|
+
generally; `Capability`/`ErrorCategory`-style additions should still default to it.
|
|
83
|
+
|
|
84
|
+
## Consequences
|
|
85
|
+
|
|
86
|
+
- **`openframe.core.inbound.__init__`** exports `SessionHandler`, `Session`, `CloseReason`
|
|
87
|
+
alongside the existing `UseCase`, `CommandHandler`, `QueryHandler`, `RequestContext`.
|
|
88
|
+
- **No change to any existing contract.** `UseCase`/`CommandHandler`/`QueryHandler`/
|
|
89
|
+
`RequestContext` are untouched — this is a pure addition.
|
|
90
|
+
- **`openframe-protocol-base` gains transport-neutral session plumbing next** (a `run_session`
|
|
91
|
+
loop helper and a `SessionConformanceTests` suite), reusing `build_request_context` and
|
|
92
|
+
`categorize`/`build_envelope` as-is — both already generic enough for a session's single
|
|
93
|
+
per-connection context and in-band error events, confirmed during design research, not assumed.
|
|
94
|
+
- **SSE is the first transport to implement `SessionHandler`**, as a new module inside
|
|
95
|
+
`openframe-protocol-http` (per that repo's own design spec placement decision) rather than a new
|
|
96
|
+
package — chosen because it is one-directional (`on_event` never fires), the lower-risk half of
|
|
97
|
+
the contract to prove first. WebSocket (bidirectional, its own package-boundary question) is
|
|
98
|
+
explicitly out of scope for this ADR.
|
|
99
|
+
- **Version bump: `3.7.0` → `3.8.0`** — additive, non-breaking minor version.
|
|
100
|
+
|
|
101
|
+
## Alternatives considered
|
|
102
|
+
|
|
103
|
+
- **Pull-based `AsyncIterator[TOut]` return value from `on_open`.** Rejected: maps cleanly onto
|
|
104
|
+
SSE alone, but can't express a handler pushing an event from outside its own generator's loop
|
|
105
|
+
(an external task, or `on_event` triggering an unprompted send) without the handler building an
|
|
106
|
+
internal queue regardless — see "Decision" above.
|
|
107
|
+
- **Model session rejection as a return value** (e.g. `on_open` returns `bool`) instead of raising.
|
|
108
|
+
Rejected: every other inbound contract in this module signals failure by raising, and a second,
|
|
109
|
+
bespoke "did it accept" channel here would mean `SessionHandler` is the only inbound contract a
|
|
110
|
+
caller can't route through the shared `categorize`/`build_envelope` error path uniformly.
|
|
111
|
+
- **Incubate in `openframe-protocol-base` first, per the design spec's own stated rule.**
|
|
112
|
+
Rejected for this specific contract — see "Why skip incubation" above. Not a general reversal of
|
|
113
|
+
the rule-of-three discipline for future additions.
|
{openframe_core-3.7.0 → openframe_core-3.8.0}/docs/technical/architecture/system-architecture.md
RENAMED
|
@@ -11,7 +11,7 @@ As of v3.1.0, `ports/` unifies what were previously two separate
|
|
|
11
11
|
modules: `openframe.core.contracts` (the port/lifecycle primitives) and
|
|
12
12
|
`openframe.core.ports` (the outbound protocols, now internally organised
|
|
13
13
|
under `ports/outbound/`). See
|
|
14
|
-
[ADR-006's v3.1.0 addendum](adrs/adr-006-unified-port-lifecycle.md#addendum
|
|
14
|
+
[ADR-006's v3.1.0 addendum](adrs/adr-006-unified-port-lifecycle.md#addendum-contracts-merged-into-ports-outbound-sub-module-introduced-v310)
|
|
15
15
|
for the full rationale.
|
|
16
16
|
|
|
17
17
|
---
|
|
@@ -41,11 +41,106 @@ Lower-priority items:
|
|
|
41
41
|
|
|
42
42
|
### openframe-adapters
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
44
|
+
Done since this was last updated:
|
|
45
|
+
|
|
46
|
+
- ~~`openframe-adapters-db-postgres` — first adapter, reference implementation~~
|
|
47
|
+
- ~~`openframe-adapters-db-redis` — key-value, pub/sub, semantic cache~~
|
|
48
|
+
- ~~`openframe-adapters-db-mongo` — document store for AI Research Vault Phase 1~~
|
|
49
|
+
- ~~`openframe-adapters-queue-kafka` — producer + consumer, unlocks event-driven template~~
|
|
50
|
+
- ~~Remaining 11 adapters in parallel once the pattern is validated~~ —
|
|
51
|
+
built in checklist-driven waves (see `openframe-adapters`' own
|
|
52
|
+
`.github/CHANGELOG.md`): queue (nats, rabbitmq), relational
|
|
53
|
+
(cockroachdb, mysql, mariadb, oracle), key-value/columnar (dynamodb,
|
|
54
|
+
cassandra), time-series (influxdb), vector (qdrant, milvus, chromadb),
|
|
55
|
+
graph (falkordb). 17 real packages total. FAISS remains explicitly
|
|
56
|
+
descoped — no native id→metadata store, doesn't fit
|
|
57
|
+
`BaseVectorStore`'s id-addressable design.
|
|
58
|
+
- ~~`BaseVectorStore[T]` / `BaseGraphStore[T]`~~ — both shipped in this
|
|
59
|
+
repo (v3.5.0 / v3.6.0), each extending `BaseRepository[T]` directly
|
|
60
|
+
plus exactly one new method, rather than as disjoint sibling ports.
|
|
61
|
+
See [ADR-006](../architecture/adrs/adr-006-unified-port-lifecycle.md)
|
|
62
|
+
for the port-design philosophy both followed.
|
|
63
|
+
|
|
64
|
+
Next capabilities under consideration — each is a candidate NEW
|
|
65
|
+
top-level capability, not an extension of an existing adapter package.
|
|
66
|
+
**Every adapter in this ecosystem depends only on `openframe-core` plus
|
|
67
|
+
its own driver library, never on a sibling `openframe-adapters-*`
|
|
68
|
+
package** (e.g. `openframe-adapters-db-falkordb` depends on `redis-py`
|
|
69
|
+
directly, not on `openframe-adapters-db-redis`, even though FalkorDB is
|
|
70
|
+
Redis-based under the hood) — this constraint applies to every item
|
|
71
|
+
below too, most pointedly the Redis-backed ones:
|
|
72
|
+
|
|
73
|
+
- **Object storage** (`BaseObjectStore` — already named in the v3.1.0
|
|
74
|
+
entry further down this same file) — S3, GCS, Azure Blob, MinIO. Strongest fit for the existing
|
|
75
|
+
extend-`BaseRepository`-plus-one-method pattern; put/get/delete/list
|
|
76
|
+
by key is arguably more naturally CRUD-shaped than graph nodes were.
|
|
77
|
+
**Superseded twice, history kept for continuity.** Pass 1 of this
|
|
78
|
+
entry proposed an outbound FTP/SFTP client adapter here under
|
|
79
|
+
`Capability.STORAGE` (server half → `openframe-protocol`, client half
|
|
80
|
+
→ here). Pass 2 (`openframe-protocol` draft 7) moved both halves of
|
|
81
|
+
FTP into `openframe-protocol-ftp` together, removing it as a
|
|
82
|
+
candidate here entirely. **Pass 3 (`openframe-protocol` draft 8,
|
|
83
|
+
current)** reverses pass 2: the boundary is now "by peer, not by
|
|
84
|
+
protocol" — FTP's *server* half (accepting uploads from unknown
|
|
85
|
+
callers) stays in `openframe-protocol-ftp`, but the *client* half
|
|
86
|
+
(connecting to one specific named third-party FTP server) is a
|
|
87
|
+
third-party-peer integration and belongs back here, in
|
|
88
|
+
`openframe-adapters`, as originally thought. Its exact `Capability`
|
|
89
|
+
fit is explicitly **not yet decided** (draft 8's own open decision
|
|
90
|
+
D11 — `STORAGE` is a candidate, not settled) — don't treat the old
|
|
91
|
+
pass-1 `STORAGE` placement as confirmed; it's a plausible answer to
|
|
92
|
+
an open question, not a closed one. Same reopened status applies to
|
|
93
|
+
RTMP push and RTSP pull (D11 again — none of the eleven existing
|
|
94
|
+
`Capability` members fits either yet) and to a raw SMTP relay
|
|
95
|
+
(tentatively `NOTIFICATION`, same not-yet-added caveat as below).
|
|
96
|
+
- **Full-text/structured search** — Elasticsearch, OpenSearch. Reuses
|
|
97
|
+
`Capability.SEARCH` (already shared with vector/graph stores); own
|
|
98
|
+
query shape (term/boolean queries, not vector similarity or Cypher).
|
|
99
|
+
- **Secrets management** (`BaseSecretsProvider` — also pre-named) —
|
|
100
|
+
Vault, AWS/GCP/Azure secret managers. Not CRUD-shaped — versioned key
|
|
101
|
+
lookups need their own port design, not a `BaseRepository` extension.
|
|
102
|
+
- **Feature flags** (`BaseFeatureFlagProvider` — also pre-named) —
|
|
103
|
+
LaunchDarkly, Unleash, GrowthBook. Not CRUD-shaped — boolean/variant
|
|
104
|
+
evaluation.
|
|
105
|
+
- **Distributed locking** — own Redis/etcd/Zookeeper client (never the
|
|
106
|
+
`openframe-adapters-db-redis` package itself). `acquire`/`release`/
|
|
107
|
+
`extend` is close to CRUD-shaped but isn't quite it.
|
|
108
|
+
- **Notifications** (email/SMS/push, plus Slack/webhook alerts and
|
|
109
|
+
PagerDuty/Opsgenie-style paging) — SES, SendGrid, Twilio, FCM, Slack
|
|
110
|
+
incoming webhooks, PagerDuty. Shaped like `BaseProducer`
|
|
111
|
+
(fire-and-forget/ack), not `BaseRepository`. Intended new taxonomy
|
|
112
|
+
member: `Capability.NOTIFICATION` — named now, not added to the enum
|
|
113
|
+
yet (see below). The role this covers is distinct from this repo's
|
|
114
|
+
existing logging/OTel-metrics signals: those are machine-consumed
|
|
115
|
+
telemetry (dashboards, alerting pipelines that process metrics);
|
|
116
|
+
`NOTIFICATION` covers reaching an actual human or external system
|
|
117
|
+
directly (an email landing in an inbox, a page firing, a Slack
|
|
118
|
+
message posting) — a different capability, not a backend for the
|
|
119
|
+
telemetry this repo already has. Candidate justification, following
|
|
120
|
+
the same bar `Capability.SEARCH` met (shared by multiple genuinely
|
|
121
|
+
analogous backends, not invented for one): email, SMS, push, and
|
|
122
|
+
chat/paging providers are all the same operational role — deliver a
|
|
123
|
+
message to an end-human/device via an external provider — which is
|
|
124
|
+
why this is a stronger fit than forcing it onto the existing `QUEUE`
|
|
125
|
+
member (which specifically describes message *brokers* with
|
|
126
|
+
consumer-side subscribe/ack semantics; SES/SendGrid/Slack have none
|
|
127
|
+
of that).
|
|
128
|
+
- **Do not add `Capability.NOTIFICATION` to the enum speculatively.**
|
|
129
|
+
Per this repo's own closed-taxonomy discipline (ADR-006;
|
|
130
|
+
`Capability.TRANSPORT` was not repurposed for inbound transport
|
|
131
|
+
servers in the `openframe-protocol` design review for the same
|
|
132
|
+
reason) — add the member only once a real adapter (email, SMS,
|
|
133
|
+
push, or paging) is actually being built, not ahead of one. This
|
|
134
|
+
bullet records the intended name and justification so that moment
|
|
135
|
+
doesn't have to re-derive either from scratch.
|
|
136
|
+
- **Identity/auth verification** — Auth0, Okta, Keycloak token
|
|
137
|
+
introspection or user lookup.
|
|
138
|
+
- **Rate limiting** — same Redis-driver-directly caveat as locking.
|
|
139
|
+
|
|
140
|
+
Deliberately out of scope for now: workflow orchestration (Temporal/
|
|
141
|
+
Camunda — too large a port surface for one method), schema registries
|
|
142
|
+
(Kafka-ecosystem-specific, niche), payment processing (too
|
|
143
|
+
domain-specific for generic infra adapters).
|
|
49
144
|
|
|
50
145
|
### openframe-suite
|
|
51
146
|
|
|
@@ -65,7 +160,7 @@ Lower-priority items:
|
|
|
65
160
|
- `ApplicationBootstrap.registry` — new read-only property exposing the underlying `PluginRegistry`.
|
|
66
161
|
- `PluginRegistry(default_init_timeout=...)` / `register(..., init_timeout=...)` (mirrored on `ApplicationBootstrap`) — bound how long `initialize_all()` waits for a port's `initialize()`. Both default to `None` (no timeout) — fully backward-compatible.
|
|
67
162
|
- Fixed: `TracingProxy`'s `asyncio.iscoroutinefunction` deprecation; `AmbiguousCapabilityError` now mirrors `capability`/`matches` into `context`.
|
|
68
|
-
- Full changelog: [`.github/CHANGELOG.md`](
|
|
163
|
+
- Full changelog: [`.github/CHANGELOG.md`](https://github.com/Furious-Meteors/openframe-core/blob/production/.github/CHANGELOG.md#v330--2026-09-28).
|
|
69
164
|
|
|
70
165
|
---
|
|
71
166
|
|
|
@@ -156,6 +156,7 @@ nav:
|
|
|
156
156
|
- ADR-004 Pydantic Settings: technical/architecture/adrs/adr-004-pydantic.md
|
|
157
157
|
- ADR-005 Middleware Telemetry: technical/architecture/adrs/adr-005-middleware.md
|
|
158
158
|
- ADR-006 Unified Port Lifecycle: technical/architecture/adrs/adr-006-unified-port-lifecycle.md
|
|
159
|
+
- ADR-007 Schema Contracts: technical/architecture/adrs/adr-007-schema-contracts.md
|
|
159
160
|
- Capability Taxonomy: technical/architecture/capability-taxonomy.md
|
|
160
161
|
- Error Taxonomy: technical/architecture/error-taxonomy.md
|
|
161
162
|
- Design Decisions: technical/architecture/design-decisions.md
|
|
@@ -11,6 +11,11 @@ routes, message-handler entrypoints, CLI commands).
|
|
|
11
11
|
- :class:`~openframe.core.inbound.usecase.UseCase` — general driving contract.
|
|
12
12
|
- :class:`~openframe.core.inbound.command.CommandHandler` — write-side CQRS specialisation.
|
|
13
13
|
- :class:`~openframe.core.inbound.query.QueryHandler` — read-side CQRS specialisation.
|
|
14
|
+
- :class:`~openframe.core.inbound.session.SessionHandler` — long-lived-connection
|
|
15
|
+
specialisation (ADR-008) for transports where a handler may receive zero or
|
|
16
|
+
many inbound events and send zero or many outbound events over time
|
|
17
|
+
(WebSocket, SSE), via a push-based :class:`~openframe.core.inbound.session.Session`
|
|
18
|
+
handle.
|
|
14
19
|
- :class:`~openframe.core.inbound.context.RequestContext` — correlation id
|
|
15
20
|
+ optional identity, constructed by inbound adapters and passed into
|
|
16
21
|
``execute()``.
|
|
@@ -22,11 +27,15 @@ from __future__ import annotations
|
|
|
22
27
|
from openframe.core.inbound.command import CommandHandler
|
|
23
28
|
from openframe.core.inbound.context import RequestContext
|
|
24
29
|
from openframe.core.inbound.query import QueryHandler
|
|
30
|
+
from openframe.core.inbound.session import CloseReason, Session, SessionHandler
|
|
25
31
|
from openframe.core.inbound.usecase import UseCase
|
|
26
32
|
|
|
27
33
|
__all__ = [
|
|
28
34
|
"UseCase",
|
|
29
35
|
"CommandHandler",
|
|
30
36
|
"QueryHandler",
|
|
37
|
+
"SessionHandler",
|
|
38
|
+
"Session",
|
|
39
|
+
"CloseReason",
|
|
31
40
|
"RequestContext",
|
|
32
41
|
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/core/inbound/session.py
|
|
3
|
+
====================================
|
|
4
|
+
SessionHandler — the long-lived-connection driving-side contract
|
|
5
|
+
(ADR-008), sibling to :class:`~openframe.core.inbound.usecase.UseCase`.
|
|
6
|
+
|
|
7
|
+
``UseCase``/``CommandHandler``/``QueryHandler`` (ADR-006) model one-shot
|
|
8
|
+
request/response exchanges: one input in, one output out, done. They do
|
|
9
|
+
not fit a long-lived connection (WebSocket, SSE) where a handler may
|
|
10
|
+
receive zero or many inbound events and send zero or many outbound
|
|
11
|
+
events over its lifetime. ``SessionHandler`` is that shape.
|
|
12
|
+
|
|
13
|
+
``Session[TOut]`` is deliberately **push-based**, not a pull-based
|
|
14
|
+
``AsyncIterator`` return value: ``send()``/``close()`` are available from
|
|
15
|
+
inside ``on_open``, ``on_event``, or a background task the handler spawns
|
|
16
|
+
at ``on_open`` — e.g. forwarding events from an unrelated source (a Kafka
|
|
17
|
+
consumer) into an already-open connection, something a generator can't
|
|
18
|
+
express without building an internal queue anyway, at which point the
|
|
19
|
+
queue *is* a ``Session`` in disguise.
|
|
20
|
+
|
|
21
|
+
``on_open`` doubles as the accept/reject point — raising rejects the
|
|
22
|
+
session, flowing through the exact same
|
|
23
|
+
``openframe.protocol.base.errors.categorize``/``build_envelope`` path
|
|
24
|
+
every other inbound contract's failures already do, rather than a
|
|
25
|
+
second return-value channel a caller would need a separate check for.
|
|
26
|
+
|
|
27
|
+
``RequestContext`` is built once per session (at connection/handshake
|
|
28
|
+
time), not once per event — a session has one correlation id for its
|
|
29
|
+
whole lifetime. This matches ``build_request_context``'s own precondition
|
|
30
|
+
(headers are only available once, at handshake time) for every transport
|
|
31
|
+
this contract is meant to serve.
|
|
32
|
+
|
|
33
|
+
Dependency order:
|
|
34
|
+
inbound/context → ports/context
|
|
35
|
+
inbound/session → inbound/context
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from enum import Enum
|
|
40
|
+
from typing import Protocol, TypeVar, runtime_checkable
|
|
41
|
+
|
|
42
|
+
from openframe.core.inbound.context import RequestContext
|
|
43
|
+
|
|
44
|
+
__all__ = ["CloseReason", "Session", "SessionHandler"]
|
|
45
|
+
|
|
46
|
+
TIn = TypeVar("TIn")
|
|
47
|
+
TOut = TypeVar("TOut")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CloseReason(Enum):
|
|
51
|
+
"""
|
|
52
|
+
Why a session ended.
|
|
53
|
+
|
|
54
|
+
Closed, transport-neutral set — every `openframe-protocol-*` session
|
|
55
|
+
transport maps its own disconnect/shutdown signal onto one of these
|
|
56
|
+
four values rather than inventing its own vocabulary.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
COMPLETED = "completed"
|
|
60
|
+
"""The handler or transport ended the session normally."""
|
|
61
|
+
|
|
62
|
+
CLIENT_DISCONNECT = "client_disconnect"
|
|
63
|
+
"""The remote end disconnected (socket closed, stream aborted)."""
|
|
64
|
+
|
|
65
|
+
SERVER_SHUTDOWN = "server_shutdown"
|
|
66
|
+
"""The server is shutting down and is closing open sessions."""
|
|
67
|
+
|
|
68
|
+
ERROR = "error"
|
|
69
|
+
"""An unhandled exception from `on_open`/`on_event` ended the session."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@runtime_checkable
|
|
73
|
+
class Session(Protocol[TOut]):
|
|
74
|
+
"""
|
|
75
|
+
Handle a :class:`SessionHandler` uses to push events to the remote end
|
|
76
|
+
and to end the session.
|
|
77
|
+
|
|
78
|
+
Adapters satisfy this protocol structurally — no inheritance needed.
|
|
79
|
+
A concrete implementation (e.g. an SSE or WebSocket session) adapts
|
|
80
|
+
its own wire format in :meth:`send`; the handler never sees that
|
|
81
|
+
format.
|
|
82
|
+
|
|
83
|
+
Type parameter:
|
|
84
|
+
TOut: The event type this session sends to the remote end.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
context: The session's `RequestContext`, built once at
|
|
88
|
+
connection/handshake time and stable for the session's
|
|
89
|
+
entire lifetime.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
context: RequestContext
|
|
93
|
+
|
|
94
|
+
async def send(self, event: TOut) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Send one event to the remote end.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
Implementation-defined if called after :meth:`close` — a
|
|
100
|
+
session that has already closed cannot send further events.
|
|
101
|
+
"""
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
async def close(self, reason: CloseReason = CloseReason.COMPLETED) -> None:
|
|
105
|
+
"""
|
|
106
|
+
End the session.
|
|
107
|
+
|
|
108
|
+
Idempotent — calling :meth:`close` more than once has no
|
|
109
|
+
additional effect beyond the first call.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
reason: Why the session is ending. Defaults to
|
|
113
|
+
`CloseReason.COMPLETED` — the handler-initiated,
|
|
114
|
+
successful end of the session.
|
|
115
|
+
"""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@runtime_checkable
|
|
120
|
+
class SessionHandler(Protocol[TIn, TOut]):
|
|
121
|
+
"""
|
|
122
|
+
Long-lived-connection driving-side contract.
|
|
123
|
+
|
|
124
|
+
Adapters satisfy this protocol structurally — no inheritance needed.
|
|
125
|
+
Any class with matching async `on_open`/`on_event`/`on_close` methods
|
|
126
|
+
satisfies `SessionHandler`.
|
|
127
|
+
|
|
128
|
+
Type parameters:
|
|
129
|
+
TIn: The inbound event type this handler receives via `on_event`.
|
|
130
|
+
TOut: The outbound event type this handler sends via
|
|
131
|
+
`session.send()`.
|
|
132
|
+
|
|
133
|
+
Runtime check::
|
|
134
|
+
|
|
135
|
+
isinstance(handler, SessionHandler) # ✓ works
|
|
136
|
+
isinstance(handler, SessionHandler[str, int]) # ✗ raises TypeError
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
async def on_open(self, session: Session[TOut]) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Called once when the session opens.
|
|
142
|
+
|
|
143
|
+
Returning normally accepts the session. Raising rejects it — the
|
|
144
|
+
exception flows through the same
|
|
145
|
+
`openframe.protocol.base.errors.categorize`/`build_envelope` path
|
|
146
|
+
every other inbound contract's failures do, so a reject looks
|
|
147
|
+
like any other mapped error to the caller, not a special case.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
session: The newly opened session. `session.context` carries
|
|
151
|
+
this session's `RequestContext`, stable for its
|
|
152
|
+
whole lifetime.
|
|
153
|
+
"""
|
|
154
|
+
...
|
|
155
|
+
|
|
156
|
+
async def on_event(self, session: Session[TOut], event: TIn) -> None:
|
|
157
|
+
"""
|
|
158
|
+
Called once per inbound event, in arrival order.
|
|
159
|
+
|
|
160
|
+
Never called at all for a one-directional transport (e.g. SSE,
|
|
161
|
+
which has no client-to-server events) — this is a valid, expected
|
|
162
|
+
lifecycle for such a handler, not a violation of the contract.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
session: The open session this event arrived on.
|
|
166
|
+
event: The inbound event.
|
|
167
|
+
"""
|
|
168
|
+
...
|
|
169
|
+
|
|
170
|
+
async def on_close(self, session: Session[TOut], reason: CloseReason) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Called exactly once when the session ends, however it ended —
|
|
173
|
+
normal completion, client disconnect, server shutdown, or an
|
|
174
|
+
unhandled error from `on_open`/`on_event`.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
session: The now-closed session.
|
|
178
|
+
reason: Why the session ended.
|
|
179
|
+
"""
|
|
180
|
+
...
|
|
@@ -29,6 +29,11 @@ Quick reference::
|
|
|
29
29
|
|
|
30
30
|
use_case = EchoUseCase(response={"id": "1"})
|
|
31
31
|
assert isinstance(use_case, UseCase)
|
|
32
|
+
|
|
33
|
+
from openframe.core.testing import InMemorySession, SessionHandlerContractTests
|
|
34
|
+
|
|
35
|
+
session = InMemorySession(context=RequestContext(correlation_id="t1"))
|
|
36
|
+
await my_session_handler.on_open(session)
|
|
32
37
|
"""
|
|
33
38
|
from __future__ import annotations
|
|
34
39
|
|
|
@@ -37,6 +42,7 @@ from openframe.core.testing.contracts.graph_store import GraphStoreContractTests
|
|
|
37
42
|
from openframe.core.testing.contracts.inbound import (
|
|
38
43
|
CommandHandlerContractTests,
|
|
39
44
|
QueryHandlerContractTests,
|
|
45
|
+
SessionHandlerContractTests,
|
|
40
46
|
UseCaseContractTests,
|
|
41
47
|
)
|
|
42
48
|
from openframe.core.testing.contracts.lifecycle import LifecycleContractTests
|
|
@@ -52,6 +58,7 @@ from openframe.core.testing.fakes.consumer import FakeConsumer
|
|
|
52
58
|
from openframe.core.testing.fakes.graph_store import InMemoryGraphStore
|
|
53
59
|
from openframe.core.testing.fakes.inbound import (
|
|
54
60
|
EchoUseCase,
|
|
61
|
+
InMemorySession,
|
|
55
62
|
SpyCommandHandler,
|
|
56
63
|
SpyQueryHandler,
|
|
57
64
|
)
|
|
@@ -68,6 +75,7 @@ __all__ = [
|
|
|
68
75
|
"EchoUseCase",
|
|
69
76
|
"SpyCommandHandler",
|
|
70
77
|
"SpyQueryHandler",
|
|
78
|
+
"InMemorySession",
|
|
71
79
|
"LifecycleContractTests",
|
|
72
80
|
"PortContractTests",
|
|
73
81
|
"RepositoryContractTests",
|
|
@@ -78,6 +86,7 @@ __all__ = [
|
|
|
78
86
|
"UseCaseContractTests",
|
|
79
87
|
"CommandHandlerContractTests",
|
|
80
88
|
"QueryHandlerContractTests",
|
|
89
|
+
"SessionHandlerContractTests",
|
|
81
90
|
"RemoteCommandClientContractTests",
|
|
82
91
|
"RemoteQueryClientContractTests",
|
|
83
92
|
]
|
|
@@ -15,10 +15,11 @@ adding traverse-specific assertions — mirroring how ``BaseGraphStore``
|
|
|
15
15
|
extends ``BaseRepository``.
|
|
16
16
|
|
|
17
17
|
Inbound: ``UseCaseContractTests``, ``CommandHandlerContractTests``,
|
|
18
|
-
``QueryHandlerContractTests`` mirror the
|
|
19
|
-
the driving side of the hexagon. They do
|
|
20
|
-
|
|
21
|
-
``
|
|
18
|
+
``QueryHandlerContractTests``, ``SessionHandlerContractTests`` mirror the
|
|
19
|
+
outbound contract classes for the driving side of the hexagon. They do
|
|
20
|
+
not build on any shared base class —
|
|
21
|
+
``UseCase``/``CommandHandler``/``QueryHandler``/``SessionHandler`` do not
|
|
22
|
+
extend ``BasePort``.
|
|
22
23
|
|
|
23
24
|
No top-level pytest import — all modules here can be imported without
|
|
24
25
|
pytest installed.
|
|
@@ -32,6 +33,7 @@ from openframe.core.testing.contracts.graph_store import GraphStoreContractTests
|
|
|
32
33
|
from openframe.core.testing.contracts.inbound import (
|
|
33
34
|
CommandHandlerContractTests,
|
|
34
35
|
QueryHandlerContractTests,
|
|
36
|
+
SessionHandlerContractTests,
|
|
35
37
|
UseCaseContractTests,
|
|
36
38
|
)
|
|
37
39
|
from openframe.core.testing.contracts.lifecycle import LifecycleContractTests
|
|
@@ -55,6 +57,7 @@ __all__ = [
|
|
|
55
57
|
"UseCaseContractTests",
|
|
56
58
|
"CommandHandlerContractTests",
|
|
57
59
|
"QueryHandlerContractTests",
|
|
60
|
+
"SessionHandlerContractTests",
|
|
58
61
|
"RemoteCommandClientContractTests",
|
|
59
62
|
"RemoteQueryClientContractTests",
|
|
60
63
|
]
|