@cosmicdrift/kumiko-bundled-features 0.209.1 → 0.210.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 (41) hide show
  1. package/package.json +8 -8
  2. package/src/auth-mfa/schema/user-mfa.ts +8 -5
  3. package/src/auth-mfa-user-data/hooks.ts +9 -8
  4. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -2
  5. package/src/billing-foundation/entities.ts +5 -3
  6. package/src/billing-foundation/tenant-destroy-hook.ts +1 -1
  7. package/src/compliance-profiles/schema/profile-selection.ts +2 -1
  8. package/src/config/table.ts +4 -1
  9. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +4 -4
  10. package/src/custom-fields/feature.ts +2 -2
  11. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
  12. package/src/data-retention/schema/tenant-retention-override.ts +6 -3
  13. package/src/delivery/tables.ts +1 -1
  14. package/src/document-ingest-foundation/entity.ts +6 -3
  15. package/src/files/README.md +11 -11
  16. package/src/form-draft/entity.ts +6 -3
  17. package/src/inbound-mail-foundation/entities.ts +13 -11
  18. package/src/inbound-mail-foundation/feature.ts +5 -5
  19. package/src/ledger/entity.ts +5 -3
  20. package/src/managed-pages/screens/branding-screen.ts +2 -1
  21. package/src/managed-pages/table.ts +3 -2
  22. package/src/notes-history/entity.ts +8 -4
  23. package/src/notes-history-user-data/hooks.ts +6 -5
  24. package/src/personal-access-tokens/schema/api-token.ts +2 -1
  25. package/src/sessions/feature.ts +0 -4
  26. package/src/sessions/schema/user-session.ts +4 -2
  27. package/src/tags/entity.ts +11 -5
  28. package/src/template-resolver/__tests__/collection-ownership.integration.test.ts +1 -1
  29. package/src/template-resolver/table.ts +4 -1
  30. package/src/template-resolver/user-content-table.ts +5 -2
  31. package/src/template-resolver-user-data/hooks.ts +6 -5
  32. package/src/tenant/invitation-table.ts +3 -3
  33. package/src/tenant-lifecycle/boot-checks.ts +2 -2
  34. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +58 -0
  35. package/src/tenant-settings/feature.ts +12 -0
  36. package/src/user/schema/user.ts +5 -5
  37. package/src/user-data-rights/__tests__/boot-checks.test.ts +73 -9
  38. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +2 -2
  39. package/src/user-data-rights/boot-checks.ts +2 -2
  40. package/src/user-data-rights/schema/download-token.ts +12 -10
  41. package/src/user-data-rights/schema/export-job.ts +5 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.209.1",
3
+ "version": "0.210.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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.209.1",
130
- "@cosmicdrift/kumiko-framework": "0.209.1",
131
- "@cosmicdrift/kumiko-headless": "0.209.1",
132
- "@cosmicdrift/kumiko-renderer": "0.209.1",
133
- "@cosmicdrift/kumiko-renderer-web": "0.209.1",
134
- "@cosmicdrift/kumiko-types": "0.209.1",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.210.0",
130
+ "@cosmicdrift/kumiko-framework": "0.210.0",
131
+ "@cosmicdrift/kumiko-headless": "0.210.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.210.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.210.0",
134
+ "@cosmicdrift/kumiko-types": "0.210.0",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -160,6 +160,6 @@
160
160
  "devDependencies": {
161
161
  "@testing-library/user-event": "^14.6.1",
162
162
  "@types/qrcode": "^1.5.5",
163
- "@cosmicdrift/kumiko-locale-de": "0.209.1"
163
+ "@cosmicdrift/kumiko-locale-de": "0.210.0"
164
164
  }
165
165
  }
