@cloudbitmaps/roaring 0.9.0 → 0.11.0

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 (80) hide show
  1. package/NOTICE +3 -3
  2. package/PRIVACY.md +268 -76
  3. package/README.md +85 -95
  4. package/dist/bin/export-segments.d.ts +21 -0
  5. package/dist/bin/export-segments.js +1972 -30
  6. package/dist/bin/export-segments.js.map +7 -1
  7. package/dist/index.d.ts +969 -458
  8. package/dist/index.js +1461 -354
  9. package/dist/index.js.map +7 -1
  10. package/dist/open-charging-storage.d.ts +34 -0
  11. package/dist/option-keys.d.ts +19 -0
  12. package/dist/portable/decode.d.ts +53 -0
  13. package/dist/portable/layout.d.ts +32 -0
  14. package/dist/reserved-namespace.d.ts +3 -0
  15. package/dist/roaring-codec.d.ts +68 -0
  16. package/dist/system-clock.d.ts +39 -0
  17. package/dist/testing/conformance.d.ts +54 -0
  18. package/dist/testing/fuzz-codec.d.ts +23 -0
  19. package/package.json +11 -117
  20. package/dist/azure/index.cjs +0 -14
  21. package/dist/azure/index.cjs.map +0 -1
  22. package/dist/azure/index.d.cts +0 -9
  23. package/dist/azure/index.d.ts +0 -9
  24. package/dist/azure/index.js +0 -3
  25. package/dist/azure/index.js.map +0 -1
  26. package/dist/bin/compact-segments.js +0 -201
  27. package/dist/bin/compact-segments.js.map +0 -1
  28. package/dist/cassandra/index.cjs +0 -14
  29. package/dist/cassandra/index.cjs.map +0 -1
  30. package/dist/cassandra/index.d.cts +0 -1
  31. package/dist/cassandra/index.d.ts +0 -1
  32. package/dist/cassandra/index.js +0 -3
  33. package/dist/cassandra/index.js.map +0 -1
  34. package/dist/chunk-D6LIJSGF.js +0 -906
  35. package/dist/chunk-D6LIJSGF.js.map +0 -1
  36. package/dist/dynamodb/index.cjs +0 -14
  37. package/dist/dynamodb/index.cjs.map +0 -1
  38. package/dist/dynamodb/index.d.cts +0 -1
  39. package/dist/dynamodb/index.d.ts +0 -1
  40. package/dist/dynamodb/index.js +0 -3
  41. package/dist/dynamodb/index.js.map +0 -1
  42. package/dist/gcs/index.cjs +0 -14
  43. package/dist/gcs/index.cjs.map +0 -1
  44. package/dist/gcs/index.d.cts +0 -1
  45. package/dist/gcs/index.d.ts +0 -1
  46. package/dist/gcs/index.js +0 -3
  47. package/dist/gcs/index.js.map +0 -1
  48. package/dist/index.cjs +0 -894
  49. package/dist/index.cjs.map +0 -1
  50. package/dist/index.d.cts +0 -734
  51. package/dist/mongodb/index.cjs +0 -14
  52. package/dist/mongodb/index.cjs.map +0 -1
  53. package/dist/mongodb/index.d.cts +0 -1
  54. package/dist/mongodb/index.d.ts +0 -1
  55. package/dist/mongodb/index.js +0 -3
  56. package/dist/mongodb/index.js.map +0 -1
  57. package/dist/mysql/index.cjs +0 -14
  58. package/dist/mysql/index.cjs.map +0 -1
  59. package/dist/mysql/index.d.cts +0 -1
  60. package/dist/mysql/index.d.ts +0 -1
  61. package/dist/mysql/index.js +0 -3
  62. package/dist/mysql/index.js.map +0 -1
  63. package/dist/postgres/index.cjs +0 -14
  64. package/dist/postgres/index.cjs.map +0 -1
  65. package/dist/postgres/index.d.cts +0 -1
  66. package/dist/postgres/index.d.ts +0 -1
  67. package/dist/postgres/index.js +0 -3
  68. package/dist/postgres/index.js.map +0 -1
  69. package/dist/redis/index.cjs +0 -14
  70. package/dist/redis/index.cjs.map +0 -1
  71. package/dist/redis/index.d.cts +0 -1
  72. package/dist/redis/index.d.ts +0 -1
  73. package/dist/redis/index.js +0 -3
  74. package/dist/redis/index.js.map +0 -1
  75. package/dist/s3/index.cjs +0 -14
  76. package/dist/s3/index.cjs.map +0 -1
  77. package/dist/s3/index.d.cts +0 -1
  78. package/dist/s3/index.d.ts +0 -1
  79. package/dist/s3/index.js +0 -3
  80. package/dist/s3/index.js.map +0 -1
package/NOTICE CHANGED
@@ -1,4 +1,4 @@
1
- CloudRoaring
1
+ @cloudbitmaps/roaring
2
2
  Copyright 2026 Sharvil Kadam
3
3
 
4
4
  This product includes software developed as an open-source project.
@@ -7,6 +7,6 @@ file. You may obtain a copy of the License at:
7
7
 
8
8
  http://www.apache.org/licenses/LICENSE-2.0
9
9
 
10
- CloudRoaring builds on the Roaring Bitmap data structure and is designed to use
11
- the CRoaring engine (via the `roaring` Node.js binding) for in-memory bit math.
10
+ @cloudbitmaps/roaring builds on the Roaring Bitmap data structure and uses the CRoaring
11
+ engine (via the `roaring` Node.js binding) for in-memory bit math.
12
12
  Those projects are independent works under their own licenses.
package/PRIVACY.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Privacy, data protection & shared responsibility
2
2
 
3
+ > This is the copy published with the npm package; it is kept identical in substance to
4
+ > [`PRIVACY.md`](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/PRIVACY.md) in the repository.
5
+
6
+
3
7
  > **Engineering guidance, not legal advice.** This document explains CloudBitmaps' data-handling posture and
4
8
  > the controls it gives you, so you (and your privacy counsel / DPO) can place it correctly in your compliance
