babelqueue 1.19.0__tar.gz → 1.21.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 (148) hide show
  1. {babelqueue-1.19.0 → babelqueue-1.21.0}/.github/workflows/ci.yml +35 -8
  2. babelqueue-1.21.0/.github/workflows/release.yml +66 -0
  3. {babelqueue-1.19.0 → babelqueue-1.21.0}/CHANGELOG.md +57 -0
  4. {babelqueue-1.19.0 → babelqueue-1.21.0}/PKG-INFO +26 -1
  5. {babelqueue-1.19.0 → babelqueue-1.21.0}/README.md +25 -0
  6. babelqueue-1.21.0/conformance-known-gaps.json +84 -0
  7. {babelqueue-1.19.0 → babelqueue-1.21.0}/pyproject.toml +1 -1
  8. babelqueue-1.21.0/scripts/check_conformance_results.py +160 -0
  9. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/__init__.py +1 -1
  10. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/_envelope_shape.py +3 -0
  11. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/app.py +10 -6
  12. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/artemis_transport.py +3 -3
  13. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/asb_transport.py +3 -3
  14. babelqueue-1.21.0/src/babelqueue/codec.py +341 -0
  15. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/exceptions.py +15 -0
  16. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/kafka_transport.py +3 -3
  17. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/pulsar_transport.py +13 -8
  18. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/redrive.py +2 -2
  19. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/sqs_transport.py +7 -4
  20. babelqueue-1.21.0/tests/conformance/CONFORMANCE_VERSION +1 -0
  21. babelqueue-1.21.0/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +16 -0
  22. babelqueue-1.21.0/tests/conformance/fixtures/dead-letter-roundtrip.json +23 -0
  23. babelqueue-1.21.0/tests/conformance/fixtures/forbidden-key-job-and-urn.json +16 -0
  24. babelqueue-1.21.0/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +16 -0
  25. babelqueue-1.21.0/tests/conformance/fixtures/job-type-array-urn-fallback.json +18 -0
  26. babelqueue-1.21.0/tests/conformance/fixtures/job-type-bool-urn-fallback.json +16 -0
  27. babelqueue-1.21.0/tests/conformance/fixtures/job-type-empty-string-urn-fallback.json +16 -0
  28. babelqueue-1.21.0/tests/conformance/fixtures/job-type-missing-job-array-urn.json +17 -0
  29. babelqueue-1.21.0/tests/conformance/fixtures/job-type-null-no-urn.json +15 -0
  30. babelqueue-1.21.0/tests/conformance/fixtures/job-type-null-urn-fallback.json +16 -0
  31. babelqueue-1.21.0/tests/conformance/fixtures/job-type-number-blank-urn.json +16 -0
  32. babelqueue-1.21.0/tests/conformance/fixtures/job-type-number-nonstring-urn.json +16 -0
  33. babelqueue-1.21.0/tests/conformance/fixtures/job-type-number-urn-fallback.json +16 -0
  34. babelqueue-1.21.0/tests/conformance/fixtures/job-type-object-urn-fallback.json +18 -0
  35. babelqueue-1.21.0/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +27 -0
  36. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-job-cr.json +16 -0
  37. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-job-ff.json +16 -0
  38. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-job-lf.json +16 -0
  39. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-job-space.json +16 -0
  40. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-job-tab.json +16 -0
  41. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-job-vt.json +16 -0
  42. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-trace-id-cr.json +15 -0
  43. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-trace-id-ff.json +15 -0
  44. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-trace-id-lf.json +15 -0
  45. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-trace-id-space.json +15 -0
  46. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-trace-id-tab.json +15 -0
  47. babelqueue-1.21.0/tests/conformance/fixtures/trim-blank-trace-id-vt.json +15 -0
  48. babelqueue-1.21.0/tests/conformance/fixtures/trim-job-and-urn-both-blank.json +16 -0
  49. babelqueue-1.21.0/tests/conformance/fixtures/trim-reemit-job-ascii-padded.json +15 -0
  50. babelqueue-1.21.0/tests/conformance/fixtures/trim-reemit-job-unicode-padded.json +15 -0
  51. babelqueue-1.21.0/tests/conformance/fixtures/trim-reemit-trace-id-padded.json +15 -0
  52. babelqueue-1.21.0/tests/conformance/fixtures/trim-unicode-job-emsp.json +15 -0
  53. babelqueue-1.21.0/tests/conformance/fixtures/trim-unicode-job-ideographic.json +15 -0
  54. babelqueue-1.21.0/tests/conformance/fixtures/trim-unicode-job-nbsp.json +15 -0
  55. babelqueue-1.21.0/tests/conformance/fixtures/trim-urn-only-ascii-blank.json +15 -0
  56. babelqueue-1.21.0/tests/conformance/fixtures/trim-urn-only-unicode-space.json +15 -0
  57. babelqueue-1.21.0/tests/conformance/fixtures/urn-only-roundtrip.json +15 -0
  58. babelqueue-1.21.0/tests/conformance/manifest.json +1863 -0
  59. babelqueue-1.21.0/tests/conformance/schema/results.example.json +60 -0
  60. babelqueue-1.21.0/tests/conformance/schema/results.schema.json +109 -0
  61. babelqueue-1.21.0/tests/test_behaviour_conformance.py +1266 -0
  62. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_codec.py +5 -7
  63. babelqueue-1.21.0/tests/test_conformance.py +295 -0
  64. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_kafka_transport.py +1 -1
  65. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_pulsar_transport.py +16 -2
  66. babelqueue-1.21.0/tests/test_reencode_regression.py +186 -0
  67. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_schema_conformance.py +4 -2
  68. babelqueue-1.19.0/.github/workflows/release.yml +0 -46
  69. babelqueue-1.19.0/src/babelqueue/codec.py +0 -214
  70. babelqueue-1.19.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  71. babelqueue-1.19.0/tests/conformance/manifest.json +0 -675
  72. babelqueue-1.19.0/tests/test_behaviour_conformance.py +0 -195
  73. babelqueue-1.19.0/tests/test_conformance.py +0 -58
  74. {babelqueue-1.19.0 → babelqueue-1.21.0}/.github/FUNDING.yml +0 -0
  75. {babelqueue-1.19.0 → babelqueue-1.21.0}/.github/dependabot.yml +0 -0
  76. {babelqueue-1.19.0 → babelqueue-1.21.0}/.gitignore +0 -0
  77. {babelqueue-1.19.0 → babelqueue-1.21.0}/LICENSE +0 -0
  78. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/celery.py +0 -0
  79. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/contracts.py +0 -0
  80. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/dead_letter.py +0 -0
  81. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/django/__init__.py +0 -0
  82. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/django/apps.py +0 -0
  83. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/django/management/__init__.py +0 -0
  84. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  85. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  86. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/gdpr.py +0 -0
  87. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/headers.py +0 -0
  88. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/idempotency.py +0 -0
  89. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/otel.py +0 -0
  90. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/outbox.py +0 -0
  91. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/pika_transport.py +0 -0
  92. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/py.typed +0 -0
  93. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/redis_transport.py +0 -0
  94. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/replay.py +0 -0
  95. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/routing.py +0 -0
  96. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/schema.py +0 -0
  97. {babelqueue-1.19.0 → babelqueue-1.21.0}/src/babelqueue/transport.py +0 -0
  98. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  99. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  100. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
  101. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  102. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  103. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  104. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  105. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  106. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  107. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  108. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  109. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
  110. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/order-created.json +0 -0
  111. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  112. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  113. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  114. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  115. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  116. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
  117. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  118. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
  119. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  120. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/fixtures/dead-lettered.json +0 -0
  121. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/fixtures/order-created.json +0 -0
  122. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/poison-trigger-set.json +0 -0
  123. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_app.py +0 -0
  124. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_artemis_conformance.py +0 -0
  125. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_artemis_transport.py +0 -0
  126. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_asb_conformance.py +0 -0
  127. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_asb_transport.py +0 -0
  128. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_celery.py +0 -0
  129. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_dead_letter.py +0 -0
  130. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_django.py +0 -0
  131. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_gdpr.py +0 -0
  132. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_headers.py +0 -0
  133. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_idempotency.py +0 -0
  134. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_kafka_conformance.py +0 -0
  135. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_kafka_integration.py +0 -0
  136. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_kafka_integration_logic.py +0 -0
  137. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_otel.py +0 -0
  138. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_outbox.py +0 -0
  139. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_overhead.py +0 -0
  140. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_pika_transport.py +0 -0
  141. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_pulsar_conformance.py +0 -0
  142. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_redis_transport.py +0 -0
  143. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_redrive.py +0 -0
  144. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_replay.py +0 -0
  145. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_schema.py +0 -0
  146. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_shutdown.py +0 -0
  147. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_sqs_conformance.py +0 -0
  148. {babelqueue-1.19.0 → babelqueue-1.21.0}/tests/test_sqs_transport.py +0 -0
