babelqueue 1.11.0__tar.gz → 1.13.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 (77) hide show
  1. {babelqueue-1.11.0 → babelqueue-1.13.0}/CHANGELOG.md +56 -0
  2. {babelqueue-1.11.0 → babelqueue-1.13.0}/PKG-INFO +103 -1
  3. {babelqueue-1.11.0 → babelqueue-1.13.0}/README.md +100 -0
  4. {babelqueue-1.11.0 → babelqueue-1.13.0}/pyproject.toml +4 -1
  5. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/__init__.py +24 -3
  6. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/exceptions.py +12 -0
  7. babelqueue-1.13.0/src/babelqueue/gdpr.py +245 -0
  8. babelqueue-1.13.0/src/babelqueue/outbox.py +360 -0
  9. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/schema.py +59 -1
  10. babelqueue-1.13.0/tests/test_gdpr.py +328 -0
  11. babelqueue-1.13.0/tests/test_outbox.py +263 -0
  12. {babelqueue-1.11.0 → babelqueue-1.13.0}/.github/FUNDING.yml +0 -0
  13. {babelqueue-1.11.0 → babelqueue-1.13.0}/.github/workflows/ci.yml +0 -0
  14. {babelqueue-1.11.0 → babelqueue-1.13.0}/.github/workflows/release.yml +0 -0
  15. {babelqueue-1.11.0 → babelqueue-1.13.0}/.gitignore +0 -0
  16. {babelqueue-1.11.0 → babelqueue-1.13.0}/LICENSE +0 -0
  17. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/app.py +0 -0
  18. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/artemis_transport.py +0 -0
  19. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/asb_transport.py +0 -0
  20. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/celery.py +0 -0
  21. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/codec.py +0 -0
  22. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/contracts.py +0 -0
  23. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/dead_letter.py +0 -0
  24. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/django/__init__.py +0 -0
  25. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/django/apps.py +0 -0
  26. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/django/management/__init__.py +0 -0
  27. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  28. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  29. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/headers.py +0 -0
  30. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/idempotency.py +0 -0
  31. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/kafka_transport.py +0 -0
  32. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/otel.py +0 -0
  33. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/pika_transport.py +0 -0
  34. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/pulsar_transport.py +0 -0
  35. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/py.typed +0 -0
  36. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/redis_transport.py +0 -0
  37. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/redrive.py +0 -0
  38. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/replay.py +0 -0
  39. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/routing.py +0 -0
  40. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/sqs_transport.py +0 -0
  41. {babelqueue-1.11.0 → babelqueue-1.13.0}/src/babelqueue/transport.py +0 -0
  42. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  43. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  44. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  45. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/order-created.json +0 -0
  46. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  47. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  48. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/manifest.json +0 -0
  49. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  50. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/fixtures/dead-lettered.json +0 -0
  51. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/fixtures/order-created.json +0 -0
  52. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_app.py +0 -0
  53. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_artemis_conformance.py +0 -0
  54. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_artemis_transport.py +0 -0
  55. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_asb_conformance.py +0 -0
  56. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_asb_transport.py +0 -0
  57. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_celery.py +0 -0
  58. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_codec.py +0 -0
  59. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_conformance.py +0 -0
  60. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_dead_letter.py +0 -0
  61. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_django.py +0 -0
  62. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_headers.py +0 -0
  63. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_idempotency.py +0 -0
  64. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_kafka_conformance.py +0 -0
  65. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_kafka_transport.py +0 -0
  66. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_otel.py +0 -0
  67. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_overhead.py +0 -0
  68. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_pika_transport.py +0 -0
  69. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_pulsar_conformance.py +0 -0
  70. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_pulsar_transport.py +0 -0
  71. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_redis_transport.py +0 -0
  72. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_redrive.py +0 -0
  73. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_replay.py +0 -0
  74. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_schema.py +0 -0
  75. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_schema_conformance.py +0 -0
  76. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_sqs_conformance.py +0 -0
  77. {babelqueue-1.11.0 → babelqueue-1.13.0}/tests/test_sqs_transport.py +0 -0
