@cosmicdrift/kumiko-framework 0.304.0 → 0.306.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 (92) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  3. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  4. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  5. package/src/api/__tests__/server-error-logging.test.ts +104 -0
  6. package/src/api/api-constants.ts +13 -0
  7. package/src/api/extra-route.ts +33 -4
  8. package/src/api/index.ts +1 -0
  9. package/src/api/request-context.ts +5 -4
  10. package/src/api/routes.ts +26 -1
  11. package/src/api/server.ts +8 -2
  12. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  13. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  14. package/src/bun-db/query.ts +42 -18
  15. package/src/changes.json +108 -0
  16. package/src/db/__tests__/pg-error.test.ts +14 -0
  17. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  18. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  19. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  20. package/src/db/event-store-executor-write.ts +7 -0
  21. package/src/db/index.ts +1 -1
  22. package/src/db/pg-error.ts +13 -0
  23. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  24. package/src/db/tenant-db.ts +140 -16
  25. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  26. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  27. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  28. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  29. package/src/engine/boot-validator/access-declarations.ts +5 -66
  30. package/src/engine/extension-names.ts +55 -25
  31. package/src/engine/extensions/storage-provider.ts +14 -41
  32. package/src/engine/extensions/tenant-data.ts +4 -0
  33. package/src/engine/extensions/tenant-resource.ts +40 -0
  34. package/src/engine/extensions/user-data.ts +8 -7
  35. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  36. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  37. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  38. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  39. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  40. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  41. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  42. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  43. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  44. package/src/engine/feature-ast/index.ts +11 -1
  45. package/src/engine/feature-ast/patch.ts +338 -5
  46. package/src/engine/feature-ast/patcher.ts +2 -2
  47. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  48. package/src/engine/feature-ast/patterns.ts +22 -15
  49. package/src/engine/feature-ast/render.ts +1 -0
  50. package/src/engine/feature-ui-extensions.ts +8 -7
  51. package/src/engine/index.ts +23 -5
  52. package/src/engine/personal-data-fields.ts +66 -0
  53. package/src/engine/registry-validate.ts +15 -0
  54. package/src/engine/registry.ts +2 -0
  55. package/src/engine/types/extension-options-map.ts +1 -0
  56. package/src/engine/types/index.ts +8 -0
  57. package/src/env/__tests__/dry-run.test.ts +43 -3
  58. package/src/env/dry-run.ts +28 -15
  59. package/src/errors/__tests__/write-failures.test.ts +47 -4
  60. package/src/errors/i18n/de.yaml +12 -0
  61. package/src/errors/i18n/en.yaml +12 -0
  62. package/src/errors/reasons.ts +4 -0
  63. package/src/errors/write-error-info.ts +12 -3
  64. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  65. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  66. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  67. package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
  68. package/src/jobs/job-runner.ts +170 -19
  69. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  70. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  71. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  72. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  73. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  74. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  75. package/src/pipeline/active-membership.ts +5 -1
  76. package/src/pipeline/dispatch-batch.ts +59 -13
  77. package/src/pipeline/dispatch-query.ts +16 -5
  78. package/src/pipeline/dispatch-shared.ts +12 -5
  79. package/src/pipeline/dispatch-stream.ts +7 -2
  80. package/src/pipeline/dispatch-write.ts +22 -5
  81. package/src/pipeline/dispatcher.ts +9 -2
  82. package/src/pipeline/idempotency.ts +16 -0
  83. package/src/pipeline/member-reader.ts +3 -1
  84. package/src/pipeline/system-identity-switch.ts +22 -4
  85. package/src/pipeline/write-origin.ts +107 -0
  86. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  87. package/src/rate-limit/middleware.ts +3 -0
  88. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  89. package/src/stack/test-stack.ts +5 -0
  90. package/src/testing/closed-connection-error.ts +62 -0
  91. package/src/testing/index.ts +1 -0
  92. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -0,0 +1,1411 @@
