@cosmicdrift/kumiko-framework 0.273.0 → 0.274.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.273.0",
3
+ "version": "0.274.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.273.0",
201
+ "@cosmicdrift/kumiko-types": "0.274.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.273.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.274.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
package/src/changes.json CHANGED
@@ -1,4 +1,25 @@
1
1
  [
2
+ {
3
+ "version": "0.274.0",
4
+ "type": "breaking",
5
+ "title": "A searchable reference field targeting an encrypted/PII labelField now fails boot unless that label is itself searchable.",
6
+ "detail": "A searchable reference field can now match a search term against a target row's label even when that label is encrypted or personal data — the match runs through the target entity's own search index instead of the plain-text lookup used for a readable label, which could never match ciphertext and always returned zero matches. As with the plain-text lookup, an unusually large number of index matches drops the match clause instead of silently returning an incomplete page. A matched target row is also checked against the target entity's own read access for the current user, so a match can never surface a row the user could not otherwise see.",
7
+ "migration": "A searchable reference field whose target label is encrypted or personal data, but is not itself marked searchable (with fuzzy matching enabled if it also carries a personal-data annotation), now fails to boot instead of silently never matching at request time. Mark the target's label field searchable — with fuzzy matching if it holds personal data, so it is indexed for search — or remove the searchable flag from the reference field if the label was never meant to be searched."
8
+ },
9
+ {
10
+ "version": "0.274.0",
11
+ "type": "improvement",
12
+ "title": "Entity-list filters gain lte/gte comparison operators.",
13
+ "detail": "Entity-list filters — a screen's static filter, its faceted filters, and any handler built on the entity-list executor — now accept `lte` and `gte` alongside the existing `eq`/`ne`/`lt`/`gt`/`in`, so a \"valid as of today or earlier\" filter can be applied server-side instead of loading every row and filtering in memory. Allowed on the same field types as `lt`/`gt` (number/money/decimal/date/timestamp/locatedTimestamp) — the boot validator rejects them elsewhere the same way it already rejects `lt`/`gt`.",
14
+ "migration": "No action required — purely additive; existing filters and their allowed operators are unchanged."
15
+ },
16
+ {
17
+ "version": "0.274.0",
18
+ "type": "improvement",
19
+ "title": "Entity create handlers accept a caller-chosen id for idempotent creates from jobs/hooks.",
20
+ "detail": "`buildInsertSchema` — and every write handler built through `defineEntityCreateHandler`/`defineEntityWriteHandler`'s `create` verb — now accepts an optional `id` field validated as a UUID string. The create handler only honors it when the acting `SessionUser` is a system identity (`createSystemUser(tenantId, extraRoles)`; `extraRoles` is still needed to clear the handler's normal `hasAccess` role gate, and carrying `SYSTEM_ROLE` is what `isSystemIdentity` checks). An HTTP-authenticated end user's payload `id` is silently ignored and a fresh `generateId()` is minted, exactly as before this field existed — `SYSTEM_ROLE` is reserved and never assignable through tenant membership, so an ordinary caller can never present that identity. `executor.create()` already honored an explicit `id` (the seed/SCIM-import pattern); this only stops the generic create-handler schema from stripping it for a trusted caller. A same-id retry within the same tenant returns `version_conflict` (no duplicate row, no overwrite). A same id already used by a different tenant returns `unique_violation` instead — every entity's projection table has a plain `id` primary key that is not tenant-scoped, so the id space is shared per entity table across tenants; a deterministic id must fold the tenant id into its key to stay collision-free. New `generateDeterministicId(namespace, key)` (`@cosmicdrift/kumiko-framework/utils`, UUIDv5) sits next to `generateId()` for deriving such an id. The agent-tool manifest now lists `id` as an optional parameter on every entity's create tool; supplying it has no effect for a non-system caller, since agent-tool dispatch always passes the real caller's identity, never a system one.",
21
+ "migration": "No action for existing callers — the field is additive and inert for anyone but a system-identity caller, so no existing create request changes behavior. To create idempotently from an event-triggered job or hook: derive an id with `generateDeterministicId(namespace, key)` (fold the tenant id into `key` so two tenants can't collide), dispatch via `ctx.writeAs(createSystemUser(tenantId, [<a role your handler's access rule allows>]), qn, { ...payload, id })` instead of matching on description text or other business fields, and treat a `version_conflict` response as \"already created\"."
22
+ },
2
23
  {
3
24
  "version": "0.272.0",
4
25
  "type": "breaking",
@@ -27,13 +48,6 @@
27
48
  "migration": "Replace `crossTenant: true` with `escapeHatch: { reason: \"...\" }` on the same entity-convention handler call. Run `bun packages/framework/src/scripts/codemod/migrate-cross-tenant.ts [--dry-run] <path...>` first — it rewrites every `defineEntity*Handler`/`defineEntityWriteHandler`/`defineEntityQueryHandler` call whose entity name and verb it can read off the call itself, with a generated reason to replace with the concrete operator use case. Sites it lists as manual (a `crossTenant` on a shared/spread access object, a `registerEntityCrud` `write`/`read` block covering several verbs, a non-boolean-literal value, or an object that already declares `escapeHatch`) need a hand-written `escapeHatch: { reason }` per handler/verb.",
28
49
  "codemod": "scripts/codemod/migrate-cross-tenant.ts"
29
50
  },
30
- {
31
- "version": "0.271.0",
32
- "type": "improvement",
33
- "title": "Entity create handlers accept a caller-chosen id for idempotent creates from jobs/hooks.",
34
- "detail": "`buildInsertSchema` — and every write handler built through `defineEntityCreateHandler`/`defineEntityWriteHandler`'s `create` verb — now accepts an optional `id` field validated as a UUID string. The create handler only honors it when the acting `SessionUser` is a system identity (`createSystemUser(tenantId, extraRoles)`; `extraRoles` is still needed to clear the handler's normal `hasAccess` role gate, and carrying `SYSTEM_ROLE` is what `isSystemIdentity` checks). An HTTP-authenticated end user's payload `id` is silently ignored and a fresh `generateId()` is minted, exactly as before this field existed — `SYSTEM_ROLE` is reserved and never assignable through tenant membership, so an ordinary caller can never present that identity. `executor.create()` already honored an explicit `id` (the seed/SCIM-import pattern); this only stops the generic create-handler schema from stripping it for a trusted caller. A same-id retry within the same tenant returns `version_conflict` (no duplicate row, no overwrite). A same id already used by a different tenant returns `unique_violation` instead — every entity's projection table has a plain `id` primary key that is not tenant-scoped, so the id space is shared per entity table across tenants; a deterministic id must fold the tenant id into its key to stay collision-free. New `generateDeterministicId(namespace, key)` (`@cosmicdrift/kumiko-framework/utils`, UUIDv5) sits next to `generateId()` for deriving such an id. The agent-tool manifest now lists `id` as an optional parameter on every entity's create tool; supplying it has no effect for a non-system caller, since agent-tool dispatch always passes the real caller's identity, never a system one.",
35
- "migration": "No action for existing callers — the field is additive and inert for anyone but a system-identity caller, so no existing create request changes behavior. To create idempotently from an event-triggered job or hook: derive an id with `generateDeterministicId(namespace, key)` (fold the tenant id into `key` so two tenants can't collide), dispatch via `ctx.writeAs(createSystemUser(tenantId, [<a role your handler's access rule allows>]), qn, { ...payload, id })` instead of matching on description text or other business fields, and treat a `version_conflict` response as \"already created\"."
36
- },
37
51
  {
38
52
  "version": "0.271.0",
39
53
  "type": "breaking",
@@ -26,6 +26,14 @@ describe("buildFilterWhere", () => {
26
26
  expect(buildFilterWhere("createdAt", "gt", 100)).toEqual({ createdAt: { gt: 100 } });
27
27
  });
28
28
 
29
+ test("lte: wraps the value in a { lte } clause", () => {
30
+ expect(buildFilterWhere("createdAt", "lte", 100)).toEqual({ createdAt: { lte: 100 } });
31
+ });
32
+
33
+ test("gte: wraps the value in a { gte } clause", () => {
34
+ expect(buildFilterWhere("createdAt", "gte", 100)).toEqual({ createdAt: { gte: 100 } });
35
+ });
36
+
29
37
  test("in: non-empty array → direct array WhereObject", () => {
30
38
  expect(buildFilterWhere("status", "in", ["active", "pending"])).toEqual({
31
39
  status: ["active", "pending"],
@@ -283,6 +283,75 @@ describe("event-store-executor.list — filter (Tier 2.7c)", () => {
283
283
  expect(res.rows.map((r) => r["rank"])).toEqual([8, 9]);
284
284
  });
285
285
 
286
+ test("filter lte: rank <= 3 → 0,1,2,3 (boundary included)", async () => {
287
+ await seed(6);
288
+ const res = await exec.list(
289
+ {
290
+ limit: 50,
291
+ sort: "rank",
292
+ sortDirection: "asc",
293
+ filter: { field: "rank", op: "lte", value: 3 },
294
+ },
295
+ admin,
296
+ tdb,
297
+ );
298
+ expect(res.rows.map((r) => r["rank"])).toEqual([0, 1, 2, 3]);
299
+ });
300
+
301
+ test("filter gte: rank >= 7 → 7,8,9 (boundary included)", async () => {
302
+ await seed(10);
303
+ const res = await exec.list(
304
+ {
305
+ limit: 50,
306
+ sort: "rank",
307
+ sortDirection: "asc",
308
+ filter: { field: "rank", op: "gte", value: 7 },
309
+ },
310
+ admin,
311
+ tdb,
312
+ );
313
+ expect(res.rows.map((r) => r["rank"])).toEqual([7, 8, 9]);
314
+ });
315
+
316
+ test("filter lte on dueDate: boundary date included", async () => {
317
+ await exec.create({ title: "d-1", dueDate: "2026-01-10" }, admin, tdb);
318
+ await exec.create({ title: "d-2", dueDate: "2026-02-10" }, admin, tdb);
319
+ await exec.create({ title: "d-3", dueDate: "2026-03-10" }, admin, tdb);
320
+ const res = await exec.list(
321
+ {
322
+ limit: 50,
323
+ sort: "dueDate",
324
+ sortDirection: "asc",
325
+ filter: { field: "dueDate", op: "lte", value: "2026-02-10" },
326
+ },
327
+ admin,
328
+ tdb,
329
+ );
330
+ expect(res.rows.map((r) => r["title"])).toEqual(["d-1", "d-2"]);
331
+ });
332
+
333
+ test("filters[] gte+lte AND, kombiniert mit offset-Paging: zweite Seite korrekt", async () => {
334
+ await seed(10);
335
+ const res = await exec.list(
336
+ {
337
+ limit: 2,
338
+ offset: 2,
339
+ sort: "rank",
340
+ sortDirection: "asc",
341
+ totalCount: true,
342
+ filters: [
343
+ { field: "rank", op: "gte", value: 2 },
344
+ { field: "rank", op: "lte", value: 7 },
345
+ ],
346
+ },
347
+ admin,
348
+ tdb,
349
+ );
350
+ // range gte:2/lte:7 → [2,3,4,5,6,7] (6 rows), page size 2 offset 2 → [4,5]
351
+ expect(res.rows.map((r) => r["rank"])).toEqual([4, 5]);
352
+ expect(res.total).toBe(6);
353
+ });
354
+
286
355
  test("filter in: rank in [1,3,5]", async () => {
287
356
  await seed(10);
288
357
  const res = await exec.list(
@@ -9,7 +9,7 @@
9
9
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
10
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
11
11
  import { asRawClient } from "../../db/query";
12
- import { createEntity, createTextField } from "../../engine";
12
+ import { createEntity, createTextField, from } from "../../engine";
13
13
  import type { EntityDefinition } from "../../engine/types";
14
14
  import { createEventsTable } from "../../event-store";
15
15
  import { createInMemorySearchAdapter } from "../../search";
@@ -70,6 +70,45 @@ const tenantMetaEntity = createEntity({
70
70
  });
71
71
  const tenantMetaTable = buildEntityTable("tenant", tenantMetaEntity);
72
72
 
73
+ // PII target with a derived search index — ciphertext at rest, plaintext
74
+ // mirrored into the adapter; `ownerId` + `access.read` gives a real row-owned rule.
75
+ const piiCustomerEntity = createEntity({
76
+ table: "read_ref_search_pii_customers",
77
+ fields: {
78
+ ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
79
+ name: createTextField({ required: true, personal: "self", find: "fuzzy" }),
80
+ },
81
+ access: {
82
+ read: { Admin: "all", User: from("user:id", "ownerId") },
83
+ },
84
+ });
85
+ const piiCustomerTable = buildEntityTable("refSearchPiiCustomer", piiCustomerEntity);
86
+
87
+ const piiOrderEntity = createEntity({
88
+ table: "read_ref_search_pii_orders",
89
+ fields: {
90
+ note: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
91
+ customerId: {
92
+ type: "reference",
93
+ entity: "refSearchPiiCustomer",
94
+ labelField: "name",
95
+ searchable: true,
96
+ },
97
+ },
98
+ });
99
+ const piiOrderTable = buildEntityTable("refSearchPiiOrder", piiOrderEntity);
100
+
101
+ function resolvePiiEntity(name: string): EntityDefinition | undefined {
102
+ return name === "refSearchPiiCustomer" ? piiCustomerEntity : undefined;
103
+ }
104
+
105
+ const piiReferenceSearch = {
106
+ fields: [
107
+ { fieldName: "customerId", targetEntityName: "refSearchPiiCustomer", labelField: "name" },
108
+ ],
109
+ resolveEntity: resolvePiiEntity,
110
+ };
111
+
73
112
  function resolveEntity(name: string): EntityDefinition | undefined {
74
113
  return name === "refSearchCustomer" ? customerEntity : undefined;
75
114
  }
@@ -104,6 +143,9 @@ const orderExec = createEventStoreExecutor(orderTable, orderEntity, {
104
143
  const restrictedOrderExec = createEventStoreExecutor(restrictedOrderTable, restrictedOrderEntity, {
105
144
  entityName: "refSearchRestrictedOrder",
106
145
  });
146
+ const piiOrderExec = createEventStoreExecutor(piiOrderTable, piiOrderEntity, {
147
+ entityName: "refSearchPiiOrder",
148
+ });
107
149
 
108
150
  beforeAll(async () => {
109
151
  await ensureTemporalPolyfill();
@@ -111,6 +153,8 @@ beforeAll(async () => {
111
153
  await unsafeCreateEntityTable(testDb.db, customerEntity, "refSearchCustomer");
112
154
  await unsafeCreateEntityTable(testDb.db, orderEntity, "refSearchOrder");
113
155
  await unsafeCreateEntityTable(testDb.db, restrictedOrderEntity, "refSearchRestrictedOrder");
156
+ await unsafeCreateEntityTable(testDb.db, piiCustomerEntity, "refSearchPiiCustomer");
157
+ await unsafeCreateEntityTable(testDb.db, piiOrderEntity, "refSearchPiiOrder");
114
158
  await unsafeCreateEntityTable(testDb.db, tenantMetaEntity, "tenant");
115
159
  await createEventsTable(testDb.db);
116
160
  tdbA = createTenantDb(testDb.db, admin.tenantId);
@@ -124,7 +168,8 @@ afterAll(async () => {
124
168
  beforeEach(async () => {
125
169
  await asRawClient(testDb.db).unsafe(
126
170
  "TRUNCATE kumiko_events, read_ref_search_customers, read_ref_search_orders, " +
127
- "read_ref_search_restricted_orders, read_tenants RESTART IDENTITY CASCADE",
171
+ "read_ref_search_restricted_orders, read_ref_search_pii_customers, " +
172
+ "read_ref_search_pii_orders, read_tenants RESTART IDENTITY CASCADE",
128
173
  );
129
174
  });
130
175
 
@@ -379,3 +424,222 @@ describe("event-store-executor.list — searchable reference fields (fw#2660)",
379
424
  }
380
425
  });
381
426
  });
427
+
428
+ describe("event-store-executor.list — searchable reference to an encrypted/PII labelField via the search index", () => {
429
+ test('matches via the target\'s derived search index when the labelField is PII (find: "fuzzy")', async () => {
430
+ const [contact] = await seedRows(testDb.db, piiCustomerTable, [
431
+ {
432
+ id: crypto.randomUUID(),
433
+ tenantId: admin.tenantId,
434
+ ownerId: admin.id,
435
+ name: "Confidential Contact",
436
+ },
437
+ ]);
438
+ const [order] = await seedRows(testDb.db, piiOrderTable, [
439
+ {
440
+ id: crypto.randomUUID(),
441
+ tenantId: admin.tenantId,
442
+ note: "totally unrelated",
443
+ customerId: (contact as { id: string }).id,
444
+ },
445
+ ]);
446
+
447
+ const searchAdapter = createInMemorySearchAdapter();
448
+ // One shared per-tenant index across every entity type (real Meilisearch
449
+ // adapter parity: one index per tenant, `_type` is only a filter
450
+ // attribute) — `searchableFields` must list every entity's own
451
+ // searchable field, not just this test's own entity's.
452
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
453
+ // Plaintext label lives in the search adapter under the target's own
454
+ // type — never a raw ILIKE scan of the (encrypted-at-rest) column.
455
+ await searchAdapter.index(admin.tenantId, {
456
+ entityType: "refSearchPiiCustomer",
457
+ entityId: (contact as { id: string }).id,
458
+ weight: 1,
459
+ fields: { name: "Confidential Contact" },
460
+ });
461
+
462
+ const res = await piiOrderExec.list({ search: "confidential" }, admin, tdbA, {
463
+ searchAdapter,
464
+ referenceSearch: piiReferenceSearch,
465
+ });
466
+ expect(res.rows.map((r) => r["id"])).toEqual([(order as { id: string }).id]);
467
+ });
468
+
469
+ test("drops the reference clause above 200 search-index matches — native search still applies", async () => {
470
+ const fooCustomers = Array.from({ length: 201 }, (_, i) => ({
471
+ id: crypto.randomUUID(),
472
+ tenantId: admin.tenantId,
473
+ ownerId: admin.id,
474
+ name: `Foo Contact ${i}`,
475
+ }));
476
+ await seedRows(testDb.db, piiCustomerTable, fooCustomers);
477
+
478
+ const [refOnlyOrder, textMatchOrder] = await seedRows(testDb.db, piiOrderTable, [
479
+ {
480
+ id: crypto.randomUUID(),
481
+ tenantId: admin.tenantId,
482
+ note: "no keyword here",
483
+ customerId: fooCustomers[5]?.id,
484
+ },
485
+ {
486
+ id: crypto.randomUUID(),
487
+ tenantId: admin.tenantId,
488
+ note: "foo appears right here",
489
+ customerId: fooCustomers[10]?.id,
490
+ },
491
+ ]);
492
+
493
+ const searchAdapter = createInMemorySearchAdapter();
494
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
495
+ for (const c of fooCustomers) {
496
+ await searchAdapter.index(admin.tenantId, {
497
+ entityType: "refSearchPiiCustomer",
498
+ entityId: c.id,
499
+ weight: 1,
500
+ fields: { name: c.name },
501
+ });
502
+ }
503
+ for (const o of [refOnlyOrder, textMatchOrder]) {
504
+ const row = o as { id: string; note: string };
505
+ await searchAdapter.index(admin.tenantId, {
506
+ entityType: "refSearchPiiOrder",
507
+ entityId: row.id,
508
+ weight: 1,
509
+ fields: { note: row.note },
510
+ });
511
+ }
512
+
513
+ const res = await piiOrderExec.list({ search: "foo" }, admin, tdbA, {
514
+ searchAdapter,
515
+ referenceSearch: piiReferenceSearch,
516
+ });
517
+ expect(res.rows.map((r) => r["id"])).toEqual([(textMatchOrder as { id: string }).id]);
518
+ });
519
+
520
+ test("a target label match in a foreign tenant's search index never surfaces a row (mandatory)", async () => {
521
+ const [contact] = await seedRows(testDb.db, piiCustomerTable, [
522
+ {
523
+ id: crypto.randomUUID(),
524
+ tenantId: otherTenantAdmin.tenantId,
525
+ ownerId: otherTenantAdmin.id,
526
+ name: "Foreign Contact",
527
+ },
528
+ ]);
529
+ await seedRows(testDb.db, piiOrderTable, [
530
+ {
531
+ id: crypto.randomUUID(),
532
+ tenantId: otherTenantAdmin.tenantId,
533
+ note: "unrelated",
534
+ customerId: (contact as { id: string }).id,
535
+ },
536
+ ]);
537
+
538
+ const searchAdapter = createInMemorySearchAdapter();
539
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
540
+ // Indexed only under the OTHER tenant's search index — admin's own
541
+ // search must never see it, regardless of the FK row's actual tenantId.
542
+ await searchAdapter.index(otherTenantAdmin.tenantId, {
543
+ entityType: "refSearchPiiCustomer",
544
+ entityId: (contact as { id: string }).id,
545
+ weight: 1,
546
+ fields: { name: "Foreign Contact" },
547
+ });
548
+
549
+ const res = await piiOrderExec.list({ search: "foreign" }, admin, tdbA, {
550
+ searchAdapter,
551
+ referenceSearch: piiReferenceSearch,
552
+ });
553
+ expect(res.rows).toHaveLength(0);
554
+ });
555
+
556
+ test("row-level ownership on the target entity intersects the search-index candidates, same as the target's own list would", async () => {
557
+ const [ownRow, otherRow] = await seedRows(testDb.db, piiCustomerTable, [
558
+ {
559
+ id: crypto.randomUUID(),
560
+ tenantId: admin.tenantId,
561
+ ownerId: TestUsers.user.id,
562
+ name: "Shared Label",
563
+ },
564
+ {
565
+ id: crypto.randomUUID(),
566
+ tenantId: admin.tenantId,
567
+ ownerId: admin.id,
568
+ name: "Shared Label",
569
+ },
570
+ ]);
571
+ const [ownOrder, otherOrder] = await seedRows(testDb.db, piiOrderTable, [
572
+ {
573
+ id: crypto.randomUUID(),
574
+ tenantId: admin.tenantId,
575
+ note: "n1",
576
+ customerId: (ownRow as { id: string }).id,
577
+ },
578
+ {
579
+ id: crypto.randomUUID(),
580
+ tenantId: admin.tenantId,
581
+ note: "n2",
582
+ customerId: (otherRow as { id: string }).id,
583
+ },
584
+ ]);
585
+
586
+ const searchAdapter = createInMemorySearchAdapter();
587
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
588
+ for (const row of [ownRow, otherRow]) {
589
+ const r = row as { id: string; name: string };
590
+ await searchAdapter.index(admin.tenantId, {
591
+ entityType: "refSearchPiiCustomer",
592
+ entityId: r.id,
593
+ weight: 1,
594
+ fields: { name: r.name },
595
+ });
596
+ }
597
+
598
+ // Both customers match "shared" in the index — the search index itself
599
+ // has no ownership awareness. Only the order pointing at the customer
600
+ // TestUsers.user (role "User") actually owns via ownerId may surface.
601
+ const res = await piiOrderExec.list({ search: "shared" }, TestUsers.user, tdbA, {
602
+ searchAdapter,
603
+ referenceSearch: piiReferenceSearch,
604
+ });
605
+ expect(res.rows.map((r) => r["id"])).toEqual([(ownOrder as { id: string }).id]);
606
+ expect(res.rows.map((r) => r["id"])).not.toContain((otherOrder as { id: string }).id);
607
+ });
608
+
609
+ test("a role with no read grant on the target entity gets no reference-match hit at all", async () => {
610
+ const [contact] = await seedRows(testDb.db, piiCustomerTable, [
611
+ {
612
+ id: crypto.randomUUID(),
613
+ tenantId: admin.tenantId,
614
+ ownerId: admin.id,
615
+ name: "Driver Blind Spot",
616
+ },
617
+ ]);
618
+ await seedRows(testDb.db, piiOrderTable, [
619
+ {
620
+ id: crypto.randomUUID(),
621
+ tenantId: admin.tenantId,
622
+ note: "unrelated",
623
+ customerId: (contact as { id: string }).id,
624
+ },
625
+ ]);
626
+
627
+ const searchAdapter = createInMemorySearchAdapter();
628
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
629
+ await searchAdapter.index(admin.tenantId, {
630
+ entityType: "refSearchPiiCustomer",
631
+ entityId: (contact as { id: string }).id,
632
+ weight: 1,
633
+ fields: { name: "Driver Blind Spot" },
634
+ });
635
+
636
+ // "Driver" has no rule at all on piiCustomerEntity.access.read →
637
+ // buildOwnershipClause returns "empty" — no reference-match hit, no
638
+ // matter what the search index found.
639
+ const res = await piiOrderExec.list({ search: "driver" }, TestUsers.driver, tdbA, {
640
+ searchAdapter,
641
+ referenceSearch: piiReferenceSearch,
642
+ });
643
+ expect(res.rows).toHaveLength(0);
644
+ });
645
+ });
@@ -4,6 +4,7 @@
4
4
  // single-key ciphertexts (pre-envelope ENCRYPTION_KEY era) readable via
5
5
  // the cipher's legacy fallback until re-encrypted.
6
6
 
7
+ import { collectPiiSubjectFields } from "../crypto";
7
8
  import type { EntityDefinition, TenantId } from "../engine/types";
8
9
  import { createEnvMasterKeyProvider } from "../secrets/env-master-key-provider";
9
10
  import type { EnvelopeCipher } from "../secrets/envelope-cipher";
@@ -19,6 +20,23 @@ export function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySe
19
20
  return names;
20
21
  }
21
22
 
23
+ // A field whose stored value is ciphertext, either always (encrypted) or
24
+ // depending on runtime KMS wiring (PII) — either way it has no plaintext
25
+ // column to compare against directly.
26
+ export function isSensitiveLabelField(entity: EntityDefinition, field: string): boolean {
27
+ return (
28
+ collectEncryptedFieldNames(entity).has(field) || collectPiiSubjectFields(entity).includes(field)
29
+ );
30
+ }
31
+
32
+ // `searchable: true` on a text field means it is indexed plaintext
33
+ // somewhere findable by term — a plain text field's own column, or (for a
34
+ // personal-data field) the derived search index mirroring it.
35
+ export function hasSearchablePlaintext(entity: EntityDefinition, field: string): boolean {
36
+ const def = entity.fields[field];
37
+ return def?.type === "text" && def.searchable === true;
38
+ }
39
+
22
40
  // BYOK hook: thread the row's tenant to the cipher when the row carries one
23
41
  // (system-scope rows may not).
24
42
  function scopeOf(row: Record<string, unknown>): KeyScope | undefined {
@@ -51,11 +51,11 @@ export type Table = TableColumns<any>;
51
51
 
52
52
  // Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
53
53
  // Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
54
- // `op` ist auf {eq,ne,lt,gt,in} normalisiert; "in" mit empty-array ist
55
- // explizit no-match.
54
+ // `op` ist auf {eq,ne,lt,gt,lte,gte,in} normalisiert; "in" mit
55
+ // empty-array ist explizit no-match.
56
56
  export function buildFilterWhere(
57
57
  field: string,
58
- op: "eq" | "ne" | "lt" | "gt" | "in",
58
+ op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in",
59
59
  value: unknown,
60
60
  ): WhereObject | null {
61
61
  switch (op) {
@@ -67,6 +67,10 @@ export function buildFilterWhere(
67
67
  return { [field]: { lt: value } };
68
68
  case "gt":
69
69
  return { [field]: { gt: value } };
70
+ case "lte":
71
+ return { [field]: { lte: value } };
72
+ case "gte":
73
+ return { [field]: { gte: value } };
70
74
  case "in":
71
75
  if (Array.isArray(value) && value.length > 0) {
72
76
  return { [field]: value };
@@ -9,14 +9,19 @@ import {
9
9
  normalizeAccessEntry,
10
10
  shiftParams,
11
11
  } from "../engine/ownership";
12
- import type { EntityDefinition, EntityId } from "../engine/types";
12
+ import type { EntityDefinition, EntityId, SessionUser } from "../engine/types";
13
13
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
14
14
  import { UnprocessableError } from "../errors";
15
15
  import { getStreamVersion } from "../event-store";
16
+ import type { SearchAdapter } from "../search/types";
16
17
  import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta";
17
18
  import { rehydrateCompoundTypes } from "./compound-types";
18
19
  import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
19
- import { collectEncryptedFieldNames } from "./entity-field-encryption";
20
+ import {
21
+ collectEncryptedFieldNames,
22
+ hasSearchablePlaintext,
23
+ isSensitiveLabelField,
24
+ } from "./entity-field-encryption";
20
25
  import type { EventStoreExecutor } from "./event-store-executor";
21
26
  import { buildFilterWhere, type ExecutorContext, type Table } from "./event-store-executor-context";
22
27
  import { buildParentRefClause } from "./parent-ref-clause";
@@ -104,26 +109,27 @@ function keysetBoundarySql(
104
109
 
105
110
  type ListFilter = {
106
111
  readonly field: string;
107
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
112
+ readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
108
113
  readonly value: unknown;
109
114
  };
110
115
 
111
116
  // multiSelect stores its options as a jsonb array — a filter value is one
112
117
  // option, not the whole array, so eq/ne/in must check array containment
113
118
  // (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column (fw#2490).
114
- // lt/gt have no containment analogue; the boot-validator already blocks them
115
- // for screen-declared filters (screen-filter-ops.ts EQUALITY_ONLY), but a
116
- // client-supplied facet filter reaches here unvalidated, so treat it as
117
- // unsatisfiable rather than emitting SQL Postgres would reject.
119
+ // lt/gt/lte/gte have no containment analogue; the boot-validator already
120
+ // blocks them for screen-declared filters (screen-filter-ops.ts
121
+ // EQUALITY_ONLY), but a client-supplied facet filter reaches here
122
+ // unvalidated, so treat it as unsatisfiable rather than emitting SQL
123
+ // Postgres would reject.
118
124
  function applyMultiSelectFilter(
119
125
  colSql: (field: string) => string,
120
126
  whereSql: string[],
121
127
  params: unknown[],
122
128
  f: ListFilter,
123
129
  ): void {
124
- if (f.op === "lt" || f.op === "gt") {
130
+ if (f.op === "lt" || f.op === "gt" || f.op === "lte" || f.op === "gte") {
125
131
  whereSql.push("FALSE");
126
- // skip: lt/gt is unsatisfiable on a multiSelect column
132
+ // skip: lt/gt/lte/gte is unsatisfiable on a multiSelect column
127
133
  return;
128
134
  }
129
135
  if (f.op === "in") {
@@ -290,11 +296,86 @@ function collectReferenceSearchDescriptors(
290
296
  // unscoped SQL, a match in a foreign tenant must never surface a row here
291
297
  // (fw#2660's hard constraint). Mirrors the main table's own tenant filter
292
298
  // (see the `table["tenantId"]` check further down in list()).
299
+ async function resolveReferenceMatchesViaIlike(
300
+ descriptor: ReferenceSearchDescriptor,
301
+ searchTerm: string,
302
+ targetTable: Table,
303
+ targetTableName: string,
304
+ db: TenantDb,
305
+ ): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
306
+ const labelCol = physicalColumnName(targetTable, descriptor.labelField);
307
+ const subParams: unknown[] = [`%${escapeLikePattern(searchTerm)}%`];
308
+ let tenantClause = "";
309
+ if (targetTable["tenantId"] !== undefined && db.mode === "tenant") {
310
+ subParams.push(db.tenantId, SYSTEM_TENANT_ID);
311
+ tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
312
+ }
313
+ // ::text cast covers a non-text labelField (e.g. a number/select column
314
+ // used as label) — Postgres has no ILIKE for those types otherwise.
315
+ const sql =
316
+ `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
317
+ `LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
318
+ const rows = await executeRawQueryRead<{ id: string }>(tenantDbRunner(db), sql, subParams);
319
+ if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
320
+ // skip: no match, or over the cap — drop the clause rather than
321
+ // truncate to an arbitrary slice of matching targets
322
+ return undefined;
323
+ }
324
+ return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
325
+ }
326
+
327
+ // Same lookup, but the target's index is tenant-wide, not per-viewer, so
328
+ // candidate ids get re-checked against the target entity's own read access.
329
+ async function resolveReferenceMatchesViaSearchIndex(
330
+ descriptor: ReferenceSearchDescriptor,
331
+ searchTerm: string,
332
+ targetEntity: EntityDefinition,
333
+ targetTable: Table,
334
+ targetTableName: string,
335
+ user: SessionUser,
336
+ db: TenantDb,
337
+ searchAdapter: SearchAdapter,
338
+ ): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
339
+ const searchTenantId = targetEntity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
340
+ const results = await searchAdapter.search(searchTenantId, searchTerm, {
341
+ filterType: descriptor.targetEntityName,
342
+ limit: MAX_REFERENCE_SEARCH_IDS + 1,
343
+ });
344
+ if (results.length === 0 || results.length > MAX_REFERENCE_SEARCH_IDS) {
345
+ // skip: no match, or over the cap — same drop-the-clause policy as the ILIKE path
346
+ return undefined;
347
+ }
348
+
349
+ const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
350
+ if (ownership.kind === "empty") return undefined;
351
+
352
+ const candidateIds = results.map((r) => String(r.entityId));
353
+ if (ownership.kind === "pass") {
354
+ return { ownColumn: descriptor.ownColumn, ids: candidateIds };
355
+ }
356
+
357
+ const params: unknown[] = [...candidateIds];
358
+ const idPlaceholders = candidateIds.map((_, i) => `$${i + 1}`);
359
+ const shifted = shiftParams(
360
+ { sqlText: ownership.sqlText, params: ownership.params },
361
+ params.length,
362
+ );
363
+ const sql =
364
+ `SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")}) ` +
365
+ `AND ${shifted.sqlText}`;
366
+ for (const p of shifted.params) params.push(p);
367
+ const rows = await executeRawQueryRead<{ id: string }>(tenantDbRunner(db), sql, params);
368
+ if (rows.length === 0) return undefined;
369
+ return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
370
+ }
371
+
293
372
  async function resolveReferenceMatches(
294
373
  descriptor: ReferenceSearchDescriptor,
295
374
  searchTerm: string,
296
375
  resolveEntity: (entityName: string) => EntityDefinition | undefined,
297
376
  db: TenantDb,
377
+ user: SessionUser,
378
+ searchAdapter: SearchAdapter,
298
379
  ): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
299
380
  const targetEntity = resolveEntity(descriptor.targetEntityName);
300
381
  if (targetEntity === undefined) {
@@ -308,38 +389,36 @@ async function resolveReferenceMatches(
308
389
  // `name` is a declared field on the tenant entity, not an id/row-meta column
309
390
  return undefined;
310
391
  }
311
- if (
312
- collectEncryptedFieldNames(targetEntity).has(descriptor.labelField) ||
313
- collectPiiSubjectFields(targetEntity).includes(descriptor.labelField)
314
- ) {
315
- // skip: encrypted/PII labelField — ILIKE can never match ciphertext,
316
- // emitting the query would just be a silent, permanent non-match
317
- return undefined;
318
- }
319
392
 
320
393
  const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
321
394
  const targetTableName = String(
322
395
  (targetTable as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL],
323
396
  );
324
- const labelCol = physicalColumnName(targetTable, descriptor.labelField);
325
- const subParams: unknown[] = [`%${escapeLikePattern(searchTerm)}%`];
326
- let tenantClause = "";
327
- if (targetTable["tenantId"] !== undefined && db.mode === "tenant") {
328
- subParams.push(db.tenantId, SYSTEM_TENANT_ID);
329
- tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
397
+
398
+ if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
399
+ return resolveReferenceMatchesViaIlike(
400
+ descriptor,
401
+ searchTerm,
402
+ targetTable,
403
+ targetTableName,
404
+ db,
405
+ );
330
406
  }
331
- // ::text cast covers a non-text labelField (e.g. a number/select column
332
- // used as label) — Postgres has no ILIKE for those types otherwise.
333
- const sql =
334
- `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
335
- `LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
336
- const rows = await executeRawQueryRead<{ id: string }>(tenantDbRunner(db), sql, subParams);
337
- if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
338
- // skip: no match, or over the cap — drop the clause rather than
339
- // truncate to an arbitrary slice of matching targets
407
+ if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
408
+ // skip: no plaintext anywhere to match — boot rejects this normally,
409
+ // this is defense-in-depth for a target entity resolved only at runtime.
340
410
  return undefined;
341
411
  }
342
- return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
412
+ return resolveReferenceMatchesViaSearchIndex(
413
+ descriptor,
414
+ searchTerm,
415
+ targetEntity,
416
+ targetTable,
417
+ targetTableName,
418
+ user,
419
+ db,
420
+ searchAdapter,
421
+ );
343
422
  }
344
423
 
345
424
  // Projected alongside the row when a reference sort is active, so the keyset
@@ -517,7 +596,14 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
517
596
  ? (
518
597
  await Promise.all(
519
598
  descriptors.map((d) =>
520
- resolveReferenceMatches(d, searchTerm, referenceSearch.resolveEntity, db),
599
+ resolveReferenceMatches(
600
+ d,
601
+ searchTerm,
602
+ referenceSearch.resolveEntity,
603
+ db,
604
+ user,
605
+ effectiveSearchAdapter,
606
+ ),
521
607
  ),
522
608
  )
523
609
  ).filter(
@@ -2306,7 +2306,7 @@ describe("boot-validator", () => {
2306
2306
  function makeFeature(
2307
2307
  filter: {
2308
2308
  readonly field: string;
2309
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
2309
+ readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
2310
2310
  readonly value: unknown;
2311
2311
  },
2312
2312
  fields: Record<string, unknown> = {
@@ -2365,6 +2365,25 @@ describe("boot-validator", () => {
2365
2365
  ).not.toThrow();
2366
2366
  });
2367
2367
 
2368
+ test("filter.op=lte/gte auf number-Feld → kein Throw (vergleichbar)", () => {
2369
+ const fields = {
2370
+ name: { type: "text", filterable: true },
2371
+ rank: { type: "number", filterable: true },
2372
+ };
2373
+ expect(() =>
2374
+ validateBoot([makeFeature({ field: "rank", op: "lte", value: 5 }, fields)]),
2375
+ ).not.toThrow();
2376
+ expect(() =>
2377
+ validateBoot([makeFeature({ field: "rank", op: "gte", value: 5 }, fields)]),
2378
+ ).not.toThrow();
2379
+ });
2380
+
2381
+ test("filter.op=lte auf text-Feld → Throw (op-vs-Type-Compat)", () => {
2382
+ expect(() => validateBoot([makeFeature({ field: "status", op: "lte", value: "x" })])).toThrow(
2383
+ /filter\.op "lte" is not allowed on field "status" \(type "text"\)/,
2384
+ );
2385
+ });
2386
+
2368
2387
  test('filter.op="in" mit non-array value → Throw', () => {
2369
2388
  expect(() =>
2370
2389
  validateBoot([makeFeature({ field: "status", op: "in", value: "active" })]),
@@ -754,6 +754,92 @@ describe("createRegistry", () => {
754
754
  expect(() => createRegistry([feature])).toThrow(/multiple/);
755
755
  });
756
756
 
757
+ test("throws at boot when a searchable reference targets an encrypted labelField", () => {
758
+ const feature = defineFeature("crm", (r) => {
759
+ r.entity(
760
+ "customer",
761
+ createEntity({
762
+ table: "Customers",
763
+ fields: {
764
+ name: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
765
+ },
766
+ }),
767
+ );
768
+ r.entity(
769
+ "order",
770
+ createEntity({
771
+ table: "Orders",
772
+ fields: {
773
+ customerId: {
774
+ type: "reference",
775
+ entity: "customer",
776
+ labelField: "name",
777
+ searchable: true,
778
+ },
779
+ },
780
+ }),
781
+ );
782
+ });
783
+
784
+ expect(() => createRegistry([feature])).toThrow(/encrypted\/PII/);
785
+ });
786
+
787
+ test('throws at boot when a searchable reference targets a PII labelField without find: "fuzzy" (no plaintext index)', () => {
788
+ const feature = defineFeature("crm", (r) => {
789
+ r.entity(
790
+ "contact",
791
+ createEntity({
792
+ table: "Contacts",
793
+ fields: { displayName: createTextField({ personal: "self", find: "exact" }) },
794
+ }),
795
+ );
796
+ r.entity(
797
+ "order",
798
+ createEntity({
799
+ table: "Orders",
800
+ fields: {
801
+ contactId: {
802
+ type: "reference",
803
+ entity: "contact",
804
+ labelField: "displayName",
805
+ searchable: true,
806
+ },
807
+ },
808
+ }),
809
+ );
810
+ });
811
+
812
+ expect(() => createRegistry([feature])).toThrow(/encrypted\/PII/);
813
+ });
814
+
815
+ test('does not throw when a searchable reference targets a PII labelField with find: "fuzzy" (derived search index)', () => {
816
+ const feature = defineFeature("crm", (r) => {
817
+ r.entity(
818
+ "contact",
819
+ createEntity({
820
+ table: "Contacts",
821
+ fields: { displayName: createTextField({ personal: "self", find: "fuzzy" }) },
822
+ }),
823
+ );
824
+ r.entity(
825
+ "order",
826
+ createEntity({
827
+ table: "Orders",
828
+ fields: {
829
+ contactId: {
830
+ type: "reference",
831
+ entity: "contact",
832
+ labelField: "displayName",
833
+ searchable: true,
834
+ },
835
+ },
836
+ }),
837
+ );
838
+ });
839
+
840
+ expect(() => createRegistry([feature])).not.toThrow();
841
+ });
842
+
757
843
  test("returns sortable reference fields for entity, without leaking them into getSortableFields (fw#2741)", () => {
758
844
  const feature = defineFeature("crm", (r) => {
759
845
  r.entity(
@@ -3,7 +3,7 @@ import { createDecimalField, createMoneyField, createNumberField } from "../fact
3
3
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
4
4
  import type { ScreenFilterOp } from "../types";
5
5
 
6
- const COMPARABLE: ScreenFilterOp[] = ["eq", "ne", "lt", "gt", "in"];
6
+ const COMPARABLE: ScreenFilterOp[] = ["eq", "ne", "lt", "gt", "lte", "gte", "in"];
7
7
 
8
8
  describe("getAllowedFilterOps — decimal is comparable (#343/1)", () => {
9
9
  test("decimal yields the full comparable op-set, not the empty default", () => {
@@ -96,7 +96,7 @@ function validateProjectionListFilterSchemaAcceptance(
96
96
  if (screen.filter === undefined || schemaAccepts(schema, "filter")) return;
97
97
  throw new Error(
98
98
  `${prefix}: declares filter but query "${screen.query}" has no "filter" parameter in its Zod ` +
99
- `schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
99
+ `schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
100
100
  `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
101
101
  );
102
102
  }
@@ -203,7 +203,7 @@ function validateRelatedListParentFilter(
203
203
  if (!schemaAccepts(schema, "filter")) {
204
204
  throw new Error(
205
205
  `${prefix}: declares parentFilter but query "${section.query}" has no "filter" parameter in its ` +
206
- `Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
206
+ `Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
207
207
  `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
208
208
  );
209
209
  }
@@ -102,7 +102,7 @@ type ListPayload = {
102
102
  // den gleichen Query-Handler.
103
103
  filter?: {
104
104
  readonly field: string;
105
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
105
+ readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
106
106
  readonly value: unknown;
107
107
  };
108
108
  };
@@ -157,7 +157,7 @@ export const entityListSchema = z.object({
157
157
  filter: z
158
158
  .object({
159
159
  field: z.string(),
160
- op: z.enum(["eq", "ne", "lt", "gt", "in"]),
160
+ op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
161
161
  // Value ist `unknown` zur Compile-Zeit; Server-Side prüft beim
162
162
  // Build der WHERE-Clause ob der Type zum Field passt. z.unknown()
163
163
  // lässt alles durch; Type-Check kommt im executor.list.
@@ -170,7 +170,7 @@ export const entityListSchema = z.object({
170
170
  .array(
171
171
  z.object({
172
172
  field: z.string(),
173
- op: z.enum(["eq", "ne", "lt", "gt", "in"]),
173
+ op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
174
174
  value: z.unknown(),
175
175
  }),
176
176
  )
@@ -1,4 +1,5 @@
1
1
  import { configureEventPiiCatalog } from "../crypto/event-pii";
2
+ import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption";
2
3
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
3
4
  import { resolveName } from "./handler-helpers";
4
5
  import type {
@@ -16,6 +17,7 @@ import {
16
17
  softDeleteGraceDaysConfig,
17
18
  } from "./soft-delete-cleanup";
18
19
  import type {
20
+ EntityDefinition,
19
21
  EventPiiFields,
20
22
  EventUpcastFn,
21
23
  FeatureDefinition,
@@ -206,6 +208,7 @@ function buildSearchableReferenceField(
206
208
  entityName: string,
207
209
  fieldName: string,
208
210
  field: ReferenceFieldDef,
211
+ entityMap: ReadonlyMap<string, EntityDefinition>,
209
212
  ): SearchableReferenceField {
210
213
  if (field.labelField === undefined || field.labelField === "id") {
211
214
  throw new Error(
@@ -220,9 +223,26 @@ function buildSearchableReferenceField(
220
223
  `(array) reference fields — remove "multiple" or "searchable".`,
221
224
  );
222
225
  }
226
+ const targetEntityName = parseReferenceTargetEntityName(field.entity);
227
+ const targetEntity = entityMap.get(targetEntityName);
228
+ if (targetEntity !== undefined) {
229
+ if (
230
+ isSensitiveLabelField(targetEntity, field.labelField) &&
231
+ !hasSearchablePlaintext(targetEntity, field.labelField)
232
+ ) {
233
+ throw new Error(
234
+ `[Entity ${entityName}] field "${fieldName}": searchable reference targets ` +
235
+ `"${targetEntityName}.${field.labelField}", which is encrypted/PII and not itself ` +
236
+ `searchable — there is no plaintext anywhere to match a search term against. Mark ` +
237
+ `"${targetEntityName}.${field.labelField}" searchable: true (add find: "fuzzy" if it ` +
238
+ `also carries a personal annotation, so it lands in the derived search index) or ` +
239
+ `remove searchable from "${fieldName}".`,
240
+ );
241
+ }
242
+ }
223
243
  return {
224
244
  fieldName,
225
- targetEntityName: parseReferenceTargetEntityName(field.entity),
245
+ targetEntityName,
226
246
  labelField: field.labelField,
227
247
  };
228
248
  }
@@ -273,7 +293,9 @@ export function buildSearchableSortableCaches(state: RegistryState): void {
273
293
  }
274
294
  }
275
295
  if (field.type === "reference" && field.searchable === true) {
276
- searchableReferences.push(buildSearchableReferenceField(name, fieldName, field));
296
+ searchableReferences.push(
297
+ buildSearchableReferenceField(name, fieldName, field, state.entityMap),
298
+ );
277
299
  }
278
300
  if (field.type === "reference" && field.sortable === true) {
279
301
  sortableReferences.push(buildSortableReferenceField(name, fieldName, field));
@@ -6,7 +6,7 @@
6
6
  // compare nutzt der Author selten bewusst); Author kann den
7
7
  // Filter dann einfach nicht setzen.
8
8
  // - boolean: eq, ne (in/lt/gt sinnlos für 2-Werte-Type).
9
- // - number/money/decimal/date/timestamp/locatedTimestamp: alle 5
9
+ // - number/money/decimal/date/timestamp/locatedTimestamp: alle 7
10
10
  // Ops — die Felder sind natürlich vergleichbar.
11
11
  //
12
12
  // Boot-Validator nutzt diese Map um Author-Fehler früh zu fangen
@@ -17,7 +17,15 @@
17
17
  import type { FieldDefinition, ScreenFilterOp } from "./types";
18
18
 
19
19
  const EQUALITY_ONLY = ["eq", "ne", "in"] as const satisfies readonly ScreenFilterOp[];
20
- const COMPARABLE = ["eq", "ne", "lt", "gt", "in"] as const satisfies readonly ScreenFilterOp[];
20
+ const COMPARABLE = [
21
+ "eq",
22
+ "ne",
23
+ "lt",
24
+ "gt",
25
+ "lte",
26
+ "gte",
27
+ "in",
28
+ ] as const satisfies readonly ScreenFilterOp[];
21
29
  const BOOL_OPS = ["eq", "ne"] as const satisfies readonly ScreenFilterOp[];
22
30
 
23
31
  export function getAllowedFilterOps(field: FieldDefinition): readonly ScreenFilterOp[] {