@cosmicdrift/kumiko-framework 0.249.0 → 0.251.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 (57) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/full-stack.integration.test.ts +3 -1
  3. package/src/crypto/__tests__/event-pii.test.ts +25 -3
  4. package/src/crypto/__tests__/kms-wiring.test.ts +15 -2
  5. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +18 -0
  6. package/src/crypto/event-pii.ts +7 -1
  7. package/src/crypto/index.ts +1 -0
  8. package/src/crypto/kms-wiring.ts +23 -10
  9. package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +258 -0
  10. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +33 -0
  11. package/src/db/entity-table-meta.ts +9 -2
  12. package/src/db/event-store-executor-read.ts +124 -7
  13. package/src/db/table-builder.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +132 -2
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +2 -0
  16. package/src/engine/__tests__/extend-entity-projection.test.ts +15 -5
  17. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  18. package/src/engine/__tests__/screen.test.ts +184 -0
  19. package/src/engine/boot-validator/index.ts +5 -1
  20. package/src/engine/boot-validator/projection-list-screens.ts +42 -0
  21. package/src/engine/boot-validator/screens.ts +45 -29
  22. package/src/engine/entity-handlers.ts +4 -0
  23. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +1 -0
  24. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -1
  25. package/src/engine/feature-ast/__tests__/parse.test.ts +27 -1
  26. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  27. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  28. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +56 -3
  29. package/src/engine/feature-ast/extractors/events.ts +24 -0
  30. package/src/engine/feature-ast/patcher.ts +6 -1
  31. package/src/engine/feature-ast/patterns.ts +6 -0
  32. package/src/engine/feature-ast/render.ts +3 -4
  33. package/src/engine/feature-config-events-jobs.ts +33 -9
  34. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  35. package/src/engine/registry-facade.ts +10 -1
  36. package/src/engine/registry-state.ts +8 -0
  37. package/src/engine/registry-validate.ts +54 -3
  38. package/src/engine/types/index.ts +1 -0
  39. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +4 -1
  40. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +10 -3
  41. package/src/event-store/__tests__/upcaster.integration.test.ts +8 -1
  42. package/src/jobs/__tests__/job-write-as.integration.test.ts +206 -0
  43. package/src/jobs/job-runner.ts +12 -4
  44. package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
  45. package/src/pipeline/__tests__/causation-chain.integration.test.ts +3 -1
  46. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +3 -1
  47. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +6 -2
  48. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +6 -2
  49. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +3 -1
  50. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -0
  51. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +5 -3
  52. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +3 -0
  53. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +2 -0
  54. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +3 -1
  55. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +260 -0
  56. package/src/pipeline/__tests__/try-append-event.integration.test.ts +3 -1
  57. package/src/pipeline/system-hooks.ts +94 -25
