@cosmicdrift/kumiko-framework 0.254.0 → 0.255.1

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.
@@ -1,7 +1,8 @@
1
+ import { type EventPiiSubject, normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import type { EntityDefinition } from "../engine/types/fields";
2
3
  import type { TenantId } from "../engine/types/identifiers";
3
4
  import { isSelfPiiField } from "./is-self-pii-field";
4
- import type { SubjectId } from "./kms-adapter";
5
+ import { RECORD_ENTITY_PATTERN, type SubjectId } from "./kms-adapter";
5
6
 
6
7
  // Thrown when a field IS pii-annotated but the row can't name its subject —
7
8
  // that must surface as an error, not fall back to plaintext.
@@ -125,6 +126,66 @@ export function resolveSubjectForField(
125
126
  return null;
126
127
  }
127
128
 
129
+ // Envelope facts an event's own aggregate stream always carries — unlike a
130
+ // row's tenantId/id columns, these are structural, so a missing/empty value
131
+ // here is a caller bug, not a normal "no subject to shred" case.
132
+ export interface EventSubjectEnvelope {
133
+ readonly tenantId: TenantId;
134
+ readonly aggregateType: string;
135
+ readonly aggregateId: string;
136
+ }
137
+
138
+ // Same resolver for the live-append path (encryptEventPayloadPii) and the
139
+ // backfill catalog path (backfillEventPiiEncryption) — the reason the two
140
+ // can never encrypt the same field under different subjects. "user" returns
141
+ // null on a missing owner (no key to shred for system-triggered events);
142
+ // "tenant"/"self" throw instead, since their envelope facts are structural.
143
+ export function resolveEventSubject(
144
+ fieldName: string,
145
+ spec: EventPiiSubject,
146
+ payload: Record<string, unknown>,
147
+ envelope: EventSubjectEnvelope,
148
+ ): SubjectId | null {
149
+ const normalized = normalizeEventPiiSubject(spec);
150
+
151
+ if (normalized.kind === "user") {
152
+ const userId = nonEmptyString(payload[normalized.ownerField]);
153
+ return userId === null ? null : { kind: "user", userId };
154
+ }
155
+
156
+ if (normalized.kind === "tenant") {
157
+ if (nonEmptyString(envelope.tenantId) === null) {
158
+ throw new SubjectResolutionError(fieldName, "event envelope tenantId is empty");
159
+ }
160
+ return { kind: "tenant", tenantId: envelope.tenantId };
161
+ }
162
+
163
+ if (normalized.kind === "self") {
164
+ if (
165
+ nonEmptyString(envelope.aggregateType) === null ||
166
+ nonEmptyString(envelope.aggregateId) === null
167
+ ) {
168
+ throw new SubjectResolutionError(
169
+ fieldName,
170
+ "event envelope aggregateType/aggregateId is empty",
171
+ );
172
+ }
173
+ // aggregate_type is a free-form text column (events-schema.ts) — reject
174
+ // here rather than mint a record key that subjectIdSchema (kms-adapter.ts)
175
+ // would later refuse to shred.
176
+ if (!RECORD_ENTITY_PATTERN.test(envelope.aggregateType)) {
177
+ throw new SubjectResolutionError(
178
+ fieldName,
179
+ `event aggregateType "${envelope.aggregateType}" does not match ${RECORD_ENTITY_PATTERN} — not a shreddable record subject`,
180
+ );
181
+ }
182
+ return { kind: "record", entity: envelope.aggregateType, id: envelope.aggregateId };
183
+ }
184
+
185
+ const exhaustiveCheck: never = normalized;
186
+ throw new Error(`Unhandled normalized event PII subject: ${JSON.stringify(exhaustiveCheck)}`);
187
+ }
188
+
128
189
  function hasSubjectAnnotation(field: EntityDefinition["fields"][string]): boolean {
129
190
  return (
130
191
  ("userOwned" in field && field.userOwned !== undefined) ||
@@ -11,7 +11,8 @@
11
11
  // same collectPiiSubjectFields/resolveSubjectForField the live write
12
12
  // path uses
13
13
  // - custom events from the event-PII catalog (r.defineEvent piiFields) —
14
- // user-subject only, see the note at its call site (fw#2801)
14
+ // user/tenant/self subjects, via the same resolveEventSubject the live
15
+ // append path uses (fw#2801)
15
16
  //
16
17
  // Already-forgotten subjects must NOT get a fresh key minted for their old
17
18
  // plaintext — three erased-detection layers write [[erased]] instead:
@@ -59,6 +60,7 @@ import {
59
60
  } from "../../crypto/pii-field-encryption";
60
61
  import {
61
62
  collectPiiSubjectFields,
63
+ resolveEventSubject,
62
64
  resolveSubjectForField,
63
65
  SubjectResolutionError,
64
66
  } from "../../crypto/subject-resolver";
@@ -284,11 +286,13 @@ export async function backfillEventPiiEncryption(
284
286
  const catalogFields = eventCatalog.get(row.type);
285
287
  if (catalogFields) {
286
288
  for (const [field, spec] of Object.entries(catalogFields)) {
287
- const subjectId = payload[spec.subjectField];
288
- if (typeof subjectId !== "string" || subjectId.length === 0) continue;
289
- // Catalog entries only ever resolve a user subject — a tenant/record
290
- // subject in a custom event is not backfillable today (fw#2801).
291
- const outcome = await encryptField(payload, field, { kind: "user", userId: subjectId });
289
+ const subject = resolveEventSubject(field, spec, payload, {
290
+ tenantId: row.tenant_id as TenantId, // @cast-boundary db-read — tenant_id column is the branded TenantId
291
+ aggregateType: row.aggregate_type,
292
+ aggregateId: row.aggregate_id,
293
+ });
294
+ if (subject === null) continue;
295
+ const outcome = await encryptField(payload, field, subject);
292
296
  bump(outcome);
293
297
  }
294
298
  } else {
@@ -132,6 +132,37 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
132
132
  expect(() => validateBoot([feature])).toThrow(/metric "balance" has no entry in fieldLabels/);
133
133
  });
134
134
 
135
+ test("object-form metric with its own label needs no fieldLabels entry", () => {
136
+ const feature = defineFeature("app", (r) => {
137
+ r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
138
+ access: { openToAll: true },
139
+ });
140
+ r.screen({
141
+ id: "rent-detail",
142
+ type: "projectionDetail",
143
+ query: "app:query:rent:detail",
144
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
145
+ metrics: [{ field: "balance", label: "rent.balance" }],
146
+ });
147
+ });
148
+ expect(() => validateBoot([feature])).not.toThrow();
149
+ });
150
+
151
+ test("object-form metric with neither its own label nor a fieldLabels entry throws", () => {
152
+ const feature = defineFeature("app", (r) => {
153
+ r.screen({
154
+ id: "rent-detail",
155
+ type: "projectionDetail",
156
+ query: "app:query:rent:detail",
157
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
158
+ metrics: [{ field: "balance" }],
159
+ });
160
+ });
161
+ expect(() => validateBoot([feature])).toThrow(
162
+ /metric "balance" has no entry in fieldLabels and no own "label"/,
163
+ );
164
+ });
165
+
135
166
  test("valid tabs + header + metrics declaration boots cleanly", () => {
136
167
  const feature = defineFeature("app", (r) => {
137
168
  r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
@@ -142,8 +173,8 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
142
173
  type: "projectionDetail",
143
174
  query: "app:query:rent:detail",
144
175
  header: { title: "name", subtitle: "address", status: "state" },
145
- metrics: ["balance", "overdueDays"],
146
- fieldLabels: { balance: "rent.balance", overdueDays: "rent.overdueDays" },
176
+ metrics: ["balance", { field: "overdueDays", label: "rent.overdueDays" }],
177
+ fieldLabels: { balance: "rent.balance" },
147
178
  layout: {
148
179
  mode: "tabs",
149
180
  sections: [
@@ -1,9 +1,8 @@
1
1
  // fw#2809 — a recordOwned field is encrypted under `record:<entity>:<id>`
2
- // (kms-adapter-types.ts) and forgetSubject's subjectIdSchema validates that
3
- // id as a UUID (crypto-shredding/handlers/forget-subject.write.ts). An
4
- // idType: "serial" entity would encrypt such a field but could never satisfy
5
- // a forget-subject request for it — the boot guard catches that combination
6
- // before it ships.
2
+ // (kms-adapter-types.ts) and forgetSubject's subjectIdSchema (crypto/kms-adapter.ts)
3
+ // validates that id as a UUID. An idType: "serial" entity would encrypt such
4
+ // a field but could never satisfy a forget-subject request for it — the boot
5
+ // guard catches that combination before it ships.
7
6
 
8
7
  import { describe, expect, test } from "bun:test";
9
8
  import { defineFeature } from "../../define-feature";
@@ -12,6 +12,7 @@ import type {
12
12
  QueryHandlerDef,
13
13
  ScreenDefinition,
14
14
  } from "../types";
15
+ import { metricField } from "../types";
15
16
  import { buildQueryHandlerMap } from "./projection-list-screens";
16
17
  import { getZodObjectShape, getZodRowShape } from "./zod-shape";
17
18
 
@@ -130,11 +131,12 @@ function checkProjectionDetailOutputFields(
130
131
  if (screen.header.status !== undefined) checkHeaderField("status", screen.header.status);
131
132
  }
132
133
  for (const metric of screen.metrics ?? []) {
134
+ const field = metricField(metric);
133
135
  checkFieldExists(
134
136
  recordShape,
135
- metric,
137
+ field,
136
138
  () =>
137
- `${prefix} metrics references field "${metric}" which is not present in query "${screen.query}"'s outputSchema.`,
139
+ `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`,
138
140
  );
139
141
  }
140
142
  }
@@ -2,10 +2,9 @@ import type { FeatureDefinition } from "../types";
2
2
  import type { ResolvedPiiFlags } from "../types/fields";
3
3
 
4
4
  // A recordOwned field is encrypted under `record:<entity>:<id>` (kms-adapter-types.ts)
5
- // and forgetSubject's subjectIdSchema requires that id to be a UUID
6
- // (crypto-shredding/handlers/forget-subject.write.ts) — an entity with
7
- // idType: "serial" would encrypt the field but could never satisfy a
8
- // forget-subject request for it.
5
+ // and forgetSubject's subjectIdSchema (crypto/kms-adapter.ts) requires that id
6
+ // to be a UUID — an entity with idType: "serial" would encrypt the field but
7
+ // could never satisfy a forget-subject request for it.
9
8
  export function validateRecordOwnedSubjects(feature: FeatureDefinition): void {
10
9
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
11
10
  if (entity.idType !== "serial") continue;
@@ -16,6 +16,7 @@ import {
16
16
  normalizeListColumn,
17
17
  } from "../screen-helpers";
18
18
  import type { EntityDefinition, FeatureDefinition } from "../types";
19
+ import { metricField } from "../types";
19
20
  import type {
20
21
  DashboardCustomPanel,
21
22
  DashboardFilterDefinition,
@@ -697,11 +698,13 @@ export function validateScreens(
697
698
  }
698
699
  if (screen.metrics !== undefined) {
699
700
  for (const metric of screen.metrics) {
700
- if (screen.fieldLabels?.[metric] === undefined) {
701
+ const field = metricField(metric);
702
+ const hasOwnLabel = typeof metric !== "string" && metric.label !== undefined;
703
+ if (!hasOwnLabel && screen.fieldLabels?.[field] === undefined) {
701
704
  throw new Error(
702
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${metric}" has ` +
703
- `no entry in fieldLabels — every metrics field needs a label, there is no fallback to ` +
704
- `the raw column name.`,
705
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" has ` +
706
+ `no entry in fieldLabels and no own "label" — every metrics field needs one of the two, ` +
707
+ `there is no fallback to the raw column name.`,
705
708
  );
706
709
  }
707
710
  }
@@ -1,3 +1,4 @@
1
+ import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { ZodObject, type ZodType, type z } from "zod";
2
3
  import type { FeatureBuilderState } from "./feature-builder-state";
3
4
  import { resolveName } from "./handler-helpers";
@@ -96,7 +97,7 @@ export function buildConfigEventsJobsMethods<TName extends string>(
96
97
  }
97
98
 
98
99
  // piiFields misconfiguration is a boot-time error, not a silent
99
- // plaintext leak: both the pii field and its subjectField must exist
100
+ // plaintext leak: both the pii field and its owner field must exist
100
101
  // on the payload schema (checkable when the schema is a ZodObject).
101
102
  function validateEventPiiFields(
102
103
  eventName: string,
@@ -105,12 +106,24 @@ export function buildConfigEventsJobsMethods<TName extends string>(
105
106
  ): void {
106
107
  const shape = schema instanceof ZodObject ? schema.shape : undefined;
107
108
  for (const [field, spec] of Object.entries(piiFields)) {
108
- if (field === spec.subjectField) {
109
+ const normalized = normalizeEventPiiSubject(spec);
110
+ // Only the user form names an owner field — tenant/self subjects come
111
+ // from the write-time envelope (tenantId / aggregateType+aggregateId),
112
+ // so there is no owner-field existence or self-reference check for them.
113
+ if (normalized.kind !== "user") {
114
+ if (shape && !(field in shape)) {
115
+ throw new Error(
116
+ `[Feature ${name}] defineEvent("${eventName}"): piiFields references "${field}" which is not a field of the payload schema.`,
117
+ );
118
+ }
119
+ continue;
120
+ }
121
+ if (field === normalized.ownerField) {
109
122
  throw new Error(
110
- `[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as subjectField — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`,
123
+ `[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as the owner field — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`,
111
124
  );
112
125
  }
113
- for (const required of [field, spec.subjectField]) {
126
+ for (const required of [field, normalized.ownerField]) {
114
127
  if (shape && !(required in shape)) {
115
128
  throw new Error(
116
129
  `[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`,
@@ -238,7 +251,7 @@ export function buildConfigEventsJobsMethods<TName extends string>(
238
251
  // an event without declaring whether its payload carries personal data.
239
252
  const missingStanceError = () =>
240
253
  new Error(
241
- `[Feature ${name}] defineEvent("${eventName}") must declare an explicit PII stance. Pass { piiFields: { <payloadField>: { subjectField: "<userIdField>" } } } for payload fields holding personal data, or { piiFields: "none" } when the payload holds none.`,
254
+ `[Feature ${name}] defineEvent("${eventName}") must declare an explicit PII stance. Pass { piiFields: { <payloadField>: { personal: { of: "<userIdField>" } } } } for payload fields owned by a user (or { personal: "tenant" } / { personal: "self" } for tenant- or record-scoped subjects), or { piiFields: "none" } when the payload holds none.`,
242
255
  );
243
256
  if (options === undefined || options.piiFields === undefined) {
244
257
  throw missingStanceError();
@@ -390,6 +390,8 @@ export type {
390
390
  ListSortSpec,
391
391
  LongTextFindability,
392
392
  ManyToManyRelation,
393
+ MetricNavigate,
394
+ MetricSpec,
393
395
  MspErrorMode,
394
396
  MspErrorPolicy,
395
397
  MultiSelectFieldDef,
@@ -292,6 +292,8 @@ export type {
292
292
  ListPaginationMode,
293
293
  ListSortDir,
294
294
  ListSortSpec,
295
+ MetricNavigate,
296
+ MetricSpec,
295
297
  PlatformComponent,
296
298
  ProjectionDetailScreenDefinition,
297
299
  ProjectionListScreenDefinition,
@@ -311,6 +313,7 @@ export type {
311
313
  ToolbarAction,
312
314
  UnitKey,
313
315
  } from "@cosmicdrift/kumiko-types/screen";
316
+ export { metricField } from "@cosmicdrift/kumiko-types/screen";
314
317
  export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
315
318
  export type {
316
319
  Subscribe,
@@ -1,2 +1,3 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
2
  export type * from "@cosmicdrift/kumiko-types/screen";
3
+ export { metricField } from "@cosmicdrift/kumiko-types/screen";
@@ -51,12 +51,31 @@ const crmFeature = defineFeature("crm", (r) => {
51
51
  r.entity("contact", contactEntity);
52
52
  });
53
53
 
54
+ // Qualified event-type strings are kebab-case (qn/toKebab, feature-config-
55
+ // events-jobs.ts) — captured from defineEvent's own return value instead of
56
+ // hand-written, so a camelCase event name (tenantNote → tenant-note) can
57
+ // never drift from what the catalog is actually keyed by (fw#2801).
58
+ let PING_EVENT_TYPE: string;
54
59
  const mailerFeature = defineFeature("mailer", (r) => {
55
- r.defineEvent(
60
+ PING_EVENT_TYPE = r.defineEvent(
56
61
  "ping",
57
62
  z.object({ targetId: z.string().nullable(), address: z.string().nullable() }),
58
63
  { piiFields: { address: { subjectField: "targetId" } } },
59
- );
64
+ ).name;
65
+ });
66
+
67
+ // fw#2801: custom events declaring a tenant/self subject — no entity,
68
+ // pure catalog events — proving the catalog branch resolves through the
69
+ // same resolveEventSubject the live append path uses.
70
+ let TENANT_NOTE_EVENT_TYPE: string;
71
+ let SELF_NOTE_EVENT_TYPE: string;
72
+ const signalsFeature = defineFeature("signals", (r) => {
73
+ TENANT_NOTE_EVENT_TYPE = r.defineEvent("tenantNote", z.object({ note: z.string().nullable() }), {
74
+ piiFields: { note: { personal: "tenant" } },
75
+ }).name;
76
+ SELF_NOTE_EVENT_TYPE = r.defineEvent("selfNote", z.object({ note: z.string().nullable() }), {
77
+ piiFields: { note: { personal: "self" } },
78
+ }).name;
60
79
  });
61
80
 
62
81
  // personal: { of } (not "self") — the named owner field can legitimately be
@@ -111,7 +130,13 @@ async function appendPlain(
111
130
 
112
131
  beforeAll(async () => {
113
132
  testDb = await createTestDb();
114
- registry = createRegistry([crmFeature, mailerFeature, notesFeature, surveyFeature]);
133
+ registry = createRegistry([
134
+ crmFeature,
135
+ mailerFeature,
136
+ notesFeature,
137
+ surveyFeature,
138
+ signalsFeature,
139
+ ]);
115
140
  await createEventsTable(testDb.db);
116
141
  await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
117
142
  await unsafeCreateEntityTable(testDb.db, noteEntity, "note");
@@ -220,15 +245,15 @@ describe("backfillEventPiiEncryption", () => {
220
245
  const p1 = generateId();
221
246
  const p2 = generateId();
222
247
  const p3 = generateId();
223
- await appendPlain(p1, "ping", "mailer:event:ping", {
248
+ await appendPlain(p1, "ping", PING_EVENT_TYPE, {
224
249
  targetId: "u-7",
225
250
  address: "u7@x.com",
226
251
  });
227
- await appendPlain(p2, "ping", "mailer:event:ping", {
252
+ await appendPlain(p2, "ping", PING_EVENT_TYPE, {
228
253
  targetId: forgottenUser,
229
254
  address: "f@x.com",
230
255
  });
231
- await appendPlain(p3, "ping", "mailer:event:ping", { targetId: null, address: "ops@x.com" });
256
+ await appendPlain(p3, "ping", PING_EVENT_TYPE, { targetId: null, address: "ops@x.com" });
232
257
 
233
258
  armKms();
234
259
  const result = await backfillEventPiiEncryption(testDb.db, registry);
@@ -386,7 +411,7 @@ describe("backfillEventPiiEncryption", () => {
386
411
 
387
412
  test("dryRun over a catalogued custom event mints no key for its payload-resolved subject", async () => {
388
413
  const p1 = generateId();
389
- await appendPlain(p1, "ping", "mailer:event:ping", {
414
+ await appendPlain(p1, "ping", PING_EVENT_TYPE, {
390
415
  targetId: "u-7",
391
416
  address: "u7@x.com",
392
417
  });
@@ -730,3 +755,104 @@ describe("backfillEventPiiEncryption: raw jsonb column type (fw#2253)", () => {
730
755
  expect(rows[0]?.t).toBe("object");
731
756
  });
732
757
  });
758
+
759
+ // The security-critical invariant this PR exists for: a catalogued field
760
+ // must resolve to the IDENTICAL subject key whether it was encrypted at
761
+ // live-append time or re-encrypted by the backfill — both paths now go
762
+ // through the same resolveEventSubject (subject-resolver.ts). If they ever
763
+ // diverged, an Art. 17 erase of one subject key would leave the same field's
764
+ // other-path ciphertext readable under a different key.
765
+ describe("backfillEventPiiEncryption: catalog subject kinds match live append (fw#2801)", () => {
766
+ test("user subject: backfill and live append encrypt under the same subject key", async () => {
767
+ const preKmsId = generateId();
768
+ await appendPlain(preKmsId, "ping", PING_EVENT_TYPE, {
769
+ targetId: "u-42",
770
+ address: "pre-kms@x.com",
771
+ });
772
+
773
+ armKms();
774
+ const liveId = generateId();
775
+ await append(testDb.db, {
776
+ aggregateId: liveId,
777
+ aggregateType: "ping",
778
+ tenantId: TENANT,
779
+ expectedVersion: 0,
780
+ type: PING_EVENT_TYPE,
781
+ payload: { targetId: "u-42", address: "live@x.com" },
782
+ metadata: { userId: "system" },
783
+ });
784
+
785
+ await backfillEventPiiEncryption(testDb.db, registry);
786
+
787
+ const backfilled = (await loadAggregate(testDb.db, preKmsId, TENANT))[0]?.payload as Record<
788
+ string,
789
+ unknown
790
+ >;
791
+ const live = (await loadAggregate(testDb.db, liveId, TENANT))[0]?.payload as Record<
792
+ string,
793
+ unknown
794
+ >;
795
+ expect(String(backfilled["address"])).toContain("user:u-42");
796
+ expect(String(live["address"])).toContain("user:u-42");
797
+ });
798
+
799
+ test("tenant subject: backfill and live append encrypt under the same subject key", async () => {
800
+ const preKmsId = generateId();
801
+ await appendPlain(preKmsId, "signal", TENANT_NOTE_EVENT_TYPE, { note: "pre-kms" });
802
+
803
+ armKms();
804
+ const liveId = generateId();
805
+ await append(testDb.db, {
806
+ aggregateId: liveId,
807
+ aggregateType: "signal",
808
+ tenantId: TENANT,
809
+ expectedVersion: 0,
810
+ type: TENANT_NOTE_EVENT_TYPE,
811
+ payload: { note: "live" },
812
+ metadata: { userId: "system" },
813
+ });
814
+
815
+ await backfillEventPiiEncryption(testDb.db, registry);
816
+
817
+ const backfilled = (await loadAggregate(testDb.db, preKmsId, TENANT))[0]?.payload as Record<
818
+ string,
819
+ unknown
820
+ >;
821
+ const live = (await loadAggregate(testDb.db, liveId, TENANT))[0]?.payload as Record<
822
+ string,
823
+ unknown
824
+ >;
825
+ expect(String(backfilled["note"])).toContain(`tenant:${TENANT}`);
826
+ expect(String(live["note"])).toContain(`tenant:${TENANT}`);
827
+ });
828
+
829
+ test("self (record) subject: backfill and live append encrypt each under its own aggregate's key", async () => {
830
+ const preKmsId = generateId();
831
+ await appendPlain(preKmsId, "signal", SELF_NOTE_EVENT_TYPE, { note: "pre-kms" });
832
+
833
+ armKms();
834
+ const liveId = generateId();
835
+ await append(testDb.db, {
836
+ aggregateId: liveId,
837
+ aggregateType: "signal",
838
+ tenantId: TENANT,
839
+ expectedVersion: 0,
840
+ type: SELF_NOTE_EVENT_TYPE,
841
+ payload: { note: "live" },
842
+ metadata: { userId: "system" },
843
+ });
844
+
845
+ await backfillEventPiiEncryption(testDb.db, registry);
846
+
847
+ const backfilled = (await loadAggregate(testDb.db, preKmsId, TENANT))[0]?.payload as Record<
848
+ string,
849
+ unknown
850
+ >;
851
+ const live = (await loadAggregate(testDb.db, liveId, TENANT))[0]?.payload as Record<
852
+ string,
853
+ unknown
854
+ >;
855
+ expect(String(backfilled["note"])).toContain(`record:signal:${preKmsId}`);
856
+ expect(String(live["note"])).toContain(`record:signal:${liveId}`);
857
+ });
858
+ });
@@ -78,7 +78,11 @@ export const EVENTS_PUBSUB_CHANNEL = "kumiko_events_new";
78
78
  export async function append(db: DbRunner, event: EventToAppend): Promise<StoredEvent> {
79
79
  // Event-PII (#799): stored payload AND returned echo carry ciphertext, so
80
80
  // inline projections and rebuilds materialize identical rows.
81
- const payload = await encryptEventPayloadPii(event.type, event.payload);
81
+ const payload = await encryptEventPayloadPii(event.type, event.payload, {
82
+ tenantId: event.tenantId,
83
+ aggregateType: event.aggregateType,
84
+ aggregateId: event.aggregateId,
85
+ });
82
86
  const toStore = payload === event.payload ? event : { ...event, payload };
83
87
  const newVersion = toStore.expectedVersion + 1;
84
88
  const eventVersion = toStore.eventVersion ?? 1;
@@ -10,7 +10,9 @@ import {
10
10
  createSystemUser,
11
11
  createTextField,
12
12
  defineFeature,
13
+ HookPhases,
13
14
  } from "../../engine";
15
+ import type { HandlerContext } from "../../engine/types";
14
16
  import { UnprocessableError, writeFailure } from "../../errors";
15
17
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
16
18
 
@@ -44,14 +46,32 @@ const secretEntity = createEntity({
44
46
  });
45
47
  const secretTable = buildEntityTable("secret", secretEntity);
46
48
 
49
+ // Target entity for the afterCommit-hook-writes-a-second-entity test below
50
+ // (dead-tx-in-afterCommit-hooks). Plain Admin access — kept separate
51
+ // from `secret` so that test doesn't entangle with the privileged-access
52
+ // assertions the other describe blocks make about it.
53
+ const echoEntity = createEntity({
54
+ table: "ctx_echoes",
55
+ fields: {
56
+ bagId: createTextField({ required: true }),
57
+ },
58
+ });
59
+ const echoTable = buildEntityTable("echo", echoEntity);
60
+
47
61
  let stack: TestStack;
48
62
  const admin = TestUsers.admin;
49
63
 
50
64
  const afterCommitLog: string[] = [];
51
65
 
66
+ // Toggled per-test so the afterCommit hook only writes echo rows for
67
+ // the test that exercises it — reset in beforeEach.
68
+ let afterCommitHookShouldEchoBag = false;
69
+ const echoedBagIds: string[] = [];
70
+
52
71
  const bridgeFeature = defineFeature("ctxbridge", (r) => {
53
72
  const bag = r.entity("bag", bagEntity);
54
73
  const secret = r.entity("secret", secretEntity);
74
+ r.entity("echo", echoEntity);
55
75
 
56
76
  r.writeHandler(
57
77
  "bag:create",
@@ -182,11 +202,45 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
182
202
  { access: { roles: ["Admin"] } },
183
203
  );
184
204
 
205
+ r.writeHandler(
206
+ "echo:create",
207
+ z.object({ bagId: z.string() }),
208
+ async (event, ctx) => {
209
+ const crud = createEventStoreExecutor(echoTable, echoEntity, {
210
+ entityName: "echo",
211
+ });
212
+ return crud.create(event.payload, event.user, ctx.db);
213
+ },
214
+ { access: { roles: ["Admin"] } },
215
+ );
216
+
185
217
  // afterCommit hook on bag — fires once per outer commit.
186
218
  r.hook("postSave", { allOf: bag }, async (result) => {
187
219
  afterCommitLog.push(`bag:${result.data["label"]}`);
188
220
  });
189
221
 
222
+ // afterCommit hook on bag that writes a SECOND entity via ctx.write,
223
+ // going through the real event-store append path (crud.create ->
224
+ // runInSavepointIfSupported). Proves the afterCommit context's ctx.db
225
+ // is a live, usable handle rather than the already-committed tx
226
+ // (dead-tx-in-afterCommit-hooks). Explicit phase so the test says
227
+ // what it means, even though afterCommit is already the default.
228
+ r.hook(
229
+ "postSave",
230
+ { allOf: bag },
231
+ async (result, ctx) => {
232
+ if (!afterCommitHookShouldEchoBag) return;
233
+ const bagId = String(result.id);
234
+ // Hooks are typed against AppContext (no bridge), but the runtime
235
+ // object is always the full HandlerContext — see the comment on
236
+ // AppContext in @cosmicdrift/kumiko-types/handlers. @cast-boundary
237
+ const handlerCtx = ctx as unknown as HandlerContext;
238
+ const echoRes = await handlerCtx.write("ctxbridge:write:echo:create", { bagId });
239
+ if (echoRes.isSuccess) echoedBagIds.push(bagId);
240
+ },
241
+ { phase: HookPhases.afterCommit },
242
+ );
243
+
190
244
  // afterCommit hook on secret — the entity targeted by the nested writeAs.
191
245
  // Proves: (a) hook fires exactly once per successful writeAs, (b) hook
192
246
  // does NOT fire when the outer transaction rolls back.
@@ -199,6 +253,7 @@ beforeAll(async () => {
199
253
  stack = await setupTestStack({ features: [bridgeFeature] });
200
254
  await unsafeCreateEntityTable(stack.db, bagEntity);
201
255
  await unsafeCreateEntityTable(stack.db, secretEntity);
256
+ await unsafeCreateEntityTable(stack.db, echoEntity);
202
257
  });
203
258
 
204
259
  afterAll(async () => {
@@ -207,8 +262,11 @@ afterAll(async () => {
207
262
 
208
263
  beforeEach(async () => {
209
264
  afterCommitLog.length = 0;
265
+ afterCommitHookShouldEchoBag = false;
266
+ echoedBagIds.length = 0;
210
267
  await asRawClient(stack.db).unsafe(`DELETE FROM "${bagTable.tableName}"`);
211
268
  await asRawClient(stack.db).unsafe(`DELETE FROM "${secretTable.tableName}"`);
269
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${echoTable.tableName}"`);
212
270
  // Clear the event-dedup cache — tests re-use entity ids (Postgres sequences
213
271
  // reset, each test sees id=1). Without flushing Redis the second test hits
214
272
  // a dedup hit on the same handler:id:version:phase key and the hook is
@@ -331,3 +389,24 @@ describe("ctx.dbOutsideTransaction", () => {
331
389
  expect(labels).toEqual(["signal-probe-outside-tx"]);
332
390
  });
333
391
  });
392
+
393
+ describe("afterCommit hook context is usable for real DB work", () => {
394
+ test("a postSave afterCommit hook can write a second entity via ctx.write", async () => {
395
+ afterCommitHookShouldEchoBag = true;
396
+
397
+ const res = await stack.http.write("ctxbridge:write:bag:create", { label: "echo-me" }, admin);
398
+ expect((await res.json()).isSuccess).toBe(true);
399
+
400
+ // The effect, not just "no throw": afterCommit hook errors are caught
401
+ // and logged by the framework (dispatch-batch.ts flushAfterCommit), so
402
+ // a broken hook still returns a 200 here. What proves the fix is that
403
+ // the hook's ctx.write actually landed a row through the event-store
404
+ // append path.
405
+ expect(echoedBagIds).toHaveLength(1);
406
+ const [expectedBagId] = echoedBagIds;
407
+ if (!expectedBagId) throw new Error("echoedBagIds[0] missing");
408
+ const echoes = await selectMany(stack.db, echoTable);
409
+ expect(echoes).toHaveLength(1);
410
+ expect((echoes[0] as { bagId: string }).bagId).toBe(expectedBagId);
411
+ });
412
+ });