@cynodia/axiom 0.4.1-alpha.1 → 0.5.2-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.
package/README.md CHANGED
@@ -1,14 +1,13 @@
1
1
  # Axiom
2
2
 
3
- AI-native semantic web application framework.
3
+ AI-native semantic application framework.
4
4
 
5
- **Status: experimental / alpha.** Axiom is a research prototype. Its API may change
6
- between alpha releases, and it is not production-ready.
5
+ Axiom represents application behavior, state, UI structure and presentation as structured
6
+ semantic data executed by generic runtimes. An application is a typed graph, not source
7
+ files: the JavaScript and HTML that reach the browser are output, and are never edited.
7
8
 
8
- An Axiom application is not source code. It is a typed semantic graph of entities,
9
- fields, state, actions, constraints, routes and UI nodes. A generic compiler normalizes
10
- that graph and a generic runtime executes it in an unmodified browser — the JavaScript
11
- and HTML that reach the browser are output, never something you maintain by hand.
9
+ **Status: experimental / alpha (0.5.2-alpha.x).** The API may change between alpha
10
+ releases. The documentation in `docs/` describes this exact version.
12
11
 
13
12
  ## Installation
14
13
 
@@ -16,236 +15,134 @@ and HTML that reach the browser are output, never something you maintain by hand
16
15
  npm install @cynodia/axiom@alpha
17
16
  ```
18
17
 
19
- ## A minimal application
18
+ ## Canonical mental model
19
+
20
+ | Concept | Is |
21
+ | --- | --- |
22
+ | `ApplicationGraph` | The authoritative representation of an application. |
23
+ | `Expression` | What value is computed. Pure; never writes. |
24
+ | `Location` | Where a writable value lives. An address, not a value. |
25
+ | `StateDef` | A stored or derived application value. |
26
+ | `ActionDef` | A transactional semantic operation. |
27
+ | `ConstraintDef` | An invariant over proposed state. |
28
+ | `TransitionConstraintDef` | An invariant over previous committed state → proposed state. |
29
+ | UI nodes | Semantic interaction structure. |
30
+ | `Presentation` | Semantic UX intent. Roles and tokens, never CSS. |
31
+ | `Theme` | Translation of presentation intent into visual design. |
32
+ | Renderer | Platform-specific materialization. Not part of the graph. |
33
+
34
+ ```text
35
+ ApplicationGraph → validateGraph → compileToIR → runtime (+ theme → renderer) → application
36
+ ```
37
+
38
+ ## Load-bearing invariants
39
+
40
+ Know these before authoring an application. Each is stated in full in
41
+ `docs/SEMANTIC_CONTRACT.md`.
42
+
43
+ 1. **Entity runtime values are keyed by `FieldId`**, not by field name.
44
+ 2. **Expressions produce values; Locations name writable addresses.** Stored state is deeply frozen.
45
+ 3. **Derived state is read-only.**
46
+ 4. **An action is a transaction.** Every mutation commits, or every mutation rolls back.
47
+ 5. **Operations see provisional state**, and `for-each` iteration N sees the writes of iterations `< N`.
48
+ 6. **Constraints are evaluated against proposed state**, per instance, wherever it is stored.
49
+ 7. **Transition constraints compare the instance at transaction entry with the instance proposed.** A newly inserted instance has no previous state and is not evaluated.
50
+ 8. **Input writes are governed** by the same constraints, through the same engine and transaction.
51
+ 9. **`hydrateState` bypasses semantic enforcement.** It is administrative.
52
+ 10. **Presentation never authorizes behavior.** `hidden` ≠ forbidden.
53
+ 11. **`null` and `[]` are distinct.** `null` fails a collection operator; `[]` does not. A collection is truthy only when non-empty.
54
+ 12. **`required(x)` asks only whether a value exists.** `required([])` is `true`.
55
+ 13. **A theme changes presentation only.**
56
+
57
+ ## Minimal application
20
58
 
21
59
  ```ts
