babelqueue 1.18.0__tar.gz → 1.20.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 (109) hide show
  1. {babelqueue-1.18.0 → babelqueue-1.20.0}/.github/workflows/ci.yml +1 -1
  2. {babelqueue-1.18.0 → babelqueue-1.20.0}/CHANGELOG.md +81 -0
  3. {babelqueue-1.18.0 → babelqueue-1.20.0}/PKG-INFO +24 -1
  4. {babelqueue-1.18.0 → babelqueue-1.20.0}/README.md +23 -0
  5. {babelqueue-1.18.0 → babelqueue-1.20.0}/pyproject.toml +1 -1
  6. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/__init__.py +1 -1
  7. babelqueue-1.20.0/src/babelqueue/_envelope_shape.py +156 -0
  8. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/app.py +18 -5
  9. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/codec.py +107 -32
  10. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/exceptions.py +15 -0
  11. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/kafka_transport.py +176 -53
  12. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/pulsar_transport.py +10 -5
  13. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/redrive.py +6 -2
  14. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/sqs_transport.py +5 -2
  15. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/transport.py +27 -0
  16. babelqueue-1.20.0/tests/conformance/CONFORMANCE_VERSION +1 -0
  17. babelqueue-1.20.0/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +16 -0
  18. babelqueue-1.20.0/tests/conformance/fixtures/dead-letter-roundtrip.json +23 -0
  19. babelqueue-1.20.0/tests/conformance/fixtures/duplicate-keys-last-wins.json +19 -0
  20. babelqueue-1.20.0/tests/conformance/fixtures/forbidden-key-job-and-urn.json +16 -0
  21. babelqueue-1.20.0/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +16 -0
  22. babelqueue-1.20.0/tests/conformance/fixtures/invalid-wrong-case-job.json +15 -0
  23. babelqueue-1.20.0/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +27 -0
  24. babelqueue-1.20.0/tests/conformance/fixtures/urn-only-roundtrip.json +15 -0
  25. babelqueue-1.20.0/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +20 -0
  26. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/manifest.json +323 -2
  27. babelqueue-1.20.0/tests/poison-trigger-set.json +392 -0
  28. babelqueue-1.20.0/tests/test_behaviour_conformance.py +513 -0
  29. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_codec.py +5 -7
  30. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_kafka_transport.py +428 -43
  31. babelqueue-1.20.0/tests/test_reencode_regression.py +186 -0
  32. babelqueue-1.18.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  33. babelqueue-1.18.0/tests/test_behaviour_conformance.py +0 -195
  34. {babelqueue-1.18.0 → babelqueue-1.20.0}/.github/FUNDING.yml +0 -0
  35. {babelqueue-1.18.0 → babelqueue-1.20.0}/.github/dependabot.yml +0 -0
  36. {babelqueue-1.18.0 → babelqueue-1.20.0}/.github/workflows/release.yml +0 -0
  37. {babelqueue-1.18.0 → babelqueue-1.20.0}/.gitignore +0 -0
  38. {babelqueue-1.18.0 → babelqueue-1.20.0}/LICENSE +0 -0
  39. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/artemis_transport.py +0 -0
  40. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/asb_transport.py +0 -0
  41. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/celery.py +0 -0
  42. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/contracts.py +0 -0
  43. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/dead_letter.py +0 -0
  44. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/django/__init__.py +0 -0
  45. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/django/apps.py +0 -0
  46. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/django/management/__init__.py +0 -0
  47. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  48. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  49. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/gdpr.py +0 -0
  50. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/headers.py +0 -0
  51. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/idempotency.py +0 -0
  52. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/otel.py +0 -0
  53. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/outbox.py +0 -0
  54. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/pika_transport.py +0 -0
  55. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/py.typed +0 -0
  56. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/redis_transport.py +0 -0
  57. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/replay.py +0 -0
  58. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/routing.py +0 -0
  59. {babelqueue-1.18.0 → babelqueue-1.20.0}/src/babelqueue/schema.py +0 -0
  60. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  61. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  62. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  63. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  64. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  65. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  66. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  67. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  68. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  69. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  70. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/order-created.json +0 -0
  71. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  72. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  73. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  74. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  75. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  76. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  77. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
  78. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  79. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/fixtures/dead-lettered.json +0 -0
  80. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/fixtures/order-created.json +0 -0
  81. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_app.py +0 -0
  82. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_artemis_conformance.py +0 -0
  83. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_artemis_transport.py +0 -0
  84. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_asb_conformance.py +0 -0
  85. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_asb_transport.py +0 -0
  86. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_celery.py +0 -0
  87. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_conformance.py +0 -0
  88. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_dead_letter.py +0 -0
  89. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_django.py +0 -0
  90. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_gdpr.py +0 -0
  91. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_headers.py +0 -0
  92. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_idempotency.py +0 -0
  93. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_kafka_conformance.py +0 -0
  94. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_kafka_integration.py +0 -0
  95. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_kafka_integration_logic.py +0 -0
  96. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_otel.py +0 -0
  97. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_outbox.py +0 -0
  98. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_overhead.py +0 -0
  99. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_pika_transport.py +0 -0
  100. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_pulsar_conformance.py +0 -0
  101. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_pulsar_transport.py +0 -0
  102. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_redis_transport.py +0 -0
  103. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_redrive.py +0 -0
  104. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_replay.py +0 -0
  105. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_schema.py +0 -0
  106. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_schema_conformance.py +0 -0
  107. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_shutdown.py +0 -0
  108. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_sqs_conformance.py +0 -0
  109. {babelqueue-1.18.0 → babelqueue-1.20.0}/tests/test_sqs_transport.py +0 -0
