dpdp-erasure-cli 1.0.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 (155) hide show
  1. package/.env.example +55 -0
  2. package/Dockerfile +33 -0
  3. package/compliance.worker.yaml +64 -0
  4. package/package.json +41 -0
  5. package/src/constants/index.ts +1 -0
  6. package/src/errors/fail.ts +110 -0
  7. package/src/errors/index.ts +4 -0
  8. package/src/errors/inferer.ts +166 -0
  9. package/src/errors/registry.ts +122 -0
  10. package/src/errors/types.ts +65 -0
  11. package/src/errors/worker.ts +161 -0
  12. package/src/index.ts +328 -0
  13. package/src/lib/crypto/digest.ts +22 -0
  14. package/src/lib/crypto/encoding.ts +78 -0
  15. package/src/lib/crypto/index.ts +2 -0
  16. package/src/lib/index.ts +1 -0
  17. package/src/modules/bootstrap/index.ts +2 -0
  18. package/src/modules/bootstrap/integrity.ts +38 -0
  19. package/src/modules/bootstrap/preflight.ts +296 -0
  20. package/src/modules/cli/check-integrity.ts +48 -0
  21. package/src/modules/cli/dry-run.ts +90 -0
  22. package/src/modules/cli/graph.ts +87 -0
  23. package/src/modules/cli/index.ts +184 -0
  24. package/src/modules/cli/init.ts +115 -0
  25. package/src/modules/cli/inspect.ts +86 -0
  26. package/src/modules/cli/introspector.ts +117 -0
  27. package/src/modules/cli/keygen.ts +38 -0
  28. package/src/modules/cli/scan.ts +126 -0
  29. package/src/modules/cli/sign.ts +50 -0
  30. package/src/modules/cli/ui.ts +61 -0
  31. package/src/modules/cli/verify-schema.ts +31 -0
  32. package/src/modules/cli/verify.ts +85 -0
  33. package/src/modules/config/compatibility.ts +271 -0
  34. package/src/modules/config/index.ts +4 -0
  35. package/src/modules/config/reader.ts +149 -0
  36. package/src/modules/config/signature.ts +69 -0
  37. package/src/modules/config/validation.ts +658 -0
  38. package/src/modules/crypto/aes.ts +158 -0
  39. package/src/modules/crypto/envelope.ts +48 -0
  40. package/src/modules/crypto/hmac.ts +60 -0
  41. package/src/modules/crypto/index.ts +3 -0
  42. package/src/modules/db/drift.ts +36 -0
  43. package/src/modules/db/graph.ts +203 -0
  44. package/src/modules/db/index.ts +4 -0
  45. package/src/modules/db/migrations.ts +254 -0
  46. package/src/modules/db/sql-debug.ts +61 -0
  47. package/src/modules/engine/blob/index.ts +3 -0
  48. package/src/modules/engine/blob/s3.ts +455 -0
  49. package/src/modules/engine/blob/store.ts +236 -0
  50. package/src/modules/engine/blob/types.ts +44 -0
  51. package/src/modules/engine/helpers/identity.ts +47 -0
  52. package/src/modules/engine/helpers/index.ts +4 -0
  53. package/src/modules/engine/helpers/outbox.ts +118 -0
  54. package/src/modules/engine/helpers/runtime.ts +115 -0
  55. package/src/modules/engine/helpers/types.ts +61 -0
  56. package/src/modules/engine/index.ts +6 -0
  57. package/src/modules/engine/notifier/config.ts +147 -0
  58. package/src/modules/engine/notifier/dispatcher.ts +300 -0
  59. package/src/modules/engine/notifier/index.ts +3 -0
  60. package/src/modules/engine/notifier/payload.ts +51 -0
  61. package/src/modules/engine/notifier/reservation.ts +153 -0
  62. package/src/modules/engine/notifier/types.ts +38 -0
  63. package/src/modules/engine/shredder.ts +254 -0
  64. package/src/modules/engine/types.ts +146 -0
  65. package/src/modules/engine/vault/compiled-targets.ts +562 -0
  66. package/src/modules/engine/vault/context.ts +254 -0
  67. package/src/modules/engine/vault/dry-run.ts +94 -0
  68. package/src/modules/engine/vault/execution.ts +485 -0
  69. package/src/modules/engine/vault/index.ts +3 -0
  70. package/src/modules/engine/vault/purge.ts +82 -0
  71. package/src/modules/engine/vault/retention.ts +124 -0
  72. package/src/modules/engine/vault/satellite-mutation.ts +193 -0
  73. package/src/modules/engine/vault/satellite.ts +103 -0
  74. package/src/modules/engine/vault/shadow.ts +36 -0
  75. package/src/modules/engine/vault/static-plan.ts +116 -0
  76. package/src/modules/engine/vault/store.ts +34 -0
  77. package/src/modules/engine/vault/vault.ts +84 -0
  78. package/src/modules/introspector/classifier.ts +502 -0
  79. package/src/modules/introspector/dag.ts +276 -0
  80. package/src/modules/introspector/index.ts +7 -0
  81. package/src/modules/introspector/naming.ts +75 -0
  82. package/src/modules/introspector/report.ts +153 -0
  83. package/src/modules/introspector/run.ts +123 -0
  84. package/src/modules/introspector/s3-sampler.ts +227 -0
  85. package/src/modules/introspector/types.ts +131 -0
  86. package/src/modules/introspector/yaml.ts +101 -0
  87. package/src/modules/network/api/control-plane.ts +275 -0
  88. package/src/modules/network/api/index.ts +1 -0
  89. package/src/modules/network/api/validation.ts +71 -0
  90. package/src/modules/network/index.ts +4 -0
  91. package/src/modules/network/object-store/aws/client.ts +444 -0
  92. package/src/modules/network/object-store/aws/credentials.ts +271 -0
  93. package/src/modules/network/object-store/aws/index.ts +2 -0
  94. package/src/modules/network/object-store/aws/sigv4.ts +190 -0
  95. package/src/modules/network/object-store/aws/type.ts +6 -0
  96. package/src/modules/network/object-store/index.ts +1 -0
  97. package/src/modules/network/outbox/dispatcher.ts +183 -0
  98. package/src/modules/network/outbox/index.ts +3 -0
  99. package/src/modules/network/outbox/process.ts +133 -0
  100. package/src/modules/network/outbox/shared.ts +56 -0
  101. package/src/modules/network/outbox/store.ts +346 -0
  102. package/src/modules/network/outbox/types.ts +54 -0
  103. package/src/modules/network/request-signing.ts +61 -0
  104. package/src/modules/worker/index.ts +2 -0
  105. package/src/modules/worker/tasks.ts +58 -0
  106. package/src/modules/worker/types.ts +89 -0
  107. package/src/modules/worker/worker.ts +243 -0
  108. package/src/secrets/index.ts +4 -0
  109. package/src/secrets/kms/index.ts +2 -0
  110. package/src/secrets/kms/signature.ts +82 -0
  111. package/src/secrets/kms/validation.ts +64 -0
  112. package/src/secrets/reader.ts +42 -0
  113. package/src/secrets/repository/crypto.ts +89 -0
  114. package/src/secrets/repository/index.ts +2 -0
  115. package/src/secrets/repository/methods.ts +37 -0
  116. package/src/secrets/resolvers.ts +247 -0
  117. package/src/secrets/signature.ts +78 -0
  118. package/src/types/index.ts +1 -0
  119. package/src/types/types.ts +23 -0
  120. package/src/utils/identifiers.ts +48 -0
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/json.ts +35 -0
  123. package/src/utils/logger.ts +161 -0
  124. package/src/validation/zod.ts +70 -0
  125. package/tests/adversarial.test.ts +464 -0
  126. package/tests/blob-s3.test.ts +216 -0
  127. package/tests/config.test.ts +395 -0
  128. package/tests/control-plane-client.test.ts +108 -0
  129. package/tests/crypto.test.ts +106 -0
  130. package/tests/errors.test.ts +69 -0
  131. package/tests/fetch-dispatcher.test.ts +213 -0
  132. package/tests/graph.test.ts +84 -0
  133. package/tests/helpers/index.ts +101 -0
  134. package/tests/index-preflight.test.ts +168 -0
  135. package/tests/introspector-classifier.test.ts +62 -0
  136. package/tests/introspector-report.test.ts +85 -0
  137. package/tests/introspector.test.ts +394 -0
  138. package/tests/kms.test.ts +124 -0
  139. package/tests/logger.test.ts +61 -0
  140. package/tests/notifier.test.ts +303 -0
  141. package/tests/outbox.test.ts +478 -0
  142. package/tests/purge-policy.test.ts +124 -0
  143. package/tests/retention.test.ts +103 -0
  144. package/tests/s3-client.test.ts +110 -0
  145. package/tests/satellite.test.ts +119 -0
  146. package/tests/schema-compatibility.test.ts +237 -0
  147. package/tests/schema-integrity.test.ts +64 -0
  148. package/tests/shredder.test.ts +163 -0
  149. package/tests/vault.compiled-targets.test.ts +243 -0
  150. package/tests/vault.replica.test.ts +59 -0
  151. package/tests/vault.test.ts +279 -0
  152. package/tests/worker.retry.test.ts +291 -0
  153. package/tests/worker.test.ts +200 -0
  154. package/tsconfig.json +19 -0
  155. package/vitest.config.ts +13 -0