5
9
  > program. Regulatory references (GDPR, CCPA/CPRA, HIPAA, …) are illustrative. Nothing here is a compliance
@@ -8,7 +12,7 @@
8
12
  ## TL;DR — the trust boundary
9
13
 
10
14
  **CloudBitmaps is an embedded library.** It runs inside *your* process/function and talks to *your* storage
11
- accounts (S3, DynamoDB, local disk — whatever drivers you wire). **It has no hosted backend and transmits no
15
+ accounts (S3, GCS, Azure Blob, local disk — whatever drivers you wire). **It has no hosted backend and transmits no
12
16
  data to the authors or any third party** — there is no telemetry, no phone-home, no usage ping. The metrics
13
17
  and audit sinks are local and off by default; they only go where *you* send them.
14
18
 
@@ -38,105 +42,293 @@ from constructing a cross-region topology. The points where personal data moves
38
42
 
39
43
  | Location | What's there | Residency note |
40
44
  |---|---|---|
41
- | **Cold** (object store) | immutable `.crbm` generations | the region of the bucket you wire |
42
- | **Warm** (NoSQL) | pending add/remove deltas | the region of the table you wire |
43
- | **HOT cache** (process RAM) | decoded chunks, bounded LRU | **wherever your process/Lambda runs** — an EU segment queried from a US function is processed in the US |
44
- | **Compaction** (daemon) | reads Cold+Warm, writes new Cold | runs wherever you run the daemon |
45
+ | **Storage** (object store) | immutable `.crbm` generations — every generation a segment has had, until a superseded one is collected. Azure Blob objects, and GCS objects above the simple-upload threshold, also carry a random write id in their metadata (`cbwid`): 128 random bits, no data from the bitmap or the source | the region of the bucket you wire |
46
+ | **Registry** (S3 / GCS / Azure Blob / local) | one row per segment: the current-generation pointer, wrapped keys, retention metadata — no IDs. An Azure Blob row also carries the random write id in its metadata | the region of the bucket you wire |
47
+ | **cache** (process RAM) | decoded chunks, bounded LRU | **wherever your process/Lambda runs** — an EU segment queried from a US function is processed in the US |
48
+ | **Loads and rewrites** (`store.load()`, the `*Into` verbs, `eraseSubject`) | read your source (or existing generations), write a new generation | run wherever you run them — a loader in one region writing to a bucket in another is a transfer |
45
49
  | **Intersection** | pulls chunks from N segments into one process | co-locates those segments in one region |
46
50
 
47
51
  **Guidance (not enforced by the library):** to keep EU data in EU infrastructure, wire region-local drivers
48
- *and* run the process/daemon in-region; keep a segment's Warm, Cold, and the querying compute in one
49
- jurisdiction; treat the HOT cache and the intersection runtime as **processing locations** in your transfer
50
- assessment and breach scope (process RAM, and any heap/core dumps, hold personal data). A fail-closed
51
- residency-enforcement policy in the library was considered and deferred as over-engineering for v1 — the honest
52
- posture is "you wire it correctly," documented here.
52
+ *and* run your loaders and erasure jobs in-region; keep a segment's storage objects, its registry row, and the querying compute in
53
+ one jurisdiction; treat the cache and the intersection runtime as **processing locations** in your transfer
54
+ assessment and breach scope (process RAM, and any heap/core dumps, hold personal data). The library has no
55
+ fail-closed residency-enforcement policy; the posture is "you wire it correctly," documented here.
53
56
 
54
57
  ## Erasure — what "delete" actually means
55
58
 
56
- CloudBitmaps gives you three erasure levers with different guarantees. Use them deliberately:
59
+ CloudBitmaps gives you three levers with different guarantees. Use them deliberately:
57
60
 
58
61
  | Lever | API | Guarantee | Use for |
59
62
  |---|---|---|---|
60
- | **Logical remove** | `segment.remove(id)` / `removeMany` | *Immediate* — reads exclude the ID at once (tombstone; `effective = (Cold ∪ adds) \ removes`). The bit **physically persists** in the immutable Cold `.crbm` until that chunk is next compacted. | everyday "take this user out of this audience" |
61
- | **Physical purge** | `store.compact(ref, { owner })` on demand, or the compaction daemon (`compact-segments` / `runCompactionCycle`) on a schedule | A compaction folds the tombstone into a fresh generation and **physically drops** the bit. | meeting a physical-deletion deadline — *run compaction (the daemon on a schedule, or `store.compact` on demand) inside your SLA* |
62
- | **Crypto-shred** | `destroySegment` / `eraseNamespace` | *Instant + total* — destroys the segment's wrapped key, so **every** copy (current, prior generations, backups, WORM-locked objects) becomes unreadable without touching the bytes. Requires the segment to be encrypted. | whole-segment / tenant offboarding; erasure under immutable backups (see below) |
63
+ | **Subject erasure** | `store.eraseSubject(id, { namespace })` | *Physical on return* — the segment's current generation is rewritten without the id (every chunk streamed through, one bit cleared), published **fenced on the generation it streamed**, and **the generation that held the bit is deleted before the call returns**. So is every other generation still holding the id: a retained *superseded* one (an ex-member dropped by a re-seed), and each one above the pointer after a `rollback` — generations a rollback can move the pointer onto again — while, when the current generation does not hold the id, the ones there that never held it stay as rollback targets (a rewrite is numbered above everything, so its `keep: 0` collection takes every older generation, above the pointer or below). Before it says `erased: true` the call lists the bucket and reads what is left, so **`erased: true` means no generation of the segment holds the id**, above the pointer or below it. `eraseSubject` reports a per-segment fault as an `error: …` ledger entry rather than throwing it, and a call that a racing writer overtook (a load, another erasure, or a rollback) as `erased: false, note: 'superseded'`, so "on return" is a claim about every segment whose entry says **`erased: true`** (a fault is an `error: …` entry, never `erased: true`). The store that performs the call stops serving the id immediately; **other processes converge on their own read TTL** — see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). Does **not** reach backups, replicas or noncurrent object versions — those hold the old generation until their own lifecycle removes it. | "forget this person" — GDPR Art. 17 |
64
+ | **Dispose** | `store.dropSegment(ref, { confirmSegment })` | *Immediate* — the segment is tombstoned and its Storage generations deleted, reclaiming the space. **Check `generationsRemaining`:** if it is non-empty the space was *not* fully reclaimed and the drop should be re-run (a load that was already writing when the tombstone landed still finishes its object). Works on cleartext; on an encrypted segment it *also* crypto-shreds, on the terms in the next row. Does **not** reach noncurrent versions / replicas / PITR snapshots — deleting an object is weaker than removing a key. | retiring a dated bucket; rolling-window retention |
65
+ | **Crypto-shred** | `destroySegment` / `eraseNamespace` | *At rest, complete once no copy of the wrapped key survives* — removes the segment's wrapped DEK(s) from its registry row in one compare-and-swap, without touching the bytes or any KEK. **Every** copy of the objects (current, prior generations, backups, WORM-locked objects) is then unreadable once no retained copy of that row still holds the wrapped key — noncurrent object versions, backups and PITR copies of the row included — or once every KEK that wrapped it is destroyed. Until then, a retained copy of the row and a KEK that wrapped it decrypt the segment, and a registry restore to a point before the shred makes it readable again; see *Erasure vs. backups / WORM* below, and [an optional registry expiry rule](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/disaster-recovery.md#optional-make-a-shred-durable-with-a-registry-expiry-rule) that makes a shred durable after a set number of days. Requires the segment to be encrypted. A process that had already opened the segment holds the **unwrapped** key in memory and keeps reading until it is told — call `store.invalidate(ref)` there; see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). | whole-segment / tenant offboarding; erasure under immutable backups (see below) |
63
66
 
