@cosmicdrift/kumiko-bundled-features 0.314.0 → 0.315.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 (33) hide show
  1. package/package.json +9 -9
  2. package/src/compliance-profiles/resolve-for-tenant.ts +15 -5
  3. package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +6 -6
  4. package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +1 -1
  5. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +10 -10
  6. package/src/data-retention/changes.json +6 -0
  7. package/src/data-retention/feature.ts +1 -1
  8. package/src/data-retention/handlers/policy-for.query.ts +14 -31
  9. package/src/data-retention/index.ts +2 -0
  10. package/src/data-retention/resolve-for-tenant.ts +25 -17
  11. package/src/data-retention/resolve-tenant-preset.ts +5 -2
  12. package/src/data-retention/run-retention-cleanup.ts +5 -4
  13. package/src/delivery/__tests__/delivery.integration.test.ts +189 -19
  14. package/src/delivery/address-opt-out.ts +49 -21
  15. package/src/delivery/changes.json +12 -0
  16. package/src/delivery/constants.ts +1 -0
  17. package/src/delivery/delivery-service.ts +48 -27
  18. package/src/delivery/feature.ts +5 -1
  19. package/src/delivery/handlers/unsubscribe-address.write.ts +40 -0
  20. package/src/delivery/handlers/unsubscribe-user.write.ts +43 -0
  21. package/src/delivery/index.ts +1 -0
  22. package/src/delivery/public-names.ts +6 -0
  23. package/src/delivery/unsubscribe.ts +144 -67
  24. package/src/delivery/upsert-preference.ts +74 -60
  25. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +1 -0
  26. package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +2 -1
  27. package/src/file-derivatives/__tests__/public-variant-predicate-args.integration.test.ts +2 -1
  28. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +4 -1
  29. package/src/shared/index.ts +1 -0
  30. package/src/shared/is-tenant-db.ts +9 -0
  31. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +5 -1
  32. package/src/user-data-rights/__tests__/retention-preset-forget.integration.test.ts +272 -0
  33. package/src/user-data-rights/run-forget-cleanup.ts +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.314.0",
3
+ "version": "0.315.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -133,12 +133,12 @@
133
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
134
  },
135
135
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.314.0",
137
- "@cosmicdrift/kumiko-framework": "0.314.0",
138
- "@cosmicdrift/kumiko-headless": "0.314.0",
139
- "@cosmicdrift/kumiko-renderer": "0.314.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.314.0",
141
- "@cosmicdrift/kumiko-types": "0.314.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.315.0",
137
+ "@cosmicdrift/kumiko-framework": "0.315.0",
138
+ "@cosmicdrift/kumiko-headless": "0.315.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.315.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.315.0",
141
+ "@cosmicdrift/kumiko-types": "0.315.0",
142
142
  "@mollie/api-client": "^4.5.0",
143
143
  "@node-rs/argon2": "^2.0.2",
144
144
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.314.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.314.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.315.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.315.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -14,22 +14,32 @@ import {
14
14
  type EffectiveComplianceProfile,
15
15
  resolveComplianceProfile,
16
16
  } from "@cosmicdrift/kumiko-framework/compliance";
17
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
17
+ import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
18
18
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { isTenantDb } from "../shared";
19
20
  import { parseComplianceProfileOverride } from "./_internal/parse-override";
20
21
  import { tenantComplianceProfileTable } from "./schema/profile-selection";
21
22
 
22
23
  export interface ResolveProfileForTenantArgs {
23
- readonly db: DbRunner;
24
+ // data-retention's resolveTenantRetentionPreset forwards this via
25
+ // resolveRetentionPolicyForTenant, whose own `db` is DbRunner | TenantDb
26
+ // (bulk cron vs. per-hook TenantDb). This lookup is a plain tenant-scoped
27
+ // read either way.
28
+ readonly db: DbRunner | TenantDb;
24
29
  readonly tenantId: TenantId;
25
30
  }
26
31
 