@@ -166,7 +166,7 @@ jobs:
166
166
  - uses: actions/checkout@v7
167
167
  - name: Verify vendored conformance matches the canonical suite
168
168
  run: |
169
- git clone --depth 1 --branch v1.2.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
169
+ git clone --depth 1 --branch v1.4.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
170
170
  diff -u "$RUNNER_TEMP/conformance/CONFORMANCE_VERSION" "tests/conformance/CONFORMANCE_VERSION"
171
171
  diff -ru "$RUNNER_TEMP/conformance/manifest.json" "tests/conformance/manifest.json"
172
172
  diff -ru "$RUNNER_TEMP/conformance/fixtures" "tests/conformance/fixtures"
@@ -9,6 +9,87 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.20.0] - 2026-10-17
13
+
14
+ Conformance suite 1.4.0, [ADR-0031](https://babelqueue.com). The envelope is unchanged
15
+ (`schema_version: 1`); this is a MINOR with one deliberate behaviour change (encode rejects).
16
+
17
+ ### Added
18
+ - `EnvelopeCodec.make(urn, data, *, queue, trace_id, meta=None)`: a producer `meta` write API. The entries
19
+ land in `meta` next to the core keys (recommended shape `meta.ext.<vendor>`). A collision with a core
20
+ meta key or a forbidden key raises the new `EnvelopeKeyError` (subclass of `BabelQueueError`, with a
21
+ `pointer` attribute). `_envelope_shape.CORE_META_KEYS` is the single copy of the core key list.
22
+ - `decode` folds the `urn` alias into `job` (ADR-0031 (c)): a `job` that is missing, empty or only ASCII
23
+ whitespace (`" \t\n\v\f\r"`; a U+2003 `job` is NOT blank) falls back to `urn` silently; when both carry
24
+ a value, `job` wins and `urn` is dropped with a warning naming `/urn`. `accepts()` / `urn()` follow the
25
+ same rule. The decoded envelope never keeps a `urn` key.
26
+ - Tests: the runner now implements the 1.4.0 fields (`expect_preserved` on `forbidden_keys`;
27
+ `expect_absent_after_reencode`, `apply_dead_letter`, `expect_dead_letter` on `roundtrip`, the latter via
28
+ `dead_letter.annotate`) plus the `extension_write` and `encode_reject` sections, with a runner
29
+ self-test proving violations are reported.
30
+
31
+ ### Changed
32
+ - **Behaviour change: `EnvelopeCodec.encode` raises `EnvelopeKeyError`** for an envelope carrying a
33
+ forbidden key (message-envelope.md section 10) or a top-level `urn`, instead of dropping it with a
34
+ warning (policy K-15 (a)). Nothing is written. `decode` still warns and drops, so re-encoding a decoded
35
+ envelope is safe. Code that built an envelope from a raw legacy body and relied on encode to clean it
36
+ must decode it first (`EnvelopeCodec.encode(EnvelopeCodec.decode(raw))`).
37
+ - `test_all_five_contract_keys_are_covered` is a subset check (the suite adds `/urn` cases).
38
+ - 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.
39
+
40
+ ### Fixed
41
+ - Raw-body re-encode paths now decode before they encode, so the new `encode` rejection cannot strand a
42
+ legacy body: the runtime's raw-body RELEASE re-publish (`app.py`), and the SQS and Pulsar attempts
43
+ reconciliation (Kafka, Azure Service Bus and Artemis already decoded first). A body with a legacy
44
+ `timestamp` or only a `urn` alias is re-emitted normalised (forbidden key dropped and warned, `urn`
45
+ folded into `job`); unknown top-level and `meta` keys, including `meta.ext`, are preserved.
46
+
47
+ ## [1.19.0] - 2026-10-12
48
+
49
+ ### Changed
50
+ - Tests only: the vendored conformance suite is now 1.3.0 (exact-case envelope keys, last-wins duplicates: new `invalid-wrong-case-job`, `wrong-case-keys-roundtrip` and `duplicate-keys-last-wins` fixtures). No production change; the Python core already behaved this way.
51
+ - **Kafka: a poison record is dead-lettered wrapped (`dead_letter.reason: "poison"`), like the Go
52
+ reference (§6.5, ADR-0009).** A value that is not valid UTF-8, not valid JSON, not a JSON object, or has
53
+ a member of the wrong type (the exact set is listed in the next entry) used to reach `<topic>.dlq` as its raw
54
+ bytes. It is now written as a body that keeps its keys when it is exactly one JSON object, otherwise
55
+ `{"raw": "<body>"}` (bytes that are not UTF-8 are stored with U+FFFD substitutions), plus a
56
+ `dead_letter` block (`reason: "poison"`, `original_queue`, `attempts` from the `bq-attempts` header, `lang`);
57
+ the record key is preserved. A poison record is still never retried and is dropped when no DLQ is
58
+ configured (the `on_unknown_urn` strategy, `DELETE` included, no longer matters: see below). The version-gate rejection (`bq-schema-version`, §6.7) still
59
+ dead-letters its record raw. The wrap is applied when the runtime writes the DLQ record (not when the
60
+ record is read), so a poison record that `redrive` restores into the DLQ it popped it from stays byte
61
+ for byte, whatever that DLQ is named. `original_queue` is the `bq-original-topic` header when present
62
+ (else the consumed topic), like the Java, .NET and Node consumers; a body whose numbers cannot be
63
+ written back as valid JSON (e.g. `1e400`) is carried as `{"raw": ...}` instead of producing an invalid DLQ body.
64
+ A body that decodes but names no URN is not poison (unknown-URN path, as before). Consumers of the DLQ
65
+ that expected the raw bytes of a malformed record must now read the wrapped body.
66
+ - **Kafka: the poison trigger set now equals what the Go reference's typed `Decode` rejects.** Behaviour
67
+ change; the old Python-only rules (a lenient `attempts` read, `Accepts`-style conformance checks) are gone.
68
+ A record is poison when it is not valid UTF-8, not valid JSON (an empty or whitespace-only body, a BOM,
69
+ trailing data, leading zeros, `NaN` / `Infinity`, single quotes), or not a JSON object (a bare `null` is
70
+ not poison: it is an empty envelope and takes the unknown-URN path), or when a member has the wrong type:
71
+ `job`, `trace_id`, `meta.id|queue|lang` and `dead_letter.reason|error|exception|original_queue|lang`
72
+ must be a string or null; `meta.schema_version`, `meta.created_at`, `attempts` and
73
+ `dead_letter.failed_at|attempts` must be an int64 integer literal or null (a string such as `"3"` or
74
+ `"2026"`, a fraction or exponent such as `3.0` / `1e2`, a bool, an array and an object are poison);
75
+ `data`, `meta` and `dead_letter` must be an object or null; every number inside `data` must be a finite
76
+ double. Member names are matched exactly as spelled (case-sensitive, as the contract schema and the core decoder read them; a `JOB` or `Meta` member is an unknown member, not a typed one) and every duplicate key is checked. A record Go decodes
77
+ is no longer poison, even when it is not a conformant envelope: a non-string `urn` alias, an absent
78
+ `data`, `"attempts": null` (read as 0 and written back as 0) and `meta.schema_version: 2` are routed by URN
79
+ like any other record (Go's consume path never calls `Accepts`). The Kafka transport no longer
80
+ delivers records under the shared non-numeric-`attempts` route; they are plain poison now.
81
+ `redrive` restores a poison-routed record into the DLQ unchanged instead of replaying or re-wrapping it,
82
+ even when its `job` still reads as a URN. The only Python-specific limit is JSON nested deeper than
83
+ the interpreter's recursion limit (about 1000 levels, Go allows 10000), which is treated as poison.
84
+ - Known, intentional differences from the Go reference for a poison record: `dead_letter.attempts` is the `bq-attempts` header (Go writes 0) and `original_queue` is the `bq-original-topic` header when present (Go writes the consumed topic); a float literal such as `1.0` / `1e2` is re-formatted when the body is re-encoded. The wire envelope and the `dead_letter` shape are unchanged.
85
+ - **Kafka: any decode failure is poison; the partition is never left to wedge.** Member names are matched case-sensitively, exactly as the core decoder reads them (no `encoding/json`-style folding, no `str.casefold()`); the Go reference's case-insensitive read is a known difference that contradicts the contract schema and is tightened separately. Whatever `EnvelopeCodec.decode` raises after the shape scan (e.g. `RecursionError` at the interpreter's limit) is now routed as poison instead of escaping `pop` (which left the offset uncommitted and the partition redelivered forever); the poison wrapper falls back to `{"raw": ...}` on `RecursionError` or a body that cannot be written as UTF-8. A lone surrogate escape (`"\ud800"`) that survives into a retry/DLQ write is substituted by U+FFFD, as Go does, instead of raising `UnicodeEncodeError`. Invalid UTF-8 remains poison (Go substitutes U+FFFD and accepts it). The wire envelope and the `dead_letter` shape are unchanged.
86
+ - **Kafka: a body the core JSON parser refuses is poison, not an empty envelope.** The core decoder turns a `ValueError` from `json.loads` into an empty envelope; where the shape scan accepts a body that plain `json.loads` rejects (Python 3.11+: an unknown member holding an integer of more than 4300 digits), the record would have reached a handler with its content dropped. It is now dead-lettered as poison. No change to the wire envelope or the `dead_letter` shape.
87
+ - **Kafka: a poison record is dead-lettered whatever `on_unknown_urn` is.** The runtime checked the `DELETE` strategy before it recognised a poison record, so with `on_unknown_urn="delete"` a poison record was acknowledged and silently dropped even with a DLQ configured, where the Go reference writes it to `<topic>.dlq` with `dead_letter.reason: "poison"` and commits. A poison delivery (`PoisonRoute`) is now settled before the strategy is consulted: written wrapped to the DLQ and committed under `fail`, `delete`, `dead_letter` and `release`, and dropped only when no DLQ is configured (unchanged). The version-gate path and every other transport keep their behaviour. No change to the wire envelope or the `dead_letter` shape.
88
+
89
+ ### Fixed
90
+ - Kafka: a wrongly-typed `meta` or `meta.created_at` in a wrapped poison body no longer fails the write
91
+ that settles it (header projection and record timestamp tolerate it).
92
+
12
93
  ## [1.18.0] - 2026-10-11
13
94
 
14
95
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.18.0
3
+ Version: 1.20.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
@@ -106,6 +106,29 @@ The envelope is identical to every other SDK's:
106
106
  }