@@ -9,6 +9,62 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.13.0] - 2026-06-21
13
+
14
+ ### Added
15
+ - **Runtime GDPR field encryption** (ADR-0030) — the optional `babelqueue.gdpr` module ports the Go
16
+ reference to Python: the SDK-enforcement half of governed PII. The babelqueue-registry only
17
+ **declares** and **audits** `x-gdpr-sensitive` fields (and can **mask** them for safe logging);
18
+ this module **enforces** them on the wire — a producer encrypts each marked leaf before publish, a
19
+ consumer decrypts it after decode. Strictly **opt-in**: a producer/consumer that never calls
20
+ `protect`/`unprotect` is unchanged. The **envelope stays frozen** (GR-1): only **values inside
21
+ `data`** change — a sensitive leaf's value becomes a ciphertext **string** — so `data` stays pure
22
+ JSON (an SDK without the key still carries the envelope), `meta.schema_version` stays `1`, and
23
+ `trace_id` is untouched (GR-4). The core stays **stdlib-only** (GR-7): Python's standard library
24
+ has **no AES-GCM**, so the module ships **no** concrete cipher and pulls **no** crypto dependency
25
+ — `Cipher` is a caller-provided `typing.Protocol` (`encrypt(bytes) -> str` / `decrypt(str) ->
26
+ bytes`) bound to a KMS / Vault / HSM / tokenisation service, or to a local AES-256-GCM via the
27
+ optional `cryptography` library (new `babelqueue[gdpr]` extra, documented in the README — still
28
+ **not** a core dependency). `protect(data, schema, cipher)` canonically JSON-encodes each marked
29
+ leaf and replaces it with the cipher's ciphertext string **in place**; `unprotect(...)` is the
30
+ exact inverse, restoring `data` **byte-for-byte**. The marked paths come from the **same per-URN
31
+ schema** the validator already loads — `babelqueue.schema.sensitive_paths` walks nested objects
32
+ (`profile.full_name`), array items (`addresses[].line`), container, and root marks, and the
33
+ `x-gdpr-sensitive` keyword is **validation-neutral** (annotating a schema is never breaking). An
34
+ absent marked field is skipped (not an error); a re-run `unprotect` on already-cleartext data is a
35
+ no-op (non-string leaves are left alone); a wrong key / tampered / non-ciphertext value raises the
36
+ new `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
37
+ handle unreadable PII. Validate cleartext **before** `protect` / **after** `unprotect`, since a
38
+ schema constraining a sensitive field would reject the ciphertext string. Unit-tested without any
39
+ crypto dependency (a stdlib-only authenticated fake cipher) for round-trip exactness across
40
+ nested/array/container/root marks, skips, idempotent re-runs, frozen-envelope decode/validate, and
41
+ wrong-key failure. The envelope is unchanged (`schema_version: 1`); this is purely additive.
42
+
43
+ ## [1.12.0] - 2026-06-21
44
+
45
+ ### Added
46
+ - **Transactional outbox helper** (ADR-0029) — the optional `babelqueue.outbox` module ports the
47
+ PHP `BabelQueue\Outbox` helper to Python, removing the producer **dual write**: the message is
48
+ persisted **into your database, in the same transaction** as the business data (so it commits or
49
+ rolls back atomically with it), and a separate **relay** publishes the durable rows afterwards.
50
+ No distributed transaction; exactly-once *handoff* into the broker, then at-least-once on the wire
51
+ (the consumer dedupes on `meta.id` via the idempotency helper, the consumer-side mirror, ADR-0022).
52
+ The core stays **stdlib-only** (GR-7): `OutboxStore` is an abstract `typing.Protocol` the caller
53
+ binds to their own DB — the module ships only the in-memory `InMemoryOutboxStore` reference and
54
+ pulls in **no** DB driver. `Outbox.write(envelope)` encodes via the frozen `EnvelopeCodec` and
55
+ delegates to `OutboxStore.save` **inside the transaction the caller already opened** — it does not
56
+ begin/commit anything (the caller owns the transaction boundary). `OutboxRelay.flush()` publishes
57
+ one batch through the existing publish-only `Transport`, marking each row published **only after**
58
+ the transport accepts it, or failed (caught → `mark_failed`, row left pending, with a bounded
59
+ linear backoff via an injectable sleeper) so one poison row never blocks the batch;
60
+ `OutboxRelay.drain()` loops while a pass makes progress, with a safety ceiling. The relay
61
+ publishes the **stored bytes verbatim** — it never decodes, rebuilds or re-encodes the envelope —
62
+ so `trace_id` is preserved end-to-end and the body is byte-compatible across SDKs (GR-1/GR-4/GR-5).
63
+ Unit-tested without a broker (write stores the encoded envelope byte-identical; relay publishes via
64
+ a fake `Transport` + marks published; a raising publish → `mark_failed`, row still pending, batch
65
+ continues; `drain` loops to empty and stops on no-progress; backoff grows linearly and caps via the
66
+ injected sleeper). The envelope is unchanged (`schema_version: 1`); this is purely additive.
67
+
12
68
  ## [1.11.0] - 2026-06-21
13
69
 
14
70
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 1.11.0
3
+ Version: 1.13.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
@@ -38,6 +38,8 @@ Requires-Dist: pytest>=7; extra == 'dev'
38
38
  Requires-Dist: ruff>=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
@@ -234,6 +236,47 @@ def on_created(data, meta): ...
234
236
  python manage.py babelqueue_worker --queue orders # run the consumer
235
237
  ```
236
238
 