@@ -160,18 +160,45 @@ jobs:
160
160
  run: pytest tests/test_kafka_integration.py -v
161
161
 
162
162
  conformance:
163
- name: Conformance suite in sync
163
+ name: Conformance suite in sync + results.json
164
164
  runs-on: ubuntu-latest
165
+ env:
166
+ # The pinned conformance release (ADR-0032). Bump together with tests/conformance/.
167
+ BQ_CONFORMANCE_VERSION: 1.5.0
168
+ # sha256 of that release's fetch.sh: the script runs on the runner, so it is pinned rather
169
+ # than trusted from a mutable tag. Bump together with BQ_CONFORMANCE_VERSION.
170
+ BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
165
171
  steps:
166
172
  - uses: actions/checkout@v7
167
- - name: Verify vendored conformance matches the canonical suite
173
+
174
+ - name: Setup Python
175
+ uses: actions/setup-python@v7
176
+ with:
177
+ python-version: '3.12'
178
+
179
+ - name: Verify the vendored suite against the release asset
168
180
  run: |
169
- git clone --depth 1 --branch v1.2.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
170
- diff -u "$RUNNER_TEMP/conformance/CONFORMANCE_VERSION" "tests/conformance/CONFORMANCE_VERSION"
171
- diff -ru "$RUNNER_TEMP/conformance/manifest.json" "tests/conformance/manifest.json"
172
- diff -ru "$RUNNER_TEMP/conformance/fixtures" "tests/conformance/fixtures"
173
- diff -ru "$RUNNER_TEMP/conformance/schema" "tests/conformance/schema"
174
- echo "Vendored conformance is in sync with the canonical suite."
181
+ # fetch.sh comes from the pinned tag; it downloads babelqueue-conformance-<v>.tar.gz,
182
+ # verifies it against the release SHA256SUMS before unpacking, then diffs the vendor dir.
183
+ curl -fsSL -o "$RUNNER_TEMP/fetch.sh" \
184
+ "https://raw.githubusercontent.com/BabelQueue/conformance/v${BQ_CONFORMANCE_VERSION}/fetch.sh"
185
+ echo "${BQ_FETCH_SH_SHA256} $RUNNER_TEMP/fetch.sh" | sha256sum -c -
186
+ bash "$RUNNER_TEMP/fetch.sh" --check "$BQ_CONFORMANCE_VERSION" tests/conformance
187
+ test "$(tr -d '[:space:]' < tests/conformance/CONFORMANCE_VERSION)" = "$BQ_CONFORMANCE_VERSION"
188
+ echo "Vendored conformance is in sync with the v${BQ_CONFORMANCE_VERSION} release asset."
189
+
190
+ - name: Install
191
+ run: |
192
+ python -m pip install --upgrade pip
193
+ pip install -e ".[dev,celery,django,otel]" opentelemetry-sdk jsonschema
194
+
195
+ - name: Run the conformance suite and write results.json
196
+ env:
197
+ BQ_CONFORMANCE_RESULTS: ${{ runner.temp }}/results.json
198
+ run: pytest -q tests/test_conformance.py
199
+
200
+ - name: Validate results.json (schema, bindings, sections)
201
+ run: python scripts/check_conformance_results.py "$RUNNER_TEMP/results.json" tests/conformance
175
202
 