64
67
  **Subject-wide erasure** (GDPR Art. 17 — "forget this person everywhere") is
65
- `store.eraseSubject(id, { owner, namespace })`:
66
- it removes the id from every **registered** segment it's in *and* force-compacts those segments on the spot, so
67
- the bit is physically gone from Cold on return — even for idle/archival segments organic compaction would never
68
- touch. It reuses the store's own drivers (so build the store with a raw cold driver + a registry). It returns an
69
- **erasure ledger** (per-segment: removed / physically-purged / which generation retired the bit) as your proof
70
- of deletion; persist it or route it to your audit sink. `store.subjectReport(id, { namespace })` answers the read side
71
- (Art. 15 — which segments an id is in). Run erasure without concurrently re-adding the same id; any ledger entry with
72
- `physicallyPurged:false` (a held compaction lease, or an isolated fault) needs a follow-up `store.compact(ref)` —
73
- re-running `eraseSubject` won't re-purge it.
68
+ `store.eraseSubject(id, { namespace })` — or `{ allNamespaces: true }` to sweep every tenant deliberately. For
69
+ every **registered** segment the id is in, it rewrites the current generation without the id, publishes the
70
+ rewrite, and deletes every generation that held the bit — the one it replaced, a retained superseded one, and
71
+ any above the pointer that a rollback left — so the bit is physically gone from the bucket on return, for idle
72
+ and archival segments as much as busy ones. It checks rather than assumes: before an entry says `erased: true`,
73
+ the call lists the segment's generations and reads each one it has not already seen without the id. There is no logical-then-physical gap and no
74
+ scheduled step to wait for: an erasure *is* a new generation, the same shape as every other write in the library.
75
+ It reuses the store's own drivers (so build the store with a backend). It returns an
76
+ **erasure ledger** — one entry per segment the id was found in, `{ segment, namespace?, erased, fromGeneration,
77
+ generation, note? }` — as your proof of deletion; persist it or route it to your audit sink, which also receives
78
+ one `segment.rewrite { fromGeneration, generation }` event per rewrite when you pass `audit` — an id found only
79
+ outside the current generation (a retained *superseded* one, or one above the pointer after a rollback) is
80
+ collected rather than rewritten, so it emits no event and its ledger entry carries no `generation`. Segments the id is
81
+ not in are not listed. `store.subjectReport(id, { namespace })` answers the read side (Art. 15 — which segments
82
+ an id is in).
83
+
84
+ Two rules. **Do not load a segment while erasing from it**: a load that lands after the rewrite carries whatever
85
+ its source held, and the library cannot know that source was meant to exclude the id — fix the source first, or
86
+ quiesce loads of the affected segments for the duration. A writer that lands *during* the rewrite is caught and
87
+ the entry says `erased: false, note: 'superseded'` — because the publish was refused **by that fence**, because
88
+ a racing **erasure** (which collects with `keep: 0`) deleted the generation the rewrite was still streaming, or
89
+ because an operator's `rollback` moved the pointer while the call was deleting generations above it. Two
90
+ erasures of different ids racing on one segment are safe: the loser's rewrite was derived from the generation the
91
+ winner replaced, so it still holds the winner's id, and when it sits above the winner's pointer the loser deletes
92
+ it before returning. **Read the ledger**: an `erased: false` entry means *this run* did not erase the id from that segment,
93
+ which is not the same as the id still being present — if the racing writer was another erasure of the same id, it
94
+ is already gone. For `'superseded'` re-run `eraseSubject`: it is idempotent, it erases the id if the id is still
95
+ there, and a segment the id is no longer in is simply not listed — though "not listed" alone does not prove the
96
+ id is gone, since a segment whose **registry row** has been purged is not scanned either and its objects outlive
97
+ it as orphans (`store.checkConsistency()` does not report them: it visits registry rows only). Because a settled segment drops out of the
98
+ ledger entirely, **the attestation for a subject is the ledger of the run that reported `erased: true`** — if you
99
+ must hold one artifact per request, re-run until no entry carries a `'superseded'` note, and keep that run's
100
+ ledger alongside any earlier one. An `error: …` note is an isolated per-segment fault, and it is reported rather
101
+ than swallowed because it can be the signal that a *published* rewrite's physical half did not complete **on that
102
+ call**. If it occurred *after* the rewrite was published — a Storage `delete` fault, a collection pass that
103
+ could not prove the segment was still the same one, or a generation still holding the id when the bucket is listed at the end, such as one an operator
104
+ rolled the pointer onto while the rewrite was collecting — then the pointer has already moved, and the re-run
105
+ searches every generation in the bucket, not only the current one. A transient fault on the publish itself is an `error: …` note too, and there the rewrite may or
106
+ may not have become current: the re-run settles it either way, rewriting the id out if the pointer did not move and
107
+ finding it in the generation the rewrite replaced if it did. (The collection-side errors just listed — a Storage `delete` fault, a pass that could not prove the segment was the
108
+ same one, a generation still holding the id — can also come from the collect-only path, where the bit was found
109
+ only outside the current generation and nothing was published; there the pointer has not moved, and a re-run
110
+ simply repeats the attempt.) **Read what the re-run says.** Usually it reports `erased: true` against the
111
+ generation it found the id in, which is the receipt the failed call could not give you. If a racing collector took
112
+ that generation first it reports nothing for the segment: the bit is gone, but no run holds a receipt for it, so
113
+ keep the failed call's error alongside your ledger. And if the segment's registry row has since been purged, it is
114
+ not a segment any more and `eraseSubject` does not scan it at all — anything left in its bucket is an **orphan**,
115
+ which neither `store.checkConsistency()` (it visits registry rows only) nor the collection a load and the retention
116
+ sweep run (it collects nothing without a row) reaches. List those objects with `store.generations(ref)` and delete them with
117
+ `store.dropSegment(ref, { confirmSegment: ref.segment })`, which leaves a tombstone row fencing the name. An empty
118
+ ledger is not by itself proof the id is gone.
119
+
120
+ **Do not roll a segment back while erasing from it.** A rollback that lands mid-erasure can move the pointer onto a
121
+ generation the erasure did not rewrite, so the id can be current again. Roll back before the erasure starts or
122
+ after it returns, and re-run any erasure a rollback interrupted.
123
+
124
+ ### One process, and the rest of your fleet
125
+
126
+ Erasure and crypto-shred are **immediate in storage and immediate in the store whose verb performed them**. They
127
+ are not immediate in any *other* store, in the same process or another, and this library ships nothing that could
128
+ make them so — there is no daemon, no bus, and no connection between two stores that happen to point at the same
129
+ bucket.
130
+
131
+ | | when the id stops being readable |
132
+ |---|---|
133
+ | storage | on return — the generation holding it is deleted, or the wrapped DEK is removed from the segment's current registry row (a crypto-shred is complete once no retained copy of that row holds it, or every KEK that wrapped it is destroyed; see *Erasure vs. backups / WORM* below) |
134
+ | the store whose verb made the call (`eraseSubject`, `dropSegment`, `retireExpired`) | on return — it invalidates what it cached, and its pins then fail |
135
+ | another store, with a registry and a `cache.genTtlMs` above 0 | within `cache.genTtlMs` (default 2 s), when its snapshot re-resolves, **while the registry can be read** (see below) |
136
+ | another store with **no registry** (a bare `IStorageDriver`), with `cache: { genTtlMs: 0 }`, or on a storage source built with **no clock** | **no bound** — only when its caches happen to let the segment go, or something tells it |
137
+ | a pinned handle (`seg.pin()`) in another store | **no bound** — until that store's reader cache evicts the pin's reader and its chunk cache evicts the chunks the pin decoded, or something tells it |
138
+
139
+ **An outage of the registry extends that bound.** A refresh that cannot read the row because of a transient fault
140
+ (throttling, a 5xx, a dropped connection) keeps serving the generation the reader already holds, and the key it
141
+ unwrapped, and asks again 500 ms later, or after `cache.genTtlMs` if that is shorter. So while the registry cannot
142
+ be read, a shred or a drop is not seen, and the store converges within one retry of the registry answering. Any
143
+ other failure of the refresh (an access denial, a row that will not parse) is not ridden out: the read that finds it
144
+ fails with that error, and the reader is dropped with the key it held.
145
+
146
+ `destroySegment` and `eraseNamespace` are free functions over raw drivers, not verbs of a store, so for them every
147
+ store is another store, one in the same process included.
148
+
149
+ The last two rows are the ones to design around. `cache: { genTtlMs: 0 }` turns the timed refresh off, and is a
150
+ legitimate setting for a read-only replica of immutable data — but a store set that way has no bound on when it
151
+ observes a shred: until its reader cache lets the segment go, it keeps decrypting with the key it already unwrapped.
152
+ A pinned handle is that case in any store, clock or none, and holds on longer: its reader keeps the key until the
153
+ reader cache evicts it, and after that the pin still answers `has()` for the ids in chunks it decoded, until the
154
+ chunk cache drops them. If a
155
+ compliance deadline depends on every reader converging, fan the reference out to your fleet and have each
156
+ process call `store.invalidate(ref)`; that is the hook, and delivering it is yours because the transport is
157
+ yours.
158
+
159
+ A read served from a stale cache is bounded by the same window and answers from memory, so nothing on the
160
+ storage side — a bucket policy, a lifecycle rule, the object's own deletion — can shorten it.
74
161
 