107
107
  ```
108
108
 
109
+ ### Extension metadata, forbidden keys and the `urn` alias (1.20.0, ADR-0031)
110
+
111
+ ```python
112
+ # Producer meta: extra keys next to the core meta keys (recommended shape: meta.ext.<vendor>).
113
+ envelope = EnvelopeCodec.make(
114
+ "urn:babel:orders:created", {"order_id": 1042},
115
+ queue="orders", meta={"ext": {"acme": {"tier": "gold"}}},
116
+ )
117
+ ```
118
+
119
+ - `meta=` is keyword-only. A key that collides with a core meta key (`id`, `queue`, `lang`,
120
+ `schema_version`, `created_at`) or with a forbidden key (`max_retries`, `attempts`, `source`, `ts`)
121
+ raises `EnvelopeKeyError` (a `BabelQueueError`; its `pointer` names the key, e.g. `/meta/ts`).
122
+ - Unknown top-level and `meta` keys of an inbound message survive a decode -> `attempts + 1` -> encode
123
+ hop (retry, redrive, dead-letter), so a vendor `meta.ext` block is never lost.
124
+ - `EnvelopeCodec.encode` **rejects** an envelope that carries a forbidden key (top-level `timestamp`;
125
+ `meta.max_retries`, `meta.attempts`, `meta.source`, `meta.ts`) or the inbound-only top-level `urn`
126
+ alias: it raises `EnvelopeKeyError` instead of dropping the key with a warning. `decode` is unchanged
127
+ (it still warns and drops), so a decoded envelope can always be re-encoded.
128
+ - `decode` folds `urn` into `job`: a `job` that is missing, empty or only ASCII whitespace (space, `\t`,
129
+ `\n`, `\v`, `\f`, `\r`) falls back to `urn`; when both carry a value, `job` wins and `urn` is dropped
130
+ with a warning naming `/urn`. The decoded envelope never keeps a `urn` key.
131
+
109
132
  ### Typed messages (optional)
110
133
 
111
134
  ```python