27
32
  export async function resolveProfileForTenant(
28
33
  args: ResolveProfileForTenantArgs,
29
34
  ): Promise<EffectiveComplianceProfile> {
30
- const row = (await fetchOne(args.db, tenantComplianceProfileTable, {
31
- tenantId: args.tenantId,
32
- })) as { profileKey: string; override: string | null } | null; // @cast-boundary db-runner
35
+ const row = isTenantDb(args.db)
36
+ ? ((await args.db.fetchOne<{ profileKey: string; override: string | null }>(
37
+ tenantComplianceProfileTable,
38
+ { tenantId: args.tenantId },
39
+ )) ?? null)
40
+ : ((await fetchOne(args.db, tenantComplianceProfileTable, {
41
+ tenantId: args.tenantId,
42
+ })) as { profileKey: string; override: string | null } | null); // @cast-boundary db-runner
33
43
 
34
44
  if (!row) {
35
45
  return resolveComplianceProfile({});
@@ -249,7 +249,7 @@ describe("runRetentionCleanup :: hardDelete purges file bytes + derivatives + fi
249
249
  db: stack.db,
250
250
  registry: stack.registry,
251
251
  tenantId: T1,
252
- tenantPreset: null,
252
+ preloadedTenantPreset: null,
253
253
  now,
254
254
  files: filesCtx(),
255
255
  });
@@ -289,7 +289,7 @@ describe("runRetentionCleanup :: hardDelete purges file bytes + derivatives + fi
289
289
  db: stack.db,
290
290
  registry: stack.registry,
291
291
  tenantId: T1,
292
- tenantPreset: null,
292
+ preloadedTenantPreset: null,
293
293
  now,
294
294
  files: filesCtx(),
295
295
  });
@@ -324,7 +324,7 @@ describe("runRetentionCleanup :: hardDelete purges file bytes + derivatives + fi
324
324
  db: stack.db,
325
325
  registry: stack.registry,
326
326
  tenantId: T1,
327
- tenantPreset: null,
327
+ preloadedTenantPreset: null,
328
328
  now,
329
329
  files: filesCtx(failingProvider),
330
330
  });
@@ -339,7 +339,7 @@ describe("runRetentionCleanup :: hardDelete purges file bytes + derivatives + fi
339
339
  db: stack.db,
340
340
  registry: stack.registry,
341
341
  tenantId: T1,
342
- tenantPreset: null,
342
+ preloadedTenantPreset: null,
343
343
  now,
344
344
  files: filesCtx(),
345
345
  });
@@ -365,7 +365,7 @@ describe("runRetentionCleanup :: hardDelete purges file bytes + derivatives + fi
365
365
  db: stack.db,
366
366
  registry: stack.registry,
367
367
  tenantId: T1,
368
- tenantPreset: null,
368
+ preloadedTenantPreset: null,
369
369
  now,
370
370
  });
371
371
 
@@ -405,7 +405,7 @@ describe("runRetentionCleanup :: hardDelete purges file bytes + derivatives + fi
405
405
  db: stack.db,
406
406
  registry: stack.registry,
407
407
  tenantId: T1,
408
- tenantPreset: null,
408
+ preloadedTenantPreset: null,
409
409
  now,
410
410
  files: filesCtx(),
411
411
  });
@@ -133,7 +133,7 @@ describe("runRetentionCleanup :: hardDelete crypto-shredding (Refs #2057)", () =
133
133
  db: stack.db,
134
134
  registry: stack.registry,
135
135
  tenantId,
136
- tenantPreset: null,
136
+ preloadedTenantPreset: null,
137
137
  now,
138
138
  });
139
139
 
@@ -206,7 +206,7 @@ describe("runRetentionCleanup :: real postgres", () => {
206
206
  db: stack.db,
207
207
  registry: stack.registry,
208
208
  tenantId: T1,
209
- tenantPreset: null,
209
+ preloadedTenantPreset: null,
210
210
  now,
211
211
  });
212
212
 
