@cynodia/axiom-runtime 0.4.0-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
  *
@@ -12,8 +22,13 @@
12
22
  export declare function cloneValue<T>(value: T): T;
13
23
  export declare function deepFreeze<T>(value: T): T;
14
24
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
15
- /** 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
+ */
16
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;
17
32
  export declare function toBoolean(value: unknown): boolean;
18
33
  export declare function toText(value: unknown): string;
19
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
  *
@@ -24,18 +38,25 @@ export function deepFreeze(value) {
24
38
  export function isRecord(value) {
25
39
  return typeof value === 'object' && value !== null && !Array.isArray(value);
26
40
  }
27
- /** 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
+ */
28
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) {
29
50
  if (value === null || value === undefined) {
30
- return false;
51
+ return true;
31
52
  }
32
53
  if (typeof value === 'string') {
33
- return value.trim().length > 0;
54
+ return value.trim().length === 0;
34
55
  }
35
56
  if (Array.isArray(value)) {
36
- return value.length > 0;
57
+ return value.length === 0;
37
58
  }
38
- return true;
59
+ return false;
39
60
  }
40
61
  export function toBoolean(value) {
41
62
  if (Array.isArray(value)) {
@@ -63,6 +84,19 @@ export function compareValues(left, right) {
63
84
  const rightText = toText(right);
64
85
  return leftText === rightText ? 0 : leftText < rightText ? -1 : 1;
65
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
+ }
66
100
  export function valuesEqual(left, right) {
67
101
  if (left === right) {
68
102
  return true;
@@ -71,7 +105,7 @@ export function valuesEqual(left, right) {
71
105
  return (left ?? null) === (right ?? null);
72
106
  }
73
107
  if (typeof left === 'object' || typeof right === 'object') {
74
- return JSON.stringify(left) === JSON.stringify(right);
108
+ return canonical(left) === canonical(right);
75
109
  }
76
110
  return false;
77
111
  }
package/dist/runtime.d.ts CHANGED
@@ -20,6 +20,7 @@ export declare const RUNTIME_DIAGNOSTIC_CODES: {
20
20
  readonly MUTATION_FAILED: "MUTATION_FAILED";
21
21
  readonly DERIVED_STATE_WRITE: "DERIVED_STATE_WRITE";
22
22
  readonly UNKNOWN_STATE: "UNKNOWN_STATE";
23
+ readonly TRANSITION_CONSTRAINT_VIOLATION: "TRANSITION_CONSTRAINT_VIOLATION";
23
24
  readonly UNSUPPORTED_EXPRESSION: "UNSUPPORTED_EXPRESSION";
24
25
  readonly UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION";
25
26
  readonly ROUTE_NOT_FOUND: "ROUTE_NOT_FOUND";
@@ -69,7 +70,14 @@ export interface AxiomRuntime {
69
70
  start(): void;
70
71
  render(): void;
71
72
  getState(id: NodeId): unknown;
72
- 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;
73
81
  invokeAction(id: NodeId, args?: Record<string, unknown>): ActionResult;
74
82
  navigate(path: string): void;
75
83
  currentRoute(): RouteMatch | null;
package/dist/runtime.js CHANGED
@@ -2,7 +2,7 @@ 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
6
  /**
7
7
  * The diagnostics a runtime can report. Agents match on the code rather than parsing the
8
8
  * message, so this vocabulary is part of the public contract.
@@ -22,6 +22,7 @@ export const RUNTIME_DIAGNOSTIC_CODES = {
22
22
  MUTATION_FAILED: 'MUTATION_FAILED',
23
23
  DERIVED_STATE_WRITE: 'DERIVED_STATE_WRITE',
24
24
  UNKNOWN_STATE: 'UNKNOWN_STATE',
25
+ TRANSITION_CONSTRAINT_VIOLATION: 'TRANSITION_CONSTRAINT_VIOLATION',
25
26
  UNSUPPORTED_EXPRESSION: 'UNSUPPORTED_EXPRESSION',
26
27
  UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION',
27
28
  ROUTE_NOT_FOUND: 'ROUTE_NOT_FOUND',
@@ -58,6 +59,17 @@ function defaultForType(type) {
58
59
  return null;
59
60
  }
60
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
+ }
61
73
  /** A short, structured description of a runtime value, for diagnostics. */
62
74
  function describeValue(value) {
63
75
  if (value === null || value === undefined) {
@@ -172,9 +184,16 @@ export function createAxiomRuntime(options) {
172
184
  return derivedCache.get(stateId);
173
185
  }
174
186
  derivedCache.set(stateId, null);
175
- const value = deepFreeze(cloneValue(evaluate(state.derivation, rootScope())));
176
- derivedCache.set(stateId, value);
177
- 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
+ }
178
197
  }
179
198
  return store.read(stateId);
180
199
  }
@@ -245,6 +264,28 @@ export function createAxiomRuntime(options) {
245
264
  }
246
265
  }
247
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
+ }
248
289
  /** Applies a mutation inside the current transaction and reports resolution failures. */
249
290
  function mutate(apply, context, failures) {
250
291
  try {
@@ -298,13 +339,7 @@ export function createAxiomRuntime(options) {
298
339
  if (statesById.has(expression.targetId)) {
299
340
  return readState(expression.targetId);
300
341
  }
301
- report({
302
- code: RUNTIME_DIAGNOSTIC_CODES.UNRESOLVED_REFERENCE,
303
- message: `Reference ${expression.targetId} could not be resolved`,
304
- severity: 'error',
305
- nodeId: expression.targetId,
306
- });
307
- return null;
342
+ throw new ExpressionEvaluationError(`Reference ${expression.targetId} could not be resolved`, { targetId: expression.targetId });
308
343
  }
309
344
  case 'field': {
310
345
  const source = evaluate(expression.source, scope);
@@ -330,53 +365,42 @@ export function createAxiomRuntime(options) {
330
365
  case 'call':
331
366
  return evaluateCall(expression.function, expression.arguments, scope);
332
367
  case 'filter': {
333
- const source = evaluate(expression.source, scope);
334
- if (!Array.isArray(source)) {
335
- return [];
336
- }
368
+ const source = requireCollection(evaluate(expression.source, scope), 'filter');
337
369
  return source.filter((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
338
370
  }
339
371
  case 'map': {
340
- const source = evaluate(expression.source, scope);
341
- if (!Array.isArray(source)) {
342
- report({
343
- code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
344
- message: `map expects a collection but received ${describeValue(source)}`,
345
- severity: 'error',
346
- });
347
- return [];
348
- }
372
+ const source = requireCollection(evaluate(expression.source, scope), 'map');
349
373
  return source.map((item) => evaluate(expression.projection, childScope(scope, expression.scopeId, item)));
350
374
  }
351
375
  case 'sort': {
352
- const source = evaluate(expression.source, scope);
353
- if (!Array.isArray(source)) {
354
- report({
355
- code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
356
- message: `sort expects a collection but received ${describeValue(source)}`,
357
- severity: 'error',
358
- });
359
- return [];
360
- }
376
+ const source = requireCollection(evaluate(expression.source, scope), 'sort');
361
377
  const direction = expression.direction === 'desc' ? -1 : 1;
362
378
  const key = (item) => evaluate(expression.by, childScope(scope, expression.scopeId, item));
363
379
  return [...source].sort((left, right) => direction * compareValues(key(left), key(right)));
364
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);
365
397
  case 'find': {
366
- const source = evaluate(expression.source, scope);
367
- if (!Array.isArray(source)) {
368
- return null;
369
- }
398
+ const source = requireCollection(evaluate(expression.source, scope), 'find');
370
399
  const found = source.find((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
371
400
  return found === undefined ? null : found;
372
401
  }
373
402
  default:
374
- report({
375
- code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION,
376
- message: `Unknown expression kind "${expression.kind}"`,
377
- severity: 'error',
378
- });
379
- return null;
403
+ throw new ExpressionEvaluationError(`Unknown expression kind "${expression.kind}"`, { kind: expression.kind });
380
404
  }
381
405
  }
382
406
  function evaluateBinary(operator, leftExpression, rightExpression, scope) {
@@ -412,44 +436,31 @@ export function createAxiomRuntime(options) {
412
436
  return divisor === 0 ? null : Number(left ?? 0) / divisor;
413
437
  }
414
438
  default:
415
- report({ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION, message: `Unknown operator "${operator}"`, severity: 'error' });
416
- return null;
439
+ throw new ExpressionEvaluationError(`Unknown operator "${operator}"`, { operator });
417
440
  }
418
441
  }
419
442
  function evaluateCall(fn, args, scope) {
420
443
  const values = args.map((argument) => evaluate(argument, scope));
421
444
  switch (fn) {
422
445
  case 'required':
446
+ // Presence only: an empty collection or string exists, and so does 0 and false.
423
447
  return isPresent(values[0]);
424
448
  case 'is-empty':
425
- return !isPresent(values[0]);
449
+ return isEmptyValue(values[0]);
450
+ case 'non-empty':
451
+ return !isEmptyValue(values[0]);
426
452
  case 'length':
427
453
  return Array.isArray(values[0]) ? values[0].length : toText(values[0]).length;
428
454
  case 'count':
429
- return Array.isArray(values[0]) ? values[0].length : 0;
455
+ return requireCollection(values[0], 'count').length;
430
456
  case 'sum': {
431
- // An aggregation that cannot be computed must not quietly produce a value that
432
- // makes a guard pass. It reports, and yields a number no comparison accepts.
433
- const source = values[0];
434
- if (!Array.isArray(source)) {
435
- report({
436
- code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
437
- message: `sum expects a collection of numbers but received ${describeValue(source)}`,
438
- severity: 'error',
439
- details: { received: source },
440
- });
441
- return Number.NaN;
442
- }
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');
443
460
  let total = 0;
444
461
  for (const member of source) {
445
462
  if (typeof member !== 'number' || !Number.isFinite(member)) {
446
- report({
447
- code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
448
- message: `sum encountered ${describeValue(member)} where a number was required`,
449
- severity: 'error',
450
- details: { member },
451
- });
452
- return Number.NaN;
463
+ throw new ExpressionEvaluationError(`sum encountered ${describeValue(member)} where a number was required`, { member });
453
464
  }
454
465
  total += member;
455
466
  }
@@ -465,6 +476,7 @@ export function createAxiomRuntime(options) {
465
476
  case 'concat':
466
477
  return values.map(toText).join('');
467
478
  case 'coalesce':
479
+ // Nullish, not "non-empty": falling back to an empty collection has to be possible.
468
480
  return values.find((value) => isPresent(value)) ?? null;
469
481
  case 'one-of':
470
482
  return values.slice(1).some((option) => valuesEqual(option, values[0]));
@@ -477,8 +489,7 @@ export function createAxiomRuntime(options) {
477
489
  case 'uuid':
478
490
  return host.uuid();
479
491
  default:
480
- report({ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION, message: `Unknown function "${fn}"`, severity: 'error' });
481
- return null;
492
+ throw new ExpressionEvaluationError(`Unknown function "${fn}"`, { function: fn });
482
493
  }
483
494
  }
484
495
  // -------------------------------------------------------------- validation
@@ -495,7 +506,7 @@ export function createAxiomRuntime(options) {
495
506
  * their declared types. Entities nested inside collections and inside other entities
496
507
  * are reached recursively, so their constraints apply wherever they actually live.
497
508
  */
498
- function collectInstances() {
509
+ function collectInstances(read = readState) {
499
510
  const found = new Map();
500
511
  const visit = (value, type) => {
501
512
  const resolved = unwrapType(type);
@@ -523,10 +534,69 @@ export function createAxiomRuntime(options) {
523
534
  if (state.draft || state.derivation) {
524
535
  continue;
525
536
  }
526
- visit(readState(state.id), state.valueType);
537
+ visit(read(state.id), state.valueType);
527
538
  }
528
539
  return found;
529
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) {
558
+ continue;
559
+ }
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);
564
+ }
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);
596
+ }
597
+ }
598
+ return failures;
599
+ }
530
600
  function instancesOf(entityId) {
531
601
  return collectInstances().get(entityId) ?? [];
532
602
  }
@@ -653,15 +723,26 @@ export function createAxiomRuntime(options) {
653
723
  ...(constraint.entityId ? { details: { entityId: constraint.entityId, instance } } : {}),
654
724
  });
655
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
+ };
656
738
  if (!constraint.entityId) {
657
- if (!toBoolean(evaluate(constraint.expression, rootScope()))) {
739
+ if (!holds(rootScope())) {
658
740
  record();
659
741
  }
660
742
  return failures;
661
743
  }
662
744
  for (const instance of instances.get(constraint.entityId) ?? []) {
663
- const scope = childScope(rootScope(), constraint.entityId, instance);
664
- if (!toBoolean(evaluate(constraint.expression, scope))) {
745
+ if (!holds(childScope(rootScope(), constraint.entityId, instance))) {
665
746
  record(instance);
666
747
  }
667
748
  }
@@ -669,6 +750,17 @@ export function createAxiomRuntime(options) {
669
750
  }
670
751
  // --------------------------------------------------------------- behaviour
671
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) {
672
764
  switch (operation.kind) {
673
765
  case 'set':
674
766
  case 'insert':
@@ -680,16 +772,7 @@ export function createAxiomRuntime(options) {
680
772
  // walks the collection as it stood when the operation began. Nothing here opens
681
773
  // a transaction: these mutations belong to the action's own transaction, and a
682
774
  // failure in any iteration rolls back every iteration with it.
683
- const members = evaluate(operation.collection, scope);
684
- if (!Array.isArray(members)) {
685
- result.push({
686
- code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
687
- message: `for-each expects a collection but received ${describeValue(members)}`,
688
- severity: 'error',
689
- ...(context.sourceNodeId ? { actionId: context.sourceNodeId } : {}),
690
- });
691
- return;
692
- }
775
+ const members = requireCollection(evaluate(operation.collection, scope), 'for-each');
693
776
  for (const member of members) {
694
777
  const iteration = childScope(scope, operation.scopeId, member);
695
778
  for (const nested of operation.operations ?? []) {
@@ -794,7 +877,12 @@ export function createAxiomRuntime(options) {
794
877
  // Failure modes line up with preconditions by position, so a refusal says which
795
878
  // condition was not met rather than always naming the first one.
796
879
  for (const [index, precondition] of (action.preconditions ?? []).entries()) {
797
- if (toBoolean(evaluate(precondition, scope))) {
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)) {
798
886
  continue;
799
887
  }
800
888
  const mode = action.failureModes?.[index];
@@ -831,9 +919,14 @@ export function createAxiomRuntime(options) {
831
919
  ...operationDiagnostics.filter((diagnostic) => diagnostic.severity === 'error'),
832
920
  ...collected.slice(reportedBefore).filter((diagnostic) => diagnostic.severity === 'error'),
833
921
  ...evaluateInvariants().filter((diagnostic) => diagnostic.severity === 'error'),
922
+ ...evaluateTransitions().filter((diagnostic) => diagnostic.severity === 'error'),
834
923
  ];
835
924
  for (const postcondition of action.postconditions ?? []) {
836
- 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
+ }
837
930
  violations.push({
838
931
  code: RUNTIME_DIAGNOSTIC_CODES.POSTCONDITION_FAILED,
839
932
  message: `A postcondition of ${action.name ?? action.id} was not satisfied`,
@@ -1016,6 +1109,15 @@ export function createAxiomRuntime(options) {
1016
1109
  return raw;
1017
1110
  }
1018
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) {
1019
1121
  const node = ir.uiNodes[id];
1020
1122
  if (!node) {
1021
1123
  report({
@@ -1161,15 +1263,21 @@ export function createAxiomRuntime(options) {
1161
1263
  failures.forEach(report);
1162
1264
  }
1163
1265
  else {
1164
- const introduced = before ? violationsIntroducedSince(before) : [];
1165
- 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) {
1166
1273
  settle(transaction, 'rolled-back');
1167
- introduced.forEach(report);
1274
+ rejected.forEach((diagnostic) => report({ ...diagnostic, details: { ...diagnostic.details, source: 'input', nodeId: node.id } }));
1168
1275
  report({
1169
1276
  code: RUNTIME_DIAGNOSTIC_CODES.INPUT_REJECTED,
1170
- message: `${node.label ?? node.id} kept its previous value: ${introduced[0].message}`,
1277
+ message: `${node.label ?? node.id} kept its previous value: ${rejected[0].message}`,
1171
1278
  severity: 'warning',
1172
1279
  nodeId: node.id,
1280
+ details: { source: 'input' },
1173
1281
  });
1174
1282
  }
1175
1283
  else {
@@ -1271,7 +1379,7 @@ export function createAxiomRuntime(options) {
1271
1379
  getState(id) {
1272
1380
  return cloneValue(readState(id));
1273
1381
  },
1274
- setState(id, value) {
1382
+ hydrateState(id, value) {
1275
1383
  const transaction = transactions.begin();
1276
1384
  const failures = [];
1277
1385
  const context = { source: 'system', transactionId: transaction.id };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-runtime",
3
- "version": "0.4.0-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.4.0-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",