@@ -0,0 +1,478 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import type { Sql } from "@/types";
3
+ import { workerError } from "@/errors";
4
+ import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
5
+ import { runMigrations } from "@modules/db";
6
+ import { processOutbox, type OutboxEvent } from "@modules/network";
7
+ import { enqueueOutboxEvent } from "@modules/engine";
8
+ import { calculateRetryDelayMs } from "@modules/network/outbox/shared";
9
+
10
+ describe("Network Outbox Relay", () => {
11
+ let sql: Sql;
12
+ const schemasToDrop: string[] = [];
13
+
14
+ beforeAll(() => {
15
+ sql = createTestSql();
16
+ });
17
+
18
+ afterAll(async () => {
19
+ await dropSchemas(sql, ...schemasToDrop);
20
+ await sql.end();
21
+ });
22
+
23
+ async function prepare() {
24
+ const engineSchema = uniqueSchema("outbox_engine");
25
+ schemasToDrop.push(engineSchema);
26
+ await dropSchemas(sql, engineSchema);
27
+ await runMigrations(sql, engineSchema);
28
+ return { engineSchema };
29
+ }
30
+
31
+ function toHex(buffer: ArrayBuffer): string {
32
+ return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
33
+ }
34
+
35
+ async function seedEvent(
36
+ engineSchema: string,
37
+ idempotencyKey: string,
38
+ userHash: string,
39
+ eventType: string,
40
+ nextAttemptAt: Date = new Date(),
41
+ createdAt: Date = new Date(),
42
+ previousHash: string = "GENESIS"
43
+ ) {
44
+ await sql`
45
+ INSERT INTO ${sql(engineSchema)}.outbox (
46
+ idempotency_key,
47
+ user_uuid_hash,
48
+ event_type,
49
+ payload,
50
+ previous_hash,
51
+ current_hash,
52
+ status,
53
+ attempt_count,
54
+ next_attempt_at,
55
+ created_at,
56
+ updated_at
57
+ )
58
+ VALUES (
59
+ ${idempotencyKey},
60
+ ${userHash},
61
+ ${eventType},
62
+ '{}'::jsonb,
63
+ ${previousHash},
64
+ ${`${idempotencyKey}-hash`},
65
+ 'pending',
66
+ 0,
67
+ ${nextAttemptAt},
68
+ ${createdAt},
69
+ ${createdAt}
70
+ )
71
+ `;
72
+ }
73
+
74
+ it("processes due events and marks them as processed", async () => {
75
+ const { engineSchema } = await prepare();
76
+ const baseTime = new Date("2026-04-15T00:00:00.000Z");
77
+ const laterTime = new Date("2026-04-15T00:00:01.000Z");
78
+ await seedEvent(engineSchema, "event-1", "user1", "TEST_EVENT", baseTime, baseTime);
79
+ await seedEvent(engineSchema, "event-2", "user2", "TEST_EVENT", baseTime, laterTime, "event-1-hash");
80
+
81
+ const result = await processOutbox(sql, async () => true, { engineSchema, batchSize: 10 });
82
+ expect(result).toEqual({
83
+ claimed: 2,
84
+ processed: 2,
85
+ failed: 0,
86
+ deadLettered: 0,
87
+ });
88
+
89
+ const rows = await sql`SELECT status, processed_at FROM ${sql(engineSchema)}.outbox ORDER BY idempotency_key ASC`;
90
+ expect(rows.every((row) => row.status === "processed")).toBe(true);
91
+ expect(rows.every((row) => row.processed_at !== null)).toBe(true);
92
+ });
93
+
94
+ it("preserves WORM order instead of prioritizing terminal events ahead of predecessors", async () => {
95
+ const { engineSchema } = await prepare();
96
+ const baseTime = new Date("2026-04-15T00:00:00.000Z");
97
+ const laterTime = new Date("2026-04-15T00:00:01.000Z");
98
+
99
+ await seedEvent(
100
+ engineSchema,
101
+ "vault-old",
102
+ "user-old",
103
+ "USER_VAULTED",
104
+ baseTime,
105
+ baseTime
106
+ );
107
+ await seedEvent(
108
+ engineSchema,
109
+ "shred-new",
110
+ "user-new",
111
+ "SHRED_SUCCESS",
112
+ baseTime,
113
+ laterTime,
114
+ "vault-old-hash"
115
+ );
116
+
117
+ const delivered: string[] = [];
118
+ const result = await processOutbox(
119
+ sql,
120
+ async (event) => {
121
+ delivered.push(event.idempotency_key);
122
+ return true;
123
+ },
124
+ { engineSchema, batchSize: 2, now: laterTime }
125
+ );
126
+
127
+ expect(result.processed).toBe(2);
128
+ expect(delivered).toEqual(["vault-old", "shred-new"]);
129
+ });
130
+
131
+ it("chains outbox events with tamper-evident hashes", async () => {
132
+ const { engineSchema } = await prepare();
133
+ const now = new Date("2026-04-15T00:00:00.000Z");
134
+ const next = new Date("2026-04-15T00:00:01.000Z");
135
+
136
+ await sql.begin((tx) =>
137
+ enqueueOutboxEvent(
138
+ tx,
139
+ engineSchema,
140
+ "user-hash-1",
141
+ "USER_VAULTED",
142
+ { rootId: "1", state: "vaulted" },
143
+ "vault:tenant:users:1",
144
+ now
145
+ )
146
+ );
147
+
148
+ await sql.begin((tx) =>
149
+ enqueueOutboxEvent(
150
+ tx,
151
+ engineSchema,
152
+ "user-hash-2",
153
+ "NOTIFICATION_SENT",
154
+ { rootId: "2", state: "notified" },
155
+ "notice:tenant:users:2",
156
+ next
157
+ )
158
+ );
159
+
160
+ await processOutbox(sql, async () => true, { engineSchema, batchSize: 10, now: next });
161
+
162
+ const expectedFirstBuffer = await globalThis.crypto.subtle.digest(
163
+ "SHA-256",
164
+ new TextEncoder().encode(
165
+ `GENESIS${JSON.stringify({ rootId: "1", state: "vaulted" })}vault:tenant:users:1`
166
+ )
167
+ );
168
+ const expectedFirstHash = toHex(expectedFirstBuffer);
169
+ const expectedSecondBuffer = await globalThis.crypto.subtle.digest(
170
+ "SHA-256",
171
+ new TextEncoder().encode(
172
+ `${expectedFirstHash}${JSON.stringify({ rootId: "2", state: "notified" })}notice:tenant:users:2`
173
+ )
174
+ );
175
+ const expectedSecondHash = toHex(expectedSecondBuffer);
176
+
177
+ const rows = await sql`
178
+ SELECT idempotency_key, previous_hash, current_hash, chain_status
179
+ FROM ${sql(engineSchema)}.outbox
180
+ ORDER BY created_at ASC, id ASC
181
+ `;
182
+
183
+ expect(rows).toEqual([
184
+ {
185
+ idempotency_key: "vault:tenant:users:1",
186
+ previous_hash: "GENESIS",
187
+ current_hash: expectedFirstHash,
188
+ chain_status: "finalized",
189
+ },
190
+ {
191
+ idempotency_key: "notice:tenant:users:2",
192
+ previous_hash: expectedFirstHash,
193
+ current_hash: expectedSecondHash,
194
+ chain_status: "finalized",
195
+ },
196
+ ]);
197
+ });
198
+
199
+ it("returns the existing finalized row on idempotent replay without mutating the chain", async () => {
200
+ const { engineSchema } = await prepare();
201
+ const now = new Date("2026-04-15T00:00:00.000Z");
202
+
203
+ const first = await sql.begin((tx) =>
204
+ enqueueOutboxEvent(
205
+ tx,
206
+ engineSchema,
207
+ "user-hash-1",
208
+ "USER_VAULTED",
209
+ { rootId: "1", state: "vaulted" },
210
+ "vault:tenant:users:1",
211
+ now
212
+ )
213
+ );
214
+
215
+ await processOutbox(sql, async () => true, { engineSchema, batchSize: 10, now });
216
+
217
+ const replay = await sql.begin((tx) =>
218
+ enqueueOutboxEvent(
219
+ tx,
220
+ engineSchema,
221
+ "user-hash-1",
222
+ "USER_VAULTED",
223
+ { rootId: "1", state: "mutated" },
224
+ "vault:tenant:users:1",
225
+ new Date("2026-04-15T00:00:30.000Z")
226
+ )
227
+ );
228
+
229
+ expect(replay.id).toBe(first.id);
230
+ expect(replay.previous_hash).toBe("GENESIS");
231
+ expect(replay.chain_status).toBe("finalized");
232
+
233
+ const rows = await sql`
234
+ SELECT payload, previous_hash, current_hash, chain_status
235
+ FROM ${sql(engineSchema)}.outbox
236
+ WHERE idempotency_key = 'vault:tenant:users:1'
237
+ `;
238
+
239
+ expect(rows).toHaveLength(1);
240
+ expect(rows[0]?.payload).toEqual({ rootId: "1", state: "vaulted" });
241
+ expect(rows[0]?.previous_hash).toBe("GENESIS");
242
+ expect(rows[0]?.current_hash).toBe(replay.current_hash);
243
+ expect(rows[0]?.chain_status).toBe("finalized");
244
+ });
245
+
246
+ it("produces identical hashes for semantically equivalent payloads with different key order", async () => {
247
+ const { engineSchema } = await prepare();
248
+ const now = new Date("2026-04-15T00:00:00.000Z");
249
+
250
+ const first = await sql.begin((tx) =>
251
+ enqueueOutboxEvent(
252
+ tx,
253
+ engineSchema,
254
+ "user-hash-1",
255
+ "USER_VAULTED",
256
+ { b: "second", a: "first", nested: { y: 2, x: 1 } },
257
+ "vault:tenant:users:ordered",
258
+ now
259
+ )
260
+ );
261
+ await processOutbox(sql, async () => true, { engineSchema, batchSize: 10, now });
262
+ const replay = await sql.begin((tx) =>
263
+ enqueueOutboxEvent(
264
+ tx,
265
+ engineSchema,
266
+ "user-hash-1",
267
+ "USER_VAULTED",
268
+ { nested: { x: 1, y: 2 }, a: "first", b: "second" },
269
+ "vault:tenant:users:ordered",
270
+ new Date("2026-04-15T00:00:30.000Z")
271
+ )
272
+ );
273
+
274
+ expect(replay.id).toBe(first.id);
275
+ expect(replay.chain_status).toBe("finalized");
276
+ });
277
+
278
+ it("requeues failed events with backoff and error context", async () => {
279
+ const { engineSchema } = await prepare();
280
+ const now = new Date("2026-04-15T00:00:00.000Z");
281
+ await seedEvent(engineSchema, "event-3", "user3", "FAIL_EVENT", now);
282
+
283
+ const result = await processOutbox(
284
+ sql,
285
+ async () => {
286
+ throw new Error("Network Error");
287
+ },
288
+ {
289
+ engineSchema,
290
+ batchSize: 10,
291
+ now,
292
+ baseBackoffMs: 500,
293
+ maxAttempts: 3,
294
+ }
295
+ );
296
+
297
+ expect(result).toEqual({
298
+ claimed: 1,
299
+ processed: 0,
300
+ failed: 1,
301
+ deadLettered: 0,
302
+ });
303
+
304
+ const [row] = await sql`
305
+ SELECT status, attempt_count, next_attempt_at, last_error
306
+ FROM ${sql(engineSchema)}.outbox
307
+ WHERE idempotency_key = 'event-3'
308
+ `;
309
+
310
+ expect(row?.status).toBe("pending");
311
+ expect(row?.attempt_count).toBe(1);
312
+ expect(row?.last_error).toContain("Network Error");
313
+ expect(new Date(row!.next_attempt_at).getTime()).toBe(now.getTime() + calculateRetryDelayMs(1, 500));
314
+ });
315
+
316
+ it("moves an event to dead_letter after the maximum retry count is reached", async () => {
317
+ const { engineSchema } = await prepare();
318
+ await seedEvent(engineSchema, "event-4", "user4", "FAIL_EVENT");
319
+
320
+ const result = await processOutbox(
321
+ sql,
322
+ async () => {
323
+ throw new Error("Permanent Failure");
324
+ },
325
+ {
326
+ engineSchema,
327
+ batchSize: 10,
328
+ maxAttempts: 1,
329
+ }
330
+ );
331
+
332
+ expect(result).toEqual({
333
+ claimed: 1,
334
+ processed: 0,
335
+ failed: 1,
336
+ deadLettered: 1,
337
+ });
338
+
339
+ const [row] = await sql`
340
+ SELECT status, attempt_count, last_error
341
+ FROM ${sql(engineSchema)}.outbox
342
+ WHERE idempotency_key = 'event-4'
343
+ `;
344
+
345
+ expect(row?.status).toBe("dead_letter");
346
+ expect(row?.attempt_count).toBe(1);
347
+ expect(row?.last_error).toContain("Permanent Failure");
348
+ });
349
+
350
+ it("releases the lease and rethrows fatal delivery errors without burning retry attempts", async () => {
351
+ const { engineSchema } = await prepare();
352
+ await seedEvent(engineSchema, "event-fatal", "user-fatal", "AUTH_FAIL");
353
+
354
+ await expect(
355
+ processOutbox(
356
+ sql,
357
+ async () => {
358
+ throw workerError({
359
+ code: "OUTBOX_AUTH_REJECTED",
360
+ title: "Control Plane authentication rejected outbox event",
361
+ detail: "Brain API responded with HTTP 401.",
362
+ category: "configuration",
363
+ retryable: false,
364
+ fatal: true,
365
+ });
366
+ },
367
+ {
368
+ engineSchema,
369
+ batchSize: 10,
370
+ }
371
+ )
372
+ ).rejects.toMatchObject({
373
+ code: "OUTBOX_AUTH_REJECTED",
374
+ fatal: true,
375
+ });
376
+
377
+ const [row] = await sql`
378
+ SELECT status, attempt_count, lease_token, lease_expires_at, last_error
379
+ FROM ${sql(engineSchema)}.outbox
380
+ WHERE idempotency_key = 'event-fatal'
381
+ `;
382
+
383
+ expect(row?.status).toBe("pending");
384
+ expect(row?.attempt_count).toBe(0);
385
+ expect(row?.lease_token).toBeNull();
386
+ expect(row?.lease_expires_at).toBeNull();
387
+ expect(row?.last_error).toContain("HTTP 401");
388
+ });
389
+
390
+ it("handles concurrent workers without duplicating event delivery", async () => {
391
+ const { engineSchema } = await prepare();
392
+ await enqueueOutboxEvent(sql, engineSchema, "user5", "CONCURRENT", {}, "event-5", new Date());
393
+ await enqueueOutboxEvent(sql, engineSchema, "user6", "CONCURRENT", {}, "event-6", new Date());
394
+ await enqueueOutboxEvent(sql, engineSchema, "user7", "CONCURRENT", {}, "event-7", new Date());
395
+
396
+ const deliveredIds = new Set<string>();
397
+
398
+ const syncFn = async (event: OutboxEvent) => {
399
+ await new Promise((resolve) => setTimeout(resolve, 50));
400
+ if (deliveredIds.has(event.id)) {
401
+ throw new Error(`Duplicate delivery for ${event.id}`);
402
+ }
403
+ deliveredIds.add(event.id);
404
+ return true;
405
+ };
406
+
407
+ let processed = 0;
408
+ for (let round = 0; round < 10 && processed < 3; round += 1) {
409
+ const results = await Promise.all([
410
+ processOutbox(sql, syncFn, { engineSchema, batchSize: 1 }),
411
+ processOutbox(sql, syncFn, { engineSchema, batchSize: 1 }),
412
+ processOutbox(sql, syncFn, { engineSchema, batchSize: 1 }),
413
+ ]);
414
+ processed += results.reduce((sum, result) => sum + result.processed, 0);
415
+ }
416
+
417
+ expect(processed).toBe(3);
418
+ expect(deliveredIds.size).toBe(3);
419
+
420
+ const forkedLinks = await sql`
421
+ SELECT previous_hash, COUNT(*)::INT AS count
422
+ FROM ${sql(engineSchema)}.outbox
423
+ WHERE status = 'processed'
424
+ GROUP BY previous_hash
425
+ HAVING COUNT(*) > 1
426
+ `;
427
+ expect(forkedLinks).toHaveLength(0);
428
+ });
429
+
430
+ it("reclaims an event whose lease has already expired", async () => {
431
+ const { engineSchema } = await prepare();
432
+
433
+ await sql`
434
+ INSERT INTO ${sql(engineSchema)}.outbox (
435
+ idempotency_key,
436
+ user_uuid_hash,
437
+ event_type,
438
+ payload,
439
+ previous_hash,
440
+ current_hash,
441
+ status,
442
+ attempt_count,
443
+ lease_token,
444
+ lease_expires_at,
445
+ next_attempt_at,
446
+ created_at,
447
+ updated_at
448
+ )
449
+ VALUES (
450
+ 'event-8',
451
+ 'user8',
452
+ 'LEASED_EVENT',
453
+ '{}'::jsonb,
454
+ 'GENESIS',
455
+ 'event-8-hash',
456
+ 'leased',
457
+ 2,
458
+ gen_random_uuid(),
459
+ NOW() - INTERVAL '10 minutes',
460
+ NOW() - INTERVAL '10 minutes',
461
+ NOW(),
462
+ NOW()
463
+ )
464
+ `;
465
+
466
+ const result = await processOutbox(sql, async () => true, { engineSchema, batchSize: 10 });
467
+ expect(result.processed).toBe(1);
468
+
469
+ const [row] = await sql`
470
+ SELECT status, processed_at
471
+ FROM ${sql(engineSchema)}.outbox
472
+ WHERE idempotency_key = 'event-8'
473
+ `;
474
+
475
+ expect(row?.status).toBe("processed");
476
+ expect(row?.processed_at).toBeDefined();
477
+ });
478
+ });
@@ -0,0 +1,124 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import { selectPurgeCandidates } from "@modules/engine/vault/purge";
3
+ import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
4
+ import type { Sql } from "@/types";
5
+
6
+ describe("DPO-attested purge policy", () => {
7
+ let sql: Sql;
8
+ const schemasToDrop: string[] = [];
9
+
10
+ beforeAll(() => {
11
+ sql = createTestSql();
12
+ });
13
+
14
+ afterAll(async () => {
15
+ await dropSchemas(sql, ...schemasToDrop);
16
+ await sql.end();
17
+ });
18
+
19
+ async function createUsers(schema: string): Promise<void> {
20
+ schemasToDrop.push(schema);
21
+ await dropSchemas(sql, schema);
22
+ await sql`CREATE SCHEMA ${sql(schema)}`;
23
+ await sql`
24
+ CREATE TABLE ${sql(schema)}.users (
25
+ id TEXT PRIMARY KEY,
26
+ purge_eligible BOOLEAN NOT NULL DEFAULT FALSE,
27
+ account_state TEXT NOT NULL DEFAULT 'active',
28
+ disabled_at TIMESTAMPTZ
29
+ )
30
+ `;
31
+ await sql`
32
+ INSERT INTO ${sql(schema)}.users (id, purge_eligible, account_state, disabled_at)
33
+ VALUES
34
+ ('usr_001', true, 'deleted', '2026-01-01T00:00:00.000Z'),
35
+ ('usr_002', false, 'active', NULL),
36
+ ('usr_003', true, 'deleted', '2026-01-02T00:00:00.000Z'),
37
+ ('usr_004', false, 'closed', '2026-04-01T00:00:00.000Z')
38
+ `;
39
+ }
40
+
41
+ it("selects purge candidates only through the configured boolean selector", async () => {
42
+ const schema = uniqueSchema("purge_bool");
43
+ await createUsers(schema);
44
+
45
+ await expect(
46
+ selectPurgeCandidates(sql, {
47
+ appSchema: schema,
48
+ rootTable: "users",
49
+ rootIdColumn: "id",
50
+ purgePolicy: {
51
+ enabled: true,
52
+ selector: {
53
+ kind: "boolean_column",
54
+ column: "purge_eligible",
55
+ value: true,
56
+ },
57
+ max_batch_size: 100,
58
+ actor_opaque_id: "system:purge",
59
+ legal_framework: "DPDP_2023",
60
+ },
61
+ })
62
+ ).resolves.toEqual(["usr_001", "usr_003"]);
63
+ });
64
+
65
+ it("supports enum and timestamp selectors without scanning unbounded rows in application code", async () => {
66
+ const schema = uniqueSchema("purge_selectors");
67
+ await createUsers(schema);
68
+
69
+ const enumCandidates = await selectPurgeCandidates(sql, {
70
+ appSchema: schema,
71
+ rootTable: "users",
72
+ rootIdColumn: "id",
73
+ purgePolicy: {
74
+ enabled: true,
75
+ selector: {
76
+ kind: "enum_column",
77
+ column: "account_state",
78
+ values: ["closed", "deleted"],
79
+ },
80
+ max_batch_size: 2,
81
+ actor_opaque_id: "system:purge",
82
+ legal_framework: "DPDP_2023",
83
+ },
84
+ });
85
+ expect(enumCandidates).toEqual(["usr_001", "usr_003"]);
86
+
87
+ const timestampCandidates = await selectPurgeCandidates(sql, {
88
+ appSchema: schema,
89
+ rootTable: "users",
90
+ rootIdColumn: "id",
91
+ purgePolicy: {
92
+ enabled: true,
93
+ selector: {
94
+ kind: "timestamp_before",
95
+ column: "disabled_at",
96
+ before: "2026-03-01T00:00:00.000Z",
97
+ },
98
+ max_batch_size: 100,
99
+ actor_opaque_id: "system:purge",
100
+ legal_framework: "DPDP_2023",
101
+ },
102
+ });
103
+ expect(timestampCandidates).toEqual(["usr_001", "usr_003"]);
104
+ });
105
+
106
+ it("fails closed when purge automation is disabled", async () => {
107
+ const schema = uniqueSchema("purge_disabled");
108
+ await createUsers(schema);
109
+
110
+ await expect(
111
+ selectPurgeCandidates(sql, {
112
+ appSchema: schema,
113
+ rootTable: "users",
114
+ rootIdColumn: "id",
115
+ purgePolicy: {
116
+ enabled: false,
117
+ max_batch_size: 100,
118
+ actor_opaque_id: "system:purge",
119
+ legal_framework: "DPDP_2023",
120
+ },
121
+ })
122
+ ).rejects.toThrow(/without an enabled purge_policy selector/i);
123
+ });
124
+ });
@@ -0,0 +1,103 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
3
+ import { evaluateRetention } from "@modules/engine/vault/retention";
4
+ import type { Sql } from "@/types";
5
+
6
+ describe("Retention Evaluation Engine", () => {
7
+ let sql: Sql;
8
+ const schemasToDrop: string[] = [];
9
+
10
+ beforeAll(() => {
11
+ sql = createTestSql();
12
+ });
13
+
14
+ afterAll(async () => {
15
+ await dropSchemas(sql, ...schemasToDrop);
16
+ await sql.end();
17
+ });
18
+
19
+ it("selects the longest retention rule when evidence exists in multiple tables", async () => {
20
+ const appSchema = uniqueSchema("retention_app");
21
+ schemasToDrop.push(appSchema);
22
+
23
+ await dropSchemas(sql, appSchema);
24
+ await sql`CREATE SCHEMA ${sql(appSchema)}`;
25
+ await sql`CREATE TABLE ${sql(appSchema)}.transactions (id SERIAL PRIMARY KEY, idempotent_user_id TEXT NOT NULL)`;
26
+ await sql`CREATE TABLE ${sql(appSchema)}.kyc_documents (id SERIAL PRIMARY KEY, idempotent_user_id TEXT NOT NULL)`;
27
+ await sql`
28
+ INSERT INTO ${sql(appSchema)}.transactions (idempotent_user_id)
29
+ VALUES ('usr_multi')
30
+ `;
31
+ await sql`
32
+ INSERT INTO ${sql(appSchema)}.kyc_documents (idempotent_user_id)
33
+ VALUES ('usr_multi')
34
+ `;
35
+
36
+ const result = await sql.begin(async (tx) =>
37
+ evaluateRetention(
38
+ tx,
39
+ "usr_multi",
40
+ {
41
+ default_retention_years: 0,
42
+ root_id_column: "idempotent_user_id",
43
+ retention_rules: [
44
+ {
45
+ rule_name: "RBI_KYC",
46
+ legal_citation: "RBI KYC Directions, 2016, Sec 38",
47
+ if_has_data_in: ["kyc_documents"],
48
+ retention_years: 5,
49
+ },
50
+ {
51
+ rule_name: "PMLA_FINANCIAL",
52
+ legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12",
53
+ if_has_data_in: ["transactions"],
54
+ retention_years: 10,
55
+ },
56
+ ],
57
+ app_schema: appSchema,
58
+ }
59
+ )
60
+ );
61
+
62
+ expect(result).toEqual({
63
+ retentionYears: 10,
64
+ appliedRuleName: "PMLA_FINANCIAL",
65
+ appliedRuleCitation: "Prevention of Money Laundering Act, 2002, Sec 12",
66
+ });
67
+ });
68
+
69
+ it("falls back to the default retention window when no evidence rule matches", async () => {
70
+ const appSchema = uniqueSchema("retention_default");
71
+ schemasToDrop.push(appSchema);
72
+
73
+ await dropSchemas(sql, appSchema);
74
+ await sql`CREATE SCHEMA ${sql(appSchema)}`;
75
+ await sql`CREATE TABLE ${sql(appSchema)}.transactions (id SERIAL PRIMARY KEY, subject_id TEXT NOT NULL)`;
76
+
77
+ const result = await sql.begin(async (tx) =>
78
+ evaluateRetention(
79
+ tx,
80
+ "usr_none",
81
+ {
82
+ default_retention_years: 1,
83
+ root_id_column: "subject_id",
84
+ retention_rules: [
85
+ {
86
+ rule_name: "PMLA_FINANCIAL",
87
+ legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12",
88
+ if_has_data_in: ["transactions"],
89
+ retention_years: 10,
90
+ },
91
+ ],
92
+ app_schema: appSchema,
93
+ }
94
+ )
95
+ );
96
+
97
+ expect(result).toEqual({
98
+ retentionYears: 1,
99
+ appliedRuleName: "DEFAULT",
100
+ appliedRuleCitation: "Configured default_retention_years policy",
101
+ });
102
+ });
103
+ });