@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
@@ -1,6 +1,10 @@
1
1
  import { configureEventPiiCatalog } from "../crypto/event-pii";
2
2
  import { resolveName } from "./handler-helpers";
3
- import type { RegistryState, SearchableReferenceField } from "./registry-state";
3
+ import type {
4
+ RegistryState,
5
+ SearchableReferenceField,
6
+ SortableReferenceField,
7
+ } from "./registry-state";
4
8
  import { buildImplicitProjection, hasFieldAccessRules, qualify } from "./registry-state";
5
9
  import {
6
10
  buildSoftDeleteCleanupJob,
@@ -192,12 +196,43 @@ function buildSearchableReferenceField(
192
196
  };
193
197
  }
194
198
 
195
- // Precompute: searchable/sortable fields, searchable reference fields.
199
+ // fw#2741: ORDER BY runs against the referenced row's label column, so the
200
+ // same explicit-labelField requirement as searchable applies — ordering by the
201
+ // "id" default would be a UUID order that looks deliberate to the user.
202
+ function buildSortableReferenceField(
203
+ entityName: string,
204
+ fieldName: string,
205
+ field: ReferenceFieldDef,
206
+ ): SortableReferenceField {
207
+ if (field.labelField === undefined || field.labelField === "id") {
208
+ throw new Error(
209
+ `[Entity ${entityName}] field "${fieldName}": sortable reference fields require an ` +
210
+ `explicit, non-"id" labelField. Ordering by "id" is a UUID order — arbitrary to the ` +
211
+ `user while looking deliberate. Set labelField to a human-readable field on the ` +
212
+ `referenced entity.`,
213
+ );
214
+ }
215
+ if (field.multiple === true) {
216
+ throw new Error(
217
+ `[Entity ${entityName}] field "${fieldName}": sortable is not supported on multiple ` +
218
+ `(array) reference fields — there is no single label to order by; remove "multiple" ` +
219
+ `or "sortable".`,
220
+ );
221
+ }
222
+ return {
223
+ fieldName,
224
+ targetEntityName: parseReferenceTargetEntityName(field.entity),
225
+ labelField: field.labelField,
226
+ };
227
+ }
228
+
229
+ // Precompute: searchable/sortable fields, searchable + sortable reference fields.
196
230
  export function buildSearchableSortableCaches(state: RegistryState): void {
197
231
  for (const [name, entity] of state.entityMap) {
198
232
  const searchable: string[] = [];
199
233
  const sortable: string[] = [];
200
234
  const searchableReferences: SearchableReferenceField[] = [];
235
+ const sortableReferences: SortableReferenceField[] = [];
201
236
  for (const [fieldName, field] of Object.entries(entity.fields)) {
202
237
  if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
203
238
  if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
@@ -209,10 +244,14 @@ export function buildSearchableSortableCaches(state: RegistryState): void {
209
244
  if (field.type === "reference" && field.searchable === true) {
210
245
  searchableReferences.push(buildSearchableReferenceField(name, fieldName, field));
211
246
  }
247
+ if (field.type === "reference" && field.sortable === true) {
248
+ sortableReferences.push(buildSortableReferenceField(name, fieldName, field));
249
+ }
212
250
  }
213
251
  state.searchableFieldsCache.set(name, searchable);
214
252
  state.sortableFieldsCache.set(name, sortable);
215
253
  state.searchableReferencesCache.set(name, searchableReferences);
254
+ state.sortableReferencesCache.set(name, sortableReferences);
216
255
  }
217
256
  }
218
257
 
@@ -664,9 +703,21 @@ export function publishEventPiiCatalog(state: RegistryState): void {
664
703
  // into kumiko_events — encrypts catalogued payload fields regardless of
665
704
  // which path produced the event (ctx.appendEvent, MSP-apply, low-level
666
705
  // append in delivery/jobs loggers).
706
+ //
707
+ // Defense-in-depth (fw#2558): defineEvent is the only producer of
708
+ // EventDef.piiFields and already fails closed without a stance, but this
709
+ // boot gate re-checks every registered event — a future producer that
710
+ // forgets to run through defineEvent would otherwise leave a hole where
711
+ // append() silently ships plaintext instead of refusing to boot.
667
712
  const eventPiiCatalog = new Map<string, EventPiiFields>();
668
713
  for (const [qualified, def] of state.eventMap) {
669
- if (def.piiFields) eventPiiCatalog.set(qualified, def.piiFields);
714
+ if (def.piiFields === undefined) {
715
+ throw new Error(
716
+ `Registered event "${qualified}" has no PII stance. Every r.defineEvent(...) call must pass { piiFields: { ... } } or { piiFields: "none" }.`,
717
+ );
718
+ }
719
+ if (def.piiFields === "none") continue;
720
+ eventPiiCatalog.set(qualified, def.piiFields);
670
721
  }
671
722
  configureEventPiiCatalog(eventPiiCatalog);
672
723
  }
@@ -158,6 +158,7 @@ export type {
158
158
  EventDef,
159
159
  EventMigrationDef,
160
160
  EventPiiFields,
161
+ EventPiiStance,
161
162
  EventUpcastCtx,
162
163
  EventUpcastFn,
163
164
  FetchForWritingArgs,
@@ -25,7 +25,10 @@ const writeProbeResults: Array<{ isSuccess: boolean }> = [];
25
25
  const writeProbeFailures: string[] = [];
26
26
 
27
27
  const writeProbeFeature = defineFeature("writeProbe", (r) => {
28
- const noted = r.defineEvent("noted", z.object({ note: z.string() }), { version: 1 });
28
+ const noted = r.defineEvent("noted", z.object({ note: z.string() }), {
29
+ piiFields: "none",
30
+ version: 1,
31
+ });
29
32
  r.writeHandler(
30
33
  "note",
31
34
  z.object({ note: z.string() }),
@@ -23,7 +23,10 @@ import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
23
23
  import { createAllInOneEntrypoint, createApiEntrypoint, createWorkerEntrypoint } from "../index";
24
24
 
25
25
  const splitFeature = defineFeature("split", (r) => {
26
- const tick = r.defineEvent("tick", z.object({ note: z.string() }), { version: 1 });
26
+ const tick = r.defineEvent("tick", z.object({ note: z.string() }), {
27
+ piiFields: "none",
28
+ version: 1,
29
+ });
27
30
  r.multiStreamProjection({
28
31
  name: "spy",
29
32
  apply: {
@@ -38,7 +41,10 @@ const splitFeature = defineFeature("split", (r) => {
38
41
  const consumedNotes: string[] = [];
39
42
 
40
43
  const workerWriteFeature = defineFeature("workerWrite", (r) => {
41
- const noted = r.defineEvent("noted", z.object({ note: z.string() }), { version: 1 });
44
+ const noted = r.defineEvent("noted", z.object({ note: z.string() }), {
45
+ piiFields: "none",
46
+ version: 1,
47
+ });
42
48
  r.writeHandler(
43
49
  "note",
44
50
  z.object({ note: z.string() }),
@@ -76,6 +82,7 @@ const jobSawFilesRef: string[] = [];
76
82
 
77
83
  const fileJobFeature = defineFeature("fileJob", (r) => {
78
84
  const requested = r.defineEvent("bytes-requested", z.object({ storageKey: z.string() }), {
85
+ piiFields: "none",
79
86
  version: 1,
80
87
  });
81
88
  // Stands in for file-foundation, which this package cannot import.
@@ -310,7 +317,7 @@ describe("entrypoint factories", () => {
310
317
  // process's lane are skipped during buildServer()".
311
318
 
312
319
  const laneFeature = defineFeature("lane", (r) => {
313
- const ping = r.defineEvent("ping", z.object({}), { version: 1 });
320
+ const ping = r.defineEvent("ping", z.object({}), { piiFields: "none", version: 1 });
314
321
  // Three MSPs: one pinned to api, one to worker (explicit), one to both.
315
322
  // A fourth would be a default-undefined-runIn MSP which resolves to
316
323
  // "worker" — covered implicitly by the worker test below.
@@ -57,6 +57,7 @@ const orderFeature = defineFeature("upcastshop", (r) => {
57
57
  "priced",
58
58
  z.object({ totalCents: z.number().int(), currency: z.string() }),
59
59
  {
60
+ piiFields: "none",
60
61
  version: 3,
61
62
  migrations: [
62
63
  // v1 → v2: renamed totalEuros → total (kept as string for this step)
@@ -321,6 +322,7 @@ describe("upcaster: async (Marten AsyncOnlyEventUpcaster — DB-Lookups)", () =>
321
322
  "placed",
322
323
  z.object({ customerId: z.string(), segment: z.string() }),
323
324
  {
325
+ piiFields: "none",
324
326
  version: 2,
325
327
  migrations: [
326
328
  {
@@ -407,6 +409,7 @@ describe("upcaster: boot-time validation", () => {
407
409
  r.entity("hole-order", orderEntity);
408
410
  // Only 1→2 registered — the 2→3 gap must be rejected.
409
411
  r.defineEvent("bad", z.object({ v3: z.string() }), {
412
+ piiFields: "none",
410
413
  version: 3,
411
414
  migrations: [{ fromVersion: 1, toVersion: 2, transform: (p) => p }],
412
415
  });
@@ -424,6 +427,7 @@ describe("upcaster: boot-time validation", () => {
424
427
  const future = defineFeature("future", (r) => {
425
428
  r.entity("future-order", orderEntity);
426
429
  r.defineEvent("early", z.object({ x: z.number() }), {
430
+ piiFields: "none",
427
431
  version: 1,
428
432
  migrations: [{ fromVersion: 1, toVersion: 2, transform: (p) => p }],
429
433
  });
@@ -435,6 +439,7 @@ describe("upcaster: boot-time validation", () => {
435
439
  const gaps = defineFeature("gaps", (r) => {
436
440
  r.entity("gap-order", orderEntity);
437
441
  r.defineEvent("jumpy", z.object({ v: z.number() }), {
442
+ piiFields: "none",
438
443
  version: 4,
439
444
  migrations: [
440
445
  { fromVersion: 1, toVersion: 2, transform: (p) => p },
@@ -452,6 +457,7 @@ describe("upcaster: registrar input validation", () => {
452
457
  defineFeature("bigstep", (r) => {
453
458
  r.entity("bigstep-order", orderEntity);
454
459
  r.defineEvent("biz", z.object({ x: z.number() }), {
460
+ piiFields: "none",
455
461
  version: 3,
456
462
  migrations: [{ fromVersion: 1, toVersion: 3, transform: (p) => p }],
457
463
  });
@@ -464,6 +470,7 @@ describe("upcaster: registrar input validation", () => {
464
470
  defineFeature("dupestep", (r) => {
465
471
  r.entity("dup-order", orderEntity);
466
472
  r.defineEvent("dup", z.object({ x: z.number() }), {
473
+ piiFields: "none",
467
474
  version: 2,
468
475
  migrations: [
469
476
  { fromVersion: 1, toVersion: 2, transform: (p) => p },
@@ -478,7 +485,7 @@ describe("upcaster: registrar input validation", () => {
478
485
  expect(() =>
479
486
  defineFeature("badver", (r) => {
480
487
  r.entity("badver-order", orderEntity);
481
- r.defineEvent("neg", z.object({ x: z.number() }), { version: 0 });
488
+ r.defineEvent("neg", z.object({ x: z.number() }), { piiFields: "none", version: 0 });
482
489
  }),
483
490
  ).toThrow(/positive integer/);
484
491
  });
@@ -0,0 +1,206 @@
1
+ // framework#2585 — JobContext.writeAs.
2
+ //
3
+ // `hasAccess` has no system bypass, so before writeAs existed a job could
4
+ // only write as its own systemUser (roles: ["system"]) and every write
5
+ // handler gated on concrete roles was unreachable from a job: the dispatch
6
+ // came back access_denied and the only signal was a failed job. These tests
7
+ // pin both halves — ctx.write against a role-gated handler is still denied,
8
+ // and ctx.writeAs(actor) reaches the same handler and attributes the event
9
+ // to the actor rather than to SYSTEM.
10
+
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
+ import { z } from "zod";
13
+ import { ROLES } from "../../auth/roles";
14
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
15
+ import { asRawClient } from "../../db/query";
16
+ import { createRegistry, defineFeature, type SessionUser, type WriteResult } from "../../engine";
17
+ import { createWorkerEntrypoint } from "../../entrypoint";
18
+ import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
19
+ import { createEventConsumerStateTable } from "../../pipeline";
20
+ import { createTestRedis, type TestRedis } from "../../stack";
21
+ import { waitFor } from "../../testing";
22
+ import { createJobRunner } from "../job-runner";
23
+
24
+ const ADMIN_USER_ID = "11111111-1111-4111-8111-111111111111";
25
+
26
+ type ProbeOutcome = { readonly isSuccess: boolean; readonly errorCode: string | undefined };
27
+
28
+ const writeOutcomes: ProbeOutcome[] = [];
29
+ const writeAsOutcomes: ProbeOutcome[] = [];
30
+ const writeAsFailures: string[] = [];
31
+
32
+ function recordOutcome(sink: ProbeOutcome[], result: WriteResult): void {
33
+ sink.push({
34
+ isSuccess: result.isSuccess,
35
+ errorCode: result.isSuccess ? undefined : result.error.code,
36
+ });
37
+ }
38
+
39
+ const writeAsProbeFeature = defineFeature("writeAsProbe", (r) => {
40
+ const noted = r.defineEvent("adminNoted", z.object({ note: z.string() }), {
41
+ piiFields: "none",
42
+ version: 1,
43
+ });
44
+
45
+ r.writeHandler(
46
+ "adminNote",
47
+ z.object({ note: z.string() }),
48
+ async (event, ctx) => {
49
+ await ctx.unsafeAppendEvent({
50
+ aggregateId: crypto.randomUUID(),
51
+ aggregateType: "write-as-probe-note",
52
+ type: noted.name,
53
+ payload: { note: event.payload.note },
54
+ });
55
+ return { isSuccess: true as const, data: { note: event.payload.note } };
56
+ },
57
+ { access: { roles: [ROLES.TenantAdmin] } },
58
+ );
59
+
60
+ r.job("writeAsSystem", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
61
+ const result = await ctx.write("write-as-probe:write:admin-note", {
62
+ note: payload["note"] as string, // @cast-boundary dynamic-key
63
+ });
64
+ recordOutcome(writeOutcomes, result);
65
+ });
66
+
67
+ r.job("writeAsActor", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
68
+ const actor: SessionUser = {
69
+ id: payload["actorId"] as string, // @cast-boundary dynamic-key
70
+ tenantId: ctx.systemUser.tenantId,
71
+ roles: [ROLES.TenantAdmin],
72
+ };
73
+ try {
74
+ const result = await ctx.writeAs(actor, "write-as-probe:write:admin-note", {
75
+ note: payload["note"] as string, // @cast-boundary dynamic-key
76
+ });
77
+ recordOutcome(writeAsOutcomes, result);
78
+ } catch (error) {
79
+ writeAsFailures.push(error instanceof Error ? error.message : String(error));
80
+ throw error;
81
+ }
82
+ });
83
+ });
84
+
85
+ const JWT = "job-write-as-test-secret-must-be-32-chars!";
86
+
87
+ let testDb: BunTestDb;
88
+ let testRedis: TestRedis;
89
+
90
+ beforeAll(async () => {
91
+ [testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
92
+ await createEventsTable(testDb.db);
93
+ await createArchivedStreamsTable(testDb.db);
94
+ await createEventConsumerStateTable(testDb.db);
95
+ });
96
+
97
+ afterAll(async () => {
98
+ await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
99
+ });
100
+
101
+ function redisUrl(): string {
102
+ return `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
103
+ }
104
+
105
+ function uniquePrefix(label: string): string {
106
+ return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
107
+ }
108
+
109
+ async function notesFor(note: string): Promise<Array<{ created_by: string }>> {
110
+ const rows = await asRawClient(testDb.db).unsafe<{ created_by: string }>(
111
+ `SELECT created_by FROM kumiko_events
112
+ WHERE type = 'write-as-probe:event:admin-noted' AND payload->>'note' = $1`,
113
+ [note],
114
+ );
115
+ return [...rows];
116
+ }
117
+
118
+ describe("JobContext.writeAs reaches a role-gated write handler (framework#2585)", () => {
119
+ test("ctx.write is denied — the job's systemUser carries no TenantAdmin role", async () => {
120
+ writeOutcomes.length = 0;
121
+ const worker = createWorkerEntrypoint({
122
+ registry: createRegistry([writeAsProbeFeature]),
123
+ context: { db: testDb.db, redis: testRedis.redis },
124
+ jwtSecret: JWT,
125
+ redisUrl: redisUrl(),
126
+ queueNamePrefix: uniquePrefix("job-write-as-denied"),
127
+ });
128
+
129
+ await worker.start();
130
+ try {
131
+ await worker.jobRunner.dispatch("write-as-probe:job:write-as-system", {
132
+ note: "system attempt",
133
+ });
134
+
135
+ await waitFor(() => {
136
+ expect(writeOutcomes.length).toBe(1);
137
+ });
138
+ expect(writeOutcomes[0]?.isSuccess).toBe(false);
139
+ expect(writeOutcomes[0]?.errorCode).toBe("access_denied");
140
+ expect(await notesFor("system attempt")).toHaveLength(0);
141
+ } finally {
142
+ await worker.stop();
143
+ }
144
+ });
145
+
146
+ test("ctx.writeAs(actor) commits and attributes the event to the actor", async () => {
147
+ writeAsOutcomes.length = 0;
148
+ const worker = createWorkerEntrypoint({
149
+ registry: createRegistry([writeAsProbeFeature]),
150
+ context: { db: testDb.db, redis: testRedis.redis },
151
+ jwtSecret: JWT,
152
+ redisUrl: redisUrl(),
153
+ queueNamePrefix: uniquePrefix("job-write-as-allowed"),
154
+ });
155
+
156
+ await worker.start();
157
+ try {
158
+ await worker.jobRunner.dispatch("write-as-probe:job:write-as-actor", {
159
+ note: "actor attempt",
160
+ actorId: ADMIN_USER_ID,
161
+ });
162
+
163
+ await waitFor(() => {
164
+ expect(writeAsOutcomes.length).toBe(1);
165
+ });
166
+ expect(writeAsOutcomes[0]?.isSuccess).toBe(true);
167
+
168
+ const rows = await notesFor("actor attempt");
169
+ expect(rows).toHaveLength(1);
170
+ expect(rows[0]?.created_by).toBe(ADMIN_USER_ID);
171
+ } finally {
172
+ await worker.stop();
173
+ }
174
+ });
175
+ });
176
+
177
+ describe("JobContext.writeAs before attachDispatcher()", () => {
178
+ test("throws the same not-attached stub ctx.write does", async () => {
179
+ writeAsFailures.length = 0;
180
+ const runner = createJobRunner({
181
+ registry: createRegistry([writeAsProbeFeature]),
182
+ context: { db: testDb.db, redis: testRedis.redis },
183
+ redisUrl: redisUrl(),
184
+ consumerLane: "worker",
185
+ queueNamePrefix: uniquePrefix("job-write-as-bare"),
186
+ });
187
+
188
+ await runner.start();
189
+ try {
190
+ await runner.dispatch("write-as-probe:job:write-as-actor", {
191
+ note: "should never land",
192
+ actorId: ADMIN_USER_ID,
193
+ });
194
+
195
+ await waitFor(() => {
196
+ expect(writeAsFailures.length).toBe(1);
197
+ });
198
+ expect(writeAsFailures[0]).toContain(
199
+ "JobContext.writeAs called before dispatcher attached — call attachDispatcher() first",
200
+ );
201
+ expect(await notesFor("should never land")).toHaveLength(0);
202
+ } finally {
203
+ await runner.stop();
204
+ }
205
+ });
206
+ });
@@ -144,9 +144,9 @@ export type JobRunner = {
144
144
  payload: Record<string, unknown>,
145
145
  user?: SessionUser,
146
146
  ): Promise<void>;
147
- // Wires JobContext.write/queryAs to the real dispatcher — called once at
148
- // boot, after the dispatcher exists (job-runner construction happens
149
- // before it). Before this runs, JobContext.write/queryAs throw.
147
+ // Wires JobContext.write/writeAs/queryAs to the real dispatcher — called
148
+ // once at boot, after the dispatcher exists (job-runner construction happens
149
+ // before it). Before this runs, JobContext.write/writeAs/queryAs throw.
150
150
  attachDispatcher(ref: DispatchWriteRef): void;
151
151
  };
152
152
 
@@ -362,7 +362,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
362
362
  // at job-execution time (after start()), so it is always defined by then.
363
363
  let selfRunner: JobRunner | undefined;
364
364
  // Set by attachDispatcher() once the boot-level dispatcher exists.
365
- // JobContext.write/queryAs throw until this is set — see JobContext doc.
365
+ // JobContext.write/writeAs/queryAs throw until this is set — see JobContext doc.
366
366
  let dispatchWriteRef: DispatchWriteRef | undefined;
367
367
 
368
368
  // Counts active + waiting jobs with this name for this tenant across
@@ -581,6 +581,14 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
581
581
  }
582
582
  return dispatchWriteRef.write(jobSystemUser, qn, payload);
583
583
  },
584
+ writeAs: (user: SessionUser, qn: string, payload: unknown) => {
585
+ if (!dispatchWriteRef) {
586
+ throw new Error(
587
+ "JobContext.writeAs called before dispatcher attached — call attachDispatcher() first",
588
+ );
589
+ }
590
+ return dispatchWriteRef.write(user, qn, payload);
591
+ },
584
592
  queryAs: (user: SessionUser, qn: string, payload: unknown) => {
585
593
  if (!dispatchWriteRef) {
586
594
  throw new Error(
@@ -32,7 +32,9 @@ const itemTable = buildEntityTable("arch-item", itemEntity);
32
32
  const archFeature = defineFeature("archtest", (r) => {
33
33
  r.entity("arch-item", itemEntity);
34
34
 
35
- const labelChanged = r.defineEvent("label-changed", z.object({ label: z.string() }));
35
+ const labelChanged = r.defineEvent("label-changed", z.object({ label: z.string() }), {
36
+ piiFields: "none",
37
+ });
36
38
 
37
39
  const executor = createEventStoreExecutor(itemTable, itemEntity, {
38
40
  entityName: "arch-item",
@@ -54,7 +54,9 @@ const applyObservations: ReqCtxSnapshot[] = [];
54
54
  const causationFeature = defineFeature("causation", (r) => {
55
55
  r.entity("causation-order", orderEntity);
56
56
 
57
- const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }));
57
+ const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }), {
58
+ piiFields: "none",
59
+ });
58
60
 
59
61
  const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
60
62
  entityName: "causation-order",
@@ -58,7 +58,9 @@ const shippingFeature = defineFeature("shipping", (r) => {
58
58
  r.entity("domain-shipment", shipmentEntity);
59
59
 
60
60
  // Domain event. Qualified name is "shipping:event:billed".
61
- const shipmentBilled = r.defineEvent("billed", z.object({ cost: z.number() }));
61
+ const shipmentBilled = r.defineEvent("billed", z.object({ cost: z.number() }), {
62
+ piiFields: "none",
63
+ });
62
64
 
63
65
  r.projection({
64
66
  name: "shipment-billing",
@@ -34,14 +34,18 @@ let foreignEventName = "";
34
34
  // emit this from one of its handlers — Sprint-E cross-feature-ownership
35
35
  // guard must reject it at the append site.
36
36
  const neighborFeature = defineFeature("neighbor", (r) => {
37
- const foreign = r.defineEvent("neighbor.signal", z.object({ userId: z.uuid() }));
37
+ const foreign = r.defineEvent("neighbor.signal", z.object({ userId: z.uuid() }), {
38
+ piiFields: "none",
39
+ });
38
40
  foreignEventName = foreign.name;
39
41
  });
40
42
 
41
43
  const emitterFeature = defineFeature("emitter", (r) => {
42
44
  r.entity("widget", sharedWidgetEntity);
43
45
 
44
- const welcome = r.defineEvent("user.welcomed", z.object({ userId: z.uuid(), email: z.email() }));
46
+ const welcome = r.defineEvent("user.welcomed", z.object({ userId: z.uuid(), email: z.email() }), {
47
+ piiFields: "none",
48
+ });
45
49
  welcomeEventName = welcome.name;
46
50
 
47
51
  r.writeHandler(
@@ -31,8 +31,12 @@ const cartTable = buildEntityTable("f4wCart", cartEntity);
31
31
  const cartFeature = defineFeature("f4w", (r) => {
32
32
  r.entity("f4wCart", cartEntity);
33
33
 
34
- const itemAdded = r.defineEvent("itemAdded", z.object({ sku: z.string(), qty: z.number() }));
35
- const checkedOut = r.defineEvent("checkedOut", z.object({ totalCents: z.number() }));
34
+ const itemAdded = r.defineEvent("itemAdded", z.object({ sku: z.string(), qty: z.number() }), {
35
+ piiFields: "none",
36
+ });
37
+ const checkedOut = r.defineEvent("checkedOut", z.object({ totalCents: z.number() }), {
38
+ piiFields: "none",
39
+ });
36
40
 
37
41
  const cartExecutor = createEventStoreExecutor(cartTable, cartEntity, {
38
42
  entityName: "f4wCart",
@@ -30,7 +30,9 @@ const FILE_REF_CREATED = entityEventName("fileRef", "created");
30
30
  const processedItems: Array<{ readonly fileRefId: string }> = [];
31
31
 
32
32
  const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
33
- r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }));
33
+ r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }), {
34
+ piiFields: "none",
35
+ });
34
36
 
35
37
  // Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
36
38
  // to fileRef.created, appends a NEW event via unsafeAppendEvent — no
@@ -46,6 +46,7 @@ const asOfFeature = defineFeature("asoftest", (r) => {
46
46
  "approved",
47
47
  z.object({ amount: z.number().int(), approvedBy: z.string() }),
48
48
  {
49
+ piiFields: "none",
49
50
  version: 2,
50
51
  migrations: [
51
52
  {
@@ -42,9 +42,11 @@ const confirmLoadCounts: number[] = [];
42
42
  const mmhFeature = defineFeature("mmh", (r) => {
43
43
  r.entity("mmh-order", orderEntity);
44
44
 
45
- const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }));
46
- const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() }));
47
- const shipped = r.defineEvent("shipped", z.object({ orderId: z.uuid() }));
45
+ const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }), { piiFields: "none" });
46
+ const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() }), {
47
+ piiFields: "none",
48
+ });
49
+ const shipped = r.defineEvent("shipped", z.object({ orderId: z.uuid() }), { piiFields: "none" });
48
50
 
49
51
  const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
50
52
  entityName: "mmh-order",
@@ -72,14 +72,17 @@ const feature = defineFeature("mspreb", (r) => {
72
72
  const invoiceBilled = r.defineEvent(
73
73
  "invoice-billed",
74
74
  z.object({ customer: z.uuid(), cents: z.number().int() }),
75
+ { piiFields: "none" },
75
76
  );
76
77
  const paymentReceived = r.defineEvent(
77
78
  "payment-received",
78
79
  z.object({ customer: z.uuid(), cents: z.number().int() }),
80
+ { piiFields: "none" },
79
81
  );
80
82
  const escalationTriggered = r.defineEvent(
81
83
  "escalation-triggered",
82
84
  z.object({ customer: z.uuid() }),
85
+ { piiFields: "none" },
83
86
  );
84
87
 
85
88
  // 1) Main rebuildable MSP — table materialized from two event types.
@@ -56,10 +56,12 @@ const mspFeature = defineFeature("msptest", (r) => {
56
56
  const shipmentBilled = r.defineEvent(
57
57
  "shipment-billed",
58
58
  z.object({ customer: z.uuid(), cents: z.number().int() }),
59
+ { piiFields: "none" },
59
60
  );
60
61
  const refundIssued = r.defineEvent(
61
62
  "refund-issued",
62
63
  z.object({ customer: z.uuid(), cents: z.number().int() }),
64
+ { piiFields: "none" },
63
65
  );
64
66
 
65
67
  r.multiStreamProjection({
@@ -110,7 +110,9 @@ const MSP_EVENT_SHORT = "poison-noted";
110
110
  const feature = defineFeature("poisontest", (r) => {
111
111
  r.entity("poison-item", itemEntity);
112
112
  r.projection(poisonProjection);
113
- const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() }));
113
+ const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() }), {
114
+ piiFields: "none",
115
+ });
114
116
  r.multiStreamProjection({
115
117
  name: "poison-msp-counter",
116
118
  table: mspCounterTable,