@cosmicdrift/kumiko-bundled-features 0.252.0 → 0.253.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 (76) 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/cap-counter/entity.ts +8 -1
  11. package/src/config/table.ts +4 -2
  12. package/src/crypto-shredding/__tests__/forget-subject-record.integration.test.ts +141 -1
  13. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +1 -1
  14. package/src/crypto-shredding/constants.ts +1 -0
  15. package/src/crypto-shredding/handlers/forget-subject.write.ts +42 -0
  16. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
  17. package/src/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +1 -1
  18. package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +1 -1
  19. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +1 -1
  20. package/src/custom-fields/__tests__/field-access.integration.test.ts +1 -1
  21. package/src/custom-fields/__tests__/quota.integration.test.ts +1 -1
  22. package/src/custom-fields/__tests__/retention.integration.test.ts +1 -1
  23. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
  24. package/src/custom-fields/__tests__/wire-for-entity.test.ts +1 -1
  25. package/src/custom-fields/entity.ts +24 -4
  26. package/src/data-retention/__tests__/resolver.test.ts +6 -4
  27. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
  28. package/src/delivery/__tests__/delivery.integration.test.ts +3 -3
  29. package/src/delivery/tables.ts +6 -2
  30. package/src/document-ingest-foundation/entity.ts +6 -2
  31. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +12 -2
  32. package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +7 -1
  33. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +12 -2
  34. package/src/folders/__tests__/folders.integration.test.ts +8 -1
  35. package/src/folders/entity.ts +27 -5
  36. package/src/folders-user-data/__tests__/hooks.integration.test.ts +8 -1
  37. package/src/form-draft/entity.ts +6 -1
  38. package/src/inbound-mail-foundation/entities.ts +57 -12
  39. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +111 -1
  40. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +2 -2
  41. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +6 -1
  42. package/src/jobs/db/queries/stale-run-sweep.ts +36 -14
  43. package/src/jobs/feature.ts +5 -0
  44. package/src/jobs/job-run-logger.ts +7 -2
  45. package/src/jobs/job-run-table.ts +11 -6
  46. package/src/ledger/entity.ts +27 -7
  47. package/src/managed-pages/screens/branding-screen.ts +27 -5
  48. package/src/managed-pages/table.ts +23 -4
  49. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +8 -1
  50. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +8 -1
  51. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +20 -3
  52. package/src/notes-history/__tests__/notes-history.integration.test.ts +8 -1
  53. package/src/notes-history/entity.ts +12 -2
  54. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +8 -1
  55. package/src/personal-access-tokens/schema/api-token.ts +29 -1
  56. package/src/secrets/table.ts +1 -1
  57. package/src/sessions/schema/user-session.ts +4 -0
  58. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +12 -2
  59. package/src/tags/__tests__/tags.integration.test.ts +8 -1
  60. package/src/tags/entity.ts +20 -5
  61. package/src/template-resolver/table.ts +8 -7
  62. package/src/template-resolver/user-content-table.ts +6 -4
  63. package/src/tenant/invitation-table.ts +6 -1
  64. package/src/tenant/membership-table.ts +2 -2
  65. package/src/tenant/schema/tenant.ts +22 -3
  66. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +1 -1
  67. package/src/tier-engine/entity.ts +12 -2
  68. package/src/user/schema/user.ts +15 -1
  69. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -2
  70. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +1 -1
  71. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +1 -1
  72. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +3 -1
  73. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
  74. package/src/user-data-rights/schema/download-attempt.ts +25 -7
  75. package/src/user-data-rights/schema/download-token.ts +4 -0
  76. package/src/user-data-rights/schema/export-job.ts +6 -0