@@ -223,7 +223,7 @@ describe("runRetentionCleanup :: real postgres", () => {
223
223
  db: stack.db,
224
224
  registry: stack.registry,
225
225
  tenantId: T1,
226
- tenantPreset: null,
226
+ preloadedTenantPreset: null,
227
227
  now,
228
228
  });
229
229
 
@@ -240,7 +240,7 @@ describe("runRetentionCleanup :: real postgres", () => {
240
240
  db: stack.db,
241
241
  registry: stack.registry,
242
242
  tenantId: T1,
243
- tenantPreset: null,
243
+ preloadedTenantPreset: null,
244
244
  now,
245
245
  });
246
246
 
@@ -254,7 +254,7 @@ describe("runRetentionCleanup :: real postgres", () => {
254
254
  db: stack.db,
255
255
  registry: stack.registry,
256
256
  tenantId: T1,
257
- tenantPreset: null,
257
+ preloadedTenantPreset: null,
258
258
  now,
259
259
  });
260
260
 
@@ -301,7 +301,7 @@ describe("runRetentionCleanup :: real postgres", () => {
301
301
  db: stack.db,
302
302
  registry: stack.registry,
303
303
  tenantId: T1,
304
- tenantPreset: null,
304
+ preloadedTenantPreset: null,
305
305
  now,
306
306
  });
307
307
 
@@ -326,7 +326,7 @@ describe("runRetentionCleanup :: real postgres", () => {
326
326
  db: stack.db,
327
327
  registry: stack.registry,
328
328
  tenantId: T1,
329
- tenantPreset: null,
329
+ preloadedTenantPreset: null,
330
330
  now,
331
331
  });
332
332
 
@@ -351,7 +351,7 @@ describe("runRetentionCleanup :: real postgres", () => {
351
351
  db: stack.db,
352
352
  registry: stack.registry,
353
353
  tenantId: T1,
354
- tenantPreset: null,
354
+ preloadedTenantPreset: null,
355
355
  now,
356
356
  });
357
357
  expect(first.anonymized).toBe(1);
@@ -364,7 +364,7 @@ describe("runRetentionCleanup :: real postgres", () => {
364
364
  db: stack.db,
365
365
  registry: stack.registry,
366
366
  tenantId: T1,
367
- tenantPreset: null,
367
+ preloadedTenantPreset: null,
368
368
  now,
369
369
  });
370
370
  expect(second.anonymized).toBe(0);
@@ -388,7 +388,7 @@ describe("runRetentionCleanup :: real postgres", () => {
388
388
  db: stack.db,
389
389
  registry: stack.registry,
390
390
  tenantId: T1,
391
- tenantPreset: null,
391
+ preloadedTenantPreset: null,
392
392
  now,
393
393
  batchLimit: 1,
394
394
  });
@@ -412,7 +412,7 @@ describe("runRetentionCleanup :: real postgres", () => {
412
412
  db: stack.db,
413
413
  registry: stack.registry,
414
414
  tenantId: T1,
415
- tenantPreset: null,
415
+ preloadedTenantPreset: null,
416
416
  now,
417
417
  });
418
418
 
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.315.0",
4
+ "type": "breaking",
5
+ "title": "forget/policy-for now honor the tenant retention preset",
6
+ "migration": "ResolveForTenantArgs.tenantPreset was renamed to preloadedTenantPreset (mirrors preloadedOverride): omitting it now makes the resolver load the tenant's retention preset itself instead of skipping it. Behavior change: forget (Art. 17) and the policy-for query now honor the tenant's compliance-profile-derived retention preset, not just entity defaults and per-tenant overrides. For tenants with a mapped compliance profile (e.g. de-hr-dsgvo-hgb), forget now keeps invoice/booking/contract rows via blockDelete and anonymizes order rows instead of hard-deleting them; notes-history mentions on such hosts are no longer shredded when the mentioned note's host entity is preset-protected. policy-for (data-retention:query:policy-for) now returns source: \"preset\" where it previously returned \"none\" for these entities."
7
+ },
2
8
  {
3
9
  "version": "0.313.0",
4
10
  "type": "fix",
@@ -107,7 +107,7 @@ export function createDataRetentionFeature(): FeatureDefinition {
107
107
  db: cleanupDb,
108
108
  registry: ctx.registry,
109
109
  tenantId,
110
- tenantPreset,
110
+ preloadedTenantPreset: tenantPreset,
111
111
  now: T.Now.instant(),
112
112
  files: ctx.files,
113
113
  });
