codex-platform 0.3.0__tar.gz → 0.5.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.
- codex_platform-0.5.0/.agents/rules/graphify.md +9 -0
- codex_platform-0.5.0/AGENTS.md +9 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/CHANGELOG.md +25 -0
- codex_platform-0.5.0/CLAUDE.md +9 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/PKG-INFO +1 -1
- codex_platform-0.5.0/docs/dev/messaging/00_overview.md +157 -0
- codex_platform-0.5.0/docs/dev/messaging/01_core_dtos_and_protocols.md +267 -0
- codex_platform-0.5.0/docs/dev/messaging/02_channels_and_registry.md +166 -0
- codex_platform-0.5.0/docs/dev/messaging/03_renderer_and_templates.md +151 -0
- codex_platform-0.5.0/docs/dev/messaging/04_threading_and_headers.md +142 -0
- codex_platform-0.5.0/docs/dev/messaging/05_workers_contract.md +171 -0
- codex_platform-0.5.0/docs/dev/messaging/06_migration_from_notifications.md +140 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/streams/README.md +8 -3
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/streams/data_flow.md +8 -8
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/streams/README.md +8 -3
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/streams/data_flow.md +8 -8
- codex_platform-0.5.0/redis-backends-recovery.md +376 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/registry.py +1 -1
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/base.py +31 -0
- codex_platform-0.5.0/src/codex_platform/redis_service/exceptions.py +20 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/__init__.py +4 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/hash.py +12 -1
- codex_platform-0.5.0/src/codex_platform/redis_service/operations/sync_hash.py +263 -0
- codex_platform-0.5.0/src/codex_platform/redis_service/operations/sync_string.py +187 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/streams/__init__.py +18 -18
- codex_platform-0.5.0/src/codex_platform/streams/codec.py +39 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/streams/consumer.py +59 -5
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/streams/dispatcher.py +33 -12
- codex_platform-0.5.0/src/codex_platform/streams/producer.py +130 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/streams/router.py +28 -3
- codex_platform-0.5.0/src/codex_platform/streams/runtime.py +90 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/arq/config.py +1 -1
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/integration/test_docs_build.py +2 -1
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/integration/test_streams.py +83 -2
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_stream_consumer.py +30 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_stream_dispatcher.py +21 -2
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_stream_processor.py +28 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_stream_producer.py +44 -14
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_stream_router.py +18 -7
- codex_platform-0.5.0/tests/unit/test_stream_runtime.py +77 -0
- codex_platform-0.5.0/tests/unit/test_sync_operations.py +112 -0
- codex_platform-0.5.0/tools/dev/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tools/dev/check.py +3 -0
- codex_platform-0.3.0/src/codex_platform/redis_service/exceptions.py +0 -20
- codex_platform-0.3.0/src/codex_platform/streams/producer.py +0 -60
- {codex_platform-0.3.0 → codex_platform-0.5.0}/.github/workflows/ci.yml +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/.github/workflows/docs.yml +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/.github/workflows/publish.yml +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/.gitignore +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/.pre-commit-config.yaml +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/.python-version +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/README.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/changelog.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/index.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/channels.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/delivery/arq.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/delivery/base.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/delivery/direct.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/dto.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/index.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/orchestrator.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/registry.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/renderer.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/base.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/index.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/keys.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/hash.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/json_module.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/json_string.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/list_.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/pipeline.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/set_.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/string.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/zset.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/service.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/consumer.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/dispatcher.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/index.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/processor.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/producer.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/router.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/workers/arq/base.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/workers/arq/config.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/workers/arq/index.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/workers/arq/task_utils.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/workers/arq/types.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/notifications/README.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/notifications/data_flow.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/redis_service/README.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/redis_service/data_flow.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/workers/README.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/workers/data_flow.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/index.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/notifications/README.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/notifications/data_flow.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/redis_service/README.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/redis_service/data_flow.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/workers/README.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/workers/data_flow.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/stylesheets/extra.css +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/mkdocs.yml +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/project_structure.txt +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/pyproject.toml +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/channels.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/clients/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/clients/smtp.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/delivery/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/delivery/arq.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/delivery/base.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/delivery/direct.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/dto.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/interfaces.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/orchestrator.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/renderer.py +0 -0
- /codex_platform-0.3.0/tests/integration/__init__.py → /codex_platform-0.5.0/src/codex_platform/py.typed +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/keys.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/managers/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/managers/base_manager.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/managers/site_settings.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/json_module.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/json_string.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/list_.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/pipeline.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/set_.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/string.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/zset.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/service.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/streams/processor.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/arq/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/arq/base.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/arq/task_utils.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/arq/types.py +0 -0
- {codex_platform-0.3.0/tests/unit → codex_platform-0.5.0/tests/integration}/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/integration/conftest.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/integration/test_redis_service.py +0 -0
- {codex_platform-0.3.0/tools → codex_platform-0.5.0/tests/unit}/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/conftest.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_arq_base.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_arq_config.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_arq_public_api.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_arq_task_utils.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_adapters.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_channels.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_dto.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_orchestrator.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_registry.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_renderer.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_smtp.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_base.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_hash.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_keys.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_list.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_manager.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_pipeline.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_service.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_set.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_string.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_zset.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_site_settings_manager.py +0 -0
- {codex_platform-0.3.0/tools/dev → codex_platform-0.5.0/tools}/__init__.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tools/dev/README.md +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/tools/dev/generate_project_tree.py +0 -0
- {codex_platform-0.3.0 → codex_platform-0.5.0}/uv.lock +0 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## graphify
|
|
2
|
+
|
|
3
|
+
This component is part of a larger project. It uses a global knowledge graph located at the workspace root (`../graphify-out/`).
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
- Before answering architecture or codebase questions, read the global report at **`../graphify-out/GRAPH_REPORT.md`** for god nodes and community structure.
|
|
7
|
+
- If `../graphify-out/wiki/index.md` exists, navigate it instead of reading raw files.
|
|
8
|
+
- For cross-module questions, prefer `graphify query`, `graphify path`, or `graphify explain` (run from root or via MCP) over grep.
|
|
9
|
+
- After modifying code files in this session, run `graphify update .` from the **workspace root** to keep the global graph current.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## graphify
|
|
2
|
+
|
|
3
|
+
This component is part of a larger project. It uses a global knowledge graph located at the workspace root (`../graphify-out/`).
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
- Before answering architecture or codebase questions, read the global report at **`../graphify-out/GRAPH_REPORT.md`** for god nodes and community structure.
|
|
7
|
+
- If `../graphify-out/wiki/index.md` exists, navigate it instead of reading raw files.
|
|
8
|
+
- For cross-module questions, prefer `graphify query`, `graphify path`, or `graphify explain` (run from root or via MCP) over grep.
|
|
9
|
+
- After modifying code files in this session, run `graphify update .` from the **workspace root** to keep the global graph current.
|
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
Grouped by `Added` · `Changed` · `Deprecated` · `Removed` · `Fixed`.
|
|
5
5
|
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.5.0] - 2026-04-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Added `StreamRuntime` and `StreamRuntimeConfig` for grouped Redis Streams workers that can run all handlers in monolith mode or only selected logical groups in split-service mode.
|
|
12
|
+
- Added `StreamHandlerSpec` metadata and `group` / `reply` arguments to stream router and dispatcher decorators.
|
|
13
|
+
- Added `StreamProducer.publish()`, `StreamProducer.request()`, and `StreamProducer.publish_reply()` for correlation-id based request/reply flows.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Redis Streams payloads now use JSON-safe field encoding for structured values instead of coercing every value to plain `str` or dropping `None`.
|
|
17
|
+
- `StreamConsumer` now implements the `StreamStorageProtocol` used by `StreamProcessor` directly.
|
|
18
|
+
|
|
19
|
+
## [0.4.0] - 2026-04-22
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Synchronous Redis operations: `SyncStringOperations` and `SyncHashOperations` using `redis.Redis`.
|
|
23
|
+
- Added `catch_redis_errors_sync` decorator for synchronous methods.
|
|
24
|
+
- Added `encoder` parameter to `HashOperations.set_fields` and `SyncHashOperations.set_fields`.
|
|
25
|
+
### Added
|
|
26
|
+
- Added `py.typed` marker for PEP 561 compliance — downstream consumers now benefit from full type inference when using mypy or pyright.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- Translated all Russian comments and docstrings to English across `redis_service/exceptions.py`, `workers/arq/config.py`, `notifications/registry.py`, and `streams/__init__.py`.
|
|
30
|
+
|
|
6
31
|
## [0.3.0] - 2026-04-05
|
|
7
32
|
|
|
8
33
|
### Changed
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## graphify
|
|
2
|
+
|
|
3
|
+
This component is part of a larger project. It uses a global knowledge graph located at the workspace root (`../graphify-out/`).
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
- Before answering architecture or codebase questions, read the global report at **`../graphify-out/GRAPH_REPORT.md`** for god nodes and community structure.
|
|
7
|
+
- If `../graphify-out/wiki/index.md` exists, navigate it instead of reading raw files.
|
|
8
|
+
- For cross-module questions, prefer `graphify query`, `graphify path`, or `graphify explain` (run from root or via MCP) over grep.
|
|
9
|
+
- After modifying code files in this session, run `graphify update .` from the **workspace root** to keep the global graph current.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-platform
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Modular async infrastructure (Redis, Streams, ARQ, Notifications) built for Codex, designed for independent use.
|
|
5
5
|
Project-URL: Homepage, https://github.com/codexdlc/codex-platform
|
|
6
6
|
Project-URL: Documentation, https://codexdlc.github.io/codex-platform/
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# `codex-platform.messaging` — Overview
|
|
2
|
+
|
|
3
|
+
> **Status**: Phase 0 design document. The package described here is the
|
|
4
|
+
> rename + expansion of the existing `codex_platform.notifications` package.
|
|
5
|
+
> No code has been written yet; this file is the contract that drives Phase 2
|
|
6
|
+
> of the migration roadmap.
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
|
|
10
|
+
`codex_platform.messaging` is the **framework-agnostic core** of the codex
|
|
11
|
+
messaging stack. It provides the building blocks that any host framework
|
|
12
|
+
(Django, FastAPI, a bare ARQ worker) can compose to send transactional
|
|
13
|
+
emails, deliver multi-channel notifications, render templates, and route
|
|
14
|
+
inbound replies to a thread.
|
|
15
|
+
|
|
16
|
+
It deliberately does **not**:
|
|
17
|
+
|
|
18
|
+
* import any web framework,
|
|
19
|
+
* depend on a database,
|
|
20
|
+
* know about HTTP request/response objects,
|
|
21
|
+
* hold mutable global state beyond a registry of pluggable factories.
|
|
22
|
+
|
|
23
|
+
If a piece of code needs Django, it does not belong here — it belongs in
|
|
24
|
+
`codex_django.messaging`.
|
|
25
|
+
|
|
26
|
+
## What lives here
|
|
27
|
+
|
|
28
|
+
| Sub-package | Owns |
|
|
29
|
+
|-------------|------|
|
|
30
|
+
| `messaging.dto` | Pydantic DTOs that travel across process boundaries (Django ↔ worker, FastAPI ↔ worker). |
|
|
31
|
+
| `messaging.channels` | `NotificationChannel` enum (`email`, `sms`, `telegram`, `whatsapp`). |
|
|
32
|
+
| `messaging.clients` | Concrete protocol-conforming channel implementations (SMTP, SendGrid HTTP, future Telegram). |
|
|
33
|
+
| `messaging.delivery` | Adapters that move a payload to a runtime: ARQ queue, in-process direct send. |
|
|
34
|
+
| `messaging.orchestrator` | `BaseDeliveryOrchestrator` — fallback chain over a list of `DeliveryChannel`s. |
|
|
35
|
+
| `messaging.registry` | `ChannelRegistry` — config-driven factory list. |
|
|
36
|
+
| `messaging.renderer` | Optional Jinja2 renderer for worker-side template mode. |
|
|
37
|
+
| `messaging.threading` | Helpers for RFC 5322 `Message-ID`, `In-Reply-To`, `References` and the neutral `X-Codex-Thread-Key` header. |
|
|
38
|
+
| `messaging.audience` | `AudienceBuilder` protocol + `RecipientDraft` DTO for mass-mail batching. |
|
|
39
|
+
| `messaging.campaigns` | `CampaignDispatcher` protocol + `CampaignBatchDTO` schema for the worker callback contract. |
|
|
40
|
+
| `messaging.workers_contract` | Frozen schema versions and task-name constants the worker must keep in sync with. |
|
|
41
|
+
|
|
42
|
+
## Layer diagram
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
┌──────────────────────────────┐
|
|
46
|
+
│ codex_core │
|
|
47
|
+
│ - BaseDTO (Pydantic) │
|
|
48
|
+
│ - settings primitives │
|
|
49
|
+
└──────────────┬───────────────┘
|
|
50
|
+
│
|
|
51
|
+
┌──────────────▼───────────────┐
|
|
52
|
+
│ codex_platform.messaging │ ← THIS PACKAGE
|
|
53
|
+
│ (framework-agnostic) │
|
|
54
|
+
└──────┬──────────────────┬─────┘
|
|
55
|
+
│ │
|
|
56
|
+
┌───────────────▼─────┐ ┌─────────▼────────────┐
|
|
57
|
+
│ codex_django. │ │ future: │
|
|
58
|
+
│ messaging │ │ codex_fastapi. │
|
|
59
|
+
│ (Django adapters) │ │ messaging │
|
|
60
|
+
└─────────────────────┘ └──────────────────────┘
|
|
61
|
+
│ │
|
|
62
|
+
└────────┬─────────┘
|
|
63
|
+
│
|
|
64
|
+
┌───────────────▼───────────────┐
|
|
65
|
+
│ project apps (lily_backend, …)│
|
|
66
|
+
└───────────────────────────────┘
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## FastAPI-readiness contract
|
|
70
|
+
|
|
71
|
+
The whole package must satisfy the following invariants. CI should enforce
|
|
72
|
+
each of them with a static check:
|
|
73
|
+
|
|
74
|
+
1. **No `import django`** anywhere under `src/codex_platform/messaging/`.
|
|
75
|
+
2. **No transitive Django pull-in.** All channel/orchestrator/renderer code
|
|
76
|
+
uses `pydantic`, `arq`, `aiosmtplib`, `httpx`, `jinja2` (optional). No
|
|
77
|
+
adapter from another framework is imported eagerly.
|
|
78
|
+
3. **All cross-process payloads are Pydantic DTOs** that subclass
|
|
79
|
+
`codex_core.core.BaseDTO`. Plain dicts may be used internally between
|
|
80
|
+
`messaging.delivery.*.enqueue()` and the worker, but every dict must
|
|
81
|
+
round-trip through a DTO at both ends.
|
|
82
|
+
4. **All time fields are timezone-aware ISO-8601 strings** in DTOs. The
|
|
83
|
+
package never assumes a global timezone; the host application is
|
|
84
|
+
responsible for the wall-clock interpretation.
|
|
85
|
+
5. **All registry entries are functions, not module-level imports.** The
|
|
86
|
+
registry calls factories lazily so an unconfigured channel does not
|
|
87
|
+
force a missing dependency import.
|
|
88
|
+
|
|
89
|
+
## Two delivery modes (kept from `notifications`, made first-class)
|
|
90
|
+
|
|
91
|
+
### Mode 1 — Worker-rendered (`TemplateNotificationDTO`)
|
|
92
|
+
|
|
93
|
+
The host pushes a `template_name` + a Redis context key. The worker fetches
|
|
94
|
+
the context, renders the template via Jinja2, then sends.
|
|
95
|
+
|
|
96
|
+
* **Use when**: content depends on data that may change between enqueue
|
|
97
|
+
and send (booking time, reschedule), or when you want the worker to
|
|
98
|
+
share a single template directory across many hosts.
|
|
99
|
+
* **Cost**: worker must have Jinja2 installed and a templates directory.
|
|
100
|
+
|
|
101
|
+
### Mode 2 — Pre-rendered (`RenderedNotificationDTO`)
|
|
102
|
+
|
|
103
|
+
The host renders HTML/text in its own template engine and passes the
|
|
104
|
+
result on the wire. The worker only delivers.
|
|
105
|
+
|
|
106
|
+
* **Use when**: the host has Django templates (i18n, request context,
|
|
107
|
+
CMS blocks) and you want exactly the same layout in send and preview.
|
|
108
|
+
* **Cost**: payload is larger; you cannot mutate the rendered content
|
|
109
|
+
after enqueue.
|
|
110
|
+
|
|
111
|
+
The orchestrator and channels do not care which mode produced the
|
|
112
|
+
payload — they only see the resolved `html_content` / `text_content`
|
|
113
|
+
fields on the DTO.
|
|
114
|
+
|
|
115
|
+
## What this package does NOT decide
|
|
116
|
+
|
|
117
|
+
* The shape of recipients, threads, campaigns, or message bodies in a
|
|
118
|
+
database. That is owned by `codex_django.messaging` (abstract models)
|
|
119
|
+
and the project (concrete models).
|
|
120
|
+
* The cabinet / admin UI. That is owned by `codex_django.messaging` plus
|
|
121
|
+
project templates.
|
|
122
|
+
* Audience selection logic (segmentation, GDPR consent, etc.). The
|
|
123
|
+
package only declares the `AudienceBuilder` protocol; concrete query
|
|
124
|
+
logic belongs in projects.
|
|
125
|
+
* Provider-specific identity (sender name, reply-to). Those flow into
|
|
126
|
+
the channel via DTO fields and configuration; the package never
|
|
127
|
+
hard-codes them.
|
|
128
|
+
|
|
129
|
+
## Open design points (resolved during doc review)
|
|
130
|
+
|
|
131
|
+
Listed here so future maintainers can find the rationale:
|
|
132
|
+
|
|
133
|
+
1. **Decorator API for content builders.** Two competing styles —
|
|
134
|
+
split (`@email_template` / `@email_rendered`) and unified
|
|
135
|
+
(`@notification(mode=…)`) — are documented in
|
|
136
|
+
`codex-django/docs/dev/messaging/03_decorators.md`. The platform
|
|
137
|
+
layer is agnostic; it sees only the DTO that comes out the other end.
|
|
138
|
+
2. **Inbound transport.** Currently out of scope. The package exposes
|
|
139
|
+
`messaging.threading` so that any inbound transport (IMAP poller,
|
|
140
|
+
webhook, S3 bucket) can attach incoming mail to an existing thread
|
|
141
|
+
using the same headers used on outbound. Concrete inbound transports
|
|
142
|
+
are deferred.
|
|
143
|
+
3. **Provider-key storage.** `codex_platform.messaging` never reads
|
|
144
|
+
secrets. SendGrid API key, SMTP password, etc. flow in via the host
|
|
145
|
+
config object. The host MUST keep secrets out of the database; see
|
|
146
|
+
`codex-django/docs/dev/messaging/05_settings_migration.md`.
|
|
147
|
+
|
|
148
|
+
## Referenced documents
|
|
149
|
+
|
|
150
|
+
* `01_core_dtos_and_protocols.md` — DTO and protocol catalog.
|
|
151
|
+
* `02_channels_and_registry.md` — `DeliveryChannel`, `ChannelRegistry`.
|
|
152
|
+
* `03_renderer_and_templates.md` — Jinja2 renderer + template-key
|
|
153
|
+
conventions.
|
|
154
|
+
* `04_threading_and_headers.md` — RFC 5322 helpers and the
|
|
155
|
+
`X-Codex-Thread-Key` neutral header.
|
|
156
|
+
* `05_workers_contract.md` — task names, payload schema versions.
|
|
157
|
+
* `06_migration_from_notifications.md` — concrete rename + alias plan.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# Core DTOs and Protocols
|
|
2
|
+
|
|
3
|
+
This document is the canonical reference for every type that crosses a
|
|
4
|
+
process boundary in the `codex_platform.messaging` stack. All DTOs inherit
|
|
5
|
+
from `codex_core.core.BaseDTO` (Pydantic v2 with PII auto-masking in
|
|
6
|
+
`__repr__`). All protocols are `typing.Protocol` so that adapters can be
|
|
7
|
+
implemented in any framework without import gymnastics.
|
|
8
|
+
|
|
9
|
+
## 1. `messaging.channels`
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
class NotificationChannel(StrEnum):
|
|
13
|
+
EMAIL = "email"
|
|
14
|
+
TELEGRAM = "telegram"
|
|
15
|
+
SMS = "sms"
|
|
16
|
+
WHATSAPP = "whatsapp"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
* `StrEnum` (Python 3.11+) so JSON round-trip is implicit.
|
|
20
|
+
* New channels MUST be added to this enum before being used in any DTO;
|
|
21
|
+
the worker validates payload `channels` against the enum.
|
|
22
|
+
|
|
23
|
+
## 2. `messaging.dto`
|
|
24
|
+
|
|
25
|
+
### `NotificationRecipient`
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
class NotificationRecipient(BaseDTO):
|
|
29
|
+
email: str | None = None
|
|
30
|
+
phone: str | None = None
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
* Both fields are optional but the orchestrator requires at least one to
|
|
34
|
+
resolve a destination address; a payload with neither will be logged
|
|
35
|
+
and dropped.
|
|
36
|
+
* PII is masked in `__repr__` by `BaseDTO`.
|
|
37
|
+
|
|
38
|
+
### `NotificationPayloadDTO` (base)
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
class NotificationPayloadDTO(BaseDTO):
|
|
42
|
+
notification_id: str
|
|
43
|
+
recipient: NotificationRecipient
|
|
44
|
+
channels: list[NotificationChannel] = [NotificationChannel.EMAIL]
|
|
45
|
+
event_type: str | None = None
|
|
46
|
+
subject: str | None = None
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
* `notification_id` is opaque to the platform and is the **only** primary
|
|
50
|
+
key used to correlate worker callbacks with host-side records. Hosts
|
|
51
|
+
MUST assign an idempotent value (UUID or content hash) — never an
|
|
52
|
+
auto-incremented database PK.
|
|
53
|
+
* `event_type` is a free-form domain key (`booking.confirmed`,
|
|
54
|
+
`conversations.compose_new`, …). The worker emits it back on retry
|
|
55
|
+
callbacks so the host can route delivery status updates to the right
|
|
56
|
+
feature.
|
|
57
|
+
* `subject` is set by the host — the platform never derives it.
|
|
58
|
+
|
|
59
|
+
### `TemplateNotificationDTO`
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
class TemplateNotificationDTO(NotificationPayloadDTO):
|
|
63
|
+
template_name: str
|
|
64
|
+
context_key: str
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
* `template_name` is a path relative to the worker's templates directory
|
|
68
|
+
(e.g. `booking/bk_confirmation.html`). The platform itself does not
|
|
69
|
+
validate the path; the renderer raises `TemplateNotFound` on send.
|
|
70
|
+
* `context_key` is a Redis key where a JSON-serialized context is stored.
|
|
71
|
+
Storing context out-of-band keeps the queue payload small and lets the
|
|
72
|
+
host update context after enqueue (reschedule a booking, re-render).
|
|
73
|
+
|
|
74
|
+
### `RenderedNotificationDTO`
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
class RenderedNotificationDTO(NotificationPayloadDTO):
|
|
78
|
+
html_content: str
|
|
79
|
+
text_content: str | None = None
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
* `html_content` is the final HTML to be delivered. The orchestrator
|
|
83
|
+
passes it verbatim to the channel.
|
|
84
|
+
* `text_content` is the optional plain-text alternative. If absent, the
|
|
85
|
+
channel emits a generic fallback string ("Please enable HTML to view
|
|
86
|
+
this email.").
|
|
87
|
+
|
|
88
|
+
### `ThreadHeadersDTO` *(new in messaging)*
|
|
89
|
+
|
|
90
|
+
Promoted out of the worker's `_mailbox_headers()` helper into a real
|
|
91
|
+
DTO so it can be composed with both notification DTOs:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
class ThreadHeadersDTO(BaseDTO):
|
|
95
|
+
message_id: str # RFC 5322 Message-ID, host-issued
|
|
96
|
+
in_reply_to: str | None = None
|
|
97
|
+
references: list[str] = []
|
|
98
|
+
thread_key: str # opaque per-thread token (was X-Lily-Thread-Key)
|
|
99
|
+
reply_match_token: str | None = None # token routed back via Reply-To
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The two notification DTOs gain an optional `headers: ThreadHeadersDTO | None`
|
|
103
|
+
field. The SMTP / SendGrid channels read it and emit the corresponding
|
|
104
|
+
RFC 5322 headers; channels that don't support headers (SMS, Telegram)
|
|
105
|
+
ignore the field.
|
|
106
|
+
|
|
107
|
+
### `CampaignBatchDTO` *(new in messaging)*
|
|
108
|
+
|
|
109
|
+
Replaces the ad-hoc batch payload that
|
|
110
|
+
`features/conversations/tasks/campaign_tasks.py` consumes today.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
class CampaignRecipientDraft(BaseDTO):
|
|
114
|
+
recipient_id: str
|
|
115
|
+
email: str
|
|
116
|
+
first_name: str = ""
|
|
117
|
+
last_name: str = ""
|
|
118
|
+
locale: str = "de"
|
|
119
|
+
unsubscribe_token: str | None = None
|
|
120
|
+
|
|
121
|
+
class CampaignBatchDTO(BaseDTO):
|
|
122
|
+
campaign_id: str
|
|
123
|
+
template_name: str | None = None
|
|
124
|
+
html_content: str | None = None
|
|
125
|
+
subject: str
|
|
126
|
+
recipients: list[CampaignRecipientDraft]
|
|
127
|
+
base_context: dict[str, Any] = {} # site_url, logo_url, etc.
|
|
128
|
+
callback_url: str # where the worker reports recipient status
|
|
129
|
+
callback_token: str # opaque, host-issued
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
* Either `template_name` (template mode) or `html_content` (rendered
|
|
133
|
+
mode) MUST be set — the worker rejects a batch with both or neither.
|
|
134
|
+
* `callback_url` + `callback_token` decouple the worker from any
|
|
135
|
+
framework: the worker only knows where to POST. The host validates
|
|
136
|
+
the token (currently `OPS_WORKER_API_KEY` scoped to
|
|
137
|
+
`"campaigns.worker"` in lily, see `system/api/auth.py:12-18`).
|
|
138
|
+
|
|
139
|
+
## 3. `messaging.interfaces`
|
|
140
|
+
|
|
141
|
+
### `ContentProvider`
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
class ContentProvider(Protocol):
|
|
145
|
+
def get_text(self, key: str) -> str | None: ...
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Used by selectors to translate subject / body keys to localized strings.
|
|
149
|
+
The Django adapter wraps `django.utils.translation`; a FastAPI adapter
|
|
150
|
+
will wrap `babel`.
|
|
151
|
+
|
|
152
|
+
### `ContentCacheAdapter`
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
class ContentCacheAdapter(Protocol):
|
|
156
|
+
def get_cached_value(self, key: str) -> str | None: ...
|
|
157
|
+
def set_cached_value(self, key: str, value: str, timeout: int) -> None: ...
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The platform never imports a cache backend directly. The Django adapter
|
|
161
|
+
wraps `django.core.cache`; a FastAPI adapter will wrap `redis-py` or
|
|
162
|
+
`aiocache`.
|
|
163
|
+
|
|
164
|
+
## 4. `messaging.orchestrator`
|
|
165
|
+
|
|
166
|
+
### `DeliveryChannel`
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
class DeliveryChannel(Protocol):
|
|
170
|
+
async def send(
|
|
171
|
+
self,
|
|
172
|
+
to: str,
|
|
173
|
+
subject: str,
|
|
174
|
+
html_content: str | None,
|
|
175
|
+
text_content: str | None,
|
|
176
|
+
) -> bool: ...
|
|
177
|
+
def is_available(self) -> bool: ...
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
* Implementations: `AsyncEmailClient` (SMTP), `SendGridChannel` (HTTP),
|
|
181
|
+
future `TelegramChannel`, `TwilioSmsChannel`.
|
|
182
|
+
* `is_available()` MUST be cheap — it is called on every dispatch to
|
|
183
|
+
decide whether to skip the channel.
|
|
184
|
+
* `send()` returns `False` for *logical* failures (recipient rejected,
|
|
185
|
+
rate-limit exceeded). It MUST raise on infrastructure failures so the
|
|
186
|
+
orchestrator can fall through to the next channel.
|
|
187
|
+
|
|
188
|
+
> **Open question**: the current signature does not accept
|
|
189
|
+
> `ThreadHeadersDTO`. The migration adds an optional `headers` keyword
|
|
190
|
+
> argument. Channels that don't support headers (SMS, Telegram) ignore
|
|
191
|
+
> it. Documented in `02_channels_and_registry.md`.
|
|
192
|
+
|
|
193
|
+
### `BaseDeliveryOrchestrator`
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
class BaseDeliveryOrchestrator:
|
|
197
|
+
def __init__(self, channels: list[DeliveryChannel]) -> None: ...
|
|
198
|
+
async def deliver(self, payload: NotificationPayloadDTO) -> bool: ...
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
* Pure: only logs and tries channels in order.
|
|
202
|
+
* Stops on the first `True` return.
|
|
203
|
+
* Logs and falls through on any raised exception.
|
|
204
|
+
* Returns `False` if every channel is exhausted.
|
|
205
|
+
* Does **not** persist anything — the host (or `EmailLog` writer in the
|
|
206
|
+
Django adapter) is responsible for delivery audit.
|
|
207
|
+
|
|
208
|
+
## 5. `messaging.delivery`
|
|
209
|
+
|
|
210
|
+
### `NotificationAdapter`
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
class NotificationAdapter(Protocol):
|
|
214
|
+
def enqueue(self, task_name: str, payload: dict[str, Any]) -> str | None: ...
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
* Implementations: `ArqNotificationAdapter`, `DirectNotificationAdapter`.
|
|
218
|
+
* Returns a job/task ID when the transport supports tracking; `None`
|
|
219
|
+
otherwise.
|
|
220
|
+
* Infrastructure errors propagate.
|
|
221
|
+
|
|
222
|
+
## 6. `messaging.audience` *(new in messaging)*
|
|
223
|
+
|
|
224
|
+
### `AudienceBuilder` Protocol
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
class AudienceBuilder(Protocol):
|
|
228
|
+
def count(self, audience_filter: dict[str, Any]) -> int: ...
|
|
229
|
+
def materialize(
|
|
230
|
+
self, audience_filter: dict[str, Any]
|
|
231
|
+
) -> Iterable[CampaignRecipientDraft]: ...
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
* The filter is a JSON-serializable dict — its shape is project-defined
|
|
235
|
+
(lily uses `consent_marketing`, `locales`, `service_ids`,
|
|
236
|
+
`has_appointment_since`).
|
|
237
|
+
* `materialize()` MUST stream — the Django implementation already uses
|
|
238
|
+
`iterator(chunk_size=500)`. The platform documents this expectation;
|
|
239
|
+
it is not enforceable in a Protocol.
|
|
240
|
+
|
|
241
|
+
### `CampaignDispatcher` Protocol
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
class CampaignDispatcher(Protocol):
|
|
245
|
+
def enqueue_batch(self, batch: CampaignBatchDTO) -> str: ...
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The lily implementation today (`features/conversations/campaigns/dispatcher.py`)
|
|
249
|
+
returns the ARQ `job_id`. Documented identical here.
|
|
250
|
+
|
|
251
|
+
## Migration table
|
|
252
|
+
|
|
253
|
+
| Old (`notifications`) | New (`messaging`) | Notes |
|
|
254
|
+
|-----------------------|-------------------|-------|
|
|
255
|
+
| `NotificationPayloadDTO` | `NotificationPayloadDTO` | Unchanged. |
|
|
256
|
+
| `TemplateNotificationDTO` | `TemplateNotificationDTO` | Unchanged. |
|
|
257
|
+
| `RenderedNotificationDTO` | `RenderedNotificationDTO` | Unchanged. |
|
|
258
|
+
| `NotificationRecipient` | `NotificationRecipient` | Unchanged. |
|
|
259
|
+
| `NotificationChannel` | `NotificationChannel` | Unchanged. |
|
|
260
|
+
| `DeliveryChannel` | `DeliveryChannel` | Adds optional `headers` kwarg. |
|
|
261
|
+
| `ContentProvider` / `ContentCacheAdapter` | unchanged | Unchanged. |
|
|
262
|
+
| *(new)* | `ThreadHeadersDTO` | Promoted from worker helper. |
|
|
263
|
+
| *(new)* | `CampaignRecipientDraft`, `CampaignBatchDTO` | Currently lives in lily. |
|
|
264
|
+
| *(new)* | `AudienceBuilder`, `CampaignDispatcher` | Currently lives in lily. |
|
|
265
|
+
|
|
266
|
+
The legacy import path `codex_platform.notifications.<X>` MUST keep
|
|
267
|
+
working for at least one minor release; see `06_migration_from_notifications.md`.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Channels and Channel Registry
|
|
2
|
+
|
|
3
|
+
## What a "channel" is
|
|
4
|
+
|
|
5
|
+
A `DeliveryChannel` is a single provider-specific transport: SMTP,
|
|
6
|
+
SendGrid HTTP API, Twilio SMS, Telegram bot, etc. Channels are
|
|
7
|
+
stateless from the orchestrator's perspective — they hold connection
|
|
8
|
+
config but no per-request state.
|
|
9
|
+
|
|
10
|
+
The orchestrator (`BaseDeliveryOrchestrator`) does not know how many
|
|
11
|
+
channels exist or in which order they should be tried — that is the
|
|
12
|
+
**registry's** job.
|
|
13
|
+
|
|
14
|
+
## Channel contract (final)
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
class DeliveryChannel(Protocol):
|
|
18
|
+
async def send(
|
|
19
|
+
self,
|
|
20
|
+
to: str,
|
|
21
|
+
subject: str,
|
|
22
|
+
html_content: str | None,
|
|
23
|
+
text_content: str | None,
|
|
24
|
+
headers: ThreadHeadersDTO | None = None, # new
|
|
25
|
+
) -> bool: ...
|
|
26
|
+
|
|
27
|
+
def is_available(self) -> bool: ...
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
* **Returns `True`** — the message was accepted by the provider.
|
|
31
|
+
* **Returns `False`** — recipient was rejected for a *logical* reason
|
|
32
|
+
(suppression list, invalid address). The orchestrator stops; the next
|
|
33
|
+
channel will not be tried.
|
|
34
|
+
* **Raises** — infrastructure error (DNS, TLS, connection refused, 5xx
|
|
35
|
+
from a HTTP API). The orchestrator logs and tries the next channel.
|
|
36
|
+
* **`headers` ignored** when the channel cannot carry RFC 5322 headers
|
|
37
|
+
(SMS, Telegram, WhatsApp). The orchestrator never inspects the field.
|
|
38
|
+
|
|
39
|
+
## Built-in channels
|
|
40
|
+
|
|
41
|
+
### `clients.smtp.AsyncEmailClient`
|
|
42
|
+
|
|
43
|
+
* Existing implementation — covered in
|
|
44
|
+
`codex_platform/notifications/clients/smtp.py`.
|
|
45
|
+
* `is_available()` returns `True` iff `smtp_host` is set and not
|
|
46
|
+
`localhost`.
|
|
47
|
+
* Constructor takes raw config: `smtp_host`, `smtp_port`, `smtp_user`,
|
|
48
|
+
`smtp_password`, `smtp_from_email`, `smtp_use_tls`.
|
|
49
|
+
* Uses `aiosmtplib`. SSL/STARTTLS auto-detected from port (465 → SSL,
|
|
50
|
+
587 → STARTTLS).
|
|
51
|
+
* **Migration delta**: gain `smtp_from_name` parameter. Today the
|
|
52
|
+
rendered `From:` header uses the email-only form because the field
|
|
53
|
+
does not exist. The host is responsible for passing a non-empty
|
|
54
|
+
display name (typically `EmailSettings.email_sender_name`).
|
|
55
|
+
|
|
56
|
+
### `clients.sendgrid.SendGridChannel` *(new, promoted from worker)*
|
|
57
|
+
|
|
58
|
+
* Today the SendGrid logic lives in
|
|
59
|
+
`src/workers/core/base_module/email_client.py:103-129` and is
|
|
60
|
+
hard-wired to `"LILY Beauty Salon"` as the sender name (line 113).
|
|
61
|
+
* Promotion plan:
|
|
62
|
+
1. Move the HTTP POST + payload assembly into a new
|
|
63
|
+
`clients/sendgrid.py` module.
|
|
64
|
+
2. Add it as a registered channel in `ChannelRegistry`. The factory
|
|
65
|
+
returns `None` when `SENDGRID_API_KEY` is missing.
|
|
66
|
+
3. Read sender name from the constructor argument; never hard-code.
|
|
67
|
+
|
|
68
|
+
### Future channels
|
|
69
|
+
|
|
70
|
+
The package only documents the contract. Concrete `TelegramChannel`,
|
|
71
|
+
`TwilioSmsChannel`, etc. live in `codex_platform.messaging.clients.*`
|
|
72
|
+
when added. Each new channel MUST:
|
|
73
|
+
|
|
74
|
+
1. Implement `DeliveryChannel`.
|
|
75
|
+
2. Provide a `register_*_channel(registry, config)` helper.
|
|
76
|
+
3. Add an entry to the migration table in
|
|
77
|
+
`01_core_dtos_and_protocols.md`.
|
|
78
|
+
|
|
79
|
+
## `ChannelRegistry` (unchanged behavior, documented expectations)
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
class ChannelRegistry:
|
|
83
|
+
def register(
|
|
84
|
+
self,
|
|
85
|
+
name: str,
|
|
86
|
+
factory: Callable[[Any], DeliveryChannel | None],
|
|
87
|
+
) -> None: ...
|
|
88
|
+
|
|
89
|
+
def build_channels(self, config: Any) -> list[DeliveryChannel]: ...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
* `name` is for logging only; duplicates are allowed but the registry
|
|
93
|
+
emits a `WARNING`.
|
|
94
|
+
* The factory MUST return `None` (not raise) when the channel cannot be
|
|
95
|
+
configured (missing API key, missing host, etc.). The registry treats
|
|
96
|
+
exceptions raised inside the factory as registration failures and
|
|
97
|
+
logs them via `log.exception`.
|
|
98
|
+
* `build_channels(config)` calls every factory in registration order
|
|
99
|
+
and includes channels that are both non-`None` and report
|
|
100
|
+
`is_available() == True`. The result is the ordered list passed to
|
|
101
|
+
`BaseDeliveryOrchestrator`.
|
|
102
|
+
|
|
103
|
+
### Recommended registration order
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
registry = ChannelRegistry()
|
|
107
|
+
registry.register("smtp", smtp_factory) # primary
|
|
108
|
+
registry.register("sendgrid", sendgrid_factory) # fallback
|
|
109
|
+
# (future) registry.register("amazon_ses", ses_factory)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The orchestrator stops on the first `True` so the **primary** channel
|
|
113
|
+
must be the lowest-cost / highest-trust transport (typically the
|
|
114
|
+
project's own SMTP). Fallbacks come after.
|
|
115
|
+
|
|
116
|
+
## Fallback semantics — explicit
|
|
117
|
+
|
|
118
|
+
The orchestrator currently treats any thrown exception as
|
|
119
|
+
"try next channel". That is the right default, but channels MUST be
|
|
120
|
+
careful to:
|
|
121
|
+
|
|
122
|
+
* Raise for recoverable infrastructure failures (network, TLS, 5xx).
|
|
123
|
+
* Return `False` for permanent rejections (invalid recipient,
|
|
124
|
+
suppression). Returning `False` stops the chain — this is correct
|
|
125
|
+
because retrying with a fallback would just re-deliver to a rejected
|
|
126
|
+
address.
|
|
127
|
+
* **Never** silently swallow exceptions. The orchestrator's audit log
|
|
128
|
+
is the only signal the host has that the chain failed.
|
|
129
|
+
|
|
130
|
+
## SendGrid hardcoded-sender bug — explicit fix
|
|
131
|
+
|
|
132
|
+
`src/workers/core/base_module/email_client.py:113` reads:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
"from": {"email": from_email, "name": "LILY Beauty Salon"}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The migration moves this code into `clients/sendgrid.py` and accepts
|
|
139
|
+
`from_name` via constructor:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
class SendGridChannel:
|
|
143
|
+
def __init__(self, *, api_key: str, from_email: str, from_name: str = "") -> None:
|
|
144
|
+
self.api_key = api_key
|
|
145
|
+
self.from_email = from_email
|
|
146
|
+
self.from_name = from_name or from_email.split("@")[0]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The host (`codex_django.messaging.adapters.sendgrid_factory`) reads
|
|
150
|
+
`EmailSettings.email_sender_name` and passes it to the factory. Once
|
|
151
|
+
the migration lands, no project-specific string ever leaks into the
|
|
152
|
+
platform layer.
|
|
153
|
+
|
|
154
|
+
## Anti-patterns
|
|
155
|
+
|
|
156
|
+
The following patterns are forbidden in any concrete channel:
|
|
157
|
+
|
|
158
|
+
1. **Reading config off a global `django.conf.settings`** — channels
|
|
159
|
+
take all config via constructor.
|
|
160
|
+
2. **Mutating the input DTO** — `send()` is read-only on the payload.
|
|
161
|
+
3. **Holding a long-lived connection in the constructor** — channels
|
|
162
|
+
are created per-worker-startup; if a connection is needed, lazy-init
|
|
163
|
+
it on the first `send()` call. SMTP currently follows this rule via
|
|
164
|
+
`aiosmtplib.send` per call.
|
|
165
|
+
4. **Logging the full HTML body or recipient PII** — the
|
|
166
|
+
`BaseDTO.__repr__` masks PII; channels MUST not bypass it.
|