22
60
  import {
23
- ApplicationGraph,
24
- binary,
25
- call,
26
- compileToHtml,
27
- compileToIR,
28
- createAxiomRuntime,
29
- createMemoryHost,
30
- literal,
31
- nodeId,
32
- primitiveType,
33
- ref,
34
- stateLocation,
35
- synchronizeEdges,
36
- validateGraph,
61
+ ApplicationGraph, binary, compileToIR, createAxiomRuntime, createMemoryHost,
62
+ literal, nodeId, primitiveType, ref, stateLocation, validateGraph,
37
63
  } from '@cynodia/axiom';
38
64
  import type { ActionDef, ButtonNode, RouteDef, StateDef, TextNode, ViewNode } from '@cynodia/axiom';
39
65
 
40
66
  const COUNT = nodeId('state_count');
41
67
  const INCREMENT = nodeId('action_increment');
42
- const DISPLAY = nodeId('ui_display');
43
- const BUTTON = nodeId('ui_increment');
44
68
  const VIEW = nodeId('ui_view');
45
69
 
46
70
  const graph = new ApplicationGraph('counter', 'Counter');
47
71
 
48
72
  graph.addNode<StateDef>({
49
- id: COUNT,
50
- kind: 'state',
51
- name: 'count',
52
- valueType: primitiveType('number'),
53
- initialValue: 0,
73
+ id: COUNT, kind: 'state', name: 'count',
74
+ valueType: primitiveType('number'), initialValue: 0,
54
75
  });
55
76
 
56
- // Values are expressions; writable positions are locations.
77
+ // A value is an Expression. The position written to is a Location.
57
78
  graph.addNode<ActionDef>({
58
- id: INCREMENT,
59
- kind: 'action',
60
- name: 'increment',
79
+ id: INCREMENT, kind: 'action', name: 'increment',
61
80
  operations: [
62
81
  { kind: 'set', target: stateLocation(COUNT), value: binary('add', ref(COUNT), literal(1)) },
63
82
  ],
64
83
  });
65
84
 
66
85
  graph.addNode<TextNode>({
67
- id: DISPLAY,
68
- kind: 'text',
69
- value: call('concat', literal('Count: '), call('to-string', ref(COUNT))),
86
+ id: nodeId('ui_display'), kind: 'text', value: ref(COUNT),
87
+ presentation: { textRole: 'title', format: { kind: 'number' } },
88
+ });
89
+ graph.addNode<ButtonNode>({
90
+ id: nodeId('ui_increment'), kind: 'button', label: 'Add one', actionId: INCREMENT,
91
+ presentation: { uxRole: 'primary-action', icon: 'add' },
92
+ });
93
+ graph.addNode<ViewNode>({
94
+ id: VIEW, kind: 'view', name: 'Counter',
95
+ children: [nodeId('ui_display'), nodeId('ui_increment')],
70
96
  });
71
- graph.addNode<ButtonNode>({ id: BUTTON, kind: 'button', label: 'Add one', actionId: INCREMENT });
72
- graph.addNode<ViewNode>({ id: VIEW, kind: 'view', children: [DISPLAY, BUTTON] });
73
97
  graph.addNode<RouteDef>({ id: nodeId('route_root'), kind: 'route', path: '/', viewId: VIEW });
74
98
 
