@cosmicdrift/kumiko-framework 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 (35) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-permalink-open.integration.test.ts +6 -1
  3. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  4. package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
  5. package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
  6. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  7. package/src/db/__tests__/cursor.test.ts +26 -1
  8. package/src/db/__tests__/eagerload.integration.test.ts +3 -3
  9. package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
  10. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  11. package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
  12. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
  13. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
  14. package/src/db/cursor.ts +32 -0
  15. package/src/db/event-store-executor-read.ts +80 -9
  16. package/src/db/index.ts +2 -2
  17. package/src/db/pg-error.ts +7 -0
  18. package/src/db/queries/backfill-pii.ts +188 -18
  19. package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
  20. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
  21. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
  22. package/src/engine/__tests__/factories-personal.test.ts +130 -0
  23. package/src/engine/__tests__/field-access.test.ts +1 -23
  24. package/src/engine/__tests__/store-table.test.ts +4 -1
  25. package/src/engine/boot-validator/pii-retention.ts +22 -54
  26. package/src/engine/build-config-feature-schema.ts +9 -1
  27. package/src/engine/factories.ts +108 -30
  28. package/src/engine/field-access.ts +2 -13
  29. package/src/engine/index.ts +7 -1
  30. package/src/engine/types/index.ts +7 -1
  31. package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
  32. package/src/files/file-ref-entity.ts +2 -2
  33. package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
  34. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
  35. package/src/testing/shared-entities.ts +6 -3
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { checkWriteFieldRoles, filterReadFields, PII_MASKED_VALUE } from "../field-access";
2
+ import { checkWriteFieldRoles, filterReadFields } from "../field-access";
3
3
  import type { EntityDefinition } from "../types";
4
4
 
