@cosmicdrift/kumiko-framework 0.209.1 → 0.211.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 (36) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-permalink-open.integration.test.ts +6 -1
  3. package/src/changes.json +56 -0
  4. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  5. package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
  6. package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
  7. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  8. package/src/db/__tests__/cursor.test.ts +26 -1
  9. package/src/db/__tests__/eagerload.integration.test.ts +3 -3
  10. package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
  11. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  12. package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
  13. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
  14. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
  15. package/src/db/cursor.ts +32 -0
  16. package/src/db/event-store-executor-read.ts +80 -9
  17. package/src/db/index.ts +2 -2
  18. package/src/db/pg-error.ts +7 -0
  19. package/src/db/queries/backfill-pii.ts +188 -18
  20. package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
  22. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
  23. package/src/engine/__tests__/factories-personal.test.ts +130 -0
  24. package/src/engine/__tests__/field-access.test.ts +1 -23
  25. package/src/engine/__tests__/store-table.test.ts +4 -1
  26. package/src/engine/boot-validator/pii-retention.ts +22 -54
  27. package/src/engine/build-config-feature-schema.ts +9 -1
  28. package/src/engine/factories.ts +108 -30
  29. package/src/engine/field-access.ts +2 -13
  30. package/src/engine/index.ts +7 -1
  31. package/src/engine/types/index.ts +7 -1
  32. package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
  33. package/src/files/file-ref-entity.ts +2 -2
  34. package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
  35. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
  36. package/src/testing/shared-entities.ts +6 -3
@@ -32,6 +32,13 @@ import {
32
32
  createTimestampField,
33
33
  createTzField,
34
34
  } from "../factories";
35
+ import type { LongTextFieldDef, TextFieldDef } from "../types";
36
+
37
+ // The new personal/find union can no longer express some flag combinations
38
+ // on purpose (e.g. two subjects on one field) — these tests deliberately
39
+ // construct the raw, rejected shape to prove the boot-validator still throws.
40
+ const rawField = <T>(f: T) => f as unknown as TextFieldDef;
41
+ const rawLongTextField = <T>(f: T) => f as unknown as LongTextFieldDef;
35
42
 
36
43
  // Stubt einen leeren `<entity>:list`-Query-Handler damit der reference-
37
44
  // Field-Boot-Validator den Audit-Fix-#2-Check durchläßt. Wird gebraucht