176
203
  ci-green:
177
204
  name: CI green
@@ -0,0 +1,66 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ release:
12
+ name: Build, test & publish to PyPI
13
+ runs-on: ubuntu-latest
14
+ # Recommended for PyPI Trusted Publishing — configure a "pypi" environment +
15
+ # a trusted publisher on PyPI (repo BabelQueue/babelqueue-python, this workflow).
16
+ environment: pypi
17
+ env:
18
+ # The pinned conformance release (ADR-0032). Keep identical to ci.yml.
19
+ BQ_CONFORMANCE_VERSION: 1.5.0
20
+ BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
21
+ permissions:
22
+ id-token: write # PyPI Trusted Publishing (OIDC) — no API token needed
23
+ contents: write # create the GitHub release
24
+ steps:
25
+ - uses: actions/checkout@v7
26
+
27
+ - name: Setup Python
28
+ uses: actions/setup-python@v7
29
+ with:
30
+ python-version: '3.12'
31
+
32
+ - name: Verify the vendored suite against the release asset
33
+ run: |
34
+ curl -fsSL -o "$RUNNER_TEMP/fetch.sh" \
35
+ "https://raw.githubusercontent.com/BabelQueue/conformance/v${BQ_CONFORMANCE_VERSION}/fetch.sh"
36
+ echo "${BQ_FETCH_SH_SHA256} $RUNNER_TEMP/fetch.sh" | sha256sum -c -
37
+ bash "$RUNNER_TEMP/fetch.sh" --check "$BQ_CONFORMANCE_VERSION" tests/conformance
38
+ test "$(tr -d '[:space:]' < tests/conformance/CONFORMANCE_VERSION)" = "$BQ_CONFORMANCE_VERSION"
39
+
40
+ - name: Install & test (writes results.json)
41
+ env:
42
+ BQ_CONFORMANCE_RESULTS: ${{ runner.temp }}/results.json
43
+ run: |
44
+ python -m pip install --upgrade pip build
45
+ pip install -e ".[dev,celery,django,otel]" opentelemetry-sdk jsonschema
46
+ pytest
47
+ test -s "$BQ_CONFORMANCE_RESULTS" # the conformance run must have produced results.json
48
+
49
+ - name: Validate results.json (schema, bindings, sections)
50
+ run: python scripts/check_conformance_results.py "$RUNNER_TEMP/results.json" tests/conformance
51
+
52
+ - name: Build distributions
53
+ run: python -m build
54
+
55
+ - name: Publish to PyPI
56
+ uses: pypa/gh-action-pypi-publish@release/v1
57
+ # Or, with a token instead of trusted publishing:
58
+ # with:
59
+ # password: ${{ secrets.PYPI_API_TOKEN }}
60
+
61
+ - name: Create GitHub release
62
+ uses: softprops/action-gh-release@v3
63
+ with:
64
+ generate_release_notes: true
65
+ files: ${{ runner.temp }}/results.json
66
+ fail_on_unmatched_files: true
@@ -9,6 +9,63 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.21.0] - 2026-10-19
13
+
14
+ Implements ADR-0032 (conformance packaging, results format, ASCII trim and the non-string `job` rule).
15
+ The envelope stays at `schema_version: 1`.
16
+
17
+ ### Changed
18
+ - **`EnvelopeCodec.decode` raises `BabelQueueError` for a JSON object with neither a valid string `job` nor
19
+ a valid string `urn`** (ADR-0032 section 6). Malformed or non-object input still yields `{}`. The runtime
20
+ dispatch, the transports' attempts reconciliation and `redrive` use the new `decode_lenient`, so such a
21
+ body still takes the poison / pass-through path.
22
+ - A non-string `job` (`None`, number, boolean, object, array, falsy or not) is ignored with a warning and a
23
+ valid string `urn` takes over.
24
+ - Trim and blank checks in `make` (`urn`, `trace_id`) and `accepts` (`trace_id`) use the closed ASCII set
25
+ (space, `\t`, `\n`, `\v`, `\f`, `\r`); `str.strip()` Unicode whitespace is no longer trimmed
26
+ (ADR-0032 section 5). The set is exported as `codec.ASCII_WHITESPACE` and the transports import it.
27
+
28
+ ### Added
29
+ - `EnvelopeCodec.decode_lenient`.
30
+ - Conformance runner covers the v1.5.0 sections and writes `results.json` (ADR-0032 section 2).
31
+ `conformance-known-gaps.json` and `scripts/check_conformance_results.py` gate it in CI and release: the
32
+ SQS `delay` and Kafka `backoff` tier cases run and record honest `fail`s until they are implemented.
33
+
34
+ ## [1.20.0] - 2026-10-17
35
+
36
+ Conformance suite 1.4.0, [ADR-0031](https://babelqueue.com). The envelope is unchanged
37
+ (`schema_version: 1`); this is a MINOR with one deliberate behaviour change (encode rejects).
38
+
39
+ ### Added
40
+ - `EnvelopeCodec.make(urn, data, *, queue, trace_id, meta=None)`: a producer `meta` write API. The entries
41
+ land in `meta` next to the core keys (recommended shape `meta.ext.<vendor>`). A collision with a core
42
+ meta key or a forbidden key raises the new `EnvelopeKeyError` (subclass of `BabelQueueError`, with a
43
+ `pointer` attribute). `_envelope_shape.CORE_META_KEYS` is the single copy of the core key list.
44
+ - `decode` folds the `urn` alias into `job` (ADR-0031 (c)): a `job` that is missing, empty or only ASCII
45
+ whitespace (`" \t\n\v\f\r"`; a U+2003 `job` is NOT blank) falls back to `urn` silently; when both carry
46
+ a value, `job` wins and `urn` is dropped with a warning naming `/urn`. `accepts()` / `urn()` follow the
47
+ same rule. The decoded envelope never keeps a `urn` key.
48
+ - Tests: the runner now implements the 1.4.0 fields (`expect_preserved` on `forbidden_keys`;
49
+ `expect_absent_after_reencode`, `apply_dead_letter`, `expect_dead_letter` on `roundtrip`, the latter via
50
+ `dead_letter.annotate`) plus the `extension_write` and `encode_reject` sections, with a runner
51
+ self-test proving violations are reported.
52
+
53
+ ### Changed
54
+ - **Behaviour change: `EnvelopeCodec.encode` raises `EnvelopeKeyError`** for an envelope carrying a
55
+ forbidden key (message-envelope.md section 10) or a top-level `urn`, instead of dropping it with a
56
+ warning (policy K-15 (a)). Nothing is written. `decode` still warns and drops, so re-encoding a decoded
57
+ envelope is safe. Code that built an envelope from a raw legacy body and relied on encode to clean it
58
+ must decode it first (`EnvelopeCodec.encode(EnvelopeCodec.decode(raw))`).
59
+ - `test_all_five_contract_keys_are_covered` is a subset check (the suite adds `/urn` cases).
60
+ - Docs only: the note in the previous release that the Go reference's case-insensitive envelope-key read "is tightened separately" is now closed — Go core 1.14.0 reads envelope keys exactly (case-sensitive, last duplicate wins), so every SDK now decides poison on the same exact-case keys.
61
+
62
+ ### Fixed
63
+ - Raw-body re-encode paths now decode before they encode, so the new `encode` rejection cannot strand a
64
+ legacy body: the runtime's raw-body RELEASE re-publish (`app.py`), and the SQS and Pulsar attempts
65
+ reconciliation (Kafka, Azure Service Bus and Artemis already decoded first). A body with a legacy
66
+ `timestamp` or only a `urn` alias is re-emitted normalised (forbidden key dropped and warned, `urn`
67
+ folded into `job`); unknown top-level and `meta` keys, including `meta.ext`, are preserved.
68
+
12
69
  ## [1.19.0] - 2026-10-12
13
70
 
14
71
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.19.0
3
+ Version: 1.21.0
4
4
  Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
5
5
  Project-URL: Homepage, https://babelqueue.com
6
6
  Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
@@ -58,6 +58,8 @@ Description-Content-Type: text/markdown
58
58
  [![PyPI](https://img.shields.io/pypi/v/babelqueue.svg)](https://pypi.org/project/babelqueue/)
59
59
  [![Python](https://img.shields.io/pypi/pyversions/babelqueue.svg)](https://pypi.org/project/babelqueue/)
60
60
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
61
+ [![Live interop](https://img.shields.io/endpoint?url=https%3A%2F%2Fbabelqueue.com%2Fbadges%2Fnightly.json)](https://babelqueue.com/compatibility/)
62
+ [![BabelQueue conformance](https://img.shields.io/badge/BabelQueue-conformance%20matrix-10B981.svg)](https://babelqueue.com/compatibility/)
61
63
 
62
64
  > **Polyglot Queues, Simplified.** Read and write the canonical BabelQueue message
63
65
  > envelope from Python — so your Python services (AI/ML, data processing, …)
@@ -106,6 +108,29 @@ The envelope is identical to every other SDK's:
106
108
  }
107
109
  ```
108
110
 
111
+ ### Extension metadata, forbidden keys and the `urn` alias (1.20.0, ADR-0031)
112
+
113
+ ```python
114
+ # Producer meta: extra keys next to the core meta keys (recommended shape: meta.ext.<vendor>).
115
+ envelope = EnvelopeCodec.make(
116
+ "urn:babel:orders:created", {"order_id": 1042},
117
+ queue="orders", meta={"ext": {"acme": {"tier": "gold"}}},
118
+ )
119
+ ```
120
+
121
+ - `meta=` is keyword-only. A key that collides with a core meta key (`id`, `queue`, `lang`,
122
+ `schema_version`, `created_at`) or with a forbidden key (`max_retries`, `attempts`, `source`, `ts`)
123
+ raises `EnvelopeKeyError` (a `BabelQueueError`; its `pointer` names the key, e.g. `/meta/ts`).
124
+ - Unknown top-level and `meta` keys of an inbound message survive a decode -> `attempts + 1` -> encode
125
+ hop (retry, redrive, dead-letter), so a vendor `meta.ext` block is never lost.
126
+ - `EnvelopeCodec.encode` **rejects** an envelope that carries a forbidden key (top-level `timestamp`;
127
+ `meta.max_retries`, `meta.attempts`, `meta.source`, `meta.ts`) or the inbound-only top-level `urn`
128
+ alias: it raises `EnvelopeKeyError` instead of dropping the key with a warning. `decode` is unchanged
129
+ (it still warns and drops), so a decoded envelope can always be re-encoded.
130
+ - `decode` folds `urn` into `job`: a `job` that is missing, empty or only ASCII whitespace (space, `\t`,
131
+ `\n`, `\v`, `\f`, `\r`) falls back to `urn`; when both carry a value, `job` wins and `urn` is dropped
132
+ with a warning naming `/urn`. The decoded envelope never keeps a `urn` key.
133
+
109
134
  ### Typed messages (optional)
110
135
 
111
136
  ```python
@@ -4,6 +4,8 @@
4
4
  [![PyPI](https://img.shields.io/pypi/v/babelqueue.svg)](https://pypi.org/project/babelqueue/)
5
5
  [![Python](https://img.shields.io/pypi/pyversions/babelqueue.svg)](https://pypi.org/project/babelqueue/)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
+ [![Live interop](https://img.shields.io/endpoint?url=https%3A%2F%2Fbabelqueue.com%2Fbadges%2Fnightly.json)](https://babelqueue.com/compatibility/)
8
+ [![BabelQueue conformance](https://img.shields.io/badge/BabelQueue-conformance%20matrix-10B981.svg)](https://babelqueue.com/compatibility/)
7
9
 
8
10
  > **Polyglot Queues, Simplified.** Read and write the canonical BabelQueue message
9
11
  > envelope from Python — so your Python services (AI/ML, data processing, …)
@@ -52,6 +54,29 @@ The envelope is identical to every other SDK's:
52
54
  }
53
55
  ```
54
56
 
57
+ ### Extension metadata, forbidden keys and the `urn` alias (1.20.0, ADR-0031)
58
+
59
+ ```python
60
+ # Producer meta: extra keys next to the core meta keys (recommended shape: meta.ext.<vendor>).
61
+ envelope = EnvelopeCodec.make(
62
+ "urn:babel:orders:created", {"order_id": 1042},
63
+ queue="orders", meta={"ext": {"acme": {"tier": "gold"}}},
64
+ )
65
+ ```
66
+
67
+ - `meta=` is keyword-only. A key that collides with a core meta key (`id`, `queue`, `lang`,
68
+ `schema_version`, `created_at`) or with a forbidden key (`max_retries`, `attempts`, `source`, `ts`)
69
+ raises `EnvelopeKeyError` (a `BabelQueueError`; its `pointer` names the key, e.g. `/meta/ts`).
70
+ - Unknown top-level and `meta` keys of an inbound message survive a decode -> `attempts + 1` -> encode
71
+ hop (retry, redrive, dead-letter), so a vendor `meta.ext` block is never lost.
72
+ - `EnvelopeCodec.encode` **rejects** an envelope that carries a forbidden key (top-level `timestamp`;
73
+ `meta.max_retries`, `meta.attempts`, `meta.source`, `meta.ts`) or the inbound-only top-level `urn`
74
+ alias: it raises `EnvelopeKeyError` instead of dropping the key with a warning. `decode` is unchanged
75
+ (it still warns and drops), so a decoded envelope can always be re-encoded.
76
+ - `decode` folds `urn` into `job`: a `job` that is missing, empty or only ASCII whitespace (space, `\t`,
77
+ `\n`, `\v`, `\f`, `\r`) falls back to `urn`; when both carry a value, `job` wins and `urn` is dropped
78
+ with a warning naming `/urn`. The decoded envelope never keeps a `urn` key.
79
+
55
80
  ### Typed messages (optional)
56
81
 
57
82
  ```python
@@ -0,0 +1,84 @@
1
+ {
2
+ "gaps": [
3
+ {
4
+ "section": "delay",
5
+ "case": "standard-1s",
6
+ "reason": "Python SqsTransport has no per-message DelaySeconds publish path (broker-bindings section 3.4, ADR-0012); not implemented in this core, tracked in the r1 follow-up plan"
7
+ },
8
+ {
9
+ "section": "delay",
10
+ "case": "standard-60s",
11
+ "reason": "Python SqsTransport has no per-message DelaySeconds publish path (broker-bindings section 3.4, ADR-0012); not implemented in this core, tracked in the r1 follow-up plan"
12
+ },
13
+ {
14
+ "section": "delay",
15
+ "case": "standard-900s-is-the-cap",
16
+ "reason": "Python SqsTransport has no per-message DelaySeconds publish path (broker-bindings section 3.4, ADR-0012); not implemented in this core, tracked in the r1 follow-up plan"
17
+ },
18
+ {
19
+ "section": "delay",
20
+ "case": "standard-901s-rejected",
21
+ "reason": "Python SqsTransport has no per-message DelaySeconds publish path (broker-bindings section 3.4, ADR-0012); not implemented in this core, tracked in the r1 follow-up plan"
22
+ },
23
+ {
24
+ "section": "delay",
25
+ "case": "standard-3600s-rejected",
26
+ "reason": "Python SqsTransport has no per-message DelaySeconds publish path (broker-bindings section 3.4, ADR-0012); not implemented in this core, tracked in the r1 follow-up plan"
27
+ },
28
+ {
29
+ "section": "delay",
30
+ "case": "fifo-per-message-delay-rejected",
31
+ "reason": "Python SqsTransport has no per-message DelaySeconds publish path (broker-bindings section 3.4, ADR-0012); not implemented in this core, tracked in the r1 follow-up plan"
32
+ },
33
+ {
34
+ "section": "backoff",
35
+ "case": "below-first-tier",
36
+ "reason": "Python KafkaTransport has no retry-topic tier routing (broker-bindings sections 6.4/6.5, ADR-0015); not implemented in this core, tracked in the r1 follow-up plan"
37
+ },
38
+ {
39
+ "section": "backoff",
40
+ "case": "exactly-first-tier",
41
+ "reason": "Python KafkaTransport has no retry-topic tier routing (broker-bindings sections 6.4/6.5, ADR-0015); not implemented in this core, tracked in the r1 follow-up plan"
42
+ },
43
+ {
44
+ "section": "backoff",
45
+ "case": "just-above-first-tier",
46
+ "reason": "Python KafkaTransport has no retry-topic tier routing (broker-bindings sections 6.4/6.5, ADR-0015); not implemented in this core, tracked in the r1 follow-up plan"
47
+ },
48
+ {
49
+ "section": "backoff",
50
+ "case": "exactly-third-tier",
51
+ "reason": "Python KafkaTransport has no retry-topic tier routing (broker-bindings sections 6.4/6.5, ADR-0015); not implemented in this core, tracked in the r1 follow-up plan"
52
+ },
53
+ {
54
+ "section": "backoff",
55
+ "case": "exactly-largest-tier",
56
+ "reason": "Python KafkaTransport has no retry-topic tier routing (broker-bindings sections 6.4/6.5, ADR-0015); not implemented in this core, tracked in the r1 follow-up plan"
57
+ },
58
+ {
59
+ "section": "backoff",
60
+ "case": "above-largest-tier-rejected",
61
+ "reason": "Python KafkaTransport has no retry-topic tier routing (broker-bindings sections 6.4/6.5, ADR-0015); not implemented in this core, tracked in the r1 follow-up plan"
62
+ },
63
+ {
64
+ "section": "backoff",
65
+ "case": "no-tiers-configured-rejected",
66
+ "reason": "Python KafkaTransport has no retry-topic tier routing (broker-bindings sections 6.4/6.5, ADR-0015); not implemented in this core, tracked in the r1 follow-up plan"
67
+ },
68
+ {
69
+ "section": "backoff",
70
+ "case": "retry_republish/first-retry",
71
+ "reason": "Python KafkaTransport has no retry-topic tier routing (broker-bindings sections 6.4/6.5, ADR-0015); not implemented in this core, tracked in the r1 follow-up plan"
72
+ },
73
+ {
74
+ "section": "backoff",
75
+ "case": "retry_republish/third-retry-picks-larger-tier",
76
+ "reason": "Python KafkaTransport has no retry-topic tier routing (broker-bindings sections 6.4/6.5, ADR-0015); not implemented in this core, tracked in the r1 follow-up plan"
77
+ },
78
+ {
79
+ "section": "backoff",
80
+ "case": "retry_republish/no-tiers-configured-rejected",
81
+ "reason": "Python KafkaTransport has no retry-topic tier routing (broker-bindings sections 6.4/6.5, ADR-0015); not implemented in this core, tracked in the r1 follow-up plan"
82
+ }
83
+ ]
84
+ }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.19.0"
7
+ version = "1.21.0"
8
8
  description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """Completeness gate for a conformance ``results.json`` (ADR-0032 section 3 / Compliance).
3
+
4
+ Usage: python scripts/check_conformance_results.py <results.json> <conformance-dir> [src-dir]
5
+
6
+ Known gaps: a ``fail`` is accepted only when ``conformance-known-gaps.json`` (repo root) lists its
7
+ section and case; a listed case that reports ``pass`` is a stale gap and fails the gate, as does
8
+ a ``skip`` or a missing result for a listed case, and a gap naming a case the manifest lacks.
9
+
10
+ Shared by ``ci.yml`` and ``release.yml`` so a tag cannot publish an asset CI would have refused.
11
+ It is deliberately independent of the test runner: the carried bindings are re-derived from the
12
+ package modules and compared for *set equality* with ``results.bindings`` (a missing binding and
13
+ a surplus one both fail), then the sections are checked against the manifest scopes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import jsonschema
24
+
25
+ # Transport module -> binding (manifest scope). Keep in step with tests/test_conformance.py.
26
+ TRANSPORT_BINDINGS = {
27
+ "redis_transport": "redis",
28
+ "pika_transport": "amqp",
29
+ "sqs_transport": "sqs",
30
+ "asb_transport": "asb",
31
+ "pulsar_transport": "pulsar",
32
+ "kafka_transport": "kafka",
33
+ "artemis_transport": "artemis",
34
+ }
35
+
36
+
37
+ def derive_bindings(src: Path) -> set:
38
+ bindings = set()
39
+ for path in sorted(src.glob("*_transport.py")):
40
+ if path.stem not in TRANSPORT_BINDINGS:
41
+ raise SystemExit(f"transport module {path.name} has no binding mapping")
42
+ bindings.add(TRANSPORT_BINDINGS[path.stem])
43
+ otel = src / "otel.py"
44
+ if otel.is_file() and "traceparent" in otel.read_text(encoding="utf-8").lower():
45
+ bindings.add("otel")
46
+ return bindings
47
+
48
+
49
+ GAPS_FILE = Path(__file__).resolve().parent.parent / "conformance-known-gaps.json"
50
+
51
+ #: A reason must cite the contract section and the follow-up plan, so a gap is never anonymous.
52
+ REASON_PATTERN = re.compile(r"section[s]? \d")
53
+ REASON_PLAN = "r1 follow-up plan"
54
+
55
+
56
+ def check_gap_entry(entry: dict, manifest: dict) -> list:
57
+ """Problems with one known-gap entry against the manifest (empty when it is well formed)."""
58
+ problems = []
59
+ section, case, reason = entry["section"], entry["case"], entry["reason"]
60
+ definition = manifest["sections"].get(section)
61
+ if definition is None:
62
+ problems.append(f"{section}/{case}: unknown section")
63
+ elif definition["scope"] == "core":
64
+ # Only a broker-scoped feature may be a documented gap; the core envelope never is.
65
+ problems.append(f"{section}/{case}: a core-scope section cannot have a known gap")
66
+ if not REASON_PATTERN.search(reason) or REASON_PLAN not in reason:
67
+ problems.append(f'{section}/{case}: reason must cite a contract "section N" and the "{REASON_PLAN}"')
68
+ return problems
69
+
70
+
71
+ def load_gaps(path: Path, errors: list, manifest: dict) -> dict:
72
+ """``(section, case) -> reason`` from the known-gaps file; malformed entries are errors."""
73
+ if not path.is_file():
74
+ return {}
75
+ document = json.loads(path.read_text(encoding="utf-8"))
76
+ gaps: dict = {}
77
+ entry_problems: list = []
78
+ entries = document.get("gaps") if isinstance(document, dict) else None
79
+ if not isinstance(entries, list):
80
+ errors.append(f'{path.name}: top level must be {{"gaps": [...]}}')
81
+ return gaps
82
+ for index, entry in enumerate(entries):
83
+ if not isinstance(entry, dict) or not all(
84
+ isinstance(entry.get(key), str) and entry[key].strip() for key in ("section", "case", "reason")
85
+ ):
86
+ errors.append(f"{path.name}: gaps[{index}] needs non-empty string section, case and reason")
87
+ continue
88
+ key = (entry["section"], entry["case"])
89
+ entry_problems.extend(check_gap_entry(entry, manifest))
90
+ if key in gaps:
91
+ errors.append(f"{path.name}: duplicate gap {key[0]}/{key[1]}")
92
+ gaps[key] = entry["reason"]
93
+ errors.extend(f"{path.name}: {problem}" for problem in entry_problems)
94
+ return gaps
95
+
96
+
97
+ def main(argv: list) -> int:
98
+ if len(argv) not in (2, 3):
99
+ print(__doc__, file=sys.stderr)
100
+ return 2
101
+ results_path, suite = Path(argv[0]), Path(argv[1])
102
+ src = Path(argv[2]) if len(argv) == 3 else Path("src/babelqueue")
103
+
104
+ results = json.loads(results_path.read_text(encoding="utf-8"))
105
+ manifest = json.loads((suite / "manifest.json").read_text(encoding="utf-8"))
106
+ schema = json.loads((suite / "schema" / "results.schema.json").read_text(encoding="utf-8"))
107
+
108
+ errors = []
109
+ try:
110
+ jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker()).validate(results)
111
+ except jsonschema.ValidationError as exc:
112
+ errors.append(f"results.json does not match results.schema.json: {exc.message}")
113
+
114
+ carried = derive_bindings(src)
115
+ listed = set(results.get("bindings", []))
116
+ for name in sorted(carried - listed):
117
+ errors.append(f'binding "{name}" is carried by the package but missing from results.bindings')
118
+ for name in sorted(listed - carried):
119
+ errors.append(f'results.bindings lists "{name}", which the package does not carry')
120
+
121
+ sections = results.get("sections", {})
122
+ for name, definition in manifest["sections"].items():
123
+ in_scope = definition["scope"] == "core" or definition["scope"] in listed
124
+ if in_scope and name not in sections:
125
+ errors.append(f'in-scope section "{name}" (scope {definition["scope"]}) is missing')
126
+ if not in_scope and name in sections:
127
+ errors.append(f'section "{name}" (scope {definition["scope"]}) must be absent')
128
+
129
+ gaps = load_gaps(GAPS_FILE, errors, manifest)
130
+ for (section, case_id), _reason in sorted(gaps.items()):
131
+ outcome = sections.get(section, {}).get(case_id)
132
+ if outcome is None:
133
+ errors.append(f"{section}/{case_id}: listed as a known gap but absent from results.json")
134
+ elif outcome.get("status") == "pass":
135
+ errors.append(f"{section}/{case_id}: stale gap (listed in {GAPS_FILE.name} but passes); remove it")
136
+ elif outcome.get("status") == "skip":
137
+ errors.append(f"{section}/{case_id}: a known gap must be reported as fail, not skip")
138
+
139
+ failed = []
140
+ for section, cases in sections.items():
141
+ for case_id, outcome in cases.items():
142
+ if outcome.get("status") == "fail" and (section, case_id) not in gaps:
143
+ failed.append(f"{section}/{case_id}")
144
+ errors.extend(f"{case}: fail (not a known gap)" for case in failed)
145
+
146
+ if errors:
147
+ print("\n".join(errors), file=sys.stderr)
148
+ return 1
149
+ known = sum(
150
+ 1
151
+ for section, cases in sections.items()
152
+ for case_id, outcome in cases.items()
153
+ if outcome.get("status") == "fail" and (section, case_id) in gaps
154
+ )
155
+ print(f"results.json complete: bindings={sorted(listed)}, sections={len(sections)}, fail={known} (all known gaps)")
156
+ return 0
157
+
158
+
159
+ if __name__ == "__main__":
160
+ sys.exit(main(sys.argv[1:]))
@@ -41,7 +41,7 @@ from .transport import (
41
41
  Transport,
42
42
  )
43
43
 
44
- __version__ = "1.19.0"
44
+ __version__ = "1.21.0"
45
45
 
46
46
  __all__ = [
47
47
  "BabelQueue",
@@ -51,6 +51,9 @@ _META = {
51
51
  "schema_version": _INTEGER,
52
52
  "created_at": _INTEGER,
53
53
  }
54
+ #: The core ``meta`` keys of message-envelope.md section 4 (the single copy; the producer ``meta``
55
+ #: write API of the codec rejects a collision with any of them, ADR-0031 (b)).
56
+ CORE_META_KEYS = frozenset(_META)
54
57
  _DEAD_LETTER = {
55
58
  "reason": _STRING,
56
59
  "error": _STRING,
@@ -284,7 +284,7 @@ class BabelQueue:
284
284
  # §3.7/§4.7/§5.7/§6.7/§7.7): the raw body goes to the DLQ byte-identical, so there is
285
285
  # no envelope to read here.
286
286
  gated = received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX)
287
- envelope = {} if gated else EnvelopeCodec.decode(received.body)
287
+ envelope = {} if gated else EnvelopeCodec.decode_lenient(received.body)
288
288
  urn = str(envelope.get("job") or envelope.get("urn") or "")
289
289
  if received.route_urn and envelope:
290
290
  # Transport-supplied routing override; body untouched. Only for a body that
@@ -458,8 +458,9 @@ class BabelQueue:
458
458
  self._stop.wait(self._rewind_pause)
459
459
 
460
460
  def _release_unprocessed(self, received: ReceivedMessage) -> None:
461
- """Release a message whose handler never completed (shutdown): unchanged body, no
462
- backoff, and the runtime adds no ``attempts`` increment. A broker that counts
461
+ """Release a message whose handler never completed (shutdown): unchanged body
462
+ (normalised only if it carries a forbidden key or a ``urn`` alias), no backoff, and the
463
+ runtime adds no ``attempts`` increment. A broker that counts
463
464
  deliveries still counts it: on SQS the release bumps ``ApproximateReceiveCount``, so the
464
465
  redelivered envelope reads one more attempt (and a ``RedrivePolicy`` sees it too)."""
465
466
  self._release(received, received.body, 0.0)
@@ -578,12 +579,15 @@ _UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
578
579
 
579
580
  def _without_forbidden_keys(body: str) -> str:
580
581
  """``body`` unchanged, unless it carries a forbidden envelope key (message-envelope.md §10,
581
- e.g. from a legacy producer) — then it is re-encoded without it (warned by the codec), so a
582
+ e.g. from a legacy producer) or a ``urn`` alias — then it is re-encoded without it (warned by the codec), so a
582
583
  raw-body re-publish never re-emits a key the decode boundary reported as dropped."""
583
584
  parsed = parse_envelope(body)
584
- if not parsed or not has_forbidden_keys(parsed):
585
+ # ``urn`` is not a §10 key but ``encode`` rejects it and ADR-0031 (a)/(c) forbid re-emitting it.
586
+ if not parsed or not (has_forbidden_keys(parsed) or "urn" in parsed):
585
587
  return body
586
- return EnvelopeCodec.encode(parsed)
588
+ # ``decode`` (not the raw parse) so the dropped keys — and a top-level ``urn`` alias, folded
589
+ # into ``job`` — are gone before ``encode``, which rejects them (ADR-0031 (c)).
590
+ return EnvelopeCodec.encode(EnvelopeCodec.decode_lenient(body))
587
591
 
588
592
 
589
593
  def _handler_wants_envelope(fn: Handler) -> bool:
@@ -37,7 +37,7 @@ import re
37
37
  from typing import Any, Dict, Optional
38
38
  from urllib.parse import quote
39
39
 
40
- from .codec import SCHEMA_VERSION, EnvelopeCodec, parse_envelope
40
+ from .codec import ASCII_WHITESPACE, SCHEMA_VERSION, EnvelopeCodec, parse_envelope
41
41
  from .transport import ReceivedMessage, Transport, truncate_declared_version
42
42
 
43
43
  _logger = logging.getLogger("babelqueue.artemis")
@@ -45,7 +45,7 @@ _logger = logging.getLogger("babelqueue.artemis")
45
45
  #: A blank ``bq_schema_version``: empty or only ASCII whitespace (space, \t, \n, \v, \f, \r) — the
46
46
  #: same set in every SDK (GR-5). Deliberately not ``str.strip()``, which also strips U+001C-U+001F,
47
47
  #: U+0085, U+00A0, U+2028, U+3000 ...
48
- _BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
48
+ _BLANK_ASCII_PATTERN = re.compile("[" + re.escape(ASCII_WHITESPACE) + "]*")
49
49
  #: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
50
50
  #: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
51
51
  #: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
@@ -188,7 +188,7 @@ class ArtemisTransport(Transport):
188
188
  return body
189
189
  if dc <= 0:
190
190
  return body
191
- env = EnvelopeCodec.decode(body)
191
+ env = EnvelopeCodec.decode_lenient(body)
192
192
  if not env or dc <= int(env.get("attempts", 0) or 0):
193
193
  return body
194
194
  env["attempts"] = dc
@@ -28,7 +28,7 @@ import re
28
28
  from typing import Any, Dict, Optional
29
29
  from urllib.parse import quote, urlsplit
30
30
 
31
- from .codec import SCHEMA_VERSION, EnvelopeCodec, parse_envelope
31
+ from .codec import ASCII_WHITESPACE, SCHEMA_VERSION, EnvelopeCodec, parse_envelope
32
32
  from .transport import ReceivedMessage, Transport, truncate_declared_version
33
33
 
34
34
  _logger = logging.getLogger("babelqueue.asb")
@@ -36,7 +36,7 @@ _logger = logging.getLogger("babelqueue.asb")
36
36
  #: A blank ``bq-schema-version``: empty or only ASCII whitespace (space, \t, \n, \v, \f, \r) — the
37
37
  #: same set in every SDK (GR-5). Deliberately not ``str.strip()``, which also strips U+001C-U+001F,
38
38
  #: U+0085, U+00A0, U+2028, U+3000 ...
39
- _BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
39
+ _BLANK_ASCII_PATTERN = re.compile("[" + re.escape(ASCII_WHITESPACE) + "]*")
40
40
  #: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
41
41
  #: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
42
42
  #: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
@@ -149,7 +149,7 @@ class AsbTransport(Transport):
149
149
  if dc <= 1:
150
150
  return body
151
151
  native = dc - 1
152
- env = EnvelopeCodec.decode(body)
152
+ env = EnvelopeCodec.decode_lenient(body)
153
153
  if not env or native <= int(env.get("attempts", 0)):
154
154
  return body
155
155
  env["attempts"] = native