@@ -16,15 +16,17 @@ export const userMfaEntity = createEntity({
16
16
  // FK to the user row, not content — PII-naming heuristic would flag
17
17
  // "userId" as content; it's a pseudonymous reference, matching the same
18
18
  // annotation `config`'s userId column uses.
19
- userId: createTextField({ required: true, allowPlaintext: "pseudonymous-fk" }),
19
+ userId: createTextField({ required: true, personal: false, reason: "pseudonymous_fk" }),
20
20
  // Envelope-encrypted via the same MasterKeyProvider as secrets/config
21
21
  // (entity-field-encryption.ts) — no manual wrapDek/unwrapDek needed.
22
- // userOwned: crypto-shredding this field is exactly "revoke this
23
- // user's 2FA on GDPR forget", which is the correct behavior anyway.
22
+ // `personal: { of: "userId" }`: crypto-shredding this field is exactly
23
+ // "revoke this user's 2FA on GDPR forget", which is the correct
24
+ // behavior anyway.
24
25
  totpSecret: createTextField({
25
26
  required: true,
26
27
  encrypted: true,
27
- userOwned: { ownerField: "userId" },
28
+ personal: { of: "userId" },
29
+ find: "none",
28
30
  }),
29
31
  // JSON-stringified `{ hashes: string[] }` (argon2id hashes of unredeemed
30
32
  // recovery codes) — both encryption layers (entity-field envelope +
@@ -39,7 +41,8 @@ export const userMfaEntity = createEntity({
39
41
  recoveryCodes: createTextField({
40
42
  required: true,
41
43
  encrypted: true,
42
- userOwned: { ownerField: "userId" },
44
+ personal: { of: "userId" },
45
+ find: "none",
43
46
  }),
44
47
  enabledAt: createTimestampField({ required: true }),
45
48
  lastUsedAt: createTimestampField(),
@@ -1,10 +1,11 @@
1
1
  // EXT_USER_DATA hooks for userMfa (GDPR Art. 15/17/20). Both totpSecret and
2
- // recoveryCodes are marked userOwned in the schema for exactly this: on
3
- // forget, the row must actually disappear — a leftover TOTP secret or
4
- // recovery-code hash under an anonymized user is still a live credential
5
- // nobody can rotate. Mirrors folders-user-data/hooks.ts's executor-based
6
- // delete (rebuild-safe: forget replays via the event, a raw DELETE would be
7
- // resurrected by a projection rebuild).
2
+ // recoveryCodes are annotated `personal: { of: "userId" }` in the schema
3
+ // for exactly this: on forget, the row must actually disappear — a
4
+ // leftover TOTP secret or recovery-code hash under an anonymized user is
5
+ // still a live credential nobody can rotate. Mirrors
6
+ // folders-user-data/hooks.ts's executor-based delete (rebuild-safe: forget
7
+ // replays via the event, a raw DELETE would be resurrected by a
8
+ // projection rebuild).
8
9
 
9
10
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
11
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
@@ -36,8 +37,8 @@ export const userMfaExportHook: UserDataExportHook = async (ctx) => {
36
37
  };
37
38
 
38
39
  // Always a real hard-delete regardless of the incoming strategy — a 2FA
39
- // secret can't be anonymized, and userOwned already means this row belongs
40
- // to exactly one user (never shared tenant data).
40
+ // secret can't be anonymized, and `personal: { of: "userId" }` already
41
+ // means this row belongs to exactly one user (never shared tenant data).
41
42
  export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
42
43
  const rows = await selectMany<{ id: string }>(ctx.db, userMfaTable, {
43
44
  userId: ctx.userId,
@@ -119,8 +119,8 @@ beforeAll(async () => {
119
119
  // gepusht (r.projection mit `table`-Property → auto-push).
120
120
  await createEventsTable(db);
121
121
  await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
122
- // providerCustomerId/providerSubscriptionId are `tenantOwned` PII-subject
123
- // fields — no executor wires the KMS automatically here (raw r.projection,
122
+ // providerCustomerId/providerSubscriptionId are `personal: "tenant"`
123
+ // PII-subject fields — no executor wires the KMS automatically here (raw r.projection,
124
124
  // see feature.ts), so process-event.write.ts calls configuredPiiSubjectKms()
125
125
  // directly and needs one configured, same as run{Prod,Dev}App do at boot.
126
126
  configurePiiSubjectKms(new InMemoryKmsAdapter());
@@ -40,7 +40,7 @@ export const subscriptionEntity = createEntity({
40
40
  table: "read_subscriptions",
41
41
  fields: {
42
42
  providerName: createTextField({ required: true, maxLength: 50 }),
43
- // tenantOwned (not `encrypted`): the field must crypto-shred when
43
+ // `personal: "tenant"` (not `encrypted`): the field must crypto-shred when
44
44
  // eraseSubjectKeys erases the tenant's subject key on tenant-destroy
45
45
  // (#800). `encrypted: true` uses the app-wide master key instead — that
46
46
  // key is never erased per-tenant, so it would only add encryption-at-
@@ -52,12 +52,14 @@ export const subscriptionEntity = createEntity({
52
52
  providerCustomerId: createTextField({
53
53
  required: true,
54
54
  maxLength: 1000,
55
- tenantOwned: true,
55
+ personal: "tenant",
56
+ find: "none",
56
57
  }),
57
58
  providerSubscriptionId: createTextField({
58
59
  required: true,
59
60
  maxLength: 1000,
60
- tenantOwned: true,
61
+ personal: "tenant",
62
+ find: "none",
61
63
  }),
62
64
  status: createTextField({ required: true, maxLength: 30 }),
63
65
  tier: createTextField({ required: true, maxLength: 50 }),
@@ -6,7 +6,7 @@ import { subscriptionAggregateId } from "./aggregate-id";
6
6
  import { SUBSCRIPTION_AGGREGATE_TYPE } from "./events";
7
7
  import { subscriptionsProjectionTable } from "./projection";
8
8
 
9
- // providerCustomerId/providerSubscriptionId are `tenantOwned: true` on the
9
+ // providerCustomerId/providerSubscriptionId are `personal: "tenant"` on the
10
10
  // entity (envelope-encrypted with the tenant subject key, see entities.ts) —
11
11
  // eraseSubjectKeys crypto-shreds them for tenants that hard-delete. HGB
12
12
  // can not: it needs the accounting facts (status/tier/period) to survive, so
@@ -34,7 +34,8 @@ export const tenantComplianceProfileEntity = createEntity({
34
34
  // bedeutet "Default-Profile, keine Override". Validiert beim
35
35
  // set-profile-Handler via Zod.
36
36
  override: createLongTextField({
37
- allowPlaintext: "is-business-data",
37
+ personal: false,
38
+ reason: "is_business_data",
38
39
  }),
39
40
  },
40
41
  indexes: [
@@ -25,7 +25,10 @@ export const configValueEntity = createEntity({
25
25
  // breaking the null-vs-missing distinction the resolver already draws.
26
26
  value: createTextField({}),
27
27
  // user-scope row: userId populated. tenant- / system-scope: null.
28
- userId: createTextField({ allowPlaintext: "pseudonymous-fk" }),
28
+ userId: createTextField({
29
+ personal: false,
30
+ reason: "pseudonymous_fk",
31
+ }),
29
32
  },
30
33
  indexes: [
31
34
  { unique: true, columns: ["key", "tenantId", "userId"], name: "read_config_values_unique" },
@@ -185,14 +185,14 @@ describe("crypto-shredding :: forget-subject purges the derived search index (#1
185
185
  userNote: createTextField({
186
186
  required: true,
187
187
  maxLength: 100,
188
- userOwned: { ownerField: "ownerId" },
189
- searchable: true,
188
+ personal: { of: "ownerId" },
189
+ find: "fuzzy",
190
190
  }),
191
191
  tenantNote: createTextField({
192
192
  required: true,
193
193
  maxLength: 100,
194
- tenantOwned: true,
195
- searchable: true,
194
+ personal: "tenant",
195
+ find: "fuzzy",
196
196
  }),
197
197
  },
198
198
  });
@@ -6,8 +6,8 @@
6
6
  // nicht für personenbezogene Daten. Das frühere `sensitive`-Flag wurde
7
7
  // entfernt; das Schema rejected es und `parseSerializedField` wirft auf
8
8
  // Alt-Definitionen. Werte stehen vollständig im Event-Log (rebuild-safe).
9
- // PII gehört in Schema-Entity-Felder mit pii/userOwned/tenantOwned-
10
- // Annotation (Crypto-Shredding, Event-Ciphertext, Forget-Pipeline).
9
+ // PII belongs in schema-entity fields with a `personal` annotation
10
+ // (crypto-shredding, event ciphertext, forget pipeline).
11
11
  //
12
12
  // **Was diese Feature liefert (B1 + B2, 2026-05-23):**
13
13
  // 1. r.entity("field-definition") — Definition-Storage (event-sourced).
@@ -61,7 +61,10 @@ const staleEntity = createEntity({
61
61
  const retainedEntity = createEntity({
62
62
  table: "read_c7_retained",
63
63
  fields: {
64
- label: createTextField({ required: true, anonymize: () => "[ANONYMIZED]" }),
64
+ // No subject flag on the old fixture either — anonymize alone has no
65
+ // PersonalAnnotations arm, so this stays a spread instead of a direct
66
+ // createTextField(...) override (kumiko-framework#2250).
67
+ label: { ...createTextField({ required: true }), anonymize: () => "[ANONYMIZED]" },
65
68
  },
66
69
  retention: { keepFor: "30d", strategy: "blockDelete" },
67
70
  });
@@ -71,8 +74,11 @@ const retainedEntity = createEntity({
71
74
  const anonEntity = createEntity({
72
75
  table: "read_c7_anon",
73
76
  fields: {
74
- label: createTextField({ required: true, anonymize: () => "[ANONYMIZED]" }),
75
- note: createTextField({ allowPlaintext: "is-business-data" }),
77
+ label: { ...createTextField({ required: true }), anonymize: () => "[ANONYMIZED]" },
78
+ note: createTextField({
79
+ personal: false,
80
+ reason: "is_business_data",
81
+ }),
76
82
  },
77
83
  retention: { keepFor: "30d", strategy: "anonymize" },
78
84
  });
@@ -81,7 +87,9 @@ const anonEntity = createEntity({
81
87
  // muss das als skip melden statt still nichts zu tun.
82
88
  const bareEntity = createEntity({
83
89
  table: "read_c7_bare",
84
- fields: { label: createTextField({ required: true, allowPlaintext: "is-business-data" }) },
90
+ fields: {
91
+ label: createTextField({ required: true, personal: false, reason: "is_business_data" }),
92
+ },
85
93
  retention: { keepFor: "30d", strategy: "anonymize" },
86
94
  });
87
95
 
@@ -26,16 +26,19 @@ export const tenantRetentionOverrideEntity = createEntity({
26
26
  entityName: createTextField({
27
27
  required: true,
28
28
  maxLength: 100,
29
- allowPlaintext: "is-business-data",
29
+ personal: false,
30
+ reason: "is_business_data",
30
31
  }),
31
32
  config: createLongTextField({
32
33
  required: true,
33
- allowPlaintext: "is-business-data",
34
+ personal: false,
35
+ reason: "is_business_data",
34
36
  }),
35
37
  reason: createTextField({
36
38
  required: true,
37
39
  maxLength: 500,
38
- allowPlaintext: "is-business-data",
40
+ personal: false,
41
+ reason: "is_business_data",
39
42
  }),
40
43
  },
41
44
  indexes: [{ unique: true, columns: ["tenantId", "entityName"] }],
@@ -76,7 +76,7 @@ export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
76
76
  export const notificationPreferenceEntity = createEntity({
77
77
  table: "read_notification_preferences",
78
78
  fields: {
79
- userId: createTextField({ required: true, allowPlaintext: "pseudonymous-fk" }),
79
+ userId: createTextField({ required: true, personal: false, reason: "pseudonymous_fk" }),
80
80
  notificationType: createTextField({ required: true }), // qualified name or "*"
81
81
  channel: createTextField({ required: true }), // "inApp", "email", "push", or "*"
82
82
  enabled: createBooleanField({ default: true }),
@@ -37,14 +37,17 @@ export const documentExtractEntity = createEntity({
37
37
  fields: {
38
38
  fileRefId: createTextField({ required: true }),
39
39
  storageKey: createTextField({ required: true }),
40
- // Holds the full extracted document text (PII). `tenantOwned`, NOT
41
- // `encrypted: true` (#1621): the master-key path has no erasure subject,
40
+ // Holds the full extracted document text (PII). `personal: "tenant"`,
41
+ // NOT `encrypted: true` (#1621): the master-key path has no erasure subject,
42
42
  // so nothing here would ever be shreddable. Tenant-subject ciphertext
43
43
  // dies with eraseSubjectKeys on tenant-destroy (#800 pattern). Whose
44
44
  // subject a third party named inside a document is stays open, same as
45
45
  // inbound-mail-foundation (#957). meta is provider telemetry only and
46
46
  // stays plaintext jsonb.
47
- pages: createLongTextField({ tenantOwned: true }),
47
+ pages: createLongTextField({
48
+ personal: "tenant",
49
+ find: "none",
50
+ }),
48
51
  meta: createJsonbField(),
49
52
  },
50
53
  });
@@ -28,19 +28,19 @@ bewirtschaftet. Das öffnet die Tür für Cross-Feature-Hooks:
28
28
  ## PII-Annotations (Sprint 0.1+0.7)
29
29
 
30
30
  ```ts
31
- fileName pii: true (Originalname enthält oft Personen-Bezug)
32
- storageKey allowPlaintext: "is-business-data" (interner UUID-Key)
33
- mimeType allowPlaintext: "is-business-data"
34
- size allowPlaintext: "is-business-data"
35
- entityType allowPlaintext: "is-business-data"
36
- entityId allowPlaintext: "is-business-data"
37
- fieldName allowPlaintext: "is-business-data"
38
- insertedAt → kein PII-Marker (Audit-Timestamp, Framework-managed)
39
- insertedById → allowPlaintext: "is-business-data" (User-Reference, kein Eigen-PII)
31
+ fileName personal: "self", find: "none" (Originalname enthält oft Personen-Bezug)
32
+ storageKey kein PII-Marker (interner UUID-Key, trifft die Heuristik nicht)
33
+ mimeType kein PII-Marker
34
+ size kein PII-Marker
35
+ entityType kein PII-Marker
36
+ entityId kein PII-Marker
37
+ fieldName kein PII-Marker
38
+ insertedAt → kein PII-Marker (Audit-Timestamp, Framework-managed Base-Column)
39
+ insertedById → kein PII-Marker (User-Reference, Framework-managed Base-Column, kein Eigen-PII)
40
40
  ```
41
41
 
42
- `fileName: pii: true` heißt: Sprint 3 Crypto-Shredding wird den Wert
43
- mit dem Author-Subject-Key encrypten (für File-INHALTE: separates
42
+ `fileName: personal: "self"` heißt: Sprint 3 Crypto-Shredding verschlüsselt den
43
+ Wert mit dem Author-Subject-Key (für File-INHALTE: separates
44
44
  Subject-Resolver-Pattern via `subjectField` — siehe storage-encryption.md
45
45
  Sprint 4).
46
46
 
@@ -24,13 +24,16 @@ export const formDraftEntity = createEntity({
24
24
  // feeds the GDPR-hook-coverage boot guard (it's a plain FK into `user`,
25
25
  // not content of its own) — see ../form-draft-user-data for the
26
26
  // required export/delete hook coverage.
27
- ownerId: createTextField({ subjectRef: true, required: true }),
27
+ ownerId: createTextField({
28
+ personal: "ref",
29
+ required: true,
30
+ }),
28
31
  draftKey: createTextField({ required: true, maxLength: FORM_DRAFT_KEY_MAX_LENGTH }),
29
32
  // The blob shape is fixed by issue #1889, not left to the caller:
30
33
  // { values: Record<string, unknown>, stepIndex: number, savedAt: string }.
31
34
  // Free-form because `values` mirrors whatever fields the in-progress
32
- // form has, so it can carry arbitrary user-entered PII. NOT annotated
33
- // `userOwned` — the framework's PII field-encryption engine only
35
+ // form has, so it can carry arbitrary user-entered PII. NOT given a
36
+ // `personal` annotation — the framework's PII field-encryption engine only
34
37
  // accepts string values (pii-field-encryption.ts throws on a non-string
35
38
  // field), and this is a jsonb object. Erasure instead runs as a real
36
39
  // physical row-delete on forget (see form-draft-user-data/hooks.ts),
@@ -17,9 +17,9 @@ import {
17
17
  // Source-of-truth sind die event-store-Streams `mail-account` /
18
18
  // `inbound-message` / `mail-thread` — die Tabellen sind rebuild-fähig.
19
19
  //
20
- // **PII-Konvention:** tenantOwned (nicht `encrypted`): Felder müssen
21
- // crypto-shredden wenn eraseSubjectKeys den Tenant-Subject-Key beim
22
- // tenant-destroy löscht (#800-Muster aus billing-foundation).
20
+ // **PII convention:** `personal: "tenant"` (not `encrypted`): fields must
21
+ // crypto-shred when eraseSubjectKeys deletes the tenant subject key on
22
+ // tenant-destroy (#800 pattern from billing-foundation).
23
23
  // maxLength jeweils Ciphertext-budgetiert (`kumiko-pii:v1:<subject>:
24
24
  // <blob>` — Plaintext×~2.3 + Envelope-Overhead).
25
25
  //
@@ -41,12 +41,14 @@ export const mailAccountEntity = createEntity({
41
41
  // Owner + TenantAdmin (Compliance); KEIN Crypto-Subject-Wechsel in
42
42
  // V1 (Subject bleibt Tenant, siehe Header).
43
43
  ownerUserId: createTextField({ maxLength: 36 }),
44
+ // Tenant-authored mailbox label, not a person's name.
44
45
  displayName: createTextField({
45
46
  maxLength: 200,
46
- allowPlaintext: "mailbox display name, tenant config not personal data",
47
+ personal: false,
48
+ reason: "tenant_config_not_personal",
47
49
  }),
48
50
  // Postfach-Adresse — PII des Tenants.
49
- address: createTextField({ required: true, maxLength: 1000, tenantOwned: true }),
51
+ address: createTextField({ required: true, maxLength: 1000, personal: "tenant", find: "none" }),
50
52
  status: createTextField({ required: true, maxLength: 30 }),
51
53
  watchState: createTextField({ maxLength: 100 }),
52
54
  connectedAt: createTimestampField({ required: true }),
@@ -66,12 +68,12 @@ export const inboundMessageEntity = createEntity({
66
68
  ownerUserId: createTextField({ maxLength: 36 }),
67
69
  messageIdHeader: createTextField({ required: true, maxLength: 500 }),
68
70
  threadKey: createTextField({ required: true, maxLength: 500 }),
69
- from: createTextField({ required: true, maxLength: 2000, tenantOwned: true }),
71
+ from: createTextField({ required: true, maxLength: 2000, personal: "tenant", find: "none" }),
70
72
  // JSON-stringified string[] — als Ganzes encrypted.
71
- to: createTextField({ maxLength: 8000, tenantOwned: true }),
72
- cc: createTextField({ maxLength: 8000, tenantOwned: true }),
73
- subject: createTextField({ maxLength: 4000, tenantOwned: true }),
74
- snippet: createTextField({ maxLength: 4000, tenantOwned: true }),
73
+ to: createTextField({ maxLength: 8000, personal: "tenant", find: "none" }),
74
+ cc: createTextField({ maxLength: 8000, personal: "tenant", find: "none" }),
75
+ subject: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
76
+ snippet: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
75
77
  receivedAt: createTimestampField({ required: true }),
76
78
  bodyRef: createTextField({ maxLength: 500 }),
77
79
  scope: createTextField({ maxLength: 200 }),
@@ -84,7 +86,7 @@ export const mailThreadEntity = createEntity({
84
86
  table: "read_mail_threads",
85
87
  fields: {
86
88
  threadKey: createTextField({ required: true, maxLength: 500 }),
87
- subject: createTextField({ maxLength: 4000, tenantOwned: true }),
89
+ subject: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
88
90
  lastMessageAt: createTimestampField({ required: true }),
89
91
  messageCount: createNumberField({ required: true, integer: true }),
90
92
  },
@@ -35,11 +35,11 @@
35
35
  // Supervisor-storeBody-Hook, Event trägt nur bodyRef.
36
36
  // - Kein IMAP-Flag-Write-back (V1) — Inbox-Status lebt beim Consumer.
37
37
  //
38
- // **PII/DSGVO:** address/from/to/cc/subject/snippet sind tenantOwned
39
- // encrypted VOR jedem Event-Append (einziger Write-Pfad), Event-Log UND
40
- // Projections tragen Ciphertext; tenant-destroy shreddet den Subject-Key
41
- // (crypto-shredding, Muster billing-foundation #800). Der Destroy-Hook
42
- // archiviert zusätzlich alle Streams + löscht die Rows.
38
+ // **PII/GDPR:** address/from/to/cc/subject/snippet are `personal: "tenant"`
39
+ // encrypted BEFORE every event append (the only write path), event log AND
40
+ // projections carry ciphertext; tenant-destroy shreds the subject key
41
+ // (crypto-shredding, same pattern as billing-foundation #800). The destroy
42
+ // hook additionally archives all streams + deletes the rows.
43
43
 
44
44
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
45
45
  import { defineFeature, EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
@@ -41,11 +41,12 @@ export const transactionEntity = createEntity({
41
41
  fields: {
42
42
  date: createDateField({ required: true }),
43
43
  // Journal narration ("Miete Januar", "Storno: …") is accounting data, not
44
- // user-generated PII → allowPlaintext silences the user-content heuristic.
44
+ // user-generated PII → `personal: false` silences the user-content heuristic.
45
45
  description: createTextField({
46
46
  required: true,
47
47
  maxLength: 200,
48
- allowPlaintext: "is-business-data",
48
+ personal: false,
49
+ reason: "is_business_data",
49
50
  }),
50
51
  // For a Storno entry this points at the reversed transaction's id.
51
52
  reference: createTextField({ maxLength: 120 }),
@@ -79,7 +80,8 @@ export const scheduleEntity = createEntity({
79
80
  description: createTextField({
80
81
  required: true,
81
82
  maxLength: 200,
82
- allowPlaintext: "is-business-data",
83
+ personal: false,
84
+ reason: "is_business_data",
83
85
  }),
84
86
  startDate: createDateField({ required: true }),
85
87
  // Absent → open-ended (projects to the window's end).
@@ -42,7 +42,8 @@ export function createBrandingSettingsScreen(opts: {
42
42
  description: createTextField({
43
43
  maxLength: 500,
44
44
  multiline: { rows: 3 },
45
- allowPlaintext: "is-business-data",
45
+ personal: false,
46
+ reason: "is_business_data",
46
47
  }),
47
48
  siteUrl: createTextField({ maxLength: 2000, format: "url" }),
48
49
  accentColor: createTextField({ maxLength: 9 }),
@@ -26,9 +26,10 @@ export const pageEntity = createEntity({
26
26
  body: createTextField({
27
27
  multiline: { rows: 16 },
28
28
  maxLength: 100_000,
29
- allowPlaintext: "is-business-data",
29
+ personal: false,
30
+ reason: "is_business_data",
30
31
  }),
31
- description: createTextField({ maxLength: 500, allowPlaintext: "is-business-data" }),
32
+ description: createTextField({ maxLength: 500, personal: false, reason: "is_business_data" }),
32
33
  ogImage: createTextField({ maxLength: 2000 }),
33
34
  published: createBooleanField({ default: false }),
34
35
  },
@@ -15,8 +15,9 @@ import {
15
15
  // registered (see feature.ts). A correction is a new entry, not an edit —
16
16
  // that is the whole point of a note *history* instead of the single
17
17
  // overwritable textarea this bundle replaces (solon#13). GDPR erasure of the
18
- // author still works without a delete path: `body` is `userOwned` (crypto-
19
- // shredding on the author's subject key), not `pii` on the entity itself.
18
+ // author still works without a delete path: `body` is `personal: { of:
19
+ // "authorId" }` (crypto-shredding on the author's subject key), not
20
+ // `personal: "self"` on the entity itself.
20
21
  export const noteEntryEntity = createEntity({
21
22
  table: "read_note_entries",
22
23
  fields: {
@@ -27,11 +28,14 @@ export const noteEntryEntity = createEntity({
27
28
  // ctx.user.id (see feature.ts), so a note can't be authored as someone
28
29
  // else. subjectRef feeds the GDPR-hook-coverage boot guard (it's a plain
29
30
  // FK into `user`, not content of its own).
30
- authorId: createTextField({ subjectRef: true }),
31
+ authorId: createTextField({
32
+ personal: "ref",
33
+ }),
31
34
  body: createLongTextField({
32
35
  required: true,
33
36
  maxLength: 20_000,
34
- userOwned: { ownerField: "authorId" },
37
+ personal: { of: "authorId" },
38
+ find: "none",
35
39
  }),
36
40
  },
37
41
  });
@@ -26,11 +26,12 @@ export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
26
26
  };
27
27
  };
28
28
 
29
- // Deliberate no-op: `body` is annotated `userOwned` (entity.ts), which relies
30
- // on crypto-shredding (mounted KMS) for erasure — destroying the author's
31
- // subject key makes every note-entry event AND projected row unreadable at
32
- // once, without needing a physical delete (which would also break the
33
- // bundle's append-only history for OTHER entities' co-authors reading it).
29
+ // Deliberate no-op: `body` is annotated `personal: { of: "authorId" }`
30
+ // (entity.ts), which relies on crypto-shredding (mounted KMS) for erasure —
31
+ // destroying the author's subject key makes every note-entry event AND
32
+ // projected row unreadable at once, without needing a physical delete
33
+ // (which would also break the bundle's append-only history for OTHER
34
+ // entities' co-authors reading it).
34
35
  // Same tradeoff as job-run/delivery-attempt (user-data-rights-defaults).
35
36
  // Precondition: this ONLY erases anything if the app mounts a KMS adapter —
36
37
  // without one, userOwned fields fall back to plaintext storage framework-wide
@@ -33,7 +33,8 @@ export const apiTokenEntity = createEntity({
33
33
  required: true,
34
34
  maxLength: 120,
35
35
  access: { write: access.privileged },
36
- userOwned: { ownerField: "userId" },
36
+ personal: { of: "userId" },
37
+ find: "none",
37
38
  }),
38
39
  tokenHash: createTextField({
39
40
  required: true,
@@ -245,10 +245,6 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
245
245
  type: "projectionDetail",
246
246
  query: SessionQueries.detail,
247
247
  listScreenId: SESSION_LIST_SCREEN_ID,
248
- // No write path on this screen — a "Cancel" button here would only
249
- // ever discard nothing. Back-navigation still works via listScreenId
250
- // above (resolveDetailBreadcrumb, renderer-web) — fw#2245.
251
- hideActions: true,
252
248
  layout: {
253
249
  sections: [
254
250
  {
@@ -63,12 +63,14 @@ export const userSessionEntity = createEntity({
63
63
  ip: createTextField({
64
64
  maxLength: 64,
65
65
  access: { write: access.privileged },
66
- userOwned: { ownerField: "userId" },
66
+ personal: { of: "userId" },
67
+ find: "none",
67
68
  }),
68
69
  userAgent: createTextField({
69
70
  maxLength: 512,
70
71
  access: { write: access.privileged },
71
- userOwned: { ownerField: "userId" },
72
+ personal: { of: "userId" },
73
+ find: "none",
72
74
  }),
73
75
  },
74
76
  });
@@ -7,11 +7,17 @@ export const tagEntity = createEntity({
7
7
  table: "read_tags",
8
8
  fields: {
9
9
  // Catalog labels ("urgent", "billing"), not user-identifying content —
10
- // allowPlaintext silences the user-content heuristic (456/5). A tenant
11
- // COULD name a tag after a person; if that becomes a real requirement,
12
- // this needs a userOwned annotation + forget/export hooks in the
13
- // user-data-rights pipeline (none exist for tags today).
14
- name: createTextField({ required: true, maxLength: 64, allowPlaintext: "catalog-label" }),
10
+ // `personal: false` silences the user-content heuristic (456/5). A
11
+ // tenant COULD name a tag after a person; if that becomes a real
12
+ // requirement, this needs a `personal: { of: "<f>" }` annotation +
13
+ // forget/export hooks in the user-data-rights pipeline (none exist for
14
+ // tags today).
15
+ name: createTextField({
16
+ required: true,
17
+ maxLength: 64,
18
+ personal: false,
19
+ reason: "catalog_label",
20
+ }),
15
21
  // Optional UI hint (hex or token). No enforcement — purely for rendering.
16
22
  color: createTextField({ maxLength: 32 }),
17
23
  // Optional entity-type scope (GitLab project-vs-group labels): empty = global
@@ -321,7 +321,7 @@ describe("GDPR :: the owner column is what makes erasure possible", () => {
321
321
 
322
322
  test("the tenant-wide table keeps its business-data declaration", () => {
323
323
  const content = templateResourceEntity.fields["content"];
324
- expect((content as { allowPlaintext?: string }).allowPlaintext).toBe("is-business-data");
324
+ expect((content as { allowPlaintext?: string }).allowPlaintext).toBe("is_business_data");
325
325
  expect((content as { userOwned?: unknown }).userOwned).toBeUndefined();
326
326
  });
327
327
 
@@ -30,7 +30,10 @@ export const templateResourceEntity = createEntity({
30
30
  folder: createTextField({}),
31
31
  // Template-body is authored by TenantAdmin/Operator (email-templates etc.),
32
32
  // business data — kein end-user UGC.
33
- content: createLongTextField({ allowPlaintext: "is-business-data" }),
33
+ content: createLongTextField({
34
+ personal: false,
35
+ reason: "is_business_data",
36
+ }),
34
37
  contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
35
38
  variableSchema: createLongTextField({}),
36
39
  linkedResources: createLongTextField({}),
@@ -24,13 +24,16 @@ import { CONTENT_FORMATS, TEMPLATE_KINDS } from "./constants";
24
24
  export const userContentEntryEntity = createEntity({
25
25
  table: "read_user_content_entries",
26
26
  fields: {
27
- ownerId: createTextField({ required: true, subjectRef: true }),
27
+ ownerId: createTextField({ required: true, personal: "ref" }),
28
28
  slug: createTextField({ required: true }),
29
29
  kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
30
30
  locale: createTextField({ required: true }),
31
31
  title: createTextField({}),
32
32
  folder: createTextField({}),
33
- content: createLongTextField({ userOwned: { ownerField: "ownerId" } }),
33
+ content: createLongTextField({
34
+ personal: { of: "ownerId" },
35
+ find: "none",
36
+ }),
34
37
  contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
35
38
  },
36
39
  indexes: [
@@ -29,11 +29,12 @@ export const userContentExportHook: UserDataExportHook = async (ctx) => {
29
29
  };
30
30
  };
31
31
 
32
- // Deliberate no-op: `content` is annotated `userOwned`, so erasure runs via
33
- // crypto-shredding — destroying the owner's subject key makes every event AND
34
- // the projected row unreadable at once. A physical DELETE would not be
35
- // rebuild-safe here: user-content-entry is event-sourced, so a replay would
36
- // bring the row back (with unreadable content, but back).
32
+ // Deliberate no-op: `content` is annotated `personal: { of: "ownerId" }`, so
33
+ // erasure runs via crypto-shredding — destroying the owner's subject key
34
+ // makes every event AND the projected row unreadable at once. A physical
35
+ // DELETE would not be rebuild-safe here: user-content-entry is
36
+ // event-sourced, so a replay would bring the row back (with unreadable
37
+ // content, but back).
37
38
  // Precondition: this only erases anything if the app mounts a KMS adapter —
38
39
  // without one, userOwned fields fall back to plaintext framework-wide and
39
40
  // forget is a true no-op for `content`. Same property as notes-history.
@@ -58,7 +58,7 @@ export const tenantInvitationEntity = createEntity({
58
58
  fields: {
59
59
  // Eingeladene Email — case-insensitive normalisiert beim Insert.
60
60
  // PII bis zur Annahme (danach hat der User selbst seine email in users).
61
- email: createTextField({ required: true, maxLength: 320, pii: true, lookupable: true }),
61
+ email: createTextField({ required: true, maxLength: 320, personal: "self", find: "exact" }),
62
62
  // Membership-Rolle die dem User nach Accept gegeben wird. Default
63
63
  // im handler ist "Admin" (Co-Admin-Pattern für kleine Teams).
64
64
  role: createTextField({ required: true, maxLength: 50 }),
@@ -76,8 +76,8 @@ export const tenantInvitationEntity = createEntity({
76
76
  // decrypt-and-scan (see user-data-rights-defaults' invitation hook).
77
77
  invitedBy: createTextField({
78
78
  required: true,
79
- userOwned: { ownerField: "invitedBy" },
80
- lookupable: true,
79
+ personal: { of: "invitedBy" },
80
+ find: "exact",
81
81
  }),
82
82
  // UI-Anzeige — Wahrheit liegt in Redis-TTL.
83
83
  expiresAt: createTimestampField({ required: true }),
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EXT_TENANT_DATA,
3
3
  type FeatureDefinition,
4
- type PiiAnnotations,
4
+ type ResolvedPiiFlags,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { entitiesOf } from "../shared";
7
7
 
@@ -13,7 +13,7 @@ import { entitiesOf } from "../shared";
13
13
  // original guard's "tenant-lifecycle mounted" gate exactly (unlike gating on
14
14
  // a sibling feature, which would silently narrow coverage).
15
15
 
16
- function hasTenantOwned(field: unknown): field is PiiAnnotations {
16
+ function hasTenantOwned(field: unknown): field is ResolvedPiiFlags {
17
17
  return (
18
18
  typeof field === "object" &&
19
19
  field !== null &&
@@ -0,0 +1,58 @@
1
+ // fw-i18n-funde: tenant-settings has admin-write keys at tenant-home scope
2
+ // (see config.ts) — access.admin includes SystemAdmin, which cascades a
3
+ // "set the platform default" screen up to the system audience alongside
4
+ // the tenant-home screen (buildConfigFeatureSchema's ELEVATED_ROLES). Both
5
+ // used to share the exact same `${feature}.settings` nav label, so the
6
+ // sidebar showed the identical raw i18n key twice, once under each
7
+ // audience. This test proves the real, declared translations resolve to
8
+ // actual text (not the raw key) AND that the two nav entries read
9
+ // differently — a regression guard, not an existence check.
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
13
+ import { buildConfigFeatureSchema, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { createTenantSettingsFeature } from "../feature";
15
+
16
+ function translate(
17
+ translations: Readonly<Record<string, Readonly<Record<string, string>> | undefined>>,
18
+ key: string,
19
+ locale = "en",
20
+ ): string {
21
+ return translations[key]?.[locale] ?? key;
22
+ }
23
+
24
+ describe("tenant-settings — Settings-Hub nav/section labels", () => {
25
+ test("system + tenant scope screens both translate, and their nav labels differ", () => {
26
+ const registry = createRegistry([createConfigFeature(), createTenantSettingsFeature()]);
27
+ const schema = buildConfigFeatureSchema(registry);
28
+ const translations = registry.getFeature("tenant-settings")?.translations ?? {};
29
+
30
+ const systemNav = schema.navs.find((n) => n.id === "tenant-settings-system");
31
+ const tenantNav = schema.navs.find((n) => n.id === "tenant-settings-tenant");
32
+ expect(systemNav).toBeDefined();
33
+ expect(tenantNav).toBeDefined();
34
+ if (systemNav === undefined || tenantNav === undefined) throw new Error("unreachable");
35
+
36
+ const systemLabel = translate(translations, systemNav.label);
37
+ const tenantLabel = translate(translations, tenantNav.label);
38
+
39
+ // Both labels actually resolve to text — not an echoed raw key.
40
+ expect(systemLabel).not.toBe(systemNav.label);
41
+ expect(tenantLabel).not.toBe(tenantNav.label);
42
+ // The cross-scope duplicate this test guards against: the two nav
43
+ // entries read differently instead of repeating the same words under
44
+ // both "Platform" and "Organization".
45
+ expect(systemLabel).not.toBe(tenantLabel);
46
+
47
+ const systemScreen = schema.screens.find((s) => s.id === "tenant-settings-system");
48
+ expect(systemScreen).toBeDefined();
49
+ if (systemScreen === undefined || systemScreen.type !== "configEdit") {
50
+ throw new Error("unreachable");
51
+ }
52
+ const section = systemScreen.layout.sections[0];
53
+ const sectionTitle = section !== undefined && "title" in section ? section.title : undefined;
54
+ expect(sectionTitle).toBeDefined();
55
+ if (sectionTitle === undefined) throw new Error("unreachable");
56
+ expect(translate(translations, sectionTitle)).not.toBe(sectionTitle);
57
+ });
58
+ });
@@ -41,6 +41,18 @@ export function createTenantSettingsFeature(
41
41
  de: "Tenant-Einstellungen",
42
42
  en: "Tenant Settings",
43
43
  },
44
+ // Settings-Hub nav label + configEdit section heading (same key, both
45
+ // scopes fall back here — see buildConfigFeatureSchema).
46
+ "tenant-settings.settings": { de: "Tenant-Einstellungen", en: "Tenant Settings" },
47
+ // Both currency/locale are tenant-home with admin write access, which
48
+ // cascades a SystemAdmin-only "set the platform default" screen up to
49
+ // the system audience — scoped override so its nav entry reads
50
+ // differently from the tenant-home entry instead of repeating the
51
+ // same words under both "Platform" and "Organization".
52
+ "tenant-settings.settings.system": {
53
+ de: "Tenant-Einstellungen (Plattform-Standard)",
54
+ en: "Tenant Settings (Platform Default)",
55
+ },
44
56
  },
45
57
  });
46
58
  });
@@ -67,8 +67,8 @@ export const userEntity = createEntity({
67
67
  required: true,
68
68
  format: "email",
69
69
  maxLength: 320,
70
- pii: true,
71
- lookupable: true,
70
+ personal: "self",
71
+ find: "exact",
72
72
  access: { write: access.privileged },
73
73
  }),
74
74
 
@@ -85,8 +85,8 @@ export const userEntity = createEntity({
85
85
  displayName: createTextField({
86
86
  required: true,
87
87
  maxLength: 100,
88
- pii: true,
89
- searchable: true,
88
+ personal: "self",
89
+ find: "fuzzy",
90
90
  }),
91
91
  // No default here — tenant-settings owns the app-wide locale default; see #1637.
92
92
  locale: createTextField({ maxLength: 10 }),
@@ -169,7 +169,7 @@ export const userEntity = createEntity({
169
169
  access: { write: access.privileged },
170
170
  }),
171
171
  },
172
- // fw#2134 — email is `pii: true, lookupable: true`, so the column holds
172
+ // fw#2134 — email is `personal: "self", find: "exact"`, so the column holds
173
173
  // per-row ciphertext: a plain unique index on it can't catch duplicates.
174
174
  // `unique: true` over a lookupable column makes buildEntityTable /
175
175
  // deriveEntityTableMeta emit a second, partial unique index over the
@@ -96,7 +96,17 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
96
96
 
97
97
  test("V3: pii entity without any EXT_USER_DATA hook → throws the guard's own message", () => {
98
98
  const bad = defineFeature("crm", (r) => {
99
- r.entity("contact", createEntity({ fields: { email: createTextField({ pii: true }) } }));
99
+ r.entity(
100
+ "contact",
101
+ createEntity({
102
+ fields: {
103
+ email: createTextField({
104
+ personal: "self",
105
+ find: "none",
106
+ }),
107
+ },
108
+ }),
109
+ );
100
110
  });
101
111
  expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
102
112
  });
@@ -110,7 +120,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
110
120
  source: "contact",
111
121
  table: projectionTable,
112
122
  apply: { [contactCreated.name]: async () => {} },
113
- entity: createEntity({ fields: { email: createTextField({ pii: true }) } }),
123
+ entity: createEntity({
124
+ fields: {
125
+ email: createTextField({
126
+ personal: "self",
127
+ find: "none",
128
+ }),
129
+ },
130
+ }),
114
131
  });
115
132
  });
116
133
  expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
@@ -120,7 +137,13 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
120
137
  const bad = defineFeature("tasks", (r) => {
121
138
  r.entity(
122
139
  "task",
123
- createEntity({ fields: { assigneeId: createTextField({ subjectRef: true }) } }),
140
+ createEntity({
141
+ fields: {
142
+ assigneeId: createTextField({
143
+ personal: "ref",
144
+ }),
145
+ },
146
+ }),
124
147
  );
125
148
  });
126
149
  expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
@@ -131,7 +154,13 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
131
154
  r.requires("user-data-rights");
132
155
  r.entity(
133
156
  "task",
134
- createEntity({ fields: { assigneeId: createTextField({ subjectRef: true }) } }),
157
+ createEntity({
158
+ fields: {
159
+ assigneeId: createTextField({
160
+ personal: "ref",
161
+ }),
162
+ },
163
+ }),
135
164
  );
136
165
  r.useExtension(EXT_USER_DATA, "task", { export: async () => null, delete: async () => {} });
137
166
  });
@@ -148,7 +177,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
148
177
  source: "contact",
149
178
  table: projectionTable,
150
179
  apply: { [contactCreated.name]: async () => {} },
151
- entity: createEntity({ fields: { email: createTextField({ pii: true }) } }),
180
+ entity: createEntity({
181
+ fields: {
182
+ email: createTextField({
183
+ personal: "self",
184
+ find: "none",
185
+ }),
186
+ },
187
+ }),
152
188
  });
153
189
  r.useExtension(EXT_USER_DATA, "contact-summary", {
154
190
  export: async () => null,
@@ -162,7 +198,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
162
198
  const bad = defineFeature("billing", (r) => {
163
199
  r.entity(
164
200
  "subscription",
165
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
201
+ createEntity({
202
+ fields: {
203
+ providerCustomerId: createTextField({
204
+ personal: "tenant",
205
+ find: "none",
206
+ }),
207
+ },
208
+ }),
166
209
  );
167
210
  });
168
211
  expect(() => validateBoot([...minimalTenantLifecycleFeatures(), bad])).toThrow(
@@ -174,7 +217,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
174
217
  const bad = defineFeature("billing", (r) => {
175
218
  r.entity(
176
219
  "subscription",
177
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
220
+ createEntity({
221
+ fields: {
222
+ providerCustomerId: createTextField({
223
+ personal: "tenant",
224
+ find: "none",
225
+ }),
226
+ },
227
+ }),
178
228
  );
179
229
  });
180
230
  const features = minimalTenantLifecycleFeatures().concat(bad);
@@ -186,7 +236,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
186
236
  const notMounted = defineFeature("billing-standalone", (r) => {
187
237
  r.entity(
188
238
  "subscription",
189
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
239
+ createEntity({
240
+ fields: {
241
+ providerCustomerId: createTextField({
242
+ personal: "tenant",
243
+ find: "none",
244
+ }),
245
+ },
246
+ }),
190
247
  );
191
248
  });
192
249
  expect(() => validateBoot([...baseFeatures(), notMounted])).not.toThrow();
@@ -197,7 +254,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
197
254
  r.requires("tenant-lifecycle");
198
255
  r.entity(
199
256
  "subscription",
200
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
257
+ createEntity({
258
+ fields: {
259
+ providerCustomerId: createTextField({
260
+ personal: "tenant",
261
+ find: "none",
262
+ }),
263
+ },
264
+ }),
201
265
  );
202
266
  r.useExtension(EXT_TENANT_DATA, "subscription", { destroy: async () => {} });
203
267
  });
@@ -46,8 +46,8 @@ const noteEntity = createEntity({
46
46
  authorId: createTextField({ required: true }),
47
47
  body: createTextField({
48
48
  required: true,
49
- searchable: true,
50
- userOwned: { ownerField: "authorId" },
49
+ personal: { of: "authorId" },
50
+ find: "fuzzy",
51
51
  }),
52
52
  },
53
53
  });
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EXT_USER_DATA,
3
3
  type FeatureDefinition,
4
- type PiiAnnotations,
4
+ type ResolvedPiiFlags,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { entitiesOf } from "../shared";
7
7
 
@@ -58,7 +58,7 @@ export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition
58
58
  if (hookedEntities.has(entityName)) continue;
59
59
  const subjectFields = Object.entries(entity.fields)
60
60
  .filter(([, field]) => {
61
- const annot = field as PiiAnnotations; // @cast-boundary schema-walk
61
+ const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
62
62
  return Boolean(annot.pii) || Boolean(annot.userOwned) || Boolean(annot.subjectRef);
63
63
  })
64
64
  .map(([name]) => name);
@@ -75,21 +75,23 @@ export const exportDownloadTokenEntity = createEntity({
75
75
  // (z.B. broken Sync-Tool).
76
76
  useCount: createBigIntField({}),
77
77
 
78
- // Audit: Source-IP des letzten Downloads. Hilft DPO bei Untersuchung
79
- // ungewoehnlicher Aktivitaeten ("Token wurde von 5 verschiedenen
80
- // IPs genutzt"). Plain-IP-V4/IPv6, kein hash — DPO braucht direkt
81
- // lesbar. is-business-data weil Token-Audit kein User-PII ist
82
- // (gehoert dem Tenant-Operator).
78
+ // Audit: source IP of the last download. Helps the DPO investigate
79
+ // unusual activity ("token was used from 5 different IPs"). Plain
80
+ // IPv4/IPv6, no hash — DPO needs it directly readable.
81
+ // `reason: "is_business_data"` because token-audit data isn't user
82
+ // PII (it belongs to the tenant operator).
83
83
  lastUsedFromIp: createTextField({
84
- maxLength: 45, // IPv6 max
85
- allowPlaintext: "is-business-data",
84
+ maxLength: 45,
85
+ personal: false,
86
+ reason: "is_business_data",
86
87
  }),
87
88
 
88
- // Audit: User-Agent des letzten Downloads. Audit-Wert (Email-Client
89
- // vs Browser vs CLI-Tool unterscheidbar). is-business-data analog.
89
+ // Audit: user agent of the last download (distinguishes email
90
+ // client vs browser vs CLI tool). Same `reason` as above.
90
91
  lastUsedUserAgent: createTextField({
91
92
  maxLength: 500,
92
- allowPlaintext: "is-business-data",
93
+ personal: false,
94
+ reason: "is_business_data",
93
95
  }),
94
96
  },
95
97
 
@@ -133,11 +133,12 @@ export const exportJobEntity = createEntity({
133
133
  // verbleiben.
134
134
  expiresAt: createTimestampField({}),
135
135
 
136
- // Failed-State Diagnose. longText weil Hook-Errors mit Stack-Trace
137
- // mehrere KB werden koennen. is-business-data weil Job-Status
138
- // public im UI-Polling sichtbar (kein PII).
136
+ // Failed-state diagnostics. longText because hook errors with stack
137
+ // traces can run several KB. `reason: "is_business_data"` because
138
+ // job status is publicly visible in UI polling (no PII).
139
139
  errorMessage: createLongTextField({
140
- allowPlaintext: "is-business-data",
140
+ personal: false,
141
+ reason: "is_business_data",
141
142
  }),
142
143
 
143
144
  // Audit-Info fuer Operator: wie gross war der Export. Hilft beim