1
+ // Runtime validator for the `unknown → PatternChange[]` boundary. An AI
2
+ // tool (or any external caller) hands us JSON; before it ever reaches
3
+ // `applyChanges` it must pass through here. Every rejection carries an
4
+ // exact dotted/bracketed path so the caller (frequently an LLM) can
5
+ // self-correct without a human in the loop — see kumiko-framework#3137.
6
+ //
7
+ // **Zod v4 constraint driving this file's shape:** every discriminated-
8
+ // union member below stays a plain `z.object(...)` (or `.superRefine`
9
+ // wrapper around one) — never `.transform()` at the object level. A
10
+ // whole-object transform would erase the "which member matched" signal
11
+ // `discriminatedUnion` needs to attribute a nested field error to the
12
+ // right member, and it would blur per-member `.strict()` unrecognized-key
13
+ // detection. Transforms only ever touch a single field (`sourceBody`,
14
+ // below) — never the object they live on.
15
+ //
16
+ // **Deep definitions stay opaque.** EntityDefinition, ScreenDefinition,
17
+ // NavDefinition, WorkspaceDefinition, JobDefinition-options, MetricOptions,
18
+ // etc. are NOT hand-modeled here — that would fork a second, driftable
19
+ // catalogue of their shape. They're accepted via `z.custom` (plain object,
20
+ // or a RawRefSentinel/AiStepOpaqueArgs — the extractor's own "couldn't
21
+ // resolve this statically" markers) and, for EntityDefinition only,
22
+ // additionally checked for field-type membership via the shared
23
+ // `findUnknownEntityFieldTypes` helper (kept in sync with `extractEntity`).
24
+
25
+ import { z } from "zod";
26
+ import { type LifecycleHookType, LifecycleHookTypes } from "../constants";
27
+ import type {
28
+ ConfigKeyDefinition,
29
+ ConfigKeyType,
30
+ JobDefinition,
31
+ ReferenceDataDef,
32
+ RunIn,
33
+ TranslationKeys,
34
+ } from "../types/config";
35
+ import type { MetricOptions, SecretOptions } from "../types/feature";
36
+ import type { EntityDefinition } from "../types/fields";
37
+ import type { AgentRisk, ClaimKeyType, RateLimitPer } from "../types/handlers";
38
+ import type { HookPhase } from "../types/hooks";
39
+ import type { HttpRouteMethod } from "../types/http-route";
40
+ import type { NavDefinition } from "../types/nav";
41
+ import type { RelationDefinition } from "../types/relations";
42
+ import type { ScreenDefinition } from "../types/screen";
43
+ import type { TreeActionDef } from "../types/tree-node";
44
+ import type { WorkspaceDefinition } from "../types/workspace";
45
+ import { describeUnknownFieldType, findUnknownEntityFieldTypes } from "./entity-field-types";
46
+ import { AGENT_RISK_VALUES } from "./extractors/handlers";
47
+ import { isPlainObject, isRawRefSentinel } from "./extractors/shared";
48
+ import type {
49
+ PatternChange,
50
+ PatternId,
51
+ QueryHandlerHeaderKey,
52
+ StreamHandlerHeaderKey,
53
+ WriteHandlerHeaderKey,
54
+ } from "./patch";
55
+ import { SYNTHETIC_LOC } from "./patcher";
56
+ import type { FeaturePatternKind } from "./patterns";
57
+ import type { SourceLocation, SourcePosition } from "./source-location";
58
+
59
+ // Derives an exhaustive literal-value array from a `Record<T, true>` flag
60
+ // map: an added/removed union member fails to compile here instead of
61
+ // silently producing a schema narrower than the runtime type — same
62
+ // rationale as kumiko-types's `NO_WIDGET_FIELD_TYPES`/`FIELD_TYPE_NAMES`.
63
+ function keysOf<T extends string>(flags: Record<T, true>): readonly T[] {
64
+ return Object.keys(flags) as T[];
65
+ }
66
+
67
+ // =============================================================================
68
+ // Result types
69
+ // =============================================================================
70
+
71
+ export type PatternChangeIssue = {
72
+ readonly path: string;
73
+ readonly message: string;
74
+ };
75
+
76
+ export type PatternChangesParseResult =
77
+ | { readonly ok: true; readonly changes: readonly PatternChange[] }
78
+ | { readonly ok: false; readonly issues: readonly PatternChangeIssue[] };
79
+
80
+ // =============================================================================
81
+ // Shared primitives
82
+ // =============================================================================
83
+
84
+ function isPlainObjectOrSentinel(value: unknown): boolean {
85
+ return isRawRefSentinel(value) || isPlainObject(value);
86
+ }
87
+
88
+ const sourcePositionSchema: z.ZodType<SourcePosition> = z
89
+ .object({ line: z.number(), column: z.number() })
90
+ .strict();
91
+
92
+ const fullSourceLocationSchema: z.ZodType<SourceLocation> = z
93
+ .object({
94
+ file: z.string(),
95
+ start: sourcePositionSchema,
96
+ end: sourcePositionSchema,
97
+ raw: z.string(),
98
+ })
99
+ .strict();
100
+
101
+ // Partial object form — only `raw` is meaningful, `file`/`start`/`end` (if
102
+ // present) are accepted but discarded: a wire producer sending a
103
+ // half-populated location is telling us "this came from generated code, not
104
+ // a real file span" and `rawLoc` is exactly that contract.
105
+ const partialSourceLocationSchema = z
106
+ .object({
107
+ raw: z.string(),
108
+ file: z.string().optional(),
109
+ start: sourcePositionSchema.optional(),
110
+ end: sourcePositionSchema.optional(),
111
+ })
112
+ .strict()
113
+ .transform((value): SourceLocation => ({ ...SYNTHETIC_LOC, raw: value.raw }));
114
+
115
+ const stringSourceLocationSchema = z
116
+ .string()
117
+ .transform((raw): SourceLocation => ({ ...SYNTHETIC_LOC, raw }));
118
+
119
+ // `sourceBody`: string | {raw, file?, start?, end?} | full SourceLocation.
120
+ // Order matters — a fully-populated object must match `fullSourceLocationSchema`
121
+ // first so it round-trips unchanged; only a partial object falls through to
122
+ // the raw-collapsing variant.
123
+ const sourceBodySchema: z.ZodType<SourceLocation> = z.union([
124
+ fullSourceLocationSchema,
125
+ partialSourceLocationSchema,
126
+ stringSourceLocationSchema,
127
+ ]);
128
+
129
+ const nonEmptySourceBodySchema = sourceBodySchema.refine((loc) => loc.raw.trim().length > 0, {
130
+ message: "source.raw must be non-empty",
131
+ });
132
+
133
+ // The pattern-level `source` field: optional on the wire, defaults to the
134
+ // patcher's own synthetic placeholder (mirrors createFeaturePatcher's
135
+ // add{Kind} methods, which never have a real file span either).
136
+ const sourceFieldSchema: z.ZodType<SourceLocation> = sourceBodySchema
137
+ .optional()
138
+ .default(SYNTHETIC_LOC);
139
+
140
+ // `readVarargsOrArrayProp` (extractors/shared.ts) already types a mixed
141
+ // string/sentinel array as `readonly string[]` at its own extraction
142
+ // boundary (requires.featureNames, readsConfig.qualifiedKeys); mirror that
143
+ // same typed lie here instead of re-deriving a wider array type only to
144
+ // cast it back down.
145
+ const stringOrRawRefListSchema: z.ZodType<readonly string[]> = z.custom<readonly string[]>(
146
+ (value) =>
147
+ Array.isArray(value) && value.every((el) => typeof el === "string" || isRawRefSentinel(el)),
148
+ { message: "must be an array of strings or unresolved references" },
149
+ );
150
+
151
+ // Runtime source of truth for the lifecycle-hook-type literals: the
152
+ // engine's own `LifecycleHookTypes` const object (engine/constants.ts) —
153
+ // `hook`'s `hookType` is that union plus the AST-only "validation" pseudo-
154
+ // type (r.hook(type: "validation", ...) has no runtime LifecycleHookType
155
+ // counterpart, it maps to a different registrar call).
156
+ const LIFECYCLE_HOOK_TYPE_VALUES = Object.values(
157
+ LifecycleHookTypes,
158
+ ) as readonly LifecycleHookType[];
159
+
160
+ const hookTypeSchema = z.enum([...LIFECYCLE_HOOK_TYPE_VALUES, "validation"] as const);
161
+ const hookPhaseSchema = z.enum(keysOf<HookPhase>({ inTransaction: true, afterCommit: true }));
162
+ const httpRouteMethodSchema = z.enum(
163
+ keysOf<HttpRouteMethod>({
164
+ GET: true,
165
+ POST: true,
166
+ PUT: true,
167
+ PATCH: true,
168
+ DELETE: true,
169
+ HEAD: true,
170
+ OPTIONS: true,
171
+ }),
172
+ );
173
+ const runInSchema = z.enum(keysOf<RunIn>({ api: true, worker: true, both: true }));
174
+ const claimKeyTypeSchema = z.enum(
175
+ keysOf<ClaimKeyType>({
176
+ string: true,
177
+ number: true,
178
+ boolean: true,
179
+ "string[]": true,
180
+ object: true,
181
+ }),
182
+ );
183
+ const agentRiskSchema = z.enum(AGENT_RISK_VALUES as [AgentRisk, ...AgentRisk[]]);
184
+ const rateLimitPerSchema = z.enum(
185
+ keysOf<RateLimitPer>({
186
+ user: true,
187
+ tenant: true,
188
+ ip: true,
189
+ "user+handler": true,
190
+ "tenant+handler": true,
191
+ "ip+handler": true,
192
+ }),
193
+ );
194
+ const mspDeliverySchema = z.enum(
195
+ keysOf<"shared" | "per-instance">({ shared: true, "per-instance": true }),
196
+ );
197
+
198
+ const escapeHatchSchema = z
199
+ .object({
200
+ reason: z.string().refine((s) => s.trim().length > 0, {
201
+ message: "reason must be non-empty",
202
+ }),
203
+ })
204
+ .strict();
205
+
206
+ const rawRefSentinelSchema = z.object({ __raw: z.string() }).strict();
207
+
208
+ // Adds the raw-ref-sentinel branch a handler header field accepts when the
209
+ // extractor couldn't resolve it to a literal (see extractors/hooks.ts).
210
+ function orRawRef<T extends z.ZodTypeAny>(schema: T) {
211
+ return z.union([schema, rawRefSentinelSchema]);
212
+ }
213
+
214
+ const rateLimitDisabledSchema = z
215
+ .object({
216
+ disabled: z.literal(true),
217
+ reason: z.string().refine((s) => s.trim().length > 0, {
218
+ message: "reason must be non-empty",
219
+ }),
220
+ })
221
+ .strict();
222
+
223
+ const rateLimitOptionSchema = z
224
+ .object({
225
+ per: rateLimitPerSchema,
226
+ limit: z.number(),
227
+ windowSeconds: z.number(),
228
+ cost: z.number().optional(),
229
+ })
230
+ .strict();
231
+
232
+ const agentHandlerHintsSchema = z
233
+ .object({
234
+ expose: z.boolean().optional(),
235
+ risk: agentRiskSchema.optional(),
236
+ })
237
+ .strict();
238
+
239
+ // AccessRule — DEFAULT-DENY per its doc in types/handlers.ts; a malformed
240
+ // shape must be rejected, not silently narrowed to "no access" (that would
241
+ // hide the author's mistake instead of reporting it at the boundary).
242
+ const roleAccessRuleSchema = z
243
+ .object({
244
+ roles: z.array(z.string()),
245
+ personalData: z.literal("public-intake").optional(),
246
+ })
247
+ .strict();
248
+
249
+ const openToAllAccessRuleSchema = z
250
+ .object({
251
+ openToAll: z
252
+ .object({
253
+ reason: z.string().refine((s) => s.trim().length > 0, {
254
+ message: "openToAll.reason must be non-empty",
255
+ }),
256
+ personalData: z.literal("tenant-members").optional(),
257
+ })
258
+ .strict(),
259
+ })
260
+ .strict();
261
+
262
+ const accessRuleSchema = z.union([roleAccessRuleSchema, openToAllAccessRuleSchema]);
263
+
264
+ // Header keys per handler kind — drift pin against patch.ts's *HeaderKey
265
+ // types (via `satisfies`), and the source the schemas below derive from.
266
+ const WRITE_HANDLER_HEADER_SHAPE = {
267
+ access: accessRuleSchema,
268
+ description: z.string(),
269
+ agent: agentHandlerHintsSchema,
270
+ rateLimit: rateLimitOptionSchema,
271
+ unsafeSkipTransitionGuard: z.boolean(),
272
+ escapeHatch: escapeHatchSchema,
273
+ } satisfies Record<WriteHandlerHeaderKey, z.ZodTypeAny>;
274
+ const QUERY_HANDLER_HEADER_SHAPE = {
275
+ access: accessRuleSchema,
276
+ description: z.string(),
277
+ agent: agentHandlerHintsSchema,
278
+ rateLimit: rateLimitOptionSchema,
279
+ escapeHatch: escapeHatchSchema,
280
+ } satisfies Record<QueryHandlerHeaderKey, z.ZodTypeAny>;
281
+ const STREAM_HANDLER_HEADER_SHAPE = {
282
+ access: accessRuleSchema,
283
+ rateLimit: rateLimitOptionSchema,
284
+ escapeHatch: escapeHatchSchema,
285
+ } satisfies Record<StreamHandlerHeaderKey, z.ZodTypeAny>;
286
+
287
+ const WRITE_HANDLER_HEADER_KEYS = z.object(WRITE_HANDLER_HEADER_SHAPE).keyof().options;
288
+ const QUERY_HANDLER_HEADER_KEYS = z.object(QUERY_HANDLER_HEADER_SHAPE).keyof().options;
289
+ const STREAM_HANDLER_HEADER_KEYS = z.object(STREAM_HANDLER_HEADER_SHAPE).keyof().options;
290
+
291
+ // `update`'s set-schema stays exactly as strict as the extractor's own
292
+ // output: Designer/AI-authored values must be structured, never a raw
293
+ // reference or a partially-literal shape — those only ever come from
294
+ // statically parsed source, not from a `set` payload.
295
+ const WRITE_HANDLER_UPDATE_SET_SCHEMA = z.object(WRITE_HANDLER_HEADER_SHAPE).partial().strict();
296
+ const QUERY_HANDLER_UPDATE_SET_SCHEMA = z.object(QUERY_HANDLER_HEADER_SHAPE).partial().strict();
297
+ const STREAM_HANDLER_UPDATE_SET_SCHEMA = z.object(STREAM_HANDLER_HEADER_SHAPE).partial().strict();
298
+
299
+ // `unset` may name any header field except `access` (always required).
300
+ const WRITE_HANDLER_UPDATE_UNSET_ENUM = z
301
+ .object(WRITE_HANDLER_HEADER_SHAPE)
302
+ .omit({ access: true })
303
+ .keyof();
304
+ const QUERY_HANDLER_UPDATE_UNSET_ENUM = z
305
+ .object(QUERY_HANDLER_HEADER_SHAPE)
306
+ .omit({ access: true })
307
+ .keyof();
308
+ const STREAM_HANDLER_UPDATE_UNSET_ENUM = z
309
+ .object(STREAM_HANDLER_HEADER_SHAPE)
310
+ .omit({ access: true })
311
+ .keyof();
312
+
313
+ // Full patterns (add/replace) carry whatever the extractor parsed, which
314
+ // may be a RawRefSentinel or a disabled-rate-limit shape it can't resolve
315
+ // to a literal; widen only here, not on UPDATE_SET_SCHEMA above.
316
+ const WRITE_HANDLER_PATTERN_HEADER_SHAPE = {
317
+ ...WRITE_HANDLER_HEADER_SHAPE,
318
+ access: orRawRef(accessRuleSchema),
319
+ agent: orRawRef(agentHandlerHintsSchema),
320
+ rateLimit: orRawRef(z.union([rateLimitOptionSchema, rateLimitDisabledSchema])),
321
+ escapeHatch: orRawRef(escapeHatchSchema),
322
+ };
323
+ const QUERY_HANDLER_PATTERN_HEADER_SHAPE = {
324
+ ...QUERY_HANDLER_HEADER_SHAPE,
325
+ access: orRawRef(accessRuleSchema),
326
+ agent: orRawRef(agentHandlerHintsSchema),
327
+ rateLimit: orRawRef(z.union([rateLimitOptionSchema, rateLimitDisabledSchema])),
328
+ escapeHatch: orRawRef(escapeHatchSchema),
329
+ };
330
+ const STREAM_HANDLER_PATTERN_HEADER_SHAPE = {
331
+ ...STREAM_HANDLER_HEADER_SHAPE,
332
+ access: orRawRef(accessRuleSchema),
333
+ rateLimit: orRawRef(z.union([rateLimitOptionSchema, rateLimitDisabledSchema])),
334
+ escapeHatch: orRawRef(escapeHatchSchema),
335
+ };
336
+
337
+ function requireHandlerBody(
338
+ val: {
339
+ readonly handlerName?: string;
340
+ readonly schemaSource?: SourceLocation;
341
+ readonly handlerBody?: SourceLocation;
342
+ readonly source: SourceLocation;
343
+ readonly [headerKey: string]: unknown;
344
+ },
345
+ ctx: z.RefinementCtx,
346
+ headerKeys: readonly string[],
347
+ ): void {
348
+ const hasAny =
349
+ val.handlerName !== undefined ||
350
+ val.schemaSource !== undefined ||
351
+ val.handlerBody !== undefined;
352
+ if (!hasAny) {
353
+ if (val.source.raw.trim().length === 0) {
354
+ ctx.addIssue({
355
+ code: "custom",
356
+ path: ["source"],
357
+ message:
358
+ "an opaque handler reference (no handlerName/schemaSource/handlerBody) requires a non-empty source.raw",
359
+ });
360
+ }
361
+ for (const key of headerKeys) {
362
+ if (val[key] !== undefined) {
363
+ ctx.addIssue({
364
+ code: "custom",
365
+ path: [key],
366
+ message:
367
+ "not rendered for an opaque handler reference; provide handlerName, schemaSource and handlerBody",
368
+ });
369
+ }
370
+ }
371
+ // skip: opaque handler reference carries no name/schema/body to require
372
+ return;
373
+ }
374
+ if (val.handlerName === undefined || val.handlerName.trim().length === 0) {
375
+ ctx.addIssue({
376
+ code: "custom",
377
+ path: ["handlerName"],
378
+ message: "handlerName is required and must be non-empty",
379
+ });
380
+ }
381
+ if (val.schemaSource === undefined) {
382
+ ctx.addIssue({ code: "custom", path: ["schemaSource"], message: "schemaSource is required" });
383
+ }
384
+ if (val.handlerBody === undefined) {
385
+ ctx.addIssue({ code: "custom", path: ["handlerBody"], message: "handlerBody is required" });
386
+ }
387
+ }
388
+
389
+ // =============================================================================
390
+ // Static patterns
391
+ // =============================================================================
392
+
393
+ // Deep-definition passthrough: accepts a plain object OR the extractor's
394
+ // own "couldn't resolve this statically" sentinel (RawRefSentinel), typed
395
+ // as `T` at the boundary — the same "typed lie" the extractors already
396
+ // make (e.g. round2.ts's `definition as EntityDefinition`), because a
397
+ // sentinel-bearing value is never actually shaped like `T` at runtime, but
398
+ // round-trips through `applyChanges`/the renderer unexamined either way.
399
+ function passthroughSchema<T>(): z.ZodType<T> {
400
+ return z.custom<T>(isPlainObjectOrSentinel, {
401
+ message: "must be a plain object or unresolved reference",
402
+ });
403
+ }
404
+
405
+ const entityDefinitionSchema = passthroughSchema<EntityDefinition>().superRefine((value, ctx) => {
406
+ for (const entry of findUnknownEntityFieldTypes(value)) {
407
+ ctx.addIssue({
408
+ code: "custom",
409
+ path: ["fields", entry.fieldName, "type"],
410
+ message: describeUnknownFieldType(entry),
411
+ });
412
+ }
413
+ });
414
+
415
+ const entitySchema = z
416
+ .object({
417
+ kind: z.literal("entity"),
418
+ source: sourceFieldSchema,
419
+ entityName: z.string(),
420
+ definition: entityDefinitionSchema,
421
+ })
422
+ .strict();
423
+
424
+ const relationSchema = z
425
+ .object({
426
+ kind: z.literal("relation"),
427
+ source: sourceFieldSchema,
428
+ entityName: z.string(),
429
+ relationName: z.string(),
430
+ definition: passthroughSchema<RelationDefinition>(),
431
+ })
432
+ .strict();
433
+
434
+ const navSchema = z
435
+ .object({
436
+ kind: z.literal("nav"),
437
+ source: sourceFieldSchema,
438
+ definition: passthroughSchema<NavDefinition>(),
439
+ })
440
+ .strict();
441
+
442
+ const workspaceSchema = z
443
+ .object({
444
+ kind: z.literal("workspace"),
445
+ source: sourceFieldSchema,
446
+ definition: passthroughSchema<WorkspaceDefinition>(),
447
+ })
448
+ .strict();
449
+
450
+ const configSchema = z
451
+ .object({
452
+ kind: z.literal("config"),
453
+ source: sourceFieldSchema,
454
+ keys: passthroughSchema<Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>>(),
455
+ })
456
+ .strict();
457
+
458
+ const translationsSchema = z
459
+ .object({
460
+ kind: z.literal("translations"),
461
+ source: sourceFieldSchema,
462
+ keys: passthroughSchema<TranslationKeys>(),
463
+ })
464
+ .strict();
465
+
466
+ const requiresSchema = z
467
+ .object({
468
+ kind: z.literal("requires"),
469
+ source: sourceFieldSchema,
470
+ featureNames: stringOrRawRefListSchema,
471
+ })
472
+ .strict();
473
+
474
+ const optionalRequiresSchema = z
475
+ .object({
476
+ kind: z.literal("optionalRequires"),
477
+ source: sourceFieldSchema,
478
+ featureNames: stringOrRawRefListSchema,
479
+ })
480
+ .strict();
481
+
482
+ const systemScopeSchema = z
483
+ .object({
484
+ kind: z.literal("systemScope"),
485
+ source: sourceFieldSchema,
486
+ })
487
+ .strict();
488
+
489
+ const toggleableSchema = z
490
+ .object({
491
+ kind: z.literal("toggleable"),
492
+ source: sourceFieldSchema,
493
+ default: z.boolean(),
494
+ })
495
+ .strict();
496
+
497
+ const describeSchema = z
498
+ .object({
499
+ kind: z.literal("describe"),
500
+ source: sourceFieldSchema,
501
+ text: z.string().refine((s) => s.trim().length > 0, { message: "text must be non-empty" }),
502
+ })
503
+ .strict();
504
+
505
+ const uiHintsSchema = z
506
+ .object({
507
+ kind: z.literal("uiHints"),
508
+ source: nonEmptySourceBodySchema,
509
+ })
510
+ .strict();
511
+
512
+ const metricSchema = z
513
+ .object({
514
+ kind: z.literal("metric"),
515
+ source: sourceFieldSchema,
516
+ shortName: z.string(),
517
+ options: passthroughSchema<MetricOptions>(),
518
+ })
519
+ .strict();
520
+
521
+ const secretSchema = z
522
+ .object({
523
+ kind: z.literal("secret"),
524
+ source: sourceFieldSchema,
525
+ shortName: z.string(),
526
+ options: passthroughSchema<SecretOptions>(),
527
+ })
528
+ .strict();
529
+
530
+ const claimKeySchema = z
531
+ .object({
532
+ kind: z.literal("claimKey"),
533
+ source: sourceFieldSchema,
534
+ shortName: z.string(),
535
+ claimType: claimKeyTypeSchema,
536
+ })
537
+ .strict();
538
+
539
+ const referenceDataSchema = z
540
+ .object({
541
+ kind: z.literal("referenceData"),
542
+ source: sourceFieldSchema,
543
+ entityName: z.string(),
544
+ data: z.custom<ReferenceDataDef["data"]>(
545
+ (value) => Array.isArray(value) || isRawRefSentinel(value),
546
+ { message: "data must be an array or unresolved reference" },
547
+ ),
548
+ upsertKey: z.string().optional(),
549
+ })
550
+ .strict();
551
+
552
+ const readsConfigSchema = z
553
+ .object({
554
+ kind: z.literal("readsConfig"),
555
+ source: sourceFieldSchema,
556
+ qualifiedKeys: stringOrRawRefListSchema,
557
+ })
558
+ .strict();
559
+
560
+ const useExtensionSchema = z
561
+ .object({
562
+ kind: z.literal("useExtension"),
563
+ source: sourceFieldSchema,
564
+ extensionName: z.string(),
565
+ extensionNameRaw: z.string().optional(),
566
+ entityName: z.string(),
567
+ options: passthroughSchema<Readonly<Record<string, unknown>>>().optional(),
568
+ })
569
+ .strict();
570
+
571
+ const usesApiSchema = z
572
+ .object({
573
+ kind: z.literal("usesApi"),
574
+ source: sourceFieldSchema,
575
+ apiName: z.string(),
576
+ })
577
+ .strict();
578
+
579
+ const exposesApiSchema = z
580
+ .object({
581
+ kind: z.literal("exposesApi"),
582
+ source: sourceFieldSchema,
583
+ apiName: z.string(),
584
+ })
585
+ .strict();
586
+
587
+ const treeActionsSchema = z
588
+ .object({
589
+ kind: z.literal("treeActions"),
590
+ source: sourceFieldSchema,
591
+ definitions: passthroughSchema<Readonly<Record<string, TreeActionDef>>>(),
592
+ })
593
+ .strict();
594
+
595
+ // =============================================================================
596
+ // Mixed patterns
597
+ // =============================================================================
598
+
599
+ const screenSchema = z
600
+ .object({
601
+ kind: z.literal("screen"),
602
+ source: sourceFieldSchema,
603
+ definition: passthroughSchema<ScreenDefinition>(),
604
+ opaqueProps: z.record(z.string(), sourceBodySchema).optional().default({}),
605
+ })
606
+ .strict();
607
+
608
+ const writeHandlerSchema = z
609
+ .object({
610
+ kind: z.literal("writeHandler"),
611
+ source: sourceFieldSchema,
612
+ handlerName: z.string().optional(),
613
+ schemaSource: sourceBodySchema.optional(),
614
+ handlerBody: sourceBodySchema.optional(),
615
+ ...z.object(WRITE_HANDLER_PATTERN_HEADER_SHAPE).partial().shape,
616
+ })
617
+ .strict()
618
+ .superRefine((val, ctx) => requireHandlerBody(val, ctx, WRITE_HANDLER_HEADER_KEYS));
619
+
620
+ const queryHandlerSchema = z
621
+ .object({
622
+ kind: z.literal("queryHandler"),
623
+ source: sourceFieldSchema,
624
+ handlerName: z.string().optional(),
625
+ schemaSource: sourceBodySchema.optional(),
626
+ handlerBody: sourceBodySchema.optional(),
627
+ ...z.object(QUERY_HANDLER_PATTERN_HEADER_SHAPE).partial().shape,
628
+ })
629
+ .strict()
630
+ .superRefine((val, ctx) => requireHandlerBody(val, ctx, QUERY_HANDLER_HEADER_KEYS));
631
+
632
+ const streamHandlerSchema = z
633
+ .object({
634
+ kind: z.literal("streamHandler"),
635
+ source: sourceFieldSchema,
636
+ handlerName: z.string().optional(),
637
+ schemaSource: sourceBodySchema.optional(),
638
+ handlerBody: sourceBodySchema.optional(),
639
+ ...z.object(STREAM_HANDLER_PATTERN_HEADER_SHAPE).partial().shape,
640
+ })
641
+ .strict()
642
+ .superRefine((val, ctx) => requireHandlerBody(val, ctx, STREAM_HANDLER_HEADER_KEYS));
643
+
644
+ const hookTargetSchema = z.union([
645
+ z.string(),
646
+ z.array(z.string()),
647
+ z.object({ allOf: z.string() }).strict(),
648
+ ]);
649
+
650
+ const hookSchema = z
651
+ .object({
652
+ kind: z.literal("hook"),
653
+ source: sourceFieldSchema,
654
+ hookType: hookTypeSchema,
655
+ target: hookTargetSchema,
656
+ fnBody: sourceBodySchema,
657
+ phase: hookPhaseSchema.optional(),
658
+ escapeHatch: orRawRef(escapeHatchSchema).optional(),
659
+ })
660
+ .strict();
661
+
662
+ const jobSchema = z
663
+ .object({
664
+ kind: z.literal("job"),
665
+ source: sourceFieldSchema,
666
+ jobName: z.string(),
667
+ options: passthroughSchema<Omit<JobDefinition, "name" | "handler">>(),
668
+ handlerBody: sourceBodySchema,
669
+ })
670
+ .strict();
671
+
672
+ const notificationSchema = z
673
+ .object({
674
+ kind: z.literal("notification"),
675
+ source: sourceFieldSchema,
676
+ notificationName: z.string(),
677
+ trigger: z.object({ on: z.string() }).strict(),
678
+ recipientBody: sourceBodySchema,
679
+ dataBody: sourceBodySchema,
680
+ templates: z.record(z.string(), sourceBodySchema).optional(),
681
+ })
682
+ .strict();
683
+
684
+ const authClaimsSchema = z
685
+ .object({
686
+ kind: z.literal("authClaims"),
687
+ source: sourceFieldSchema,
688
+ fnBody: sourceBodySchema,
689
+ })
690
+ .strict();
691
+
692
+ const httpRouteSchema = z
693
+ .object({
694
+ kind: z.literal("httpRoute"),
695
+ source: sourceFieldSchema,
696
+ method: httpRouteMethodSchema,
697
+ path: z.string(),
698
+ anonymous: z.boolean(),
699
+ handlerBody: sourceBodySchema,
700
+ })
701
+ .strict();
702
+
703
+ const projectionSchema = z
704
+ .object({
705
+ kind: z.literal("projection"),
706
+ source: sourceFieldSchema,
707
+ name: z.string(),
708
+ sourceEntity: z.union([z.string(), z.array(z.string())]),
709
+ applyBodies: z.record(z.string(), sourceBodySchema),
710
+ })
711
+ .strict();
712
+
713
+ const mspErrorPolicySchema = z.object({ skipApplyErrors: z.boolean().optional() }).strict();
714
+ const mspErrorModeSchema = z
715
+ .object({
716
+ continuous: mspErrorPolicySchema.optional(),
717
+ rebuild: mspErrorPolicySchema.optional(),
718
+ })
719
+ .strict();
720
+
721
+ const multiStreamProjectionSchema = z
722
+ .object({
723
+ kind: z.literal("multiStreamProjection"),
724
+ source: sourceFieldSchema,
725
+ name: z.string(),
726
+ applyBodies: z.record(z.string(), sourceBodySchema),
727
+ errorMode: mspErrorModeSchema.optional(),
728
+ runIn: runInSchema.optional(),
729
+ delivery: mspDeliverySchema.optional(),
730
+ })
731
+ .strict();
732
+
733
+ const defineEventSchema = z
734
+ .object({
735
+ kind: z.literal("defineEvent"),
736
+ source: sourceFieldSchema,
737
+ eventName: z.string(),
738
+ eventNameRaw: z.string().optional(),
739
+ schemaSource: sourceBodySchema,
740
+ version: z.number().optional(),
741
+ piiFields: sourceBodySchema,
742
+ migrations: z.record(z.string(), sourceBodySchema).optional(),
743
+ })
744
+ .strict();
745
+
746
+ const extendsRegistrarSchema = z
747
+ .object({
748
+ kind: z.literal("extendsRegistrar"),
749
+ source: sourceFieldSchema,
750
+ extensionName: z.string(),
751
+ extensionNameRaw: z.string().optional(),
752
+ defBody: sourceBodySchema,
753
+ })
754
+ .strict();
755
+
756
+ const envSchemaSchema = z
757
+ .object({
758
+ kind: z.literal("envSchema"),
759
+ source: sourceFieldSchema,
760
+ schemaBody: sourceBodySchema,
761
+ })
762
+ .strict();
763
+
764
+ const aiStepOpaqueArgsSchema = z.custom<{ readonly __raw: string }>(isRawRefSentinel, {
765
+ message: "must be an unresolved reference ({ __raw })",
766
+ });
767
+
768
+ const aiStepPolicySchema = z
769
+ .object({
770
+ enabled: z.boolean(),
771
+ providerId: z.string().optional(),
772
+ model: z.string().optional(),
773
+ params: z.custom<Record<string, unknown>>(isPlainObject, {
774
+ message: "params must be a plain object",
775
+ }),
776
+ })
777
+ .strict();
778
+
779
+ const stringOrAiOpaqueSchema = z.union([z.string(), aiStepOpaqueArgsSchema]);
780
+ const aiStepPolicyOrOpaqueSchema = z.union([aiStepPolicySchema, aiStepOpaqueArgsSchema]);
781
+
782
+ const aiGenerateSchema = z
783
+ .object({
784
+ kind: z.literal("ai.generate"),
785
+ source: sourceFieldSchema,
786
+ argsSource: aiStepOpaqueArgsSchema.optional(),
787
+ stepKey: stringOrAiOpaqueSchema.optional(),
788
+ promptKey: stringOrAiOpaqueSchema.optional(),
789
+ promptFallback: stringOrAiOpaqueSchema.optional(),
790
+ defaults: aiStepPolicyOrOpaqueSchema.optional(),
791
+ paramsSchemaSource: sourceBodySchema.optional(),
792
+ inputBody: sourceBodySchema.optional(),
793
+ })
794
+ .strict();
795
+
796
+ const aiExtractSchema = z
797
+ .object({
798
+ kind: z.literal("ai.extract"),
799
+ source: sourceFieldSchema,
800
+ argsSource: aiStepOpaqueArgsSchema.optional(),
801
+ stepKey: stringOrAiOpaqueSchema.optional(),
802
+ promptKey: stringOrAiOpaqueSchema.optional(),
803
+ promptFallback: stringOrAiOpaqueSchema.optional(),
804
+ defaults: aiStepPolicyOrOpaqueSchema.optional(),
805
+ paramsSchemaSource: sourceBodySchema.optional(),
806
+ outputSchemaSource: sourceBodySchema.optional(),
807
+ instructionsBody: sourceBodySchema.optional(),
808
+ documentBody: sourceBodySchema.optional(),
809
+ })
810
+ .strict();
811
+
812
+ const aiClassifySchema = z
813
+ .object({
814
+ kind: z.literal("ai.classify"),
815
+ source: sourceFieldSchema,
816
+ argsSource: aiStepOpaqueArgsSchema.optional(),
817
+ stepKey: stringOrAiOpaqueSchema.optional(),
818
+ promptKey: stringOrAiOpaqueSchema.optional(),
819
+ promptFallback: stringOrAiOpaqueSchema.optional(),
820
+ defaults: aiStepPolicyOrOpaqueSchema.optional(),
821
+ paramsSchemaSource: sourceBodySchema.optional(),
822
+ actions: z.array(z.object({ type: z.string(), description: z.string() }).strict()).optional(),
823
+ inputBody: sourceBodySchema.optional(),
824
+ })
825
+ .strict();
826
+
827
+ // =============================================================================
828
+ // Catch-all
829
+ // =============================================================================
830
+
831
+ const unknownPatternSchema = z
832
+ .object({
833
+ kind: z.literal("unknown"),
834
+ source: nonEmptySourceBodySchema,
835
+ methodName: z.string(),
836
+ })
837
+ .strict();
838
+
839
+ // =============================================================================
840
+ // Pattern union — Record keyed by kind doubles as the exhaustiveness pin
841
+ // consumed by the compile-time typtest (a missing/renamed kind fails to
842
+ // compile here, same contract as patterns.ts's own switch statements).
843
+ // =============================================================================
844
+
845
+ export const PATTERN_SCHEMAS_BY_KIND = {
846
+ entity: entitySchema,
847
+ relation: relationSchema,
848
+ nav: navSchema,
849
+ workspace: workspaceSchema,
850
+ config: configSchema,
851
+ translations: translationsSchema,
852
+ requires: requiresSchema,
853
+ optionalRequires: optionalRequiresSchema,
854
+ systemScope: systemScopeSchema,
855
+ toggleable: toggleableSchema,
856
+ describe: describeSchema,
857
+ uiHints: uiHintsSchema,
858
+ metric: metricSchema,
859
+ secret: secretSchema,
860
+ claimKey: claimKeySchema,
861
+ referenceData: referenceDataSchema,
862
+ readsConfig: readsConfigSchema,
863
+ useExtension: useExtensionSchema,
864
+ usesApi: usesApiSchema,
865
+ exposesApi: exposesApiSchema,
866
+ treeActions: treeActionsSchema,
867
+ screen: screenSchema,
868
+ writeHandler: writeHandlerSchema,
869
+ queryHandler: queryHandlerSchema,
870
+ streamHandler: streamHandlerSchema,
871
+ hook: hookSchema,
872
+ job: jobSchema,
873
+ notification: notificationSchema,
874
+ authClaims: authClaimsSchema,
875
+ httpRoute: httpRouteSchema,
876
+ projection: projectionSchema,
877
+ multiStreamProjection: multiStreamProjectionSchema,
878
+ defineEvent: defineEventSchema,
879
+ extendsRegistrar: extendsRegistrarSchema,
880
+ envSchema: envSchemaSchema,
881
+ "ai.generate": aiGenerateSchema,
882
+ "ai.extract": aiExtractSchema,
883
+ "ai.classify": aiClassifySchema,
884
+ unknown: unknownPatternSchema,
885
+ } satisfies Record<FeaturePatternKind, z.ZodTypeAny>;
886
+
887
+ const patternSchema = z.discriminatedUnion("kind", [
888
+ entitySchema,
889
+ relationSchema,
890
+ navSchema,
891
+ workspaceSchema,
892
+ configSchema,
893
+ translationsSchema,
894
+ requiresSchema,
895
+ optionalRequiresSchema,
896
+ systemScopeSchema,
897
+ toggleableSchema,
898
+ describeSchema,
899
+ uiHintsSchema,
900
+ metricSchema,
901
+ secretSchema,
902
+ claimKeySchema,
903
+ referenceDataSchema,
904
+ readsConfigSchema,
905
+ useExtensionSchema,
906
+ usesApiSchema,
907
+ exposesApiSchema,
908
+ treeActionsSchema,
909
+ screenSchema,
910
+ writeHandlerSchema,
911
+ queryHandlerSchema,
912
+ streamHandlerSchema,
913
+ hookSchema,
914
+ jobSchema,
915
+ notificationSchema,
916
+ authClaimsSchema,
917
+ httpRouteSchema,
918
+ projectionSchema,
919
+ multiStreamProjectionSchema,
920
+ defineEventSchema,
921
+ extendsRegistrarSchema,
922
+ envSchemaSchema,
923
+ aiGenerateSchema,
924
+ aiExtractSchema,
925
+ aiClassifySchema,
926
+ unknownPatternSchema,
927
+ ]);
928
+
929
+ // =============================================================================
930
+ // PatternId union
931
+ // =============================================================================
932
+
933
+ const patternIdEntitySchema = z
934
+ .object({ kind: z.literal("entity"), entityName: z.string() })
935
+ .strict();
936
+ const patternIdRelationSchema = z
937
+ .object({ kind: z.literal("relation"), entityName: z.string(), relationName: z.string() })
938
+ .strict();
939
+ const patternIdNavSchema = z.object({ kind: z.literal("nav"), id: z.string() }).strict();
940
+ const patternIdWorkspaceSchema = z
941
+ .object({ kind: z.literal("workspace"), id: z.string() })
942
+ .strict();
943
+ const patternIdScreenSchema = z.object({ kind: z.literal("screen"), id: z.string() }).strict();
944
+ const patternIdWriteHandlerSchema = z
945
+ .object({ kind: z.literal("writeHandler"), handlerName: z.string() })
946
+ .strict();
947
+ const patternIdQueryHandlerSchema = z
948
+ .object({ kind: z.literal("queryHandler"), handlerName: z.string() })
949
+ .strict();
950
+ const patternIdStreamHandlerSchema = z
951
+ .object({ kind: z.literal("streamHandler"), handlerName: z.string() })
952
+ .strict();
953
+ const patternIdHookSchema = z
954
+ .object({
955
+ kind: z.literal("hook"),
956
+ hookType: z.string(),
957
+ target: z.union([z.string(), z.object({ allOf: z.string() }).strict()]),
958
+ })
959
+ .strict();
960
+ const patternIdMetricSchema = z
961
+ .object({ kind: z.literal("metric"), shortName: z.string() })
962
+ .strict();
963
+ const patternIdSecretSchema = z
964
+ .object({ kind: z.literal("secret"), shortName: z.string() })
965
+ .strict();
966
+ const patternIdClaimKeySchema = z
967
+ .object({ kind: z.literal("claimKey"), shortName: z.string() })
968
+ .strict();
969
+ const patternIdReferenceDataSchema = z
970
+ .object({ kind: z.literal("referenceData"), entityName: z.string() })
971
+ .strict();
972
+ const patternIdUseExtensionSchema = z
973
+ .object({ kind: z.literal("useExtension"), extensionName: z.string(), entityName: z.string() })
974
+ .strict();
975
+ const patternIdJobSchema = z.object({ kind: z.literal("job"), jobName: z.string() }).strict();
976
+ const patternIdNotificationSchema = z
977
+ .object({ kind: z.literal("notification"), notificationName: z.string() })
978
+ .strict();
979
+ const patternIdHttpRouteSchema = z
980
+ .object({ kind: z.literal("httpRoute"), method: z.string(), path: z.string() })
981
+ .strict();
982
+ const patternIdProjectionSchema = z
983
+ .object({ kind: z.literal("projection"), name: z.string() })
984
+ .strict();
985
+ const patternIdMultiStreamProjectionSchema = z
986
+ .object({ kind: z.literal("multiStreamProjection"), name: z.string() })
987
+ .strict();
988
+ const patternIdDefineEventSchema = z
989
+ .object({ kind: z.literal("defineEvent"), eventName: z.string() })
990
+ .strict();
991
+ const patternIdExtendsRegistrarSchema = z
992
+ .object({ kind: z.literal("extendsRegistrar"), extensionName: z.string() })
993
+ .strict();
994
+ const patternIdAiGenerateSchema = z
995
+ .object({ kind: z.literal("ai.generate"), stepKey: z.string() })
996
+ .strict();
997
+ const patternIdAiExtractSchema = z
998
+ .object({ kind: z.literal("ai.extract"), stepKey: z.string() })
999
+ .strict();
1000
+ const patternIdAiClassifySchema = z
1001
+ .object({ kind: z.literal("ai.classify"), stepKey: z.string() })
1002
+ .strict();
1003
+ const patternIdRequiresSchema = z.object({ kind: z.literal("requires") }).strict();
1004
+ const patternIdOptionalRequiresSchema = z.object({ kind: z.literal("optionalRequires") }).strict();
1005
+ const patternIdReadsConfigSchema = z.object({ kind: z.literal("readsConfig") }).strict();
1006
+ const patternIdSystemScopeSchema = z.object({ kind: z.literal("systemScope") }).strict();
1007
+ const patternIdToggleableSchema = z.object({ kind: z.literal("toggleable") }).strict();
1008
+ const patternIdDescribeSchema = z.object({ kind: z.literal("describe") }).strict();
1009
+ const patternIdUiHintsSchema = z.object({ kind: z.literal("uiHints") }).strict();
1010
+ const patternIdConfigSchema = z.object({ kind: z.literal("config") }).strict();
1011
+ const patternIdTranslationsSchema = z.object({ kind: z.literal("translations") }).strict();
1012
+ const patternIdAuthClaimsSchema = z.object({ kind: z.literal("authClaims") }).strict();
1013
+ const patternIdTreeActionsSchema = z.object({ kind: z.literal("treeActions") }).strict();
1014
+
1015
+ export const PATTERN_ID_SCHEMAS_BY_KIND = {
1016
+ entity: patternIdEntitySchema,
1017
+ relation: patternIdRelationSchema,
1018
+ nav: patternIdNavSchema,
1019
+ workspace: patternIdWorkspaceSchema,
1020
+ screen: patternIdScreenSchema,
1021
+ writeHandler: patternIdWriteHandlerSchema,
1022
+ queryHandler: patternIdQueryHandlerSchema,
1023
+ streamHandler: patternIdStreamHandlerSchema,
1024
+ hook: patternIdHookSchema,
1025
+ metric: patternIdMetricSchema,
1026
+ secret: patternIdSecretSchema,
1027
+ claimKey: patternIdClaimKeySchema,
1028
+ referenceData: patternIdReferenceDataSchema,
1029
+ useExtension: patternIdUseExtensionSchema,
1030
+ job: patternIdJobSchema,
1031
+ notification: patternIdNotificationSchema,
1032
+ httpRoute: patternIdHttpRouteSchema,
1033
+ projection: patternIdProjectionSchema,
1034
+ multiStreamProjection: patternIdMultiStreamProjectionSchema,
1035
+ defineEvent: patternIdDefineEventSchema,
1036
+ extendsRegistrar: patternIdExtendsRegistrarSchema,
1037
+ "ai.generate": patternIdAiGenerateSchema,
1038
+ "ai.extract": patternIdAiExtractSchema,
1039
+ "ai.classify": patternIdAiClassifySchema,
1040
+ requires: patternIdRequiresSchema,
1041
+ optionalRequires: patternIdOptionalRequiresSchema,
1042
+ readsConfig: patternIdReadsConfigSchema,
1043
+ systemScope: patternIdSystemScopeSchema,
1044
+ toggleable: patternIdToggleableSchema,
1045
+ describe: patternIdDescribeSchema,
1046
+ uiHints: patternIdUiHintsSchema,
1047
+ config: patternIdConfigSchema,
1048
+ translations: patternIdTranslationsSchema,
1049
+ authClaims: patternIdAuthClaimsSchema,
1050
+ treeActions: patternIdTreeActionsSchema,
1051
+ } satisfies Record<PatternId["kind"], z.ZodTypeAny>;
1052
+
1053
+ const patternIdSchema = z.discriminatedUnion("kind", [
1054
+ patternIdEntitySchema,
1055
+ patternIdRelationSchema,
1056
+ patternIdNavSchema,
1057
+ patternIdWorkspaceSchema,
1058
+ patternIdScreenSchema,
1059
+ patternIdWriteHandlerSchema,
1060
+ patternIdQueryHandlerSchema,
1061
+ patternIdStreamHandlerSchema,
1062
+ patternIdHookSchema,
1063
+ patternIdMetricSchema,
1064
+ patternIdSecretSchema,
1065
+ patternIdClaimKeySchema,
1066
+ patternIdReferenceDataSchema,
1067
+ patternIdUseExtensionSchema,
1068
+ patternIdJobSchema,
1069
+ patternIdNotificationSchema,
1070
+ patternIdHttpRouteSchema,
1071
+ patternIdProjectionSchema,
1072
+ patternIdMultiStreamProjectionSchema,
1073
+ patternIdDefineEventSchema,
1074
+ patternIdExtendsRegistrarSchema,
1075
+ patternIdAiGenerateSchema,
1076
+ patternIdAiExtractSchema,
1077
+ patternIdAiClassifySchema,
1078
+ patternIdRequiresSchema,
1079
+ patternIdOptionalRequiresSchema,
1080
+ patternIdReadsConfigSchema,
1081
+ patternIdSystemScopeSchema,
1082
+ patternIdToggleableSchema,
1083
+ patternIdDescribeSchema,
1084
+ patternIdUiHintsSchema,
1085
+ patternIdConfigSchema,
1086
+ patternIdTranslationsSchema,
1087
+ patternIdAuthClaimsSchema,
1088
+ patternIdTreeActionsSchema,
1089
+ ]);
1090
+
1091
+ // =============================================================================
1092
+ // Change union — `rationale` is accepted (optional, on every member) and
1093
+ // dropped when the final PatternChange is assembled below (plain field
1094
+ // selection, not a schema-level transform — see file-header rationale).
1095
+ // =============================================================================
1096
+
1097
+ const addChangeSchema = z
1098
+ .object({
1099
+ op: z.literal("add"),
1100
+ pattern: patternSchema,
1101
+ rationale: z.string().optional(),
1102
+ })
1103
+ .strict();
1104
+
1105
+ const replaceChangeSchema = z
1106
+ .object({
1107
+ op: z.literal("replace"),
1108
+ id: patternIdSchema,
1109
+ pattern: patternSchema,
1110
+ rationale: z.string().optional(),
1111
+ })
1112
+ .strict();
1113
+
1114
+ const removeChangeSchema = z
1115
+ .object({
1116
+ op: z.literal("remove"),
1117
+ id: patternIdSchema,
1118
+ rationale: z.string().optional(),
1119
+ })
1120
+ .strict();
1121
+
1122
+ // `set`/`unset` are validated loosely here; the real per-kind check happens
1123
+ // in a second pass once `id.kind` is known (see parseUpdateChange).
1124
+ const updateChangeSchema = z
1125
+ .object({
1126
+ op: z.literal("update"),
1127
+ id: patternIdSchema,
1128
+ set: z.record(z.string(), z.unknown()).optional(),
1129
+ unset: z.array(z.string()).optional(),
1130
+ rationale: z.string().optional(),
1131
+ })
1132
+ .strict();
1133
+
1134
+ const changeSchema = z.discriminatedUnion("op", [
1135
+ addChangeSchema,
1136
+ replaceChangeSchema,
1137
+ removeChangeSchema,
1138
+ updateChangeSchema,
1139
+ ]);
1140
+
1141
+ function prefixIssuePath(
1142
+ issues: readonly z.core.$ZodIssue[],
1143
+ prefix: PropertyKey,
1144
+ ): z.core.$ZodIssue[] {
1145
+ return issues.map((issue) => ({ ...issue, path: [prefix, ...issue.path] }));
1146
+ }
1147
+
1148
+ // Shared per-`unset[j]` checks ("access" is fixed-required, overlap with
1149
+ // `set`) ahead of the kind-specific enum parse in parseHeaderSetAndUnset.
1150
+ function unsetElementIssue(
1151
+ key: string,
1152
+ j: number,
1153
+ index: number,
1154
+ setKeys: ReadonlySet<string>,
1155
+ ): PatternChangeIssue | undefined {
1156
+ if (key === "access") {
1157
+ return {
1158
+ path: `changes[${index}].unset[${j}]`,
1159
+ message: "access is required and cannot be unset",
1160
+ };
1161
+ }
1162
+ if (setKeys.has(key)) {
1163
+ return { path: `changes[${index}].unset[${j}]`, message: "key is also in set" };
1164
+ }
1165
+ return undefined;
1166
+ }
1167
+
1168
+ // Shared set/unset validation for one handler kind's Set-schema/Unset-enum —
1169
+ // the only thing that differs per kind (see the switch below).
1170
+ function parseHeaderSetAndUnset<
1171
+ SetSchema extends z.ZodType<Record<string, unknown>>,
1172
+ UnsetKey extends string,
1173
+ >(
1174
+ setSchema: SetSchema,
1175
+ unsetEnum: z.ZodType<UnsetKey>,
1176
+ kindLabel: string,
1177
+ value: z.output<typeof updateChangeSchema>,
1178
+ index: number,
1179
+ rawItem: unknown,
1180
+ ):
1181
+ | { readonly ok: true; readonly set: z.output<SetSchema>; readonly unset: readonly UnsetKey[] }
1182
+ | { readonly ok: false; readonly issues: readonly PatternChangeIssue[] } {
1183
+ const issues: PatternChangeIssue[] = [];
1184
+ const setResult = setSchema.safeParse(value.set ?? {});
1185
+ if (!setResult.success) {
1186
+ issues.push(...formatZodIssues(prefixIssuePath(setResult.error.issues, "set"), index, rawItem));
1187
+ }
1188
+ const setKeys = new Set(Object.keys(value.set ?? {}));
1189
+ const unsetInput = value.unset ?? [];
1190
+ const validatedUnset: UnsetKey[] = [];
1191
+ unsetInput.forEach((key, j) => {
1192
+ const preIssue = unsetElementIssue(key, j, index, setKeys);
1193
+ if (preIssue) {
1194
+ issues.push(preIssue);
1195
+ // skip: already reported, no further checks apply to this element
1196
+ return;
1197
+ }
1198
+ const parsed = unsetEnum.safeParse(key);
1199
+ if (!parsed.success) {
1200
+ issues.push({
1201
+ path: `changes[${index}].unset[${j}]`,
1202
+ message: `"${key}" is not a header field of ${kindLabel}`,
1203
+ });
1204
+ // skip: already reported, no further checks apply to this element
1205
+ return;
1206
+ }
1207
+ validatedUnset.push(parsed.data);
1208
+ });
1209
+ const hasSet = value.set !== undefined && Object.keys(value.set).length > 0;
1210
+ if (!hasSet && unsetInput.length === 0) {
1211
+ issues.push({
1212
+ path: `changes[${index}].set`,
1213
+ message: "update needs at least one key in set or unset",
1214
+ });
1215
+ }
1216
+ if (!setResult.success || issues.length > 0) return { ok: false, issues };
1217
+ return { ok: true, set: setResult.data, unset: validatedUnset };
1218
+ }
1219
+
1220
+ // Per-kind switch so `value.id` narrows and `change` needs no cast.
1221
+ function parseUpdateChange(
1222
+ value: z.output<typeof updateChangeSchema>,
1223
+ index: number,
1224
+ rawItem: unknown,
1225
+ ): { readonly change?: PatternChange; readonly issues: readonly PatternChangeIssue[] } {
1226
+ switch (value.id.kind) {
1227
+ case "writeHandler": {
1228
+ const result = parseHeaderSetAndUnset(
1229
+ WRITE_HANDLER_UPDATE_SET_SCHEMA,
1230
+ WRITE_HANDLER_UPDATE_UNSET_ENUM,
1231
+ "writeHandler",
1232
+ value,
1233
+ index,
1234
+ rawItem,
1235
+ );
1236
+ if (!result.ok) return { issues: result.issues };
1237
+ const change: PatternChange = {
1238
+ op: "update",
1239
+ id: value.id,
1240
+ set: result.set,
1241
+ ...(result.unset.length > 0 ? { unset: result.unset } : {}),
1242
+ };
1243
+ return { change, issues: [] };
1244
+ }
1245
+ case "queryHandler": {
1246
+ const result = parseHeaderSetAndUnset(
1247
+ QUERY_HANDLER_UPDATE_SET_SCHEMA,
1248
+ QUERY_HANDLER_UPDATE_UNSET_ENUM,
1249
+ "queryHandler",
1250
+ value,
1251
+ index,
1252
+ rawItem,
1253
+ );
1254
+ if (!result.ok) return { issues: result.issues };
1255
+ const change: PatternChange = {
1256
+ op: "update",
1257
+ id: value.id,
1258
+ set: result.set,
1259
+ ...(result.unset.length > 0 ? { unset: result.unset } : {}),
1260
+ };
1261
+ return { change, issues: [] };
1262
+ }
1263
+ case "streamHandler": {
1264
+ const result = parseHeaderSetAndUnset(
1265
+ STREAM_HANDLER_UPDATE_SET_SCHEMA,
1266
+ STREAM_HANDLER_UPDATE_UNSET_ENUM,
1267
+ "streamHandler",
1268
+ value,
1269
+ index,
1270
+ rawItem,
1271
+ );
1272
+ if (!result.ok) return { issues: result.issues };
1273
+ const change: PatternChange = {
1274
+ op: "update",
1275
+ id: value.id,
1276
+ set: result.set,
1277
+ ...(result.unset.length > 0 ? { unset: result.unset } : {}),
1278
+ };
1279
+ return { change, issues: [] };
1280
+ }
1281
+ default:
1282
+ return {
1283
+ issues: [
1284
+ {
1285
+ path: `changes[${index}].id.kind`,
1286
+ message: `update is not supported for kind "${value.id.kind}"; use replace`,
1287
+ },
1288
+ ],
1289
+ };
1290
+ }
1291
+ }
1292
+
1293
+ // =============================================================================
1294
+ // Issue formatting
1295
+ // =============================================================================
1296
+
1297
+ function formatPath(index: number, path: readonly PropertyKey[]): string {
1298
+ let out = `changes[${index}]`;
1299
+ for (const segment of path) {
1300
+ out += typeof segment === "number" ? `[${segment}]` : `.${String(segment)}`;
1301
+ }
1302
+ return out;
1303
+ }
1304
+
1305
+ function getAtPath(root: unknown, path: readonly PropertyKey[]): unknown {
1306
+ let current: unknown = root;
1307
+ for (const segment of path) {
1308
+ if (isPlainObject(current)) {
1309
+ current = current[String(segment)];
1310
+ } else if (Array.isArray(current) && typeof segment === "number") {
1311
+ current = current[segment];
1312
+ } else {
1313
+ return undefined;
1314
+ }
1315
+ }
1316
+ return current;
1317
+ }
1318
+
1319
+ // zod v4 reports `.strict()` violations as a single `unrecognized_keys`
1320
+ // issue per offending object, carrying all extra key names at once. We
1321
+ // split it into one PatternChangeIssue per key so each unexpected key gets
1322
+ // its own actionable path — except `definition` on a handler-kind pattern
1323
+ // nesting an `access` sub-key, which gets the more specific message the
1324
+ // spec calls for (a caller who nests `access` under a stray `definition`
1325
+ // key is making one specific, common mistake, not an arbitrary unknown key).
1326
+ // Only these kinds render a top-level `access`; on any other kind the hint
1327
+ // would send the caller into a second rejection.
1328
+ function isKindWithTopLevelAccess(kind: unknown): boolean {
1329
+ return kind === "writeHandler" || kind === "queryHandler" || kind === "streamHandler";
1330
+ }
1331
+
1332
+ function formatZodIssues(
1333
+ issues: readonly z.core.$ZodIssue[],
1334
+ index: number,
1335
+ rawItem: unknown,
1336
+ ): PatternChangeIssue[] {
1337
+ const out: PatternChangeIssue[] = [];
1338
+ for (const issue of issues) {
1339
+ if (issue.code === "unrecognized_keys") {
1340
+ const parentValue = getAtPath(rawItem, issue.path);
1341
+ for (const key of issue.keys) {
1342
+ const keyValue = isPlainObject(parentValue) ? parentValue[key] : undefined;
1343
+ if (
1344
+ key === "definition" &&
1345
+ isPlainObject(parentValue) &&
1346
+ isKindWithTopLevelAccess(parentValue["kind"]) &&
1347
+ isPlainObject(keyValue) &&
1348
+ "access" in keyValue
1349
+ ) {
1350
+ out.push({
1351
+ path: `${formatPath(index, issue.path)}.definition.access`,
1352
+ message: "unexpected key; access belongs at top level of the pattern",
1353
+ });
1354
+ continue;
1355
+ }
1356
+ out.push({
1357
+ path: `${formatPath(index, issue.path)}.${key}`,
1358
+ message: "unexpected key",
1359
+ });
1360
+ }
1361
+ continue;
1362
+ }
1363
+ out.push({ path: formatPath(index, issue.path), message: issue.message });
1364
+ }
1365
+ return out;
1366
+ }
1367
+
1368
+ // =============================================================================
1369
+ // Public API
1370
+ // =============================================================================
1371
+
1372
+ export function parsePatternChanges(input: unknown): PatternChangesParseResult {
1373
+ if (!Array.isArray(input)) {
1374
+ return { ok: false, issues: [{ path: "changes", message: "must be an array" }] };
1375
+ }
1376
+
1377
+ const issues: PatternChangeIssue[] = [];
1378
+ const changes: PatternChange[] = [];
1379
+
1380
+ input.forEach((item, index) => {
1381
+ const result = changeSchema.safeParse(item);
1382
+ if (!result.success) {
1383
+ issues.push(...formatZodIssues(result.error.issues, index, item));
1384
+ // skip: schema violations already reported as issues
1385
+ return;
1386
+ }
1387
+ const value = result.data;
1388
+ if (value.op === "replace" && value.id.kind !== value.pattern.kind) {
1389
+ issues.push({
1390
+ path: `changes[${index}].pattern.kind`,
1391
+ message: `pattern.kind "${value.pattern.kind}" does not match id.kind "${value.id.kind}"`,
1392
+ });
1393
+ // skip: id/pattern kind mismatch already reported as an issue
1394
+ return;
1395
+ }
1396
+ if (value.op === "add") {
1397
+ changes.push({ op: "add", pattern: value.pattern });
1398
+ } else if (value.op === "replace") {
1399
+ changes.push({ op: "replace", id: value.id, pattern: value.pattern });
1400
+ } else if (value.op === "remove") {
1401
+ changes.push({ op: "remove", id: value.id });
1402
+ } else {
1403
+ const outcome = parseUpdateChange(value, index, item);
1404
+ issues.push(...outcome.issues);
1405
+ if (outcome.change) changes.push(outcome.change);
1406
+ }
1407
+ });
1408
+
1409
+ if (issues.length > 0) return { ok: false, issues };
1410
+ return { ok: true, changes };
1411
+ }