@cynodia/axiom-core 0.16.0-alpha.1 → 0.16.0-alpha.2

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.
package/dist/authority.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { referencedIds } from './derive-edges.js';
2
- import { actionGuards, isMutationOperation } from './nodes.js';
2
+ import { actionGuards, actionOperations, isMutationOperation, operationChildren } from './nodes.js';
3
3
  import { locationExpressions, locationProviderEntityId, locationRootStateId } from './location.js';
4
4
  import { queryExpressions } from './query.js';
5
5
  export const AUTHORITIES = ['client', 'server'];
@@ -121,7 +121,7 @@ function operationExpressions(operation) {
121
121
  switch (operation.kind) {
122
122
  case 'for-each':
123
123
  found.push(operation.collection);
124
- for (const nested of operation.operations ?? []) {
124
+ for (const nested of operationChildren(operation)) {
125
125
  found.push(...operationExpressions(nested));
126
126
  }
127
127
  break;
@@ -157,11 +157,11 @@ function operationExpressions(operation) {
157
157
  }
158
158
  /** Whether an action calls out to an integration anywhere in its top-level operations. */
159
159
  export function actionUsesIntegration(action) {
160
- return (action.operations ?? []).some((operation) => operation.kind === 'integration-query' || operation.kind === 'integration-effect');
160
+ return actionOperations(action).some((operation) => operation.kind === 'integration-query' || operation.kind === 'integration-effect');
161
161
  }
162
162
  /** Whether an action reaches an object store anywhere in its top-level operations. */
163
163
  export function actionUsesStorage(action) {
164
- return (action.operations ?? []).some((operation) => operation.kind === 'blob-metadata' ||
164
+ return actionOperations(action).some((operation) => operation.kind === 'blob-metadata' ||
165
165
  operation.kind === 'blob-commit' ||
166
166
  operation.kind === 'blob-delete');
167
167
  }
@@ -170,7 +170,7 @@ export function actionUsesStorage(action) {
170
170
  * data provider, so an action that reads authoritative data through a query executes there.
171
171
  */
172
172
  export function actionUsesQuery(action) {
173
- return (action.operations ?? []).some((operation) => operation.kind === 'query');
173
+ return actionOperations(action).some((operation) => operation.kind === 'query');
174
174
  }
175
175
  /**
176
176
  * Whether an action writes a `provider-record` location anywhere in its operations
@@ -183,11 +183,11 @@ export function actionWritesProviderRecord(action) {
183
183
  return locationProviderEntityId(operation.target) !== undefined;
184
184
  }
185
185
  if (operation.kind === 'for-each') {
186
- return targets(operation.operations);
186
+ return targets(operationChildren(operation));
187
187
  }
188
188
  return false;
189
189
  });
190
- return targets(action.operations ?? []);
190
+ return targets(actionOperations(action));
191
191
  }
192
192
  /** The states an action writes, following `for-each`, `invoke` and declared native effects. */
193
193
  export function statesWrittenBy(action, context, visited = new Set()) {
@@ -204,7 +204,7 @@ export function statesWrittenBy(action, context, visited = new Set()) {
204
204
  }
205
205
  switch (operation.kind) {
206
206
  case 'for-each':
207
- walk(operation.operations ?? []);
207
+ walk(operationChildren(operation));
208
208
  break;
209
209
  case 'invoke': {
210
210
  const target = context.actions.get(operation.actionId);
@@ -238,7 +238,7 @@ export function statesWrittenBy(action, context, visited = new Set()) {
238
238
  }
239
239
  }
240
240
  };
241
- walk(action.operations ?? []);
241
+ walk(actionOperations(action));
242
242
  return found;
243
243
  }
244
244
  /** The states an action reads: its guards, its values, its selectors, its authorization. */
@@ -252,11 +252,11 @@ export function statesReadByAction(action, context, visited = new Set()) {
252
252
  ...(action.postconditions ?? []),
253
253
  ...(action.authorization ? [action.authorization] : []),
254
254
  ];
255
- for (const operation of action.operations ?? []) {
255
+ for (const operation of actionOperations(action)) {
256
256
  expressions.push(...operationExpressions(operation));
257
257
  }
258
258
  const found = statesReadBy(expressions, context);
259
- for (const operation of action.operations ?? []) {
259
+ for (const operation of actionOperations(action)) {
260
260
  if (operation.kind === 'invoke') {
261
261
  const target = context.actions.get(operation.actionId);
262
262
  if (target) {
@@ -1,4 +1,5 @@
1
1
  import { constructedFieldIds, expressionDefsIn, expressionFieldIds, walkExpression } from './expressions.js';
2
+ import { actionOperations, operationChildren } from './nodes.js';
2
3
  import { locationExpressions, locationFieldIds, locationRootStateId, locationSelectorFieldIds, } from './location.js';
3
4
  import { isGroupFieldId } from './group.js';
4
5
  import { isUINode } from './ui.js';
@@ -251,12 +252,22 @@ export function deriveEdges(nodes) {
251
252
  },
252
253
  }));
253
254
  }
255
+ function isPlainObject(value) {
256
+ return !!value && typeof value === 'object' && !Array.isArray(value);
257
+ }
254
258
  /**
255
259
  * The states an expression ultimately draws its members from. Following `field` and calls
256
260
  * matters: a collection reached as `coalesce(field(ref(state), lines), [])` still comes
257
261
  * from that state, and its members' fields are still reads of it.
262
+ *
263
+ * Total over malformed input (spec16pt2 §12-24): a candidate expression can be
264
+ * AI-generated, deserialized or hand-tampered.
258
265
  */
259
- function statesOf(expression, scope, states, defs = new Map()) {
266
+ function statesOf(expressionInput, scope, states, defs = new Map()) {
267
+ if (!isPlainObject(expressionInput) || typeof expressionInput.kind !== 'string') {
268
+ return [];
269
+ }
270
+ const expression = expressionInput;
260
271
  switch (expression.kind) {
261
272
  case 'ref': {
262
273
  const bound = scope.get(expression.targetId);
@@ -364,7 +375,7 @@ function bind(scope, id, targets) {
364
375
  * projecting a field of each member is recorded as a read of that field of the state the
365
376
  * members came from.
366
377
  */
367
- function collectReads(expression, scope, states, found = new Map(), defs = new Map()) {
378
+ function collectReads(expressionInput, scope, states, found = new Map(), defs = new Map()) {
368
379
  const record = (stateId, fieldId) => {
369
380
  const entry = found.get(stateId) ?? new Set();
370
381
  if (fieldId) {
@@ -372,6 +383,10 @@ function collectReads(expression, scope, states, found = new Map(), defs = new M
372
383
  }
373
384
  found.set(stateId, entry);
374
385
  };
386
+ if (!isPlainObject(expressionInput) || typeof expressionInput.kind !== 'string') {
387
+ return found;
388
+ }
389
+ const expression = expressionInput;
375
390
  switch (expression.kind) {
376
391
  case 'ref':
377
392
  for (const stateId of statesOf(expression, scope, states, defs)) {
@@ -455,7 +470,7 @@ function linkAction(action, linker) {
455
470
  for (const expression of [...(action.preconditions ?? []), ...(action.postconditions ?? [])]) {
456
471
  linker.reads(action.id, expression, linker.scope);
457
472
  }
458
- linkOperations(action.id, action.operations ?? [], linker, linker.scope);
473
+ linkOperations(action.id, actionOperations(action), linker, linker.scope);
459
474
  }
460
475
  function linkOperations(actionId, operations, linker, scope) {
461
476
  for (const operation of operations) {
@@ -476,7 +491,7 @@ function linkOperations(actionId, operations, linker, scope) {
476
491
  case 'for-each': {
477
492
  linker.reads(actionId, operation.collection, scope);
478
493
  const inner = bind(scope, operation.scopeId, statesOf(operation.collection, scope, linker.states));
479
- linkOperations(actionId, operation.operations, linker, inner);
494
+ linkOperations(actionId, operationChildren(operation), linker, inner);
480
495
  break;
481
496
  }
482
497
  case 'invoke':
@@ -212,5 +212,9 @@ export declare const VALIDATION_CODES: {
212
212
  readonly authorizationUnknownPolicy: "AUTHORIZATION_UNKNOWN_POLICY";
213
213
  /** An `AuthorizationPolicyDef.allow` expression calls `now` / `uuid` / `random` — authorization must be deterministic (spec15 §34). */
214
214
  readonly authorizationNondeterministic: "AUTHORIZATION_NONDETERMINISTIC";
215
+ /** `ActionDef.operations`, or a `for-each`'s nested `operations`, is present but not an array. */
216
+ readonly invalidOperationCollection: "INVALID_OPERATION_COLLECTION";
217
+ /** An operation array entry that is not a plain object with a recognized `kind`. */
218
+ readonly invalidOperation: "INVALID_OPERATION";
215
219
  };
216
220
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -211,4 +211,11 @@ export const VALIDATION_CODES = {
211
211
  authorizationUnknownPolicy: 'AUTHORIZATION_UNKNOWN_POLICY',
212
212
  /** An `AuthorizationPolicyDef.allow` expression calls `now` / `uuid` / `random` — authorization must be deterministic (spec15 §34). */
213
213
  authorizationNondeterministic: 'AUTHORIZATION_NONDETERMINISTIC',
214
+ // Validation totality (0.16pt2, spec16pt2 §12-24). A candidate graph can be AI-generated,
215
+ // deserialized or hand-tampered, so `validateGraph` must reject a malformed runtime
216
+ // *shape* structurally — never assume TypeScript's compile-time types already hold.
217
+ /** `ActionDef.operations`, or a `for-each`'s nested `operations`, is present but not an array. */
218
+ invalidOperationCollection: 'INVALID_OPERATION_COLLECTION',
219
+ /** An operation array entry that is not a plain object with a recognized `kind`. */
220
+ invalidOperation: 'INVALID_OPERATION',
214
221
  };
@@ -203,15 +203,21 @@ export declare function groupItems(source: Expression): FieldExpression;
203
203
  */
204
204
  export declare function expressionRef(expressionId: NodeId, args?: Record<string, Expression>): ExpressionRefExpression;
205
205
  export declare function conditional(condition: Expression, whenTrue: Expression, whenFalse: Expression): ConditionalExpression;
206
- /** Visits every sub-expression, parents before children. */
207
- export declare function walkExpression(expression: Expression, visit: (node: Expression) => void): void;
206
+ /**
207
+ * Visits every sub-expression, parents before children. Total over malformed input
208
+ * (spec16pt2 §12-24): a candidate expression tree can arrive from AI generation,
209
+ * deserialization or hand-tampering (a deleted field, a tampered array element), and this
210
+ * is the shared visitor most expression-derived analysis in the codebase is built on — a
211
+ * malformed node here simply is not visited, rather than throwing while reading `.kind`.
212
+ */
213
+ export declare function walkExpression(expressionInput: unknown, visit: (node: Expression) => void): void;
208
214
  /**
209
215
  * Field ids a constructed record assigns. Only the record's own entries count: the
210
216
  * expressions that compute those values are reads, not writes.
211
217
  */
212
- export declare function constructedFieldIds(expression: Expression): FieldId[];
218
+ export declare function constructedFieldIds(expression: unknown): FieldId[];
213
219
  /** Expression definitions an expression reaches directly, in tree order. */
214
- export declare function expressionDefsIn(expression: Expression): NodeId[];
220
+ export declare function expressionDefsIn(expression: unknown): NodeId[];
215
221
  /** Field ids an expression reads, including nested sources and constructed records. */
216
- export declare function expressionFieldIds(expression: Expression): FieldId[];
222
+ export declare function expressionFieldIds(expression: unknown): FieldId[];
217
223
  //# sourceMappingURL=expressions.d.ts.map
@@ -128,8 +128,21 @@ export function expressionRef(expressionId, args) {
128
128
  export function conditional(condition, whenTrue, whenFalse) {
129
129
  return { kind: 'conditional', condition, whenTrue, whenFalse };
130
130
  }
131
- /** Visits every sub-expression, parents before children. */
132
- export function walkExpression(expression, visit) {
131
+ function isPlainObject(value) {
132
+ return !!value && typeof value === 'object' && !Array.isArray(value);
133
+ }
134
+ /**
135
+ * Visits every sub-expression, parents before children. Total over malformed input
136
+ * (spec16pt2 §12-24): a candidate expression tree can arrive from AI generation,
137
+ * deserialization or hand-tampering (a deleted field, a tampered array element), and this
138
+ * is the shared visitor most expression-derived analysis in the codebase is built on — a
139
+ * malformed node here simply is not visited, rather than throwing while reading `.kind`.
140
+ */
141
+ export function walkExpression(expressionInput, visit) {
142
+ if (!isPlainObject(expressionInput) || typeof expressionInput.kind !== 'string') {
143
+ return;
144
+ }
145
+ const expression = expressionInput;
133
146
  visit(expression);
134
147
  switch (expression.kind) {
135
148
  case 'field':
@@ -195,7 +208,12 @@ export function walkExpression(expression, visit) {
195
208
  * expressions that compute those values are reads, not writes.
196
209
  */
197
210
  export function constructedFieldIds(expression) {
198
- return expression.kind === 'object' ? expression.entries.map((entry) => entry.fieldId) : [];
211
+ if (!isPlainObject(expression) || expression.kind !== 'object' || !Array.isArray(expression.entries)) {
212
+ return [];
213
+ }
214
+ return expression.entries
215
+ .filter((entry) => isPlainObject(entry) && typeof entry.fieldId === 'string')
216
+ .map((entry) => entry.fieldId);
199
217
  }
200
218
  /** Expression definitions an expression reaches directly, in tree order. */
201
219
  export function expressionDefsIn(expression) {
package/dist/infer.d.ts CHANGED
@@ -2,7 +2,6 @@ import type { Expression } from './expressions.js';
2
2
  import type { FieldId, NodeId } from './ids.js';
3
3
  import type { EntityDef, ExpressionDef, StateDef } from './nodes.js';
4
4
  import type { FieldIndexEntry } from './graph.js';
5
- import type { Location } from './location.js';
6
5
  import type { TypeRef } from './type-ref.js';
7
6
  /**
8
7
  * The lookups static analysis needs. Both an authoring graph and a compiled IR can
@@ -23,10 +22,14 @@ export interface LocationCapabilities {
23
22
  readable: boolean;
24
23
  writable: boolean;
25
24
  }
26
- /** The type of the value a location addresses, where it can be determined statically. */
27
- export declare function inferLocationType(location: Location, context: SemanticContext): TypeRef | undefined;
25
+ /**
26
+ * The type of the value a location addresses, where it can be determined statically.
27
+ * Total over malformed input (spec16pt2 §12-20): a location that cannot be understood at
28
+ * all has no inferrable type, rather than a thrown error.
29
+ */
30
+ export declare function inferLocationType(location: unknown, context: SemanticContext): TypeRef | undefined;
28
31
  /** Derived state is readable but never writable; everything else follows its root. */
29
- export declare function locationCapabilities(location: Location, context: SemanticContext): LocationCapabilities;
32
+ export declare function locationCapabilities(location: unknown, context: SemanticContext): LocationCapabilities;
30
33
  /** The member type of a collection, ignoring optionality. */
31
34
  export declare function itemTypeOf(type: TypeRef | undefined): TypeRef | undefined;
32
35
  /** Types bound by enclosing iteration scopes, keyed by scope id. */
@@ -36,7 +39,7 @@ export type ScopeTypes = ReadonlyMap<NodeId, TypeRef>;
36
39
  * determined statically — 0.4 deliberately stops short of a complete type checker, but
37
40
  * iteration scopes are tracked so that projections and aggregations can be checked.
38
41
  */
39
- export declare function inferExpressionType(expression: Expression, context: SemanticContext, scope?: ScopeTypes,
42
+ export declare function inferExpressionType(expressionInput: unknown, context: SemanticContext, scope?: ScopeTypes,
40
43
  /** Definitions already being inferred, so a cyclic reference stops rather than recurses. */
41
44
  visiting?: ReadonlySet<NodeId>): TypeRef | undefined;
42
45
  /** The scope bindings an expression introduces for its own sub-expressions. */
@@ -49,5 +52,5 @@ export declare function isObviouslyIncompatible(target: TypeRef | undefined, val
49
52
  /** True when a type is a collection whose members are clearly not numbers. */
50
53
  export declare function isNonNumericCollection(type: TypeRef | undefined): boolean;
51
54
  /** Renders a location for people. The stored representation stays id-based. */
52
- export declare function formatLocation(location: Location, context: SemanticContext): string;
55
+ export declare function formatLocation(location: unknown, context: SemanticContext): string;
53
56
  //# sourceMappingURL=infer.d.ts.map
package/dist/infer.js CHANGED
@@ -1,10 +1,21 @@
1
+ import { isPlainLocation } from './location.js';
1
2
  import { collectionType, entityType, groupType, optionalType, primitiveType } from './type-ref.js';
2
3
  import { GROUP_ITEMS_FIELD, GROUP_KEY_FIELD } from './group.js';
4
+ function isPlainObject(value) {
5
+ return !!value && typeof value === 'object' && !Array.isArray(value);
6
+ }
3
7
  function unwrap(type) {
4
8
  return type?.kind === 'optional' ? unwrap(type.valueType) : type;
5
9
  }
6
- /** The type of the value a location addresses, where it can be determined statically. */
10
+ /**
11
+ * The type of the value a location addresses, where it can be determined statically.
12
+ * Total over malformed input (spec16pt2 §12-20): a location that cannot be understood at
13
+ * all has no inferrable type, rather than a thrown error.
14
+ */
7
15
  export function inferLocationType(location, context) {
16
+ if (!isPlainLocation(location)) {
17
+ return undefined;
18
+ }
8
19
  switch (location.kind) {
9
20
  case 'state':
10
21
  return context.getState(location.stateId)?.valueType;
@@ -46,6 +57,9 @@ export function locationCapabilities(location, context) {
46
57
  return { readable: true, writable: root.derivation === undefined };
47
58
  }
48
59
  function locationProviderRoot(location) {
60
+ if (!isPlainLocation(location)) {
61
+ return false;
62
+ }
49
63
  switch (location.kind) {
50
64
  case 'provider-record':
51
65
  return true;
@@ -58,6 +72,9 @@ function locationProviderRoot(location) {
58
72
  }
59
73
  }
60
74
  function rootState(location, context) {
75
+ if (!isPlainLocation(location)) {
76
+ return undefined;
77
+ }
61
78
  switch (location.kind) {
62
79
  case 'state':
63
80
  return context.getState(location.stateId);
@@ -89,9 +106,15 @@ function withScope(scope, id, type) {
89
106
  * determined statically — 0.4 deliberately stops short of a complete type checker, but
90
107
  * iteration scopes are tracked so that projections and aggregations can be checked.
91
108
  */
92
- export function inferExpressionType(expression, context, scope,
109
+ export function inferExpressionType(expressionInput, context, scope,
93
110
  /** Definitions already being inferred, so a cyclic reference stops rather than recurses. */
94
111
  visiting = new Set()) {
112
+ // spec16pt2 §12-24 — a malformed sub-expression (a deleted field, a tampered array
113
+ // element) has no inferrable type, rather than a thrown error reading `.kind`.
114
+ if (!isPlainObject(expressionInput)) {
115
+ return undefined;
116
+ }
117
+ const expression = expressionInput;
95
118
  switch (expression.kind) {
96
119
  case 'literal': {
97
120
  const value = expression.value;
@@ -306,6 +329,9 @@ export function isNonNumericCollection(type) {
306
329
  }
307
330
  /** Renders a location for people. The stored representation stays id-based. */
308
331
  export function formatLocation(location, context) {
332
+ if (!isPlainLocation(location)) {
333
+ return 'unknown location';
334
+ }
309
335
  const name = (id) => context.getName?.(id) ?? id;
310
336
  const fieldName = (id) => context.getField(id)?.field.name ?? id;
311
337
  switch (location.kind) {
@@ -315,12 +341,18 @@ export function formatLocation(location, context) {
315
341
  return `${formatLocation(location.target, context)} → ${fieldName(location.fieldId)}`;
316
342
  case 'collection-item': {
317
343
  const parent = formatLocation(location.collection, context);
344
+ if (!isPlainObject(location.selector)) {
345
+ return `${parent} → [invalid selector]`;
346
+ }
318
347
  if (location.selector.kind === 'identity') {
319
- const value = location.selector.value.kind === 'ref'
320
- ? name(location.selector.value.targetId)
321
- : location.selector.value.kind === 'literal'
322
- ? JSON.stringify(location.selector.value.value)
323
- : 'expression';
348
+ const selectorValue = location.selector.value;
349
+ const value = !isPlainObject(selectorValue)
350
+ ? 'expression'
351
+ : selectorValue.kind === 'ref'
352
+ ? name(selectorValue.targetId)
353
+ : selectorValue.kind === 'literal'
354
+ ? JSON.stringify(selectorValue.value)
355
+ : 'expression';
324
356
  return `${parent} → [${fieldName(location.selector.fieldId)} = ${value}]`;
325
357
  }
326
358
  return `${parent} → [index]`;
@@ -71,6 +71,13 @@ export declare function providerRecordLocation(sourceEntityId: NodeId, identityF
71
71
  export declare function providerRecordFieldLocation(sourceEntityId: NodeId, identityFieldId: FieldId, identityValue: Expression, fieldId: FieldId): FieldLocation;
72
72
  /** Convenience for the common shape: one field of one item of a collection state. */
73
73
  export declare function itemFieldLocation(stateId: NodeId, identityFieldId: FieldId, identityValue: Expression, fieldId: FieldId): FieldLocation;
74
+ /**
75
+ * A plain object carrying one of the four location kinds — total, never throws. A candidate
76
+ * `Location` can arrive from AI generation, deserialization or hand-tampering, so every
77
+ * traversal below checks this before reading `.kind` rather than trusting the compile-time
78
+ * `Location` type (spec16pt2 §12-20).
79
+ */
80
+ export declare function isPlainLocation(value: unknown): value is Location;
74
81
  /**
75
82
  * The node every location is ultimately rooted in.
76
83
  *
@@ -79,19 +86,22 @@ export declare function itemFieldLocation(stateId: NodeId, identityFieldId: Fiel
79
86
  * entity id** instead. Callers that resolve the result as a state (`context.states.get`)
80
87
  * naturally get `undefined` and skip it, which is correct: nothing materialized holds the
81
88
  * record. Use `locationProviderEntityId` to detect the provider-backed case explicitly.
89
+ *
90
+ * Total over malformed input: an empty string (never a real `NodeId`) for a location that
91
+ * cannot be understood at all, rather than a thrown error (spec16pt2 §12-13).
82
92
  */
83
- export declare function locationRootStateId(location: Location): NodeId;
93
+ export declare function locationRootStateId(location: unknown): NodeId;
84
94
  /** The source entity of the `provider-record` a location is rooted in, or `undefined` if it is state-rooted. */
85
- export declare function locationProviderEntityId(location: Location): NodeId | undefined;
95
+ export declare function locationProviderEntityId(location: unknown): NodeId | undefined;
86
96
  /**
87
97
  * Expressions embedded in a location — selector values, indexes and a provider-record's
88
98
  * identity value. These are read dependencies of whatever uses the location.
89
99
  */
90
- export declare function locationExpressions(location: Location): Expression[];
100
+ export declare function locationExpressions(location: unknown): Expression[];
91
101
  /** Fields a write through this location touches, outermost first. */
92
- export declare function locationFieldIds(location: Location): FieldId[];
102
+ export declare function locationFieldIds(location: unknown): FieldId[];
93
103
  /** Fields a location reads in order to address itself, such as identity selectors. */
94
- export declare function locationSelectorFieldIds(location: Location): FieldId[];
104
+ export declare function locationSelectorFieldIds(location: unknown): FieldId[];
95
105
  /**
96
106
  * Structural equality. Two locations that address the same position are equal even
97
107
  * though the value a selector expression produces is only known at run time.
package/dist/location.js CHANGED
@@ -24,6 +24,23 @@ export function providerRecordFieldLocation(sourceEntityId, identityFieldId, ide
24
24
  export function itemFieldLocation(stateId, identityFieldId, identityValue, fieldId) {
25
25
  return fieldLocation(itemLocation(stateLocation(stateId), identitySelector(identityFieldId, identityValue)), fieldId);
26
26
  }
27
+ function isPlainObject(value) {
28
+ return !!value && typeof value === 'object' && !Array.isArray(value);
29
+ }
30
+ const LOCATION_KINDS = ['state', 'provider-record', 'field', 'collection-item'];
31
+ /**
32
+ * A plain object carrying one of the four location kinds — total, never throws. A candidate
33
+ * `Location` can arrive from AI generation, deserialization or hand-tampering, so every
34
+ * traversal below checks this before reading `.kind` rather than trusting the compile-time
35
+ * `Location` type (spec16pt2 §12-20).
36
+ */
37
+ export function isPlainLocation(value) {
38
+ return isPlainObject(value) && LOCATION_KINDS.includes(value.kind);
39
+ }
40
+ /** A plain object carrying a recognized `CollectionSelector.kind`, guarding `.selector` access. */
41
+ function isPlainSelector(value) {
42
+ return isPlainObject(value) && (value.kind === 'identity' || value.kind === 'index');
43
+ }
27
44
  /**
28
45
  * The node every location is ultimately rooted in.
29
46
  *
@@ -32,8 +49,14 @@ export function itemFieldLocation(stateId, identityFieldId, identityValue, field
32
49
  * entity id** instead. Callers that resolve the result as a state (`context.states.get`)
33
50
  * naturally get `undefined` and skip it, which is correct: nothing materialized holds the
34
51
  * record. Use `locationProviderEntityId` to detect the provider-backed case explicitly.
52
+ *
53
+ * Total over malformed input: an empty string (never a real `NodeId`) for a location that
54
+ * cannot be understood at all, rather than a thrown error (spec16pt2 §12-13).
35
55
  */
36
56
  export function locationRootStateId(location) {
57
+ if (!isPlainLocation(location)) {
58
+ return '';
59
+ }
37
60
  switch (location.kind) {
38
61
  case 'state':
39
62
  return location.stateId;
@@ -44,11 +67,14 @@ export function locationRootStateId(location) {
44
67
  case 'collection-item':
45
68
  return locationRootStateId(location.collection);
46
69
  default:
47
- throw new Error(`Unknown location kind "${location.kind}"`);
70
+ return '';
48
71
  }
49
72
  }
50
73
  /** The source entity of the `provider-record` a location is rooted in, or `undefined` if it is state-rooted. */
51
74
  export function locationProviderEntityId(location) {
75
+ if (!isPlainLocation(location)) {
76
+ return undefined;
77
+ }
52
78
  switch (location.kind) {
53
79
  case 'provider-record':
54
80
  return location.sourceEntityId;
@@ -65,6 +91,9 @@ export function locationProviderEntityId(location) {
65
91
  * identity value. These are read dependencies of whatever uses the location.
66
92
  */
67
93
  export function locationExpressions(location) {
94
+ if (!isPlainLocation(location)) {
95
+ return [];
96
+ }
68
97
  switch (location.kind) {
69
98
  case 'state':
70
99
  return [];
@@ -73,6 +102,9 @@ export function locationExpressions(location) {
73
102
  case 'field':
74
103
  return locationExpressions(location.target);
75
104
  case 'collection-item': {
105
+ if (!isPlainSelector(location.selector)) {
106
+ return locationExpressions(location.collection);
107
+ }
76
108
  const own = location.selector.kind === 'identity' ? [location.selector.value] : [location.selector.index];
77
109
  return [...locationExpressions(location.collection), ...own];
78
110
  }
@@ -82,10 +114,16 @@ export function locationExpressions(location) {
82
114
  }
83
115
  /** Fields a write through this location touches, outermost first. */
84
116
  export function locationFieldIds(location) {
117
+ if (!isPlainLocation(location)) {
118
+ return [];
119
+ }
85
120
  return location.kind === 'field' ? [location.fieldId, ...locationFieldIds(location.target)] : [];
86
121
  }
87
122
  /** Fields a location reads in order to address itself, such as identity selectors. */
88
123
  export function locationSelectorFieldIds(location) {
124
+ if (!isPlainLocation(location)) {
125
+ return [];
126
+ }
89
127
  switch (location.kind) {
90
128
  case 'state':
91
129
  return [];
@@ -96,7 +134,9 @@ export function locationSelectorFieldIds(location) {
96
134
  case 'collection-item':
97
135
  return [
98
136
  ...locationSelectorFieldIds(location.collection),
99
- ...(location.selector.kind === 'identity' ? [location.selector.fieldId] : []),
137
+ ...(isPlainSelector(location.selector) && location.selector.kind === 'identity'
138
+ ? [location.selector.fieldId]
139
+ : []),
100
140
  ];
101
141
  default:
102
142
  return [];
package/dist/nodes.d.ts CHANGED
@@ -315,7 +315,34 @@ export interface ForEachOperation {
315
315
  scopeId: NodeId;
316
316
  operations: MutationOperation[];
317
317
  }
318
- export declare function isMutationOperation(operation: Operation): operation is MutationOperation;
318
+ /**
319
+ * Total over any input (spec16pt2 §12-19): a candidate `Operation` may arrive from
320
+ * AI generation, deserialization or hand-tampering, so the runtime type `Operation` is not
321
+ * proof of runtime shape. `null`/a primitive/an array safely reports `false` rather than
322
+ * throwing when `.kind` is read.
323
+ */
324
+ export declare function isMutationOperation(operation: unknown): operation is MutationOperation;
325
+ /** A plain object carrying one of the closed `OPERATION_KINDS` — total, never throws. */
326
+ export declare function isPlainOperation(operation: unknown): operation is Operation;
327
+ /**
328
+ * `value.operations` if it is genuinely an array, else `[]` — no per-element filtering.
329
+ * Exported for `validateGraph`, the one caller that must see every raw array element,
330
+ * malformed or not, in order to report each one structurally (spec16pt2 §20-21).
331
+ */
332
+ export declare function rawOperations(value: unknown): readonly unknown[];
333
+ /**
334
+ * `ActionDef.operations`, safe over malformed input (spec16pt2 §12-13): only a genuine
335
+ * array of well-formed operations is ever returned — never assumed from a truthy-but-non-array
336
+ * value like `{}` (exactly the shape `(action.operations ?? []).some(...)` treats as an array
337
+ * and crashes on), and never including a malformed array *element* either, so every consumer
338
+ * that just wants to reason about an action's real operations can iterate the result without
339
+ * its own defensive check. `validateGraph` is the one caller that must see and diagnose a
340
+ * malformed element rather than have it silently skipped, so it walks the raw array itself
341
+ * (`nodes.ts` is not the place that decides what gets reported — `validate.ts` is).
342
+ */
343
+ export declare function actionOperations(action: unknown): Operation[];
344
+ /** A `for-each` operation's nested `operations`, equally total and equally filtered. */
345
+ export declare function operationChildren(operation: unknown): MutationOperation[];
319
346
  export declare function forEach(collection: Expression, scopeId: NodeId, operations: MutationOperation[]): ForEachOperation;
320
347
  export interface InvokeOperation {
321
348
  kind: 'invoke';
package/dist/nodes.js CHANGED
@@ -49,9 +49,51 @@ export const BLOB_OPERATION_KINDS = [
49
49
  'blob-commit',
50
50
  'blob-delete',
51
51
  ];
52
+ /**
53
+ * Total over any input (spec16pt2 §12-19): a candidate `Operation` may arrive from
54
+ * AI generation, deserialization or hand-tampering, so the runtime type `Operation` is not
55
+ * proof of runtime shape. `null`/a primitive/an array safely reports `false` rather than
56
+ * throwing when `.kind` is read.
57
+ */
52
58
  export function isMutationOperation(operation) {
59
+ if (!isPlainOperation(operation)) {
60
+ return false;
61
+ }
53
62
  return operation.kind === 'set' || operation.kind === 'insert' || operation.kind === 'remove';
54
63
  }
64
+ /** A plain object carrying one of the closed `OPERATION_KINDS` — total, never throws. */
65
+ export function isPlainOperation(operation) {
66
+ return (!!operation &&
67
+ typeof operation === 'object' &&
68
+ !Array.isArray(operation) &&
69
+ OPERATION_KINDS.includes(operation.kind));
70
+ }
71
+ /**
72
+ * `value.operations` if it is genuinely an array, else `[]` — no per-element filtering.
73
+ * Exported for `validateGraph`, the one caller that must see every raw array element,
74
+ * malformed or not, in order to report each one structurally (spec16pt2 §20-21).
75
+ */
76
+ export function rawOperations(value) {
77
+ const operations = value?.operations;
78
+ return Array.isArray(operations) ? operations : [];
79
+ }
80
+ /**
81
+ * `ActionDef.operations`, safe over malformed input (spec16pt2 §12-13): only a genuine
82
+ * array of well-formed operations is ever returned — never assumed from a truthy-but-non-array
83
+ * value like `{}` (exactly the shape `(action.operations ?? []).some(...)` treats as an array
84
+ * and crashes on), and never including a malformed array *element* either, so every consumer
85
+ * that just wants to reason about an action's real operations can iterate the result without
86
+ * its own defensive check. `validateGraph` is the one caller that must see and diagnose a
87
+ * malformed element rather than have it silently skipped, so it walks the raw array itself
88
+ * (`nodes.ts` is not the place that decides what gets reported — `validate.ts` is).
89
+ */
90
+ export function actionOperations(action) {
91
+ return rawOperations(action).filter(isPlainOperation);
92
+ }
93
+ /** A `for-each` operation's nested `operations`, equally total and equally filtered. */
94
+ export function operationChildren(operation) {
95
+ return rawOperations(operation).filter(isMutationOperation);
96
+ }
55
97
  export function forEach(collection, scopeId, operations) {
56
98
  return { kind: 'for-each', collection, scopeId, operations };
57
99
  }
@@ -1,3 +1,4 @@
1
+ import { actionOperations } from './nodes.js';
1
2
  import { formSubmitActionId, isUINode, uiChildIds } from './ui.js';
2
3
  import { TEXT_ROLE_HEADING_LEVELS, normalizeDensity, normalizeLayout, normalizePadding, normalizeRole, } from './presentation.js';
3
4
  import { DEFAULT_THEME } from './theme.js';
@@ -269,7 +270,7 @@ function semanticLayer(node, index) {
269
270
  }
270
271
  /** Declared destructive intent, or a removal the graph performs. */
271
272
  export function isDestructiveAction(action) {
272
- return action.destructive === true || (action.operations ?? []).some((op) => op.kind === 'remove');
273
+ return action.destructive === true || actionOperations(action).some((op) => op.kind === 'remove');
273
274
  }
274
275
  /** Boolean and temporal values read better formatted than printed raw — §32, §33. */
275
276
  function formatLayer(node, index) {
@@ -34,12 +34,21 @@ export const SEMANTIC_DIFF_CATEGORIES = [
34
34
  'presentation',
35
35
  'metadata',
36
36
  ];
37
- /** Kinds `diffSchema` already classifies in full field-level detail; excluded from the generic node loop. */
38
- const SCHEMA_OWNED_KINDS = new Set(['entity', 'state', 'relationship', 'read-policy']);
37
+ /**
38
+ * Kinds `diffSchema` already classifies in full field-level detail; excluded from the
39
+ * generic node loop. `read-policy` is deliberately **not** here (spec16pt2 F3, §33, §37):
40
+ * `diffSchema`'s `ReadPolicyShape` only tracks which entity a policy governs — correctly,
41
+ * for `schemaFingerprint`'s persistence-relevant purpose — and never its `predicate`, so a
42
+ * rule edited from owner-only to public would otherwise produce no diff entry anywhere even
43
+ * though it moves `semanticFingerprint`. Routing it through the generic full-node loop
44
+ * instead catches exactly that, tagged `authorization`, at the cost of a redundant (never
45
+ * missing) second entry when `diffSchema` also reports the policy's entity moving.
46
+ */
47
+ const SCHEMA_OWNED_KINDS = new Set(['entity', 'state', 'relationship']);
39
48
  const PROVIDER_KINDS = new Set(['integration', 'integration-operation', 'subscription', 'storage']);
40
49
  const PRESENTATION_KINDS = new Set([...UI_NODE_KINDS, 'route']);
41
50
  function kindCategory(kind) {
42
- if (kind === 'authorization-policy')
51
+ if (kind === 'authorization-policy' || kind === 'read-policy')
43
52
  return 'authorization';
44
53
  if (kind === 'query')
45
54
  return 'query';
@@ -54,7 +63,21 @@ function kindCategory(kind) {
54
63
  return 'semantic';
55
64
  }
56
65
  /** Field names whose change on a node makes the change authorization-semantic too (spec16 §156). */
57
- const AUTHZ_FIELDS = ['authorizationPolicy', 'authorization', 'startPolicy', 'instanceAccessPolicy'];
66
+ /**
67
+ * Every field whose change makes a node's diff entry authorization-relevant (spec16pt2 F3,
68
+ * §32-42). `readPolicyId` closes the exact alpha.1 finding: attaching, detaching or
69
+ * replacing the row-level policy a `QueryDef` reads through controls **who may observe**
70
+ * its rows, which is authorization semantics by spec16pt2 §32's own test — the field name
71
+ * containing "Policy" is a coincidence this list must not rely on (§41 forbids matching by
72
+ * substring), so each entry here is a deliberate, individually-justified addition.
73
+ */
74
+ const AUTHZ_FIELDS = [
75
+ 'authorizationPolicy',
76
+ 'authorization',
77
+ 'startPolicy',
78
+ 'instanceAccessPolicy',
79
+ 'readPolicyId',
80
+ ];
58
81
  function isPlainObject(value) {
59
82
  return !!value && typeof value === 'object' && !Array.isArray(value);
60
83
  }
@@ -99,22 +122,19 @@ export function semanticDiff(before, after) {
99
122
  if (strippedEqual) {
100
123
  categories.add('metadata');
101
124
  }
102
- else if (isPlainObject(previous) && isPlainObject(node)) {
103
- const changedFields = new Set([...Object.keys(previous), ...Object.keys(node)].filter((key) => !equalJSON(previous[key], node[key])));
104
- const onlyAuthzFieldsChanged = changedFields.size > 0 && [...changedFields].every((key) => AUTHZ_FIELDS.includes(key));
105
- if (onlyAuthzFieldsChanged) {
106
- categories.add('authorization');
107
- }
108
- else {
109
- categories.add(kindCategory(kind));
110
- if ([...changedFields].some((key) => AUTHZ_FIELDS.includes(key))) {
125
+ else {
126
+ // spec16pt2 §40: categories are additive, never exclusive. An authorization-bearing
127
+ // field change is tagged `authorization` *in addition to* the node's own kind
128
+ // category — never in its place — so `QueryDef.readPolicyId` detaching reads as
129
+ // `[query, authorization]`, not merely `[authorization]` (F3, §34).
130
+ categories.add(kindCategory(kind));
131
+ if (isPlainObject(previous) && isPlainObject(node)) {
132
+ const changedFields = [...new Set([...Object.keys(previous), ...Object.keys(node)])].filter((key) => !equalJSON(previous[key], node[key]));
133
+ if (changedFields.some((key) => AUTHZ_FIELDS.includes(key))) {
111
134
  categories.add('authorization');
112
135
  }
113
136
  }
114
137
  }
115
- else {
116
- categories.add(kindCategory(kind));
117
- }
118
138
  entries.push({
119
139
  changeKind: 'changed',
120
140
  nodeId: id,
package/dist/server-ir.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { actionOperations, operationChildren } from './nodes.js';
1
2
  import { walkExpression } from './expressions.js';
2
3
  import { queryExpressions } from './query.js';
3
4
  import { migrationExpressions } from './migration.js';
@@ -123,7 +124,7 @@ export function usesExternalIOVocabulary(ir) {
123
124
  if ((ir.subscriptions?.length ?? 0) > 0 || (ir.storages?.length ?? 0) > 0) {
124
125
  return true;
125
126
  }
126
- return Object.values(ir.actions).some((action) => (action.operations ?? []).some((operation) => SERVER_IR_V5_OPERATION_KINDS.includes(operation.kind)));
127
+ return Object.values(ir.actions).some((action) => actionOperations(action).some((operation) => SERVER_IR_V5_OPERATION_KINDS.includes(operation.kind)));
127
128
  }
128
129
  /**
129
130
  * Whether a document uses 0.11's schema-evolution vocabulary — a `MigrationDef`, or a
@@ -221,7 +222,7 @@ export function usesQueryVocabulary(ir) {
221
222
  return true;
222
223
  }
223
224
  // A `query` operation inside an action is also v6 vocabulary.
224
- return Object.values(ir.actions).some((action) => (action.operations ?? []).some((operation) => operation.kind === 'query'));
225
+ return Object.values(ir.actions).some((action) => actionOperations(action).some((operation) => operation.kind === 'query'));
225
226
  }
226
227
  /** The higher of two contracts, ordered by `SERVER_IR_CONTRACTS`. */
227
228
  export function maxContract(a, b) {
@@ -349,7 +350,7 @@ function actionExpressions(action) {
349
350
  break;
350
351
  case 'for-each':
351
352
  found.push(operation.collection);
352
- walkOperations(operation.operations);
353
+ walkOperations(operationChildren(operation));
353
354
  break;
354
355
  case 'invoke':
355
356
  found.push(...Object.values(operation.arguments ?? {}));
@@ -379,6 +380,6 @@ function actionExpressions(action) {
379
380
  }
380
381
  }
381
382
  };
382
- walkOperations(action.operations ?? []);
383
+ walkOperations(actionOperations(action));
383
384
  return found;
384
385
  }
@@ -1,7 +1,6 @@
1
1
  import type { ValidationIssue } from './diagnostics.js';
2
2
  import type { NodeId } from './ids.js';
3
3
  import type { SemanticContext } from './infer.js';
4
- import type { Location } from './location.js';
5
4
  export interface LocationValidationOptions {
6
5
  /** Node the location belongs to, for diagnostics. */
7
6
  ownerId?: NodeId;
@@ -11,6 +10,11 @@ export interface LocationValidationOptions {
11
10
  /**
12
11
  * Structural validation of a location: does every state, field and selector it names
13
12
  * exist, do they fit together, and may it be written to?
13
+ *
14
+ * Total over malformed input (spec16pt2 §12-24): a candidate `Location` can arrive from AI
15
+ * generation, deserialization or hand-tampering. `walk` establishes shape before any
16
+ * dereference, so a `null`/primitive/malformed location is reported as one structured
17
+ * `ValidationIssue`, never a native exception.
14
18
  */
15
- export declare function validateLocation(location: Location, context: SemanticContext, options?: LocationValidationOptions): ValidationIssue[];
19
+ export declare function validateLocation(location: unknown, context: SemanticContext, options?: LocationValidationOptions): ValidationIssue[];
16
20
  //# sourceMappingURL=validate-location.d.ts.map
@@ -1,9 +1,29 @@
1
1
  import { VALIDATION_CODES } from './diagnostics.js';
2
2
  import { inferExpressionType, inferLocationType, locationCapabilities } from './infer.js';
3
- import { locationRootStateId } from './location.js';
3
+ import { isPlainLocation, locationRootStateId } from './location.js';
4
+ function isPlainObject(value) {
5
+ return !!value && typeof value === 'object' && !Array.isArray(value);
6
+ }
7
+ /** A short, safe description of a malformed value for a diagnostic message. Never throws. */
8
+ function describeShape(value) {
9
+ if (value === null)
10
+ return 'null';
11
+ if (value === undefined)
12
+ return 'undefined';
13
+ if (Array.isArray(value))
14
+ return 'an array';
15
+ if (typeof value === 'object')
16
+ return 'an object with no recognized "kind"';
17
+ return `a ${typeof value}`;
18
+ }
4
19
  /**
5
20
  * Structural validation of a location: does every state, field and selector it names
6
21
  * exist, do they fit together, and may it be written to?
22
+ *
23
+ * Total over malformed input (spec16pt2 §12-24): a candidate `Location` can arrive from AI
24
+ * generation, deserialization or hand-tampering. `walk` establishes shape before any
25
+ * dereference, so a `null`/primitive/malformed location is reported as one structured
26
+ * `ValidationIssue`, never a native exception.
7
27
  */
8
28
  export function validateLocation(location, context, options = {}) {
9
29
  const problems = [];
@@ -21,6 +41,10 @@ export function validateLocation(location, context, options = {}) {
21
41
  return problems;
22
42
  }
23
43
  function walk(location, context, report) {
44
+ if (!isPlainLocation(location)) {
45
+ report(VALIDATION_CODES.unknownStateRef, `Location is not a valid location structure: expected an object with a recognized "kind", got ${describeShape(location)}`);
46
+ return;
47
+ }
24
48
  switch (location.kind) {
25
49
  case 'state': {
26
50
  if (!context.getState(location.stateId)) {
@@ -68,6 +92,10 @@ function walk(location, context, report) {
68
92
  report(VALIDATION_CODES.selectorOnNonCollection, `An item selector was applied to a ${resolved.kind} value`);
69
93
  return;
70
94
  }
95
+ if (!isPlainObject(location.selector) || (location.selector.kind !== 'identity' && location.selector.kind !== 'index')) {
96
+ report(VALIDATION_CODES.invalidSelectorType, `Item selector is not a valid selector structure: expected { kind: 'identity' | 'index', ... }, got ${describeShape(location.selector)}`);
97
+ return;
98
+ }
71
99
  if (location.selector.kind !== 'identity') {
72
100
  // An index has to be a number. Type inference is partial, so this rejects only
73
101
  // what is statically certain to be wrong.
@@ -1,4 +1,5 @@
1
1
  import { VALIDATION_CODES } from './diagnostics.js';
2
+ import { actionOperations } from './nodes.js';
2
3
  import { ACTION_UX_ROLES, ALIGNMENTS, BOUNDED_SIZES, CONTROL_VARIANTS, DENSITIES, DEVICE_CLASSES, EMPHASIS_LEVELS, ICON_NAMES, JUSTIFICATIONS, LAYOUT_KINDS, PRESENTATION_ROLES, SIZING_VALUES, SPACING_TOKENS, SURFACE_ROLES, TEXT_ROLES, TREATMENTS, HEADING_LEVELS, UX_ROLES, VALUE_FORMAT_KINDS, } from './presentation.js';
3
4
  import { isDestructiveAction } from './resolve-presentation.js';
4
5
  import { APPEARANCES, RADIUS_TOKENS, SEMANTIC_COLOR_ROLES } from './theme.js';
@@ -759,7 +760,7 @@ function checkUnmarkedDestructiveActions(bag, index, actions) {
759
760
  if (!bound.has(action.id) || action.destructive === true) {
760
761
  continue;
761
762
  }
762
- if ((action.operations ?? []).some((operation) => operation.kind === 'remove')) {
763
+ if (actionOperations(action).some((operation) => operation.kind === 'remove')) {
763
764
  bag.warnings.push({
764
765
  code: VALIDATION_CODES.destructiveActionUnmarked,
765
766
  message: `${action.name ?? action.id} removes data but does not declare "destructive"`,
package/dist/validate.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AGGREGATE_FUNCTIONS, BUILTIN_FUNCTIONS, expressionDefsIn, walkExpression } from './expressions.js';
2
2
  import { VALIDATION_CODES } from './diagnostics.js';
3
- import { EDGE_KINDS, actionGuards, isMutationOperation } from './nodes.js';
3
+ import { EDGE_KINDS, actionGuards, isMutationOperation, isPlainOperation, rawOperations } from './nodes.js';
4
4
  import { SUBSCRIPTION_BACKPRESSURE_POLICIES, subscriptionBackpressure, subscriptionQueueLimit, } from './subscriptions.js';
5
5
  import { BLOB_REF_FIELDS } from './storage.js';
6
6
  import { queryPaginationStrategy, sortKeyDirection } from './query.js';
@@ -120,6 +120,9 @@ export function validateGraph(graph, options = {}) {
120
120
  warnings.push(...migration.warnings);
121
121
  return { valid: errors.length === 0, errors, warnings };
122
122
  }
123
+ function isPlainObject(value) {
124
+ return !!value && typeof value === 'object' && !Array.isArray(value);
125
+ }
123
126
  function validateNode(node, context) {
124
127
  if (isUINode(node)) {
125
128
  validateUiNode(node, context);
@@ -297,25 +300,64 @@ function validateAction(action, context) {
297
300
  for (const postcondition of action.postconditions ?? []) {
298
301
  validateExpression(postcondition, action.id, context, local);
299
302
  }
303
+ // spec16pt2 F1 — a candidate ActionDef.operations can arrive from AI generation,
304
+ // deserialization or hand-tampering, so its runtime shape is checked before it is ever
305
+ // iterated. A present-but-non-array value is a distinct, structural defect from "absent"
306
+ // (spec16pt2 §15): absent means no operations, non-array means the graph is malformed.
307
+ if (action.operations !== undefined && !Array.isArray(action.operations)) {
308
+ context.errors.push({
309
+ code: VALIDATION_CODES.invalidOperationCollection,
310
+ message: `Action ${action.id} declares operations as ${describeOperationShape(action.operations)}, not an array`,
311
+ nodeId: action.id,
312
+ });
313
+ }
300
314
  let scoped = local;
301
- for (const operation of action.operations ?? []) {
315
+ for (const operation of rawOperations(action)) {
302
316
  scoped = validateOperation(operation, action, context, scoped);
303
317
  }
304
318
  }
319
+ /** A short, safe description of a malformed value for a diagnostic message. Never throws. */
320
+ function describeOperationShape(value) {
321
+ if (value === null)
322
+ return 'null';
323
+ if (Array.isArray(value))
324
+ return 'an array of the wrong shape';
325
+ if (typeof value === 'object')
326
+ return 'an object';
327
+ return `a ${typeof value}`;
328
+ }
305
329
  function validateOperation(operation, action, context, local) {
330
+ // spec16pt2 F1/F2 — an operation entry (top-level, or nested inside a for-each) can itself
331
+ // be malformed: `null`, a primitive, or an object with no recognized `kind`. Establish
332
+ // shape before the switch below ever reads `.kind`/`.target`/`.value` (spec16pt2 §20).
333
+ if (!isPlainOperation(operation)) {
334
+ context.errors.push({
335
+ code: VALIDATION_CODES.invalidOperation,
336
+ message: `Action ${action.id} declares an operation that is ${describeOperationShape(operation)}, not a recognized operation`,
337
+ nodeId: action.id,
338
+ });
339
+ return local;
340
+ }
306
341
  switch (operation.kind) {
307
342
  case 'for-each': {
308
343
  validateExpression(operation.collection, action.id, context, local);
309
344
  requireCollection(operation.collection, action.id, context, local, 'for-each');
310
345
  const scoped = iterationScope(local, operation.scopeId, operation.collection, context, action.id);
311
- if ((operation.operations ?? []).length === 0) {
346
+ if (operation.operations !== undefined && !Array.isArray(operation.operations)) {
347
+ context.errors.push({
348
+ code: VALIDATION_CODES.invalidOperationCollection,
349
+ message: `A for-each in ${action.id} declares operations as ${describeOperationShape(operation.operations)}, not an array`,
350
+ nodeId: action.id,
351
+ });
352
+ }
353
+ else if (rawOperations(operation).length === 0) {
312
354
  context.warnings.push({
313
355
  code: VALIDATION_CODES.unsupportedOperation,
314
356
  message: `A for-each in ${action.id} performs no mutations`,
315
357
  nodeId: action.id,
316
358
  });
317
359
  }
318
- for (const nested of operation.operations ?? []) {
360
+ for (const nested of rawOperations(operation)) {
319
361
  if (!isMutationOperation(nested)) {
320
362
  context.errors.push({
321
363
  code: VALIDATION_CODES.unsupportedOperation,
@@ -1954,8 +1996,20 @@ function iterationScope(scope, scopeId, source, context, ownerId) {
1954
1996
  }
1955
1997
  return { ids: new Set([...scope.ids, scopeId]), types };
1956
1998
  }
1957
- function validateExpression(expression, ownerId, context, local) {
1999
+ function validateExpression(expressionInput, ownerId, context, local) {
1958
2000
  const scope = local instanceof Set ? emptyScope(local) : local;
2001
+ // spec16pt2 §12-24 — a candidate expression can be malformed (a deleted/tampered field,
2002
+ // an array-for-object mutation): establish shape before the switch below ever reads
2003
+ // `.kind`, exactly like the operation/location totality fix above.
2004
+ if (!isPlainObject(expressionInput) || typeof expressionInput.kind !== 'string') {
2005
+ context.errors.push({
2006
+ code: VALIDATION_CODES.unsupportedExpression,
2007
+ message: `${ownerId} contains an expression that is not a recognized structure`,
2008
+ nodeId: ownerId,
2009
+ });
2010
+ return;
2011
+ }
2012
+ const expression = expressionInput;
1959
2013
  switch (expression.kind) {
1960
2014
  case 'literal':
1961
2015
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-core",
3
- "version": "0.16.0-alpha.1",
3
+ "version": "0.16.0-alpha.2",
4
4
  "description": "Application Graph, semantic types, locations and validation for Axiom.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",