239
+ ## Transactional outbox (optional)
240
+
241
+ The `babelqueue.outbox` helper (ADR-0029) removes the producer **dual write**: "commit the
242
+ business row" and "publish to the broker" are two systems that can disagree on a crash. Instead the
243
+ message is persisted **into your database, in the same transaction** as the business data — so it
244
+ commits or rolls back atomically with it — and a separate **relay** publishes the durable rows
245
+ afterwards. No distributed transaction; exactly-once *handoff* into the broker, then at-least-once
246
+ on the wire (the consumer dedupes on `meta.id` — see the idempotency helper, the mirror of this).
247
+
248
+ The core stays **stdlib-only**: `OutboxStore` is an abstract `Protocol` you bind to **your own DB**
249
+ (the core ships no driver). The stored value is the `EnvelopeCodec`-encoded envelope **byte-for-byte
250
+ unchanged** (frozen, `schema_version: 1`); the relay publishes those exact bytes — it never decodes,
251
+ rebuilds or re-encodes — so `trace_id` is preserved end-to-end.
252
+
253
+ ```python
254
+ from babelqueue import BabelQueue, EnvelopeCodec
255
+ from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
256
+
257
+ store = InMemoryOutboxStore() # production: your own OutboxStore adapter, DB-backed
258
+ outbox = Outbox(store)
259
+
260
+ # write side — YOU own the transaction boundary (this is the whole point):
261
+ with db.transaction(): # your own open transaction
262
+ db.insert_order(order) # the business write
263
+ envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042}, queue="orders")
264
+ outbox.write(envelope) # same connection, same tx — both, or neither
265
+
266
+ # read/publish side — run on a short interval, after the business tx commits:
267
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
268
+ relay = OutboxRelay(app.transport, store)
269
+ relay.drain() # publish all pending rows; flush() does one batch
270
+ ```
271
+
272
+ `Outbox.write` only encodes and calls `OutboxStore.save` — it does **not** begin or commit anything.
273
+ A `save` runs inside the transaction you already opened; you commit both together. `OutboxRelay`
274
+ marks a row published only **after** the transport accepts it; a publish that raises is recorded via
275
+ `mark_failed` (with a bounded, injectable-sleeper backoff) and left pending for a later pass, so one
276
+ poison row never blocks the batch. Implement `OutboxStore` over your DB (claim rows oldest-first,
277
+ ideally with `SELECT … FOR UPDATE SKIP LOCKED` so two relays don't double-publish); `InMemoryOutboxStore`
278
+ is the reference for tests and single-process demos (no real transaction).
279
+
237
280
  ## OpenTelemetry tracing (optional)
238
281
 
239
282
  `pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
@@ -270,6 +313,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
270
313
  transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
271
314
  correlation with no error.
272
315
 
316
+ ## GDPR field encryption (optional)
317
+
318
+ The optional `babelqueue.gdpr` module is the runtime, SDK-enforcement half of ADR-0030: it
319
+ encrypts the `data` fields a registry declared `x-gdpr-sensitive`. The babelqueue-registry only
320
+ **declares** and **audits** sensitivity (and can **mask** for safe logging); this module
321
+ **enforces** it on the wire — a producer encrypts each marked leaf before publish, a consumer
322
+ decrypts it after decode. It is strictly **opt-in**: if you never call `protect`/`unprotect`,
323
+ nothing changes.
324
+
325
+ The envelope stays **frozen** (`schema_version: 1`): only the **values inside `data`** change — a
326
+ sensitive leaf's value becomes a ciphertext **string**. `data` stays pure JSON, so an SDK without
327
+ the key still carries the envelope (it just can't read the protected fields), and `trace_id` is
328
+ never touched. Which fields are sensitive lives in the **schema**, not the message.
329
+
330
+ Because Python's standard library has **no AES-GCM**, the core ships **no** cipher and pulls **no**
331
+ crypto dependency (GR-7). `Cipher` is a caller-provided `Protocol` you bind to a KMS / Vault / HSM /
332
+ tokenisation service, or to a local AES-256-GCM via the optional `cryptography` library
333
+ (`pip install "babelqueue[gdpr]"` — still **not** a core dependency):
334
+
335
+ ```python
336
+ import base64, os
337
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
338
+
339
+ class AesGcmCipher:
340
+ def __init__(self, key: bytes) -> None: # 16/24/32-byte key — the CALLER's
341
+ self._aead = AESGCM(key)
342
+
343
+ def encrypt(self, plaintext: bytes) -> str:
344
+ nonce = os.urandom(12)
345
+ return base64.b64encode(nonce + self._aead.encrypt(nonce, plaintext, None)).decode("ascii")
346
+
347
+ def decrypt(self, ciphertext: str) -> bytes:
348
+ raw = base64.b64decode(ciphertext)
349
+ return self._aead.decrypt(raw[:12], raw[12:], None) # raises on wrong key / tamper
350
+ ```
351
+
352
+ Wire it around the codec — validate **cleartext** (before `protect`, after `unprotect`), since a
353
+ schema that constrains a sensitive field would reject the ciphertext string:
354
+
355
+ ```python
356
+ from babelqueue import EnvelopeCodec
357
+ from babelqueue.gdpr import protect, unprotect
358
+
359
+ # producer: encrypt marked leaves in place, then encode
360
+ protect(data, schema, cipher)
361
+ body = EnvelopeCodec.encode(EnvelopeCodec.make("urn:babel:orders:created", data, queue="orders"))
362
+
363
+ # consumer: decode, then decrypt marked leaves in place before the handler reads data
364
+ envelope = EnvelopeCodec.decode(body)
365
+ unprotect(envelope["data"], schema, cipher)
366
+ ```
367
+
368
+ `schema` is the same per-URN JSON Schema the validator uses; `babelqueue.schema.sensitive_paths`
369
+ locates every `x-gdpr-sensitive` leaf (nested objects, `addresses[].line` array items, container or
370
+ root marks). An absent marked field is skipped; a re-run `unprotect` on cleartext is a no-op
371
+ (non-string leaves are left alone); a wrong key / tampered ciphertext raises
372
+ `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
373
+ process unreadable PII. The round-trip restores `data` **byte-for-byte**.
374
+
273
375
  ## What's here
274
376
 
275
377
  The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
@@ -182,6 +182,47 @@ def on_created(data, meta): ...
182
182
  python manage.py babelqueue_worker --queue orders # run the consumer
183
183
  ```
184
184
 
185
+ ## Transactional outbox (optional)
186
+
187
+ The `babelqueue.outbox` helper (ADR-0029) removes the producer **dual write**: "commit the
188
+ business row" and "publish to the broker" are two systems that can disagree on a crash. Instead the
189
+ message is persisted **into your database, in the same transaction** as the business data — so it
190
+ commits or rolls back atomically with it — and a separate **relay** publishes the durable rows
191
+ afterwards. No distributed transaction; exactly-once *handoff* into the broker, then at-least-once
192
+ on the wire (the consumer dedupes on `meta.id` — see the idempotency helper, the mirror of this).
193
+
194
+ The core stays **stdlib-only**: `OutboxStore` is an abstract `Protocol` you bind to **your own DB**
195
+ (the core ships no driver). The stored value is the `EnvelopeCodec`-encoded envelope **byte-for-byte
196
+ unchanged** (frozen, `schema_version: 1`); the relay publishes those exact bytes — it never decodes,
197
+ rebuilds or re-encodes — so `trace_id` is preserved end-to-end.
198
+
199
+ ```python
200
+ from babelqueue import BabelQueue, EnvelopeCodec
201
+ from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
202
+
203
+ store = InMemoryOutboxStore() # production: your own OutboxStore adapter, DB-backed
204
+ outbox = Outbox(store)
205
+
206
+ # write side — YOU own the transaction boundary (this is the whole point):
207
+ with db.transaction(): # your own open transaction
208
+ db.insert_order(order) # the business write
209
+ envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042}, queue="orders")
210
+ outbox.write(envelope) # same connection, same tx — both, or neither
211
+
212
+ # read/publish side — run on a short interval, after the business tx commits:
213
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
214
+ relay = OutboxRelay(app.transport, store)
215
+ relay.drain() # publish all pending rows; flush() does one batch
216
+ ```
217
+
218
+ `Outbox.write` only encodes and calls `OutboxStore.save` — it does **not** begin or commit anything.
219
+ A `save` runs inside the transaction you already opened; you commit both together. `OutboxRelay`
220
+ marks a row published only **after** the transport accepts it; a publish that raises is recorded via
221
+ `mark_failed` (with a bounded, injectable-sleeper backoff) and left pending for a later pass, so one
222
+ poison row never blocks the batch. Implement `OutboxStore` over your DB (claim rows oldest-first,
223
+ ideally with `SELECT … FOR UPDATE SKIP LOCKED` so two relays don't double-publish); `InMemoryOutboxStore`
224
+ is the reference for tests and single-process demos (no real transaction).
225
+
185
226
  ## OpenTelemetry tracing (optional)
186
227
 
187
228
  `pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
