@rayspec/spec 1.5.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 (62) hide show
  1. package/LICENSE +105 -0
  2. package/dist/brace-params.d.ts +32 -0
  3. package/dist/brace-params.d.ts.map +1 -0
  4. package/dist/brace-params.js +89 -0
  5. package/dist/brace-params.js.map +1 -0
  6. package/dist/detect.d.ts +44 -0
  7. package/dist/detect.d.ts.map +1 -0
  8. package/dist/detect.js +72 -0
  9. package/dist/detect.js.map +1 -0
  10. package/dist/errors.d.ts +204 -0
  11. package/dist/errors.d.ts.map +1 -0
  12. package/dist/errors.js +165 -0
  13. package/dist/errors.js.map +1 -0
  14. package/dist/export.d.ts +54 -0
  15. package/dist/export.d.ts.map +1 -0
  16. package/dist/export.js +121 -0
  17. package/dist/export.js.map +1 -0
  18. package/dist/grammar.d.ts +569 -0
  19. package/dist/grammar.d.ts.map +1 -0
  20. package/dist/grammar.js +503 -0
  21. package/dist/grammar.js.map +1 -0
  22. package/dist/index.d.ts +23 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +26 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/lint.d.ts +79 -0
  27. package/dist/lint.d.ts.map +1 -0
  28. package/dist/lint.js +823 -0
  29. package/dist/lint.js.map +1 -0
  30. package/dist/parse.d.ts +8 -0
  31. package/dist/parse.d.ts.map +1 -0
  32. package/dist/parse.js +118 -0
  33. package/dist/parse.js.map +1 -0
  34. package/dist/product-events.d.ts +73 -0
  35. package/dist/product-events.d.ts.map +1 -0
  36. package/dist/product-events.js +42 -0
  37. package/dist/product-events.js.map +1 -0
  38. package/dist/product-grammar.d.ts +948 -0
  39. package/dist/product-grammar.d.ts.map +1 -0
  40. package/dist/product-grammar.js +581 -0
  41. package/dist/product-grammar.js.map +1 -0
  42. package/dist/product-lint.d.ts +74 -0
  43. package/dist/product-lint.d.ts.map +1 -0
  44. package/dist/product-lint.js +821 -0
  45. package/dist/product-lint.js.map +1 -0
  46. package/dist/product-parse.d.ts +8 -0
  47. package/dist/product-parse.d.ts.map +1 -0
  48. package/dist/product-parse.js +119 -0
  49. package/dist/product-parse.js.map +1 -0
  50. package/dist/product-scope.d.ts +53 -0
  51. package/dist/product-scope.d.ts.map +1 -0
  52. package/dist/product-scope.js +55 -0
  53. package/dist/product-scope.js.map +1 -0
  54. package/dist/product-views-lint.d.ts +14 -0
  55. package/dist/product-views-lint.d.ts.map +1 -0
  56. package/dist/product-views-lint.js +669 -0
  57. package/dist/product-views-lint.js.map +1 -0
  58. package/dist/product-views.d.ts +426 -0
  59. package/dist/product-views.d.ts.map +1 -0
  60. package/dist/product-views.js +317 -0
  61. package/dist/product-views.js.map +1 -0
  62. package/package.json +34 -0