@@ -0,0 +1,260 @@
1
+ // #2765 — named domain events (r.defineEvent, not created/updated/restored/
2
+ // deleted/forgotten) on an entity with searchable fields must still reach
3
+ // the search index. The event payload only carries the named event's own
4
+ // field slice, so the consumer reads the live projection row instead.
5
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
6
+ import { z } from "zod";
7
+ import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
8
+ import { createEntity, createTextField, defineFeature } from "../../engine";
9
+ import {
10
+ resetEventStore,
11
+ setupTestStack,
12
+ type TestStack,
13
+ TestUsers,
14
+ unsafeCreateEntityTable,
15
+ } from "../../stack";
16
+ import { SEARCH_CONSUMER_NAME } from "../system-hooks";
17
+
18
+ const noteEntity = createEntity({
19
+ table: "read_named_search_notes",
20
+ fields: {
21
+ label: createTextField({ required: true, maxLength: 100, searchable: true }),
22
+ },
23
+ softDelete: true,
24
+ });
25
+ const noteTable = buildEntityTable("note", noteEntity);
26
+
27
+ // No DDL table for this one — proves the searchable-fields gate runs
28
+ // before any query, not after a failed one.
29
+ const ghostEntity = createEntity({
30
+ table: "read_named_search_ghosts",
31
+ fields: {
32
+ note: createTextField({ required: true, maxLength: 50 }),
33
+ },
34
+ });
35
+
36
+ const ROW_LABEL = "row-projection-value";
37
+
38
+ const namedSearchFeature = defineFeature("named-search", (r) => {
39
+ r.entity("note", noteEntity);
40
+ r.entity("ghost", ghostEntity);
41
+
42
+ const relabeled = r.defineEvent("relabeled", z.object({ label: z.string() }), {
43
+ piiFields: "none",
44
+ });
45
+ const poked = r.defineEvent("poked", z.object({}), { piiFields: "none" });
46
+
47
+ r.writeHandler(
48
+ "note:relabel",
49
+ z.object({ id: z.uuid(), label: z.string() }),
50
+ async (event, ctx) => {
51
+ await ctx.unsafeAppendEvent({
52
+ aggregateId: event.payload.id,
53
+ aggregateType: "note",
54
+ type: relabeled.name,
55
+ payload: { label: event.payload.label },
56
+ });
57
+ // Bypasses the executor on purpose (raw SQL, not updateMany — the
58
+ // executor-built table is write-locked to the executor) so the row
59
+ // ends up with a value distinct from the event payload above. Proves
60
+ // the search consumer reads the row, not the payload.
61
+ await asRawClient(ctx.db).unsafe(
62
+ `UPDATE read_named_search_notes SET label = $1 WHERE id = $2`,
63
+ [ROW_LABEL, event.payload.id],
64
+ );
65
+ return { isSuccess: true as const, data: { id: event.payload.id } };
66
+ },
67
+ { access: { roles: ["Admin"] } },
68
+ );
69
+
70
+ r.writeHandler(
71
+ "ghost:poke",
72
+ z.object({ id: z.uuid() }),
73
+ async (event, ctx) => {
74
+ await ctx.unsafeAppendEvent({
75
+ aggregateId: event.payload.id,
76
+ aggregateType: "ghost",
77
+ type: poked.name,
78
+ payload: {},
79
+ });
80
+ return { isSuccess: true as const, data: { id: event.payload.id } };
81
+ },
82
+ { access: { roles: ["Admin"] } },
83
+ );
84
+ });
85
+
86
+ let stack: TestStack;
87
+ const admin = TestUsers.admin;
88
+
89
+ function noteExecutor() {
90
+ return createEventStoreExecutor(noteTable, noteEntity, {
91
+ entityName: "note",
92
+ searchAdapter: stack.search,
93
+ });
94
+ }
95
+
96
+ function tenantDb() {
97
+ return createTenantDb(stack.db, admin.tenantId, "system");
98
+ }
99
+
100
+ beforeAll(async () => {
101
+ stack = await setupTestStack({ features: [namedSearchFeature] });
102
+ await unsafeCreateEntityTable(stack.db, noteEntity, "note");
103
+ });
104
+
105
+ afterAll(async () => {
106
+ await stack.cleanup();
107
+ });
108
+
109
+ afterEach(async () => {
110
+ await resetEventStore(stack, ["read_named_search_notes"]);
111
+ });
112
+
113
+ describe("search consumer: named domain events (#2765)", () => {
114
+ test("named event on a searchable entity indexes from the projection row, not the payload", async () => {
115
+ const created = await noteExecutor().create({ label: "original-label" }, admin, tenantDb());
116
+ if (!created.isSuccess) throw new Error("create failed");
117
+ const id = String(created.data.id);
118
+ await stack.eventDispatcher?.runOnce();
119
+
120
+ const beforeHits = await stack.search.search(admin.tenantId, "original-label", {
121
+ filterType: "note",
122
+ });
123
+ expect(beforeHits.some((h) => String(h.entityId) === id)).toBe(true);
124
+
125
+ await stack.http.writeOk(
126
+ "named-search:write:note:relabel",
127
+ { id, label: "payload-label-should-not-be-indexed" },
128
+ admin,
129
+ );
130
+ await stack.eventDispatcher?.runOnce();
131
+
132
+ const payloadHits = await stack.search.search(
133
+ admin.tenantId,
134
+ "payload-label-should-not-be-indexed",
135
+ { filterType: "note" },
136
+ );
137
+ expect(payloadHits).toHaveLength(0);
138
+
139
+ const rowHits = await stack.search.search(admin.tenantId, ROW_LABEL, { filterType: "note" });
140
+ expect(rowHits.some((h) => String(h.entityId) === id)).toBe(true);
141
+ });
142
+
143
+ test("named event on an entity without searchable fields triggers no query and no index", async () => {
144
+ const id = crypto.randomUUID();
145
+ // Ghost has no DDL table — if the consumer queried it anyway, runOnce()
146
+ // would reject instead of completing (relation does not exist).
147
+ await stack.http.writeOk("named-search:write:ghost:poke", { id }, admin);
148
+ const result = await stack.eventDispatcher?.runOnce();
149
+ expect(result?.byConsumer[SEARCH_CONSUMER_NAME]?.failed).toBe(0);
150
+
151
+ const hits = await stack.search.search(admin.tenantId, "poked", { filterType: "ghost" });
152
+ expect(hits).toHaveLength(0);
153
+ });
154
+
155
+ test("named event with no live projection row removes the index entry", async () => {
156
+ const created = await noteExecutor().create({ label: "vanishing-label" }, admin, tenantDb());
157
+ if (!created.isSuccess) throw new Error("create failed");
158
+ const id = String(created.data.id);
159
+ await stack.eventDispatcher?.runOnce();
160
+ expect(
161
+ (await stack.search.search(admin.tenantId, "vanishing-label", { filterType: "note" })).some(
162
+ (h) => String(h.entityId) === id,
163
+ ),
164
+ ).toBe(true);
165
+
166
+ // Hard-delete the row directly, bypassing executor.forget — simulates a
167
+ // projection row that's simply gone, distinct from the deleted/forgotten
168
+ // verb path covered by the regression test below.
169
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_named_search_notes WHERE id = $1`, [id]);
170
+
171
+ await stack.http.writeOk("named-search:write:note:relabel", { id, label: "irrelevant" }, admin);
172
+ await stack.eventDispatcher?.runOnce();
173
+
174
+ const after = await stack.search.search(admin.tenantId, "vanishing-label", {
175
+ filterType: "note",
176
+ });
177
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
178
+ });
179
+
180
+ test("named event on a soft-deleted entity does not resurrect the index entry", async () => {
181
+ const created = await noteExecutor().create({ label: "soft-deleted-label" }, admin, tenantDb());
182
+ if (!created.isSuccess) throw new Error("create failed");
183
+ const id = created.data.id;
184
+ await stack.eventDispatcher?.runOnce();
185
+ expect(
186
+ (
187
+ await stack.search.search(admin.tenantId, "soft-deleted-label", { filterType: "note" })
188
+ ).some((h) => String(h.entityId) === String(id)),
189
+ ).toBe(true);
190
+
191
+ const deleted = await noteExecutor().delete({ id }, admin, tenantDb());
192
+ if (!deleted.isSuccess) throw new Error("delete failed");
193
+ await stack.eventDispatcher?.runOnce();
194
+
195
+ // The row still physically exists (isDeleted: true) — a plain fetchOne
196
+ // without the soft-delete filter would find it and re-index it.
197
+ await stack.http.writeOk(
198
+ "named-search:write:note:relabel",
199
+ { id: String(id), label: "irrelevant" },
200
+ admin,
201
+ );
202
+ await stack.eventDispatcher?.runOnce();
203
+
204
+ const after = await stack.search.search(admin.tenantId, ROW_LABEL, { filterType: "note" });
205
+ expect(after.some((h) => String(h.entityId) === String(id))).toBe(false);
206
+ });
207
+
208
+ test("created/updated/restored/deleted/forgotten still index/remove as before (regression)", async () => {
209
+ const created = await noteExecutor().create({ label: "crud-created" }, admin, tenantDb());
210
+ if (!created.isSuccess) throw new Error("create failed");
211
+ const id = created.data.id;
212
+ await stack.eventDispatcher?.runOnce();
213
+ expect(
214
+ (await stack.search.search(admin.tenantId, "crud-created", { filterType: "note" })).some(
215
+ (h) => String(h.entityId) === String(id),
216
+ ),
217
+ ).toBe(true);
218
+
219
+ const updated = await noteExecutor().update(
220
+ { id, changes: { label: "crud-updated" } },
221
+ admin,
222
+ tenantDb(),
223
+ { skipOptimisticLock: true },
224
+ );
225
+ if (!updated.isSuccess) throw new Error("update failed");
226
+ await stack.eventDispatcher?.runOnce();
227
+ expect(
228
+ (await stack.search.search(admin.tenantId, "crud-updated", { filterType: "note" })).some(
229
+ (h) => String(h.entityId) === String(id),
230
+ ),
231
+ ).toBe(true);
232
+
233
+ const deleted = await noteExecutor().delete({ id }, admin, tenantDb());
234
+ if (!deleted.isSuccess) throw new Error("delete failed");
235
+ await stack.eventDispatcher?.runOnce();
236
+ expect(
237
+ (await stack.search.search(admin.tenantId, "crud-updated", { filterType: "note" })).some(
238
+ (h) => String(h.entityId) === String(id),
239
+ ),
240
+ ).toBe(false);
241
+
242
+ const restored = await noteExecutor().restore({ id }, admin, tenantDb());
243
+ if (!restored.isSuccess) throw new Error("restore failed");
244
+ await stack.eventDispatcher?.runOnce();
245
+ expect(
246
+ (await stack.search.search(admin.tenantId, "crud-updated", { filterType: "note" })).some(
247
+ (h) => String(h.entityId) === String(id),
248
+ ),
249
+ ).toBe(true);
250
+
251
+ const forgotten = await noteExecutor().forget({ id }, admin, tenantDb());
252
+ if (!forgotten.isSuccess) throw new Error("forget failed");
253
+ await stack.eventDispatcher?.runOnce();
254
+ expect(
255
+ (await stack.search.search(admin.tenantId, "crud-updated", { filterType: "note" })).some(
256
+ (h) => String(h.entityId) === String(id),
257
+ ),
258
+ ).toBe(false);
259
+ });
260
+ });
@@ -50,7 +50,9 @@ const admin = TestUsers.admin;
50
50
 
51
51
  const tryAppendFeature = defineFeature("tae", (r) => {
52
52
  r.entity("taeMarker", markerEntity);
53
- const appended = r.defineEvent("appended", z.object({ note: z.string() }));
53
+ const appended = r.defineEvent("appended", z.object({ note: z.string() }), {
54
+ piiFields: "none",
55
+ });
54
56
 
55
57
  r.writeHandler(
56
58
  "doc:try-append",
@@ -6,9 +6,18 @@ import {
6
6
  isPiiCiphertext,
7
7
  PII_ERASED_SENTINEL,
8
8
  } from "../crypto";
9
- import type { DbRow } from "../db/connection";
9
+ import { createTenantDb, entityTableFromRegistry, rehydrateCompoundTypes } from "../db";
10
+ import type { DbConnection, DbRow } from "../db/connection";
10
11
  import { tenantChannel } from "../engine/constants";
11
- import type { EntityId, JobRunnerRef, Registry, SessionUser } from "../engine/types";
12
+ import type {
13
+ AppContext,
14
+ EntityDefinition,
15
+ EntityId,
16
+ JobRunnerRef,
17
+ Registry,
18
+ SessionUser,
19
+ TenantId,
20
+ } from "../engine/types";
12
21
  import type { SearchAdapter, SearchDocument } from "../search/types";
13
22
  import type { EventConsumer } from "./event-dispatcher";
14
23
 
@@ -21,25 +30,29 @@ import type { EventConsumer } from "./event-dispatcher";
21
30
  //
22
31
  // Event → Search-Op Mapping:
23
32
  //
24
- // <entity>.created → index(tenantId, doc)
25
- // <entity>.updated → index(tenantId, doc) // re-index mit neuem state
26
- // <entity>.restored → index(tenantId, doc) // wiederbeleben
33
+ // <entity>.created → index(tenantId, doc) // state = event.payload
34
+ // <entity>.updated → index(tenantId, doc) // state = { ...previous, ...changes }
35
+ // <entity>.restored → index(tenantId, doc) // state = event.payload.previous
27
36
  // <entity>.deleted → remove(tenantId, type, id)
37
+ // <entity>.forgotten → remove(tenantId, type, id)
38
+ // <entity>.<named> → index or remove, state read from the live
39
+ // projection row (#2765) — a named domain event
40
+ // carries only a payload slice, not the full
41
+ // entity, so there's no fixed shape to reconstruct
42
+ // state from the way created/updated/restored do.
43
+ // Only queried when the entity declares searchable
44
+ // fields/extensions; no row (hard/soft-deleted) →
45
+ // remove, same as the deleted branch.
28
46
  //
29
- // Der Document-State wird aus dem Event rekonstruiert (kein SaveContext
30
- // mehr available). Regel:
47
+ // Sensitive fields never reach this consumer for created/updated/restored —
48
+ // event-store-executor.ts strips them before they land in the event log.
49
+ // The projection-row path (named events) reads them from the live table
50
+ // instead, so the same sensitive+searchable boot-guard (entity-handler.ts)
51
+ // keeps them out there too.
31
52
  //
32
- // created: state = event.payload // ganze entity ist im payload
33
- // updated: state = { ...previous, ...changes } // rekonstruiert neuen state
34
- // restored: state = event.payload.previous // restored field-set
35
- //
36
- // Sensitive fields sind aus dem event log bereits gestrippt (event-store-
37
- // executor.ts), also kriegt der Search-Index sie ebenfalls nicht — das ist
38
- // die gleiche Garantie wie vorher beim postSave-hook.
39
- //
40
- // Batch-Variante gibt's aktuell nicht mehr — jeder Event triggert einen
41
- // eigenen index()-call. Wenn Performance nach Scale-Messung das erfordert,
42
- // kann der event-dispatcher später eine Batch-Handler-Variante bekommen.
53
+ // No batch variant right now — every event triggers its own index() call.
54
+ // If a scale measurement later calls for it, the event-dispatcher could
55
+ // grow a batch-handler variant.
43
56
  export const SEARCH_CONSUMER_NAME = "system:consumer:search";
44
57
 
45
58
  export function createSearchEventConsumer(
@@ -55,7 +68,7 @@ export function createSearchEventConsumer(
55
68
  // killing the consumer near-instantly). Upgrade path: time-based
56
69
  // dead-lettering in event-dispatcher-delivery.ts if 2min isn't enough.
57
70
  errorPolicy: { maxAttempts: 1200 },
58
- handler: async (event) => {
71
+ handler: async (event, ctx) => {
59
72
  const entityName = event.aggregateType;
60
73
  const verb = event.type.split(".").pop();
61
74
  const tenantId = event.tenantId;
@@ -67,14 +80,41 @@ export function createSearchEventConsumer(
67
80
  return;
68
81
  }
69
82
 
70
- if (verb !== "created" && verb !== "updated" && verb !== "restored") {
71
- // skip: other event types (custom domain events, future verbs) don't
72
- // carry a search-indexable payload shape. If a future feature needs
73
- // them indexed, it registers its own multiStreamProjection.
74
- return;
83
+ let state: Record<string, unknown>;
84
+ if (verb === "created" || verb === "updated" || verb === "restored") {
85
+ state = reconstructStateForSearch(event.payload, verb);
86
+ } else {
87
+ // #2765 — named domain event: no fixed payload shape to reconstruct
88
+ // state from. Only worth a projection read when the entity actually
89
+ // declared searchable fields/extensions — same check
90
+ // buildSearchDocument makes below, done here first so a non-
91
+ // searchable entity's named events stay a no-op like before.
92
+ const entity = registry.getEntity(entityName);
93
+ const isSearchable =
94
+ entity !== undefined &&
95
+ (registry.getSearchableFields(entityName).length > 0 ||
96
+ registry.getSearchPayloadExtensions(entityName).length > 0);
97
+ // skip: entity declares no searchable fields/extensions — nothing to
98
+ // index, stays a no-op with no extra query, same as before #2765.
99
+ if (!entity || !isSearchable) return;
100
+
101
+ const row = await readProjectionRowForSearch(
102
+ ctx,
103
+ registry,
104
+ entityName,
105
+ entity,
106
+ tenantId,
107
+ event.aggregateId,
108
+ );
109
+ // skip: no live row (hard-deleted stream, or soft-deleted) — same
110
+ // outcome as the deleted/forgotten branch above.
111
+ if (!row) {
112
+ await searchAdapter.remove(tenantId, entityName, event.aggregateId);
113
+ return;
114
+ }
115
+ state = row;
75
116
  }
76
117
 
77
- let state = reconstructStateForSearch(event.payload, verb);
78
118
  state = await decryptSearchableSubjectFields(entityName, state, registry);
79
119
  // skip: erased subject — drop the doc so a rebuild cannot resurrect plaintext.
80
120
  if (hasErasedSearchableSubjectField(entityName, state, registry)) {
@@ -91,6 +131,35 @@ export function createSearchEventConsumer(
91
131
  };
92
132
  }
93
133
 
134
+ // #2765 — reads the live projection row for a named domain event, tenant-
135
+ // scoped through createTenantDb the same way MultiStreamProjection appliers
136
+ // scope their apply()-writes (see api/server.ts), on the table the registry
137
+ // actually booted (entityTableFromRegistry) rather than one freshly derived
138
+ // from the entity definition alone. undefined means "no live row" — a hard
139
+ // delete or a soft-delete — which the caller treats as a remove.
140
+ async function readProjectionRowForSearch(
141
+ ctx: AppContext,
142
+ registry: Registry,
143
+ entityName: string,
144
+ entity: EntityDefinition,
145
+ tenantId: TenantId,
146
+ entityId: EntityId,
147
+ ): Promise<Record<string, unknown> | undefined> {
148
+ // @cast-boundary engine-bridge — consumer ctx.db is always the outer
149
+ // DbConnection: event-dispatcher creation is gated on it being set
150
+ // (api/server.ts), never a HandlerContext's already-tenant-scoped TenantDb.
151
+ const baseDb = ctx.db as DbConnection | undefined;
152
+ if (!baseDb) return undefined;
153
+ const table = entityTableFromRegistry(registry, entityName, entity);
154
+ const where: Record<string, unknown> = { id: entityId };
155
+ if (entity.softDelete && table["isDeleted"]) {
156
+ where["isDeleted"] = false;
157
+ }
158
+ const row = await createTenantDb(baseDb, tenantId).fetchOne(table, where);
159
+ if (!row) return undefined;
160
+ return rehydrateCompoundTypes(row, entity);
161
+ }
162
+
94
163
  // #1610 — subject-annotated searchable fields are ciphertext in the event
95
164
  // payload; decrypt into the derived index only. No KMS → omit ciphertext
96
165
  // values rather than indexing blobs.