@@ -1,9 +1,7 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import * as z from "zod";
4
- import { parseRetentionOverrideOrNull } from "../_internal/parse-override";
5
- import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "../resolver";
6
- import { tenantRetentionOverrideTable } from "../schema/tenant-retention-override";
3
+ import { resolveRetentionPolicyForTenant } from "../resolve-for-tenant";
4
+ import type { EffectiveRetentionPolicy } from "../resolver";
7
5
 
8
6
  // retention:query:policy-for — Cross-Feature-API fuer den Forget-Flow.
9
7
  //
@@ -11,9 +9,9 @@ import { tenantRetentionOverrideTable } from "../schema/tenant-retention-overrid
11
9
  // Forget mit "delete" oder "anonymize" oder "blockDelete-bis-Frist"
12
10
  // laufen soll. Plus Cleanup-Job-Sprint-2.D2b fuer das gleiche.
13
11
  //
14
- // Tenant-Preset-Storage existiert noch nicht (kommt mit S2.D2b ueber
15
- // einen tenant-config-key). Bis dahin tenantPreset=null — der Resolver
16
- // liefert dann nur die Entity-Default-Layer + Override.
12
+ // Delegates to resolveRetentionPolicyForTenant so forget, the cleanup cron and
13
+ // this query can never disagree about the tenant preset. Letting the resolver
14
+ // load the preset itself costs one read per call, fine for a single lookup.
17
15
  //
18
16
  // access: openToAll — andere Features im selben Tenant duerfen das
19
17
  // abrufen. Keine PII im Result, nur Policy-Metadata.
