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.
Files changed (167) hide show
  1. codex_platform-0.5.0/.agents/rules/graphify.md +9 -0
  2. codex_platform-0.5.0/AGENTS.md +9 -0
  3. {codex_platform-0.3.0 → codex_platform-0.5.0}/CHANGELOG.md +25 -0
  4. codex_platform-0.5.0/CLAUDE.md +9 -0
  5. {codex_platform-0.3.0 → codex_platform-0.5.0}/PKG-INFO +1 -1
  6. codex_platform-0.5.0/docs/dev/messaging/00_overview.md +157 -0
  7. codex_platform-0.5.0/docs/dev/messaging/01_core_dtos_and_protocols.md +267 -0
  8. codex_platform-0.5.0/docs/dev/messaging/02_channels_and_registry.md +166 -0
  9. codex_platform-0.5.0/docs/dev/messaging/03_renderer_and_templates.md +151 -0
  10. codex_platform-0.5.0/docs/dev/messaging/04_threading_and_headers.md +142 -0
  11. codex_platform-0.5.0/docs/dev/messaging/05_workers_contract.md +171 -0
  12. codex_platform-0.5.0/docs/dev/messaging/06_migration_from_notifications.md +140 -0
  13. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/streams/README.md +8 -3
  14. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/streams/data_flow.md +8 -8
  15. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/streams/README.md +8 -3
  16. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/streams/data_flow.md +8 -8
  17. codex_platform-0.5.0/redis-backends-recovery.md +376 -0
  18. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/registry.py +1 -1
  19. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/base.py +31 -0
  20. codex_platform-0.5.0/src/codex_platform/redis_service/exceptions.py +20 -0
  21. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/__init__.py +4 -0
  22. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/hash.py +12 -1
  23. codex_platform-0.5.0/src/codex_platform/redis_service/operations/sync_hash.py +263 -0
  24. codex_platform-0.5.0/src/codex_platform/redis_service/operations/sync_string.py +187 -0
  25. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/streams/__init__.py +18 -18
  26. codex_platform-0.5.0/src/codex_platform/streams/codec.py +39 -0
  27. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/streams/consumer.py +59 -5
  28. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/streams/dispatcher.py +33 -12
  29. codex_platform-0.5.0/src/codex_platform/streams/producer.py +130 -0
  30. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/streams/router.py +28 -3
  31. codex_platform-0.5.0/src/codex_platform/streams/runtime.py +90 -0
  32. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/arq/config.py +1 -1
  33. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/integration/test_docs_build.py +2 -1
  34. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/integration/test_streams.py +83 -2
  35. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_stream_consumer.py +30 -0
  36. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_stream_dispatcher.py +21 -2
  37. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_stream_processor.py +28 -0
  38. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_stream_producer.py +44 -14
  39. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_stream_router.py +18 -7
  40. codex_platform-0.5.0/tests/unit/test_stream_runtime.py +77 -0
  41. codex_platform-0.5.0/tests/unit/test_sync_operations.py +112 -0
  42. codex_platform-0.5.0/tools/dev/__init__.py +0 -0
  43. {codex_platform-0.3.0 → codex_platform-0.5.0}/tools/dev/check.py +3 -0
  44. codex_platform-0.3.0/src/codex_platform/redis_service/exceptions.py +0 -20
  45. codex_platform-0.3.0/src/codex_platform/streams/producer.py +0 -60
  46. {codex_platform-0.3.0 → codex_platform-0.5.0}/.github/workflows/ci.yml +0 -0
  47. {codex_platform-0.3.0 → codex_platform-0.5.0}/.github/workflows/docs.yml +0 -0
  48. {codex_platform-0.3.0 → codex_platform-0.5.0}/.github/workflows/publish.yml +0 -0
  49. {codex_platform-0.3.0 → codex_platform-0.5.0}/.gitignore +0 -0
  50. {codex_platform-0.3.0 → codex_platform-0.5.0}/.pre-commit-config.yaml +0 -0
  51. {codex_platform-0.3.0 → codex_platform-0.5.0}/.python-version +0 -0
  52. {codex_platform-0.3.0 → codex_platform-0.5.0}/README.md +0 -0
  53. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/changelog.md +0 -0
  54. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/index.md +0 -0
  55. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/channels.md +0 -0
  56. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/delivery/arq.md +0 -0
  57. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/delivery/base.md +0 -0
  58. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/delivery/direct.md +0 -0
  59. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/dto.md +0 -0
  60. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/index.md +0 -0
  61. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/orchestrator.md +0 -0
  62. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/registry.md +0 -0
  63. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/notifications/renderer.md +0 -0
  64. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/base.md +0 -0
  65. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/index.md +0 -0
  66. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/keys.md +0 -0
  67. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/hash.md +0 -0
  68. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/json_module.md +0 -0
  69. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/json_string.md +0 -0
  70. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/list_.md +0 -0
  71. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/pipeline.md +0 -0
  72. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/set_.md +0 -0
  73. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/string.md +0 -0
  74. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/operations/zset.md +0 -0
  75. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/redis_service/service.md +0 -0
  76. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/consumer.md +0 -0
  77. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/dispatcher.md +0 -0
  78. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/index.md +0 -0
  79. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/processor.md +0 -0
  80. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/producer.md +0 -0
  81. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/streams/router.md +0 -0
  82. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/workers/arq/base.md +0 -0
  83. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/workers/arq/config.md +0 -0
  84. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/workers/arq/index.md +0 -0
  85. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/workers/arq/task_utils.md +0 -0
  86. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/api/workers/arq/types.md +0 -0
  87. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/notifications/README.md +0 -0
  88. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/notifications/data_flow.md +0 -0
  89. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/redis_service/README.md +0 -0
  90. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/redis_service/data_flow.md +0 -0
  91. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/workers/README.md +0 -0
  92. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/en/architecture/workers/data_flow.md +0 -0
  93. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/index.md +0 -0
  94. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/notifications/README.md +0 -0
  95. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/notifications/data_flow.md +0 -0
  96. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/redis_service/README.md +0 -0
  97. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/redis_service/data_flow.md +0 -0
  98. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/workers/README.md +0 -0
  99. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/ru/architecture/workers/data_flow.md +0 -0
  100. {codex_platform-0.3.0 → codex_platform-0.5.0}/docs/stylesheets/extra.css +0 -0
  101. {codex_platform-0.3.0 → codex_platform-0.5.0}/mkdocs.yml +0 -0
  102. {codex_platform-0.3.0 → codex_platform-0.5.0}/project_structure.txt +0 -0
  103. {codex_platform-0.3.0 → codex_platform-0.5.0}/pyproject.toml +0 -0
  104. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/__init__.py +0 -0
  105. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/__init__.py +0 -0
  106. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/channels.py +0 -0
  107. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/clients/__init__.py +0 -0
  108. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/clients/smtp.py +0 -0
  109. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/delivery/__init__.py +0 -0
  110. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/delivery/arq.py +0 -0
  111. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/delivery/base.py +0 -0
  112. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/delivery/direct.py +0 -0
  113. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/dto.py +0 -0
  114. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/interfaces.py +0 -0
  115. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/orchestrator.py +0 -0
  116. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/notifications/renderer.py +0 -0
  117. /codex_platform-0.3.0/tests/integration/__init__.py → /codex_platform-0.5.0/src/codex_platform/py.typed +0 -0
  118. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/__init__.py +0 -0
  119. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/keys.py +0 -0
  120. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/managers/__init__.py +0 -0
  121. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/managers/base_manager.py +0 -0
  122. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/managers/site_settings.py +0 -0
  123. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/json_module.py +0 -0
  124. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/json_string.py +0 -0
  125. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/list_.py +0 -0
  126. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/pipeline.py +0 -0
  127. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/set_.py +0 -0
  128. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/string.py +0 -0
  129. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/operations/zset.py +0 -0
  130. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/redis_service/service.py +0 -0
  131. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/streams/processor.py +0 -0
  132. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/__init__.py +0 -0
  133. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/arq/__init__.py +0 -0
  134. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/arq/base.py +0 -0
  135. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/arq/task_utils.py +0 -0
  136. {codex_platform-0.3.0 → codex_platform-0.5.0}/src/codex_platform/workers/arq/types.py +0 -0
  137. {codex_platform-0.3.0/tests/unit → codex_platform-0.5.0/tests/integration}/__init__.py +0 -0
  138. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/integration/conftest.py +0 -0
  139. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/integration/test_redis_service.py +0 -0
  140. {codex_platform-0.3.0/tools → codex_platform-0.5.0/tests/unit}/__init__.py +0 -0
  141. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/conftest.py +0 -0
  142. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_arq_base.py +0 -0
  143. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_arq_config.py +0 -0
  144. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_arq_public_api.py +0 -0
  145. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_arq_task_utils.py +0 -0
  146. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_adapters.py +0 -0
  147. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_channels.py +0 -0
  148. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_dto.py +0 -0
  149. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_orchestrator.py +0 -0
  150. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_registry.py +0 -0
  151. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_renderer.py +0 -0
  152. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_notification_smtp.py +0 -0
  153. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_base.py +0 -0
  154. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_hash.py +0 -0
  155. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_keys.py +0 -0
  156. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_list.py +0 -0
  157. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_manager.py +0 -0
  158. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_pipeline.py +0 -0
  159. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_service.py +0 -0
  160. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_set.py +0 -0
  161. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_string.py +0 -0
  162. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_redis_zset.py +0 -0
  163. {codex_platform-0.3.0 → codex_platform-0.5.0}/tests/unit/test_site_settings_manager.py +0 -0
  164. {codex_platform-0.3.0/tools/dev → codex_platform-0.5.0/tools}/__init__.py +0 -0
  165. {codex_platform-0.3.0 → codex_platform-0.5.0}/tools/dev/README.md +0 -0
  166. {codex_platform-0.3.0 → codex_platform-0.5.0}/tools/dev/generate_project_tree.py +0 -0
  167. {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.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.