@cosmicdrift/kumiko-framework 0.2.3 → 0.4.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 (166) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/package.json +124 -39
  3. package/src/__tests__/full-stack.integration.ts +2 -2
  4. package/src/api/auth-routes.ts +5 -5
  5. package/src/api/jwt.ts +2 -2
  6. package/src/api/route-registrars.ts +1 -1
  7. package/src/api/routes.ts +3 -3
  8. package/src/api/server.ts +6 -7
  9. package/src/compliance/profiles.ts +8 -8
  10. package/src/db/assert-exists-in.ts +2 -2
  11. package/src/db/cursor.ts +3 -3
  12. package/src/db/event-store-executor.ts +19 -13
  13. package/src/db/located-timestamp.ts +1 -1
  14. package/src/db/money.ts +12 -2
  15. package/src/db/pg-error.ts +1 -1
  16. package/src/db/row-helpers.ts +1 -1
  17. package/src/db/table-builder.ts +3 -5
  18. package/src/db/tenant-db.ts +9 -9
  19. package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
  20. package/src/engine/__tests__/build-target.test.ts +135 -0
  21. package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
  22. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  23. package/src/engine/__tests__/event-helpers.test.ts +4 -4
  24. package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
  25. package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
  26. package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
  27. package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
  28. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
  29. package/src/engine/__tests__/raw-table.test.ts +2 -2
  30. package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
  31. package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
  32. package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
  33. package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
  34. package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
  35. package/src/engine/__tests__/steps-read.test.ts +142 -0
  36. package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
  37. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
  38. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
  39. package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
  40. package/src/engine/__tests__/steps-workflow.test.ts +198 -0
  41. package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
  42. package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
  43. package/src/engine/boot-validator/api-ext.ts +77 -0
  44. package/src/engine/boot-validator/config-deps.ts +163 -0
  45. package/src/engine/boot-validator/entity-handler.ts +466 -0
  46. package/src/engine/boot-validator/index.ts +159 -0
  47. package/src/engine/boot-validator/ownership.ts +198 -0
  48. package/src/engine/boot-validator/pii-retention.ts +155 -0
  49. package/src/engine/boot-validator/screens-nav.ts +624 -0
  50. package/src/engine/boot-validator.ts +1 -1804
  51. package/src/engine/build-app-schema.ts +1 -1
  52. package/src/engine/build-target.ts +99 -0
  53. package/src/engine/codemod/index.ts +15 -0
  54. package/src/engine/codemod/pipeline-codemod.ts +641 -0
  55. package/src/engine/config-helpers.ts +9 -19
  56. package/src/engine/constants.ts +1 -1
  57. package/src/engine/define-feature.ts +88 -9
  58. package/src/engine/define-handler.ts +89 -3
  59. package/src/engine/define-roles.ts +2 -2
  60. package/src/engine/define-step.ts +28 -0
  61. package/src/engine/define-workflow.ts +110 -0
  62. package/src/engine/entity-handlers.ts +10 -9
  63. package/src/engine/event-helpers.ts +4 -4
  64. package/src/engine/factories.ts +12 -12
  65. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
  66. package/src/engine/feature-ast/extractors/index.ts +74 -0
  67. package/src/engine/feature-ast/extractors/round1.ts +110 -0
  68. package/src/engine/feature-ast/extractors/round2.ts +253 -0
  69. package/src/engine/feature-ast/extractors/round3.ts +471 -0
  70. package/src/engine/feature-ast/extractors/round4.ts +1365 -0
  71. package/src/engine/feature-ast/extractors/round5.ts +72 -0
  72. package/src/engine/feature-ast/extractors/round6.ts +66 -0
  73. package/src/engine/feature-ast/extractors/shared.ts +177 -0
  74. package/src/engine/feature-ast/parse.ts +7 -0
  75. package/src/engine/feature-ast/patch.ts +9 -1
  76. package/src/engine/feature-ast/patcher.ts +10 -3
  77. package/src/engine/feature-ast/patterns.ts +49 -1
  78. package/src/engine/feature-ast/render.ts +17 -1
  79. package/src/engine/index.ts +44 -2
  80. package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
  81. package/src/engine/pattern-library/library.ts +42 -2
  82. package/src/engine/pipeline.ts +88 -0
  83. package/src/engine/projection-helpers.ts +1 -1
  84. package/src/engine/read-claim.ts +1 -1
  85. package/src/engine/registry.ts +30 -2
  86. package/src/engine/resolve-config-or-param.ts +4 -0
  87. package/src/engine/run-pipeline.ts +162 -0
  88. package/src/engine/schema-builder.ts +2 -4
  89. package/src/engine/state-machine.ts +1 -1
  90. package/src/engine/steps/_drizzle-boundary.ts +19 -0
  91. package/src/engine/steps/_duration-utils.ts +33 -0
  92. package/src/engine/steps/_no-return-guard.ts +21 -0
  93. package/src/engine/steps/_resolver-utils.ts +42 -0
  94. package/src/engine/steps/_step-dispatch-constants.ts +38 -0
  95. package/src/engine/steps/aggregate-append-event.ts +56 -0
  96. package/src/engine/steps/aggregate-create.ts +56 -0
  97. package/src/engine/steps/aggregate-update.ts +68 -0
  98. package/src/engine/steps/branch.ts +84 -0
  99. package/src/engine/steps/call-feature.ts +49 -0
  100. package/src/engine/steps/compute.ts +41 -0
  101. package/src/engine/steps/for-each.ts +111 -0
  102. package/src/engine/steps/mail-send.ts +44 -0
  103. package/src/engine/steps/read-find-many.ts +51 -0
  104. package/src/engine/steps/read-find-one.ts +58 -0
  105. package/src/engine/steps/retry.ts +87 -0
  106. package/src/engine/steps/return.ts +34 -0
  107. package/src/engine/steps/unsafe-projection-delete.ts +46 -0
  108. package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
  109. package/src/engine/steps/wait-for-event.ts +71 -0
  110. package/src/engine/steps/wait.ts +69 -0
  111. package/src/engine/steps/webhook-send.ts +71 -0
  112. package/src/engine/system-user.ts +1 -1
  113. package/src/engine/types/feature.ts +93 -1
  114. package/src/engine/types/handlers.ts +18 -10
  115. package/src/engine/types/index.ts +11 -1
  116. package/src/engine/types/step.ts +334 -0
  117. package/src/engine/types/target-ref.ts +21 -0
  118. package/src/engine/types/tree-node.ts +132 -0
  119. package/src/engine/types/workspace.ts +7 -0
  120. package/src/engine/validate-projection-allowlist.ts +161 -0
  121. package/src/event-store/snapshot.ts +1 -1
  122. package/src/event-store/upcaster-dead-letter.ts +1 -1
  123. package/src/event-store/upcaster.ts +1 -1
  124. package/src/files/file-routes.ts +1 -1
  125. package/src/files/types.ts +2 -2
  126. package/src/jobs/job-runner.ts +10 -10
  127. package/src/lifecycle/lifecycle.ts +0 -3
  128. package/src/logging/index.ts +1 -0
  129. package/src/logging/pino-logger.ts +11 -7
  130. package/src/logging/utils.ts +24 -0
  131. package/src/observability/prometheus-meter.ts +7 -5
  132. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  133. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  134. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  135. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  136. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  137. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  138. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  139. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  140. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  141. package/src/pipeline/append-event-core.ts +22 -6
  142. package/src/pipeline/dispatcher-utils.ts +188 -0
  143. package/src/pipeline/dispatcher.ts +63 -283
  144. package/src/pipeline/distributed-lock.ts +1 -1
  145. package/src/pipeline/entity-cache.ts +2 -2
  146. package/src/pipeline/event-consumer-state.ts +0 -13
  147. package/src/pipeline/event-dispatcher.ts +4 -4
  148. package/src/pipeline/index.ts +0 -2
  149. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  150. package/src/pipeline/msp-rebuild.ts +5 -5
  151. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  152. package/src/pipeline/projection-rebuild.ts +2 -2
  153. package/src/pipeline/projection-state.ts +0 -12
  154. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  155. package/src/rate-limit/resolver.ts +1 -1
  156. package/src/search/in-memory-adapter.ts +1 -1
  157. package/src/search/meilisearch-adapter.ts +3 -3
  158. package/src/search/types.ts +1 -1
  159. package/src/secrets/leak-guard.ts +2 -2
  160. package/src/stack/request-helper.ts +9 -5
  161. package/src/stack/test-stack.ts +1 -1
  162. package/src/testing/handler-context.ts +4 -4
  163. package/src/testing/http-cookies.ts +1 -1
  164. package/src/time/tz-context.ts +1 -2
  165. package/src/ui-types/index.ts +4 -0
  166. package/src/engine/feature-ast/extractors.ts +0 -2602
