@cosmicdrift/kumiko-bundled-features 0.252.1 → 0.254.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 (93) hide show
  1. package/package.json +9 -9
  2. package/src/agent-tools/__tests__/agent-manifest.test.ts +2 -2
  3. package/src/agent-tools/__tests__/agent-optout.test.ts +22 -3
  4. package/src/agent-tools/__tests__/agent-tools.integration.test.ts +7 -1
  5. package/src/agent-tools/__tests__/tool-catalog.test.ts +11 -4
  6. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +13 -2
  7. package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
  8. package/src/audit/__tests__/audit.integration.test.ts +2 -2
  9. package/src/billing-foundation/entities.ts +30 -5
  10. package/src/billing-foundation/handlers/process-event.write.ts +1 -0
  11. package/src/billing-foundation/handlers/process-payment-event.write.ts +1 -0
  12. package/src/cap-counter/entity.ts +8 -1
  13. package/src/config/table.ts +4 -2
  14. package/src/crypto-shredding/__tests__/forget-subject-record.integration.test.ts +141 -1
  15. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +1 -1
  16. package/src/crypto-shredding/constants.ts +1 -0
  17. package/src/crypto-shredding/handlers/forget-subject.write.ts +42 -0
  18. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
  19. package/src/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +1 -1
  20. package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +1 -1
  21. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +1 -1
  22. package/src/custom-fields/__tests__/field-access.integration.test.ts +1 -1
  23. package/src/custom-fields/__tests__/quota.integration.test.ts +1 -1
  24. package/src/custom-fields/__tests__/retention.integration.test.ts +1 -1
  25. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
  26. package/src/custom-fields/__tests__/wire-for-entity.test.ts +1 -1
  27. package/src/custom-fields/entity.ts +24 -4
  28. package/src/data-retention/__tests__/resolver.test.ts +6 -4
  29. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
  30. package/src/delivery/__tests__/delivery.integration.test.ts +3 -3
  31. package/src/delivery/tables.ts +6 -2
  32. package/src/document-ingest-foundation/entity.ts +6 -2
  33. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +12 -2
  34. package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +7 -1
  35. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +12 -2
  36. package/src/folders/__tests__/folders.integration.test.ts +8 -1
  37. package/src/folders/entity.ts +27 -5
  38. package/src/folders-user-data/__tests__/hooks.integration.test.ts +8 -1
  39. package/src/form-draft/entity.ts +6 -1
  40. package/src/inbound-mail-foundation/entities.ts +57 -12
  41. package/src/inbound-mail-foundation/handlers/connect-account.write.ts +8 -4
  42. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +3 -4
  43. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +111 -1
  44. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +2 -2
  45. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +6 -1
  46. package/src/jobs/db/queries/stale-run-sweep.ts +36 -14
  47. package/src/jobs/feature.ts +5 -0
  48. package/src/jobs/job-run-logger.ts +7 -2
  49. package/src/jobs/job-run-table.ts +11 -6
  50. package/src/ledger/entity.ts +27 -7
  51. package/src/managed-pages/screens/branding-screen.ts +27 -5
  52. package/src/managed-pages/table.ts +23 -4
  53. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +8 -1
  54. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +8 -1
  55. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +20 -3
  56. package/src/notes-history/__tests__/notes-history.integration.test.ts +8 -1
  57. package/src/notes-history/entity.ts +63 -19
  58. package/src/notes-history/executor.ts +6 -1
  59. package/src/notes-history/feature.ts +7 -1
  60. package/src/notes-history/handlers/add-note.write.ts +19 -3
  61. package/src/notes-history/index.ts +7 -2
  62. package/src/notes-history/schemas.ts +5 -0
  63. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +8 -1
  64. package/src/notes-history-user-data/__tests__/mention-forget.integration.test.ts +244 -0
  65. package/src/notes-history-user-data/hooks.ts +69 -16
  66. package/src/notes-history-user-data/index.ts +11 -2
  67. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  68. package/src/personal-access-tokens/schema/api-token.ts +29 -1
  69. package/src/secrets/table.ts +1 -1
  70. package/src/sessions/schema/user-session.ts +4 -0
  71. package/src/sessions/session-callbacks.ts +1 -0
  72. package/src/shared/encrypt-for-direct-write.ts +9 -3
  73. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +12 -2
  74. package/src/tags/__tests__/tags.integration.test.ts +8 -1
  75. package/src/tags/entity.ts +20 -5
  76. package/src/template-resolver/table.ts +8 -7
  77. package/src/template-resolver/user-content-table.ts +6 -4
  78. package/src/tenant/invitation-table.ts +6 -1
  79. package/src/tenant/membership-table.ts +2 -2
  80. package/src/tenant/schema/tenant.ts +22 -3
  81. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +1 -1
  82. package/src/tier-engine/entity.ts +12 -2
  83. package/src/user/schema/user.ts +15 -1
  84. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -2
  85. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +1 -1
  86. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +1 -1
  87. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +3 -1
  88. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +4 -0
  89. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
  90. package/src/user-data-rights/index.ts +6 -1
  91. package/src/user-data-rights/schema/download-attempt.ts +25 -7
  92. package/src/user-data-rights/schema/download-token.ts +4 -0
  93. package/src/user-data-rights/schema/export-job.ts +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.252.1",