@@ -218,6 +259,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
218
259
  transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
219
260
  correlation with no error.
220
261
 
262
+ ## GDPR field encryption (optional)
263
+
264
+ The optional `babelqueue.gdpr` module is the runtime, SDK-enforcement half of ADR-0030: it
265
+ encrypts the `data` fields a registry declared `x-gdpr-sensitive`. The babelqueue-registry only
266
+ **declares** and **audits** sensitivity (and can **mask** for safe logging); this module
267
+ **enforces** it on the wire — a producer encrypts each marked leaf before publish, a consumer
268
+ decrypts it after decode. It is strictly **opt-in**: if you never call `protect`/`unprotect`,
269
+ nothing changes.
270
+
271
+ The envelope stays **frozen** (`schema_version: 1`): only the **values inside `data`** change — a
272
+ sensitive leaf's value becomes a ciphertext **string**. `data` stays pure JSON, so an SDK without
273
+ the key still carries the envelope (it just can't read the protected fields), and `trace_id` is
274
+ never touched. Which fields are sensitive lives in the **schema**, not the message.
275
+
276
+ Because Python's standard library has **no AES-GCM**, the core ships **no** cipher and pulls **no**
277
+ crypto dependency (GR-7). `Cipher` is a caller-provided `Protocol` you bind to a KMS / Vault / HSM /
278
+ tokenisation service, or to a local AES-256-GCM via the optional `cryptography` library
279
+ (`pip install "babelqueue[gdpr]"` — still **not** a core dependency):
280
+
281
+ ```python
282
+ import base64, os
283
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
284
+
285
+ class AesGcmCipher:
286
+ def __init__(self, key: bytes) -> None: # 16/24/32-byte key — the CALLER's
287
+ self._aead = AESGCM(key)
288
+
289
+ def encrypt(self, plaintext: bytes) -> str:
290
+ nonce = os.urandom(12)
291
+ return base64.b64encode(nonce + self._aead.encrypt(nonce, plaintext, None)).decode("ascii")
292
+
293
+ def decrypt(self, ciphertext: str) -> bytes:
294
+ raw = base64.b64decode(ciphertext)
295
+ return self._aead.decrypt(raw[:12], raw[12:], None) # raises on wrong key / tamper
296
+ ```
297
+
298
+ Wire it around the codec — validate **cleartext** (before `protect`, after `unprotect`), since a
299
+ schema that constrains a sensitive field would reject the ciphertext string:
300
+
301
+ ```python
302
+ from babelqueue import EnvelopeCodec
303
+ from babelqueue.gdpr import protect, unprotect
304
+
305
+ # producer: encrypt marked leaves in place, then encode
306
+ protect(data, schema, cipher)
307
+ body = EnvelopeCodec.encode(EnvelopeCodec.make("urn:babel:orders:created", data, queue="orders"))
308
+
309
+ # consumer: decode, then decrypt marked leaves in place before the handler reads data
310
+ envelope = EnvelopeCodec.decode(body)
311
+ unprotect(envelope["data"], schema, cipher)
312
+ ```
313
+
314
+ `schema` is the same per-URN JSON Schema the validator uses; `babelqueue.schema.sensitive_paths`
315
+ locates every `x-gdpr-sensitive` leaf (nested objects, `addresses[].line` array items, container or
316
+ root marks). An absent marked field is skipped; a re-run `unprotect` on cleartext is a no-op
317
+ (non-string leaves are left alone); a wrong key / tampered ciphertext raises
318
+ `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
319
+ process unreadable PII. The round-trip restores `data` **byte-for-byte**.
320
+
221
321
  ## What's here
222
322
 
223
323
  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.11.0"
7
+ version = "1.13.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,6 +37,9 @@ 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
45
  dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]
@@ -11,18 +11,27 @@ 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, 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
- from .exceptions import BabelQueueError, UnknownUrnError
21
+ from .outbox import (
22
+ InMemoryOutboxStore,
23
+ Outbox,
24
+ OutboxRecord,
25
+ OutboxRelay,
26
+ OutboxRelayResult,
27
+ OutboxStore,
28
+ )
29
+ from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
21
30
  from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
22
31
  from .routing import UnknownUrnStrategy
23
32
  from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
24
33
 
25
- __version__ = "1.11.0"
34
+ __version__ = "1.13.0"
26
35
 
27
36
  __all__ = [
28
37
  "BabelQueue",
@@ -38,9 +47,21 @@ __all__ = [
38
47
  "HeaderPublisher",
39
48
  "BabelQueueError",
40
49
  "UnknownUrnError",
50
+ "DecryptError",
51
+ "gdpr",
52
+ "Cipher",
53
+ "protect",
54
+ "unprotect",
41
55
  "dead_letter",
42
56
  "headers",
43
57
  "idempotency",
58
+ "outbox",
59
+ "Outbox",
60
+ "OutboxStore",
61
+ "OutboxRecord",
62
+ "OutboxRelay",
63
+ "OutboxRelayResult",
64
+ "InMemoryOutboxStore",
44
65
  "redrive",
45
66
  "replay",
46
67
  "is_replay",
@@ -23,3 +23,15 @@ class InvalidPayloadError(BabelQueueError):
23
23
  super().__init__(f"data for {urn!r} does not match its URN schema: {violation}")
24
24
  self.urn = urn
25
25
  self.violation = violation
26
+
27
+
28
+ class DecryptError(BabelQueueError):
29
+ """A protected ``x-gdpr-sensitive`` field could not be restored on the consume side (ADR-0030).
30
+
31
+ Raised by :func:`babelqueue.gdpr.unprotect` when a marked leaf is a ciphertext string the
32
+ :class:`~babelqueue.gdpr.Cipher` cannot open — a wrong key, a tampered/garbled ciphertext, or a
33
+ value whose decrypted bytes are not the JSON the producer encoded. The Python mirror of the Go
34
+ ``gdpr.ErrDecrypt``: a missing field is skipped (not an error), but an *unreadable* one stops
35
+ ``unprotect`` so the consumer fails the message (retry / dead-letter) rather than handle
36
+ unreadable PII.
37
+ """
@@ -0,0 +1,245 @@
1
+ """Optional runtime GDPR field encryption (ADR-0030): the SDK-enforcement half.
2
+
3
+ The Python mirror of the Go ``gdpr`` package — the reference the SDKs share. The
4
+ babelqueue-registry only **declares** and **audits** which ``data`` fields are sensitive (the
5
+ ``x-gdpr-sensitive`` schema keyword) and can **mask** them for safe logging; this module
6
+ **enforces** that on the wire — a producer encrypts each marked leaf before publish, a consumer
7
+ decrypts it after decode. It is strictly **opt-in**: a producer/consumer that never calls
8
+ :func:`protect`/:func:`unprotect` behaves exactly as before.
9
+
10
+ The contract is deliberately tight so every SDK stays byte-compatible:
11
+
12
+ - **The envelope stays frozen (GR-1).** :func:`protect` mutates only **values inside ``data``**: a
13
+ sensitive leaf's value becomes a ciphertext **string**. It never adds, renames, removes or
14
+ retypes an envelope field; ``meta.schema_version`` stays ``1``; ``trace_id`` is untouched (GR-4).
15
+ ``data`` stays **pure JSON** (GR-3) — a JSON string is still pure JSON, so any SDK can carry the
16
+ envelope even without the key (it just can't read the protected fields).
17
+ - **Zero heavy dependencies (GR-7).** Python's standard library has **no AES-GCM**, so the core
18
+ ships **no** concrete cipher and pulls **no** crypto dependency. :class:`Cipher` is a
19
+ caller-provided :class:`typing.Protocol` — bind it to a KMS, Vault transit, an HSM, a
20
+ tokenisation service, or a local AES-GCM built on the optional ``cryptography`` library (see the
21
+ bring-your-own-cipher example below). The crypto dependency lives in the *caller's* code, never
22
+ in this core.
23
+
24
+ The sensitive paths come from the **same per-URN schema** the produce/consume validation path
25
+ already loads (:func:`babelqueue.schema.sensitive_paths`) — the ``x-gdpr-sensitive`` marks ride on
26
+ it. Validate **cleartext**: run :func:`babelqueue.schema.validate` **before** :func:`protect` on
27
+ the producer and **after** :func:`unprotect` on the consumer, because a schema that constrains a
28
+ sensitive field (``minLength``, ``enum``, …) would otherwise reject the ciphertext string.
29
+
30
+ Typical wiring (producer)::
31
+
32
+ from babelqueue import EnvelopeCodec
33
+ from babelqueue.gdpr import protect
34
+
35
+ data = {"order_id": 1042, "email": "alice@example.com"}
36
+ # validate(provider, urn, data) # optional: validate cleartext first
37
+ protect(data, schema, cipher) # encrypt marked leaves IN PLACE
38
+ body = EnvelopeCodec.encode(EnvelopeCodec.make(urn, data)) # ciphertext rides inside data
39
+
40
+ and the inverse on the consumer, after decode and before the handler reads ``data``::
41
+
42
+ from babelqueue.gdpr import unprotect
43
+
44
+ envelope = EnvelopeCodec.decode(body)
45
+ unprotect(envelope["data"], schema, cipher) # decrypt marked leaves IN PLACE
46
+ # validate(provider, urn, envelope["data"]) # optional: validate cleartext after
47
+
48
+ Bring-your-own AES-256-GCM cipher (NOT part of this core — ``pip install cryptography``)::
49
+
50
+ import base64, os
51
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
52
+
53
+ class AesGcmCipher:
54
+ '''A reference Cipher: AES-256-GCM, random 12-byte nonce prepended, base64 out.
55
+ The 32-byte key is the CALLER's; this does no key management or rotation.'''
56
+
57
+ def __init__(self, key: bytes) -> None:
58
+ self._aead = AESGCM(key) # key must be 16, 24, or 32 bytes
59
+
60
+ def encrypt(self, plaintext: bytes) -> str:
61
+ nonce = os.urandom(12)
62
+ sealed = self._aead.encrypt(nonce, plaintext, None)
63
+ return base64.b64encode(nonce + sealed).decode("ascii")
64
+
65
+ def decrypt(self, ciphertext: str) -> bytes:
66
+ raw = base64.b64decode(ciphertext)
67
+ nonce, sealed = raw[:12], raw[12:]
68
+ return self._aead.decrypt(nonce, sealed, None) # raises on wrong key/tamper
69
+
70
+ The same plaintext MAY encrypt to a different string each call (the random nonce is expected and
71
+ good); :meth:`Cipher.decrypt` restores the exact bytes :meth:`Cipher.encrypt` was given, and a
72
+ wrong-key / tampered input MUST raise (GCM authentication) — :func:`unprotect` turns any such raise
73
+ into :class:`~babelqueue.exceptions.DecryptError`.
74
+ """
75
+
76
+ from __future__ import annotations
77
+
78
+ import json
79
+ from typing import Any, Callable, List, MutableMapping, MutableSequence, Optional, Protocol, Tuple, runtime_checkable
80
+
81
+ from .exceptions import DecryptError
82
+ from .schema import sensitive_paths
83
+
84
+ __all__ = ["Cipher", "protect", "unprotect"]
85
+
86
+
87
+ @runtime_checkable
88
+ class Cipher(Protocol):
89
+ """The field-level protection primitive the **caller** provides — a seam onto a KMS, Vault
90
+ transit, an HSM, a tokenisation service, or a local AES-GCM (see the module docstring). Keeping
91
+ this a Protocol is what holds GR-7: this core never imports a crypto library; only the caller's
92
+ concrete cipher does.
93
+
94
+ Contract for an implementation:
95
+
96
+ - :meth:`encrypt` takes the canonical JSON bytes of one field value (see :func:`protect`) and
97
+ returns the ciphertext as a **str** that is valid for placement inside a JSON document (e.g.
98
+ base64). The same plaintext MAY encrypt to a different string each call (a random nonce/IV is
99
+ expected and good).
100
+ - :meth:`decrypt` is the exact inverse: given a string :meth:`encrypt` produced it returns the
101
+ original JSON bytes byte-for-byte. A string it did not produce, or one produced under a
102
+ different key, MUST **raise** rather than return silent garbage, so a wrong-key consume fails
103
+ loudly (:func:`unprotect` wraps the raise in :class:`~babelqueue.exceptions.DecryptError`).
104
+ - Both SHOULD be safe for concurrent use; a producer/consumer may fan one cipher across threads.
105
+ """
106
+
107
+ def encrypt(self, plaintext: bytes) -> str:
108
+ """Protect one field value (its canonical JSON bytes) → a JSON-safe ciphertext string."""
109
+ ...
110
+
111
+ def decrypt(self, ciphertext: str) -> bytes:
112
+ """Reverse :meth:`encrypt`, returning the original field-value JSON bytes."""
113
+ ...
114
+
115
+
116
+ def protect(data: MutableMapping[str, Any], schema: Any, cipher: Cipher) -> None:
117
+ """Encrypt, in place, every value in ``data`` at a path the schema marked ``x-gdpr-sensitive``.
118
+
119
+ The producer-side step — run it after building ``data`` and before encode/publish. Each marked
120
+ leaf's value is canonically JSON-encoded and replaced by ``cipher.encrypt``'s ciphertext
121
+ **string**; the envelope frame, non-sensitive fields, and everything else are untouched (GR-1).
122
+
123
+ A marked path absent from ``data`` is skipped (not an error) — schemas evolve and a message need
124
+ not carry every optional field. ``data`` may be empty (no-op). A ``None`` schema or one with no
125
+ marks is a no-op (nothing is sensitive). A container mark (a whole object/array marked
126
+ sensitive) is supported: the entire sub-value is encoded and encrypted as one ciphertext string.
127
+
128
+ On any cipher error this propagates and leaves ``data`` partially protected; treat a raised
129
+ error as fatal for that message (do not publish it).
130
+ """
131
+ _walk(data, schema, cipher, _encrypt_leaf)
132
+
133
+
134
+ def unprotect(data: MutableMapping[str, Any], schema: Any, cipher: Cipher) -> None:
135
+ """Decrypt, in place, every value in ``data`` at an ``x-gdpr-sensitive`` path — the consumer-side
136
+ inverse of :func:`protect`. Run it after decode and before the handler reads ``data``.
137
+
138
+ An absent path is skipped. A leaf that is **not a string** — it was never protected, or this is
139
+ a re-run after a successful :func:`unprotect` — is left as-is, so re-invoking on already-cleartext
140
+ data is safe (idempotent for non-string leaves). A string the cipher cannot open (wrong key,
141
+ tampered, or not a ciphertext) raises :class:`~babelqueue.exceptions.DecryptError` — the consumer
142
+ should fail the message (retry / dead-letter) rather than process unreadable PII.
143
+ """
144
+ _walk(data, schema, cipher, _decrypt_leaf)
145
+
146
+
147
+ # A leaf op transforms one sensitive leaf value (encrypt or decrypt). It returns ``(new_value, ok)``:
148
+ # ``ok=False`` means the value should be left in place untouched.
149
+ _LeafOp = Callable[[Any, Cipher], Tuple[Any, bool]]
150
+
151
+
152
+ def _walk(data: Any, schema: Any, cipher: Optional[Cipher], op: _LeafOp) -> None:
153
+ """Drive ``op`` over every ``x-gdpr-sensitive`` path the schema declares, resolving each path
154
+ against ``data`` itself (NOT by re-walking the schema over the value), so the operation touches
155
+ exactly the declared leaves — non-sensitive siblings are never read or copied."""
156
+ if data is None or schema is None or cipher is None:
157
+ return
158
+ for sp in sensitive_paths(schema):
159
+ _apply_at_path(data, _parse_path(sp.path), cipher, op)
160
+
161
+
162
+ # One step of a sensitive path: ``(key, is_array)``. ``"addresses[].line"`` parses to
163
+ # ``[("addresses", True), ("line", False)]``; the ``[]`` marker means array-descent into every
164
+ # element before the next segment.
165
+ _Segment = Tuple[str, bool]
166
+
167
+
168
+ def _parse_path(path: str) -> List[_Segment]:
169
+ """Split a sensitive path (``"email"``, ``"profile.full_name"``, ``"addresses[].line"``) into
170
+ segments. A trailing ``[]`` on a part binds to it as array-descent. A root mark (path ``""``)
171
+ yields no segments — there is no addressable leaf for it inside ``data``, so it is skipped."""
172
+ if path == "":
173
+ return []
174
+ segments: List[_Segment] = []
175
+ for part in path.split("."):
176
+ if len(part) >= 2 and part.endswith("[]"):
177
+ segments.append((part[:-2], True))
178
+ else:
179
+ segments.append((part, False))
180
+ return segments
181
+
182
+
183
+ def _apply_at_path(node: Any, segments: List[_Segment], cipher: Cipher, op: _LeafOp) -> None:
184
+ """Resolve ``segments`` against ``node`` and run ``op`` on the leaf(s). It descends objects by
185
+ key and, when a segment is an array, fans out over every element. An absent key or a type
186
+ mismatch (a path that does not exist in this particular message) is skipped silently — schemas
187
+ describe the union of possible shapes; a given message need not contain every field."""
188
+ if not segments:
189
+ return # root mark or exhausted path with no leaf key — nothing addressable in data
190
+ key, is_array = segments[0]
191
+ if not isinstance(node, MutableMapping):
192
+ return # expected an object here but the message has something else — skip
193
+ if key not in node:
194
+ return # absent field — skip (not an error)
195
+
196
+ child = node[key]
197
+ last = len(segments) == 1
198
+
199
+ if is_array:
200
+ if not isinstance(child, MutableSequence):
201
+ return # declared array but message has a non-array — skip
202
+ for i, elem in enumerate(child):
203
+ if last:
204
+ new_value, ok = op(elem, cipher)
205
+ if ok:
206
+ child[i] = new_value
207
+ else:
208
+ _apply_at_path(elem, segments[1:], cipher, op)
209
+ return
210
+
211
+ if last:
212
+ new_value, ok = op(child, cipher)
213
+ if ok:
214
+ node[key] = new_value
215
+ return
216
+ _apply_at_path(child, segments[1:], cipher, op)
217
+
218
+
219
+ def _encrypt_leaf(value: Any, cipher: Cipher) -> Tuple[Any, bool]:
220
+ """Canonically JSON-encode one field value and replace it with the cipher's ciphertext string.
221
+ The JSON encoding is what makes the round-trip exact: :func:`unprotect`'s ``json.loads`` restores
222
+ the same decoded-JSON value (``float`` for numbers, ``dict`` for objects, …) the codec would
223
+ have produced, so protect → unprotect is byte-for-byte. ``sort_keys`` + compact separators give
224
+ one canonical encoding, mirroring the Go reference's ``json.Marshal``."""
225
+ plaintext = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
226
+ return cipher.encrypt(plaintext), True
227
+
228
+
229
+ def _decrypt_leaf(value: Any, cipher: Cipher) -> Tuple[Any, bool]:
230
+ """Reverse :func:`_encrypt_leaf`. A non-string leaf is left untouched (``ok=False``) so
231
+ :func:`unprotect` is safe to re-run on already-cleartext data; a string that fails to open or to
232
+ JSON-decode raises :class:`~babelqueue.exceptions.DecryptError` so the consumer fails the message
233
+ rather than handle unreadable PII."""
234
+ if not isinstance(value, str):
235
+ # Not a ciphertext string (already cleartext, or never protected) — leave as-is.
236
+ return None, False
237
+ try:
238
+ plaintext = cipher.decrypt(value)
239
+ except Exception as exc: # noqa: BLE001 — any cipher failure is a decrypt failure for the caller
240
+ raise DecryptError(f"cannot decrypt a protected field: {exc}") from exc
241
+ try:
242
+ restored = json.loads(plaintext)
243
+ except (ValueError, TypeError) as exc:
244
+ raise DecryptError(f"decrypted plaintext is not JSON: {exc}") from exc
245
+ return restored, True