@@ -61,15 +61,30 @@ export const fieldDefinitionEntity = createEntity({
61
61
  fields: {
62
62
  // Ziel-Entity-Name, für die dieses Field definiert wird (z.B. "property",
63
63
  // "customer"). Max 64 char passt zu Kumiko's entity-name-Convention.
64
- entityName: createTextField({ required: true, maxLength: 64 }),
64
+ entityName: createTextField({
65
+ required: true,
66
+ maxLength: 64,
67
+ personal: false,
68
+ reason: "technical_reference",
69
+ }),
65
70
 
66
71
  // Field-Key (z.B. "internalNumber", "vipFlag") — kebab-case oder camelCase
67
72
  // erlaubt; UI-rendering nutzt label statt fieldKey.
68
- fieldKey: createTextField({ required: true, maxLength: 64 }),
73
+ fieldKey: createTextField({
74
+ required: true,
75
+ maxLength: 64,
76
+ personal: false,
77
+ reason: "technical_reference",
78
+ }),
69
79
 
70
80
  // Field-Type aus dem SUPPORTED_FIELD_TYPES-Set. Validiert via Zod im
71
81
  // write-handler.
72
- type: createTextField({ required: true, maxLength: 16 }),
82
+ type: createTextField({
83
+ required: true,
84
+ maxLength: 16,
85
+ personal: false,
86
+ reason: "technical_reference",
87
+ }),
73
88
 
74
89
  // Required-Flag — wird beim Entity-Write gegen den value gecheckt
75
90
  // (Stammfeld-identische Semantik, Spec-Promise Z.4).
@@ -87,6 +102,11 @@ export const fieldDefinitionEntity = createEntity({
87
102
  // defaultValue, fieldAccess, i18n-labels. Beim Write-Validation
88
103
  // dehydriert der handler dies zurück zu einer r.field.X()-Instanz und
89
104
  // nutzt deren .schema für value-Validation.
90
- serializedField: createTextField({ required: true, maxLength: 65536 }),
105
+ serializedField: createTextField({
106
+ required: true,
107
+ maxLength: 65536,
108
+ personal: false,
109
+ reason: "technical_reference",
110
+ }),
91
111
  },
92
112
  });
@@ -8,7 +8,7 @@ import { resolveRetentionPolicy } from "../resolver";
8
8
  describe("resolveRetentionPolicy — Layer-Resolution", () => {
9
9
  test("Layer 1 Entity-Default greift wenn weder Preset noch Override", () => {
10
10
  const entity = createEntity({
11
- fields: { foo: createTextField() },
11
+ fields: { foo: createTextField({ personal: false, reason: "technical_reference" }) },
12
12
  retention: { keepFor: "30d", strategy: "hardDelete", reference: "createdAt" },
13
13
  });
14
14
 
@@ -29,7 +29,7 @@ describe("resolveRetentionPolicy — Layer-Resolution", () => {
29
29
 
30
30
  test("Layer 2 Preset überschreibt Entity-Default", () => {
31
31
  const entity = createEntity({
32
- fields: { foo: createTextField() },
32
+ fields: { foo: createTextField({ personal: false, reason: "technical_reference" }) },
33
33
  retention: { keepFor: "7d", strategy: "hardDelete" },
34
34
  });
35
35
 
@@ -74,7 +74,9 @@ describe("resolveRetentionPolicy — Layer-Resolution", () => {
74
74
  });
75
75
 
76
76
  test("Entity ohne retention + kein Preset + kein Override → policy=null + source=none", () => {
77
- const entity = createEntity({ fields: { foo: createTextField() } });
77
+ const entity = createEntity({
78
+ fields: { foo: createTextField({ personal: false, reason: "technical_reference" }) },
79
+ });
78
80
 
79
81
  const result = resolveRetentionPolicy({
80
82
  entityName: "ticket",
@@ -172,7 +174,7 @@ describe("resolveRetentionPolicy — override-incomplete-Guard", () => {
172
174
  describe("resolveRetentionPolicy — Edge-Cases", () => {
173
175
  test("default-Preset ist leer → fällt zurück auf entity-default", () => {
174
176
  const entity = createEntity({
175
- fields: { foo: createTextField() },
177
+ fields: { foo: createTextField({ personal: false, reason: "technical_reference" }) },
176
178
  retention: { keepFor: "30d", strategy: "hardDelete" },
177
179
  });
178
180
 
@@ -28,7 +28,9 @@ import { runRetentionCleanup } from "../run-retention-cleanup";
28
28
  // hardDelete, default-reference (createdAt → alias insertedAt → Spalte inserted_at).
29
29
  const widgetEntity = createEntity({
30
30
  table: "read_c7_widget",
31
- fields: { label: createTextField({ required: true }) },
31
+ fields: {
32
+ label: createTextField({ required: true, personal: false, reason: "is_business_data" }),
33
+ },
32
34
  retention: { keepFor: "30d", strategy: "hardDelete" },
33
35
  });
34
36
 
@@ -36,21 +38,27 @@ const widgetEntity = createEntity({
36
38
  const gadgetEntity = createEntity({
37
39
  table: "read_c7_gadget",
38
40
  softDelete: true,
39
- fields: { label: createTextField({ required: true }) },
41
+ fields: {
42
+ label: createTextField({ required: true, personal: false, reason: "is_business_data" }),
43
+ },
40
44
  retention: { keepFor: "30d", strategy: "softDelete" },
41
45
  });
42
46
 
43
47
  // Keine Policy → darf nie angefasst werden.
44
48
  const plainEntity = createEntity({
45
49
  table: "read_c7_plain",
46
- fields: { label: createTextField({ required: true }) },
50
+ fields: {
51
+ label: createTextField({ required: true, personal: false, reason: "is_business_data" }),
52
+ },
47
53
  });
48
54
 
49
55
  // reference="lastSeenAt" ist Boot-valide (Framework-Timestamp-Allowlist), aber
50
56
  // die Spalte existiert auf diesem Entity nicht → Guard muss skippen.
51
57
  const staleEntity = createEntity({
52
58
  table: "read_c7_stale",
53
- fields: { label: createTextField({ required: true }) },
59
+ fields: {
60
+ label: createTextField({ required: true, personal: false, reason: "is_business_data" }),
61
+ },
54
62
  retention: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
55
63
  });
56
64
 
@@ -181,9 +181,9 @@ const appFeature = defineFeature("app", (r) => {
181
181
  // Feature with CRUD entity + declarative r.notification()
182
182
  const ticketEntity = createEntity({
183
183
  fields: {
184
- title: createTextField({ required: true }),
185
- assigneeId: createTextField(),
186
- status: createTextField({ required: true }),
184
+ title: createTextField({ required: true, personal: false, reason: "technical_reference" }),
185
+ assigneeId: createTextField({ personal: false, reason: "pseudonymous_fk" }),
186
+ status: createTextField({ required: true, personal: false, reason: "technical_reference" }),
187
187
  },
188
188
  });
189
189
  const ticketTable = buildEntityTable("ticket", ticketEntity);
@@ -77,8 +77,12 @@ export const notificationPreferenceEntity = createEntity({
77
77
  table: "read_notification_preferences",
78
78
  fields: {
79
79
  userId: createTextField({ required: true, personal: false, reason: "pseudonymous_fk" }),
80
- notificationType: createTextField({ required: true }), // qualified name or "*"
81
- channel: createTextField({ required: true }), // "inApp", "email", "push", or "*"
80
+ notificationType: createTextField({
81
+ required: true,
82
+ personal: false,
83
+ reason: "technical_reference",
84
+ }), // qualified name or "*"
85
+ channel: createTextField({ required: true, personal: false, reason: "technical_reference" }), // "inApp", "email", "push", or "*"
82
86
  enabled: createBooleanField({ default: true }),
83
87
  },
84
88
  });
@@ -35,8 +35,12 @@ export type DocumentExtractMeta = {
35
35
  export const documentExtractEntity = createEntity({
36
36
  table: "read_document_extracts",
37
37
  fields: {
38
- fileRefId: createTextField({ required: true }),
39
- storageKey: createTextField({ required: true }),
38
+ fileRefId: createTextField({ required: true, personal: false, reason: "pseudonymous_fk" }),
39
+ storageKey: createTextField({
40
+ required: true,
41
+ personal: false,
42
+ reason: "technical_reference",
43
+ }),
40
44
  // Holds the full extracted document text (PII). `personal: "tenant"`,
41
45
  // NOT `encrypted: true` (#1621): the master-key path has no erasure subject,
42
46
  // so nothing here would ever be shreddable. Tenant-subject ciphertext
@@ -42,7 +42,12 @@ import { createRedisToggleSyncSignal, type RedisToggleSyncSignal } from "../togg
42
42
  const widgetEntity = createEntity({
43
43
  table: "read_widgets",
44
44
  fields: {
45
- name: createTextField({ required: true, maxLength: 100 }),
45
+ name: createTextField({
46
+ required: true,
47
+ maxLength: 100,
48
+ personal: false,
49
+ reason: "technical_reference",
50
+ }),
46
51
  active: createBooleanField({ default: true }),
47
52
  },
48
53
  });
@@ -88,7 +93,12 @@ function widgetFeature(): FeatureDefinition {
88
93
  const widgetAuditEntity = createEntity({
89
94
  table: "widget_audits",
90
95
  fields: {
91
- widgetName: createTextField({ required: true, maxLength: 100 }),
96
+ widgetName: createTextField({
97
+ required: true,
98
+ maxLength: 100,
99
+ personal: false,
100
+ reason: "technical_reference",
101
+ }),
92
102
  },
93
103
  });
94
104
  const widgetAuditTable = buildEntityTable("widget-audit", widgetAuditEntity);
@@ -30,7 +30,13 @@ describe("Sprint 8a: registered.query SYSTEM_TENANT_ID convention", () => {
30
30
  const observed: string[] = [];
31
31
 
32
32
  const dummy = defineFeature("dummy", (r) => {
33
- r.entity("widget", createEntity({ table: "Widgets", fields: { name: createTextField() } }));
33
+ r.entity(
34
+ "widget",
35
+ createEntity({
36
+ table: "Widgets",
37
+ fields: { name: createTextField({ personal: false, reason: "technical_reference" }) },
38
+ }),
39
+ );
34
40
  });
35
41
 
36
42
  const runtime: GlobalFeatureToggleRuntime | null = null;
@@ -47,8 +47,18 @@ const projectOwnership: NonNullable<EntityDefinition["access"]> = {
47
47
  const projectEntity = createEntity({
48
48
  table: PROJECT_TABLE,
49
49
  fields: {
50
- teamId: createTextField({ required: true, maxLength: 64 }),
51
- name: createTextField({ required: true, maxLength: 64 }),
50
+ teamId: createTextField({
51
+ required: true,
52
+ maxLength: 64,
53
+ personal: false,
54
+ reason: "technical_reference",
55
+ }),
56
+ name: createTextField({
57
+ required: true,
58
+ maxLength: 64,
59
+ personal: false,
60
+ reason: "technical_reference",
61
+ }),
52
62
  },
53
63
  access: projectOwnership,
54
64
  });
@@ -36,7 +36,14 @@ const foldersFeature = createFoldersFeature();
36
36
  const CREDIT_TABLE = "folders_test_credits";
37
37
  const creditEntity = createEntity({
38
38
  table: CREDIT_TABLE,
39
- fields: { name: createTextField({ required: true, maxLength: 64 }) },
39
+ fields: {
40
+ name: createTextField({
41
+ required: true,
42
+ maxLength: 64,
43
+ personal: false,
44
+ reason: "technical_reference",
45
+ }),
46
+ },
40
47
  });
41
48
  const hostFixturesFeature = defineFeature("folders-test-host-fixtures", (r) => {
42
49
  r.entity("credit", creditEntity);
@@ -10,10 +10,17 @@ export const folderEntity = createEntity({
10
10
  description:
11
11
  "One folder of a tenant's hierarchical catalog: a name plus an optional parentId pointing at another folder, so the rows together form a tree whose roots have no parent.",
12
12
  fields: {
13
- name: createTextField({ required: true, maxLength: 64 }),
13
+ // Catalog label, same reasoning as tags/entity.ts name: no author to
14
+ // anchor a subject key to, not user-identifying content.
15
+ name: createTextField({
16
+ required: true,
17
+ maxLength: 64,
18
+ personal: false,
19
+ reason: "catalog_label",
20
+ }),
14
21
  // Parent folder id, or absent for a root folder. No FK (event-sourced); a
15
22
  // dangling parentId renders the folder at root — folders-view guards cycles.
16
- parentId: createTextField({ maxLength: 64 }),
23
+ parentId: createTextField({ maxLength: 64, personal: false, reason: "technical_reference" }),
17
24
  },
18
25
  });
19
26
 
@@ -38,9 +45,24 @@ export const folderAssignmentEntity = createEntity({
38
45
  "The membership row recording which folder one host entity, addressed by entityType and entityId, is filed in. At most one row exists per entity, so filing it elsewhere changes this row's folderId rather than adding a second.",
39
46
  softDelete: true,
40
47
  fields: {
41
- folderId: createTextField({ required: true, maxLength: 64 }),
42
- entityType: createTextField({ required: true, maxLength: 64 }),
48
+ folderId: createTextField({
49
+ required: true,
50
+ maxLength: 64,
51
+ personal: false,
52
+ reason: "technical_reference",
53
+ }),
54
+ entityType: createTextField({
55
+ required: true,
56
+ maxLength: 64,
57
+ personal: false,
58
+ reason: "technical_reference",
59
+ }),
43
60
  // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
44
- entityId: createTextField({ required: true, maxLength: 128 }),
61
+ entityId: createTextField({
62
+ required: true,
63
+ maxLength: 128,
64
+ personal: false,
65
+ reason: "technical_reference",
66
+ }),
45
67
  },
46
68
  });
@@ -26,7 +26,14 @@ import { folderAssignmentExportHook } from "../hooks";
26
26
  const CREDIT_TABLE = "folders_export_test_credits";
27
27
  const creditEntity = createEntity({
28
28
  table: CREDIT_TABLE,
29
- fields: { name: createTextField({ required: true, maxLength: 64 }) },
29
+ fields: {
30
+ name: createTextField({
31
+ required: true,
32
+ maxLength: 64,
33
+ personal: false,
34
+ reason: "technical_reference",
35
+ }),
36
+ },
30
37
  });
31
38
  const hostFixturesFeature = defineFeature("folders-export-test-host-fixtures", (r) => {
32
39
  r.entity("credit", creditEntity);
@@ -28,7 +28,12 @@ export const formDraftEntity = createEntity({
28
28
  personal: "ref",
29
29
  required: true,
30
30
  }),
31
- draftKey: createTextField({ required: true, maxLength: FORM_DRAFT_KEY_MAX_LENGTH }),
31
+ draftKey: createTextField({
32
+ required: true,
33
+ maxLength: FORM_DRAFT_KEY_MAX_LENGTH,
34
+ personal: false,
35
+ reason: "technical_reference",
36
+ }),
32
37
  // The blob shape is fixed by issue #1889, not left to the caller:
33
38
  // { values: Record<string, unknown>, stepIndex: number, savedAt: string }.
34
39
  // Free-form because `values` mirrors whatever fields the in-progress
@@ -34,13 +34,23 @@ import {
34
34
  export const mailAccountEntity = createEntity({
35
35
  table: "read_mail_accounts",
36
36
  fields: {
37
- provider: createTextField({ required: true, maxLength: 50 }),
38
- authMethod: createTextField({ required: true, maxLength: 30 }),
37
+ provider: createTextField({
38
+ required: true,
39
+ maxLength: 50,
40
+ personal: false,
41
+ reason: "technical_reference",
42
+ }),
43
+ authMethod: createTextField({
44
+ required: true,
45
+ maxLength: 30,
46
+ personal: false,
47
+ reason: "technical_reference",
48
+ }),
39
49
  // null = tenant-geteiltes Postfach (info@), gesetzt = persönliches
40
50
  // Postfach dieses Users. Sichtbarkeits-Filter in den list-queries:
41
51
  // Owner + TenantAdmin (Compliance); KEIN Crypto-Subject-Wechsel in
42
52
  // V1 (Subject bleibt Tenant, siehe Header).
43
- ownerUserId: createTextField({ maxLength: 36 }),
53
+ ownerUserId: createTextField({ maxLength: 36, personal: false, reason: "pseudonymous_fk" }),
44
54
  // Tenant-authored mailbox label, not a person's name.
45
55
  displayName: createTextField({
46
56
  maxLength: 200,
@@ -49,8 +59,13 @@ export const mailAccountEntity = createEntity({
49
59
  }),
50
60
  // Postfach-Adresse — PII des Tenants.
51
61
  address: createTextField({ required: true, maxLength: 1000, personal: "tenant", find: "none" }),
52
- status: createTextField({ required: true, maxLength: 30 }),
53
- watchState: createTextField({ maxLength: 100 }),
62
+ status: createTextField({
63
+ required: true,
64
+ maxLength: 30,
65
+ personal: false,
66
+ reason: "technical_reference",
67
+ }),
68
+ watchState: createTextField({ maxLength: 100, personal: false, reason: "technical_reference" }),
54
69
  connectedAt: createTimestampField({ required: true }),
55
70
  },
56
71
  });
@@ -61,13 +76,28 @@ export const mailAccountEntity = createEntity({
61
76
  export const inboundMessageEntity = createEntity({
62
77
  table: "read_inbound_messages",
63
78
  fields: {
64
- accountId: createTextField({ required: true, maxLength: 36 }),
79
+ accountId: createTextField({
80
+ required: true,
81
+ maxLength: 36,
82
+ personal: false,
83
+ reason: "technical_reference",
84
+ }),
65
85
  // Scope-Vererbung vom Account zum Ingest-Zeitpunkt — Messages eines
66
86
  // persönlichen Postfachs sind nur für den Owner (+ TenantAdmin)
67
87
  // sichtbar, ohne Join auf read_mail_accounts.
68
- ownerUserId: createTextField({ maxLength: 36 }),
69
- messageIdHeader: createTextField({ required: true, maxLength: 500 }),
70
- threadKey: createTextField({ required: true, maxLength: 500 }),
88
+ ownerUserId: createTextField({ maxLength: 36, personal: false, reason: "pseudonymous_fk" }),
89
+ messageIdHeader: createTextField({
90
+ required: true,
91
+ maxLength: 500,
92
+ personal: false,
93
+ reason: "technical_reference",
94
+ }),
95
+ threadKey: createTextField({
96
+ required: true,
97
+ maxLength: 500,
98
+ personal: false,
99
+ reason: "technical_reference",
100
+ }),
71
101
  from: createTextField({ required: true, maxLength: 2000, personal: "tenant", find: "none" }),
72
102
  // JSON-stringified string[] — als Ganzes encrypted.
73
103
  to: createTextField({ maxLength: 8000, personal: "tenant", find: "none" }),
@@ -75,8 +105,8 @@ export const inboundMessageEntity = createEntity({
75
105
  subject: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
76
106
  snippet: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
77
107
  receivedAt: createTimestampField({ required: true }),
78
- bodyRef: createTextField({ maxLength: 500 }),
79
- scope: createTextField({ maxLength: 200 }),
108
+ bodyRef: createTextField({ maxLength: 500, personal: false, reason: "technical_reference" }),
109
+ scope: createTextField({ maxLength: 200, personal: false, reason: "technical_reference" }),
80
110
  },
81
111
  });
82
112
 
@@ -85,7 +115,12 @@ export const inboundMessageEntity = createEntity({
85
115
  export const mailThreadEntity = createEntity({
86
116
  table: "read_mail_threads",
87
117
  fields: {
88
- threadKey: createTextField({ required: true, maxLength: 500 }),
118
+ threadKey: createTextField({
119
+ required: true,
120
+ maxLength: 500,
121
+ personal: false,
122
+ reason: "technical_reference",
123
+ }),
89
124
  subject: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
90
125
  lastMessageAt: createTimestampField({ required: true }),
91
126
  messageCount: createNumberField({ required: true, integer: true }),
@@ -123,16 +158,22 @@ export const syncCursorEntity = createEntity({
123
158
  required: true,
124
159
  maxLength: 36,
125
160
  access: { write: access.privileged },
161
+ personal: false,
162
+ reason: "technical_reference",
126
163
  }),
127
164
  scope: createTextField({
128
165
  required: true,
129
166
  maxLength: 200,
130
167
  access: { write: access.privileged },
168
+ personal: false,
169
+ reason: "technical_reference",
131
170
  }),
132
171
  cursor: createTextField({
133
172
  required: true,
134
173
  maxLength: 2000,
135
174
  access: { write: access.privileged },
175
+ personal: false,
176
+ reason: "technical_reference",
136
177
  }),
137
178
  updatedAt: createTimestampField({
138
179
  required: true,
@@ -153,11 +194,15 @@ export const seenMessageEntity = createEntity({
153
194
  required: true,
154
195
  maxLength: 36,
155
196
  access: { write: access.privileged },
197
+ personal: false,
198
+ reason: "technical_reference",
156
199
  }),
157
200
  providerMessageId: createTextField({
158
201
  required: true,
159
202
  maxLength: 500,
160
203
  access: { write: access.privileged },
204
+ personal: false,
205
+ reason: "technical_reference",
161
206
  }),
162
207
  seenAt: createTimestampField({
163
208
  required: true,
@@ -14,7 +14,12 @@ import {
14
14
  isPiiCiphertext,
15
15
  PII_ERASED_SENTINEL,
16
16
  } from "@cosmicdrift/kumiko-framework/crypto";
17
- import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { sql } from "@cosmicdrift/kumiko-framework/db";
18
+ import {
19
+ createRegistry,
20
+ defineFeature,
21
+ SYSTEM_TENANT_ID,
22
+ } from "@cosmicdrift/kumiko-framework/engine";
18
23
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
19
24
  import {
20
25
  createTestDb,
@@ -29,9 +34,12 @@ import {
29
34
  import {
30
35
  resetPiiSubjectKmsForTests,
31
36
  resetTestTables,
37
+ seedRow,
32
38
  sleep,
33
39
  } from "@cosmicdrift/kumiko-framework/testing";
40
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
34
41
  import { JobHandlers, JobQueries } from "../constants";
42
+ import { markStaleJobRunsFailed, STALE_JOB_RUN_ERROR } from "../db/queries/stale-run-sweep";
35
43
  import { createJobsFeature } from "../feature";
36
44
  import { createJobRunLogger } from "../job-run-logger";
37
45
  import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
@@ -331,6 +339,108 @@ describe("jobs run-failed error column under KMS (#2307)", () => {
331
339
  });
332
340
  });
333
341
 
342
+ // markStaleJobRunsFailed (stale-run-sweep.ts) is a second, separate writer
343
+ // of the `error` column outside onJobFailed's per-row encrypt above — a
344
+ // crash-recovery batch sweep that can match many rows belonging to
345
+ // different triggering users in one pass. It must encrypt `error` per row
346
+ // under each row's own subject instead of writing STALE_JOB_RUN_ERROR in
347
+ // the clear, or a live KMS would leave plaintext sitting next to
348
+ // ciphertext siblings and detail.query.ts's decrypt would fail loud on it.
349
+ describe("jobs stale-run sweep error column under KMS", () => {
350
+ async function seedStaleRunning(triggeredById?: string): Promise<string> {
351
+ const id = generateId();
352
+ await seedRow(testDb.db, jobRunsTable, {
353
+ id,
354
+ tenantId: SYSTEM_TENANT_ID,
355
+ insertedById: "system",
356
+ jobName: "app:job:export",
357
+ bullJobId: `bull-${id}`,
358
+ status: "running",
359
+ attempt: 1,
360
+ startedAt: sql`now() - interval '48 hours'`,
361
+ ...(triggeredById !== undefined ? { triggeredById } : {}),
362
+ });
363
+ return id;
364
+ }
365
+
366
+ test("stale row's error is stored as ciphertext under its own triggering user, not the sentinel", async () => {
367
+ const id = await seedStaleRunning(USER_ID);
368
+
369
+ const result = await markStaleJobRunsFailed(testDb.db, 24);
370
+ expect(result.runsMarkedFailed).toBe(1);
371
+
372
+ const row = await fetchOne(testDb.db, jobRunsTable, { id });
373
+ expect(row?.["status"]).toBe("failed");
374
+ expect(isPiiCiphertext(row?.["error"])).toBe(true);
375
+ expect(String(row?.["error"])).toContain(`user:${USER_ID}`);
376
+
377
+ const back = await decryptPiiFieldValues({ error: row?.["error"] }, ["error"], kms, {
378
+ requestId: "t",
379
+ });
380
+ expect(back["error"]).toBe(STALE_JOB_RUN_ERROR);
381
+ });
382
+
383
+ test("two stale rows from different users each decrypt under their own key", async () => {
384
+ const OTHER_USER_ID = "u-pii-stale-other";
385
+ const idA = await seedStaleRunning(USER_ID);
386
+ const idB = await seedStaleRunning(OTHER_USER_ID);
387
+
388
+ const result = await markStaleJobRunsFailed(testDb.db, 24);
389
+ expect(result.runsMarkedFailed).toBe(2);
390
+
391
+ const rowA = await fetchOne(testDb.db, jobRunsTable, { id: idA });
392
+ const rowB = await fetchOne(testDb.db, jobRunsTable, { id: idB });
393
+ expect(String(rowA?.["error"])).toContain(`user:${USER_ID}`);
394
+ expect(String(rowB?.["error"])).toContain(`user:${OTHER_USER_ID}`);
395
+
396
+ const backB = await decryptPiiFieldValues({ error: rowB?.["error"] }, ["error"], kms, {
397
+ requestId: "t",
398
+ });
399
+ expect(backB["error"]).toBe(STALE_JOB_RUN_ERROR);
400
+ });
401
+
402
+ test("erase subject key before the sweep runs → error lands as the erased sentinel, sweep still succeeds", async () => {
403
+ // Create the subject's key first — eraseKey is a no-op on an unknown
404
+ // subject (InMemoryKmsAdapter's tombstone contract), same as the
405
+ // onJobFailed case above. A payload is required here: onJobStart only
406
+ // touches the KMS (creating the subject's key) when there's a payload
407
+ // to encrypt (see encryptStartedPayload's null short-circuit).
408
+ await logger.onJobStart?.("app:job:export", "bull-warm-key", {
409
+ triggeredById: USER_ID,
410
+ payload: SECRET_PAYLOAD,
411
+ });
412
+ await kms.eraseKey({ kind: "user", userId: USER_ID });
413
+
414
+ const id = await seedStaleRunning(USER_ID);
415
+
416
+ const result = await markStaleJobRunsFailed(testDb.db, 24);
417
+ expect(result.runsMarkedFailed).toBe(1);
418
+
419
+ const row = await fetchOne(testDb.db, jobRunsTable, { id });
420
+ expect(row?.["status"]).toBe("failed");
421
+ expect(row?.["error"]).toBe(PII_ERASED_SENTINEL);
422
+ });
423
+
424
+ test("system-triggered stale row (no triggeredById) keeps a plaintext error", async () => {
425
+ const id = await seedStaleRunning();
426
+
427
+ await markStaleJobRunsFailed(testDb.db, 24);
428
+
429
+ const row = await fetchOne(testDb.db, jobRunsTable, { id });
430
+ expect(row?.["error"]).toBe(STALE_JOB_RUN_ERROR);
431
+ });
432
+
433
+ test("without a KMS the error column stays plaintext (rollout mode)", async () => {
434
+ const id = await seedStaleRunning(USER_ID);
435
+ resetPiiSubjectKmsForTests();
436
+
437
+ await markStaleJobRunsFailed(testDb.db, 24);
438
+
439
+ const row = await fetchOne(testDb.db, jobRunsTable, { id });
440
+ expect(row?.["error"]).toBe(STALE_JOB_RUN_ERROR);
441
+ });
442
+ });
443
+
334
444
  // The suite above proves the write side (row is ciphertext) and decrypts by
335
445
  // calling decryptPiiFieldValues directly in the test body — neither touches
336
446
  // detail.query.ts's own decrypt wrapper. This block dispatches the real
@@ -49,8 +49,8 @@ import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
49
49
  const itemEntity = createEntity({
50
50
  table: "read_rebuild_items",
51
51
  fields: {
52
- groupId: createTextField({ required: true }),
53
- name: createTextField({ required: true }),
52
+ groupId: createTextField({ required: true, personal: false, reason: "technical_reference" }),
53
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
54
54
  },
55
55
  });
56
56
  const itemTable = buildEntityTable("rebuild-item", itemEntity);
@@ -38,7 +38,12 @@ const REINDEX_ENTITY_JOB = "jobs:job:reindex-entity";
38
38
  const widgetEntity = createEntity({
39
39
  table: "read_reindex_job_widgets",
40
40
  fields: {
41
- name: createTextField({ required: true, searchable: true }),
41
+ name: createTextField({
42
+ required: true,
43
+ searchable: true,
44
+ personal: false,
45
+ reason: "technical_reference",
46
+ }),
42
47
  },
43
48
  });
44
49