@@ -52,6 +52,29 @@ The envelope is identical to every other SDK's:
52
52
  }
53
53
  ```
54
54
 
55
+ ### Extension metadata, forbidden keys and the `urn` alias (1.20.0, ADR-0031)
56
+
57
+ ```python
58
+ # Producer meta: extra keys next to the core meta keys (recommended shape: meta.ext.<vendor>).
59
+ envelope = EnvelopeCodec.make(
60
+ "urn:babel:orders:created", {"order_id": 1042},
61
+ queue="orders", meta={"ext": {"acme": {"tier": "gold"}}},
62
+ )
63
+ ```
64
+
65
+ - `meta=` is keyword-only. A key that collides with a core meta key (`id`, `queue`, `lang`,
66
+ `schema_version`, `created_at`) or with a forbidden key (`max_retries`, `attempts`, `source`, `ts`)
67
+ raises `EnvelopeKeyError` (a `BabelQueueError`; its `pointer` names the key, e.g. `/meta/ts`).
68
+ - Unknown top-level and `meta` keys of an inbound message survive a decode -> `attempts + 1` -> encode
69
+ hop (retry, redrive, dead-letter), so a vendor `meta.ext` block is never lost.
70
+ - `EnvelopeCodec.encode` **rejects** an envelope that carries a forbidden key (top-level `timestamp`;
71
+ `meta.max_retries`, `meta.attempts`, `meta.source`, `meta.ts`) or the inbound-only top-level `urn`
72
+ alias: it raises `EnvelopeKeyError` instead of dropping the key with a warning. `decode` is unchanged
73
+ (it still warns and drops), so a decoded envelope can always be re-encoded.
74
+ - `decode` folds `urn` into `job`: a `job` that is missing, empty or only ASCII whitespace (space, `\t`,
75
+ `\n`, `\v`, `\f`, `\r`) falls back to `urn`; when both carry a value, `job` wins and `urn` is dropped
76
+ with a warning naming `/urn`. The decoded envelope never keeps a `urn` key.
77
+
55
78
  ### Typed messages (optional)
56
79
 
57
80
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.18.0"
7
+ version = "1.20.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"
@@ -41,7 +41,7 @@ from .transport import (
41
41
  Transport,
42
42
  )
43
43
 
44
- __version__ = "1.18.0"
44
+ __version__ = "1.20.0"
45
45
 
46
46
  __all__ = [
47
47
  "BabelQueue",
@@ -0,0 +1,156 @@
1
+ """The poison trigger set of the Kafka transport (§6.5).
2
+
3
+ A body is undecodable when a strict, typed decode into ``Envelope`` / ``Meta`` / ``DeadLetter`` (the
4
+ Go reference's ``babelqueue.Decode``, except that member names are case-sensitive here) rejects it:
5
+
6
+ * the body must be exactly one JSON value (no BOM, trailing tokens, ``NaN`` / ``Infinity``, malformed
7
+ numbers); a bare ``null`` decodes to an empty envelope, any other non-object root is rejected;
8
+ * ``job``, ``trace_id``, ``meta.id|queue|lang`` and ``dead_letter.reason|error|exception|original_queue|lang``
9
+ are string-or-null;
10
+ * ``attempts``, ``meta.schema_version|created_at`` and ``dead_letter.failed_at|attempts`` are
11
+ integer-or-null: an integer literal (no fraction or exponent) that fits ``int64``;
12
+ * ``data``, ``meta`` and ``dead_letter`` are object-or-null, and a number inside ``data`` must be a
13
+ finite double;
14
+ * member names are matched exactly as spelled (case-sensitive, as the contract schema and the core
15
+ decoder read them) and every occurrence of a duplicated key is checked; any other member (a
16
+ differently-cased name or a non-string ``urn`` alias included) is only checked for syntax.
17
+
18
+ A well-typed but non-conformant envelope is not poison: Go's consume path never calls ``Accepts``.
19
+ """
20
+
21
+ import json
22
+ import math
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+ _INT64_MIN = -(2**63)
26
+ _INT64_MAX = 2**63 - 1
27
+ #: Beyond this many digits an integer literal cannot fit an int64 or a double; it is kept as a
28
+ #: sentinel so ``json`` never trips the interpreter's integer-string-length limit.
29
+ _MAX_INT_DIGITS = 4000
30
+
31
+
32
+ class _Pairs(List[Tuple[str, Any]]):
33
+ """A decoded JSON object as its ``(key, value)`` list, so duplicate keys survive."""
34
+
35
+
36
+ class _HugeInt:
37
+ """An integer literal too large for any int64 or double."""
38
+
39
+
40
+ _HUGE = _HugeInt()
41
+
42
+ _STRING = "string"
43
+ _INTEGER = "integer"
44
+ _OBJECT = "object"
45
+ _DATA = "data"
46
+
47
+ _META = {
48
+ "id": _STRING,
49
+ "queue": _STRING,
50
+ "lang": _STRING,
51
+ "schema_version": _INTEGER,
52
+ "created_at": _INTEGER,
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)
57
+ _DEAD_LETTER = {
58
+ "reason": _STRING,
59
+ "error": _STRING,
60
+ "exception": _STRING,
61
+ "original_queue": _STRING,
62
+ "lang": _STRING,
63
+ "failed_at": _INTEGER,
64
+ "attempts": _INTEGER,
65
+ }
66
+ _TOP = {
67
+ "job": _STRING,
68
+ "trace_id": _STRING,
69
+ "attempts": _INTEGER,
70
+ "data": _DATA,
71
+ "meta": _OBJECT,
72
+ "dead_letter": _OBJECT,
73
+ }
74
+
75
+
76
+ def _reject_constant(name: str) -> Any:
77
+ raise ValueError("invalid JSON constant " + name)
78
+
79
+
80
+ def _parse_int(literal: str) -> Any:
81
+ return int(literal) if len(literal) <= _MAX_INT_DIGITS else _HUGE
82
+
83
+
84
+ def _is_finite_number(value: Any) -> bool:
85
+ if value is _HUGE:
86
+ return False
87
+ if isinstance(value, float):
88
+ return math.isfinite(value)
89
+ if isinstance(value, int) and not isinstance(value, bool):
90
+ try:
91
+ return math.isfinite(float(value))
92
+ except OverflowError:
93
+ return False
94
+ return True
95
+
96
+
97
+ def _all_numbers_finite(root: Any) -> bool:
98
+ stack = [root]
99
+ while stack:
100
+ value = stack.pop()
101
+ if isinstance(value, _Pairs):
102
+ stack.extend(item for _, item in value)
103
+ elif isinstance(value, list):
104
+ stack.extend(value)
105
+ elif not _is_finite_number(value):
106
+ return False
107
+ return True
108
+
109
+
110
+ def _check_member(kind: str, name: str, value: Any) -> Optional[str]:
111
+ if value is None:
112
+ return None
113
+ if kind == _STRING:
114
+ return None if isinstance(value, str) else "member %r is not a string" % name
115
+ if kind == _INTEGER:
116
+ if type(value) is int and _INT64_MIN <= value <= _INT64_MAX:
117
+ return None
118
+ return "member %r is not an int64 integer" % name
119
+ if not isinstance(value, _Pairs):
120
+ return "member %r is not an object" % name
121
+ if kind == _DATA and not _all_numbers_finite(value):
122
+ return "member %r holds a number that is not a finite double" % name
123
+ return None
124
+
125
+
126
+ def _check_object(pairs: _Pairs, kinds: Dict[str, str], prefix: str) -> Optional[str]:
127
+ for key, value in pairs:
128
+ kind = kinds.get(key)
129
+ if kind is None:
130
+ continue
131
+ failure = _check_member(kind, prefix + key, value)
132
+ if failure is not None:
133
+ return failure
134
+ if kind == _OBJECT and value is not None:
135
+ nested = _check_object(value, _META if key == "meta" else _DEAD_LETTER, key + ".")
136
+ if nested is not None:
137
+ return nested
138
+ return None
139
+
140
+
141
+ def envelope_violation(text: str) -> Optional[str]:
142
+ """Why ``text`` is not a decodable BabelQueue body, or ``None`` when Go's typed decode accepts it."""
143
+ try:
144
+ parsed = json.loads(
145
+ text,
146
+ object_pairs_hook=_Pairs,
147
+ parse_int=_parse_int,
148
+ parse_constant=_reject_constant,
149
+ )
150
+ except (ValueError, RecursionError) as exc:
151
+ return "invalid JSON: " + str(exc)
152
+ if parsed is None:
153
+ return None # a bare ``null`` is an empty envelope, not poison
154
+ if not isinstance(parsed, _Pairs):
155
+ return "the JSON root is not an object"
156
+ return _check_object(parsed, _TOP, "")
@@ -41,6 +41,7 @@ from .replay import HEADER_REPLAY_BYPASS, _replay_scope
41
41
  from .routing import UnknownUrnStrategy
42
42
  from .transport import (
43
43
  HeaderPublisher,
44
+ PoisonRoute,
44
45
  ReceivedMessage,
45
46
  Redeliverer,
46
47
  Rewinder,
@@ -457,8 +458,9 @@ class BabelQueue:
457
458
  self._stop.wait(self._rewind_pause)
458
459
 
459
460
  def _release_unprocessed(self, received: ReceivedMessage) -> None:
460
- """Release a message whose handler never completed (shutdown): unchanged body, no
461
- 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
462
464
  deliveries still counts it: on SQS the release bumps ``ApproximateReceiveCount``, so the
463
465
  redelivered envelope reads one more attempt (and a ``RedrivePolicy`` sees it too)."""
464
466
  self._release(received, received.body, 0.0)
@@ -476,6 +478,14 @@ class BabelQueue:
476
478
 
477
479
  def _route_unknown(self, urn: str, received: ReceivedMessage, envelope: Mapping[str, Any]) -> None:
478
480
  strategy = self.on_unknown_urn
481
+ # A Kafka poison record (§6.5: not valid UTF-8 / not a decodable body) is not an unknown URN: it
482
+ # never decoded into an envelope, so ``on_unknown_urn`` has no say. It is checked before the
483
+ # DELETE strategy and settled like the Go reference does -- dead-lettered (wrapped, reason
484
+ # ``poison``) and committed, or dropped only when no DLQ is configured. Other transports'
485
+ # version-gated deliveries keep their existing order below.
486
+ if isinstance(received.route_urn, PoisonRoute):
487
+ self._settle_version_gated(received)
488
+ return
479
489
  if strategy == UnknownUrnStrategy.DELETE:
480
490
  self._ack_settled(received)
481
491
  return
@@ -569,12 +579,15 @@ _UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
569
579
 
570
580
  def _without_forbidden_keys(body: str) -> str:
571
581
  """``body`` unchanged, unless it carries a forbidden envelope key (message-envelope.md §10,
572
- 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
573
583
  raw-body re-publish never re-emits a key the decode boundary reported as dropped."""
574
584
  parsed = parse_envelope(body)
575
- 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):
576
587
  return body
577
- 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(body))
578
591
 
579
592
 
580
593
  def _handler_wants_envelope(fn: Handler) -> bool:
@@ -16,15 +16,17 @@ import time
16
16
  import uuid
17
17
  from typing import Any, Dict, List, Mapping, Optional, Tuple
18
18
 
19
- from .exceptions import BabelQueueError
19
+ from ._envelope_shape import CORE_META_KEYS
20
+ from .exceptions import BabelQueueError, EnvelopeKeyError
20
21
 
21
22
  SCHEMA_VERSION = 1
22
23
  SOURCE_LANG = "python"
23
24
 
24
25
  #: Non-canonical keys (message-envelope.md §10), as ``(container, key)`` pairs where the
25
26
  #: container is ``None`` for the top level or ``"meta"``. Policy K-15: decode WARNS and drops
26
- #: them (old producers keep working); encode NEVER emits them. Encode currently drops them with
27
- #: a warning; K-15 (a) / R1-E0 will turn that into a rejection (an error) in a future MINOR.
27
+ #: them (old producers keep working); encode NEVER emits them and REJECTS an envelope that
28
+ #: carries one (ADR-0031 (c)). The top-level ``urn`` alias is not in this table: it is an inbound
29
+ #: alias that decode folds into ``job`` (see :meth:`EnvelopeCodec.decode`), but encode rejects it.
28
30
  FORBIDDEN_KEYS: Tuple[Tuple[Optional[str], str], ...] = (
29
31
  (None, "timestamp"),
30
32
  ("meta", "max_retries"),
@@ -33,6 +35,10 @@ FORBIDDEN_KEYS: Tuple[Tuple[Optional[str], str], ...] = (
33
35
  ("meta", "ts"),
34
36
  )
35
37
 
38
+ #: The closed ASCII whitespace set that makes a ``job`` "blank" (cross-SDK parity, GR-5): never a
39
+ #: bare ``str.strip()``, which also strips Unicode spaces such as U+2003.
40
+ _ASCII_WHITESPACE = " \t\n\v\f\r"
41
+
36
42
  #: Logger every forbidden-key warning is reported through (one record per dropped key).
37
43
  logger = logging.getLogger("babelqueue.codec")
38
44
 
@@ -52,11 +58,7 @@ def strip_forbidden_keys(envelope: Dict[str, Any]) -> List[str]:
52
58
  del target[key]
53
59
  pointer = "/" + key if container is None else f"/{container}/{key}"
54
60
  dropped.append(pointer)
55
- logger.warning(
56
- "Dropped forbidden envelope key %s from the envelope "
57
- "(non-canonical, message-envelope.md §10).",
58
- pointer,
59
- )
61
+ _warn_dropped(pointer)
60
62
  return dropped
61
63
 
62
64
 
@@ -76,10 +78,42 @@ def parse_envelope(raw: Any) -> Dict[str, Any]:
76
78
 
77
79
 
78
80
  def has_forbidden_keys(envelope: Mapping[str, Any]) -> bool:
79
- """Whether ``envelope`` carries any of the non-canonical keys of message-envelope.md §10."""
81
+ """Whether ``envelope`` carries any of the non-canonical keys of message-envelope.md §10.
82
+
83
+ The top-level ``urn`` alias is NOT included (it is not a §10 key: decode folds it into ``job``),
84
+ although ``encode`` also rejects it — a caller deciding whether a raw body needs normalising
85
+ before re-emit must check ``"urn" in envelope`` as well.
86
+ """
80
87
  return _has_forbidden_keys(envelope)
81
88
 
82
89
 
90
+ def _is_blank(value: Any) -> bool:
91
+ """Missing, ``None``, empty or only ASCII whitespace; a falsy non-string (``0``, ``false``,
92
+ ``[]``, ``{}``) is blank too, as in ``job or urn``. A truthy non-string is not blank."""
93
+ if isinstance(value, str):
94
+ return value.strip(_ASCII_WHITESPACE) == ""
95
+ return not value
96
+
97
+
98
+ def _warn_dropped(pointer: str) -> None:
99
+ logger.warning(
100
+ "Dropped forbidden envelope key %s from the envelope "
101
+ "(non-canonical, message-envelope.md §10).",
102
+ pointer,
103
+ )
104
+
105
+
106
+ def _first_encode_violation(envelope: Mapping[str, Any]) -> Optional[str]:
107
+ """RFC 6901 pointer of the first key encode refuses to write, or ``None``."""
108
+ for container, key in FORBIDDEN_KEYS:
109
+ target = envelope if container is None else envelope.get(container)
110
+ if isinstance(target, Mapping) and key in target:
111
+ return "/" + key if container is None else f"/{container}/{key}"
112
+ if "urn" in envelope:
113
+ return "/urn"
114
+ return None
115
+
116
+
83
117
  def _has_forbidden_keys(envelope: Mapping[str, Any]) -> bool:
84
118
  for container, key in FORBIDDEN_KEYS:
85
119
  target = envelope if container is None else envelope.get(container)
@@ -101,12 +135,18 @@ class EnvelopeCodec:
101
135
  *,
102
136
  queue: str = "default",
103
137
  trace_id: Optional[str] = None,
138
+ meta: Optional[Mapping[str, Any]] = None,
104
139
  ) -> Dict[str, Any]:
105
140
  """Build the canonical envelope for a ``(urn, data)`` pair.
106
141
 
107
142
  ``trace_id`` is reused when given (trace continuation), otherwise a fresh
108
143
  UUID is minted. ``attempts`` starts at 0 and is the top-level transport
109
144
  counter (kept out of the immutable ``meta`` block).
145
+
146
+ ``meta`` (ADR-0031 (b)) adds producer-supplied entries (e.g. ``{"ext": {"acme": 1}}``) to
147
+ the ``meta`` block next to the core keys. An entry whose key is a core ``meta`` key
148
+ (``id``, ``queue``, ``lang``, ``schema_version``, ``created_at``) or a forbidden ``meta``
149
+ key (message-envelope.md §10) raises :class:`~babelqueue.exceptions.EnvelopeKeyError`.
110
150
  """
111
151
  urn = (urn or "").strip()
112
152
  if not urn:
@@ -117,19 +157,36 @@ class EnvelopeCodec:
117
157
 
118
158
  trace_id = (trace_id or "").strip() or str(uuid.uuid4())
119
159
 
120
- return {
160
+ extra: Dict[str, Any] = {}
161
+ if meta is not None:
162
+ if not isinstance(meta, Mapping):
163
+ raise BabelQueueError("The producer meta must be a mapping of string keys to values.")
164
+ forbidden_meta = {key for container, key in FORBIDDEN_KEYS if container == "meta"}
165
+ for key, value in meta.items():
166
+ if key in CORE_META_KEYS or key in forbidden_meta:
167
+ raise EnvelopeKeyError(
168
+ f"/meta/{key}",
169
+ f"The producer meta key /meta/{key} is reserved or forbidden "
170
+ "(core meta keys and message-envelope.md §10 keys cannot be set).",
171
+ )
172
+ extra[key] = value
173
+
174
+ meta_block: Dict[str, Any] = {
175
+ "id": str(uuid.uuid4()),
176
+ "queue": queue,
177
+ "lang": SOURCE_LANG,
178
+ "schema_version": SCHEMA_VERSION,
179
+ "created_at": int(time.time() * 1000),
180
+ }
181
+ meta_block.update(extra)
182
+ envelope: Dict[str, Any] = {
121
183
  "job": urn,
122
184
  "trace_id": trace_id,
123
185
  "data": dict(data),
124
- "meta": {
125
- "id": str(uuid.uuid4()),
126
- "queue": queue,
127
- "lang": SOURCE_LANG,
128
- "schema_version": SCHEMA_VERSION,
129
- "created_at": int(time.time() * 1000),
130
- },
186
+ "meta": meta_block,
131
187
  "attempts": 0,
132
188
  }
189
+ return envelope
133
190
 
134
191
  @staticmethod
135
192
  def from_message(message: Any, queue: str = "default") -> Dict[str, Any]:
@@ -152,20 +209,20 @@ class EnvelopeCodec:
152
209
  def encode(envelope: Mapping[str, Any]) -> str:
153
210
  """Encode the envelope as compact UTF-8 JSON (unescaped unicode/slashes).
154
211
 
155
- Forbidden keys (message-envelope.md §10) are never emitted: if the mapping carries any,
156
- they are dropped (with a warning) from a copy — the caller's mapping is not mutated.
157
-
158
- Transitional: K-15 (a) asks encode to *reject* forbidden keys. Dropping is the interim
159
- behaviour until R1-E0, when this call will raise instead — do not rely on encode to
160
- clean an envelope; strip it yourself (:func:`strip_forbidden_keys`) if you build one
161
- from a legacy source.
212
+ Forbidden keys (message-envelope.md §10) are never emitted: an envelope that carries one
213
+ (top-level ``timestamp``; ``meta.max_retries`` / ``meta.attempts`` / ``meta.source`` /
214
+ ``meta.ts``) or the inbound-only top-level ``urn`` alias raises
215
+ :class:`~babelqueue.exceptions.EnvelopeKeyError` (ADR-0031 (c)) and nothing is written.
216
+ Envelopes produced by :meth:`decode` are always safe to re-encode: decode already drops
217
+ those keys. Unknown, non-forbidden keys are kept as-is.
162
218
  """
163
- if _has_forbidden_keys(envelope):
164
- copy = dict(envelope)
165
- if isinstance(copy.get("meta"), Mapping):
166
- copy["meta"] = dict(copy["meta"])
167
- strip_forbidden_keys(copy)
168
- envelope = copy
219
+ pointer = _first_encode_violation(envelope)
220
+ if pointer is not None:
221
+ raise EnvelopeKeyError(
222
+ pointer,
223
+ f"Refusing to encode the forbidden envelope key {pointer} "
224
+ "(non-canonical, message-envelope.md §10).",
225
+ )
169
226
  return json.dumps(envelope, ensure_ascii=False, separators=(",", ":"))
170
227
 
171
228
  @staticmethod
@@ -175,15 +232,33 @@ class EnvelopeCodec:
175
232
  Unknown keys are kept as-is (forward compatibility). Forbidden keys
176
233
  (message-envelope.md §10) are dropped and reported as warnings on :data:`logger`
177
234
  (policy K-15: warn, never reject), so re-encoding never re-emits them.
235
+
236
+ The ``urn`` alias is folded into ``job`` (ADR-0031 (c)): when ``job`` is missing, empty or
237
+ only ASCII whitespace, the ``urn`` value becomes ``job`` (silently); when both carry a
238
+ value, ``job`` wins and ``urn`` is dropped with a warning naming ``/urn``. The decoded
239
+ envelope never keeps a ``urn`` key.
178
240
  """
179
241
  decoded = parse_envelope(raw)
180
242
  strip_forbidden_keys(decoded)
243
+ if "urn" in decoded:
244
+ if _is_blank(decoded.get("job")):
245
+ decoded["job"] = decoded.pop("urn")
246
+ else:
247
+ del decoded["urn"]
248
+ _warn_dropped("/urn")
181
249
  return decoded
182
250
 
183
251
  @staticmethod
184
252
  def urn(envelope: Mapping[str, Any]) -> str:
185
- """The message URN: canonical ``job``, with ``urn`` accepted as an alias."""
186
- return str(envelope.get("job") or envelope.get("urn") or "")
253
+ """The message URN: canonical ``job``, with ``urn`` accepted as an alias.
254
+
255
+ A blank ``job`` (missing, empty or only ASCII whitespace) falls back to ``urn``.
256
+ """
257
+ job = envelope.get("job")
258
+ if isinstance(job, str) and not _is_blank(job):
259
+ return job
260
+ alias = envelope.get("urn")
261
+ return alias if isinstance(alias, str) and not _is_blank(alias) else ""
187
262
 
188
263
  @staticmethod
189
264
  def accepts(envelope: Mapping[str, Any]) -> bool:
@@ -7,6 +7,21 @@ class BabelQueueError(Exception):
7
7
  """Base for every recoverable BabelQueue error (bad config, empty URN, ...)."""
8
8
 
9
9
 
10
+ class EnvelopeKeyError(BabelQueueError):
11
+ """An envelope key the codec refuses to write (ADR-0031 (b), (c)).
12
+
13
+ Raised by :meth:`~babelqueue.codec.EnvelopeCodec.make` when the ``meta`` entries collide with a
14
+ core ``meta`` key (``id``, ``queue``, ``lang``, ``schema_version``, ``created_at``) or carry a
15
+ forbidden key, and by :meth:`~babelqueue.codec.EnvelopeCodec.encode` when the envelope holds a
16
+ forbidden key of message-envelope.md section 10 (including the top-level ``urn`` alias).
17
+ ``pointer`` is the RFC 6901 pointer of the first offending key (e.g. ``"/meta/ts"``).
18
+ """
19
+
20
+ def __init__(self, pointer: str, message: str) -> None:
21
+ super().__init__(message)
22
+ self.pointer = pointer
23
+
24
+
10
25
  class UnknownUrnError(BabelQueueError):
11
26
  """A consumed message carries a URN with no mapped handler (strategy "fail")."""
12
27