5
5
  const entity: EntityDefinition = {
@@ -26,28 +26,6 @@ describe("filterReadFields", () => {
26
26
  expect(filtered["secret"]).toBe("visible");
27
27
  });
28
28
 
29
- test("masks piiEncrypted fields instead of stripping them (kumiko-platform#463)", () => {
30
- const entityWithPii: EntityDefinition = {
31
- fields: {
32
- ...entity.fields,
33
- iban: {
34
- type: "text",
35
- piiEncrypted: true,
36
- tenantOwned: true,
37
- access: { read: { admin: "all" } },
38
- },
39
- },
40
- };
41
- const row = { id: 1, title: "Hello", secret: "hidden", iban: "DE89370400440532013000" };
42
-
43
- const filteredForEditor = filterReadFields(entityWithPii, row, editor);
44
- expect(filteredForEditor["secret"]).toBeUndefined();
45
- expect(filteredForEditor["iban"]).toBe(PII_MASKED_VALUE);
46
-
47
- const filteredForAdmin = filterReadFields(entityWithPii, row, admin);
48
- expect(filteredForAdmin["iban"]).toBe("DE89370400440532013000");
49
- });
50
-
51
29
  test("filters each row of an embedded list, keeping it an array", () => {
52
30
  const entityWithLines: EntityDefinition = {
53
31
  fields: {
@@ -204,7 +204,10 @@ describe("createRegistry — store tables with PII-annotated fields (#820)", ()
204
204
  table: "rt_pii_probe",
205
205
  fields: {
206
206
  userId: createTextField({ required: true }),
207
- ip: createTextField({ userOwned: { ownerField: "userId" } }),
207
+ ip: createTextField({
208
+ personal: { of: "userId" },
209
+ find: "none",
210
+ }),
208
211
  },
209
212
  });
210
213
  const piiMeta = deriveEntityTableMeta("rt-pii-probe", piiEntity, { source: "unmanaged" });
@@ -1,5 +1,5 @@
1
1
  import type { FeatureDefinition } from "../types";
2
- import type { FieldAccess, PiiAnnotations } from "../types/fields";
2
+ import type { ResolvedPiiFlags } from "../types/fields";
3
3
  import {
4
4
  PII_DIRECT_NAME_HINTS,
5
5
  PII_USER_OWNED_NAME_HINTS,
@@ -24,7 +24,7 @@ const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
24
24
  // A field carries a subject binding — pii/userOwned/tenantOwned mark
25
25
  // annotated content, subjectRef marks a bare FK into `user` with no
26
26
  // annotated content of its own but the same Art.17 obligations (#1645).
27
- function hasSubjectAnnotation(annot: PiiAnnotations): boolean {
27
+ function hasSubjectAnnotation(annot: ResolvedPiiFlags): boolean {
28
28
  return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.subjectRef);
29
29
  }
30
30
 
@@ -43,7 +43,7 @@ function hasSubjectAnnotation(annot: PiiAnnotations): boolean {
43
43
  //
44
44
  // 3. Heuristik-Warnings: Field-Namen die typischerweise PII enthalten
45
45
  // (email, name, phone, body, etc.) ohne Annotation → Boot-Warning.
46
- // Mit `allowPlaintext: "<reason>"` unterdrückbar (geht in Audit).
46
+ // Mit `{ personal: false, reason: "<reason>" }` unterdrückbar (geht in Audit).
47
47
  //
48
48
  // 4. Retention-Integrity: retention.reference (wenn gesetzt) muss auf
49
49
  // ein bestehendes Field zeigen (oder Framework-Timestamp). retention.
@@ -57,12 +57,12 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
57
57
  const fieldsByName = entity.fields;
58
58
 
59
59
  for (const [fieldName, field] of Object.entries(fieldsByName)) {
60
- // PiiAnnotations-Properties sind type-level optional. Auf Field-
61
- // Defs die nicht via "& PiiAnnotations" erweitert sind (Boolean,
60
+ // ResolvedPiiFlags-Properties sind type-level optional. Auf Field-
61
+ // Defs die nicht via "& ResolvedPiiFlags" erweitert sind (Boolean,
62
62
  // Money, Reference, Embedded, Tz, LocatedTimestamp, File*, Image*)
63
63
  // liefert property-access undefined zur Runtime. Die TS-Compile-
64
64
  // Time-Validation hat dort schon abgelehnt → Cast ist safe.
65
- const annot = field as PiiAnnotations; // @cast-boundary schema-walk
65
+ const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
66
66
 
67
67
  const hasPii = Boolean(annot.pii);
68
68
  const hasUserOwned = Boolean(annot.userOwned);
@@ -71,7 +71,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
71
71
 
72
72
  if (annotCount > 1) {
73
73
  throw new Error(
74
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has multiple subject-key annotations (pii / userOwned / tenantOwned). Pick one — each field belongs to exactly one subject.`,
74
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`,
75
75
  );
76
76
  }
77
77
 
@@ -81,48 +81,16 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
81
81
  if (annot.lookupable === true) {
82
82
  if (field.type !== "text") {
83
83
  throw new Error(
84
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { lookupable: true } but has type "${field.type}" — blind-index equality lookups only apply to text fields.`,
84
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") but has type "${field.type}" — blind-index equality lookups only apply to text fields.`,
85
85
  );
86
86
  }
87
87
  if (annotCount === 0) {
88
88
  throw new Error(
89
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { lookupable: true } without a subject annotation (pii / userOwned / tenantOwned). Plaintext fields don't need a blind index — add the subject annotation or drop lookupable.`,
89
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") without a subject annotation (personal: "self" / "tenant" / { of: "<field>" }). Plaintext fields don't need a blind index — add personal or set find: "none".`,
90
90
  );
91
91
  }
92
92
  }
93
93
 
94
- // piiEncrypted (kumiko-platform#231/#456): a declarative alias over
95
- // the subject-KMS — text only (storage stays the plaintext column
96
- // with subject ciphertext, no separate envelope path).
97
- const piiEncryptedFlag = field as { readonly piiEncrypted?: boolean }; // @cast-boundary schema-walk
98
- if (piiEncryptedFlag.piiEncrypted === true && field.type !== "text") {
99
- throw new Error(
100
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { piiEncrypted: true } but has type "${field.type}" — piiEncrypted only applies to text fields.`,
101
- );
102
- }
103
- // piiEncrypted wiring (kumiko-platform#457): resolveSubjectForField/
104
- // collectPiiSubjectFields only ever look at pii/userOwned/tenantOwned
105
- // — piiEncrypted alone doesn't pick a subject. Without one of the
106
- // three, the field would silently stay plaintext (no encrypt-on-
107
- // write happens). Fail at boot like lookupable does, not at first read.
108
- if (piiEncryptedFlag.piiEncrypted === true && annotCount === 0) {
109
- throw new Error(
110
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { piiEncrypted: true } without a subject annotation (pii / userOwned / tenantOwned) — piiEncrypted alone doesn't encrypt anything, it only adds the access/masking layer on top of the subject-key encryption.`,
111
- );
112
- }
113
- // piiEncrypted access model (kumiko-platform#460): field-level
114
- // access.read already exists + is enforced (filterReadFields), but
115
- // its default without an access config is "visible to everyone" —
116
- // the wrong default for a field whose entire point is "only the
117
- // legitimate owner may see the plaintext". Force an explicit choice
118
- // instead of silently decrypting for every row-reader.
119
- const accessFlag = field as { readonly access?: FieldAccess }; // @cast-boundary schema-walk
120
- if (piiEncryptedFlag.piiEncrypted === true && !accessFlag.access?.read) {
121
- throw new Error(
122
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { piiEncrypted: true } without { access: { read: [...] } } — without it the decrypted value is visible to anyone who can read the row. Set the roles allowed to see the plaintext.`,
123
- );
124
- }
125
-
126
94
  // sensitive-Felder liegen seit #967 als Tabellen-Ciphertext im Event-
127
95
  // Log — ohne ciphertext-at-rest würde der Append Klartext in die
128
96
  // immutable History schreiben.
@@ -136,7 +104,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
136
104
  sensitiveFlags.encrypted !== true
137
105
  ) {
138
106
  throw new Error(
139
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (pii / userOwned / tenantOwned) or { encrypted: true }.`,
107
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (personal: "self" / "tenant" / { of: "<field>" }) or { encrypted: true }.`,
140
108
  );
141
109
  }
142
110
 
@@ -152,9 +120,9 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
152
120
  readonly sortable?: boolean;
153
121
  readonly sensitive?: boolean;
154
122
  }; // @cast-boundary schema-walk
155
- if ((annotCount > 0 || piiEncryptedFlag.piiEncrypted === true) && flags.sortable === true) {
123
+ if (annotCount > 0 && flags.sortable === true) {
156
124
  throw new Error(
157
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation or { piiEncrypted: true } with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use { lookupable: true }; drop sortable or keep the field plaintext (allowPlaintext).`,
125
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use find: "exact"; drop sortable or keep the field plaintext (personal: false).`,
158
126
  );
159
127
  }
160
128
  if (flags.sensitive === true && flags.searchable === true) {
@@ -168,14 +136,14 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
168
136
  const ownerName = annot.userOwned.ownerField;
169
137
  if (!ownerName || typeof ownerName !== "string") {
170
138
  throw new Error(
171
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has userOwned without ownerField name`,
139
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has personal: { of: ... } without an owner field name`,
172
140
  );
173
141
  }
174
142
  const ownerField = fieldsByName[ownerName];
175
143
  if (!ownerField) {
176
144
  const known = Object.keys(fieldsByName).sort().join(", ");
177
145
  throw new Error(
178
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" references userOwned.ownerField "${ownerName}" but no such field exists. Known fields: ${known}`,
146
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" references personal.of "${ownerName}" but no such field exists. Known fields: ${known}`,
179
147
  );
180
148
  }
181
149
  // Text is accepted alongside reference: the ES-framework carries
@@ -185,7 +153,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
185
153
  // ownerField (the field's own value IS the owner id) rides on this.
186
154
  if (ownerField.type !== "reference" && ownerField.type !== "text") {
187
155
  throw new Error(
188
- `[Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`,
156
+ `[Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`,
189
157
  );
190
158
  }
191
159
  // Soft-Warning wenn das reference-target nicht offensichtlich user
@@ -197,7 +165,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
197
165
  if (targetEntity !== "user") {
198
166
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
199
167
  console.warn(
200
- `[kumiko:boot] [Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`,
168
+ `[kumiko:boot] [Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`,
201
169
  );
202
170
  }
203
171
  }
@@ -206,24 +174,24 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
206
174
  // PII-Heuristik: nur wenn keine Annotation gesetzt UND kein
207
175
  // allowPlaintext-Marker. Ergibt false positives auf Geschäftsdaten
208
176
  // mit personenartigem Namen (z.B. company.legalName) — Author
209
- // unterdrückt mit { allowPlaintext: "is-business-data" }.
177
+ // unterdrückt mit { personal: false, reason: "is_business_data" }.
210
178
  const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
211
179
  if (noAnnotation) {
212
180
  const lower = fieldName.toLowerCase();
213
181
  if (PII_DIRECT_NAME_HINTS.has(lower)) {
214
182
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
215
183
  console.warn(
216
- `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no { pii: true } annotation. If this is PII, mark it. If business data, set { allowPlaintext: "is-business-data" } to silence.`,
184
+ `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no personal annotation. If this is PII, mark it { personal: "self", find: ... }. If business data, set { personal: false, reason: "is_business_data" } to silence.`,
217
185
  );
218
186
  } else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
219
187
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
220
188
  console.warn(
221
- `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no { userOwned } annotation. If this contains user-generated content, mark it { userOwned: { ownerField: "<authorIdField>" }}. If business data, set { allowPlaintext: "..." } to silence.`,
189
+ `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no personal annotation. If this contains user-generated content, mark it { personal: { of: "<authorIdField>" }, find: ... }. If business data, set { personal: false, reason: "..." } to silence.`,
222
190
  );
223
191
  } else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) && !annot.subjectRef) {
224
192
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
225
193
  console.warn(
226
- `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no { subjectRef: true } annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { subjectRef: true } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) — without the hook the V3 boot guard throws. Or { userOwned: { ownerField: "${fieldName}" } } on the field it owns. If business data, set { allowPlaintext: "..." } to silence.`,
194
+ `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) — without the hook the V3 boot guard throws. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
227
195
  );
228
196
  }
229
197
  }
@@ -254,10 +222,10 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
254
222
  // blockDelete on an entity with no subject field is the correct
255
223
  // "never auto-delete" choice; User-Forget never reaches those rows (#1622).
256
224
  const hasSubjectField = Object.values(fieldsByName).some(
257
- (f) => hasSubjectAnnotation(f as PiiAnnotations), // @cast-boundary schema-walk
225
+ (f) => hasSubjectAnnotation(f as ResolvedPiiFlags), // @cast-boundary schema-walk
258
226
  );
259
227
  const hasAnonymize = Object.values(fieldsByName).some((f) => {
260
- const a = f as PiiAnnotations; // @cast-boundary schema-walk
228
+ const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
261
229
  return Boolean(a.anonymize);
262
230
  });
263
231
  if (hasSubjectField && !hasAnonymize) {
@@ -139,9 +139,17 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
139
139
  const shortId = `${feature}-${scope}`;
140
140
 
141
141
  screens.push(buildScreen(shortId, scope, feature, ordered, access, declaredTranslationKeys));
142
+ // A tenant/user-home key with an elevated write role (SystemAdmin on a
143
+ // tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
144
+ // audience navs (cascade-default screen + home screen) — both would
145
+ // otherwise carry the identical `${feature}.settings` label. Opt-in
146
+ // scoped override (`${feature}.settings.${scope}`) disambiguates only
147
+ // where a feature actually declares one; every single-scope feature
148
+ // keeps the plain key unchanged.
149
+ const scopedLabel = `${feature}.settings.${scope}`;
142
150
  navs.push({
143
151
  id: shortId,
144
- label: `${feature}.settings`,
152
+ label: declaredTranslationKeys.has(scopedLabel) ? scopedLabel : `${feature}.settings`,
145
153
  parent: audienceNavShortId(scope),
146
154
  screen: shortId,
147
155
  icon: ordered[0]?.def.mask?.icon ?? "settings",
@@ -10,6 +10,7 @@ import type {
10
10
  FieldsMap,
11
11
  FileFieldDef,
12
12
  FilesFieldDef,
13
+ Findability,
13
14
  ImageFieldDef,
14
15
  ImagesFieldDef,
15
16
  JsonbFieldDef,
@@ -18,6 +19,11 @@ import type {
18
19
  MoneyFieldDef,
19
20
  MultiSelectFieldDef,
20
21
  NumberFieldDef,
22
+ PersonalAnnotations,
23
+ PersonalAnnotationsLongText,
24
+ PersonalAnnotationsNoFind,
25
+ PersonalSubject,
26
+ ResolvedPiiFlags,
21
27
  RetentionDef,
22
28
  SelectFieldDef,
23
29
  TextFieldDef,
@@ -25,6 +31,56 @@ import type {
25
31
  TzFieldDef,
26
32
  } from "./types";
27
33
 
34
+ type PersonalOverridesInput = {
35
+ readonly personal?: PersonalSubject | "ref" | false;
36
+ readonly find?: Findability;
37
+ readonly reason?: string;
38
+ readonly anonymize?: () => unknown | Promise<unknown>;
39
+ };
40
+
41
+ // Resolves the author-facing `personal`/`find` annotations (kumiko-framework#2250)
42
+ // into the internal ResolvedPiiFlags every factory below merges into its
43
+ // return value. `personal`, `find`, `reason` never survive into the field def.
44
+ function expandPersonalAnnotations<T extends PersonalOverridesInput>(
45
+ overrides: T | undefined,
46
+ ): Omit<T, "personal" | "find" | "reason"> & ResolvedPiiFlags {
47
+ if (!overrides) {
48
+ return {} as Omit<T, "personal" | "find" | "reason"> & ResolvedPiiFlags;
49
+ }
50
+ const { personal, find, reason, anonymize, ...rest } = overrides;
51
+
52
+ let personalFlags: ResolvedPiiFlags = {};
53
+ if (personal === "self") {
54
+ personalFlags = { pii: true };
55
+ } else if (personal === "tenant") {
56
+ personalFlags = { tenantOwned: true };
57
+ } else if (personal === "ref") {
58
+ personalFlags = { subjectRef: true };
59
+ } else if (personal === false) {
60
+ personalFlags = { allowPlaintext: reason };
61
+ } else if (personal) {
62
+ personalFlags = { userOwned: { ownerField: personal.of } };
63
+ }
64
+
65
+ // `searchable`/`sensitive` live on the individual FieldDef, not on
66
+ // ResolvedPiiFlags — "fuzzy"/"secret" resolve into those too.
67
+ let findFlags: ResolvedPiiFlags & { searchable?: boolean; sensitive?: boolean } = {};
68
+ if (find === "exact") {
69
+ findFlags = { lookupable: true };
70
+ } else if (find === "fuzzy") {
71
+ findFlags = { lookupable: true, searchable: true };
72
+ } else if (find === "secret") {
73
+ findFlags = { sensitive: true };
74
+ }
75
+
76
+ return {
77
+ ...rest,
78
+ ...personalFlags,
79
+ ...findFlags,
80
+ ...(anonymize ? { anonymize } : {}),
81
+ } as Omit<T, "personal" | "find" | "reason"> & ResolvedPiiFlags;
82
+ }
83
+
28
84
  // Generic über `R extends true | false` (statt `boolean`) damit
29
85
  // `createTextField({ required: true })` literal `required: true` im
30
86
  // Return-Type behält. `boolean` würde widenen — EntityTable<E>'s
@@ -33,7 +89,8 @@ import type {
33
89
  // degradieren. Default `R = false` matcht den runtime-default. Pattern
34
90
  // in jeder required-bearing factory unten.
35
91
  export function createTextField<R extends true | false = false>(
36
- overrides?: Partial<Omit<TextFieldDef, "type" | "required">> & { required?: R },
92
+ overrides?: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
93
+ PersonalAnnotations & { required?: R },
37
94
  ): TextFieldDef & { required: R } {
38
95
  return {
39
96
  type: "text",
@@ -41,7 +98,7 @@ export function createTextField<R extends true | false = false>(
41
98
  required: false,
42
99
  searchable: false,
43
100
  sortable: false,
44
- ...overrides,
101
+ ...expandPersonalAnnotations(overrides),
45
102
  } as TextFieldDef & { required: R }; // @cast-boundary engine-payload
46
103
  }
47
104
 
@@ -67,12 +124,13 @@ export function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef {
67
124
  }
68
125
 
69
126
  export function createLongTextField<R extends true | false = false>(
70
- overrides?: Partial<Omit<LongTextFieldDef, "type" | "required">> & { required?: R },
127
+ overrides?: Partial<Omit<LongTextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
128
+ PersonalAnnotationsLongText & { required?: R },
71
129
  ): LongTextFieldDef & { required: R } {
72
130
  return {
73
131
  type: "longText",
74
132
  required: false,
75
- ...overrides,
133
+ ...expandPersonalAnnotations(overrides),
76
134
  } as LongTextFieldDef & { required: R }; // @cast-boundary engine-payload
77
135
  }
78
136
 
@@ -92,13 +150,14 @@ export function createSelectField<
92
150
  R extends true | false = false,
93
151
  >(
94
152
  opts: { options: TOptions } & Partial<
95
- Omit<SelectFieldDef<TOptions>, "type" | "options" | "required">
96
- > & { required?: R },
153
+ Omit<SelectFieldDef<TOptions>, "type" | "options" | "required" | keyof ResolvedPiiFlags>
154
+ > &
155
+ PersonalAnnotationsNoFind & { required?: R },
97
156
  ): SelectFieldDef<TOptions> & { required: R } {
98
157
  return {
99
158
  type: "select",
100
159
  required: false,
101
- ...opts,
160
+ ...expandPersonalAnnotations(opts),
102
161
  } as SelectFieldDef<TOptions> & { required: R }; // @cast-boundary engine-payload
103
162
  }
104
163
 
@@ -124,12 +183,15 @@ export function createSelectField<
124
183
  * Wann statt `embedded` mit Booleans: bei mehr als ~5 Optionen.
125
184
  */
126
185
  export function createMultiSelectField<const TOptions extends readonly string[]>(
127
- opts: { options: TOptions } & Partial<Omit<MultiSelectFieldDef<TOptions>, "type" | "options">>,
186
+ opts: { options: TOptions } & Partial<
187
+ Omit<MultiSelectFieldDef<TOptions>, "type" | "options" | keyof ResolvedPiiFlags>
188
+ > &
189
+ PersonalAnnotationsNoFind,
128
190
  ): MultiSelectFieldDef<TOptions> {
129
191
  return {
130
192
  type: "multiSelect",
131
193
  required: false,
132
- ...opts,
194
+ ...expandPersonalAnnotations(opts),
133
195
  };
134
196
  }
135
197
 
@@ -140,22 +202,24 @@ export function createMultiSelectField<const TOptions extends readonly string[]>
140
202
  * instead (money-adjacent math)? Use `createDecimalField` (`numeric`).
141
203
  */
142
204
  export function createNumberField<R extends true | false = false>(
143
- overrides?: Partial<Omit<NumberFieldDef, "type" | "required">> & { required?: R },
205
+ overrides?: Partial<Omit<NumberFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
206
+ PersonalAnnotationsNoFind & { required?: R },
144
207
  ): NumberFieldDef & { required: R } {
145
208
  return {
146
209
  type: "number",
147
210
  required: false,
148
- ...overrides,
211
+ ...expandPersonalAnnotations(overrides),
149
212
  } as NumberFieldDef & { required: R }; // @cast-boundary engine-payload
150
213
  }
151
214
 
152
215
  export function createBigIntField<R extends true | false = false>(
153
- overrides?: Partial<Omit<BigIntFieldDef, "type" | "required">> & { required?: R },
216
+ overrides?: Partial<Omit<BigIntFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
217
+ PersonalAnnotationsNoFind & { required?: R },
154
218
  ): BigIntFieldDef & { required: R } {
155
219
  return {
156
220
  type: "bigInt",
157
221
  required: false,
158
- ...overrides,
222
+ ...expandPersonalAnnotations(overrides),
159
223
  } as BigIntFieldDef & { required: R }; // @cast-boundary engine-payload
160
224
  }
161
225
 
@@ -164,8 +228,9 @@ export function createBigIntField<R extends true | false = false>(
164
228
  // caveat (surfaced as JS number, safe ≤ 2^53).
165
229
  export function createDecimalField<R extends true | false = false>(
166
230
  config: { precision: number; scale: number } & Partial<
167
- Omit<DecimalFieldDef, "type" | "precision" | "scale" | "required">
168
- > & { required?: R },
231
+ Omit<DecimalFieldDef, "type" | "precision" | "scale" | "required" | keyof ResolvedPiiFlags>
232
+ > &
233
+ PersonalAnnotationsNoFind & { required?: R },
169
234
  ): DecimalFieldDef & { required: R } {
170
235
  // Fail at definition time, not at the first migration: numeric(p,s) requires
171
236
  // integer p ≥ 1 and 0 ≤ s ≤ p (Postgres rejects e.g. numeric(2,4), and the
@@ -186,7 +251,7 @@ export function createDecimalField<R extends true | false = false>(
186
251
  return {
187
252
  type: "decimal",
188
253
  required: false,
189
- ...config,
254
+ ...expandPersonalAnnotations(config),
190
255
  } as DecimalFieldDef & { required: R }; // @cast-boundary engine-payload
191
256
  }
192
257
 
@@ -201,12 +266,13 @@ export function createMoneyField<R extends true | false = false>(
201
266
 
202
267
  export function createEmbeddedField(
203
268
  schema: EmbeddedFieldDef["schema"],
204
- overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema">>,
269
+ overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema" | keyof ResolvedPiiFlags>> &
270
+ PersonalAnnotationsNoFind,
205
271
  ): EmbeddedFieldDef {
206
272
  return {
207
273
  type: "embedded",
208
274
  schema,
209
- ...overrides,
275
+ ...expandPersonalAnnotations(overrides),
210
276
  };
211
277
  }
212
278
 
@@ -221,12 +287,15 @@ export function createEmbeddedField(
221
287
  // silently fall back to the single-object column type.
222
288
  export function createEmbeddedListField(
223
289
  schema: EmbeddedFieldDef["schema"],
224
- overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema" | "multiple">>,
290
+ overrides?: Partial<
291
+ Omit<EmbeddedFieldDef, "type" | "schema" | "multiple" | keyof ResolvedPiiFlags>
292
+ > &
293
+ PersonalAnnotationsNoFind,
225
294
  ): EmbeddedFieldDef & { multiple: true } {
226
295
  return {
227
296
  type: "embedded",
228
297
  schema,
229
- ...overrides,
298
+ ...expandPersonalAnnotations(overrides),
230
299
  multiple: true,
231
300
  };
232
301
  }
@@ -235,20 +304,24 @@ export function createEmbeddedListField(
235
304
  // `{}`, NOT NULL. Hauptnutzer: custom-fields-Bundle (host-entity's
236
305
  // `customFields`-Spalte). Andere valid uses: tenant-config-blobs, AI-
237
306
  // inferred-metadata, future tags-arrays.
238
- export function createJsonbField(overrides?: Partial<Omit<JsonbFieldDef, "type">>): JsonbFieldDef {
307
+ export function createJsonbField(
308
+ overrides?: Partial<Omit<JsonbFieldDef, "type" | keyof ResolvedPiiFlags>> &
309
+ PersonalAnnotationsNoFind,
310
+ ): JsonbFieldDef {
239
311
  return {
240
312
  type: "jsonb",
241
- ...overrides,
313
+ ...expandPersonalAnnotations(overrides),
242
314
  };
243
315
  }
244
316
 
245
317
  export function createDateField<R extends true | false = false>(
246
- overrides?: Partial<Omit<DateFieldDef, "type" | "required">> & { required?: R },
318
+ overrides?: Partial<Omit<DateFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
319
+ PersonalAnnotationsNoFind & { required?: R },
247
320
  ): DateFieldDef & { required: R } {
248
321
  return {
249
322
  type: "date",
250
323
  required: false,
251
- ...overrides,
324
+ ...expandPersonalAnnotations(overrides),
252
325
  } as DateFieldDef & { required: R }; // @cast-boundary engine-payload
253
326
  }
254
327
 
@@ -261,13 +334,14 @@ export function createDateField<R extends true | false = false>(
261
334
  * das EIN atomares Feld statt eines lose verdrahteten Pairs erzeugt.
262
335
  */
263
336
  export function createTimestampField<R extends true | false = false>(
264
- overrides?: Partial<Omit<TimestampFieldDef, "type" | "required">> & { required?: R },
337
+ overrides?: Partial<Omit<TimestampFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
338
+ PersonalAnnotationsNoFind & { required?: R },
265
339
  ): TimestampFieldDef & { required: R } {
266
340
  // Object-Build vermeidet hartcodiertes `required: false` im literal —
267
341
  // das würde TS dazu bringen, R auf `boolean` zu widenen statt das
268
342
  // literal `true`/`false` aus dem overrides-Argument zu inferieren.
269
343
  return {
270
- ...overrides,
344
+ ...expandPersonalAnnotations(overrides),
271
345
  type: "timestamp",
272
346
  required: (overrides?.required ?? false) as R, // @cast-boundary engine-payload
273
347
  };
@@ -278,12 +352,13 @@ export function createTimestampField<R extends true | false = false>(
278
352
  * via `Intl.supportedValuesOf("timeZone")` geprüft (kommt im Zod-Schritt).
279
353
  */
280
354
  export function createTzField<R extends true | false = false>(
281
- overrides?: Partial<Omit<TzFieldDef, "type" | "required">> & { required?: R },
355
+ overrides?: Partial<Omit<TzFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
356
+ PersonalAnnotationsNoFind & { required?: R },
282
357
  ): TzFieldDef & { required: R } {
283
358
  return {
284
359
  type: "tz",
285
360
  required: false,
286
- ...overrides,
361
+ ...expandPersonalAnnotations(overrides),
287
362
  } as TzFieldDef & { required: R }; // @cast-boundary engine-payload
288
363
  }
289
364
 
@@ -318,12 +393,15 @@ export function createTzField<R extends true | false = false>(
318
393
  * separater Berechnung.
319
394
  */
320
395
  export function createLocatedTimestampField<R extends true | false = false>(
321
- overrides?: Partial<Omit<LocatedTimestampFieldDef, "type" | "required">> & { required?: R },
396
+ overrides?: Partial<
397
+ Omit<LocatedTimestampFieldDef, "type" | "required" | keyof ResolvedPiiFlags>
398
+ > &
399
+ PersonalAnnotationsNoFind & { required?: R },
322
400
  ): LocatedTimestampFieldDef & { required: R } {
323
401
  return {
324
402
  type: "locatedTimestamp",
325
403
  required: false,
326
- ...overrides,
404
+ ...expandPersonalAnnotations(overrides),
327
405
  } as LocatedTimestampFieldDef & { required: R }; // @cast-boundary engine-payload
328
406
  }
329
407
 
@@ -2,13 +2,6 @@ import type { DbRow } from "../db/connection";
2
2
  import { normalizeAccessEntry, userCanReadFieldRow, userCanWriteFieldRow } from "./ownership";
3
3
  import type { EntityDefinition, SessionUser } from "./types";
4
4
 
5
- // piiEncrypted fields deliberately break that silence (kumiko-platform#463):
6
- // the field's whole point is "a legitimate reader may see the plaintext",
7
- // so an unauthorized reader seeing the field exist-but-masked is the
8
- // intended signal, not a leak — unlike a secret whose existence itself
9
- // should stay hidden.
10
- export const PII_MASKED_VALUE = "••••••";
11
-
12
5
  // Field-level read filtering. Returns a copy of `data` with fields stripped
13
6
  // if the user's roles don't grant read access OR the ownership-rule for the
14
7
  // matching role doesn't accept this concrete row. Fields without access
@@ -18,8 +11,7 @@ export const PII_MASKED_VALUE = "••••••";
18
11
  // one place in the ownership system where silence is the right default:
19
12
  // reporting "you tried to read X but can't" leaks the field's existence.
20
13
  // Writes do the opposite (loud error) because a silent drop there masks
21
- // save-bugs. piiEncrypted fields are the one exception — see
22
- // PII_MASKED_VALUE above.
14
+ // save-bugs.
23
15
  export function filterReadFields(
24
16
  entity: EntityDefinition,
25
17
  data: Readonly<Record<string, unknown>>,
@@ -37,10 +29,7 @@ export function filterReadFields(
37
29
 
38
30
  const accessMap = normalizeAccessEntry(field.access?.read);
39
31
  if (!userCanReadFieldRow(user, accessMap, data)) {
40
- if ("piiEncrypted" in field && field.piiEncrypted === true) {
41
- result[key] = PII_MASKED_VALUE;
42
- }
43
- continue; // entire field stripped (masked instead, for piiEncrypted)
32
+ continue; // entire field stripped
44
33
  }
45
34
 
46
35
  // For embedded fields: filter sub-fields with access restrictions.
@@ -341,6 +341,7 @@ export type {
341
341
  FieldRenderer,
342
342
  FileFieldDef,
343
343
  FilesFieldDef,
344
+ Findability,
344
345
  HandlerContext,
345
346
  HasManyRelation,
346
347
  HookMap,
@@ -357,6 +358,7 @@ export type {
357
358
  KumikoHandlerResultMap,
358
359
  LifecycleHookType,
359
360
  ListColumnSpec,
361
+ LongTextFindability,
360
362
  ManyToManyRelation,
361
363
  MspErrorMode,
362
364
  MspErrorPolicy,
@@ -376,7 +378,10 @@ export type {
376
378
  NotifyPriority,
377
379
  NumberFieldDef,
378
380
  OnDeleteStrategy,
379
- PiiAnnotations,
381
+ PersonalAnnotations,
382
+ PersonalAnnotationsLongText,
383
+ PersonalAnnotationsNoFind,
384
+ PersonalSubject,
380
385
  PlatformComponent,
381
386
  PostDeleteHookFn,
382
387
  PostSaveHookFn,
@@ -392,6 +397,7 @@ export type {
392
397
  ReferenceDataDef,
393
398
  Registry,
394
399
  RelationDefinition,
400
+ ResolvedPiiFlags,
395
401
  RetentionDef,
396
402
  RowAction,
397
403
  SaveContext,