@@ -115,5 +115,5 @@ export async function listDeadLetters(
115
115
  .from(upcasterDeadLetterTable)
116
116
  .orderBy(desc(upcasterDeadLetterTable.createdAt))
117
117
  .limit(limit);
118
- return rows as readonly DeadLetterRow[];
118
+ return rows as readonly DeadLetterRow[]; // @cast-boundary db-row
119
119
  }
@@ -88,7 +88,7 @@ async function upcastStoredEventWithPolicy(
88
88
  if (!info) return event;
89
89
  if (event.eventVersion >= info.currentVersion) return event;
90
90
 
91
- let payload = event.payload as unknown;
91
+ let payload = event.payload as unknown; // @cast-boundary engine-payload
92
92
  let v = event.eventVersion;
93
93
  const startVersion = event.eventVersion;
94
94
  while (v < info.currentVersion) {
@@ -346,7 +346,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
346
346
  .select()
347
347
  .from(fileRefsTable)
348
348
  .where(and(eq(fileRefsTable.id, id), eq(fileRefsTable.tenantId, tenantId)));
349
- return (row as FileRef | undefined) ?? null;
349
+ return (row as FileRef | undefined) ?? null; // @cast-boundary db-row
350
350
  }
351
351
 
352
352
  return api;
@@ -1,4 +1,4 @@
1
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { TenantId } from "../engine/types/identifiers";
2
2
 
3
3
  export type FileMetadata = {
4
4
  readonly fileName: string;
@@ -99,7 +99,7 @@ const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
99
99
  csv: ["text/csv", "application/csv", "text/plain"],
100
100
  json: ["application/json", "text/json"],
101
101
  md: ["text/markdown", "text/plain"],
102
- };
102
+ } satisfies Record<string, readonly string[]>;
103
103
 