@@ -33,31 +31,16 @@ export const policyForQuery = defineQueryHandler({
33
31
  description:
34
32
  "Resolves the effective retention policy for one entity name in the caller's tenant (keep-for duration plus delete or anonymize strategy) by layering the entity default, tenant preset and tenant override, so a forget flow or cleanup job knows how that data may be removed.",
35
33
  handler: async (query, ctx): Promise<EffectiveRetentionPolicy> => {
36
- const entityName = query.payload.entityName;
37
-
38
- // Layer 3: Tenant-Override aus DB laden (UNIQUE(tenantId, entityName))
39
- const overrideRow = (await fetchOne(ctx.db, tenantRetentionOverrideTable, {
34
+ if (!ctx.registry) {
35
+ throw new Error(
36
+ "data-retention:policy-for: ctx.registry required (HandlerContext incomplete)",
37
+ );
38
+ }
39
+ return resolveRetentionPolicyForTenant({
40
+ db: ctx.db,
41
+ registry: ctx.registry,
40
42
  tenantId: query.user.tenantId,
41
- entityName,
42
- })) as { config: string | null } | null; // @cast-boundary db-runner
43
-
44
- const tenantOverride = parseRetentionOverrideOrNull(
45
- overrideRow?.config ?? null,
46
- query.user.tenantId,
47
- "data-retention:policy-for",
48
- );
49
-
50
- // Layer 1: Entity-Default aus Registry
51
- const entityDef = ctx.registry?.getEntity(entityName) ?? null;
52
-
53
- // Layer 2: Tenant-Preset — Storage kommt mit S2.D2b. Bis dahin null.
54
- const tenantPreset = null;
55
-
56
- return resolveRetentionPolicy({
57
- entityName,
58
- entityDef,
59
- tenantPreset,
60
- tenantOverride,
43
+ entityName: query.payload.entityName,
61
44
  });
62
45
  },
63
46
  });
@@ -2,6 +2,7 @@ export type {
2
2
  EffectiveRetentionPolicy,
3
3
  ResolveForTenantArgs,
4
4
  ResolveRetentionPolicyArgs,
5
+ ResolveTenantPresetArgs,
5
6
  RetentionOverride,
6
7
  RetentionPreset,
7
8
  RetentionPresetKey,
@@ -11,6 +12,7 @@ export {
11
12
  RETENTION_PRESETS,
12
13
  resolveRetentionPolicy,
13
14
  resolveRetentionPolicyForTenant,
15
+ resolveTenantRetentionPreset,
14
16
  retentionOverrideSchema,
15
17
  SELECTABLE_RETENTION_PRESETS,
16
18
  tenantRetentionOverrideEntity,
@@ -1,23 +1,17 @@
1
- // Direkter Resolver-Helper fuer Bulk-Iteration (S2.U5b Cleanup-Runner).
2
- //
3
- // `policy-for` Query (handlers/policy-for.query.ts) ist die Cross-Feature-
4
- // API fuer einzelne Lookups. Der Cleanup-Runner iteriert N Entities × M
5
- // Tenants — Handler-Roundtrip pro Lookup waere zu teuer + braucht einen
6
- // HandlerContext. Beide Pfade nutzen denselben `parseRetentionOverrideOrNull`
7
- // + `resolveRetentionPolicy`, also kein Drift-Risiko.
1
+ // Single implementation of the three-layer policy resolution: the cleanup cron,
2
+ // the forget runner and the `policy-for` query all go through here, so a caller
3
+ // cannot skip a layer (e.g. the tenant preset) by accident.
8
4
 
9
5
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
10
6
  import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
11
7
  import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { isTenantDb } from "../shared";
12
9
  import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
13
10
  import type { RetentionPresetKey } from "./presets";
11
+ import { resolveTenantRetentionPreset } from "./resolve-tenant-preset";
14
12
  import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
15
13
  import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
16
14
 
17
- function isTenantDb(db: DbRunner | TenantDb): db is TenantDb {
18
- return typeof (db as Partial<TenantDb>).unsafeRaw === "function";
19
- }
20
-
21
15
  export interface ResolveForTenantArgs {
22
16
  // fw#2914 — a EXT_USER_DATA hook's ctx.db is a TenantDb (method-form);
23
17
  // the cleanup cron's own db is still a raw DbRunner. Both are valid here —
@@ -27,12 +21,17 @@ export interface ResolveForTenantArgs {
27
21
  readonly tenantId: TenantId;
28
22
  readonly entityName: string;
29
23
  /**
30
- * Layer 2 — Tenant-Preset, vom Caller aufgeloest (retention-cleanup-Cron
31
- * leitet ihn aus dem Compliance-Profile ab, siehe resolve-tenant-preset.ts).
32
- * null = kein Preset, Resolver faellt auf Entity-Default (Layer 1) +
33
- * Tenant-Override (Layer 3) zurueck.
24
+ * Layer 2 — Tenant-Preset. `undefined` (the default) means this resolver
25
+ * derives it itself via resolveTenantRetentionPreset — one compliance-
26
+ * profile read per call, fine for single-lookup callers (`policy-for`, a
27
+ * per-hook TenantDb caller like notes-history-user-data). A bulk caller
28
+ * iterating N entities × M tenants (the forget-cleanup runner, the
29
+ * retention-cleanup cron) MUST resolve it ONCE per tenant and pass it in
30
+ * here instead, or it re-derives per entity. `null` means "resolved
31
+ * already, no preset applies" — the resolver falls back to Entity-Default
32
+ * (Layer 1) + Tenant-Override (Layer 3).
34
33
  */
35
- readonly tenantPreset?: RetentionPresetKey | null;
34
+ readonly preloadedTenantPreset?: RetentionPresetKey | null;
36
35
  /**
37
36
  * Pre-fetched override row for this (tenantId, entityName) pair — lets a
38
37
  * bulk caller (the cleanup cron, N entities × M tenants) read every
@@ -68,10 +67,19 @@ export async function resolveRetentionPolicyForTenant(
68
67
 
69
68
  const entityDef = args.registry.getEntity(args.entityName) ?? null;
70
69
 
70
+ const tenantPreset =
71
+ args.preloadedTenantPreset !== undefined
72
+ ? args.preloadedTenantPreset
73
+ : await resolveTenantRetentionPreset({
74
+ db: args.db,
75
+ registry: args.registry,
76
+ tenantId: args.tenantId,
77
+ });
78
+
71
79
  return resolveRetentionPolicy({
72
80
  entityName: args.entityName,
73
81
  entityDef,
74
- tenantPreset: args.tenantPreset ?? null,
82
+ tenantPreset,
75
83
  tenantOverride,
76
84
  });
77
85
  }
@@ -12,7 +12,7 @@
12
12
  // sogar namensgleich. minimal-no-region → "default" (No-Op-Preset).
13
13
 
14
14
  import type { ComplianceProfileKey } from "@cosmicdrift/kumiko-framework/compliance";
15
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
15
+ import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
16
16
  import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
17
17
  import { resolveProfileForTenant } from "../compliance-profiles";
18
18
  import type { RetentionPresetKey } from "./presets";
@@ -29,7 +29,10 @@ const PROFILE_TO_PRESET: Readonly<Record<ComplianceProfileKey, RetentionPresetKe
29
29
  } satisfies Readonly<Record<ComplianceProfileKey, RetentionPresetKey>>;
30
30
 
31
31
  export interface ResolveTenantPresetArgs {
32
- readonly db: DbRunner;
32
+ // resolveRetentionPolicyForTenant calls this itself when its own
33
+ // caller omits preloadedTenantPreset. Its `db` is DbRunner | TenantDb (bulk
34
+ // cron vs. per-hook TenantDb), so this must accept both too.
35
+ readonly db: DbRunner | TenantDb;
33
36
  readonly registry: Registry;
34
37
  readonly tenantId: TenantId;
35
38
  }
@@ -95,8 +95,9 @@ export interface RunRetentionCleanupArgs {
95
95
  readonly db: DbRunner;
96
96
  readonly registry: Registry;
97
97
  readonly tenantId: TenantId;
98
- /** Layer-2 Preset (aus resolveTenantRetentionPreset). null = nur Layer 1/3. */
99
- readonly tenantPreset: RetentionPresetKey | null;
98
+ /** Layer-2 preset from resolveTenantRetentionPreset, resolved once per tenant
99
+ * by the caller rather than per entity here. null = layers 1 and 3 only. */
100
+ readonly preloadedTenantPreset: RetentionPresetKey | null;
100
101
  /** Now-Injection — Tests pinnen den Wert ohne Date-Mock (Pattern keep-for.ts). */
101
102
  readonly now: Instant;
102
103
  readonly batchLimit?: number;
@@ -587,7 +588,7 @@ async function anonymizeMatchingRows(
587
588
  export async function runRetentionCleanup(
588
589
  args: RunRetentionCleanupArgs,
589
590
  ): Promise<RunRetentionCleanupResult> {
590
- const { db, registry, tenantId, tenantPreset, now } = args;
591
+ const { db, registry, tenantId, preloadedTenantPreset, now } = args;
591
592
  const batchLimit = args.batchLimit ?? DEFAULT_BATCH_LIMIT;
592
593
  const kms = args.kms ?? configuredPiiSubjectKms();
593
594
 
@@ -628,7 +629,7 @@ export async function runRetentionCleanup(
628
629
  registry,
629
630
  tenantId,
630
631
  entityName,
631
- tenantPreset,
632
+ preloadedTenantPreset,
632
633
  preloadedOverride: overrideByEntity.get(entityName) ?? null,
633
634
  });
634
635
  const policy = resolved.policy;