babelqueue 1.12.0__tar.gz → 1.14.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 (92) hide show
  1. babelqueue-1.14.0/.github/dependabot.yml +11 -0
  2. {babelqueue-1.12.0 → babelqueue-1.14.0}/.github/workflows/ci.yml +2 -1
  3. {babelqueue-1.12.0 → babelqueue-1.14.0}/CHANGELOG.md +80 -0
  4. {babelqueue-1.12.0 → babelqueue-1.14.0}/PKG-INFO +85 -3
  5. {babelqueue-1.12.0 → babelqueue-1.14.0}/README.md +80 -0
  6. {babelqueue-1.12.0 → babelqueue-1.14.0}/pyproject.toml +5 -2
  7. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/__init__.py +11 -4
  8. babelqueue-1.14.0/src/babelqueue/app.py +502 -0
  9. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/artemis_transport.py +5 -5
  10. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/asb_transport.py +2 -2
  11. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/codec.py +93 -8
  12. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/exceptions.py +12 -0
  13. babelqueue-1.14.0/src/babelqueue/gdpr.py +245 -0
  14. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/kafka_transport.py +3 -3
  15. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/pulsar_transport.py +2 -2
  16. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/schema.py +59 -1
  17. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/sqs_transport.py +87 -11
  18. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/transport.py +19 -0
  19. babelqueue-1.14.0/tests/conformance/CONFORMANCE_VERSION +1 -0
  20. babelqueue-1.14.0/tests/conformance/fixtures/data-array-rejected.json +13 -0
  21. babelqueue-1.14.0/tests/conformance/fixtures/empty-data-roundtrip.json +13 -0
  22. babelqueue-1.14.0/tests/conformance/fixtures/forbidden-key-meta-attempts.json +16 -0
  23. babelqueue-1.14.0/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +16 -0
  24. babelqueue-1.14.0/tests/conformance/fixtures/forbidden-key-meta-source.json +16 -0
  25. babelqueue-1.14.0/tests/conformance/fixtures/forbidden-key-meta-ts.json +16 -0
  26. babelqueue-1.14.0/tests/conformance/fixtures/forbidden-key-timestamp.json +16 -0
  27. babelqueue-1.14.0/tests/conformance/fixtures/unknown-lang.json +15 -0
  28. babelqueue-1.14.0/tests/conformance/fixtures/unknown-meta-roundtrip.json +20 -0
  29. babelqueue-1.14.0/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +16 -0
  30. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/manifest.json +256 -0
  31. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_artemis_transport.py +27 -0
  32. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_asb_transport.py +22 -0
  33. babelqueue-1.14.0/tests/test_behaviour_conformance.py +195 -0
  34. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_codec.py +27 -0
  35. babelqueue-1.14.0/tests/test_gdpr.py +328 -0
  36. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_kafka_transport.py +23 -0
  37. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_pulsar_transport.py +25 -0
  38. babelqueue-1.14.0/tests/test_shutdown.py +302 -0
  39. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_sqs_transport.py +245 -3
  40. babelqueue-1.12.0/src/babelqueue/app.py +0 -252
  41. {babelqueue-1.12.0 → babelqueue-1.14.0}/.github/FUNDING.yml +0 -0
  42. {babelqueue-1.12.0 → babelqueue-1.14.0}/.github/workflows/release.yml +0 -0
  43. {babelqueue-1.12.0 → babelqueue-1.14.0}/.gitignore +0 -0
  44. {babelqueue-1.12.0 → babelqueue-1.14.0}/LICENSE +0 -0
  45. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/celery.py +0 -0
  46. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/contracts.py +0 -0
  47. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/dead_letter.py +0 -0
  48. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/django/__init__.py +0 -0
  49. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/django/apps.py +0 -0
  50. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/django/management/__init__.py +0 -0
  51. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  52. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  53. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/headers.py +0 -0
  54. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/idempotency.py +0 -0
  55. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/otel.py +0 -0
  56. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/outbox.py +0 -0
  57. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/pika_transport.py +0 -0
  58. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/py.typed +0 -0
  59. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/redis_transport.py +0 -0
  60. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/redrive.py +0 -0
  61. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/replay.py +0 -0
  62. {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/routing.py +0 -0
  63. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  64. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  65. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  66. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/order-created.json +0 -0
  67. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  68. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  69. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  70. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/fixtures/dead-lettered.json +0 -0
  71. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/fixtures/order-created.json +0 -0
  72. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_app.py +0 -0
  73. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_artemis_conformance.py +0 -0
  74. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_asb_conformance.py +0 -0
  75. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_celery.py +0 -0
  76. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_conformance.py +0 -0
  77. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_dead_letter.py +0 -0
  78. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_django.py +0 -0
  79. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_headers.py +0 -0
  80. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_idempotency.py +0 -0
  81. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_kafka_conformance.py +0 -0
  82. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_otel.py +0 -0
  83. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_outbox.py +0 -0
  84. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_overhead.py +0 -0
  85. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_pika_transport.py +0 -0
  86. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_pulsar_conformance.py +0 -0
  87. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_redis_transport.py +0 -0
  88. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_redrive.py +0 -0
  89. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_replay.py +0 -0
  90. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_schema.py +0 -0
  91. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_schema_conformance.py +0 -0
  92. {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_sqs_conformance.py +0 -0
@@ -0,0 +1,11 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+
8
+ - package-ecosystem: "github-actions"
9
+ directory: "/"
10
+ schedule:
11
+ interval: "weekly"
@@ -118,7 +118,8 @@ jobs:
118
118
  - uses: actions/checkout@v5
119
119
  - name: Verify vendored conformance matches the canonical suite
120
120
  run: |
121
- git clone --depth 1 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
121
+ git clone --depth 1 --branch v1.0.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
122
+ diff -u "$RUNNER_TEMP/conformance/CONFORMANCE_VERSION" "tests/conformance/CONFORMANCE_VERSION"
122
123
  diff -ru "$RUNNER_TEMP/conformance/manifest.json" "tests/conformance/manifest.json"
123
124
  diff -ru "$RUNNER_TEMP/conformance/fixtures" "tests/conformance/fixtures"
124
125
  diff -ru "$RUNNER_TEMP/conformance/schema" "tests/conformance/schema"
@@ -9,6 +9,86 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.14.0] - 2026-10-03
13
+
14
+ ### Added
15
+ - **Graceful shutdown** of `BabelQueue.consume()`: on SIGTERM/SIGINT the loop sets a stop flag,
16
+ takes no new message, lets the in-flight handler finish (or releases the message unchanged once
17
+ the new `shutdown_timeout` — default 30 s — expires), restores the previous signal handlers and
18
+ closes the transport. Handlers are installed only on the main thread (`handle_signals=False`
19
+ opts out); a second signal forces an immediate exit with the message released. A plain
20
+ `KeyboardInterrupt` keeps its previous behaviour. New `BabelQueue.stop()` ends the loop from any
21
+ thread after the in-flight message.
22
+ - `Redeliverer` protocol (`redeliver(message, body, delay)`) — a transport's native
23
+ release; the runtime uses it for retries, unknown-URN `RELEASE` and shutdown releases, and
24
+ falls back to publish + ack otherwise. New `retry_backoff` / `unknown_urn_release_delay`
25
+ settings feed its delay.
26
+ - Conformance runners for the `roundtrip`, `data_shape`, `forbidden_keys` and
27
+ `payload_schema_unicode` sections (none skipped).
28
+ - `.github/dependabot.yml` (pip + GitHub Actions, weekly).
29
+
30
+ ### Changed
31
+ - **SQS release** (broker-bindings §3.5): retries, unknown-URN `RELEASE` and shutdown releases
32
+ **always** call `ChangeMessageVisibility` instead of re-sending, so the broker's
33
+ `ApproximateReceiveCount` carries the attempt count. There is no send + delete fallback. The
34
+ default delay for `retry_backoff` and `unknown_urn_release_delay` is **0 s** (immediate
35
+ redelivery); a delay outside 0..43200 s (including `inf`/`nan`) is clamped with a warning on the
36
+ `babelqueue.sqs` logger. A message without a receipt handle is not released (warning); one
37
+ without a receive count is released but its attempts cannot advance (warning).
38
+ **Poison-loop risk:** a message that always fails is redelivered immediately until
39
+ `max_attempts` — configure a native SQS `RedrivePolicy` (`maxReceiveCount` ≥ `max_attempts`) as
40
+ the backstop and a non-zero `retry_backoff` where needed. A shutdown release also counts as a
41
+ receive on SQS, so keep `shutdown_timeout` above the longest handler time.
42
+ - Delivery semantics are documented as **at-least-once**: a handler that finishes right as the
43
+ shutdown deadline fires may be released and run again.
44
+ - The SQS, Kafka, Pulsar, Azure Service Bus and Artemis transports' header/property projections
45
+ read the envelope without logging a false forbidden-key drop (the body is sent unchanged).
46
+
47
+ ### Fixed
48
+ - The five forbidden envelope keys (`timestamp`, `meta.max_retries`, `meta.attempts`,
49
+ `meta.source`, `meta.ts` — message-envelope §10) are dropped on decode with a warning on the
50
+ `babelqueue.codec` logger, and never written on encode. **Transitional:** encode currently drops
51
+ them with a warning; a future MINOR (K-15 / R1-E0) will reject them instead. A raw-body release
52
+ republish now strips them too (a clean body is re-sent byte for byte); DLQ redrive still restores
53
+ the original bytes unchanged.
54
+ - `BabelQueue.stop()` called before `consume()` starts is now honoured instead of being cleared.
55
+ - A failed acknowledgement of an already-processed message (e.g. SQS `DeleteMessage` after a
56
+ successful handler, an unknown-URN `DELETE`, or a dead-letter publish) is no longer treated as a
57
+ handler failure: the message is **not** released or retried; the failure is logged at `ERROR`
58
+ on the new `babelqueue.app` logger and the broker redelivers it on its own (SQS: after the
59
+ visibility timeout). The consume loop keeps running.
60
+
61
+ ## [1.13.0] - 2026-06-21
62
+
63
+ ### Added
64
+ - **Runtime GDPR field encryption** (ADR-0030) — the optional `babelqueue.gdpr` module ports the Go
65
+ reference to Python: the SDK-enforcement half of governed PII. The babelqueue-registry only
66
+ **declares** and **audits** `x-gdpr-sensitive` fields (and can **mask** them for safe logging);
67
+ this module **enforces** them on the wire — a producer encrypts each marked leaf before publish, a
68
+ consumer decrypts it after decode. Strictly **opt-in**: a producer/consumer that never calls
69
+ `protect`/`unprotect` is unchanged. The **envelope stays frozen** (GR-1): only **values inside
70
+ `data`** change — a sensitive leaf's value becomes a ciphertext **string** — so `data` stays pure
71
+ JSON (an SDK without the key still carries the envelope), `meta.schema_version` stays `1`, and
72
+ `trace_id` is untouched (GR-4). The core stays **stdlib-only** (GR-7): Python's standard library
73
+ has **no AES-GCM**, so the module ships **no** concrete cipher and pulls **no** crypto dependency
74
+ — `Cipher` is a caller-provided `typing.Protocol` (`encrypt(bytes) -> str` / `decrypt(str) ->
75
+ bytes`) bound to a KMS / Vault / HSM / tokenisation service, or to a local AES-256-GCM via the
76
+ optional `cryptography` library (new `babelqueue[gdpr]` extra, documented in the README — still
77
+ **not** a core dependency). `protect(data, schema, cipher)` canonically JSON-encodes each marked
78
+ leaf and replaces it with the cipher's ciphertext string **in place**; `unprotect(...)` is the
79
+ exact inverse, restoring `data` **byte-for-byte**. The marked paths come from the **same per-URN
80
+ schema** the validator already loads — `babelqueue.schema.sensitive_paths` walks nested objects
81
+ (`profile.full_name`), array items (`addresses[].line`), container, and root marks, and the
82
+ `x-gdpr-sensitive` keyword is **validation-neutral** (annotating a schema is never breaking). An
83
+ absent marked field is skipped (not an error); a re-run `unprotect` on already-cleartext data is a
84
+ no-op (non-string leaves are left alone); a wrong key / tampered / non-ciphertext value raises the
85
+ new `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
86
+ handle unreadable PII. Validate cleartext **before** `protect` / **after** `unprotect`, since a
87
+ schema constraining a sensitive field would reject the ciphertext string. Unit-tested without any
88
+ crypto dependency (a stdlib-only authenticated fake cipher) for round-trip exactness across
89
+ nested/array/container/root marks, skips, idempotent re-runs, frozen-envelope decode/validate, and
90
+ wrong-key failure. The envelope is unchanged (`schema_version: 1`); this is purely additive.
91
+
12
92
  ## [1.12.0] - 2026-06-21
13
93
 
14
94
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.12.0
3
+ Version: 1.14.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
@@ -35,9 +35,11 @@ Provides-Extra: dev
35
35
  Requires-Dist: mypy>=1.8; extra == 'dev'
36
36
  Requires-Dist: pytest-cov>=4; extra == 'dev'
37
37
  Requires-Dist: pytest>=7; extra == 'dev'
38
- Requires-Dist: ruff>=0.5; extra == 'dev'
38
+ Requires-Dist: ruff<0.16,>=0.5; extra == 'dev'
39
39
  Provides-Extra: django
40
40
  Requires-Dist: django>=4.2; extra == 'django'
41
+ Provides-Extra: gdpr
42
+ Requires-Dist: cryptography>=42; extra == 'gdpr'
41
43
  Provides-Extra: kafka
42
44
  Requires-Dist: confluent-kafka>=2.3; extra == 'kafka'
43
45
  Provides-Extra: otel
@@ -172,6 +174,27 @@ app.run() # consume forever (Ctrl-C to stop)
172
174
  and `memory://` (in-process, great for tests/local). Bring your own by passing
173
175
  `transport=...`.
174
176
 
177
+ - **Graceful shutdown:** SIGTERM/SIGINT finish the in-flight handler, or release
178
+ the message unchanged once `shutdown_timeout` expires. Delivery is
179
+ **at-least-once** — keep handlers idempotent (see the idempotency helper).
180
+
181
+ ### SQS release and poison messages
182
+
183
+ On a handler failure, unknown-URN `release` or shutdown release, the SQS
184
+ transport **always** releases with `ChangeMessageVisibility` (broker-bindings
185
+ §3.5) — it never re-sends a copy. The broker's `ApproximateReceiveCount` is the
186
+ attempt counter, so a shutdown release also consumes an attempt. The default
187
+ delay is **0 s** (`retry_backoff` / `unknown_urn_release_delay`), i.e. the
188
+ message is visible again immediately; delays outside 0..43200 s are clamped with
189
+ a warning.
190
+
191
+ **Poison-loop risk:** a message that always fails is redelivered at once until
192
+ `max_attempts` is reached. If the receive count is unavailable the SDK cannot
193
+ advance attempts at all. **Configure a native `RedrivePolicy`**
194
+ (`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>.dlq`) on every SQS
195
+ queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
196
+ handler's dependencies need time to recover.
197
+
175
198
  ### Sharing a Redis queue with Laravel
176
199
 
177
200
  By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
@@ -311,6 +334,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
311
334
  transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
312
335
  correlation with no error.
313
336
 
337
+ ## GDPR field encryption (optional)
338
+
339
+ The optional `babelqueue.gdpr` module is the runtime, SDK-enforcement half of ADR-0030: it
340
+ encrypts the `data` fields a registry declared `x-gdpr-sensitive`. The babelqueue-registry only
341
+ **declares** and **audits** sensitivity (and can **mask** for safe logging); this module
342
+ **enforces** it on the wire — a producer encrypts each marked leaf before publish, a consumer
343
+ decrypts it after decode. It is strictly **opt-in**: if you never call `protect`/`unprotect`,
344
+ nothing changes.
345
+
346
+ The envelope stays **frozen** (`schema_version: 1`): only the **values inside `data`** change — a
347
+ sensitive leaf's value becomes a ciphertext **string**. `data` stays pure JSON, so an SDK without
348
+ the key still carries the envelope (it just can't read the protected fields), and `trace_id` is
349
+ never touched. Which fields are sensitive lives in the **schema**, not the message.
350
+
351
+ Because Python's standard library has **no AES-GCM**, the core ships **no** cipher and pulls **no**
352
+ crypto dependency (GR-7). `Cipher` is a caller-provided `Protocol` you bind to a KMS / Vault / HSM /
353
+ tokenisation service, or to a local AES-256-GCM via the optional `cryptography` library
354
+ (`pip install "babelqueue[gdpr]"` — still **not** a core dependency):
355
+
356
+ ```python
357
+ import base64, os
358
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
359
+
360
+ class AesGcmCipher:
361
+ def __init__(self, key: bytes) -> None: # 16/24/32-byte key — the CALLER's
362
+ self._aead = AESGCM(key)
363
+
364
+ def encrypt(self, plaintext: bytes) -> str:
365
+ nonce = os.urandom(12)
366
+ return base64.b64encode(nonce + self._aead.encrypt(nonce, plaintext, None)).decode("ascii")
367
+
368
+ def decrypt(self, ciphertext: str) -> bytes:
369
+ raw = base64.b64decode(ciphertext)
370
+ return self._aead.decrypt(raw[:12], raw[12:], None) # raises on wrong key / tamper
371
+ ```
372
+
373
+ Wire it around the codec — validate **cleartext** (before `protect`, after `unprotect`), since a
374
+ schema that constrains a sensitive field would reject the ciphertext string:
375
+
376
+ ```python
377
+ from babelqueue import EnvelopeCodec
378
+ from babelqueue.gdpr import protect, unprotect
379
+
380
+ # producer: encrypt marked leaves in place, then encode
381
+ protect(data, schema, cipher)
382
+ body = EnvelopeCodec.encode(EnvelopeCodec.make("urn:babel:orders:created", data, queue="orders"))
383
+
384
+ # consumer: decode, then decrypt marked leaves in place before the handler reads data
385
+ envelope = EnvelopeCodec.decode(body)
386
+ unprotect(envelope["data"], schema, cipher)
387
+ ```
388
+
389
+ `schema` is the same per-URN JSON Schema the validator uses; `babelqueue.schema.sensitive_paths`
390
+ locates every `x-gdpr-sensitive` leaf (nested objects, `addresses[].line` array items, container or
391
+ root marks). An absent marked field is skipped; a re-run `unprotect` on cleartext is a no-op
392
+ (non-string leaves are left alone); a wrong key / tampered ciphertext raises
393
+ `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
394
+ process unreadable PII. The round-trip restores `data` **byte-for-byte**.
395
+
314
396
  ## What's here
315
397
 
316
398
  The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
@@ -120,6 +120,27 @@ app.run() # consume forever (Ctrl-C to stop)
120
120
  and `memory://` (in-process, great for tests/local). Bring your own by passing
121
121
  `transport=...`.
122
122
 
123
+ - **Graceful shutdown:** SIGTERM/SIGINT finish the in-flight handler, or release
124
+ the message unchanged once `shutdown_timeout` expires. Delivery is
125
+ **at-least-once** — keep handlers idempotent (see the idempotency helper).
126
+
127
+ ### SQS release and poison messages
128
+
129
+ On a handler failure, unknown-URN `release` or shutdown release, the SQS
130
+ transport **always** releases with `ChangeMessageVisibility` (broker-bindings
131
+ §3.5) — it never re-sends a copy. The broker's `ApproximateReceiveCount` is the
132
+ attempt counter, so a shutdown release also consumes an attempt. The default
133
+ delay is **0 s** (`retry_backoff` / `unknown_urn_release_delay`), i.e. the
134
+ message is visible again immediately; delays outside 0..43200 s are clamped with
135
+ a warning.
136
+
137
+ **Poison-loop risk:** a message that always fails is redelivered at once until
138
+ `max_attempts` is reached. If the receive count is unavailable the SDK cannot
139
+ advance attempts at all. **Configure a native `RedrivePolicy`**
140
+ (`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>.dlq`) on every SQS
141
+ queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
142
+ handler's dependencies need time to recover.
143
+
123
144
  ### Sharing a Redis queue with Laravel
124
145
 
125
146
  By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
@@ -259,6 +280,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
259
280
  transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
260
281
  correlation with no error.
261
282
 
283
+ ## GDPR field encryption (optional)
284
+
285
+ The optional `babelqueue.gdpr` module is the runtime, SDK-enforcement half of ADR-0030: it
286
+ encrypts the `data` fields a registry declared `x-gdpr-sensitive`. The babelqueue-registry only
287
+ **declares** and **audits** sensitivity (and can **mask** for safe logging); this module
288
+ **enforces** it on the wire — a producer encrypts each marked leaf before publish, a consumer
289
+ decrypts it after decode. It is strictly **opt-in**: if you never call `protect`/`unprotect`,
290
+ nothing changes.
291
+
292
+ The envelope stays **frozen** (`schema_version: 1`): only the **values inside `data`** change — a
293
+ sensitive leaf's value becomes a ciphertext **string**. `data` stays pure JSON, so an SDK without
294
+ the key still carries the envelope (it just can't read the protected fields), and `trace_id` is
295
+ never touched. Which fields are sensitive lives in the **schema**, not the message.
296
+
297
+ Because Python's standard library has **no AES-GCM**, the core ships **no** cipher and pulls **no**
298
+ crypto dependency (GR-7). `Cipher` is a caller-provided `Protocol` you bind to a KMS / Vault / HSM /
299
+ tokenisation service, or to a local AES-256-GCM via the optional `cryptography` library
300
+ (`pip install "babelqueue[gdpr]"` — still **not** a core dependency):
301
+
302
+ ```python
303
+ import base64, os
304
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
305
+
306
+ class AesGcmCipher:
307
+ def __init__(self, key: bytes) -> None: # 16/24/32-byte key — the CALLER's
308
+ self._aead = AESGCM(key)
309
+
310
+ def encrypt(self, plaintext: bytes) -> str:
311
+ nonce = os.urandom(12)
312
+ return base64.b64encode(nonce + self._aead.encrypt(nonce, plaintext, None)).decode("ascii")
313
+
314
+ def decrypt(self, ciphertext: str) -> bytes:
315
+ raw = base64.b64decode(ciphertext)
316
+ return self._aead.decrypt(raw[:12], raw[12:], None) # raises on wrong key / tamper
317
+ ```
318
+
319
+ Wire it around the codec — validate **cleartext** (before `protect`, after `unprotect`), since a
320
+ schema that constrains a sensitive field would reject the ciphertext string:
321
+
322
+ ```python
323
+ from babelqueue import EnvelopeCodec
324
+ from babelqueue.gdpr import protect, unprotect
325
+
326
+ # producer: encrypt marked leaves in place, then encode
327
+ protect(data, schema, cipher)
328
+ body = EnvelopeCodec.encode(EnvelopeCodec.make("urn:babel:orders:created", data, queue="orders"))
329
+
330
+ # consumer: decode, then decrypt marked leaves in place before the handler reads data
331
+ envelope = EnvelopeCodec.decode(body)
332
+ unprotect(envelope["data"], schema, cipher)
333
+ ```
334
+
335
+ `schema` is the same per-URN JSON Schema the validator uses; `babelqueue.schema.sensitive_paths`
336
+ locates every `x-gdpr-sensitive` leaf (nested objects, `addresses[].line` array items, container or
337
+ root marks). An absent marked field is skipped; a re-run `unprotect` on cleartext is a no-op
338
+ (non-string leaves are left alone); a wrong key / tampered ciphertext raises
339
+ `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
340
+ process unreadable PII. The round-trip restores `data` **byte-for-byte**.
341
+
262
342
  ## What's here
263
343
 
264
344
  The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.12.0"
7
+ version = "1.14.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"
@@ -37,9 +37,12 @@ pulsar = ["pulsar-client>=3.4"]
37
37
  kafka = ["confluent-kafka>=2.3"]
38
38
  artemis = ["python-qpid-proton>=0.39"]
39
39
  otel = ["opentelemetry-api>=1.20"]
40
+ # Only for the documented bring-your-own AES-256-GCM reference cipher (ADR-0030). The core never
41
+ # imports it; protect/unprotect take a caller-provided Cipher Protocol, so this is NOT a core dep.
42
+ gdpr = ["cryptography>=42"]
40
43
  celery = ["celery>=5"]
41
44
  django = ["django>=4.2"]
42
- dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]
45
+ dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5,<0.16"]
43
46
 
44
47
  [project.urls]
45
48
  Homepage = "https://babelqueue.com"
@@ -11,10 +11,11 @@ and dead-letter helpers. Framework adapters (Celery, Django, ...) build on this.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- from . import dead_letter, headers, idempotency, outbox, redrive, replay
14
+ from . import dead_letter, gdpr, headers, idempotency, outbox, redrive, replay
15
15
  from .app import BabelQueue
16
16
  from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
17
17
  from .contracts import HasTraceId, PolyglotMessage
18
+ from .gdpr import Cipher, protect, unprotect
18
19
  from .headers import headers_from_context
19
20
  from .idempotency import IdempotencyStore, InMemoryStore
20
21
  from .outbox import (
@@ -25,12 +26,12 @@ from .outbox import (
25
26
  OutboxRelayResult,
26
27
  OutboxStore,
27
28
  )
28
- from .exceptions import BabelQueueError, UnknownUrnError
29
+ from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
29
30
  from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
30
31
  from .routing import UnknownUrnStrategy
31
- from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
32
+ from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Redeliverer, Transport
32
33
 
33
- __version__ = "1.12.0"
34
+ __version__ = "1.14.0"
34
35
 
35
36
  __all__ = [
36
37
  "BabelQueue",
@@ -44,8 +45,14 @@ __all__ = [
44
45
  "InMemoryTransport",
45
46
  "ReceivedMessage",
46
47
  "HeaderPublisher",
48
+ "Redeliverer",
47
49
  "BabelQueueError",
48
50
  "UnknownUrnError",
51
+ "DecryptError",
52
+ "gdpr",
53
+ "Cipher",
54
+ "protect",
55
+ "unprotect",
49
56
  "dead_letter",
50
57
  "headers",
51
58
  "idempotency",