104
104
  export function validateFile(
105
105
  metadata: FileMetadata,
@@ -104,7 +104,7 @@ const TRACE_CONTEXT_KEY = "_traceContext";
104
104
  function readTraceContext(data: Record<string, unknown>): SerializedTraceContext | undefined {
105
105
  const raw = data[TRACE_CONTEXT_KEY];
106
106
  if (!raw || typeof raw !== "object") return undefined;
107
- const ctx = raw as Partial<SerializedTraceContext>;
107
+ const ctx = raw as Partial<SerializedTraceContext>; // @cast-boundary engine-payload
108
108
  if (!ctx.traceId || !ctx.spanId) return undefined;
109
109
  return { traceId: ctx.traceId, spanId: ctx.spanId };
110
110
  }
@@ -198,7 +198,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
198
198
  for (const list of results) {
199
199
  for (const j of list) {
200
200
  if (j.name !== jobName) continue;
201
- const t = (j.data as { _tenantId?: string } | undefined)?._tenantId;
201
+ const t = (j.data as { _tenantId?: string } | undefined)?._tenantId; // @cast-boundary dynamic-key
202
202
  if (t === tenantId) {
203
203
  count += 1;
204
204
  if (count >= max) return true;
@@ -274,8 +274,8 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
274
274
  // without peeking at BullMQ internals.
275
275
  const rawData = bullJob.data as DbRow;
276
276
  const meta: JobMeta = {
277
- triggeredById: rawData["_triggeredById"] as string | undefined,
278
- payload: rawData["_payload"] as string | undefined,
277
+ triggeredById: rawData["_triggeredById"] as string | undefined, // @cast-boundary dynamic-key
278
+ payload: rawData["_payload"] as string | undefined, // @cast-boundary dynamic-key
279
279
  attempt: bullJob.attemptsMade + 1,
280
280
  };
281
281
 
@@ -287,16 +287,16 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
287
287
 
288
288
  // Determine tenantId and triggeredBy from meta
289
289
  const tenantId =
290
- (rawData["_tenantId"] as string | undefined) ??
291
- (payload["tenantId"] as string | undefined) ??
290
+ (rawData["_tenantId"] as string | undefined) ?? // @cast-boundary dynamic-key
291
+ (payload["tenantId"] as string | undefined) ?? // @cast-boundary dynamic-key
292
292
  SYSTEM_TENANT_ID;
293
- const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null;
293
+ const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null; // @cast-boundary dynamic-key
294
294
 
295
295
  // _triggerName aus rawData übernehmen falls gesetzt — handleEvent
296
296
  // packt das beim Multi-Trigger-Dispatch rein (siehe unten). Über
297
297
  // jobContext.triggerName freigegeben damit der Handler nicht selbst
298
298
  // im rohen Payload kramen muss.
299
- const triggerName = rawData["_triggerName"] as string | undefined;
299
+ const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
300
300
  const jobContext: AppContext = {
301
301
  ...context,
302
302
  systemUser: createSystemUser(tenantId),
@@ -317,7 +317,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
317
317
  // so event writes during this job stamp the same correlation as the
318
318
  // request that scheduled it. Cron/boot jobs (no scheduler) start fresh
319
319
  // — correlationId = new requestId, no parent causation.
320
- const inheritedCorrelationId = (rawData["_correlationId"] as string | undefined) ?? undefined;
320
+ const inheritedCorrelationId = (rawData["_correlationId"] as string | undefined) ?? undefined; // @cast-boundary dynamic-key
321
321
  const jobRequestId = requestContext.generateId();
322
322
  const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
323
323
 
@@ -460,7 +460,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
460
460
  // dispatch). Fan-out children of perTenant jobs land here on their
461
461
  // recursive queue.add and DO carry _tenantId.
462
462
  if (jobDef.maxPerTenant !== undefined) {
463
- const tenantId = (payload as { _tenantId?: string } | undefined)?._tenantId;
463
+ const tenantId = (payload as { _tenantId?: string } | undefined)?._tenantId; // @cast-boundary dynamic-key
464
464
  if (
465
465
  tenantId !== undefined &&
466
466
  (await isOverPerTenantLimit(jobName, tenantId, jobDef.maxPerTenant))
@@ -146,9 +146,6 @@ export function createLifecycle(opts: LifecycleOptions = {}): Lifecycle {
146
146
  };
147
147
  }
148
148
 
149
- // Builds a single error-log closure once per lifecycle instance. Structured
150
- // logger wins when present; otherwise plain stderr via console.error so we
151
- // never eat a failure silently.
152
149
  function makeErrorLogger(
153
150
  logger: Pick<Logger, "error"> | undefined,
154
151
  ): (msg: string, err: unknown) => void {
@@ -1,3 +1,4 @@
1
1
  export type { LoggerOptions } from "./pino-logger";
2
2
  export { createLogger } from "./pino-logger";
3
3
  export type { Logger } from "./types";
4
+ export { createFallbackLogger } from "./utils";
@@ -11,7 +11,7 @@ export type LoggerOptions = {
11
11
  };
12
12
 
13
13
  export function createLogger(options: LoggerOptions = {}): Logger {
14
- const level = options.level ?? (process.env["LOG_LEVEL"] as LoggerOptions["level"]) ?? "info";
14
+ const level = options.level ?? (process.env["LOG_LEVEL"] as LoggerOptions["level"]) ?? "info"; // @cast-boundary dynamic-key
15
15
  const pretty = options.pretty ?? process.env["LOG_FORMAT"] === "pretty";
16
16
 
17
17
  const pinoConfig = {
@@ -43,22 +43,26 @@ function wrapPino(p: pino.Logger): Logger {
43
43
  return {
44
44
  info(msg, data) {
45
45
  const merged = mergeTraceFields(data);
46
- merged ? p.info(merged, msg) : p.info(msg);
46
+ if (merged) p.info(merged, msg);
47
+ else p.info(msg);
47
48
  },
48
49
  warn(msg, data) {
49
50
  const merged = mergeTraceFields(data);
50
- merged ? p.warn(merged, msg) : p.warn(msg);
51
+ if (merged) p.warn(merged, msg);
52
+ else p.warn(msg);
51
53
  },
52
54
  error(msg, data) {
53
55
  const merged = mergeTraceFields(data);
54
- merged ? p.error(merged, msg) : p.error(msg);
56
+ if (merged) p.error(merged, msg);
57
+ else p.error(msg);
55
58
  },
56
59
  debug(msg, data) {
57
60
  const merged = mergeTraceFields(data);
58
- merged ? p.debug(merged, msg) : p.debug(msg);
61
+ if (merged) p.debug(merged, msg);
62
+ else p.debug(msg);
59
63
  },
60
- child(context) {
61
- return wrapPino(p.child(context));
64
+ child(ctx) {
65
+ return wrapPino(p.child(ctx));
62
66
  },
63
67
  };
64
68
  }
@@ -0,0 +1,24 @@
1
+ import type { Logger } from "./types";
2
+
3
+ type FallbackLogger = {
4
+ error(msg: string, data?: Record<string, unknown>): void;
5
+ };
6
+
7
+ export function createFallbackLogger(
8
+ namespace: string,
9
+ logger?: Pick<Logger, "error"> | undefined,
10
+ ): FallbackLogger {
11
+ if (logger) {
12
+ return {
13
+ error(msg, data) {
14
+ logger.error(`[${namespace}] ${msg}`, data);
15
+ },
16
+ };
17
+ }
18
+ return {
19
+ error(msg, data) {
20
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
21
+ console.error(`[${namespace}] ${msg}:`, data);
22
+ },
23
+ };
24
+ }
@@ -244,21 +244,23 @@ export function serializeOpenMetrics(meter: PrometheusMeter): string {
244
244
  for (const name of names) {
245
245
  const entry = snap.get(name);
246
246
  if (!entry) continue;
247
- const { def, slots } = entry;
247
+ const { def } = entry;
248
248
  if (def.description) lines.push(`# HELP ${name} ${def.description}`);
249
249
  lines.push(`# TYPE ${name} ${def.type}`);
250
250
 
251
- // @cast-boundary engine-bridge — slots union narrows by def.type
252
251
  if (def.type === "counter") {
253
- for (const s of slots as CounterState[]) {
252
+ const counterSlots = entry.slots as CounterState[]; // @cast-boundary engine-bridge
253
+ for (const s of counterSlots) {
254
254
  lines.push(`${name}${renderLabels(s.labels)} ${s.value}`);
255
255
  }
256
256
  } else if (def.type === "gauge") {
257
- for (const s of slots as GaugeState[]) {
257
+ const gaugeSlots = entry.slots as GaugeState[]; // @cast-boundary engine-bridge
258
+ for (const s of gaugeSlots) {
258
259
  lines.push(`${name}${renderLabels(s.labels)} ${s.value}`);
259
260
  }
260
261
  } else {
261
- for (const s of slots as HistogramState[]) {
262
+ const histSlots = entry.slots as HistogramState[]; // @cast-boundary engine-bridge
263
+ for (const s of histSlots) {
262
264
  // Cumulative bucket counts + +Inf terminator + sum/count suffixes.
263
265
  let cumulative = 0;
264
266
  for (let i = 0; i < s.boundaries.length; i++) {
@@ -49,7 +49,7 @@ const archFeature = defineFeature("archtest", (r) => {
49
49
  "item:relabel",
50
50
  z.object({ id: z.uuid(), label: z.string() }),
51
51
  async (event, ctx) => {
52
- await ctx.appendEventUnsafe({
52
+ await ctx.unsafeAppendEvent({
53
53
  aggregateId: event.payload.id,
54
54
  aggregateType: "arch-item",
55
55
  type: labelChanged.name,
@@ -65,7 +65,7 @@ const causationFeature = defineFeature("causation", (r) => {
65
65
  async (event, ctx) => {
66
66
  const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
67
67
  if (!created.isSuccess) return created;
68
- await ctx.appendEventUnsafe({
68
+ await ctx.unsafeAppendEvent({
69
69
  aggregateId: String(created.data.id),
70
70
  aggregateType: "causation-order",
71
71
  type: placed.name,
@@ -105,7 +105,7 @@ const shippingFeature = defineFeature("shipping", (r) => {
105
105
  "shipment:bill",
106
106
  z.object({ id: z.uuid(), cost: z.number() }),
107
107
  async (event, ctx) => {
108
- await ctx.appendEventUnsafe({
108
+ await ctx.unsafeAppendEvent({
109
109
  aggregateId: event.payload.id,
110
110
  aggregateType: "domain-shipment",
111
111
  type: shipmentBilled.name,
@@ -137,7 +137,7 @@ const shippingFeature = defineFeature("shipping", (r) => {
137
137
  "shipment:bill-unregistered",
138
138
  z.object({ id: z.uuid() }),
139
139
  async (event, ctx) => {
140
- await ctx.appendEventUnsafe({
140
+ await ctx.unsafeAppendEvent({
141
141
  aggregateId: event.payload.id,
142
142
  aggregateType: "domain-shipment",
143
143
  type: "shipping:event:ghost", // never defined via r.defineEvent
@@ -152,7 +152,7 @@ const shippingFeature = defineFeature("shipping", (r) => {
152
152
  "shipment:bill-bad-payload",
153
153
  z.object({ id: z.uuid() }),
154
154
  async (event, ctx) => {
155
- await ctx.appendEventUnsafe({
155
+ await ctx.unsafeAppendEvent({
156
156
  aggregateId: event.payload.id,
157
157
  aggregateType: "domain-shipment",
158
158
  type: shipmentBilled.name,
@@ -49,7 +49,7 @@ const emitterFeature = defineFeature("emitter", (r) => {
49
49
  "emit:valid",
50
50
  z.object({ userId: z.uuid(), email: z.email() }),
51
51
  async (cmd, ctx) => {
52
- await ctx.appendEventUnsafe({
52
+ await ctx.unsafeAppendEvent({
53
53
  aggregateId: cmd.payload.userId,
54
54
  aggregateType: "user",
55
55
  type: welcome.name,
@@ -66,7 +66,7 @@ const emitterFeature = defineFeature("emitter", (r) => {
66
66
  async (cmd, ctx) => {
67
67
  // Deliberately NOT passing welcome.name — "emitter:event:not-registered"
68
68
  // was never registered. ctx.appendEvent must reject at the append site.
69
- await ctx.appendEventUnsafe({
69
+ await ctx.unsafeAppendEvent({
70
70
  aggregateId: cmd.payload.userId,
71
71
  aggregateType: "user",
72
72
  type: "emitter:event:not-registered",
@@ -82,7 +82,7 @@ const emitterFeature = defineFeature("emitter", (r) => {
82
82
  z.object({ userId: z.uuid() }),
83
83
  async (cmd, ctx) => {
84
84
  // userId is correct but email is missing / not an email string.
85
- await ctx.appendEventUnsafe({
85
+ await ctx.unsafeAppendEvent({
86
86
  aggregateId: cmd.payload.userId,
87
87
  aggregateType: "user",
88
88
  type: welcome.name,
@@ -100,7 +100,7 @@ const emitterFeature = defineFeature("emitter", (r) => {
100
100
  // "neighbor:event:neighbor-signal" is owned by the neighbor feature.
101
101
  // The ownership guard in appendDomainEventCore must reject this append
102
102
  // at emit-site — cross-feature emission silently breaks encapsulation.
103
- await ctx.appendEventUnsafe({
103
+ await ctx.unsafeAppendEvent({
104
104
  aggregateId: cmd.payload.userId,
105
105
  aggregateType: "user",
106
106
  type: foreignEventName,
@@ -66,7 +66,7 @@ const asOfFeature = defineFeature("asoftest", (r) => {
66
66
  "invoice:approve",
67
67
  z.object({ id: z.uuid(), amount: z.number().int(), approvedBy: z.string() }),
68
68
  async (event, ctx) => {
69
- await ctx.appendEventUnsafe({
69
+ await ctx.unsafeAppendEvent({
70
70
  aggregateId: event.payload.id,
71
71
  aggregateType: "asof-invoice",
72
72
  type: approved.name,
@@ -55,7 +55,7 @@ const mmhFeature = defineFeature("mmh", (r) => {
55
55
  async (event, ctx) => {
56
56
  const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
57
57
  if (!created.isSuccess) return created;
58
- await ctx.appendEventUnsafe({
58
+ await ctx.unsafeAppendEvent({
59
59
  aggregateId: String(created.data.id),
60
60
  aggregateType: "mmh-order",
61
61
  type: placed.name,
@@ -75,7 +75,7 @@ const mmhFeature = defineFeature("mmh", (r) => {
75
75
  if (!ctx) throw new Error("MSP-apply ctx missing — regression of C.2b wiring");
76
76
  const history = await ctx.loadAggregate(event.aggregateId);
77
77
  confirmLoadCounts.push(history.length);
78
- await ctx.appendEventUnsafe({
78
+ await ctx.unsafeAppendEvent({
79
79
  aggregateId: event.aggregateId,
80
80
  aggregateType: "mmh-order",
81
81
  type: confirmed.name,
@@ -91,7 +91,7 @@ const mmhFeature = defineFeature("mmh", (r) => {
91
91
  apply: {
92
92
  [confirmed.name]: async (event, _tx, ctx) => {
93
93
  if (!ctx) throw new Error("MSP-apply ctx missing — regression of C.2b wiring");
94
- await ctx.appendEventUnsafe({
94
+ await ctx.unsafeAppendEvent({
95
95
  aggregateId: event.aggregateId,
96
96
  aggregateType: "mmh-order",
97
97
  type: shipped.name,
@@ -139,7 +139,7 @@ const feature = defineFeature("mspreb", (r) => {
139
139
  apply: {
140
140
  [invoiceBilled.name]: async (event, _tx, ctx) => {
141
141
  const p = event.payload as { customer: string };
142
- await ctx.appendEventUnsafe({
142
+ await ctx.unsafeAppendEvent({
143
143
  aggregateId: p.customer,
144
144
  aggregateType: "msp-reb-invoice",
145
145
  type: escalationTriggered.name,
@@ -166,7 +166,7 @@ const feature = defineFeature("mspreb", (r) => {
166
166
  ctx.db,
167
167
  );
168
168
  if (!res.isSuccess) return res;
169
- await ctx.appendEventUnsafe({
169
+ await ctx.unsafeAppendEvent({
170
170
  aggregateId: String(res.data.id),
171
171
  aggregateType: "msp-reb-invoice",
172
172
  type: invoiceBilled.name,
@@ -187,7 +187,7 @@ const feature = defineFeature("mspreb", (r) => {
187
187
  ctx.db,
188
188
  );
189
189
  if (!res.isSuccess) return res;
190
- await ctx.appendEventUnsafe({
190
+ await ctx.unsafeAppendEvent({
191
191
  aggregateId: String(res.data.id),
192
192
  aggregateType: "msp-reb-payment",
193
193
  type: paymentReceived.name,
@@ -124,7 +124,7 @@ const mspFeature = defineFeature("msptest", (r) => {
124
124
  ctx.db,
125
125
  );
126
126
  if (!res.isSuccess) return res;
127
- await ctx.appendEventUnsafe({
127
+ await ctx.unsafeAppendEvent({
128
128
  aggregateId: String(res.data.id),
129
129
  aggregateType: "msp-shipment",
130
130
  type: shipmentBilled.name,
@@ -145,7 +145,7 @@ const mspFeature = defineFeature("msptest", (r) => {
145
145
  ctx.db,
146
146
  );
147
147
  if (!res.isSuccess) return res;
148
- await ctx.appendEventUnsafe({
148
+ await ctx.unsafeAppendEvent({
149
149
  aggregateId: String(res.data.id),
150
150
  aggregateType: "msp-refund",
151
151
  type: refundIssued.name,
@@ -96,10 +96,10 @@ const qpFeature = defineFeature("qp", (r) => {
96
96
 
97
97
  r.queryHandler(
98
98
  "widget:list-system",
99
- z.object({ allTenants: z.boolean().optional() }),
99
+ z.object({ unsafeAllTenants: z.boolean().optional() }),
100
100
  async (query, ctx) =>
101
101
  ctx.queryProjection("qp:projection:widget-audit", {
102
- allTenants: query.payload.allTenants ?? false,
102
+ unsafeAllTenants: query.payload.unsafeAllTenants ?? false,
103
103
  }),
104
104
  { access: { openToAll: true } },
105
105
  );
@@ -172,8 +172,8 @@ describe("ctx.queryProjection", () => {
172
172
  expect(rows.map((r) => r.label).sort()).toEqual(["X", "Y"]);
173
173
  });
174
174
 
175
- test("allTenants=true bypasses tenant filter on tenant-scoped projection", async () => {
176
- // Repurpose list-system by passing allTenants=true — but list-system is
175
+ test("unsafeAllTenants=true bypasses tenant filter on tenant-scoped projection", async () => {
176
+ // Repurpose list-system by passing unsafeAllTenants=true — but list-system is
177
177
  // already no-tenant-column. The semantic matters when a projection HAS
178
178
  // tenant_id but the handler wants a cross-tenant sweep (audit). We
179
179
  // exercise that contract via a direct queryProjection call here.
@@ -186,7 +186,7 @@ describe("ctx.queryProjection", () => {
186
186
  // surface small — assert against the two query handlers we have.)
187
187
  const sys = await stack.http.queryOk<Array<{ label: string }>>(
188
188
  "qp:query:widget:list-system",
189
- { allTenants: true },
189
+ { unsafeAllTenants: true },
190
190
  admin,
191
191
  );
192
192
  expect(sys).toHaveLength(2);
@@ -41,12 +41,21 @@ function eventOwnerFeature(qualifiedName: string): string | undefined {
41
41
  return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
42
42
  }
43
43
 
44
+ // System-event prefix: events under this namespace bypass the registry +
45
+ // ownership checks. Reserved for framework-internal coordination (step-
46
+ // engine deferred dispatch, lifecycle signals). The matching MSP filters
47
+ // on the literal type-string. Apps cannot write into "kumiko:system:*"
48
+ // directly — only framework step implementations call unsafeAppendEvent
49
+ // with these types.
50
+ const SYSTEM_EVENT_PREFIX = "kumiko:system:";
51
+
44
52
  export async function appendDomainEventCore(
45
53
  deps: AppendDomainEventCoreDeps,
46
54
  args: AppendEventArgs,
47
55
  ): Promise<StoredEvent> {
56
+ const isSystemEvent = args.type.startsWith(SYSTEM_EVENT_PREFIX);
48
57
  const eventDef = deps.registry.getEvent(args.type);
49
- if (!eventDef) {
58
+ if (!eventDef && !isSystemEvent) {
50
59
  throw new InternalError({
51
60
  message: `${deps.callSiteLabel}("${args.type}") — event not registered. Call r.defineEvent(shortName, schema) in a feature; appendEvent expects the qualified name returned by defineEvent (e.g. "<feature>:event:<short>").`,
52
61
  });
@@ -61,7 +70,7 @@ export async function appendDomainEventCore(
61
70
  // Feature names are registered case-preserving (pubsubOrders) but qualified
62
71
  // into kebab-case for the event/handler names (pubsub-orders:event:…) — so
63
72
  // we compare the kebab form on both sides.
64
- if (deps.callerFeature) {
73
+ if (deps.callerFeature && !isSystemEvent) {
65
74
  const owner = eventOwnerFeature(args.type);
66
75
  const callerKebab = toKebab(deps.callerFeature);
67
76
  if (owner && owner !== callerKebab) {
@@ -70,9 +79,16 @@ export async function appendDomainEventCore(
70
79
  });
71
80
  }
72
81
  }
73
- const parsed = eventDef.schema.safeParse(args.payload ?? {});
74
- if (!parsed.success) throw validationErrorFromZod(parsed.error);
75
- const validatedPayload = parsed.data as Record<string, unknown>; // @cast-boundary engine-payload
82
+ // System events skip schema validation — payload shape is owned by the
83
+ // framework step that emits + the bundled MSP that consumes.
84
+ let validatedPayload: Record<string, unknown>;
85
+ if (eventDef) {
86
+ const parsed = eventDef.schema.safeParse(args.payload ?? {});
87
+ if (!parsed.success) throw validationErrorFromZod(parsed.error);
88
+ validatedPayload = parsed.data as Record<string, unknown>;
89
+ } else {
90
+ validatedPayload = (args.payload ?? {}) as Record<string, unknown>;
91
+ }
76
92
 
77
93
  // Archive guard: block writes on archived streams. Without this an append
78
94
  // would produce an "invisible" row that loadAggregate filters out by default
@@ -97,7 +113,7 @@ export async function appendDomainEventCore(
97
113
  tenantId: deps.tenantId,
98
114
  expectedVersion,
99
115
  type: args.type,
100
- eventVersion: eventDef.version,
116
+ eventVersion: eventDef?.version ?? 1,
101
117
  payload: validatedPayload,
102
118
  metadata: {
103
119
  userId: deps.userId,