package/dist/lint.js ADDED
@@ -0,0 +1,823 @@
1
+ /**
2
+ * `lintSpec` — the semantic pass beyond Zod shape validation.
3
+ *
4
+ * Zod (`grammar.ts`) proves SHAPE (types, enums, strict unknown-key rejection). The lint pass
5
+ * proves SEMANTICS that Zod cannot express across sections:
6
+ *
7
+ * 1. Cross-references RESOLVE — every `tooling.handler`, `agents[].tools[]`, `api.action.*`,
8
+ * `triggers.action.*`, and `stores[].foreignKeys[].references` points at a declared id/name.
9
+ * 2. NO DUPLICATE ids/names within any section — incl. tooling by `name` (the dispatchTool
10
+ * registry keys on `spec.name`, dispatch.ts), api routes by `${method} ${path}`, and store
11
+ * columns by `name` within a store.
12
+ * 3. CAPABILITY — every agent is run through core `validateSpec(syntheticAgentSpec, backend, …)`
13
+ * so a capability the backend lacks fails at CONFIG time (the canonical violation:
14
+ * `outputSchema` + `requireNativeStructuredOutput:true` on `backend:'pi'`).
15
+ * 4. EMBEDDED SCHEMAS COMPILE — every tool `parameters`/`outputSchema` AND every agent
16
+ * `outputSchema.schema` is compiled with Ajv2020 at load; a malformed one is
17
+ * `invalid_embedded_schema`. A tool's `parameters` must additionally be an OBJECT schema
18
+ * (`type:'object'`) — all 3 backends require object-typed tool args (`schema_violation`).
19
+ * 5. KIND→FIELD coherence — a `cron` trigger requires `schedule`; an `event` trigger requires
20
+ * `event`; a handler referenced by `tooling` must be `kind:'tool'`, by `api` must be `route`,
21
+ * by `triggers` must be `trigger` (so a handler is wired through the right chokepoint).
22
+ * 6. DDL COHERENCE — a store column may not collide with an injected tenancy/GDPR column
23
+ * (`reserved_column_name`); an FK `onDelete:'set null'` requires a NULLABLE local column
24
+ * (`schema_violation`).
25
+ *
26
+ * NOTE on `deployment`: the optional `deployment.durableWorker` is a
27
+ * DEPLOYMENT declaration ("does this deployment run a durable off-request worker?"), gated by
28
+ * `.strict()` shape validation. For the per-REQUEST `async:true` run signal there is NO grammar field
29
+ * to cross-check (it is `StartRunRequest.async`, runs.ts), and the LOAD-BEARING async gate is the
30
+ * RUNTIME one: `async:true` + no durable executor wired ⇒ a clean fail-closed 501 at `executeAgentRun`.
31
+ * BUT a declared `cron` OR `manual` TRIGGER is a CONFIG-LEVEL coupling we CAN check: both are fired by
32
+ * the durable worker (a cron on its crontab, a manual on demand), so a `cron`/`manual` trigger WITHOUT
33
+ * `deployment.durableWorker:true` would be silently never scheduled / un-fireable — rule (5) below
34
+ * rejects that (`schema_violation`). The composition root ALSO boot-aborts on the same coupling
35
+ * (defense-in-depth), but the static lint rule fails it at parse/deploy time.
36
+ *
37
+ * Returns the FULL list of violations (closed `SpecError` codes) — never the first. Pure function
38
+ * over an already-shape-valid `RaySpec` (the parser calls it after the Zod parse succeeds).
39
+ */
40
+ import { validateSpec } from '@rayspec/core';
41
+ import * as Ajv2020Module from 'ajv/dist/2020.js';
42
+ import { specError, specWarning } from './errors.js';
43
+ import { MAX_IDENTIFIER_LENGTH } from './grammar.js';
44
+ const Ajv2020Ctor = (Ajv2020Module.default ?? Ajv2020Module);
45
+ /**
46
+ * Column names the table generator INJECTS on every product table (the tenancy/GDPR pattern —
47
+ * see packages/db/src/schema.ts). An author-declared business column with one of these names
48
+ * would shadow/collide with the injected column, so the linter rejects it fail-closed.
49
+ */
50
+ export const RESERVED_COLUMN_NAMES = new Set([
51
+ 'id',
52
+ 'tenant_id',
53
+ 'created_at',
54
+ 'deleted_at',
55
+ 'retention_days',
56
+ 'region',
57
+ // The injected actor + idempotency columns (server-controlled, never author-declarable).
58
+ 'created_by',
59
+ 'idempotency_key',
60
+ ]);
61
+ /**
62
+ * The list-query CONTROL keywords — the reserved query-string keys the declarative `list` route
63
+ * uses to steer sorting, keyset pagination, and substring search (mirrors `CONTROL_KEYS` in the compose
64
+ * package's store-query.ts; @rayspec/spec cannot import the compose package, so this is the KEEP-IN-SYNC
65
+ * copy).
66
+ *
67
+ * A declared BUSINESS column of one of these names is a config error, because at the `list` route it is
68
+ * (a) silently un-equality-filterable — `buildListQuery` routes `?order=`/`?after=`/`?limit=`/`?search=`
69
+ * to the control parsers and never reaches the per-column equality lookup — AND (b) it makes the emitted
70
+ * OpenAPI document carry a DUPLICATE query parameter (the hard-coded control param + the per-column
71
+ * filter param share a `name`+`in`), which is an INVALID OpenAPI 3.1 doc. This is a SEPARATE set from
72
+ * `RESERVED_COLUMN_NAMES` on purpose — that Set is meta-test-locked to equal the injected columns
73
+ * (`INJECTED_COLUMN_NAMES`); these keywords are not injected columns, they are query controls.
74
+ */
75
+ export const RESERVED_QUERY_KEYWORDS = new Set([
76
+ 'order',
77
+ 'after',
78
+ 'limit',
79
+ 'search',
80
+ // The ranked full-text-search control key (see `FTS_SEARCH_PARAM`) — reserved for the SAME reason as
81
+ // `search`: a business column of this name would be un-filterable (routed to the FTS control parser)
82
+ // and would emit a duplicate OpenAPI query parameter on a full-text-search store.
83
+ '__search',
84
+ ]);
85
+ /**
86
+ * The name of the GENERATED tsvector column the store generator injects when a store declares
87
+ * `fullTextSearch: true` (a GENERATED-ALWAYS-STORED `to_tsvector('simple', …)` column over the store's
88
+ * text columns, backed by a GIN index — see @rayspec/db generate-product-sql). It is a DB-level search
89
+ * structure (NOT represented in the Drizzle ORM twins, exactly like the injected `<table>_tenant_idx`
90
+ * and the idempotency unique index), so it never surfaces in a list response. Reserved: an FTS store may
91
+ * not declare a business column of this name (the FTS coherence check below rejects the clash).
92
+ */
93
+ export const FTS_COLUMN_NAME = 'search_vector';
94
+ /**
95
+ * The ranked full-text-search list-query control key (`?__search=<term>`). Distinct from the substring
96
+ * `search` control key: `__search` is available ONLY on a store that declares `fullTextSearch: true`,
97
+ * runs a `search_vector @@ websearch_to_tsquery('simple', term)` match, and orders by `ts_rank` DESC.
98
+ * A store WITHOUT full-text search rejects the param fail-closed. Mirrored in the compose package's
99
+ * store-query.ts `CONTROL_KEYS` (this package cannot import compose — a KEEP-IN-SYNC copy, guarded by
100
+ * the reserved-keyword membership above).
101
+ */
102
+ export const FTS_SEARCH_PARAM = '__search';
103
+ /**
104
+ * Route prefixes the PLATFORM owns — a declared static frontend mount may neither claim one nor nest
105
+ * under one (the frontend lint rule below), and the static runtime declines any request under one so a
106
+ * platform miss falls through to the uniform JSON 404 rather than a served file / SPA shell
107
+ * (serve-static.ts consumes this SAME constant so the two never drift). Root `/` is NOT here: it is a
108
+ * legitimate static catch-all that coexists with `/v1/*` via registration order + fall-through.
109
+ */
110
+ export const RESERVED_ROUTE_PREFIXES = ['/v1', '/health', '/oidc'];
111
+ /**
112
+ * Find duplicate keys in a list, reporting each duplicate occurrence (by index) as a SpecError.
113
+ * `keyOf` extracts the dedup key; `pathOf` builds the JSON path for a violating index.
114
+ */
115
+ function findDuplicates(items, keyOf, section, pathOf) {
116
+ const errors = [];
117
+ const seen = new Set();
118
+ items.forEach((item, index) => {
119
+ const key = keyOf(item);
120
+ if (seen.has(key)) {
121
+ errors.push(specError('duplicate_name', `duplicate ${section} '${key}' (each ${section} id/name must be unique)`, pathOf(index)));
122
+ }
123
+ else {
124
+ seen.add(key);
125
+ }
126
+ });
127
+ return errors;
128
+ }
129
+ /**
130
+ * snake_case -> camelCase, IDENTICAL to the generator's `toCamel` (generate-product-schema.ts), so
131
+ * the collision check here predicts the exact JS identifier the generator would emit. (TEN-3)
132
+ *
133
+ * EXPORTED for the product-store column-collision check in `product-lint.ts` —
134
+ * the ONE spec-side copy of the rule. KEEP-IN-SYNC (honest replication, dependency direction:
135
+ * spec must not import platform/db): the SAME rule also lives in
136
+ * - packages/db/src/generated/generate-product-schema.ts (`toCamel`) + build-product-tables.ts,
137
+ * - packages/platform/src/handlers/store-facade.ts (`snakeToCamel`),
138
+ * - packages/api-auth/src/engine/injected-columns-view.ts (`snakeToCamel`).
139
+ * A literal-example pin test (product-stores.test.ts, "snake→camel pin") guards this copy against
140
+ * drift; if the rule ever changes, ALL copies + the pin must move together.
141
+ */
142
+ export function toJsIdentifier(name) {
143
+ return name.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
144
+ }
145
+ /**
146
+ * Detect a foreign-key CYCLE among the declared stores (A→B, B→A, directly or transitively). Returns
147
+ * the cycle as a node-name chain (e.g. `['a','b','a']`) or `null` when the product→product FK graph is
148
+ * acyclic. SELF-references are excluded (a self-FK applies fine after the table's own CREATE) and so are
149
+ * references to undeclared stores (already reported as `dangling_ref`). Mirrors the generator's
150
+ * `topoSortStoresByFk` cycle condition, so a spec that lints clean here never throws at generation/apply.
151
+ */
152
+ function findFkCycle(stores) {
153
+ const inSet = new Set(stores.map((s) => s.name));
154
+ // parents[name] = the DISTINCT in-set, non-self stores `name` references (the DDL parents).
155
+ const parents = new Map();
156
+ for (const s of stores) {
157
+ const ps = [];
158
+ for (const fk of s.foreignKeys) {
159
+ if (fk.references === s.name)
160
+ continue; // self-FK: legal, not a cycle edge
161
+ if (!inSet.has(fk.references))
162
+ continue; // dangling: reported as dangling_ref elsewhere
163
+ if (!ps.includes(fk.references))
164
+ ps.push(fk.references);
165
+ }
166
+ parents.set(s.name, ps);
167
+ }
168
+ const WHITE = 0;
169
+ const GRAY = 1;
170
+ const BLACK = 2;
171
+ const color = new Map(stores.map((s) => [s.name, WHITE]));
172
+ const stack = [];
173
+ let cycle = null;
174
+ const visit = (node) => {
175
+ color.set(node, GRAY);
176
+ stack.push(node);
177
+ for (const parent of parents.get(node) ?? []) {
178
+ const pc = color.get(parent);
179
+ if (pc === GRAY) {
180
+ // A back-edge to a node still on the stack ⇒ the cycle is stack[parent..node] + parent.
181
+ const idx = stack.indexOf(parent);
182
+ cycle = [...stack.slice(idx), parent];
183
+ return true;
184
+ }
185
+ if (pc === WHITE && visit(parent))
186
+ return true;
187
+ }
188
+ stack.pop();
189
+ color.set(node, BLACK);
190
+ return false;
191
+ };
192
+ for (const s of stores) {
193
+ if (color.get(s.name) === WHITE && visit(s.name))
194
+ break;
195
+ }
196
+ return cycle;
197
+ }
198
+ /**
199
+ * The JSON-Schema types a declared store column of each `ColumnType` accepts when a run's validated
200
+ * output is written into it (the `persistTo` cross-check). A `jsonb` column accepts any JSON value; a
201
+ * scalar column accepts only its matching JSON-Schema scalar (a `timestamp` takes an ISO string, which
202
+ * the store facade coerces to a Date). Deliberately permissive on `integer` (accepts JSON `number` too —
203
+ * numeric↔numeric) so the check flags only genuine SHAPE mismatches (e.g. an object into a text column),
204
+ * never a false positive on a numeric-typed property. `null` is universally compatible (handled below).
205
+ */
206
+ const PERSIST_COLUMN_COMPAT = {
207
+ text: new Set(['string']),
208
+ uuid: new Set(['string']),
209
+ timestamp: new Set(['string']),
210
+ integer: new Set(['integer', 'number']),
211
+ boolean: new Set(['boolean']),
212
+ jsonb: new Set(['object', 'array', 'string', 'number', 'integer', 'boolean']),
213
+ };
214
+ /** snake_case → camelCase — the SAME rule the store facade + product-table builder use for columns. */
215
+ function toCamelIdent(snake) {
216
+ return snake.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
217
+ }
218
+ /** The declared JSON-Schema type(s) of a property schema, as a list ([] when none is declared). */
219
+ function jsonSchemaTypes(propSchema) {
220
+ if (typeof propSchema !== 'object' || propSchema === null)
221
+ return [];
222
+ const t = propSchema.type;
223
+ if (typeof t === 'string')
224
+ return [t];
225
+ if (Array.isArray(t))
226
+ return t.filter((x) => typeof x === 'string');
227
+ return [];
228
+ }
229
+ /**
230
+ * Validate an agent action's optional `persistTo` (shared by api routes AND triggers). Two directions,
231
+ * both fail-closed at DEPLOY so nothing surfaces at the runtime persist write; aggregates ALL violations:
232
+ *
233
+ * FORWARD — the target store must be declared, the referenced agent must declare an OBJECT
234
+ * `outputSchema`, and EVERY output property must map to a WRITABLE business column of a compatible type.
235
+ *
236
+ * REVERSE — every REQUIRED (NOT-NULL, no-default) business column of the store must be reliably produced
237
+ * by the output: (a) a matching output property exists, (b) that property is in the schema's `required`
238
+ * array (an optional property the model may omit would violate NOT-NULL), and (c) the property's type
239
+ * does not include `null`. Plus (d): where a store column and the mapped property BOTH declare an `enum`,
240
+ * the property's enum must be a subset of the column's whitelist. Without the reverse pass, an uncovered
241
+ * NOT-NULL column (or a nullable/optional property mapped to one) fails the INSERT with a NOT-NULL
242
+ * violation AFTER the run has billed — silently defeating the exactly-once persist.
243
+ */
244
+ function checkPersistTo(persistTo, agentId, storeByName, agentById, pathPrefix, refLabel) {
245
+ const out = [];
246
+ const store = storeByName.get(persistTo);
247
+ if (!store) {
248
+ out.push(specError('dangling_ref', `${refLabel} persists to unknown store '${persistTo}'`, `${pathPrefix}.persistTo`));
249
+ return out; // no store ⇒ the column checks below cannot run
250
+ }
251
+ // A dangling agent ref is reported by the caller's agent-existence check; only proceed if resolvable.
252
+ const agent = agentById.get(agentId);
253
+ if (!agent)
254
+ return out;
255
+ const schema = agent.outputSchema?.schema;
256
+ if (!agent.outputSchema || schema === undefined) {
257
+ out.push(specError('schema_violation', `${refLabel} sets persistTo '${persistTo}' but agent '${agentId}' declares no outputSchema — there ` +
258
+ 'is no structured output to persist. Declare an outputSchema on the agent, or drop persistTo', `${pathPrefix}.persistTo`));
259
+ return out;
260
+ }
261
+ const properties = schema.properties;
262
+ if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) {
263
+ out.push(specError('schema_violation', `${refLabel} sets persistTo '${persistTo}' but agent '${agentId}' outputSchema declares no object ` +
264
+ "'properties' — persistTo requires an object output whose properties map to the store's columns", `${pathPrefix}.persistTo`));
265
+ return out;
266
+ }
267
+ // name (snake OR camel) → the declared business column of the target store (the facade accepts both).
268
+ const columnByName = new Map();
269
+ for (const c of store.columns) {
270
+ columnByName.set(c.name, c);
271
+ columnByName.set(toCamelIdent(c.name), c);
272
+ }
273
+ for (const [propName, propSchema] of Object.entries(properties)) {
274
+ const column = columnByName.get(propName);
275
+ if (!column) {
276
+ out.push(specError('schema_violation', `${refLabel} persists to store '${persistTo}', but the agent's output property '${propName}' is ` +
277
+ 'not a writable business column of that store (server-controlled columns like id/tenant_id/' +
278
+ 'created_at/created_by are not writable — check the declared column name)', `${pathPrefix}.persistTo`));
279
+ continue;
280
+ }
281
+ const declaredTypes = jsonSchemaTypes(propSchema);
282
+ const compat = PERSIST_COLUMN_COMPAT[column.type];
283
+ const incompatible = declaredTypes.filter((t) => t !== 'null' && !compat.has(t));
284
+ if (declaredTypes.length > 0 && incompatible.length > 0) {
285
+ out.push(specError('schema_violation', `${refLabel} persists output property '${propName}' (type ${incompatible
286
+ .map((t) => `'${t}'`)
287
+ .join('/')}) into store '${persistTo}' column '${column.name}' of type '${column.type}', which ` +
288
+ 'is not a compatible type', `${pathPrefix}.persistTo`));
289
+ }
290
+ }
291
+ // The forward loop above proves every OUTPUT property maps to a compatible writable column. It does
292
+ // NOT prove the CONVERSE: that every REQUIRED (NOT-NULL, no-default) business column of the store is
293
+ // actually produced by the output. A business column defaults NOT NULL (grammar) and gets NO database
294
+ // default (the product-table builder emits `.notNull()` with no `.default()` on business columns), so a
295
+ // NOT-NULL column the output does not reliably fill fails the runtime INSERT with a NOT-NULL violation
296
+ // AFTER the run has billed — silently defeating the exactly-once persist. Reject that at DEPLOY. The
297
+ // server-controlled/injected columns (id / tenant_id / created_at / created_by / region / …) are filled
298
+ // by the platform, never by the output — RESERVED_COLUMN_NAMES is the authoritative injected-column set
299
+ // (meta-locked to INJECTED_COLUMN_NAMES), so they are exempt.
300
+ const props = properties;
301
+ const requiredSet = new Set(Array.isArray(schema.required)
302
+ ? schema.required.filter((x) => typeof x === 'string')
303
+ : []);
304
+ // The OUTPUT property that maps to a store column (snake OR camel — the facade accepts both), if any.
305
+ const propNameForColumn = (columnName) => {
306
+ if (columnName in props)
307
+ return columnName;
308
+ const camel = toCamelIdent(columnName);
309
+ if (camel in props)
310
+ return camel;
311
+ return undefined;
312
+ };
313
+ for (const column of store.columns) {
314
+ // Only NOT-NULL business columns must be covered; a nullable column may be omitted safely. Injected
315
+ // columns are platform-filled (never author-declarable as business columns anyway) — skip them.
316
+ if (column.nullable === true)
317
+ continue;
318
+ if (RESERVED_COLUMN_NAMES.has(column.name))
319
+ continue;
320
+ const propName = propNameForColumn(column.name);
321
+ if (propName === undefined) {
322
+ // (a) No output property maps to this NOT-NULL column → a runtime NOT-NULL violation.
323
+ out.push(specError('schema_violation', `${refLabel} persists to store '${persistTo}', but its NOT-NULL column '${column.name}' is not ` +
324
+ `produced by agent '${agentId}' outputSchema — no output property maps to it, so the persist ` +
325
+ "write would fail the column's NOT-NULL constraint at runtime. Add a matching output property " +
326
+ `(named '${column.name}'), or make the column nullable`, `${pathPrefix}.persistTo`));
327
+ continue;
328
+ }
329
+ // (b) The property exists but is not in `required` → the model may OMIT it → a runtime NOT-NULL
330
+ // violation (a property present in `properties` but absent from `required` is optional output).
331
+ if (!requiredSet.has(propName)) {
332
+ out.push(specError('schema_violation', `${refLabel} persists to store '${persistTo}': output property '${propName}' maps to NOT-NULL ` +
333
+ `column '${column.name}' but is not in the outputSchema 'required' array — the model may omit ` +
334
+ "it, failing the column's NOT-NULL constraint at runtime. Add it to 'required', or make the " +
335
+ 'column nullable', `${pathPrefix}.persistTo`));
336
+ }
337
+ // (c) The property's declared type includes 'null' → the model may EMIT null into a NOT-NULL column.
338
+ if (jsonSchemaTypes(props[propName]).includes('null')) {
339
+ out.push(specError('schema_violation', `${refLabel} persists to store '${persistTo}': output property '${propName}' declares a nullable ` +
340
+ `type (includes 'null') but maps to NOT-NULL column '${column.name}' — an emitted null would ` +
341
+ "fail the column's NOT-NULL constraint at runtime. Remove 'null' from the property's type, or " +
342
+ 'make the column nullable', `${pathPrefix}.persistTo`));
343
+ }
344
+ }
345
+ // (d) ENUM whitelist subset: a store column `enum` is a value whitelist the platform enforces
346
+ // server-side (an out-of-whitelist write is a fail-closed StoreInputError at runtime). When the mapped
347
+ // output property ALSO declares an `enum`, EVERY member must be within the column's whitelist — else the
348
+ // model may legally emit a value the store rejects, failing the persist AFTER the run has billed. (A
349
+ // property with NO enum against an enum column is deliberately NOT rejected here: the value may still be
350
+ // bounded by the agent's instructions, and forcing an enum on every such property would be over-strict —
351
+ // that residual is a runtime fail-closed case, documented rather than blocked at deploy.)
352
+ for (const column of store.columns) {
353
+ if (column.enum === undefined)
354
+ continue;
355
+ const propName = propNameForColumn(column.name);
356
+ if (propName === undefined)
357
+ continue; // coverage is handled above; nothing maps here
358
+ const propSchema = props[propName];
359
+ const propEnum = typeof propSchema === 'object' && propSchema !== null
360
+ ? propSchema.enum
361
+ : undefined;
362
+ if (!Array.isArray(propEnum))
363
+ continue; // no property enum → the documented runtime-fail-closed residual
364
+ const allowed = new Set(column.enum);
365
+ // A member outside the whitelist — OR a non-string member (the enum column stores text) — is invalid.
366
+ const offenders = propEnum.filter((v) => typeof v !== 'string' || !allowed.has(v));
367
+ if (offenders.length > 0) {
368
+ out.push(specError('schema_violation', `${refLabel} persists to store '${persistTo}': output property '${propName}' enum includes ` +
369
+ `${offenders.map((v) => JSON.stringify(v)).join('/')}, outside store column '${column.name}' ` +
370
+ 'enum whitelist — the store rejects an out-of-whitelist value fail-closed at runtime. Constrain ' +
371
+ "the property's enum to a subset of the column's whitelist", `${pathPrefix}.persistTo`));
372
+ }
373
+ }
374
+ return out;
375
+ }
376
+ /** The full semantic pass. Input is already shape-valid (post-Zod-parse). */
377
+ export function lintSpec(spec) {
378
+ const errors = [];
379
+ // ---- ID/NAME SETS (built once; reused by the cross-ref checks) ------------------------
380
+ const storeNames = new Set(spec.stores.map((s) => s.name));
381
+ // name -> store, so a business-key FK (`referencesColumn`) can resolve its referenced column in the
382
+ // TARGET store (unique + type-match validation below).
383
+ const storeByName = new Map(spec.stores.map((s) => [s.name, s]));
384
+ // id -> agent, so an action's `persistTo` can resolve the referenced agent's outputSchema and check
385
+ // it maps to the target store's columns.
386
+ const agentById = new Map(spec.agents.map((a) => [a.id, a]));
387
+ const agentIds = new Set(spec.agents.map((a) => a.id));
388
+ const toolIds = new Set(spec.tooling.map((t) => t.id));
389
+ // handlers indexed by id -> kind, so a ref can also assert the handler is the RIGHT kind.
390
+ const handlerKindById = new Map(spec.handlers.map((h) => [h.id, h.kind]));
391
+ // ---- 2. DUPLICATES (within each section) ----------------------------------------------
392
+ errors.push(...findDuplicates(spec.stores, (s) => s.name, 'store name', (i) => `stores[${i}].name`), ...findDuplicates(spec.agents, (a) => a.id, 'agent id', (i) => `agents[${i}].id`), ...findDuplicates(spec.tooling, (t) => t.id, 'tooling id', (i) => `tooling[${i}].id`),
393
+ // Tool NAME (not just id): dispatchTool keys its registry by `t.spec.name` (dispatch.ts), so
394
+ // two tools sharing a name silently collide at runtime — one handler is lost. Reject at config.
395
+ ...findDuplicates(spec.tooling, (t) => t.name, 'tooling name', (i) => `tooling[${i}].name`), ...findDuplicates(spec.handlers, (h) => h.id, 'handler id', (i) => `handlers[${i}].id`), ...findDuplicates(spec.triggers, (t) => t.name, 'trigger name', (i) => `triggers[${i}].name`),
396
+ // API route uniqueness: a duplicate `${method} ${path}` would register two handlers on one
397
+ // route — the second silently shadows the first. Reject at config.
398
+ ...findDuplicates(spec.api, (r) => `${r.method} ${r.path}`, 'api route', (i) => `api[${i}].path`));
399
+ // ---- 1 & 5. CROSS-REFS + KIND COHERENCE -----------------------------------------------
400
+ // stores: column uniqueness + reserved-name guard + FK resolution + FK on-delete coherence.
401
+ spec.stores.forEach((store, si) => {
402
+ // (6) Duplicate column names within this store.
403
+ errors.push(...findDuplicates(store.columns, (c) => c.name, 'store column', (i) => `stores[${si}].columns[${i}].name`));
404
+ // A column -> column map (used by the FK column-resolution + 'set null' coherence check below).
405
+ const columnByName = new Map(store.columns.map((c) => [c.name, c]));
406
+ // (9) Reserved (injected) column names — a business column may not shadow a tenancy/GDPR column.
407
+ store.columns.forEach((col, ci) => {
408
+ if (RESERVED_COLUMN_NAMES.has(col.name)) {
409
+ errors.push(specError('reserved_column_name', `store '${store.name}' declares reserved column '${col.name}' — that column is injected ` +
410
+ 'by the generator (tenancy/GDPR); rename the business column', `stores[${si}].columns[${ci}].name`));
411
+ }
412
+ else if (RESERVED_QUERY_KEYWORDS.has(col.name)) {
413
+ // A column named after a list-query control keyword would be un-filterable AND would emit
414
+ // a duplicate OpenAPI query parameter — reject at config with an explaining rename hint.
415
+ errors.push(specError('reserved_query_keyword', `store '${store.name}' declares column '${col.name}', which collides with a reserved ` +
416
+ 'list-query control keyword (order/after/limit/search/__search) the declarative list route ' +
417
+ 'uses for sorting/keyset pagination/substring/full-text search — the column would be ' +
418
+ 'un-filterable and would emit a duplicate OpenAPI query parameter; rename the business column', `stores[${si}].columns[${ci}].name`));
419
+ }
420
+ // (enum) An `enum` whitelist is a TEXT-column value constraint the platform enforces server-side
421
+ // (store-validation derives a `z.enum` → an out-of-whitelist create/update value is a 400). The
422
+ // grammar already pins a non-empty array of non-empty members; the lint adds the two facts Zod
423
+ // cannot express here: (a) it belongs ONLY on a text column; (b) the members are DISTINCT.
424
+ if (col.enum !== undefined) {
425
+ if (col.type !== 'text') {
426
+ errors.push(specError('schema_violation', `store '${store.name}' column '${col.name}' declares an enum whitelist but is type ` +
427
+ `'${col.type}' — enum is only valid on a 'text' column`, `stores[${si}].columns[${ci}].enum`));
428
+ }
429
+ const seenValues = new Set();
430
+ for (const value of col.enum) {
431
+ if (seenValues.has(value)) {
432
+ errors.push(specError('schema_violation', `store '${store.name}' column '${col.name}' enum has a duplicate value ` +
433
+ `'${value}' — enum members must be distinct`, `stores[${si}].columns[${ci}].enum`));
434
+ break; // one report per column is enough (the author fixes the list in one pass)
435
+ }
436
+ seenValues.add(value);
437
+ }
438
+ }
439
+ });
440
+ // (FTS) A store that opts into full-text search MUST declare at least one `text` column — the
441
+ // generated tsvector is built over the store's text columns, so a text-less FTS store would index
442
+ // nothing (fail-closed rather than materialize a useless empty-vector column). It also may not
443
+ // declare a business column named `search_vector` (FTS_COLUMN_NAME), the reserved name the generator
444
+ // injects for the GENERATED-ALWAYS tsvector column when full-text search is enabled.
445
+ if (store.fullTextSearch === true) {
446
+ if (!store.columns.some((c) => c.type === 'text')) {
447
+ errors.push(specError('schema_violation', `store '${store.name}' enables fullTextSearch but declares no 'text' column — the ` +
448
+ 'generated full-text-search vector would index nothing; add a text column or remove ' +
449
+ 'fullTextSearch', `stores[${si}].fullTextSearch`));
450
+ }
451
+ const clashIndex = store.columns.findIndex((c) => c.name === FTS_COLUMN_NAME);
452
+ if (clashIndex >= 0) {
453
+ errors.push(specError('reserved_column_name', `store '${store.name}' declares column '${FTS_COLUMN_NAME}', which is reserved for the ` +
454
+ 'generated tsvector column the generator injects when fullTextSearch is enabled; rename ' +
455
+ 'the business column', `stores[${si}].columns[${clashIndex}].name`));
456
+ }
457
+ }
458
+ store.foreignKeys.forEach((fk, fi) => {
459
+ // (FK-NAME-LEN) The generated Postgres constraint name is a TOTAL function of
460
+ // `(table, column, references, refCol)` — `<table>_<column>_<references>_<refCol>_fk` — mirroring
461
+ // `fkConstraintName` in @rayspec/db, the single source of truth (kept in sync by this comment; the
462
+ // db layer cannot be imported here — @rayspec/db depends on @rayspec/spec, so the reverse edge
463
+ // would cycle). Each identifier is individually ≤ MAX_IDENTIFIER_LENGTH (SafeIdentifier), but their
464
+ // CONCATENATION is not, and Postgres SILENTLY TRUNCATES an ADD CONSTRAINT name past 63 bytes. A
465
+ // truncated name (a) breaks the store-route 23503 UPDATE discriminator, which matches the reported
466
+ // `constraint_name` EXACTLY against this full computed name to tell an own-FK bad-INPUT update (400)
467
+ // apart from a child-restrict conflict (409) — a truncation is a missed match → a wrongful 409; and
468
+ // (b) can truncate-COLLIDE two distinct long FK names into one real DDL conflict. Reject it at config
469
+ // time so the emitted name is never truncated (fail-closed at the source).
470
+ const refCol = fk.referencesColumn ?? 'id';
471
+ const constraintName = `${store.name}_${fk.column}_${fk.references}_${refCol}_fk`;
472
+ if (constraintName.length > MAX_IDENTIFIER_LENGTH) {
473
+ errors.push(specError('schema_violation', `store '${store.name}' foreign key on column '${fk.column}' generates the constraint name ` +
474
+ `'${constraintName}' (${constraintName.length} chars), which exceeds the ` +
475
+ `${MAX_IDENTIFIER_LENGTH}-char Postgres identifier limit — Postgres would SILENTLY TRUNCATE ` +
476
+ 'it (breaking the update-conflict 400-vs-409 discriminator and risking a name collision). ' +
477
+ 'Use shorter identifiers for the store name, the FK column, the referenced store, or the ' +
478
+ 'referenced column', `stores[${si}].foreignKeys[${fi}].column`));
479
+ }
480
+ if (!storeNames.has(fk.references)) {
481
+ errors.push(specError('dangling_ref', `store '${store.name}' foreign key references unknown store '${fk.references}'`, `stores[${si}].foreignKeys[${fi}].references`));
482
+ }
483
+ const fkColumn = columnByName.get(fk.column);
484
+ if (fkColumn === undefined) {
485
+ errors.push(specError('dangling_ref', `store '${store.name}' foreign key column '${fk.column}' is not a declared column`, `stores[${si}].foreignKeys[${fi}].column`));
486
+ }
487
+ else if (fk.referencesColumn === undefined) {
488
+ // ID-TARGET FK (default): the local column references the parent's injected uuid PK (`id`), so
489
+ // it MUST be declared `type:'uuid'` (GEN-1). A non-uuid FK column diverges the generators (the
490
+ // TS generator forces uuid() while the SQL generator emits the author type) and yields an
491
+ // unappliable migration — reject it at config time.
492
+ if (fkColumn.type !== 'uuid') {
493
+ errors.push(specError('schema_violation', `store '${store.name}' foreign key column '${fk.column}' is type '${fkColumn.type}' but ` +
494
+ "must be 'uuid' (it references the parent store's injected uuid primary key)", `stores[${si}].foreignKeys[${fi}].column`));
495
+ }
496
+ if (fk.onDelete === 'set null' && fkColumn.nullable === false) {
497
+ // (8) ON DELETE SET NULL requires a NULLABLE column — otherwise the DDL is self-contradictory.
498
+ errors.push(specError('schema_violation', `store '${store.name}' foreign key on column '${fk.column}' uses onDelete:'set null' but ` +
499
+ 'the column is NOT NULL — make it nullable or change the on-delete policy', `stores[${si}].foreignKeys[${fi}].onDelete`));
500
+ }
501
+ }
502
+ else {
503
+ // BUSINESS-KEY FK: the local column references a NAMED unique column of the target store — a
504
+ // TENANT-SCOPED COMPOUND FK `(tenant_id, col) -> parent(tenant_id, refcol)`.
505
+ //
506
+ // (a) onDelete:'set null' is IMPOSSIBLE on a compound FK — it would have to null `tenant_id`,
507
+ // which is NOT NULL by construction. A business-key FK supports 'cascade' or 'restrict' only.
508
+ if (fk.onDelete === 'set null') {
509
+ errors.push(specError('schema_violation', `store '${store.name}' foreign key on column '${fk.column}' uses onDelete:'set null' with ` +
510
+ 'referencesColumn — a business-key FK is a tenant-scoped compound key and cannot null ' +
511
+ "tenant_id; use onDelete:'cascade' or 'restrict'", `stores[${si}].foreignKeys[${fi}].onDelete`));
512
+ }
513
+ // (b) Resolve the referenced column in the TARGET store (skip when the target store is dangling —
514
+ // the dangling_ref above already reports that).
515
+ const targetStore = storeByName.get(fk.references);
516
+ if (targetStore !== undefined) {
517
+ const targetCol = targetStore.columns.find((c) => c.name === fk.referencesColumn);
518
+ if (targetCol === undefined) {
519
+ errors.push(specError('dangling_ref', `store '${store.name}' foreign key referencesColumn '${fk.referencesColumn}' is not a ` +
520
+ `declared column of the referenced store '${fk.references}'`, `stores[${si}].foreignKeys[${fi}].referencesColumn`));
521
+ }
522
+ else {
523
+ // (c) A FK can only reference a UNIQUE column (Postgres requires a matching unique index).
524
+ if (targetCol.unique !== true) {
525
+ errors.push(specError('schema_violation', `store '${store.name}' foreign key referencesColumn ` +
526
+ `'${fk.references}.${fk.referencesColumn}' must be declared 'unique: true' — a ` +
527
+ 'foreign key can only reference a unique column', `stores[${si}].foreignKeys[${fi}].referencesColumn`));
528
+ }
529
+ // (d) The local FK column's type MUST match the referenced column's type (relaxes the
530
+ // uuid-only GEN-1 rule for a non-id target — a slug FK is text, a code FK is integer, …).
531
+ if (fkColumn.type !== targetCol.type) {
532
+ errors.push(specError('schema_violation', `store '${store.name}' foreign key column '${fk.column}' is type '${fkColumn.type}' but ` +
533
+ `references '${fk.references}.${fk.referencesColumn}' of type '${targetCol.type}' — ` +
534
+ "an FK column's type must match its referenced column", `stores[${si}].foreignKeys[${fi}].column`));
535
+ }
536
+ }
537
+ }
538
+ }
539
+ });
540
+ // (TEN-3) Two store columns whose names camelCase to the SAME JS identifier would collide as
541
+ // duplicate keys in the generated TS table object (e.g. `foo_bar` and `fooBar` both -> fooBar).
542
+ // The safe-identifier grammar narrows the input, but `_`-vs-camel ambiguity remains — reject it.
543
+ errors.push(...findDuplicates(store.columns, (c) => toJsIdentifier(c.name), 'store column camelCase identifier', (i) => `stores[${si}].columns[${i}].name`));
544
+ });
545
+ // (TEN-3) Two STORE names that camelCase to the same const identifier collide as duplicate consts
546
+ // in the generated product-schema module (e.g. `audit_log` and `auditLog` both -> auditLog).
547
+ errors.push(...findDuplicates(spec.stores, (s) => toJsIdentifier(s.name), 'store camelCase identifier', (i) => `stores[${i}].name`));
548
+ // FK CYCLE — a circular foreign-key reference (A→B, B→A, directly or transitively) is UNORDERABLE:
549
+ // no CREATE order lets every store's FK ADD find its parent table already present, so it fails at
550
+ // apply (42P01). Reject it fail-closed at config time (the generator's topoSortStoresByFk throws on
551
+ // the same condition — this is the config-level gate so it never reaches apply). Self-references and
552
+ // references to an unknown store (already `dangling_ref`) are excluded from the graph.
553
+ const fkCycle = findFkCycle(spec.stores);
554
+ if (fkCycle !== null) {
555
+ const si = spec.stores.findIndex((s) => s.name === fkCycle[0]);
556
+ errors.push(specError('fk_cycle', `stores form a circular foreign-key reference (${fkCycle.join(' -> ')}) — a circular FK is ` +
557
+ "unorderable (each store's FK ADD needs its parent table created first) and cannot be " +
558
+ 'applied; break the cycle (make one side nullable and add it in a separate migration)', si >= 0 ? `stores[${si}].foreignKeys` : undefined));
559
+ }
560
+ // tooling[].handler -> a declared handler of kind 'tool'.
561
+ spec.tooling.forEach((tool, ti) => {
562
+ const kind = handlerKindById.get(tool.handler);
563
+ if (kind === undefined) {
564
+ errors.push(specError('dangling_ref', `tool '${tool.id}' references unknown handler '${tool.handler}'`, `tooling[${ti}].handler`));
565
+ }
566
+ else if (kind !== 'tool') {
567
+ errors.push(specError('dangling_ref', `tool '${tool.id}' references handler '${tool.handler}' of kind '${kind}', expected 'tool'`, `tooling[${ti}].handler`));
568
+ }
569
+ });
570
+ // agents[].tools[] -> declared tooling ids.
571
+ spec.agents.forEach((agent, ai) => {
572
+ agent.tools.forEach((toolId, tidx) => {
573
+ if (!toolIds.has(toolId)) {
574
+ errors.push(specError('dangling_ref', `agent '${agent.id}' references unknown tool '${toolId}'`, `agents[${ai}].tools[${tidx}]`));
575
+ }
576
+ });
577
+ });
578
+ // api[].action.* -> declared store/agent/handler/stream (handler must be kind 'route').
579
+ spec.api.forEach((route, ri) => {
580
+ const action = route.action;
581
+ if (action.kind === 'store') {
582
+ if (!storeNames.has(action.store)) {
583
+ errors.push(specError('dangling_ref', `route ${route.method} ${route.path} references unknown store '${action.store}'`, `api[${ri}].action.store`));
584
+ }
585
+ }
586
+ else if (action.kind === 'agent') {
587
+ if (!agentIds.has(action.agent)) {
588
+ errors.push(specError('dangling_ref', `route ${route.method} ${route.path} references unknown agent '${action.agent}'`, `api[${ri}].action.agent`));
589
+ }
590
+ if (action.persistTo !== undefined) {
591
+ errors.push(...checkPersistTo(action.persistTo, action.agent, storeByName, agentById, `api[${ri}].action`, `route ${route.method} ${route.path}`));
592
+ }
593
+ }
594
+ else {
595
+ // handler OR stream action — both resolve `action.handler` against a declared `route`-kind
596
+ // handler (a stream handler dispatches through the api chokepoint, like a `{handler}` route —
597
+ // the ingest/playback `mode` is a runtime concern, not a handler kind). The shared
598
+ // resolution below covers both kinds.
599
+ const kind = handlerKindById.get(action.handler);
600
+ if (kind === undefined) {
601
+ errors.push(specError('dangling_ref', `route ${route.method} ${route.path} references unknown handler '${action.handler}'`, `api[${ri}].action.handler`));
602
+ }
603
+ else if (kind !== 'route') {
604
+ errors.push(specError('dangling_ref', `route ${route.method} ${route.path} handler '${action.handler}' is kind '${kind}', expected 'route'`, `api[${ri}].action.handler`));
605
+ }
606
+ }
607
+ });
608
+ // ---- frontend[] static mounts — route COLLISIONS (fail-closed) -------------------------------
609
+ // A declared static frontend mount is served alongside the API (composition-root / serve-static.ts).
610
+ // Its `route` must not collide with (a) another mount, (b) a declared `api[].path` (one would shadow
611
+ // the other), or (c) a reserved system prefix (`/v1`, `/health`, `/oidc` — platform-owned). Root `/`
612
+ // is EXEMPT: it never equals an api path nor nests under a reserved prefix, and a static-last `/` mount
613
+ // legitimately coexists with `/v1/*` (registration order + a static miss fall-through).
614
+ const apiRoutePaths = new Set(spec.api.map((r) => r.path));
615
+ const seenFrontendRoutes = new Set();
616
+ (spec.frontend ?? []).forEach((mount, fi) => {
617
+ const route = mount.route;
618
+ // (a) DUPLICATE mount route.
619
+ if (seenFrontendRoutes.has(route)) {
620
+ errors.push(specError('frontend_route_collision', `duplicate frontend route '${route}' (each frontend mount route must be unique)`, `frontend[${fi}].route`));
621
+ }
622
+ else {
623
+ seenFrontendRoutes.add(route);
624
+ }
625
+ // (b) EXACTLY equals a declared api route path — a static mount and an api route cannot share a path.
626
+ if (apiRoutePaths.has(route)) {
627
+ errors.push(specError('frontend_route_collision', `frontend route '${route}' collides with a declared api route path — a static mount and an ` +
628
+ 'api route cannot share a path; choose a different frontend route', `frontend[${fi}].route`));
629
+ }
630
+ // (c) EQUALS or NESTS UNDER a reserved system prefix (root `/` is exempt — it matches neither).
631
+ if (RESERVED_ROUTE_PREFIXES.some((p) => route === p || route.startsWith(`${p}/`))) {
632
+ errors.push(specError('frontend_route_collision', `frontend route '${route}' is reserved for the platform (${RESERVED_ROUTE_PREFIXES.join(', ')}); choose a different route`, `frontend[${fi}].route`));
633
+ }
634
+ });
635
+ // ---- extensions[] DUPLICATE ids (cross-ref/merge resolution lands in `loadExtensions`) ----------------
636
+ // The `loadExtensions` merge keys packs by `id`; two refs sharing an id would silently
637
+ // collide (one pack lost). Reject at config time — symmetric with the other section dup checks.
638
+ errors.push(...findDuplicates(spec.extensions, (e) => e.id, 'extension id', (i) => `extensions[${i}].id`));
639
+ // triggers[].action.* -> declared agent/handler (handler must be kind 'trigger'); kind->field.
640
+ spec.triggers.forEach((trigger, ti) => {
641
+ const action = trigger.action;
642
+ if (action.kind === 'agent') {
643
+ if (!agentIds.has(action.agent)) {
644
+ errors.push(specError('dangling_ref', `trigger '${trigger.name}' references unknown agent '${action.agent}'`, `triggers[${ti}].action.agent`));
645
+ }
646
+ if (action.persistTo !== undefined) {
647
+ errors.push(...checkPersistTo(action.persistTo, action.agent, storeByName, agentById, `triggers[${ti}].action`, `trigger '${trigger.name}'`));
648
+ }
649
+ }
650
+ else {
651
+ const kind = handlerKindById.get(action.handler);
652
+ if (kind === undefined) {
653
+ errors.push(specError('dangling_ref', `trigger '${trigger.name}' references unknown handler '${action.handler}'`, `triggers[${ti}].action.handler`));
654
+ }
655
+ else if (kind !== 'trigger') {
656
+ errors.push(specError('dangling_ref', `trigger '${trigger.name}' handler '${action.handler}' is kind '${kind}', expected 'trigger'`, `triggers[${ti}].action.handler`));
657
+ }
658
+ }
659
+ // kind -> required field coherence (cron needs schedule; event needs event).
660
+ if (trigger.kind === 'cron' && trigger.schedule === undefined) {
661
+ errors.push(specError('schema_violation', `cron trigger '${trigger.name}' is missing 'schedule'`, `triggers[${ti}].schedule`));
662
+ }
663
+ // A cron trigger is FIRED by the durable off-request worker. Without
664
+ // `deployment.durableWorker:true` no worker is wired, so the cron would be SILENTLY not scheduled
665
+ // (it never fires — no error at deploy, just nothing at 2am). Reject at config time: a declared
666
+ // cron REQUIRES the durable worker. (Defense-in-depth: composition-root ALSO boot-aborts if a cron
667
+ // is registered with no worker wired — see deployDeclaredSpec.)
668
+ if (trigger.kind === 'cron' && spec.deployment?.durableWorker !== true) {
669
+ errors.push(specError('schema_violation', `cron trigger '${trigger.name}' requires 'deployment.durableWorker: true' — a cron is fired ` +
670
+ 'by the durable off-request worker; without it the trigger would never fire (silently ' +
671
+ 'unscheduled). Set deployment.durableWorker:true or remove the cron trigger', `triggers[${ti}].kind`));
672
+ }
673
+ // A manual trigger is FIRED on demand through the SAME durable off-request worker (its exactly-once
674
+ // reserve→dispatch machinery). Without `deployment.durableWorker:true` no worker is wired, so an
675
+ // explicit fire could never dispatch — the trigger would be declared but un-fireable. Reject at
676
+ // config time: a declared manual trigger REQUIRES the durable worker. (Defense-in-depth: the
677
+ // composition-root boot ALSO aborts if a fireable trigger is registered with no worker wired.)
678
+ if (trigger.kind === 'manual' && spec.deployment?.durableWorker !== true) {
679
+ errors.push(specError('schema_violation', `manual trigger '${trigger.name}' requires 'deployment.durableWorker: true' — a manual ` +
680
+ 'trigger is fired on demand through the durable off-request worker; without it the trigger ' +
681
+ 'could never dispatch. Set deployment.durableWorker:true or remove the manual trigger', `triggers[${ti}].kind`));
682
+ }
683
+ if (trigger.kind === 'event' && trigger.event === undefined) {
684
+ errors.push(specError('schema_violation', `event trigger '${trigger.name}' is missing 'event'`, `triggers[${ti}].event`));
685
+ }
686
+ // `catchUp` is a CRON-ONLY opt-in (missed-interval make-up work is meaningful only for a
687
+ // scheduled trigger). It is fail-closed-rejected for ANY presence (even `false`) on a
688
+ // non-cron trigger — a webhook/event/manual trigger declaring catchUp is a coherence error,
689
+ // never silently ignored, so the author fixes the spec rather than shipping a dead field.
690
+ if (trigger.catchUp !== undefined && trigger.kind !== 'cron') {
691
+ errors.push(specError('schema_violation', `trigger '${trigger.name}' declares 'catchUp' but kind is '${trigger.kind}' — catchUp is ` +
692
+ "valid ONLY for 'cron' triggers (it opts a scheduled trigger into replaying intervals " +
693
+ 'missed while the worker was down). Remove catchUp or change the trigger to kind:cron', `triggers[${ti}].catchUp`));
694
+ }
695
+ });
696
+ // ---- 3. CAPABILITY (every agent through core validateSpec) -----------------------------
697
+ spec.agents.forEach((agent, ai) => {
698
+ // A synthetic neutral AgentSpec for capability validation. `input` is a runtime value the
699
+ // config omits, so we supply a placeholder ('') — validateSpec ignores input, it inspects
700
+ // outputSchema + tools against the backend's capabilities. Tools are referenced by id in the
701
+ // config; capability validation only needs to know whether the agent uses ANY tools (the
702
+ // backend must be tool-capable), so we attach lightweight neutral ToolSpecs for the resolved
703
+ // tool ids. Cross-ref resolution above already flags an unknown tool id; here we only build
704
+ // capability input from the ids that resolve.
705
+ const resolvedToolSpecs = agent.tools
706
+ .filter((id) => toolIds.has(id))
707
+ .map((id) => {
708
+ const t = spec.tooling.find((tool) => tool.id === id);
709
+ return {
710
+ name: t?.name ?? id,
711
+ description: t?.description ?? '',
712
+ parameters: (t?.parameters ?? {}),
713
+ };
714
+ });
715
+ const synthetic = {
716
+ name: agent.name,
717
+ instructions: agent.instructions,
718
+ model: agent.model,
719
+ input: '',
720
+ tools: resolvedToolSpecs,
721
+ maxTurns: agent.maxTurns,
722
+ ...(agent.outputSchema ? { outputSchema: agent.outputSchema } : {}),
723
+ };
724
+ const res = validateSpec(synthetic, agent.backend, {
725
+ requireNativeStructuredOutput: agent.requireNativeStructuredOutput,
726
+ });
727
+ if (!res.ok) {
728
+ for (const v of res.violations) {
729
+ errors.push(specError('capability_violation', `agent '${agent.id}' (backend '${agent.backend}'): ${v.message}`, `agents[${ai}].backend`));
730
+ }
731
+ }
732
+ });
733
+ // ---- 4. EMBEDDED SCHEMAS COMPILE (tool parameters/outputSchema + agent outputSchema.schema) --
734
+ // One Ajv instance for the whole pass. strict:false so a tool schema using vendor keywords or
735
+ // draft-mixing does not hard-fail compilation (matches dispatch.ts) — a STRUCTURALLY malformed
736
+ // schema still throws (verified: {type:'not-a-type'} / non-array required / non-object schema).
737
+ const ajv = new Ajv2020Ctor({ allErrors: true, strict: false });
738
+ /** Compile an embedded JSON-Schema; push `invalid_embedded_schema` on a compile throw. */
739
+ const compileEmbedded = (schema, label, path) => {
740
+ try {
741
+ ajv.compile(schema);
742
+ }
743
+ catch (e) {
744
+ errors.push(specError('invalid_embedded_schema', `${label} is a malformed JSON-Schema: ${String(e instanceof Error ? e.message : e)}`, path));
745
+ }
746
+ };
747
+ spec.tooling.forEach((tool, ti) => {
748
+ compileEmbedded(tool.parameters, `tool '${tool.id}' 'parameters'`, `tooling[${ti}].parameters`);
749
+ // (7) Tool args must be an OBJECT schema — all 3 backends require object-typed tool args. A
750
+ // compilable-but-non-object `parameters` (e.g. type:'string', or no type) is a config error.
751
+ const params = tool.parameters;
752
+ if (params.type !== 'object') {
753
+ errors.push(specError('schema_violation', `tool '${tool.id}' 'parameters' must be an object JSON-Schema (type:'object'); ` +
754
+ `got type:${JSON.stringify(params.type)}`, `tooling[${ti}].parameters`));
755
+ }
756
+ if (tool.outputSchema) {
757
+ compileEmbedded(tool.outputSchema, `tool '${tool.id}' 'outputSchema'`, `tooling[${ti}].outputSchema`);
758
+ }
759
+ });
760
+ // (3) An agent's structured-output schema is also embedded JSON-Schema — compile it too, so a
761
+ // malformed agent output schema fails at config time rather than reaching the backend.
762
+ spec.agents.forEach((agent, ai) => {
763
+ if (agent.outputSchema) {
764
+ compileEmbedded(agent.outputSchema.schema, `agent '${agent.id}' 'outputSchema.schema'`, `agents[${ai}].outputSchema.schema`);
765
+ }
766
+ });
767
+ return errors;
768
+ }
769
+ /**
770
+ * The NON-FATAL semantic-warning pass — advisory findings that do NOT fail a parse (unlike `lintSpec`).
771
+ * Pure over an already-shape-valid `RaySpec`. `doctor`/`plan` surface these alongside the `ok` result so
772
+ * an author sees a documented interaction without being blocked.
773
+ *
774
+ * Today it flags ONE interaction: a `softDelete` store that is the TARGET of a `restrict` foreign key —
775
+ * EITHER an id-target FK (referencing the parent's injected `id`) OR a business-key FK
776
+ * (`referencesColumn`). Both carry the identical footgun: soft-deleting such a parent is an
777
+ * `UPDATE(deleted_at)` that does NOT fire the database ON DELETE restrict, so the referencing rows keep
778
+ * pointing at the (tombstoned) parent — the restrict guarantee only binds on a HARD delete. This is a
779
+ * permitted, documented interaction, so it is a WARNING, not a fail-closed error.
780
+ */
781
+ export function lintSpecWarnings(spec) {
782
+ const warnings = [];
783
+ spec.stores.forEach((store, si) => {
784
+ if (store.softDelete !== true)
785
+ return;
786
+ for (const other of spec.stores) {
787
+ for (const fk of other.foreignKeys) {
788
+ // Fire for ANY restrict FK onto this softDelete parent — id-target OR business-key. A soft
789
+ // delete is an UPDATE(deleted_at), which does NOT fire ON DELETE restrict on either FK shape.
790
+ if (fk.references === store.name && fk.onDelete === 'restrict') {
791
+ const fkDesc = fk.referencesColumn !== undefined
792
+ ? `business-key foreign key from '${other.name}.${fk.column}' (referencesColumn ` +
793
+ `'${fk.referencesColumn}')`
794
+ : `foreign key from '${other.name}.${fk.column}' (references '${store.name}.id')`;
795
+ warnings.push(specWarning('softdelete_fk_restrict', `store '${store.name}' is softDelete AND is the target of a restrict ${fkDesc} — ` +
796
+ `soft-deleting a referenced '${store.name}' row is an UPDATE that does NOT fire the ` +
797
+ `database ON DELETE restrict, so '${other.name}' rows keep pointing at the tombstoned ` +
798
+ 'parent; the restrict guarantee only binds on a hard delete', `stores[${si}].softDelete`));
799
+ }
800
+ }
801
+ }
802
+ });
803
+ // FK FORWARD-REFERENCE — a store whose FK references a store declared LATER in the array. The
804
+ // product-SQL generator topo-sorts stores so the parent table is created before the child's FK is
805
+ // added, so a forward reference still APPLIES cleanly; this advisory just tells the author the declared
806
+ // order relies on that reordering (a true cycle is the fail-closed `fk_cycle` error, never a warning).
807
+ const storeIndexByName = new Map(spec.stores.map((s, i) => [s.name, i]));
808
+ spec.stores.forEach((store, si) => {
809
+ store.foreignKeys.forEach((fk, fi) => {
810
+ if (fk.references === store.name)
811
+ return; // self-FK applies after this table's own CREATE
812
+ const parentIndex = storeIndexByName.get(fk.references);
813
+ if (parentIndex === undefined || parentIndex <= si)
814
+ return; // unknown (dangling) or already-before
815
+ warnings.push(specWarning('fk_forward_reference', `store '${store.name}' declares a foreign key referencing '${fk.references}', which is ` +
816
+ 'declared LATER in the stores list — the generator reorders stores so the parent table is ' +
817
+ `created first (it applies cleanly); declare '${fk.references}' before '${store.name}' to ` +
818
+ 'make the dependency order explicit', `stores[${si}].foreignKeys[${fi}]`));
819
+ });
820
+ });
821
+ return warnings;
822
+ }
823
+ //# sourceMappingURL=lint.js.map