75
162
  **Your exit path** (and a building block for a **data-portability / Art. 20** response): `store.exportSegments(sink,
76
- { format })` (and the `export-segments` CLI) dumps every registered segment's effective set to a portable file —
77
- `roaring` (loadable by any roaring library) or `ndjson` (newline ids) — readable without CloudBitmaps. It's a
78
- **controller-side bulk dump** (all segments, opaque ids), not a per-subject deliverable — the per-subject rights
79
- are `subjectReport` (Art. 15) / `eraseSubject` (Art. 17). Encrypted segments are decrypted transparently if the
80
- store has the keystore, so the **export is cleartext — protect it** (the CLI writes owner-only files; also encrypt
81
- the dump at rest, restrict access, and delete it when done).
82
-
83
- **Honest limits.** Logical `remove()` is *not* physical deletion on its own — you need compaction (which
84
- `eraseSubject` forces, or a scheduled daemon), or crypto-shred. **Per-subject crypto-shred is infeasible** (a
85
- subject's bit is co-mingled with millions of others in one shared container), so single-subject erasure goes
86
- through remove + compaction (`eraseSubject`), while crypto-shred (`destroySegment`/`eraseNamespace`) cleanly
87
- handles segment/tenant-level erasure and is the only erasure that survives immutable backups / WORM.
88
-
89
- **Erasure vs. backups / WORM (the trap).** If you enable S3 versioning, Object Lock, or DynamoDB PITR for
90
- durability, a `remove()`+compaction *does not* reach the retained copies — the deleted bit survives in
91
- noncurrent versions, locked objects, and backups. **Crypto-shred is the only erasure that survives all of
92
- them**, because it destroys the key, not the bytes. So: use **per-segment/tenant encryption** as your erasure
93
- posture under immutable storage; reserve S3 Object Lock **COMPLIANCE** mode for data under a genuine legal
163
+ { format })` (and the `export-segments` CLI) dumps every registered segment's current generation to a portable
164
+ file — `roaring` (loadable by any roaring library) or `ndjson` (newline ids) — readable without CloudBitmaps.
165
+ It's a **controller-side bulk dump** (all segments, opaque ids), not a per-subject deliverable — the per-subject
166
+ rights are `subjectReport` (Art. 15) / `eraseSubject` (Art. 17). Encrypted segments are decrypted transparently
167
+ if the store has the keystore, so the **export is cleartext — protect it** (the CLI writes owner-only files;
168
+ also encrypt the dump at rest, restrict access, and delete it when done).
169
+
170
+ **Honest limits.** Subject erasure deletes the object that held the bit; it does not reach the copies of that
171
+ object your storage keeps on its own — noncurrent versions (S3 versioning), cross-region replicas, backups and
172
+ WORM-locked copies hold the old generation until their own lifecycle expires it. **Per-subject crypto-shred is
173
+ infeasible** (one DEK covers the whole segment, and a subject's bit is co-mingled with millions of others), so
174
+ single-subject erasure is a rewrite (`eraseSubject`), while crypto-shred (`destroySegment` / `eraseNamespace`)
175
+ handles segment/tenant-level erasure and is the only erasure that reaches immutable backups / WORM copies of the
176
+ objects — once no copy of the segment's registry row still holds its wrapped key, or every KEK that wrapped it
177
+ is destroyed (below). And a
178
+ **materialized segment** (`intersectInto` / `unionInto` / `andNotInto`) is a point-in-time snapshot of its
179
+ inputs: erasing a subject from a source does not touch a destination computed earlier — which is exactly why
180
+ `eraseSubject` scans *every* registered segment, destinations included, rather than erasing per source. What that
181
+ costs is one unit of the per-op `budget` for each segment, and one more for each generation it opens in a segment
182
+ whose current generation lacks the id (it searches every generation left in the bucket, since a retained one can
183
+ still hold the bit). A segment that runs the budget out is listed `erased: false` with an `error:` note, with
184
+ nothing of it deleted before the refusal (a segment that holds the id is rewritten first, and the last check
185
+ after it can be the one refused, which the same note reports); re-run with a higher `budget`.
186
+
187
+ **Erasure vs. backups / WORM (the trap).** If you enable S3 versioning, Object Lock, or registry PITR for
188
+ durability, a subject-erasure rewrite *does not* reach the retained copies — the deleted bit survives in
189
+ noncurrent versions, locked objects, and backups, and a restore that brings one of them back brings the erased id
190
+ back with it (the [disaster-recovery guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/disaster-recovery.md#restore-procedure) re-runs the
191
+ erasures made after the restore point). **Crypto-shred reaches the retained copies of the objects without touching
192
+ them**, because it removes the key, not the bytes — but it removes the key from one place: the segment's current
193
+ registry row. The same versioning, PITR and backups keep earlier copies of that row, and every copy written since
194
+ the segment's first encrypted load still holds the wrapped key. **A shred is complete once no retained copy of the
195
+ registry row still holds the wrapped key** (noncurrent object versions, backups and PITR copies included), **or
196
+ once every KEK that wrapped it is destroyed.** Until then, a retained copy of the row plus a KEK that wrapped it
197
+ decrypts the segment, and a registry restore to a point before the shred makes it readable again, which is why the
198
+ disaster-recovery guide re-shreds after a restore. Versioning on the registry prefix is a requirement of that
199
+ guide's restore procedure, so with it on, when a shred completes is set by how long your storage keeps the row's
200
+ noncurrent versions and backups — the same history a registry restore picks from — and an Object Lock default
201
+ retention on a bucket that also holds the registry locks those versions for that retention period. Destroying a
202
+ KEK happens in your keystore, not through the library, and a segment is shredded that way only once every KEK in
203
+ its wrappings is destroyed: with `InProcessKeystore`, the KEK that was active when the segment was first loaded
204
+ and, if one was configured, the recovery KEK, which wraps every segment's DEK a second time. Destroying those
205
+ shreds every segment they wrapped. So: use **per-segment/tenant encryption** as your erasure posture under
206
+ immutable objects, and count the registry row's retained copies in when you state that a shred is complete; give
207
+ noncurrent versions of the objects a short expiry (a lifecycle rule on *noncurrent* versions is fine — it is
208
+ *current* generations that a rule must never expire, see below — though it also bounds how far back the
209
+ disaster-recovery guide can restore); reserve S3 Object Lock **COMPLIANCE** mode for data under a genuine legal
94
210
  hold (it *cannot* be deleted before its retention date, by anyone — incompatible with on-demand erasure), and
95
211
  prefer **GOVERNANCE** mode where erasure must remain possible.
96
212
 
97
213
  ## Retention & data minimization
98
214
 
99
- The library does not age data out for you — segments grow until you prune them, and "cheap to keep forever" is
100
- a storage-limitation anti-pattern if the data is personal. **Retention policy is yours.** Practical patterns:
101
-
102
- - **Rolling windows** (e.g. "active this week"): keep N daily sub-segments, union them for reads, and drop the
103
- oldest wholesale — turning retention into cheap whole-segment deletion (an object delete or crypto-shred),
104
- not per-bit aging.
105
- - **Scheduled compaction** enforces any tombstones you've written for aged-out members within your window.
106
- - Surface segment age/size via the **metrics sink** so unbounded growth is visible, not silent.
215
+ The library will age a **segment** out for you once you tell it when to — but it will not schedule that for you,
216
+ and it will never age out an individual **id** (a bitmap stores ids, not timestamps). "Cheap to keep forever" is a
217
+ storage-limitation anti-pattern if the data is personal, so: **the policy is yours to set, the heartbeat is yours
218
+ to run, and the deletion is ours to perform correctly.** Practical patterns:
219
+
220
+ - **A per-segment expiry** — `store.setRetention(ref, { expiresAt })` records when a segment becomes eligible for
221
+ retirement, and `store.retireExpired()` retires every expired segment (through `dropSegment`, so the ordering
222
+ and the storage reclamation below are inherited rather than reimplemented). The expiry is an absolute instant
223
+ the writer sets; the sweep is a call **you** schedule (EventBridge, a CronJob, cron, a queue job), because this
224
+ library starts no background timer. Bounded per cycle, previewable with `dryRun`, and it returns a per-segment
225
+ ledger you should inspect — an entry can say a segment's storage was not fully reclaimed.
226
+ - **Rolling windows** (e.g. "active this week"): keep N daily sub-segments, union them for reads, and let the
227
+ policy above retire the oldest wholesale — turning retention into cheap whole-segment disposal, not per-bit aging.
228
+ - **A reload is the per-id retention.** Since a segment's content is whatever its last load said, ageing members
229
+ out *within* a segment is a matter of loading the next generation from a source that no longer includes them.
230
+ The registry row's `retention` is untouched by a load, an `*Into` materialization or an erasure rewrite, so the
231
+ `expiresAt` you set stays put across all of them.
232
+ - Surface segment age and size so unbounded growth is visible, not silent. The **metrics sink** carries neither (its `storage.get` events count the bytes of each read, not a segment's size).
233
+ `seg.count()` gives a segment's cardinality; `seg.costReport()` prices its current generation's measured size
234
+ (`monthlyUSD.byOp.storage`, $0 when the storage source cannot measure it), and does not count superseded
235
+ generations still in the bucket, which `store.generations(ref)` lists; and its registry row carries `createdAt`
236
+ and `updatedAt`, which the backend's `registry.list()` returns for every row.
237
+
238
+ **Be precise about what "drop the oldest" involves**, because the three levers differ in what they guarantee:
239
+
240
+ - **`store.dropSegment(ref, { confirmSegment })` is the retention primitive**, and `setRetention` +
241
+ `retireExpired` is the policy-driven form that calls it — and its result must be inspected, not
242
+ assumed: `generationsRemaining` non-empty means bytes survived and the call should be repeated. It tombstones
243
+ the segment and deletes **every Storage generation** — so the space is actually reclaimed. It works on a
244
+ cleartext segment, and on an encrypted one it *also* crypto-shreds, making it a strict superset there.
245
+ - **`destroySegment` crypto-shreds** — it removes the wrapped key from the registry row, so the Storage bytes
246
+ become unreadable *everywhere including backups, replicas and WORM-locked copies* once no retained copy of that
247
+ row still holds the key, or every KEK that wrapped it is destroyed (see the trap above), which no object
248
+ deletion can achieve. But **the objects
249
+ remain in your bucket** and you keep paying for them, and it **requires encryption at rest** (a cleartext
250
+ segment has no key to discard; `allowCleartext` writes the tombstone while leaving the Storage bytes readable — and
251
+ still in the bucket).
252
+ - **The collection a `load` runs** (its `keep` window; an `*Into` runs it when given `keep`) deletes only
253
+ *superseded* generations, never the current one — with `keep: 0` it is what a subject erasure runs to remove the
254
+ generations below the pointer that held the bit. A holder *above* the pointer
255
+ is outside its range, so the erasure deletes that one itself.
256
+
257
+ So the two are complements, not alternatives: **`dropSegment` for "stop paying for it", `destroySegment` for
258
+ "it must be unreadable even in backups"** — and on an encrypted segment `dropSegment` gives you both at once.
259
+
260
+ > ⚠️ **Do not use an object-store lifecycle rule as your retention mechanism.** It deletes the bytes while the
261
+ > registry still points at them, which is the `missing-storage-generation` torn state — and it surfaces
262
+ > *intermittently*, because a read consults the in-process cache before Storage, so it passes testing on a warm
263
+ > process and starts failing after a restart. A lifecycle rule is a fine **backstop** for orphans left by a
264
+ > failed `dropSegment`, and a fine way to expire *noncurrent* object versions; give it a window comfortably
265
+ > longer than your retention and never let it touch a current generation.
266
+
267
+ > ⚠️ **Never let a lifecycle rule expire a registry row's current version or its tombstone** — a separate trap,
268
+ > and a worse one. Deleting a registry row does not remove it: it writes a **tombstone** carrying a counter that only
269
+ > ever advances, which is what makes the row's token unique for all time. Expire those tombstones and a segment
270
+ > re-created under the same name starts the counter again and re-issues a token that was already used. That
271
+ > token is the segment's **identity**: it is what a cached reader, a fenced publish and a generation-collection
272
+ > pass each compare to decide whether two observations describe the same segment. Re-issue one and they can all
273
+ > answer "yes" about a segment that no longer exists — serving a deleted incarnation's data, or collecting a
274
+ > live one's objects. Storage for a tombstone is a few bytes per retired segment; treat it as permanent. A rule
275
+ > on *noncurrent* versions of the registry prefix touches neither the current row nor its tombstone; what it
276
+ > shortens is the history a registry restore picks from, and the time a crypto-shred takes to complete (above).
277
+
278
+ Full detail, including the dated-bucket pattern and the pitfalls, is in the retention section of the
279
+ [retention guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/retention.md).
107
280
 
108
281
  ## Legal hold
109
282
 
110
283
  A litigation/regulatory hold *forbids* deletion — the opposite of erasure — and can apply to the same segment.
111
284
  **Enforce a hold with S3 Object Lock**, which is the real, tamper-proof mechanism: in **COMPLIANCE** mode a
112
- locked Cold object *cannot* be deleted before its retention date by anyone (not even the account root); in
285
+ locked Storage object *cannot* be deleted before its retention date by anyone (not even the account root); in
113
286
  **GOVERNANCE** mode a privileged role can override. To place a hold on a segment:
114
287
 
115
- 1. Enable **Object Lock** on the segment's Cold `.crbm` objects (and enable versioning) for the hold period.
116
- 2. **Exclude the segment from your compaction and erasure runs** — don't call `eraseSubject`, `destroySegment`,
117
- or the compaction daemon against held segments, so the current generation (and its members) is preserved.
288
+ 1. Enable **Object Lock** on the segment's Storage `.crbm` objects (and enable versioning) for the hold period.
289
+ 2. **Exclude the segment from your erasure runs and your loads** — don't call `eraseSubject` in a scope that
290
+ reaches it, `destroySegment`, **`dropSegment`**, or load a new generation onto a held segment, so the current
291
+ generation (and its members) is preserved. Because `eraseSubject` scans a whole namespace, the practical
292
+ shape is to keep held segments in a namespace of their own. **`dropSegment` matters most here**: it is the only
293
+ call that deletes the bytes a hold exists to protect. In COMPLIANCE mode Object Lock will refuse the delete,
294
+ so the drop reports the generation as not deleted rather than silently succeeding — but the tombstone is
295
+ still written, so the segment reads as empty while the locked bytes remain. **Do not rely on Object Lock to be
296
+ the guard**, and note how exclusion works today: `retireExpired` has **no exclusion predicate**, so a held
297
+ segment must either *never carry a retention policy*, or have it removed with `clearRetention` for the
298
+ duration of the hold and re-set afterwards. Recording the hold itself is free — any key in the row's
299
+ `retention` metadata other than `expiresAt` and `retiredBySweepAt` (the stamp the sweep puts on its own
300
+ retirements, which marks a tombstone as one it may purge) is yours and survives both `setRetention` and
301
+ `clearRetention`, so `{ legalHold: 'case-1234' }` is a durable marker; it is simply not yet something the
302
+ sweep reads. `setRetention` writes only `expiresAt`, so write the marker yourself, with `compareAndSwap` on the
303
+ backend's `registry`. (An `exclude` predicate is under consideration; say so on an issue if you need it.)
118
304
  3. Decide hold-vs-erasure precedence when both apply to the same subject — that is a **legal determination**;
119
305
  under a hold, erasure is suspended.
120
306
 
121
307
  CloudBitmaps deliberately does **not** ship a native `legalHold` flag: Object Lock is a stronger guarantee than
122
- an in-library flag (which our own daemon could respect but a direct caller could bypass), so a flag would be
123
- advisory where Object Lock is enforced at rest. If a real deployment needs a library-managed hold that the
124
- daemon refuses to purge, it's a clean fast-follow — but the enforced posture is Object Lock + operational
125
- exclusion, documented here (P10).
308
+ an in-library flag (which our own erasure and sweep could respect but a direct caller could bypass), so a flag
309
+ would be advisory where Object Lock is enforced at rest. If a real deployment needs a library-managed hold that
310
+ `eraseSubject` and `retireExpired` refuse to touch, it's a clean fast-follow — but the enforced posture is
311
+ Object Lock + operational exclusion, documented in this section.
126
312
 
127
313
  ## Audit & accountability (GDPR Art. 30 / Art. 5(2))
128
314
 
129
315
  Wire the **audit sink** (`IAuditSink`) to get an append-only, vendor-neutral record of the compliance-relevant
130
- state changes — `segment.publish`, `segment.compact`, `segment.erase` (a genuine crypto-shred), and
131
- `namespace.erase` — for your audit log / SIEM. It is off by default and exception-safe. See the
132
- [dashboards guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/dashboards.md). An erasure *ledger* — per-subject-request proof of physical
133
- deletion — is returned by **`eraseSubject`** (shipped in Phase 6b). `subjectReport` is the read side and
134
- returns only which segments an id is in; it performs no deletion and issues no ledger.
316
+ state changes — `segment.publish` (a loaded generation became current), `segment.load-refused` (a load that did
317
+ not publish, because its guard refused it or another writer got there first), `segment.rollback` (an operator moved
318
+ the pointer to a generation it named), `segment.rewrite` (a subject-erasure
319
+ rewrite: `fromGeneration` → `generation`), `segment.erase` (a genuine crypto-shred), `segment.dispose` (a
320
+ `dropSegment`, including every retirement the sweep performs) and `namespace.erase` — for your audit log / SIEM.
321
+ It is off by default and exception-safe. See the [dashboards guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/dashboards.md), which also says
322
+ which event is the receipt for which claim. An erasure *ledger* — per-subject-request proof of physical deletion
323
+ — is returned by **`eraseSubject`**. `subjectReport` is the read side and returns only which segments an id is
324
+ in; it performs no deletion and issues no ledger.
135
325
 
136
326
  **Log hygiene:** `segment` / `namespace` are *your* strings and may encode sensitive purpose; IDs are personal
137
327
  data. The library never logs bitmap contents or raw IDs, but **you** should treat segment names and IDs as PII
138
328
  in your own logs, error reporting, and metric/trace tags — hash or redact them, and prefer opaque/coded
139
- segment names for sensitive segments (keeping the human label in your own classified registry).
329
+ segment names for sensitive segments (keeping the human label in your own classified registry). The name rules are deliberately permissive — any non-empty string — so a name can carry an email
330
+ address, free text, or anything else an upstream system hands you. That makes this advice stronger rather than
331
+ weaker: the library will store what you name, and will not sanitise it for you.
140
332
 
141
333
  ## Shared-responsibility matrix
142
334
 
@@ -144,12 +336,12 @@ segment names for sensitive segments (keeping the human label in your own classi
144
336
  |---|---|---|
145
337
  | **Controller/processor role** | an embedded library; sends nothing to us | be the controller/processor; run your own DPAs with *your* cloud providers |
146
338
  | **Encryption at rest** | AES-256-GCM envelope encryption, BYOK keystore (`InProcessKeystore`), per-segment DEK + active/recovery KEK | hold and protect your keys (KMS/HSM); enable encryption for sensitive segments |
147
- | **Erasure** | `remove` (logical), compaction (physical), `destroySegment`/`eraseNamespace` (crypto-shred) | run compaction within your SLA; choose crypto-shred under WORM/backups; classify what needs erasing |
148
- | **Residency** | region-agnostic drivers; you choose every location | wire region-correct drivers; run compute in-region; assess transfers |
339
+ | **Erasure** | `eraseSubject` (per-id rewrite, physical on return), `dropSegment` (dispose), `destroySegment`/`eraseNamespace` (crypto-shred) | read the erasure ledger and re-run on `erased: false`; don't load a segment while erasing from it; choose crypto-shred under WORM/backups, and count the registry row's retained copies in when a shred is complete; re-apply erasures after a restore; classify what needs erasing |
340
+ | **Residency** | region-agnostic drivers; you choose every location | wire region-correct drivers; run loaders and compute in-region; assess transfers |
149
341
  | **Classification** | opaque handling; a place to keep sensitive segments encrypted + audited | classify your segments (the library can't infer sensitivity) |
150
- | **Retention** | scheduled compaction; rolling-window pattern | set and enforce retention windows |
151
- | **Legal hold** | Object Lock guidance (the enforced mechanism; no in-library flag) | place holds via Object Lock; exclude held segments from compaction/erasure; decide precedence |
152
- | **Accountability** | `IAuditSink` (publish/compact/erase events) | route it to durable, append-only storage; keep your Art. 30 record |
342
+ | **Retention** | per-segment `expiresAt` + `retireExpired` (bounded, previewable, ledgered); rolling-window pattern | set the policy per segment; **schedule the sweep yourself** and alarm on its ledger |
343
+ | **Legal hold** | Object Lock guidance (the enforced mechanism; no in-library flag, and no sweep-level exclusion — see above) | place holds via Object Lock; keep a held segment out of the retention sweep by not giving it a policy, and out of `eraseSubject`'s scope; decide precedence |
344
+ | **Accountability** | `IAuditSink` (publish / rewrite / erase / dispose events) | route it to durable, append-only storage; keep your Art. 30 record |
153
345
  | **Telemetry** | **none** — no phone-home | (nothing — there is nothing to disable) |
154
346
 
155
347
  ## DPIA skeleton (for deployments with sensitive segments)
@@ -159,13 +351,13 @@ A minimal Data-Protection-Impact-Assessment outline to adapt:
159
351
  1. **Processing description** — which segments, what each membership *means*, source of the IDs, volume.
160
352
  2. **Necessity & proportionality** — lawful basis per segment (esp. Art. 9 special-category); why membership
161
353
  is retained and for how long.
162
- 3. **Data flow & residency** — Warm/Cold regions, where compute (HOT cache, intersection) runs, cross-border
163
- transfers and their safeguards.
354
+ 3. **Data flow & residency** — Storage and registry regions, where compute (loaders, the cache, intersection)
355
+ runs, cross-border transfers and their safeguards.
164
356
  4. **Risks** — re-identification, sensitive inference (incl. *derived* segments from intersections — treat a
165
357
  `paying ∩ pregnant` result as at least as sensitive as its inputs, and note it's a point-in-time snapshot
166
358
  that does not auto-honour later erasures of source members), breach scope (incl. process RAM / dumps).
167
- 5. **Controls** — encryption + crypto-shred, erasure SLA (scheduled compaction), retention windows, audit
168
- sink, access control on keys and storage, redaction of names/IDs in logs.
359
+ 5. **Controls** — encryption + crypto-shred, subject erasure that is physical on return (`eraseSubject`),
360
+ retention windows, audit sink, access control on keys and storage, redaction of names/IDs in logs.
169
361
  6. **Residual risk & sign-off** — DPO review.
170
362
 
171
363
  ## Art. 30 record — mapping template
@@ -174,14 +366,14 @@ Map CloudBitmaps' processing onto the categories a record of processing needs:
174
366
 
175
367
  | Art. 30 field | CloudBitmaps mapping |
176
368
  |---|---|
177
- | Categories of processing | storage of set-membership; set intersection; compaction; caching |
369
+ | Categories of processing | storage of set-membership; set intersection (incl. materialized results); loading of generations; caching |
178
370
  | Categories of data subjects / data | your IDs' subjects; membership (possibly special-category) |
179
371
  | Recipients | none external to your infrastructure (no sub-processor) |
180
372
  | Transfers | any cross-region driver/compute topology *you* configure |
181
- | Retention | your per-segment policy (enforced via scheduled compaction / rolling windows) |
373
+ | Retention | your per-segment policy — `setRetention(ref, { expiresAt })`, enforced by a `retireExpired` sweep **you schedule** |
182
374
  | Security measures | AES-256-GCM at rest, crypto-shred erasure, audit sink, your access controls |
183
375
 
184
376
  ---
185
377
 
186
- **See also:** the [getting-started guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/getting-started.md) (§9 encryption/crypto-shred, §10
187
- metrics, §12 audit) and the [dashboards guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/dashboards.md).
378
+ **See also:** the [encryption](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/encryption.md), [observability](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/observability.md) (metrics and
379
+ audit) and [erasure](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/erasure.md) guides, and the [dashboards guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/dashboards.md).