@@ -64,7 +71,10 @@ describe("validateBoot — PII annotations", () => {
64
71
  "user",
65
72
  createEntity({
66
73
  fields: {
67
- email: createTextField({ pii: true }),
74
+ email: createTextField({
75
+ personal: "self",
76
+ find: "none",
77
+ }),
68
78
  },
69
79
  }),
70
80
  );
@@ -78,7 +88,10 @@ describe("validateBoot — PII annotations", () => {
78
88
  "branding",
79
89
  createEntity({
80
90
  fields: {
81
- brandColor: createTextField({ tenantOwned: true }),
91
+ brandColor: createTextField({
92
+ personal: "tenant",
93
+ find: "none",
94
+ }),
82
95
  },
83
96
  }),
84
97
  );
@@ -88,13 +101,26 @@ describe("validateBoot — PII annotations", () => {
88
101
 
89
102
  test("userOwned with valid ownerField on reference passes", () => {
90
103
  const feature = defineFeature("test", (r) => {
91
- r.entity("user", createEntity({ fields: { email: createTextField({ pii: true }) } }));
104
+ r.entity(
105
+ "user",
106
+ createEntity({
107
+ fields: {
108
+ email: createTextField({
109
+ personal: "self",
110
+ find: "none",
111
+ }),
112
+ },
113
+ }),
114
+ );
92
115
  stubListHandler(r, "user");
93
116
  r.entity(
94
117
  "comment",
95
118
  createEntity({
96
119
  fields: {
97
- body: createLongTextField({ userOwned: { ownerField: "authorId" } }),
120
+ body: createLongTextField({
121
+ personal: { of: "authorId" },
122
+ find: "none",
123
+ }),
98
124
  authorId: { type: "reference", entity: "user" },
99
125
  },
100
126
  }),
@@ -109,7 +135,11 @@ describe("validateBoot — PII annotations", () => {
109
135
  "thing",
110
136
  createEntity({
111
137
  fields: {
112
- confused: createTextField({ pii: true, tenantOwned: true }),
138
+ confused: rawField({
139
+ ...createTextField(),
140
+ pii: true,
141
+ tenantOwned: true,
142
+ }),
113
143
  },
114
144
  }),
115
145
  );
@@ -137,7 +167,10 @@ describe("validateBoot — PII annotations", () => {
137
167
  "vault",
138
168
  createEntity({
139
169
  fields: {
140
- apiToken: createTextField({ sensitive: true, pii: true }),
170
+ apiToken: createTextField({
171
+ personal: "self",
172
+ find: "secret",
173
+ }),
141
174
  },
142
175
  }),
143
176
  );
@@ -172,13 +205,16 @@ describe("validateBoot — PII annotations", () => {
172
205
  "comment",
173
206
  createEntity({
174
207
  fields: {
175
- body: createLongTextField({ userOwned: { ownerField: "ghostField" } }),
208
+ body: createLongTextField({
209
+ personal: { of: "ghostField" },
210
+ find: "none",
211
+ }),
176
212
  },
177
213
  }),
178
214
  );
179
215
  });
180
216
  expect(() => validateBoot([feature])).toThrow(
181
- /userOwned\.ownerField "ghostField" but no such field exists/,
217
+ /personal\.of "ghostField" but no such field exists/,
182
218
  );
183
219
  });
184
220
 
@@ -188,7 +224,10 @@ describe("validateBoot — PII annotations", () => {
188
224
  "comment",
189
225
  createEntity({
190
226
  fields: {
191
- body: createLongTextField({ userOwned: { ownerField: "authorId" } }),
227
+ body: createLongTextField({
228
+ personal: { of: "authorId" },
229
+ find: "none",
230
+ }),
192
231
  authorId: createTextField(),
193
232
  },
194
233
  }),
@@ -203,7 +242,10 @@ describe("validateBoot — PII annotations", () => {
203
242
  "comment",
204
243
  createEntity({
205
244
  fields: {
206
- body: createLongTextField({ userOwned: { ownerField: "isPublic" } }),
245
+ body: createLongTextField({
246
+ personal: { of: "isPublic" },
247
+ find: "none",
248
+ }),
207
249
  isPublic: { type: "boolean" },
208
250
  },
209
251
  }),
@@ -222,7 +264,10 @@ describe("validateBoot — PII annotations", () => {
222
264
  "personalNote",
223
265
  createEntity({
224
266
  fields: {
225
- body: createLongTextField({ userOwned: { ownerField: "employeeId" } }),
267
+ body: createLongTextField({
268
+ personal: { of: "employeeId" },
269
+ find: "none",
270
+ }),
226
271
  employeeId: { type: "reference", entity: "employee" },
227
272
  },
228
273
  }),
@@ -295,7 +340,9 @@ describe("validateBoot — PII annotations", () => {
295
340
  "thing",
296
341
  createEntity({
297
342
  fields: {
298
- authorId: createTextField({ subjectRef: true }),
343
+ authorId: createTextField({
344
+ personal: "ref",
345
+ }),
299
346
  },
300
347
  }),
301
348
  );
@@ -313,7 +360,10 @@ describe("validateBoot — PII annotations", () => {
313
360
  "company",
314
361
  createEntity({
315
362
  fields: {
316
- name: createTextField({ allowPlaintext: "is-business-data" }),
363
+ name: createTextField({
364
+ personal: false,
365
+ reason: "is_business_data",
366
+ }),
317
367
  },
318
368
  }),
319
369
  );
@@ -331,7 +381,10 @@ describe("validateBoot — PII annotations", () => {
331
381
  "user",
332
382
  createEntity({
333
383
  fields: {
334
- email: createTextField({ pii: true }),
384
+ email: createTextField({
385
+ personal: "self",
386
+ find: "none",
387
+ }),
335
388
  },
336
389
  }),
337
390
  );
@@ -351,7 +404,9 @@ describe("validateBoot — PII annotations", () => {
351
404
  "payslip",
352
405
  createEntity({
353
406
  fields: {
354
- grossAmount: createNumberField({ pii: true }),
407
+ grossAmount: createNumberField({
408
+ personal: "self",
409
+ }),
355
410
  },
356
411
  }),
357
412
  );
@@ -367,7 +422,7 @@ describe("validateBoot — PII annotations", () => {
367
422
  fields: {
368
423
  gender: createSelectField({
369
424
  options: ["male", "female", "diverse", "prefer-not-to-say"] as const,
370
- pii: true,
425
+ personal: "self",
371
426
  }),
372
427
  },
373
428
  }),
@@ -384,7 +439,7 @@ describe("validateBoot — PII annotations", () => {
384
439
  fields: {
385
440
  dietaryRestrictions: createMultiSelectField({
386
441
  options: ["vegan", "vegetarian", "halal", "kosher", "gluten-free"] as const,
387
- pii: true,
442
+ personal: "self",
388
443
  }),
389
444
  },
390
445
  }),
@@ -399,7 +454,9 @@ describe("validateBoot — PII annotations", () => {
399
454
  "profile",
400
455
  createEntity({
401
456
  fields: {
402
- dateOfBirth: createDateField({ pii: true }),
457
+ dateOfBirth: createDateField({
458
+ personal: "self",
459
+ }),
403
460
  },
404
461
  }),
405
462
  );
@@ -413,7 +470,9 @@ describe("validateBoot — PII annotations", () => {
413
470
  "session",
414
471
  createEntity({
415
472
  fields: {
416
- lastLoginAt: createTimestampField({ pii: true }),
473
+ lastLoginAt: createTimestampField({
474
+ personal: "self",
475
+ }),
417
476
  },
418
477
  }),
419
478
  );
@@ -427,7 +486,9 @@ describe("validateBoot — PII annotations", () => {
427
486
  "profile",
428
487
  createEntity({
429
488
  fields: {
430
- homeTz: createTzField({ pii: true }),
489
+ homeTz: createTzField({
490
+ personal: "self",
491
+ }),
431
492
  },
432
493
  }),
433
494
  );
@@ -441,7 +502,9 @@ describe("validateBoot — PII annotations", () => {
441
502
  "shift",
442
503
  createEntity({
443
504
  fields: {
444
- startsAt: createLocatedTimestampField({ pii: true }),
505
+ startsAt: createLocatedTimestampField({
506
+ personal: "self",
507
+ }),
445
508
  },
446
509
  }),
447
510
  );
@@ -461,7 +524,9 @@ describe("validateBoot — PII annotations", () => {
461
524
  city: { type: "text" },
462
525
  postalCode: { type: "text" },
463
526
  },
464
- { pii: true },
527
+ {
528
+ personal: "self",
529
+ },
465
530
  ),
466
531
  },
467
532
  }),
@@ -593,8 +658,14 @@ describe("validateBoot — retention", () => {
593
658
  "invoice",
594
659
  createEntity({
595
660
  fields: {
596
- invoiceNumber: createTextField({ allowPlaintext: "is-business-data" }),
597
- customerName: createTextField({ pii: true }),
661
+ invoiceNumber: createTextField({
662
+ personal: false,
663
+ reason: "is_business_data",
664
+ }),
665
+ customerName: createTextField({
666
+ personal: "self",
667
+ find: "none",
668
+ }),
598
669
  },
599
670
  retention: { keepFor: "10y", strategy: "blockDelete" },
600
671
  }),
@@ -613,7 +684,10 @@ describe("validateBoot — retention", () => {
613
684
  "lease",
614
685
  createEntity({
615
686
  fields: {
616
- reference: createTextField({ allowPlaintext: "is-business-data" }),
687
+ reference: createTextField({
688
+ personal: false,
689
+ reason: "is_business_data",
690
+ }),
617
691
  },
618
692
  retention: { keepFor: "10y", strategy: "blockDelete" },
619
693
  }),
@@ -632,7 +706,9 @@ describe("validateBoot — retention", () => {
632
706
  "lease",
633
707
  createEntity({
634
708
  fields: {
635
- authorId: createTextField({ subjectRef: true }),
709
+ authorId: createTextField({
710
+ personal: "ref",
711
+ }),
636
712
  },
637
713
  retention: { keepFor: "10y", strategy: "blockDelete" },
638
714
  }),
@@ -651,7 +727,10 @@ describe("validateBoot — retention", () => {
651
727
  "thing",
652
728
  createEntity({
653
729
  fields: {
654
- note: createTextField({ allowPlaintext: "is-business-data" }),
730
+ note: createTextField({
731
+ personal: false,
732
+ reason: "is_business_data",
733
+ }),
655
734
  },
656
735
  retention: { keepFor: "30days", strategy: "hardDelete" },
657
736
  }),
@@ -672,7 +751,10 @@ describe("validateBoot — retention", () => {
672
751
  "thing",
673
752
  createEntity({
674
753
  fields: {
675
- note: createTextField({ allowPlaintext: "is-business-data" }),
754
+ note: createTextField({
755
+ personal: false,
756
+ reason: "is_business_data",
757
+ }),
676
758
  },
677
759
  retention: { keepFor, strategy: "hardDelete" },
678
760
  }),
@@ -692,9 +774,13 @@ describe("validateBoot — retention", () => {
692
774
  "invoice",
693
775
  createEntity({
694
776
  fields: {
695
- invoiceNumber: createTextField({ allowPlaintext: "is-business-data" }),
777
+ invoiceNumber: createTextField({
778
+ personal: false,
779
+ reason: "is_business_data",
780
+ }),
696
781
  customerName: createTextField({
697
- pii: true,
782
+ personal: "self",
783
+ find: "none",
698
784
  anonymize: () => "[ANONYMIZED]",
699
785
  }),
700
786
  },
@@ -717,7 +803,10 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
717
803
  "user",
718
804
  createEntity({
719
805
  fields: {
720
- email: createTextField({ pii: true, lookupable: true }),
806
+ email: createTextField({
807
+ personal: "self",
808
+ find: "exact",
809
+ }),
721
810
  },
722
811
  }),
723
812
  );
@@ -731,12 +820,15 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
731
820
  "doc",
732
821
  createEntity({
733
822
  fields: {
734
- slug: createTextField({ lookupable: true }),
823
+ slug: rawField({
824
+ ...createTextField(),
825
+ lookupable: true,
826
+ }),
735
827
  },
736
828
  }),
737
829
  );
738
830
  });
739
- expect(() => validateBoot([feature])).toThrow(/lookupable.*without a subject annotation/);
831
+ expect(() => validateBoot([feature])).toThrow(/find:.*without a subject annotation/);
740
832
  });
741
833
 
742
834
  test("lookupable on non-text field throws", () => {
@@ -745,8 +837,10 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
745
837
  "person",
746
838
  createEntity({
747
839
  fields: {
748
- // longText carries PiiAnnotations too — lookupable must still be rejected.
749
- bio: createLongTextField({ userOwned: { ownerField: "ownerId" }, lookupable: true }),
840
+ bio: rawLongTextField({
841
+ ...createLongTextField({ personal: { of: "ownerId" }, find: "none" }),
842
+ lookupable: true,
843
+ }),
750
844
  ownerId: createTextField({ required: true }),
751
845
  },
752
846
  }),
@@ -761,7 +855,10 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
761
855
  "user",
762
856
  createEntity({
763
857
  fields: {
764
- displayName: createTextField({ pii: true, searchable: true }),
858
+ displayName: createTextField({
859
+ personal: "self",
860
+ find: "fuzzy",
861
+ }),
765
862
  },
766
863
  }),
767
864
  );
@@ -775,7 +872,11 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
775
872
  "user",
776
873
  createEntity({
777
874
  fields: {
778
- email: createTextField({ pii: true, sortable: true }),
875
+ email: createTextField({
876
+ personal: "self",
877
+ find: "none",
878
+ sortable: true,
879
+ }),
779
880
  },
780
881
  }),
781
882
  );
@@ -789,7 +890,8 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
789
890
  "user",
790
891
  createEntity({
791
892
  fields: {
792
- passwordHash: createTextField({
893
+ passwordHash: rawField({
894
+ ...createTextField(),
793
895
  pii: true,
794
896
  sensitive: true,
795
897
  searchable: true,
@@ -801,105 +903,3 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
801
903
  expect(() => validateBoot([feature])).toThrow(/sensitive.*searchable/);
802
904
  });
803
905
  });
804
-
805
- describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
806
- test("piiEncrypted on a plain text field passes", () => {
807
- const feature = defineFeature("test", (r) => {
808
- r.entity(
809
- "tenant",
810
- createEntity({
811
- fields: {
812
- iban: createTextField({
813
- piiEncrypted: true,
814
- tenantOwned: true,
815
- access: { read: ["TenantAdmin"] },
816
- }),
817
- },
818
- }),
819
- );
820
- });
821
- expect(() => validateBoot([feature])).not.toThrow();
822
- });
823
-
824
- test("piiEncrypted on a non-text field throws", () => {
825
- const feature = defineFeature("test", (r) => {
826
- r.entity(
827
- "tenant",
828
- createEntity({
829
- fields: {
830
- amount: { ...createNumberField(), piiEncrypted: true } as unknown as ReturnType<
831
- typeof createNumberField
832
- >,
833
- },
834
- }),
835
- );
836
- });
837
- expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*only applies to text fields/);
838
- });
839
-
840
- test("piiEncrypted without a subject annotation throws (kumiko-platform#457)", () => {
841
- const feature = defineFeature("test", (r) => {
842
- r.entity(
843
- "tenant",
844
- createEntity({
845
- fields: {
846
- iban: createTextField({ piiEncrypted: true }),
847
- },
848
- }),
849
- );
850
- });
851
- expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without a subject annotation/);
852
- });
853
-
854
- test("piiEncrypted combined with searchable passes (#1610)", () => {
855
- const feature = defineFeature("test", (r) => {
856
- r.entity(
857
- "tenant",
858
- createEntity({
859
- fields: {
860
- iban: createTextField({
861
- piiEncrypted: true,
862
- tenantOwned: true,
863
- access: { read: ["TenantAdmin"] },
864
- searchable: true,
865
- }),
866
- },
867
- }),
868
- );
869
- });
870
- expect(() => validateBoot([feature])).not.toThrow();
871
- });
872
-
873
- test("piiEncrypted combined with sortable throws", () => {
874
- const feature = defineFeature("test", (r) => {
875
- r.entity(
876
- "tenant",
877
- createEntity({
878
- fields: {
879
- iban: createTextField({
880
- piiEncrypted: true,
881
- tenantOwned: true,
882
- access: { read: ["TenantAdmin"] },
883
- sortable: true,
884
- }),
885
- },
886
- }),
887
- );
888
- });
889
- expect(() => validateBoot([feature])).toThrow(/sortable/);
890
- });
891
-
892
- test("piiEncrypted without access.read throws (kumiko-platform#460)", () => {
893
- const feature = defineFeature("test", (r) => {
894
- r.entity(
895
- "tenant",
896
- createEntity({
897
- fields: {
898
- iban: createTextField({ piiEncrypted: true, tenantOwned: true }),
899
- },
900
- }),
901
- );
902
- });
903
- expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without.*access.*read/);
904
- });
905
- });
@@ -63,9 +63,17 @@ describe("S0 Integration — full surface stack", () => {
63
63
  "user",
64
64
  createEntity({
65
65
  fields: {
66
- email: createTextField({ pii: true }),
67
- displayName: createTextField({ pii: true }),
68
- lastLoginAt: createTimestampField({ pii: true }),
66
+ email: createTextField({
67
+ personal: "self",
68
+ find: "none",
69
+ }),
70
+ displayName: createTextField({
71
+ personal: "self",
72
+ find: "none",
73
+ }),
74
+ lastLoginAt: createTimestampField({
75
+ personal: "self",
76
+ }),
69
77
  },
70
78
  retention: {
71
79
  keepFor: "10y",
@@ -80,7 +88,8 @@ describe("S0 Integration — full surface stack", () => {
80
88
  createEntity({
81
89
  fields: {
82
90
  body: createLongTextField({
83
- userOwned: { ownerField: "authorId" },
91
+ personal: { of: "authorId" },
92
+ find: "none",
84
93
  anonymize: () => "[ANONYMIZED]",
85
94
  }),
86
95
  authorId: { type: "reference", entity: "user" },
@@ -130,7 +139,10 @@ describe("S0 Integration — full surface stack", () => {
130
139
  "user",
131
140
  createEntity({
132
141
  fields: {
133
- email: createTextField({ pii: true }),
142
+ email: createTextField({
143
+ personal: "self",
144
+ find: "none",
145
+ }),
134
146
  },
135
147
  retention: {
136
148
  keepFor: "30d",
@@ -0,0 +1,130 @@
1
+ // Unit tests for the `personal`/`find` PII annotation resolution
2
+ // (kumiko-framework#2250) — the `expandPersonalAnnotations` helper in
3
+ // ../factories, exercised through the public factory functions.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { createLongTextField, createTextField, createTimestampField } from "../factories";
7
+
8
+ describe("createTextField — personal/find resolution", () => {
9
+ test("no annotation at all: no PII flags, no personal/find/reason leak into the field", () => {
10
+ const f = createTextField({ required: true });
11
+ expect(f).toEqual({
12
+ type: "text",
13
+ maxLength: 200,
14
+ required: true,
15
+ searchable: false,
16
+ sortable: false,
17
+ });
18
+ expect(f).not.toHaveProperty("personal");
19
+ expect(f).not.toHaveProperty("find");
20
+ expect(f).not.toHaveProperty("reason");
21
+ });
22
+
23
+ test('personal: "self", find: "exact" → pii + lookupable', () => {
24
+ const f = createTextField({ personal: "self", find: "exact" });
25
+ expect(f.pii).toBe(true);
26
+ expect(f.lookupable).toBe(true);
27
+ expect(f.searchable).toBe(false);
28
+ expect(f).not.toHaveProperty("personal");
29
+ expect(f).not.toHaveProperty("find");
30
+ });
31
+
32
+ test('personal: "self", find: "fuzzy" → pii + lookupable + searchable', () => {
33
+ const f = createTextField({ personal: "self", find: "fuzzy" });
34
+ expect(f.pii).toBe(true);
35
+ expect(f.lookupable).toBe(true);
36
+ expect(f.searchable).toBe(true);
37
+ });
38
+
39
+ test('personal: "self", find: "none" → pii only, no lookup/search flags', () => {
40
+ const f = createTextField({ personal: "self", find: "none" });
41
+ expect(f.pii).toBe(true);
42
+ expect(f.lookupable).toBeUndefined();
43
+ expect(f.searchable).toBe(false);
44
+ expect(f.sensitive).toBeUndefined();
45
+ });
46
+
47
+ test('personal: "self", find: "secret" → pii + sensitive, no lookup/search', () => {
48
+ const f = createTextField({ personal: "self", find: "secret" });
49
+ expect(f.pii).toBe(true);
50
+ expect(f.sensitive).toBe(true);
51
+ expect(f.lookupable).toBeUndefined();
52
+ expect(f.searchable).toBe(false);
53
+ });
54
+
55
+ test('personal: "tenant", find: "exact" → tenantOwned + lookupable', () => {
56
+ const f = createTextField({ personal: "tenant", find: "exact" });
57
+ expect(f.tenantOwned).toBe(true);
58
+ expect(f.lookupable).toBe(true);
59
+ expect(f.pii).toBeUndefined();
60
+ });
61
+
62
+ test('personal: { of: "ownerId" }, find: "exact" → userOwned + lookupable', () => {
63
+ const f = createTextField({ personal: { of: "ownerId" }, find: "exact" });
64
+ expect(f.userOwned).toEqual({ ownerField: "ownerId" });
65
+ expect(f.lookupable).toBe(true);
66
+ });
67
+
68
+ test('personal: "ref" → subjectRef only, no find allowed on this variant', () => {
69
+ const f = createTextField({ personal: "ref" });
70
+ expect(f.subjectRef).toBe(true);
71
+ expect(f.pii).toBeUndefined();
72
+ expect(f.lookupable).toBeUndefined();
73
+ });
74
+
75
+ test("personal: false, reason given → allowPlaintext carries the reason", () => {
76
+ const f = createTextField({ personal: false, reason: "public display name, not identifying" });
77
+ expect(f.allowPlaintext).toBe("public display name, not identifying");
78
+ expect(f.pii).toBeUndefined();
79
+ });
80
+
81
+ test("anonymize survives resolution alongside a subject annotation", () => {
82
+ const anonymize = () => "[ANONYMIZED]";
83
+ const f = createTextField({ personal: "self", find: "none", anonymize });
84
+ expect(f.pii).toBe(true);
85
+ expect(f.anonymize).toBe(anonymize);
86
+ });
87
+
88
+ test("other overrides (e.g. maxLength) pass through unaffected", () => {
89
+ const f = createTextField({ personal: "self", find: "none", maxLength: 500 });
90
+ expect(f.maxLength).toBe(500);
91
+ expect(f.pii).toBe(true);
92
+ });
93
+ });
94
+
95
+ describe("createLongTextField — restricted find (PersonalAnnotationsLongText)", () => {
96
+ test('personal: "self", find: "none" → pii only, no lookup/search flags', () => {
97
+ const f = createLongTextField({ personal: "self", find: "none" });
98
+ expect(f.pii).toBe(true);
99
+ expect(f.lookupable).toBeUndefined();
100
+ expect(f).not.toHaveProperty("searchable");
101
+ expect(f.sensitive).toBeUndefined();
102
+ });
103
+
104
+ test('personal: "self", find: "secret" → pii + sensitive, no lookup/search', () => {
105
+ const f = createLongTextField({ personal: "self", find: "secret" });
106
+ expect(f.pii).toBe(true);
107
+ expect(f.sensitive).toBe(true);
108
+ expect(f.lookupable).toBeUndefined();
109
+ expect(f).not.toHaveProperty("searchable");
110
+ });
111
+ });
112
+
113
+ describe("createTimestampField — personal without find (PersonalAnnotationsNoFind)", () => {
114
+ test("no annotation at all: plain field, no PII flags", () => {
115
+ const f = createTimestampField({ required: true });
116
+ expect(f).toEqual({ type: "timestamp", required: true });
117
+ });
118
+
119
+ test('personal: "self" alone (no find on non-text factories) → pii only', () => {
120
+ const f = createTimestampField({ personal: "self" });
121
+ expect(f.pii).toBe(true);
122
+ expect(f).not.toHaveProperty("personal");
123
+ expect(f).not.toHaveProperty("find");
124
+ });
125
+
126
+ test("personal: false, reason given → allowPlaintext", () => {
127
+ const f = createTimestampField({ personal: false, reason: "server-generated, not user data" });
128
+ expect(f.allowPlaintext).toBe("server-generated, not user data");
129
+ });
130
+ });