75
- synchronizeEdges(graph);
76
-
77
- const result = validateGraph(graph);
78
- if (!result.valid) {
79
- throw new Error(result.errors.map((problem) => problem.message).join('\n'));
99
+ if (!validateGraph(graph).valid) {
100
+ throw new Error('invalid graph');
80
101
  }
81
102
 
82
- // Run it headlessly...
83
103
  const host = createMemoryHost({ path: '/' });
84
104
  const app = createAxiomRuntime({ ir: compileToIR(graph), rootElement: host.root, host });
85
105
  app.start();
86
106
  app.invokeAction(INCREMENT);
87
107
  console.log(app.getState(COUNT)); // 1
88
-
89
- // ...or emit a self-contained page.
90
- const page = compileToHtml(graph);
91
- ```
92
-
93
- ## Actions are transactions
94
-
95
- This is the guarantee the framework is built around:
96
-
97
- > An Axiom action executes as a semantic transaction. Its mutations are applied
98
- > provisionally, the relevant constraints are evaluated against the resulting **proposed
99
- > state**, and either the complete action commits or every one of its state mutations is
100
- > rolled back.
101
-
102
- That includes iteration. If an action reduces stock for twenty order lines and the
103
- seventeenth breaks an invariant, the first sixteen do not survive — you never write
104
- rollback logic yourself, and `runtime.getMutationLog()` shows every attempted write with
105
- its `outcome` of `committed` or `rolled-back`.
106
-
107
- Within one action, each `for-each` iteration reads the state the previous iterations
108
- proposed. Two lines for the same product debit it twice:
109
-
110
- ```text
111
- stock 5 → line A (−3) → 2 → line A (−3) → −1 → stock >= 0 fails → all rolled back
112
- ```
113
-
114
- That is a guarantee, not an implementation detail: an aggregate rule can be expressed as a
115
- simple per-record invariant.
116
-
117
- ## What is enforced, and where
118
-
119
- Two kinds of rule, and they answer different questions:
120
-
121
- | | Question | Applies to |
122
- | --- | --- | --- |
123
- | **Constraint** | Is this state allowed? | Every instance of an entity, wherever it is stored — including instances nested inside other entities. |
124
- | **Transition constraint** | Is this *change* allowed? | The instance as it was when the transaction began, compared with the instance the transaction proposes. |
125
-
126
- A transition constraint is what makes a rule like "a confirmed order never changes" hold
127
- no matter which path attempts the write:
128
-
129
- ```ts
130
- graph.addNode<TransitionConstraintDef>({
131
- id: ORDER_SEALED,
132
- kind: 'transition-constraint',
133
- entityId: ORDER,
134
- previousScopeId: PREVIOUS,
135
- proposedScopeId: PROPOSED,
136
- message: 'A confirmed order cannot be changed.',
137
- expression: binary(
138
- 'or',
139
- binary('neq', field(ref(PREVIOUS), STATUS), literal('confirmed')),
140
- binary('eq', ref(PROPOSED), ref(PREVIOUS)),
141
- ),
142
- });
143
- ```
144
-
145
- **Governed paths** — actions, `for-each` iterations, and input bindings — all evaluate
146
- entity constraints *and* transition constraints against the proposed state, and roll the
147
- whole transaction back if either refuses. You do not have to remember not to bind an input
148
- to a protected location: binding it and typing into it is simply refused, with a
149
- `TRANSITION_CONSTRAINT_VIOLATION` naming the rule, the entity, and the previous and
150
- proposed values.
151
-
152
- **`hydrateState` is not governed.** It replaces a state value outright for hosts, tests and
153
- seeding, and evaluates nothing. It is deliberately not named like a normal write.
154
-
155
- **"Previous" means transaction entry** — committed state as it was before the outermost
156
- transaction began. Not the previous operation, and not the previous iteration.
157
-
158
- ## Collections
159
-
160
- Values are described by expressions, writable positions by **locations**. Collections add
161
- projection, aggregation and ordering to the first, and iteration to the second.
162
-
163
- ```ts
164
- import { binary, field, filter, forEach, map, ref, sum } from '@cynodia/axiom';
165
-
166
- // An order total: project each line to its amount, then sum the projection.
167
- const orderTotal = sum(
168
- map(ref(LINES), LINE, binary('multiply', field(ref(LINE), QUANTITY), field(ref(LINE), PRICE))),
169
- );
170
-
171
- // How much of one product this order asks for, across every line that mentions it.
172
- const requested = sum(
173
- map(
174
- filter(ref(LINES), LINE, binary('eq', field(ref(LINE), PRODUCT), field(ref(P), PRODUCT_ID))),
175
- LINE,
176
- field(ref(LINE), QUANTITY),
177
- ),
178
- );
179
-
180
- // Reduce the stock of every product the order mentions — one mutation per line, one
181
- // transaction for the action.
182
- const confirm = forEach(ref(LINES), LINE, [
183
- {
184
- kind: 'set',
185
- target: fieldLocation(
186
- itemLocation(stateLocation(PRODUCTS), identitySelector(PRODUCT_ID, field(ref(LINE), PRODUCT))),
187
- STOCK,
188
- ),
189
- value: binary('subtract', currentStock, field(ref(LINE), QUANTITY)),
190
- },
191
- ]);
192
108
  ```
193
109
 
194
- None of this is a callback. `map`, `sort`, `filter`, `find`, `every`, `some`, `flatten`,
195
- `conditional` and `for-each` are data: they serialize, they validate, and an agent can ask
196
- what they read and write.
110
+ `compileToHtml(graph)` emits the same application as one self-contained page.
197
111
 
198
- **Collection operators are strict about their source.** `null` means a missing or invalid
199
- collection and fails the evaluation; `[]` means an empty collection and works normally
200
- (`sum([])` is `0`, `count([])` is `0`, `every([])` is `true`). Nothing returns a
201
- plausible-looking value while reporting a failure. Where a collection may legitimately be
202
- absent, say so:
112
+ ## Documentation
203
113
 
204
- ```ts
205
- coalesce(field(ref(CURRENT_ORDER), LINES), literal([]))
206
- ```
207
-
208
- **Presence is not emptiness.** `required(value)` asks only whether a value exists:
209
-
210
- ```text
211
- required(null) → false required([]) → true
212
- required(0) → true required('') → true
213
- required(false)→ true
214
- ```
114
+ The complete operational contract ships with this package, in `docs/`.
215
115
 
216
- Use `is-empty` / `non-empty` for collections and strings, and `coalesce` to fall back on
217
- absence — which means falling back *to* an empty collection now works.
218
-
219
- ## Diagnostics
220
-
221
- Failures are structured. Match on `code` rather than reading the message:
222
-
223
- ```ts
224
- import { RUNTIME_DIAGNOSTIC_CODES } from '@cynodia/axiom';
225
-
226
- const result = app.invokeAction(CONFIRM_ORDER);
227
- if (!result.ok) {
228
- const stock = result.diagnostics.find(
229
- (diagnostic) => diagnostic.code === RUNTIME_DIAGNOSTIC_CODES.PRECONDITION_FAILED,
230
- );
231
- console.log(stock?.details); // { preconditionIndex: 2, failureMode: 'insufficient-stock' }
232
- }
233
- ```
116
+ | Need to understand | Read |
117
+ | --- | --- |
118
+ | Compressed reference for authoring or modifying an app | `docs/AGENT_REFERENCE.md` |
119
+ | Exact runtime guarantees | `docs/SEMANTIC_CONTRACT.md` |
120
+ | Graph, ids, types, entity value representation | `docs/GRAPH_MODEL.md` |
121
+ | Every expression kind, builtin and scope rule | `docs/EXPRESSIONS.md` |
122
+ | Addressing writable positions | `docs/LOCATIONS.md` |
123
+ | Stored, derived, draft and ephemeral state | `docs/STATE.md` |
124
+ | Actions, operations, transactions, iteration | `docs/ACTIONS_TRANSACTIONS.md` |
125
+ | Constraints and transition constraints | `docs/CONSTRAINTS.md` |
126
+ | Semantic UI nodes and bindings | `docs/UI.md` |
127
+ | Presentation, UX intent, themes, formatting | `docs/PRESENTATION.md` |
128
+ | Runtime API and diagnostic codes | `docs/RUNTIME.md` |
129
+ | Machine queries and graph transformations | `docs/AGENT_API.md` |
130
+ | Validation codes | `docs/VALIDATION.md` |
131
+ | Mistakes that compile but are wrong | `docs/ANTI_PATTERNS.md` |
234
132
 
235
- `result.diagnostics` belongs to that invocation. `app.diagnostics()` keeps the history and
236
- `app.clearDiagnostics()` empties it.
133
+ Start with `docs/AGENT_REFERENCE.md`. It plus the `.d.ts` declarations are intended to be
134
+ sufficient on their own.
237
135
 
238
136
  ## What is in the box
239
137
 
240
- `@cynodia/axiom` re-exports the framework packages, which can also be installed
241
- individually:
138
+ This package re-exports four, which can also be installed individually:
242
139
 
243
- | Package | Contents |
244
- | ------- | -------- |
245
- | `@cynodia/axiom-core` | The Application Graph, semantic types, expressions, locations, validation. |
246
- | `@cynodia/axiom-compiler` | Normalization into an IR, and self-contained page emission. |
247
- | `@cynodia/axiom-runtime` | The generic runtime: state, mutation engine, renderer, routing. |
248
- | `@cynodia/axiom-agent-api` | Semantic queries, mutation impact, transactional transformations. |
140
+ | Package | Responsibility |
141
+ | --- | --- |
142
+ | `@cynodia/axiom-core` | Graph, semantic types, expressions, locations, presentation, themes, validation. |
143
+ | `@cynodia/axiom-compiler` | Normalization into an IR, theme stylesheet, page emission. |
144
+ | `@cynodia/axiom-runtime` | State store, evaluation, mutation engine, constraint checking, renderer, routing. |
145
+ | `@cynodia/axiom-agent-api` | Semantic and presentation queries, mutation impact, transactional transformations. |
249
146
 
250
147
  ## License
251
148
 
@@ -0,0 +1,237 @@
1
+ # Actions and transactions
2
+
3
+ Axiom 0.5.2-alpha.1. An action is behavior expressed as data, executed as a transaction.
4
+
5
+ ```ts
6
+ {
7
+ id, kind: 'action', name?,
8
+ parameters?: [{ id, name?, valueType?, required? }],
9
+ guards?: [{ condition: Expression, failureMode?: { code, message? } }],
10
+ preconditions?: Expression[], // older, positional form
11
+ failureModes?: [{ code, message? }], // aligned with preconditions by index
12
+ operations: Operation[],
13
+ postconditions?: Expression[],
14
+ destructive?: boolean,
15
+ requiresConfirmation?: boolean,
16
+ confirmationMessage?: string,
17
+ confirmation?: { title?, description?, confirmLabel?, cancelLabel?, severity? },
18
+ }
19
+ ```
20
+
21
+ ## Guards and failure modes
22
+
23
+ Prefer `guards`: it pairs each condition with the failure it reports, so the two cannot
24
+ drift apart.
25
+
26
+ ```ts
27
+ guards: [
28
+ { condition: isDraft, failureMode: { code: 'not-draft', message: 'This order is confirmed.' } },
29
+ { condition: hasLines, failureMode: { code: 'empty-order', message: 'Add a line first.' } },
30
+ ]
31
+ ```
32
+
33
+ The older parallel arrays align **by position**: `failureModes[2]` reports
34
+ `preconditions[2]`. The compiler normalizes `guards` into that form, so the IR always
35
+ carries aligned arrays and a refusal names the condition that actually failed:
36
+
37
+ ```ts
38
+ result.diagnostics[0].details // { preconditionIndex: 2, failureMode: 'insufficient-stock' }
39
+ ```
40
+
41
+ `actionGuards(action)` returns the conditions however they were written.
42
+
43
+ ## Lifecycle
44
+
45
+ ```text
46
+ resolve the action → ACTION_NOT_FOUND, stop
47
+ bind parameters → PARAMETER_MISSING for an absent required parameter, stop
48
+ evaluate preconditions in order → PRECONDITION_FAILED { preconditionIndex, failureMode }, stop
49
+ ask for confirmation if required → declined: stop
50
+ ── nothing above opens a transaction; nothing has been mutated ──
51
+ BEGIN TRANSACTION (entry state is captured here)
52
+ execute operations sequentially against provisional state
53
+ evaluate entity constraints + schema conformance over proposed state
54
+ evaluate transition constraints over entry state → proposed state
55
+ evaluate postconditions
56
+ any error-severity failure?
57
+ no → COMMIT
58
+ yes → ROLL BACK every mutation of the transaction
59
+ re-render
60
+ ```
61
+
62
+ `invokeAction(id, args?)` returns `{ ok, diagnostics }` for **that invocation**. No diffing
63
+ of global history is needed.
64
+
65
+ All four failure sources are evaluated; the action does not stop at the first.
66
+
67
+ ## Operations
68
+
69
+ Seven kinds, enumerated by `OPERATION_KINDS`. `set`, `insert` and `remove` are the
70
+ mutations; each addresses a [`Location`](LOCATIONS.md).
71
+
72
+ ### `set`
73
+
74
+ ```ts
75
+ { kind: 'set', target: Location, value: Expression }
76
+ ```
77
+
78
+ Writes the value. A missing field along the path is created; a missing collection **item**
79
+ is `LOCATION_RESOLUTION_FAILED`.
80
+
81
+ ### `insert`
82
+
83
+ ```ts
84
+ { kind: 'insert', target: Location, value: Expression, position?: 'start' | 'end' }
85
+ ```
86
+
87
+ Appends by default. The target must address a collection; a non-array current value is
88
+ treated as `[]`. The constructed value is deep-cloned before storing.
89
+
90
+ ### `remove`
91
+
92
+ ```ts
93
+ { kind: 'remove', target: CollectionItemLocation }
94
+ ```
95
+
96
+ **A selector matching nothing is a no-op** — no mutation, no log entry, no diagnostic.
97
+
98
+ ### `for-each`
99
+
100
+ ```ts
101
+ forEach(collection: Expression, scopeId: NodeId, operations: MutationOperation[])
102
+ ```
103
+
104
+ - The collection is evaluated **once**, before any member is mutated.
105
+ - Iteration N observes the provisional writes of iterations `< N`.
106
+ - It opens **no transaction of its own**: the mutations belong to the action's transaction.
107
+ - A failure in iteration N rolls back iterations `0..N-1` and the whole action.
108
+ - Nested operations MUST be mutations only. Nested iteration, navigation and invocation are not supported (`UNSUPPORTED_OPERATION`).
109
+ - `ref(scopeId)` is the current member, and a nested location may use it to address the canonical record that member points at.
110
+
111
+ Two members touching the same record debit it twice, and the invariant catches the total:
112
+
113
+ ```text
114
+ stock 5 → member A (−3) → 2 → member B (−3) → −1 → `stock >= 0` fails → all rolled back
115
+ ```
116
+
117
+ That is public contract, not incidental behavior. It is what lets an aggregate rule be
118
+ written as a per-record invariant.
119
+
120
+ ### `invoke`
121
+
122
+ ```ts
123
+ { kind: 'invoke', actionId: NodeId, arguments?: Record<string, Expression> }
124
+ ```
125
+
126
+ Runs another action. The nested transaction **joins the outermost** one and shares its
127
+ fate: if the nested action fails, everything rolls back and the enclosing action fails too.
128
+ Arguments are keyed by the target action's parameter ids.
129
+
130
+ ### `navigate`
131
+
132
+ ```ts
133
+ { kind: 'navigate', routeId?: NodeId, path?: string, parameters?: Record<string, Expression> }
134
+ ```
135
+
136
+ Changes the route. Parameters are keyed by route parameter id and are rendered as text. An
137
+ unresolvable `routeId` reports `ROUTE_NOT_FOUND`. Navigation is not a state mutation and is
138
+ not rolled back.
139
+
140
+ ### `native`
141
+
142
+ ```ts
143
+ {
144
+ kind: 'native',
145
+ implementationId: string,
146
+ inputs?: Record<string, Expression>,
147
+ resultTarget?: Location,
148
+ declaredEffects?: NativeEffect[],
149
+ }
150
+ ```
151
+
152
+ The controlled boundary for behavior the operation vocabulary cannot express.
153
+
154
+ - The implementation is registered with the runtime (`registerNativeOperation` or `nativeOperations`), never embedded in the graph. Missing → `NATIVE_OPERATION_MISSING`.
155
+ - Inputs are **cloned copies**; native code can never reach into managed state.
156
+ - Native code does not write state. It returns a value, which the mutation engine writes to `resultTarget` with source `native`.
157
+ - Without `declaredEffects`, dependency analysis reports `analysisComplete: false` and names the gap.
158
+
159
+ **Use it only where no semantic primitive exists.** A native operation is opaque to every
160
+ analysis Axiom offers.
161
+
162
+ ## Postconditions
163
+
164
+ Evaluated after the operations, inside the transaction, against proposed state. A failure
165
+ is `POSTCONDITION_FAILED` and rolls the action back. Use them for a property of the whole
166
+ action; use constraints for a property of the state.
167
+
168
+ ## Confirmation
169
+
170
+ `requiresConfirmation: true` asks the host before the transaction opens. `confirmation`
171
+ describes what is asked, without constructing any dialog markup:
172
+
173
+ ```ts
174
+ requiresConfirmation: true,
175
+ confirmation: {
176
+ title: 'Confirm this order?',
177
+ description: 'Confirming reduces stock for every line and seals the order.',
178
+ confirmLabel: 'Confirm order',
179
+ severity: 'warning',
180
+ },
181
+ ```
182
+
183
+ A host that implements `confirmRequest` receives the structured request; a host that only
184
+ has `confirm(message)` receives a composed sentence. `confirmationMessage` overrides the
185
+ composed message.
186
+
187
+ A declined confirmation ends the invocation with `ok: false` and no diagnostics.
188
+
189
+ ## Destructive intent
190
+
191
+ `destructive: true` declares that the action destroys something. It is what
192
+ `AgentAPI.findDestructiveActions()` reports, and presentation is inferred from it — a bound
193
+ button is presented as destructive without the graph saying so twice.
194
+
195
+ An action containing a `remove` operation is *treated* as destructive by inference, and
196
+ validation warns (`DESTRUCTIVE_ACTION_UNMARKED`) if it does not declare the flag, because
197
+ the declaration is what an agent reads.
198
+
199
+ `destructive` is metadata. It does not restrict anything — see
200
+ [Presentation never authorizes](PRESENTATION.md#presentation-never-authorizes-behavior).
201
+
202
+ ## The mutation log
203
+
204
+ ```ts
205
+ app.getMutationLog();
206
+ // [{ transactionId, source, sourceNodeId, operation, path, description,
207
+ // oldValue?, newValue?, outcome }]
208
+ ```
209
+
210
+ - `source` is `action` | `ui` | `system` | `native`.
211
+ - `description` renders the resolved path: `state_products → [product-1] → field_product_stock`.
212
+ - `outcome` is set when the surrounding transaction settles. Rejected attempts remain in the log as `rolled-back`.
213
+ - Only the outermost transaction decides an outcome, so the log never suggests that early iterations of a failed loop committed.
214
+ - `oldValue` / `newValue` are recorded unless `recordMutationValues: false`.
215
+
216
+ ## Invalid usage
217
+
218
+ ```ts
219
+ // WRONG — nested iteration is not supported and is rejected by validateGraph.
220
+ forEach(a, S1, [forEach(b, S2, [...])])
221
+ ```
222
+
223
+ ```ts
224
+ // WRONG — enforcing a rule by not offering the control.
225
+ { kind: 'button', visibleWhen: isDraft, actionId: ACTION_EDIT }
226
+
227
+ // RIGHT — the control may be hidden for clarity, but the rule lives in the graph.
228
+ { kind: 'transition-constraint', entityId: ENTITY_ORDER, expression: … }
229
+ ```
230
+
231
+ ```ts
232
+ // WRONG — a native operation for something the vocabulary expresses.
233
+ { kind: 'native', implementationId: 'app.computeTotal', resultTarget: … }
234
+
235
+ // RIGHT
236
+ { id: STATE_TOTAL, kind: 'state', derivation: sum(map(...)) }
237
+ ```