3
+ "version": "0.254.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>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.252.1",
134
- "@cosmicdrift/kumiko-framework": "0.252.1",
135
- "@cosmicdrift/kumiko-headless": "0.252.1",
136
- "@cosmicdrift/kumiko-renderer": "0.252.1",
137
- "@cosmicdrift/kumiko-renderer-web": "0.252.1",
138
- "@cosmicdrift/kumiko-types": "0.252.1",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.254.0",
134
+ "@cosmicdrift/kumiko-framework": "0.254.0",
135
+ "@cosmicdrift/kumiko-headless": "0.254.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.254.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.254.0",
138
+ "@cosmicdrift/kumiko-types": "0.254.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.252.1",
168
- "@cosmicdrift/kumiko-locale-es": "0.252.1"
167
+ "@cosmicdrift/kumiko-locale-de": "0.254.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.254.0"
169
169
  }
170
170
  }
@@ -14,7 +14,7 @@ const gatedEntity = createEntity({
14
14
  fields: {
15
15
  status: createSelectField({ options: ["draft", "active"] as const, filterable: true }),
16
16
  ownerId: { type: "reference", entity: "user" } satisfies ReferenceFieldDef,
17
- title: createTextField({ searchable: true }),
17
+ title: createTextField({ searchable: true, personal: false, reason: "technical_reference" }),
18
18
  // Raw ResolvedPiiFlags form — the manifest is schema, not rows, so this
19
19
  // must never surface a value, only the marker.
20
20
  secretNote: { type: "text", pii: true } satisfies TextFieldDef,
@@ -58,7 +58,7 @@ function buildTestFeature() {
58
58
 
59
59
  const otherEntity = createEntity({
60
60
  fields: {
61
- label: createTextField({ searchable: true }),
61
+ label: createTextField({ searchable: true, personal: false, reason: "technical_reference" }),
62
62
  },
63
63
  });
64
64
 
@@ -147,7 +147,12 @@ describe("denyQns removes a tool", () => {
147
147
 
148
148
  const gadgetEntity = createEntity({
149
149
  fields: {
150
- name: createTextField({ searchable: true, filterable: true }),
150
+ name: createTextField({
151
+ searchable: true,
152
+ filterable: true,
153
+ personal: false,
154
+ reason: "technical_reference",
155
+ }),
151
156
  },
152
157
  });
153
158
 
@@ -193,10 +198,24 @@ describe("denyQns reaches the entity CRUD tools", () => {
193
198
  });
194
199
 
195
200
  const plainItemEntity = createEntity({
196
- fields: { label: createTextField({ searchable: true, filterable: true }) },
201
+ fields: {
202
+ label: createTextField({
203
+ searchable: true,
204
+ filterable: true,
205
+ personal: false,
206
+ reason: "technical_reference",
207
+ }),
208
+ },
197
209
  });
198
210
  const cloakedItemEntity = createEntity({
199
- fields: { label: createTextField({ searchable: true, filterable: true }) },
211
+ fields: {
212
+ label: createTextField({
213
+ searchable: true,
214
+ filterable: true,
215
+ personal: false,
216
+ reason: "technical_reference",
217
+ }),
218
+ },
200
219
  });
201
220
 
202
221
  // Both entities mounted in the same feature so the "default stays exposed"
@@ -27,7 +27,13 @@ const FEATURE_NAME = "agent-tools-int-test";
27
27
  const widgetEntity = createEntity({
28
28
  table: "agent_tools_int_test_widgets",
29
29
  fields: {
30
- name: createTextField({ required: true, searchable: true, filterable: true }),
30
+ name: createTextField({
31
+ required: true,
32
+ searchable: true,
33
+ filterable: true,
34
+ personal: false,
35
+ reason: "technical_reference",
36
+ }),
31
37
  status: createSelectField({ options: ["pending", "approved"] as const, filterable: true }),
32
38
  },
33
39
  });
@@ -17,13 +17,20 @@ function isRecord(value: unknown): value is Record<string, unknown> {
17
17
 
18
18
  const widgetEntity = createEntity({
19
19
  fields: {
20
- name: createTextField({ searchable: true, filterable: true }),
20
+ name: createTextField({
21
+ searchable: true,
22
+ filterable: true,
23
+ personal: false,
24
+ reason: "technical_reference",
25
+ }),
21
26
  status: createSelectField({ options: ["open", "closed"] as const, filterable: true }),
22
- notes: createTextField(),
27
+ notes: createTextField({ personal: false, reason: "technical_reference" }),
23
28
  },
24
29
  });
25
30
 
26
- const orphanEntity = createEntity({ fields: { label: createTextField() } });
31
+ const orphanEntity = createEntity({
32
+ fields: { label: createTextField({ personal: false, reason: "technical_reference" }) },
33
+ });
27
34
 
28
35
  function buildCatalogTestFeature() {
29
36
  return defineFeature("catalog-test", (r) => {
@@ -104,7 +111,7 @@ function buildCatalogTestFeature() {
104
111
  id: "widget-approve-form",
105
112
  type: "actionForm",
106
113
  handler: "catalog-test:write:widget:approve",
107
- fields: { note: createTextField() },
114
+ fields: { note: createTextField({ personal: false, reason: "technical_reference" }) },
108
115
  layout: { sections: [{ title: "s", fields: ["note"] }] },
109
116
  });
110
117
  });
@@ -23,8 +23,19 @@ import { dispatchToolCall } from "../tool-dispatch";
23
23
  const vendorEntity = createEntity({
24
24
  table: "agent_tools_test_vendors",
25
25
  fields: {
26
- name: createTextField({ required: true, searchable: true, filterable: true }),
27
- iban: createTextField({ required: true, filterable: true }),
26
+ name: createTextField({
27
+ required: true,
28
+ searchable: true,
29
+ filterable: true,
30
+ personal: false,
31
+ reason: "is_business_data",
32
+ }),
33
+ iban: createTextField({
34
+ required: true,
35
+ filterable: true,
36
+ personal: false,
37
+ reason: "is_business_data",
38
+ }),
28
39
  },
29
40
  });
30
41
 
@@ -38,7 +38,7 @@ import { createAuditFeature } from "../feature";
38
38
  const widgetEntity = createEntity({
39
39
  table: "audit_sec_widgets",
40
40
  fields: {
41
- name: createTextField({ required: true }),
41
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
42
42
  },
43
43
  });
44
44
 
@@ -29,8 +29,8 @@ import { createAuditFeature } from "../feature";
29
29
  const widgetEntity = createEntity({
30
30
  table: "audit_widgets",
31
31
  fields: {
32
- name: createTextField({ required: true }),
33
- color: createTextField(),
32
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
33
+ color: createTextField({ personal: false, reason: "technical_reference" }),
34
34
  },
35
35
  });
36
36
 
@@ -39,7 +39,12 @@ import {
39
39
  export const subscriptionEntity = createEntity({
40
40
  table: "read_subscriptions",
41
41
  fields: {
42
- providerName: createTextField({ required: true, maxLength: 50 }),
42
+ providerName: createTextField({
43
+ required: true,
44
+ maxLength: 50,
45
+ personal: false,
46
+ reason: "technical_reference",
47
+ }),
43
48
  // `personal: "tenant"` (not `encrypted`): the field must crypto-shred when
44
49
  // eraseSubjectKeys erases the tenant's subject key on tenant-destroy
45
50
  // (#800). `encrypted: true` uses the app-wide master key instead — that
@@ -61,8 +66,18 @@ export const subscriptionEntity = createEntity({
61
66
  personal: "tenant",
62
67
  find: "none",
63
68
  }),
64
- status: createTextField({ required: true, maxLength: 30 }),
65
- tier: createTextField({ required: true, maxLength: 50 }),
69
+ status: createTextField({
70
+ required: true,
71
+ maxLength: 30,
72
+ personal: false,
73
+ reason: "technical_reference",
74
+ }),
75
+ tier: createTextField({
76
+ required: true,
77
+ maxLength: 50,
78
+ personal: false,
79
+ reason: "catalog_label",
80
+ }),
66
81
  currentPeriodEnd: createTimestampField({ required: true }),
67
82
  },
68
83
  });
@@ -88,7 +103,12 @@ export const SUBSCRIPTION_PII_FIELDS = collectPiiSubjectFields(subscriptionEntit
88
103
  export const paymentEntity = createEntity({
89
104
  table: "read_payments",
90
105
  fields: {
91
- providerName: createTextField({ required: true, maxLength: 50 }),
106
+ providerName: createTextField({
107
+ required: true,
108
+ maxLength: 50,
109
+ personal: false,
110
+ reason: "technical_reference",
111
+ }),
92
112
  // Same `personal: "tenant"` rationale as subscriptionEntity above —
93
113
  // crypto-shreds on tenant-destroy (#800) via eraseSubjectKeys.
94
114
  providerCustomerId: createTextField({
@@ -97,7 +117,12 @@ export const paymentEntity = createEntity({
97
117
  personal: "tenant",
98
118
  find: "none",
99
119
  }),
100
- priceId: createTextField({ required: true, maxLength: 200 }),
120
+ priceId: createTextField({
121
+ required: true,
122
+ maxLength: 200,
123
+ personal: false,
124
+ reason: "technical_reference",
125
+ }),
101
126
  },
102
127
  });
103
128
 
@@ -155,6 +155,7 @@ export const processEventHandler: WriteHandlerDef = {
155
155
  SUBSCRIPTION_PII_FIELDS,
156
156
  piiKms,
157
157
  { requestId: `billing-foundation:process-event:${payload.providerEventId}`, tenantId },
158
+ { tenantId, entityName: SUBSCRIPTION_AGGREGATE_TYPE },
158
159
  )
159
160
  : {
160
161
  providerCustomerId: payload.providerCustomerId,
@@ -84,6 +84,7 @@ export const processPaymentEventHandler: WriteHandlerDef = {
84
84
  requestId: `billing-foundation:process-payment-event:${payload.providerEventId}`,
85
85
  tenantId,
86
86
  },
87
+ { tenantId, entityName: PAYMENT_AGGREGATE_TYPE },
87
88
  )
88
89
  : { providerCustomerId: payload.providerCustomerId };
89
90
 
@@ -42,7 +42,14 @@ export const capCounterEntity = createEntity({
42
42
  description:
43
43
  "One usage counter per tenant, cap name and calendar period, holding the accumulated value and the timestamp of the last soft-limit warning.",
44
44
  fields: {
45
- capName: createTextField({ required: true, maxLength: 100, sortable: true, searchable: true }),
45
+ capName: createTextField({
46
+ required: true,
47
+ maxLength: 100,
48
+ sortable: true,
49
+ searchable: true,
50
+ personal: false,
51
+ reason: "technical_reference",
52
+ }),
46
53
  value: createBigIntField({ required: true, default: 0 }),
47
54
  periodStart: createTimestampField({ required: true }),
48
55
  lastSoftWarnedAt: createTimestampField(),
@@ -19,11 +19,13 @@ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/eng
19
19
  export const configValueEntity = createEntity({
20
20
  table: "read_config_values",
21
21
  fields: {
22
- key: createTextField({ required: true }),
22
+ key: createTextField({ required: true, personal: false, reason: "system_metadata" }),
23
23
  // value is JSON-encoded primitive (or encrypted blob). Nullable so a
24
24
  // deleted-then-recreated stream can signal "reset to default" without
25
25
  // breaking the null-vs-missing distinction the resolver already draws.
26
- value: createTextField({}),
26
+ // App/system/tenant config values (service urls, feature toggles,
27
+ // mail-server settings), not user-authored content.
28
+ value: createTextField({ personal: false, reason: "system_metadata" }),
27
29
  // user-scope row: userId populated. tenant- / system-scope: null.
28
30
  userId: createTextField({
29
31
  personal: false,
@@ -41,7 +41,13 @@ import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko
41
41
  import { createConfigFeature } from "../../config";
42
42
  import { createTenantFeature } from "../../tenant";
43
43
  import { tenantMembershipsTable } from "../../tenant/membership-table";
44
- import { RECORD_ENTITY_NOT_REGISTERED, TARGET_RECORD_NOT_ADMIN_TENANT } from "../constants";
44
+ import {
45
+ RECORD_ENTITY_NOT_REGISTERED,
46
+ SUBJECT_FORGET_DENIED_EVENT_NAME,
47
+ SUBJECT_FORGOTTEN_EVENT_NAME,
48
+ TARGET_RECORD_NOT_ADMIN_TENANT,
49
+ TARGET_RECORD_RETENTION_BLOCK_DELETE,
50
+ } from "../constants";
45
51
  import { createCryptoShreddingFeature } from "../feature";
46
52
 
47
53
  const FORGET = "crypto-shredding:write:forget-subject";
@@ -160,6 +166,25 @@ describe("crypto-shredding :: forget-subject (record subject, #2786)", () => {
160
166
  requestId: "test",
161
167
  });
162
168
  expect(decrypted["body"]).toBe(PII_ERASED_SENTINEL);
169
+
170
+ // fw#2789: the operator path's audit trail — who erased which subject,
171
+ // and why — must be provable, not just the erase itself. The bystander
172
+ // user-forget above appended its own subject-forgotten event under the
173
+ // same rowId (record:<entity>:<id> and user:<id> share aggregate ids
174
+ // here), so this looks the record subject's event up by its subjectKey
175
+ // rather than assuming it is the only row.
176
+ const forgottenEvents = (await selectMany(stack.db, eventsTable, {
177
+ type: SUBJECT_FORGOTTEN_EVENT_NAME,
178
+ })) as Array<{ payload: Record<string, unknown> }>;
179
+ const recordSubjectKey = `record:${RECORD_PROBE_ENTITY_NAME}:${rowId}`;
180
+ const recordForgottenEvent = forgottenEvents.find(
181
+ (e) => e.payload["subjectKey"] === recordSubjectKey,
182
+ );
183
+ expect(recordForgottenEvent?.payload).toMatchObject({
184
+ subjectKey: recordSubjectKey,
185
+ reason: REASON,
186
+ forgottenBy: dpoUser.id,
187
+ });
163
188
  });
164
189
 
165
190
  // Empirical: update()'s previous-row re-encrypt (encryptForStorage(previous))
@@ -289,3 +314,118 @@ describe("crypto-shredding :: forget-subject (record subject) tenant gate, #2786
289
314
  );
290
315
  });
291
316
  });
317
+
318
+ // fw#2789: retention.strategy="blockDelete" on the host entity must win over
319
+ // a targeted row-shred — a separate stack (no tenant feature mounted) so the
320
+ // denial is proven independent of the tenant gate above, not a side effect
321
+ // of it.
322
+ const BLOCK_DELETE_PROBE_ENTITY_NAME = "blockDeleteProbe";
323
+
324
+ const blockDeleteProbeEntity = createEntity({
325
+ table: "read_forget_subject_blockdelete_probe",
326
+ retention: { keepFor: "10y", strategy: "blockDelete" },
327
+ fields: {
328
+ body: createTextField({
329
+ required: true,
330
+ maxLength: 200,
331
+ personal: { of: "id" },
332
+ find: "none",
333
+ }),
334
+ },
335
+ });
336
+ const blockDeleteProbeTable = buildEntityTable(
337
+ "forgetSubjectBlockDeleteProbe",
338
+ blockDeleteProbeEntity,
339
+ );
340
+ const blockDeleteProbeFeature = defineFeature("forget-subject-blockdelete-probe", (r) => {
341
+ r.entity(BLOCK_DELETE_PROBE_ENTITY_NAME, blockDeleteProbeEntity);
342
+ });
343
+
344
+ function blockDeleteProbeExecutor() {
345
+ return createEventStoreExecutor(blockDeleteProbeTable, blockDeleteProbeEntity, {
346
+ entityName: BLOCK_DELETE_PROBE_ENTITY_NAME,
347
+ });
348
+ }
349
+
350
+ describe("crypto-shredding :: forget-subject (record subject) retention gate, #2789", () => {
351
+ let retentionStack: TestStack;
352
+ let retentionKms: InMemoryKmsAdapter;
353
+
354
+ const RETENTION_TENANT: TenantId = testTenantId(24);
355
+ const dpoRetentionUser = {
356
+ id: "cccccccc-cccc-4ccc-8ccc-000000000004",
357
+ tenantId: RETENTION_TENANT,
358
+ roles: ["DataProtectionOfficer"],
359
+ };
360
+
361
+ beforeAll(async () => {
362
+ retentionStack = await setupTestStack({
363
+ features: [createCryptoShreddingFeature(), blockDeleteProbeFeature],
364
+ });
365
+ await unsafeCreateEntityTable(
366
+ retentionStack.db,
367
+ blockDeleteProbeEntity,
368
+ BLOCK_DELETE_PROBE_ENTITY_NAME,
369
+ );
370
+ await createEventsTable(retentionStack.db);
371
+ });
372
+
373
+ afterAll(async () => {
374
+ await retentionStack.cleanup();
375
+ });
376
+
377
+ beforeEach(async () => {
378
+ await resetTestTables(retentionStack.db, [blockDeleteProbeTable, eventsTable]);
379
+ retentionKms = new InMemoryKmsAdapter();
380
+ configurePiiSubjectKms(retentionKms);
381
+ });
382
+
383
+ afterEach(() => {
384
+ resetPiiSubjectKmsForTests();
385
+ });
386
+
387
+ test("blockDelete entity refuses the shred — key and plaintext survive, denial is audited", async () => {
388
+ const tenantDb = createTenantDb(retentionStack.db, RETENTION_TENANT, "system");
389
+ const plaintext = "ledger entry naming a third party";
390
+ const created = await blockDeleteProbeExecutor().create(
391
+ { body: plaintext },
392
+ dpoRetentionUser,
393
+ tenantDb,
394
+ );
395
+ if (!created.isSuccess) throw new Error("create failed");
396
+ const rowId = String(created.data.id);
397
+
398
+ const err = await retentionStack.http.writeErr(
399
+ FORGET,
400
+ {
401
+ subject: { kind: "record", entity: BLOCK_DELETE_PROBE_ENTITY_NAME, id: rowId },
402
+ reason: REASON,
403
+ },
404
+ dpoRetentionUser,
405
+ );
406
+ expect(err.httpStatus).toBe(403);
407
+ expect((err.details as { reason?: string } | undefined)?.reason).toBe(
408
+ TARGET_RECORD_RETENTION_BLOCK_DELETE,
409
+ );
410
+
411
+ // The load-bearing claim: blockDelete winning means the data survived,
412
+ // not merely that the request returned 403.
413
+ const stillReadable = await blockDeleteProbeExecutor().detail(
414
+ { id: rowId },
415
+ dpoRetentionUser,
416
+ tenantDb,
417
+ );
418
+ expect(stillReadable?.["body"]).toBe(plaintext);
419
+
420
+ const deniedEvents = (await selectMany(retentionStack.db, eventsTable, {
421
+ type: SUBJECT_FORGET_DENIED_EVENT_NAME,
422
+ })) as Array<{ payload: Record<string, unknown> }>;
423
+ expect(deniedEvents).toHaveLength(1);
424
+ expect(deniedEvents[0]?.payload).toMatchObject({
425
+ subjectKind: "record",
426
+ denial: TARGET_RECORD_RETENTION_BLOCK_DELETE,
427
+ reason: REASON,
428
+ forgottenBy: dpoRetentionUser.id,
429
+ });
430
+ });
431
+ });
@@ -262,7 +262,7 @@ describe("crypto-shredding :: forget-subject purges the derived search index (#1
262
262
  const probeEntity = createEntity({
263
263
  table: "read_forget_subject_search_probe",
264
264
  fields: {
265
- ownerId: createTextField({ required: true }),
265
+ ownerId: createTextField({ required: true, personal: false, reason: "pseudonymous_fk" }),
266
266
  userNote: createTextField({
267
267
  required: true,
268
268
  maxLength: 100,
@@ -8,3 +8,4 @@ export const CRYPTO_SHREDDING_AGGREGATE_TYPE = "crypto-shredding-subject";
8
8
  export const TARGET_TENANT_NOT_ADMIN_TENANT = "target_tenant_not_admin_tenant" as const;
9
9
  export const RECORD_ENTITY_NOT_REGISTERED = "record_entity_not_registered" as const;
10
10
  export const TARGET_RECORD_NOT_ADMIN_TENANT = "target_record_not_admin_tenant" as const;
11
+ export const TARGET_RECORD_RETENTION_BLOCK_DELETE = "target_record_retention_block_delete" as const;
@@ -44,6 +44,7 @@ import {
44
44
  SUBJECT_FORGET_DENIED_EVENT_NAME,
45
45
  SUBJECT_FORGOTTEN_EVENT_NAME,
46
46
  TARGET_RECORD_NOT_ADMIN_TENANT,
47
+ TARGET_RECORD_RETENTION_BLOCK_DELETE,
47
48
  TARGET_TENANT_NOT_ADMIN_TENANT,
48
49
  } from "../constants";
49
50
 
@@ -171,6 +172,28 @@ async function resolveTenantScopeDenial(
171
172
  return ownedInTenant ? undefined : memberDenied;
172
173
  }
173
174
 
175
+ // fw#2789: the host entity's OWN retention declaration gets the last word on
176
+ // a targeted row-shred — blockDelete (legally mandated physical retention,
177
+ // e.g. ledger/invoice text) means this command must refuse, not silently
178
+ // anonymize or proceed. Deliberately independent of the tenant feature/gate
179
+ // above: a retention obligation holds in single-tenant apps too, and running
180
+ // unconditionally (not nested under `features.has("tenant")`) keeps that
181
+ // true. Only the entity's declared `retention.strategy` is consulted here —
182
+ // NOT the data-retention feature's tenant-preset/override layering
183
+ // (resolveRetentionPolicy) — so a tenant cannot override its own way past a
184
+ // blockDelete declared on the entity.
185
+ function resolveRetentionDenial(
186
+ features: ReadonlyMap<string, FeatureDefinition>,
187
+ raw: SubjectIdInput,
188
+ ): WriteFailure | undefined {
189
+ if (raw.kind !== "record") return undefined;
190
+ const entity = findRegisteredEntity(features, raw.entity);
191
+ if (entity?.retention?.strategy !== "blockDelete") return undefined;
192
+ return writeFailure(
193
+ new AccessDeniedError({ details: { reason: TARGET_RECORD_RETENTION_BLOCK_DELETE } }),
194
+ );
195
+ }
196
+
174
197
  // Denied cross-tenant probes must still leave an audit trail (fw#2348).
175
198
  // Appended outside the handler tx (fw#2592): the caller's
176
199
  // `return tenantScopeDenial` rolls that tx back.
@@ -296,6 +319,25 @@ export const forgetSubjectWrite = defineWriteHandler({
296
319
  return auditFailure ?? tenantScopeDenial;
297
320
  }
298
321
 
322
+ // Runs AFTER the tenant gate (VORHER only means "before the shred"): a
323
+ // retention check ahead of the tenant gate would leak a foreign
324
+ // entity's retention posture to a cross-tenant prober.
325
+ const retentionDenial = resolveRetentionDenial(ctx.registry.features, raw);
326
+ if (retentionDenial) {
327
+ // Own reason constant, not tenantScopeDenial's `.error.code` pattern:
328
+ // AccessDeniedError.code is the generic "access_denied" for every
329
+ // branch, so re-deriving it here would make a blockDelete refusal
330
+ // indistinguishable from a cross-tenant one in the audit trail.
331
+ const auditFailure = await appendDenialAuditEvent(
332
+ ctx,
333
+ event,
334
+ subjectKey,
335
+ raw.kind,
336
+ TARGET_RECORD_RETENTION_BLOCK_DELETE,
337
+ );
338
+ return auditFailure ?? retentionDenial;
339
+ }
340
+
299
341
  // Erase BEFORE the audit append: if the append throws, the key is gone
300
342
  // but no event exists — a retry is a no-op erase plus the event. The
301
343
  // reverse order could leave an audit trail claiming a shred that never
@@ -40,7 +40,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
40
40
  const propertyEntity = createEntity({
41
41
  table: "read_t15a_properties",
42
42
  fields: {
43
- name: createTextField({ required: true }),
43
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
44
44
  customFields: customFieldsField(),
45
45
  },
46
46
  });
@@ -37,7 +37,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
37
37
  const propertyEntity = createEntity({
38
38
  table: "read_xt_properties",
39
39
  fields: {
40
- name: createTextField({ required: true }),
40
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
41
41
  customFields: customFieldsField(),
42
42
  },
43
43
  });
@@ -33,7 +33,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
33
33
  const propertyEntity = createEntity({
34
34
  table: "read_xt_set_properties",
35
35
  fields: {
36
- name: createTextField({ required: true }),
36
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
37
37
  customFields: customFieldsField(),
38
38
  },
39
39
  });
@@ -42,7 +42,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
42
42
  const propertyEntity = createEntity({
43
43
  table: "read_t1_properties",
44
44
  fields: {
45
- name: createTextField({ required: true }),
45
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
46
46
  customFields: customFieldsField(),
47
47
  },
48
48
  });
@@ -34,7 +34,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
34
34
  const propertyEntity = createEntity({
35
35
  table: "read_t15b_properties",
36
36
  fields: {
37
- name: createTextField({ required: true }),
37
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
38
38
  customFields: customFieldsField(),
39
39
  },
40
40
  });
@@ -30,7 +30,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
30
30
  const propertyEntity = createEntity({
31
31
  table: "read_t15e_properties",
32
32
  fields: {
33
- name: createTextField({ required: true }),
33
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
34
34
  customFields: customFieldsField(),
35
35
  },
36
36
  });
@@ -37,7 +37,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
37
37
  const propertyEntity = createEntity({
38
38
  table: "read_t15d_properties",
39
39
  fields: {
40
- name: createTextField({ required: true }),
40
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
41
41
  customFields: customFieldsField(),
42
42
  },
43
43
  });
@@ -62,7 +62,7 @@ const PAST = (): Instant => getTemporal().Now.instant().subtract({ minutes: 1 })
62
62
  const propertyEntity = createEntity({
63
63
  table: "read_t15c_properties",
64
64
  fields: {
65
- name: createTextField({ required: true }),
65
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
66
66
  customFields: customFieldsField(),
67
67
  },
68
68
  });
@@ -10,7 +10,7 @@ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
10
10
  const propertyEntity = createEntity({
11
11
  table: "read_test_properties",
12
12
  fields: {
13
- name: createTextField({ required: true }),
13
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
14
14
  customFields: customFieldsField(),
15
15
  },
16
16
  });