@cynodia/axiom-runtime 0.3.1-alpha.1 → 0.4.1-alpha.1

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.
@@ -16,6 +16,12 @@ export interface RuntimeTransaction {
16
16
  export interface TransactionManager {
17
17
  begin(): RuntimeTransaction;
18
18
  currentId(): string | undefined;
19
+ /**
20
+ * Committed state as it was immediately before the outermost open transaction began —
21
+ * what a transition rule means by "previous". Not the previous operation, not the
22
+ * previous iteration.
23
+ */
24
+ entrySnapshot(): unknown;
19
25
  }
20
26
  export declare function createTransactionManager(store: StoreSnapshot, nextId: () => string): TransactionManager;
21
27
  //# sourceMappingURL=transaction.d.ts.map
@@ -2,6 +2,7 @@ export function createTransactionManager(store, nextId) {
2
2
  const open = [];
3
3
  return {
4
4
  currentId: () => open[0]?.id,
5
+ entrySnapshot: () => open[0]?.snapshot,
5
6
  begin() {
6
7
  const isRoot = open.length === 0;
7
8
  const id = isRoot ? nextId() : open[0].id;
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Raised when an expression cannot be evaluated — a collection operator applied to
3
+ * something that is not a collection, an aggregation over non-numeric data, a reference
4
+ * that does not resolve. Failing loudly is deliberate: an expression must never return a
5
+ * plausible-looking value and report a failure at the same time.
6
+ */
7
+ export declare class ExpressionEvaluationError extends Error {
8
+ readonly details: Record<string, unknown>;
9
+ constructor(message: string, details?: Record<string, unknown>);
10
+ }
1
11
  /**
2
12
  * Value helpers shared by the runtime and the mutation subsystem.
3
13
  *
@@ -5,11 +15,20 @@
5
15
  * throws in strict mode, which is what keeps "no implicit object mutation" an enforced
6
16
  * invariant rather than a convention.
7
17
  */
18
+ /**
19
+ * A structured clone, not a JSON round trip: a JSON round trip turns values like NaN into
20
+ * null, which would silently disguise a failed computation as an absent one.
21
+ */
8
22
  export declare function cloneValue<T>(value: T): T;
9
23
  export declare function deepFreeze<T>(value: T): T;
10
24
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
11
- /** Presence semantics used by `required` — distinct from boolean coercion. */
25
+ /**
26
+ * Presence answers one question: does a value exist? It says nothing about whether the
27
+ * value is empty. An empty collection, an empty string, zero and false are all present.
28
+ */
12
29
  export declare function isPresent(value: unknown): boolean;
30
+ /** Emptiness of a collection or a string. Anything else is never empty. */
31
+ export declare function isEmptyValue(value: unknown): boolean;
13
32
  export declare function toBoolean(value: unknown): boolean;
14
33
  export declare function toText(value: unknown): string;
15
34
  export declare function compareValues(left: unknown, right: unknown): number;
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Raised when an expression cannot be evaluated — a collection operator applied to
3
+ * something that is not a collection, an aggregation over non-numeric data, a reference
4
+ * that does not resolve. Failing loudly is deliberate: an expression must never return a
5
+ * plausible-looking value and report a failure at the same time.
6
+ */
7
+ export class ExpressionEvaluationError extends Error {
8
+ details;
9
+ constructor(message, details = {}) {
10
+ super(message);
11
+ this.name = 'ExpressionEvaluationError';
12
+ this.details = details;
13
+ }
14
+ }
1
15
  /**
2
16
  * Value helpers shared by the runtime and the mutation subsystem.
3
17
  *
@@ -5,8 +19,12 @@
5
19
  * throws in strict mode, which is what keeps "no implicit object mutation" an enforced
6
20
  * invariant rather than a convention.
7
21
  */
22
+ /**
23
+ * A structured clone, not a JSON round trip: a JSON round trip turns values like NaN into
24
+ * null, which would silently disguise a failed computation as an absent one.
25
+ */
8
26
  export function cloneValue(value) {
9
- return value === undefined ? value : JSON.parse(JSON.stringify(value));
27
+ return value === undefined ? value : structuredClone(value);
10
28
  }
11
29
  export function deepFreeze(value) {
12
30
  if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
@@ -20,18 +38,25 @@ export function deepFreeze(value) {
20
38
  export function isRecord(value) {
21
39
  return typeof value === 'object' && value !== null && !Array.isArray(value);
22
40
  }
23
- /** Presence semantics used by `required` — distinct from boolean coercion. */
41
+ /**
42
+ * Presence answers one question: does a value exist? It says nothing about whether the
43
+ * value is empty. An empty collection, an empty string, zero and false are all present.
44
+ */
24
45
  export function isPresent(value) {
46
+ return value !== null && value !== undefined;
47
+ }
48
+ /** Emptiness of a collection or a string. Anything else is never empty. */
49
+ export function isEmptyValue(value) {
25
50
  if (value === null || value === undefined) {
26
- return false;
51
+ return true;
27
52
  }
28
53
  if (typeof value === 'string') {
29
- return value.trim().length > 0;
54
+ return value.trim().length === 0;
30
55
  }
31
56
  if (Array.isArray(value)) {
32
- return value.length > 0;
57
+ return value.length === 0;
33
58
  }
34
- return true;
59
+ return false;
35
60
  }
36
61
  export function toBoolean(value) {
37
62
  if (Array.isArray(value)) {
@@ -59,6 +84,19 @@ export function compareValues(left, right) {
59
84
  const rightText = toText(right);
60
85
  return leftText === rightText ? 0 : leftText < rightText ? -1 : 1;
61
86
  }
87
+ /** A stable serialization, so record comparison does not depend on key order. */
88
+ function canonical(value) {
89
+ if (value === null || typeof value !== 'object') {
90
+ return JSON.stringify(value) ?? 'null';
91
+ }
92
+ if (Array.isArray(value)) {
93
+ return `[${value.map(canonical).join(',')}]`;
94
+ }
95
+ const entries = Object.entries(value)
96
+ .filter(([, entry]) => entry !== undefined)
97
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
98
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(',')}}`;
99
+ }
62
100
  export function valuesEqual(left, right) {
63
101
  if (left === right) {
64
102
  return true;
@@ -67,7 +105,7 @@ export function valuesEqual(left, right) {
67
105
  return (left ?? null) === (right ?? null);
68
106
  }
69
107
  if (typeof left === 'object' || typeof right === 'object') {
70
- return JSON.stringify(left) === JSON.stringify(right);
108
+ return canonical(left) === canonical(right);
71
109
  }
72
110
  return false;
73
111
  }
package/dist/runtime.d.ts CHANGED
@@ -1,12 +1,49 @@
1
- import type { ApplicationIR, CompiledRoute, FieldId, NodeId } from '@cynodia/axiom-core';
1
+ import type { ApplicationIR, CompiledRoute, FieldId, Location, NodeId } from '@cynodia/axiom-core';
2
2
  import type { DomElement, HostEnvironment } from './dom.js';
3
3
  import type { MutationLogEntry } from './mutation/mutation-engine.js';
4
+ /**
5
+ * The diagnostics a runtime can report. Agents match on the code rather than parsing the
6
+ * message, so this vocabulary is part of the public contract.
7
+ */
8
+ export declare const RUNTIME_DIAGNOSTIC_CODES: {
9
+ readonly ACTION_NOT_FOUND: "ACTION_NOT_FOUND";
10
+ readonly PARAMETER_MISSING: "PARAMETER_MISSING";
11
+ readonly PRECONDITION_FAILED: "PRECONDITION_FAILED";
12
+ readonly POSTCONDITION_FAILED: "POSTCONDITION_FAILED";
13
+ readonly CONSTRAINT_VIOLATION: "CONSTRAINT_VIOLATION";
14
+ readonly REQUIRED_FIELD_MISSING: "REQUIRED_FIELD_MISSING";
15
+ readonly ENUM_VALUE_INVALID: "ENUM_VALUE_INVALID";
16
+ readonly TYPE_MISMATCH: "TYPE_MISMATCH";
17
+ readonly EXPRESSION_EVALUATION_FAILED: "EXPRESSION_EVALUATION_FAILED";
18
+ readonly UNRESOLVED_REFERENCE: "UNRESOLVED_REFERENCE";
19
+ readonly LOCATION_RESOLUTION_FAILED: "LOCATION_RESOLUTION_FAILED";
20
+ readonly MUTATION_FAILED: "MUTATION_FAILED";
21
+ readonly DERIVED_STATE_WRITE: "DERIVED_STATE_WRITE";
22
+ readonly UNKNOWN_STATE: "UNKNOWN_STATE";
23
+ readonly TRANSITION_CONSTRAINT_VIOLATION: "TRANSITION_CONSTRAINT_VIOLATION";
24
+ readonly UNSUPPORTED_EXPRESSION: "UNSUPPORTED_EXPRESSION";
25
+ readonly UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION";
26
+ readonly ROUTE_NOT_FOUND: "ROUTE_NOT_FOUND";
27
+ readonly NATIVE_OPERATION_MISSING: "NATIVE_OPERATION_MISSING";
28
+ readonly UI_NODE_MISSING: "UI_NODE_MISSING";
29
+ readonly UNSUPPORTED_UI_NODE: "UNSUPPORTED_UI_NODE";
30
+ readonly INPUT_REJECTED: "INPUT_REJECTED";
31
+ readonly PERSISTED_STATE_UNREADABLE: "PERSISTED_STATE_UNREADABLE";
32
+ };
33
+ export type RuntimeDiagnosticCode = (typeof RUNTIME_DIAGNOSTIC_CODES)[keyof typeof RUNTIME_DIAGNOSTIC_CODES];
4
34
  export interface RuntimeDiagnostic {
5
- code: string;
35
+ code: RuntimeDiagnosticCode;
6
36
  message: string;
7
37
  severity: 'error' | 'warning';
8
38
  nodeId?: NodeId;
9
39
  fieldId?: FieldId;
40
+ actionId?: NodeId;
41
+ constraintId?: NodeId;
42
+ stateId?: NodeId;
43
+ location?: Location;
44
+ transactionId?: string;
45
+ /** Structured context, so an agent never has to read the message. */
46
+ details?: Record<string, unknown>;
10
47
  }
11
48
  export interface ActionResult {
12
49
  ok: boolean;
@@ -33,11 +70,20 @@ export interface AxiomRuntime {
33
70
  start(): void;
34
71
  render(): void;
35
72
  getState(id: NodeId): unknown;
36
- setState(id: NodeId, value: unknown): void;
73
+ /**
74
+ * Replaces a state value outright, for hosts, tests and seeding.
75
+ *
76
+ * This is an administrative facility, not a semantic write: it does not evaluate
77
+ * preconditions, entity constraints or transition constraints. Application behaviour
78
+ * belongs in actions and input bindings, which are governed.
79
+ */
80
+ hydrateState(id: NodeId, value: unknown): void;
37
81
  invokeAction(id: NodeId, args?: Record<string, unknown>): ActionResult;
38
82
  navigate(path: string): void;
39
83
  currentRoute(): RouteMatch | null;
84
+ /** Every diagnostic reported so far. Per-invocation results carry their own. */
40
85
  diagnostics(): RuntimeDiagnostic[];
86
+ clearDiagnostics(): void;
41
87
  /** Every mutation this runtime has applied, in order, with its semantic location. */
42
88
  getMutationLog(): MutationLogEntry[];
43
89
  registerNativeOperation(implementationId: string, implementation: NativeImplementation): void;
package/dist/runtime.js CHANGED
@@ -2,7 +2,36 @@ import { createMutationEngine } from './mutation/mutation-engine.js';
2
2
  import { LocationResolutionError, resolveLocation } from './mutation/resolve-location.js';
3
3
  import { createStateStore } from './mutation/store.js';
4
4
  import { createTransactionManager } from './mutation/transaction.js';
5
- import { cloneValue, compareValues, deepFreeze, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
5
+ import { ExpressionEvaluationError, cloneValue, compareValues, deepFreeze, isEmptyValue, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
6
+ /**
7
+ * The diagnostics a runtime can report. Agents match on the code rather than parsing the
8
+ * message, so this vocabulary is part of the public contract.
9
+ */
10
+ export const RUNTIME_DIAGNOSTIC_CODES = {
11
+ ACTION_NOT_FOUND: 'ACTION_NOT_FOUND',
12
+ PARAMETER_MISSING: 'PARAMETER_MISSING',
13
+ PRECONDITION_FAILED: 'PRECONDITION_FAILED',
14
+ POSTCONDITION_FAILED: 'POSTCONDITION_FAILED',
15
+ CONSTRAINT_VIOLATION: 'CONSTRAINT_VIOLATION',
16
+ REQUIRED_FIELD_MISSING: 'REQUIRED_FIELD_MISSING',
17
+ ENUM_VALUE_INVALID: 'ENUM_VALUE_INVALID',
18
+ TYPE_MISMATCH: 'TYPE_MISMATCH',
19
+ EXPRESSION_EVALUATION_FAILED: 'EXPRESSION_EVALUATION_FAILED',
20
+ UNRESOLVED_REFERENCE: 'UNRESOLVED_REFERENCE',
21
+ LOCATION_RESOLUTION_FAILED: 'LOCATION_RESOLUTION_FAILED',
22
+ MUTATION_FAILED: 'MUTATION_FAILED',
23
+ DERIVED_STATE_WRITE: 'DERIVED_STATE_WRITE',
24
+ UNKNOWN_STATE: 'UNKNOWN_STATE',
25
+ TRANSITION_CONSTRAINT_VIOLATION: 'TRANSITION_CONSTRAINT_VIOLATION',
26
+ UNSUPPORTED_EXPRESSION: 'UNSUPPORTED_EXPRESSION',
27
+ UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION',
28
+ ROUTE_NOT_FOUND: 'ROUTE_NOT_FOUND',
29
+ NATIVE_OPERATION_MISSING: 'NATIVE_OPERATION_MISSING',
30
+ UI_NODE_MISSING: 'UI_NODE_MISSING',
31
+ UNSUPPORTED_UI_NODE: 'UNSUPPORTED_UI_NODE',
32
+ INPUT_REJECTED: 'INPUT_REJECTED',
33
+ PERSISTED_STATE_UNREADABLE: 'PERSISTED_STATE_UNREADABLE',
34
+ };
6
35
  const MISSING = Symbol('missing');
7
36
  function unwrapType(type) {
8
37
  return type.kind === 'optional' ? unwrapType(type.valueType) : type;
@@ -30,6 +59,30 @@ function defaultForType(type) {
30
59
  return null;
31
60
  }
32
61
  }
62
+ /**
63
+ * Collection operators are strict about their source: `null` means a missing or invalid
64
+ * collection, and an empty collection means an empty collection. Conflating the two is
65
+ * what made 0.4 hard to reason about.
66
+ */
67
+ function requireCollection(value, operator) {
68
+ if (!Array.isArray(value)) {
69
+ throw new ExpressionEvaluationError(`${operator} expects a collection but received ${describeValue(value)}`, { operator, received: value });
70
+ }
71
+ return value;
72
+ }
73
+ /** A short, structured description of a runtime value, for diagnostics. */
74
+ function describeValue(value) {
75
+ if (value === null || value === undefined) {
76
+ return 'nothing';
77
+ }
78
+ if (Array.isArray(value)) {
79
+ return `a collection of ${value.length}`;
80
+ }
81
+ if (typeof value === 'object') {
82
+ return 'a record';
83
+ }
84
+ return `${typeof value} ${JSON.stringify(value)}`;
85
+ }
33
86
  export function createAxiomRuntime(options) {
34
87
  const { ir, rootElement, host } = options;
35
88
  const store = createStateStore();
@@ -56,12 +109,27 @@ export function createAxiomRuntime(options) {
56
109
  parameterTypes.set(parameter.id, parameter.valueType);
57
110
  }
58
111
  }
112
+ /** Diagnostics reported while the current invocation runs, if one is collecting. */
113
+ let collector = null;
59
114
  function report(diagnostic) {
60
115
  diagnostics.push(diagnostic);
116
+ collector?.push(diagnostic);
61
117
  if (diagnostic.severity === 'error') {
62
118
  host.report?.(`${diagnostic.code}: ${diagnostic.message}`);
63
119
  }
64
120
  }
121
+ /** Runs `body` while gathering every diagnostic it reports. */
122
+ function collecting(body) {
123
+ const previous = collector;
124
+ const collected = [];
125
+ collector = collected;
126
+ try {
127
+ return body(collected);
128
+ }
129
+ finally {
130
+ collector = previous;
131
+ }
132
+ }
65
133
  // ---------------------------------------------------------------- state store
66
134
  function storageKey(state) {
67
135
  if (state.persistence?.kind !== 'local-storage') {
@@ -84,7 +152,7 @@ export function createAxiomRuntime(options) {
84
152
  }
85
153
  catch {
86
154
  report({
87
- code: 'PERSISTED_STATE_UNREADABLE',
155
+ code: RUNTIME_DIAGNOSTIC_CODES.PERSISTED_STATE_UNREADABLE,
88
156
  message: `Stored value for ${state.id} could not be parsed; falling back to the initial value`,
89
157
  severity: 'warning',
90
158
  nodeId: state.id,
@@ -116,9 +184,16 @@ export function createAxiomRuntime(options) {
116
184
  return derivedCache.get(stateId);
117
185
  }
118
186
  derivedCache.set(stateId, null);
119
- const value = deepFreeze(cloneValue(evaluate(state.derivation, rootScope())));
120
- derivedCache.set(stateId, value);
121
- return value;
187
+ try {
188
+ const value = deepFreeze(cloneValue(evaluate(state.derivation, rootScope())));
189
+ derivedCache.set(stateId, value);
190
+ return value;
191
+ }
192
+ catch (error) {
193
+ report(evaluationFailure(error, { nodeId: state.id, stateId: state.id }));
194
+ derivedCache.set(stateId, null);
195
+ return null;
196
+ }
122
197
  }
123
198
  return store.read(stateId);
124
199
  }
@@ -126,7 +201,7 @@ export function createAxiomRuntime(options) {
126
201
  function writeState(stateId, value) {
127
202
  if (!statesById.has(stateId)) {
128
203
  report({
129
- code: 'UNKNOWN_STATE',
204
+ code: RUNTIME_DIAGNOSTIC_CODES.UNKNOWN_STATE,
130
205
  message: `Cannot write to unknown state ${stateId}`,
131
206
  severity: 'error',
132
207
  nodeId: stateId,
@@ -135,7 +210,7 @@ export function createAxiomRuntime(options) {
135
210
  }
136
211
  if (statesById.get(stateId)?.derivation) {
137
212
  report({
138
- code: 'DERIVED_STATE_WRITE',
213
+ code: RUNTIME_DIAGNOSTIC_CODES.DERIVED_STATE_WRITE,
139
214
  message: `${stateId} is derived state and cannot be written to`,
140
215
  severity: 'error',
141
216
  nodeId: stateId,
@@ -189,6 +264,28 @@ export function createAxiomRuntime(options) {
189
264
  }
190
265
  }
191
266
  }
267
+ /** Turns an evaluation failure into a diagnostic instead of letting it escape. */
268
+ function evaluationFailure(error, context = {}) {
269
+ if (error instanceof ExpressionEvaluationError) {
270
+ return {
271
+ code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
272
+ message: error.message,
273
+ severity: 'error',
274
+ ...context,
275
+ details: { ...error.details, ...(context.details ?? {}) },
276
+ };
277
+ }
278
+ throw error;
279
+ }
280
+ /** Evaluates an expression, reporting rather than throwing if it cannot be evaluated. */
281
+ function tryEvaluate(expression, scope, context = {}) {
282
+ try {
283
+ return { ok: true, value: evaluate(expression, scope) };
284
+ }
285
+ catch (error) {
286
+ return { ok: false, diagnostic: evaluationFailure(error, context) };
287
+ }
288
+ }
192
289
  /** Applies a mutation inside the current transaction and reports resolution failures. */
193
290
  function mutate(apply, context, failures) {
194
291
  try {
@@ -196,7 +293,7 @@ export function createAxiomRuntime(options) {
196
293
  }
197
294
  catch (error) {
198
295
  const failure = {
199
- code: error instanceof LocationResolutionError ? 'LOCATION_UNRESOLVED' : 'MUTATION_FAILED',
296
+ code: error instanceof LocationResolutionError ? RUNTIME_DIAGNOSTIC_CODES.LOCATION_RESOLUTION_FAILED : 'MUTATION_FAILED',
200
297
  message: error instanceof Error ? error.message : String(error),
201
298
  severity: 'error',
202
299
  ...(context.sourceNodeId ? { nodeId: context.sourceNodeId } : {}),
@@ -242,13 +339,7 @@ export function createAxiomRuntime(options) {
242
339
  if (statesById.has(expression.targetId)) {
243
340
  return readState(expression.targetId);
244
341
  }
245
- report({
246
- code: 'UNRESOLVED_REFERENCE',
247
- message: `Reference ${expression.targetId} could not be resolved`,
248
- severity: 'error',
249
- nodeId: expression.targetId,
250
- });
251
- return null;
342
+ throw new ExpressionEvaluationError(`Reference ${expression.targetId} could not be resolved`, { targetId: expression.targetId });
252
343
  }
253
344
  case 'field': {
254
345
  const source = evaluate(expression.source, scope);
@@ -274,27 +365,42 @@ export function createAxiomRuntime(options) {
274
365
  case 'call':
275
366
  return evaluateCall(expression.function, expression.arguments, scope);
276
367
  case 'filter': {
277
- const source = evaluate(expression.source, scope);
278
- if (!Array.isArray(source)) {
279
- return [];
280
- }
368
+ const source = requireCollection(evaluate(expression.source, scope), 'filter');
281
369
  return source.filter((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
282
370
  }
371
+ case 'map': {
372
+ const source = requireCollection(evaluate(expression.source, scope), 'map');
373
+ return source.map((item) => evaluate(expression.projection, childScope(scope, expression.scopeId, item)));
374
+ }
375
+ case 'sort': {
376
+ const source = requireCollection(evaluate(expression.source, scope), 'sort');
377
+ const direction = expression.direction === 'desc' ? -1 : 1;
378
+ const key = (item) => evaluate(expression.by, childScope(scope, expression.scopeId, item));
379
+ return [...source].sort((left, right) => direction * compareValues(key(left), key(right)));
380
+ }
381
+ case 'every': {
382
+ const source = requireCollection(evaluate(expression.source, scope), 'every');
383
+ return source.every((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
384
+ }
385
+ case 'some': {
386
+ const source = requireCollection(evaluate(expression.source, scope), 'some');
387
+ return source.some((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
388
+ }
389
+ case 'flatten': {
390
+ const source = requireCollection(evaluate(expression.source, scope), 'flatten');
391
+ return source.flatMap((member) => requireCollection(member, 'flatten'));
392
+ }
393
+ case 'conditional':
394
+ return toBoolean(evaluate(expression.condition, scope))
395
+ ? evaluate(expression.whenTrue, scope)
396
+ : evaluate(expression.whenFalse, scope);
283
397
  case 'find': {
284
- const source = evaluate(expression.source, scope);
285
- if (!Array.isArray(source)) {
286
- return null;
287
- }
398
+ const source = requireCollection(evaluate(expression.source, scope), 'find');
288
399
  const found = source.find((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
289
400
  return found === undefined ? null : found;
290
401
  }
291
402
  default:
292
- report({
293
- code: 'UNKNOWN_EXPRESSION',
294
- message: `Unknown expression kind "${expression.kind}"`,
295
- severity: 'error',
296
- });
297
- return null;
403
+ throw new ExpressionEvaluationError(`Unknown expression kind "${expression.kind}"`, { kind: expression.kind });
298
404
  }
299
405
  }
300
406
  function evaluateBinary(operator, leftExpression, rightExpression, scope) {
@@ -330,21 +436,36 @@ export function createAxiomRuntime(options) {
330
436
  return divisor === 0 ? null : Number(left ?? 0) / divisor;
331
437
  }
332
438
  default:
333
- report({ code: 'UNKNOWN_OPERATOR', message: `Unknown operator "${operator}"`, severity: 'error' });
334
- return null;
439
+ throw new ExpressionEvaluationError(`Unknown operator "${operator}"`, { operator });
335
440
  }
336
441
  }
337
442
  function evaluateCall(fn, args, scope) {
338
443
  const values = args.map((argument) => evaluate(argument, scope));
339
444
  switch (fn) {
340
445
  case 'required':
446
+ // Presence only: an empty collection or string exists, and so does 0 and false.
341
447
  return isPresent(values[0]);
342
448
  case 'is-empty':
343
- return !isPresent(values[0]);
449
+ return isEmptyValue(values[0]);
450
+ case 'non-empty':
451
+ return !isEmptyValue(values[0]);
344
452
  case 'length':
345
453
  return Array.isArray(values[0]) ? values[0].length : toText(values[0]).length;
346
454
  case 'count':
347
- return Array.isArray(values[0]) ? values[0].length : 0;
455
+ return requireCollection(values[0], 'count').length;
456
+ case 'sum': {
457
+ // An aggregation that cannot be computed fails; it never returns a number that
458
+ // would quietly satisfy a guard.
459
+ const source = requireCollection(values[0], 'sum');
460
+ let total = 0;
461
+ for (const member of source) {
462
+ if (typeof member !== 'number' || !Number.isFinite(member)) {
463
+ throw new ExpressionEvaluationError(`sum encountered ${describeValue(member)} where a number was required`, { member });
464
+ }
465
+ total += member;
466
+ }
467
+ return total;
468
+ }
348
469
  case 'contains': {
349
470
  const [haystack, needle] = values;
350
471
  if (Array.isArray(haystack)) {
@@ -355,6 +476,7 @@ export function createAxiomRuntime(options) {
355
476
  case 'concat':
356
477
  return values.map(toText).join('');
357
478
  case 'coalesce':
479
+ // Nullish, not "non-empty": falling back to an empty collection has to be possible.
358
480
  return values.find((value) => isPresent(value)) ?? null;
359
481
  case 'one-of':
360
482
  return values.slice(1).some((option) => valuesEqual(option, values[0]));
@@ -367,8 +489,7 @@ export function createAxiomRuntime(options) {
367
489
  case 'uuid':
368
490
  return host.uuid();
369
491
  default:
370
- report({ code: 'UNKNOWN_FUNCTION', message: `Unknown function "${fn}"`, severity: 'error' });
371
- return null;
492
+ throw new ExpressionEvaluationError(`Unknown function "${fn}"`, { function: fn });
372
493
  }
373
494
  }
374
495
  // -------------------------------------------------------------- validation
@@ -380,32 +501,110 @@ export function createAxiomRuntime(options) {
380
501
  }
381
502
  return resolved.kind === 'entity' ? resolved.entityId : null;
382
503
  }
383
- function instancesOf(entityId) {
384
- const instances = [];
504
+ /**
505
+ * Every canonical occurrence of every entity, found by walking state values against
506
+ * their declared types. Entities nested inside collections and inside other entities
507
+ * are reached recursively, so their constraints apply wherever they actually live.
508
+ */
509
+ function collectInstances(read = readState) {
510
+ const found = new Map();
511
+ const visit = (value, type) => {
512
+ const resolved = unwrapType(type);
513
+ if (resolved.kind === 'collection') {
514
+ if (Array.isArray(value)) {
515
+ for (const item of value) {
516
+ visit(item, resolved.itemType);
517
+ }
518
+ }
519
+ return;
520
+ }
521
+ if (resolved.kind !== 'entity' || !isRecord(value)) {
522
+ return;
523
+ }
524
+ const instances = found.get(resolved.entityId) ?? [];
525
+ instances.push(value);
526
+ found.set(resolved.entityId, instances);
527
+ for (const field of entitiesById.get(resolved.entityId)?.fields ?? []) {
528
+ visit(value[field.id], field.valueType);
529
+ }
530
+ };
385
531
  for (const state of ir.states) {
386
532
  // Drafts are incomplete by definition, and derived states are views of data that
387
533
  // is already validated where it is stored.
388
534
  if (state.draft || state.derivation) {
389
535
  continue;
390
536
  }
391
- if (collectionEntityId(state) !== entityId) {
537
+ visit(read(state.id), state.valueType);
538
+ }
539
+ return found;
540
+ }
541
+ /**
542
+ * Transition rules, evaluated against the state the transaction started from and the
543
+ * state it now proposes. Every governed mutation path runs this before committing, so a
544
+ * rule holds regardless of which path attempted the write.
545
+ */
546
+ function evaluateTransitions() {
547
+ const snapshot = transactions.entrySnapshot();
548
+ if (!snapshot || ir.transitionConstraints.length === 0) {
549
+ return [];
550
+ }
551
+ const previousInstances = collectInstances((stateId) => snapshot.get(stateId));
552
+ const proposedInstances = collectInstances();
553
+ const failures = [];
554
+ for (const rule of ir.transitionConstraints) {
555
+ const entity = entitiesById.get(rule.entityId);
556
+ const identity = entity?.identityFieldId;
557
+ if (!identity) {
392
558
  continue;
393
559
  }
394
- const value = readState(state.id);
395
- if (Array.isArray(value)) {
396
- instances.push(...value);
560
+ const identify = (instance) => (isRecord(instance) ? instance[identity] : undefined);
561
+ const proposedByIdentity = new Map();
562
+ for (const instance of proposedInstances.get(rule.entityId) ?? []) {
563
+ proposedByIdentity.set(toText(identify(instance)), instance);
397
564
  }
398
- else if (isRecord(value)) {
399
- instances.push(value);
565
+ for (const previous of previousInstances.get(rule.entityId) ?? []) {
566
+ const key = toText(identify(previous));
567
+ // A removed instance is a transition too: its proposed form is nothing.
568
+ const proposed = proposedByIdentity.get(key) ?? null;
569
+ if (proposed !== null && valuesEqual(previous, proposed)) {
570
+ continue;
571
+ }
572
+ const scope = childScope(childScope(rootScope(), rule.previousScopeId, previous), rule.proposedScopeId, proposed);
573
+ const outcome = tryEvaluate(rule.expression, scope, {
574
+ nodeId: rule.id,
575
+ constraintId: rule.id,
576
+ });
577
+ if (outcome.ok && toBoolean(outcome.value)) {
578
+ continue;
579
+ }
580
+ failures.push(outcome.ok
581
+ ? {
582
+ code: RUNTIME_DIAGNOSTIC_CODES.TRANSITION_CONSTRAINT_VIOLATION,
583
+ message: rule.message ?? `Transition ${rule.name ?? rule.id} is not allowed`,
584
+ severity: rule.severity ?? 'error',
585
+ nodeId: rule.id,
586
+ constraintId: rule.id,
587
+ details: {
588
+ transitionConstraintId: rule.id,
589
+ entityId: rule.entityId,
590
+ identity: identify(previous),
591
+ previousValue: previous,
592
+ proposedValue: proposed,
593
+ },
594
+ }
595
+ : outcome.diagnostic);
400
596
  }
401
597
  }
402
- return instances;
598
+ return failures;
599
+ }
600
+ function instancesOf(entityId) {
601
+ return collectInstances().get(entityId) ?? [];
403
602
  }
404
603
  function checkFieldValue(field, value, entityId) {
405
604
  if (!isPresent(value)) {
406
605
  if (field.required) {
407
606
  return {
408
- code: 'REQUIRED_FIELD_MISSING',
607
+ code: RUNTIME_DIAGNOSTIC_CODES.REQUIRED_FIELD_MISSING,
409
608
  message: `${field.name ?? field.id} is required`,
410
609
  severity: 'error',
411
610
  nodeId: entityId,
@@ -417,7 +616,7 @@ export function createAxiomRuntime(options) {
417
616
  const resolved = unwrapType(field.valueType);
418
617
  if (resolved.kind === 'enum' && !resolved.values.includes(toText(value))) {
419
618
  return {
420
- code: 'ENUM_VALUE_INVALID',
619
+ code: RUNTIME_DIAGNOSTIC_CODES.ENUM_VALUE_INVALID,
421
620
  message: `${field.name ?? field.id} must be one of: ${resolved.values.join(', ')}`,
422
621
  severity: 'error',
423
622
  nodeId: entityId,
@@ -426,7 +625,7 @@ export function createAxiomRuntime(options) {
426
625
  }
427
626
  if (resolved.kind === 'primitive' && resolved.primitive === 'number' && typeof value !== 'number') {
428
627
  return {
429
- code: 'TYPE_MISMATCH',
628
+ code: RUNTIME_DIAGNOSTIC_CODES.TYPE_MISMATCH,
430
629
  message: `${field.name ?? field.id} must be a number`,
431
630
  severity: 'error',
432
631
  nodeId: entityId,
@@ -435,7 +634,7 @@ export function createAxiomRuntime(options) {
435
634
  }
436
635
  if (resolved.kind === 'primitive' && resolved.primitive === 'boolean' && typeof value !== 'boolean') {
437
636
  return {
438
- code: 'TYPE_MISMATCH',
637
+ code: RUNTIME_DIAGNOSTIC_CODES.TYPE_MISMATCH,
439
638
  message: `${field.name ?? field.id} must be a boolean`,
440
639
  severity: 'error',
441
640
  nodeId: entityId,
@@ -444,11 +643,15 @@ export function createAxiomRuntime(options) {
444
643
  }
445
644
  return null;
446
645
  }
447
- /** Schema conformance plus declared constraints, evaluated over live instances. */
646
+ /**
647
+ * Schema conformance plus declared constraints, evaluated over every canonical instance
648
+ * — including instances nested inside collections and inside other entities.
649
+ */
448
650
  function evaluateInvariants() {
449
651
  const failures = [];
652
+ const instances = collectInstances();
450
653
  for (const entity of ir.entities) {
451
- for (const instance of instancesOf(entity.id)) {
654
+ for (const instance of instances.get(entity.id) ?? []) {
452
655
  if (!isRecord(instance)) {
453
656
  continue;
454
657
  }
@@ -461,7 +664,7 @@ export function createAxiomRuntime(options) {
461
664
  }
462
665
  }
463
666
  for (const constraint of ir.constraints) {
464
- failures.push(...evaluateConstraint(constraint));
667
+ failures.push(...evaluateConstraint(constraint, instances));
465
668
  }
466
669
  return failures;
467
670
  }
@@ -503,39 +706,81 @@ export function createAxiomRuntime(options) {
503
706
  }
504
707
  return introduced;
505
708
  }
506
- function evaluateConstraint(constraint) {
709
+ /**
710
+ * An entity-scoped constraint is evaluated once per instance, with `ref(entityId)`
711
+ * bound to the instance under validation.
712
+ */
713
+ function evaluateConstraint(constraint, instances) {
507
714
  const severity = constraint.severity ?? 'error';
508
715
  const failures = [];
509
- const record = () => {
716
+ const record = (instance) => {
510
717
  failures.push({
511
- code: 'CONSTRAINT_VIOLATION',
718
+ code: RUNTIME_DIAGNOSTIC_CODES.CONSTRAINT_VIOLATION,
512
719
  message: constraint.message ?? `Constraint ${constraint.name ?? constraint.id} failed`,
513
720
  severity,
514
721
  nodeId: constraint.id,
722
+ constraintId: constraint.id,
723
+ ...(constraint.entityId ? { details: { entityId: constraint.entityId, instance } } : {}),
515
724
  });
516
725
  };
726
+ /** A constraint that cannot be evaluated counts as violated, never as satisfied. */
727
+ const holds = (scope) => {
728
+ const outcome = tryEvaluate(constraint.expression, scope, {
729
+ nodeId: constraint.id,
730
+ constraintId: constraint.id,
731
+ });
732
+ if (!outcome.ok) {
733
+ failures.push(outcome.diagnostic);
734
+ return false;
735
+ }
736
+ return toBoolean(outcome.value);
737
+ };
517
738
  if (!constraint.entityId) {
518
- if (!toBoolean(evaluate(constraint.expression, rootScope()))) {
739
+ if (!holds(rootScope())) {
519
740
  record();
520
741
  }
521
742
  return failures;
522
743
  }
523
- for (const instance of instancesOf(constraint.entityId)) {
524
- const scope = childScope(rootScope(), constraint.entityId, instance);
525
- if (!toBoolean(evaluate(constraint.expression, scope))) {
526
- record();
744
+ for (const instance of instances.get(constraint.entityId) ?? []) {
745
+ if (!holds(childScope(rootScope(), constraint.entityId, instance))) {
746
+ record(instance);
527
747
  }
528
748
  }
529
749
  return failures;
530
750
  }
531
751
  // --------------------------------------------------------------- behaviour
532
752
  function executeOperation(operation, scope, context, result) {
753
+ try {
754
+ executeOperationUnguarded(operation, scope, context, result);
755
+ }
756
+ catch (error) {
757
+ result.push(evaluationFailure(error, {
758
+ ...(context.sourceNodeId ? { nodeId: context.sourceNodeId, actionId: context.sourceNodeId } : {}),
759
+ ...(context.transactionId ? { transactionId: context.transactionId } : {}),
760
+ }));
761
+ }
762
+ }
763
+ function executeOperationUnguarded(operation, scope, context, result) {
533
764
  switch (operation.kind) {
534
765
  case 'set':
535
766
  case 'insert':
536
767
  case 'remove':
537
768
  mutate(() => mutations.apply(operation, scope, context), context, result);
538
769
  return;
770
+ case 'for-each': {
771
+ // The members are read once, before any of them are mutated, so the iteration
772
+ // walks the collection as it stood when the operation began. Nothing here opens
773
+ // a transaction: these mutations belong to the action's own transaction, and a
774
+ // failure in any iteration rolls back every iteration with it.
775
+ const members = requireCollection(evaluate(operation.collection, scope), 'for-each');
776
+ for (const member of members) {
777
+ const iteration = childScope(scope, operation.scopeId, member);
778
+ for (const nested of operation.operations ?? []) {
779
+ executeOperation(nested, iteration, context, result);
780
+ }
781
+ }
782
+ return;
783
+ }
539
784
  case 'invoke': {
540
785
  const args = {};
541
786
  for (const [parameterId, argument] of Object.entries(operation.arguments ?? {})) {
@@ -553,7 +798,7 @@ export function createAxiomRuntime(options) {
553
798
  const route = ir.routes.find((candidate) => candidate.id === operation.routeId);
554
799
  if (!route) {
555
800
  result.push({
556
- code: 'ROUTE_NOT_FOUND',
801
+ code: RUNTIME_DIAGNOSTIC_CODES.ROUTE_NOT_FOUND,
557
802
  message: `Navigate operation could not resolve route ${String(operation.routeId)}`,
558
803
  severity: 'error',
559
804
  });
@@ -570,7 +815,7 @@ export function createAxiomRuntime(options) {
570
815
  const implementation = natives.get(operation.implementationId);
571
816
  if (!implementation) {
572
817
  result.push({
573
- code: 'NATIVE_OPERATION_MISSING',
818
+ code: RUNTIME_DIAGNOSTIC_CODES.NATIVE_OPERATION_MISSING,
574
819
  message: `No implementation registered for "${operation.implementationId}"`,
575
820
  severity: 'error',
576
821
  });
@@ -590,22 +835,25 @@ export function createAxiomRuntime(options) {
590
835
  }
591
836
  default:
592
837
  result.push({
593
- code: 'UNKNOWN_OPERATION',
838
+ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_OPERATION,
594
839
  message: `Unknown operation kind "${operation.kind}"`,
595
840
  severity: 'error',
596
841
  });
597
842
  }
598
843
  }
599
844
  function runAction(actionId, args = {}) {
845
+ return collecting((collected) => runActionCollecting(actionId, args, collected));
846
+ }
847
+ function runActionCollecting(actionId, args, collected) {
600
848
  const action = ir.actions[actionId];
601
849
  if (!action) {
602
850
  const failure = {
603
- code: 'ACTION_NOT_FOUND',
851
+ code: RUNTIME_DIAGNOSTIC_CODES.ACTION_NOT_FOUND,
604
852
  message: `Action ${actionId} is not defined`,
605
853
  severity: 'error',
606
854
  };
607
855
  report(failure);
608
- return { ok: false, diagnostics: [failure] };
856
+ return { ok: false, diagnostics: [...collected] };
609
857
  }
610
858
  const scope = rootScope();
611
859
  for (const parameter of action.parameters ?? []) {
@@ -615,7 +863,7 @@ export function createAxiomRuntime(options) {
615
863
  for (const parameter of action.parameters ?? []) {
616
864
  if (parameter.required && !isPresent(scope.values.get(parameter.id))) {
617
865
  failures.push({
618
- code: 'PARAMETER_MISSING',
866
+ code: RUNTIME_DIAGNOSTIC_CODES.PARAMETER_MISSING,
619
867
  message: `Action ${action.name ?? action.id} requires ${parameter.name ?? parameter.id}`,
620
868
  severity: 'error',
621
869
  nodeId: action.id,
@@ -624,25 +872,34 @@ export function createAxiomRuntime(options) {
624
872
  }
625
873
  if (failures.length > 0) {
626
874
  failures.forEach(report);
627
- return { ok: false, diagnostics: failures };
628
- }
629
- for (const precondition of action.preconditions ?? []) {
630
- if (!toBoolean(evaluate(precondition, scope))) {
631
- const failure = {
632
- code: 'PRECONDITION_FAILED',
633
- message: action.failureModes?.[0]?.message ??
634
- `A precondition of ${action.name ?? action.id} was not satisfied`,
635
- severity: 'error',
636
- nodeId: action.id,
637
- };
638
- report(failure);
639
- return { ok: false, diagnostics: [failure] };
875
+ return { ok: false, diagnostics: [...collected] };
876
+ }
877
+ // Failure modes line up with preconditions by position, so a refusal says which
878
+ // condition was not met rather than always naming the first one.
879
+ for (const [index, precondition] of (action.preconditions ?? []).entries()) {
880
+ const outcome = tryEvaluate(precondition, scope, { nodeId: action.id, actionId: action.id });
881
+ if (!outcome.ok) {
882
+ report(outcome.diagnostic);
883
+ return { ok: false, diagnostics: [...collected] };
884
+ }
885
+ if (toBoolean(outcome.value)) {
886
+ continue;
640
887
  }
888
+ const mode = action.failureModes?.[index];
889
+ report({
890
+ code: RUNTIME_DIAGNOSTIC_CODES.PRECONDITION_FAILED,
891
+ message: mode?.message ?? `A precondition of ${action.name ?? action.id} was not satisfied`,
892
+ severity: 'error',
893
+ nodeId: action.id,
894
+ actionId: action.id,
895
+ details: { preconditionIndex: index, ...(mode?.code ? { failureMode: mode.code } : {}) },
896
+ });
897
+ return { ok: false, diagnostics: [...collected] };
641
898
  }
642
899
  if (action.requiresConfirmation) {
643
900
  const message = action.confirmationMessage ?? `Confirm ${action.name ?? action.id}. This cannot be undone.`;
644
901
  if (!host.confirm(message)) {
645
- return { ok: false, diagnostics: [] };
902
+ return { ok: false, diagnostics: [...collected] };
646
903
  }
647
904
  }
648
905
  const transaction = transactions.begin();
@@ -652,32 +909,46 @@ export function createAxiomRuntime(options) {
652
909
  transactionId: transaction.id,
653
910
  };
654
911
  const operationDiagnostics = [];
912
+ const reportedBefore = collected.length;
655
913
  for (const operation of action.operations ?? []) {
656
914
  executeOperation(operation, scope, context, operationDiagnostics);
657
915
  }
916
+ // Anything reported while the operations ran — an expression that could not be
917
+ // evaluated, for instance — is a failure of this action, not a passing curiosity.
658
918
  const violations = [
659
919
  ...operationDiagnostics.filter((diagnostic) => diagnostic.severity === 'error'),
920
+ ...collected.slice(reportedBefore).filter((diagnostic) => diagnostic.severity === 'error'),
660
921
  ...evaluateInvariants().filter((diagnostic) => diagnostic.severity === 'error'),
922
+ ...evaluateTransitions().filter((diagnostic) => diagnostic.severity === 'error'),
661
923
  ];
662
924
  for (const postcondition of action.postconditions ?? []) {
663
- if (!toBoolean(evaluate(postcondition, scope))) {
925
+ const outcome = tryEvaluate(postcondition, scope, { nodeId: action.id, actionId: action.id });
926
+ if (!outcome.ok || !toBoolean(outcome.value)) {
927
+ if (!outcome.ok) {
928
+ violations.push(outcome.diagnostic);
929
+ }
664
930
  violations.push({
665
- code: 'POSTCONDITION_FAILED',
931
+ code: RUNTIME_DIAGNOSTIC_CODES.POSTCONDITION_FAILED,
666
932
  message: `A postcondition of ${action.name ?? action.id} was not satisfied`,
667
933
  severity: 'error',
668
934
  nodeId: action.id,
935
+ actionId: action.id,
669
936
  });
670
937
  }
671
938
  }
672
939
  if (violations.length > 0) {
673
940
  settle(transaction, 'rolled-back');
674
- violations.forEach(report);
941
+ for (const violation of violations) {
942
+ if (!collected.includes(violation)) {
943
+ report({ ...violation, actionId: action.id, transactionId: transaction.id });
944
+ }
945
+ }
675
946
  renderApplication();
676
- return { ok: false, diagnostics: violations };
947
+ return { ok: false, diagnostics: [...collected] };
677
948
  }
678
949
  settle(transaction, 'committed');
679
950
  renderApplication();
680
- return { ok: true, diagnostics: operationDiagnostics };
951
+ return { ok: true, diagnostics: [...collected] };
681
952
  }
682
953
  // ------------------------------------------------------------------ routing
683
954
  function buildPath(route, values) {
@@ -838,10 +1109,19 @@ export function createAxiomRuntime(options) {
838
1109
  return raw;
839
1110
  }
840
1111
  function renderNode(id, scope) {
1112
+ try {
1113
+ return renderNodeUnguarded(id, scope);
1114
+ }
1115
+ catch (error) {
1116
+ report(evaluationFailure(error, { nodeId: id }));
1117
+ return null;
1118
+ }
1119
+ }
1120
+ function renderNodeUnguarded(id, scope) {
841
1121
  const node = ir.uiNodes[id];
842
1122
  if (!node) {
843
1123
  report({
844
- code: 'UI_NODE_MISSING',
1124
+ code: RUNTIME_DIAGNOSTIC_CODES.UI_NODE_MISSING,
845
1125
  message: `UI node ${id} is not defined`,
846
1126
  severity: 'error',
847
1127
  nodeId: id,
@@ -983,15 +1263,21 @@ export function createAxiomRuntime(options) {
983
1263
  failures.forEach(report);
984
1264
  }
985
1265
  else {
986
- const introduced = before ? violationsIntroducedSince(before) : [];
987
- if (introduced.length > 0) {
1266
+ // A transition rule always applies: it compares against the state this
1267
+ // mutation started from, so it can never be a pre-existing violation.
1268
+ const rejected = [
1269
+ ...(before ? violationsIntroducedSince(before) : []),
1270
+ ...evaluateTransitions().filter((diagnostic) => diagnostic.severity === 'error'),
1271
+ ];
1272
+ if (rejected.length > 0) {
988
1273
  settle(transaction, 'rolled-back');
989
- introduced.forEach(report);
1274
+ rejected.forEach((diagnostic) => report({ ...diagnostic, details: { ...diagnostic.details, source: 'input', nodeId: node.id } }));
990
1275
  report({
991
- code: 'INPUT_REJECTED',
992
- message: `${node.label ?? node.id} kept its previous value: ${introduced[0].message}`,
1276
+ code: RUNTIME_DIAGNOSTIC_CODES.INPUT_REJECTED,
1277
+ message: `${node.label ?? node.id} kept its previous value: ${rejected[0].message}`,
993
1278
  severity: 'warning',
994
1279
  nodeId: node.id,
1280
+ details: { source: 'input' },
995
1281
  });
996
1282
  }
997
1283
  else {
@@ -1036,7 +1322,7 @@ export function createAxiomRuntime(options) {
1036
1322
  }
1037
1323
  default:
1038
1324
  report({
1039
- code: 'UNKNOWN_UI_NODE',
1325
+ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_UI_NODE,
1040
1326
  message: `Unknown UI node kind "${node.kind}"`,
1041
1327
  severity: 'error',
1042
1328
  });
@@ -1093,7 +1379,7 @@ export function createAxiomRuntime(options) {
1093
1379
  getState(id) {
1094
1380
  return cloneValue(readState(id));
1095
1381
  },
1096
- setState(id, value) {
1382
+ hydrateState(id, value) {
1097
1383
  const transaction = transactions.begin();
1098
1384
  const failures = [];
1099
1385
  const context = { source: 'system', transactionId: transaction.id };
@@ -1117,6 +1403,9 @@ export function createAxiomRuntime(options) {
1117
1403
  diagnostics() {
1118
1404
  return diagnostics.map((diagnostic) => ({ ...diagnostic }));
1119
1405
  },
1406
+ clearDiagnostics() {
1407
+ diagnostics.length = 0;
1408
+ },
1120
1409
  getMutationLog() {
1121
1410
  return mutationLog.map((entry) => ({ ...entry }));
1122
1411
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-runtime",
3
- "version": "0.3.1-alpha.1",
3
+ "version": "0.4.1-alpha.1",
4
4
  "description": "Domain-independent runtime that executes an Axiom application graph.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",
@@ -31,7 +31,7 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "@cynodia/axiom-core": "0.3.1-alpha.1"
34
+ "@cynodia/axiom-core": "0.4.1-alpha.1"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -b tsconfig.json tsconfig.test.json",