@cynodia/axiom-core 0.15.0-alpha.2 → 0.16.0-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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Canonical machine-readable authoring metadata (spec16 §67-78).
3
+ *
4
+ * An AI agent constructing or modifying an Axiom graph must not have to guess valid node
5
+ * shapes, valid references, closed vocabularies or required fields from source code. This
6
+ * module is the **single** structured description of every publicly authorable semantic
7
+ * node kind — the same eighteen kinds `SEMANTIC_NODE_KINDS` enumerates — derived from the
8
+ * same closed vocabularies validation and the runtime already use (`OPERATION_KINDS`,
9
+ * `EDGE_KINDS`, `EXPRESSION_KINDS`, `WORKFLOW_STEP_TYPES`, `AUTHORIZATION_OPERATIONS`, …)
10
+ * rather than duplicating them by hand (spec16 §71).
11
+ *
12
+ * It is deliberately scoped to the **graph model** — `EntityDef` through
13
+ * `AuthorizationPolicyDef` — not the nine UI node kinds. UI authoring already has its own
14
+ * canonical, mechanically-checked machine catalogue (`@cynodia/axiom-ui`'s
15
+ * `packages/ui-toolkit/docs/PATTERN_CATALOG.json`, generated by `npm run toolkit:catalog`);
16
+ * a second description of the same nodes here would be exactly the "four unrelated sources
17
+ * of truth" spec16 §71 warns against, so `docs/UI.md` and the toolkit catalogue remain that
18
+ * authority.
19
+ *
20
+ * A template is a **minimal valid shape**, not a set of defaults: it never assigns
21
+ * security-sensitive semantics an author did not ask for (spec16 §78) — an `ActionDef`
22
+ * template carries no `authorizationPolicy`, which is exactly its pre-0.15 public contract,
23
+ * not a silently-inserted permission.
24
+ */
25
+ import type { SemanticNodeKind } from './types.js';
26
+ /** One field an authorable node kind carries. */
27
+ export interface AuthoringFieldDescriptor {
28
+ name: string;
29
+ required: boolean;
30
+ /** A short, stable, human/machine readable type description — not a TypeScript type. */
31
+ type: string;
32
+ /** Other semantic node kinds this field's value (a `NodeId` or `NodeId[]`) may reference. */
33
+ referenceTargets?: readonly SemanticNodeKind[];
34
+ /** The closed vocabulary this field's value is drawn from, when it has one. */
35
+ closedEnum?: readonly string[];
36
+ description: string;
37
+ }
38
+ /** Every publicly authorable graph-model semantic node kind, structurally described. */
39
+ export interface AuthoringKindDescriptor {
40
+ kind: SemanticNodeKind;
41
+ purpose: string;
42
+ fields: AuthoringFieldDescriptor[];
43
+ /** A minimal valid shape. `NodeId`/`FieldId` values are placeholders (`"<id>"`). */
44
+ template: Record<string, unknown>;
45
+ }
46
+ /** Every graph-model semantic node kind an agent may author (spec16 §72). */
47
+ export declare function listAuthorableKinds(): readonly SemanticNodeKind[];
48
+ /** The structural authoring contract for one node kind, or `undefined` for an unknown kind. */
49
+ export declare function describeAuthoringKind(kind: string): AuthoringKindDescriptor | undefined;
50
+ /** The complete authoring schema — one descriptor per authorable graph-model kind (spec16 §69, §70). */
51
+ export declare function authoringSchema(): readonly AuthoringKindDescriptor[];
52
+ /** Every edge kind the graph can derive, for tooling that renders `getEdges` results. */
53
+ export declare function authoringEdgeKinds(): readonly string[];
54
+ //# sourceMappingURL=authoring-schema.d.ts.map
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Canonical machine-readable authoring metadata (spec16 §67-78).
3
+ *
4
+ * An AI agent constructing or modifying an Axiom graph must not have to guess valid node
5
+ * shapes, valid references, closed vocabularies or required fields from source code. This
6
+ * module is the **single** structured description of every publicly authorable semantic
7
+ * node kind — the same eighteen kinds `SEMANTIC_NODE_KINDS` enumerates — derived from the
8
+ * same closed vocabularies validation and the runtime already use (`OPERATION_KINDS`,
9
+ * `EDGE_KINDS`, `EXPRESSION_KINDS`, `WORKFLOW_STEP_TYPES`, `AUTHORIZATION_OPERATIONS`, …)
10
+ * rather than duplicating them by hand (spec16 §71).
11
+ *
12
+ * It is deliberately scoped to the **graph model** — `EntityDef` through
13
+ * `AuthorizationPolicyDef` — not the nine UI node kinds. UI authoring already has its own
14
+ * canonical, mechanically-checked machine catalogue (`@cynodia/axiom-ui`'s
15
+ * `packages/ui-toolkit/docs/PATTERN_CATALOG.json`, generated by `npm run toolkit:catalog`);
16
+ * a second description of the same nodes here would be exactly the "four unrelated sources
17
+ * of truth" spec16 §71 warns against, so `docs/UI.md` and the toolkit catalogue remain that
18
+ * authority.
19
+ *
20
+ * A template is a **minimal valid shape**, not a set of defaults: it never assigns
21
+ * security-sensitive semantics an author did not ask for (spec16 §78) — an `ActionDef`
22
+ * template carries no `authorizationPolicy`, which is exactly its pre-0.15 public contract,
23
+ * not a silently-inserted permission.
24
+ */
25
+ import { EDGE_KINDS, OPERATION_KINDS, INVOCATION_SOURCES } from './nodes.js';
26
+ import { EXPRESSION_KINDS } from './expressions.js';
27
+ import { TYPE_REF_KINDS } from './type-ref.js';
28
+ import { AUTHORIZATION_OPERATIONS } from './authorization.js';
29
+ import { SEMANTIC_NODE_KINDS } from './types.js';
30
+ import { WORKFLOW_STEP_TYPES } from './workflows.js';
31
+ import { RELATIONSHIP_CARDINALITIES } from './relationships.js';
32
+ import { MIGRATION_OPERATION_KINDS } from './migration.js';
33
+ import { INTEGRATION_OPERATION_MODES } from './integrations.js';
34
+ const f = (name, required, type, description, extra = {}) => ({ name, required, type, description, ...extra });
35
+ const EXPR = 'Expression';
36
+ const NODE_ID = 'NodeId';
37
+ const KINDS = [
38
+ {
39
+ kind: 'entity',
40
+ purpose: 'A record type. Instances live wherever a StateDef says they do, including nested inside collections and other entities.',
41
+ fields: [
42
+ f('fields', true, 'FieldDef[]', 'The entity\'s fields: id, valueType, required, defaultValue.'),
43
+ f('identityFieldId', false, 'FieldId', 'The field distinguishing instances. Required to address one by identity, and required by every TransitionConstraintDef on this entity — without it such a rule is silently skipped.'),
44
+ ],
45
+ template: { kind: 'entity', id: '<id>', fields: [{ id: '<field-id>', valueType: { kind: 'primitive', primitive: 'string' } }] },
46
+ },
47
+ {
48
+ kind: 'state',
49
+ purpose: 'A named application value: stored, or computed from other state via `derivation`.',
50
+ fields: [
51
+ f('valueType', true, 'TypeRef', 'The value\'s structural type.', { closedEnum: TYPE_REF_KINDS }),
52
+ f('initialValue', false, 'LiteralValue', 'Seed data, keyed by FieldId wherever it contains a record. Absent starts the state at its type default.'),
53
+ f('derivation', false, EXPR, 'When present, the state is computed rather than stored, and is read-only — a write is rejected.'),
54
+ f('draft', false, 'boolean', 'Marks work in progress. Skipped by instance validation until an action commits it.'),
55
+ f('ephemeral', false, 'boolean', 'Marks a UI fact rather than a domain fact. Skipped by instance validation; may not be persisted.'),
56
+ f('authority', false, 'Authority', 'Who may commit a canonical mutation: \'client\' (default) or \'server\'.', { closedEnum: ['client', 'server'] }),
57
+ f('serverOnly', false, 'boolean', 'Excludes the state from the client IR entirely.'),
58
+ f('persistence', false, 'StatePersistence', 'Where a stored value survives: memory, local-storage, or remote.', { closedEnum: ['memory', 'local-storage', 'remote'] }),
59
+ ],
60
+ template: { kind: 'state', id: '<id>', valueType: { kind: 'primitive', primitive: 'string' } },
61
+ },
62
+ {
63
+ kind: 'action',
64
+ purpose: 'Behaviour expressed as data, executed as a transaction: bind parameters, evaluate guards, run operations, check constraints, commit or roll back everything.',
65
+ fields: [
66
+ f('parameters', false, 'ActionParameter[]', 'Named, typed inputs.'),
67
+ f('guards', false, 'ActionGuard[]', 'Condition + failure pairs, checked in order before any transaction opens. Preferred over the parallel preconditions/failureModes arrays.'),
68
+ f('operations', true, 'Operation[]', 'The mutations and effects this action performs, run sequentially against provisional state.', { closedEnum: OPERATION_KINDS }),
69
+ f('postconditions', false, `${EXPR}[]`, 'Checked against proposed state after operations run, before commit.'),
70
+ f('destructive', false, 'boolean', 'Declared destructive intent (also inferred from a `remove` operation).'),
71
+ f('requiresConfirmation', false, 'boolean', 'UX only — never an authorization mechanism.'),
72
+ f('authorization', false, EXPR, 'Legacy boolean expression, evaluated on the authority with the caller bound to PRINCIPAL. Never reaches the client.'),
73
+ f('authorizationPolicy', false, NODE_ID, 'The AuthorizationPolicyDef governing action.invoke (spec15). If both this and `authorization` are present, the effective decision is their conjunction.', { referenceTargets: ['authorization-policy'] }),
74
+ f('invocation', false, 'ActionInvocationPolicy', 'Restricts which invocation sources (client / system) may reach this action at all.', { closedEnum: INVOCATION_SOURCES }),
75
+ ],
76
+ template: { kind: 'action', id: '<id>', operations: [] },
77
+ },
78
+ {
79
+ kind: 'constraint',
80
+ purpose: 'An invariant over proposed state, evaluated after every governed mutation. A constraint that cannot be evaluated counts as violated, never satisfied.',
81
+ fields: [
82
+ f('expression', true, EXPR, 'The boolean invariant.'),
83
+ f('entityId', false, NODE_ID, 'When set, evaluated once per canonical instance of this entity, bound to ref(entityId). Without it, evaluated once in the root scope.', { referenceTargets: ['entity'] }),
84
+ f('severity', false, `'error' | 'warning'`, 'Defaults to error. A warning is advice and never blocks a write.', { closedEnum: ['error', 'warning'] }),
85
+ f('message', false, 'string', 'Human-readable explanation of a violation.'),
86
+ ],
87
+ template: { kind: 'constraint', id: '<id>', expression: { kind: 'literal', value: true } },
88
+ },
89
+ {
90
+ kind: 'transition-constraint',
91
+ purpose: 'A rule about how state may change, seeing the instance as it was at transaction entry and as proposed. Holds on every governed write path, not just one.',
92
+ fields: [
93
+ f('entityId', true, NODE_ID, 'The entity whose transitions are governed. Must declare identityFieldId, or this rule is silently skipped.', { referenceTargets: ['entity'] }),
94
+ f('previousScopeId', true, NODE_ID, 'Binds the instance as it was at the outermost transaction\'s start.'),
95
+ f('proposedScopeId', true, NODE_ID, 'Binds the instance as proposed. Bound to nothing when the instance is being removed.'),
96
+ f('expression', true, EXPR, 'Must hold for every governed transition.'),
97
+ f('severity', false, `'error' | 'warning'`, 'Defaults to error.', { closedEnum: ['error', 'warning'] }),
98
+ f('message', false, 'string', 'Human-readable explanation of a violation.'),
99
+ ],
100
+ template: {
101
+ kind: 'transition-constraint',
102
+ id: '<id>',
103
+ entityId: '<entity-id>',
104
+ previousScopeId: '<previous-scope>',
105
+ proposedScopeId: '<proposed-scope>',
106
+ expression: { kind: 'literal', value: true },
107
+ },
108
+ },
109
+ {
110
+ kind: 'route',
111
+ purpose: 'A URL path mapped to a view, with typed path parameters.',
112
+ fields: [
113
+ f('path', true, 'string', 'The route path, with `:name` placeholders.'),
114
+ f('viewId', true, NODE_ID, 'The view this route renders.'),
115
+ f('parameters', false, 'RouteParameter[]', 'Matches each `:name` placeholder to an id and type.'),
116
+ ],
117
+ template: { kind: 'route', id: '<id>', path: '/', viewId: '<view-id>' },
118
+ },
119
+ {
120
+ kind: 'expression',
121
+ purpose: 'A named, reusable calculation: this semantic calculation exists once. Its body is evaluated in an isolated scope — its own parameters and application state, nothing else.',
122
+ fields: [
123
+ f('parameters', false, 'ExpressionParameter[]', 'Bound only inside this definition\'s body.'),
124
+ f('expression', true, EXPR, 'The calculation.', { closedEnum: EXPRESSION_KINDS }),
125
+ f('valueType', false, 'TypeRef', 'The declared result type. Absent, inferred from the body.'),
126
+ f('description', false, 'string', 'Human documentation.'),
127
+ ],
128
+ template: { kind: 'expression', id: '<id>', expression: { kind: 'literal', value: null } },
129
+ },
130
+ {
131
+ kind: 'integration',
132
+ purpose: 'An external capability domain — a name for "the systems outside Axiom that do X" — with no host, credential or SDK reference.',
133
+ fields: [],
134
+ template: { kind: 'integration', id: '<id>' },
135
+ },
136
+ {
137
+ kind: 'integration-operation',
138
+ purpose: 'A typed operation an integration exposes: a query (ask, wait for a finite answer) or an effect (tell, no answer joins the transaction).',
139
+ fields: [
140
+ f('integrationId', true, NODE_ID, 'The integration this operation belongs to.', { referenceTargets: ['integration'] }),
141
+ f('mode', true, `'query' | 'effect'`, 'Query or effect.', { closedEnum: INTEGRATION_OPERATION_MODES }),
142
+ f('parameters', false, 'IntegrationOperationParameter[]', 'Named, typed inputs.'),
143
+ f('resultType', true, 'TypeRef', 'The result shape.'),
144
+ f('clientSafe', false, 'boolean', 'Whether client-executed code may invoke it. Absent means server-only.'),
145
+ f('idempotent', false, 'boolean', 'Whether invoking this effect twice with the same idempotency key has one effect.'),
146
+ f('retry', false, 'RetryPolicy', 'Retry policy for a failed effect. Meaningless for a query.'),
147
+ ],
148
+ template: { kind: 'integration-operation', id: '<id>', integrationId: '<integration-id>', mode: 'query', resultType: { kind: 'primitive', primitive: 'string' } },
149
+ },
150
+ {
151
+ kind: 'event',
152
+ purpose: 'A typed semantic fact this application can dispatch and react to, through the one inbound event pipeline.',
153
+ fields: [f('payloadType', true, 'TypeRef', 'The shape of the event\'s payload.')],
154
+ template: { kind: 'event', id: '<id>', payloadType: { kind: 'primitive', primitive: 'string' } },
155
+ },
156
+ {
157
+ kind: 'trigger',
158
+ purpose: 'Invokes an ordinary action under a system principal, on an interval, after a delay, on a lifecycle moment, or on an event.',
159
+ fields: [
160
+ f('actionId', true, NODE_ID, 'The action this trigger invokes.', { referenceTargets: ['action'] }),
161
+ f('when', true, 'TriggerSpec', 'interval | delay | lifecycle | event.', { closedEnum: ['interval', 'delay', 'lifecycle', 'event'] }),
162
+ f('arguments', false, 'Record<paramId, Expression>', 'Keyed by the target action\'s parameter id.'),
163
+ f('enabledWhen', false, EXPR, 'Dynamic enablement, evaluated each time the trigger would otherwise fire.'),
164
+ ],
165
+ template: { kind: 'trigger', id: '<id>', actionId: '<action-id>', when: { kind: 'interval', everyMs: 60000 } },
166
+ },
167
+ {
168
+ kind: 'subscription',
169
+ purpose: 'A long-lived external event source: the world tells Axiom, while Axiom is listening. Delivers into the ordinary EventDef -> TriggerDef -> ActionDef pipeline.',
170
+ fields: [
171
+ f('integrationId', true, NODE_ID, 'The capability domain whose adapter maintains the source.', { referenceTargets: ['integration'] }),
172
+ f('source', false, 'string', 'Which semantic source within that integration. Absent, the subscription\'s own id is the name.'),
173
+ f('arguments', false, 'Record<string, Expression>', 'Configuration evaluated once, at activation.'),
174
+ f('eventId', true, NODE_ID, 'The EventDef a delivery becomes.', { referenceTargets: ['event'] }),
175
+ f('lifecycle', false, 'SubscriptionLifecyclePolicy', 'autoStart, required, reconnect.'),
176
+ f('delivery', false, 'SubscriptionDeliveryPolicy', 'Queue depth, backpressure policy, deduplication, retry.', { closedEnum: ['block', 'drop-oldest', 'drop-newest'] }),
177
+ ],
178
+ template: { kind: 'subscription', id: '<id>', integrationId: '<integration-id>', eventId: '<event-id>' },
179
+ },
180
+ {
181
+ kind: 'storage',
182
+ purpose: 'A portable object store for binary attachments. Bytes never enter the graph; state holds a BlobRef of five scalars.',
183
+ fields: [
184
+ f('blobEntityId', true, NODE_ID, 'The entity every BlobRef of this store conforms to.', { referenceTargets: ['entity'] }),
185
+ f('readAuthorization', false, EXPR, 'Who may read bytes/metadata. Absent means no one.'),
186
+ f('uploadAuthorization', false, EXPR, 'Who may upload. Absent means no one.'),
187
+ f('acceptedMediaTypes', false, 'string[]', 'Absent accepts any.'),
188
+ f('maxSizeBytes', false, 'number', 'The largest accepted upload.'),
189
+ f('retry', false, 'RetryPolicy', 'For a failed blob-commit/blob-delete.'),
190
+ ],
191
+ template: { kind: 'storage', id: '<id>', blobEntityId: '<entity-id>' },
192
+ },
193
+ {
194
+ kind: 'query',
195
+ purpose: 'A demand-driven read over authoritative data too large to materialize: fixed named clauses, every leaf an ordinary Expression.',
196
+ fields: [
197
+ f('parameters', false, 'QueryParameter[]', 'Named, typed inputs.'),
198
+ f('source', true, NODE_ID, 'The authoritative row entity.', { referenceTargets: ['entity'] }),
199
+ f('rowScopeId', true, NODE_ID, 'Binds one source row for every clause expression.'),
200
+ f('filter', false, EXPR, 'Boolean predicate. AND-ed with the entity\'s ReadPolicyDef on the authority.'),
201
+ f('sort', false, 'QuerySortKey[]', 'Ordering keys, most significant first; canonical identity is appended as a tie-breaker.'),
202
+ f('relationships', false, 'QueryRelationshipUse[]', 'Explicit RelationshipDef traversals.', { referenceTargets: ['relationship'] }),
203
+ f('projection', false, 'QueryProjection', 'Projected result fields. Absent returns whole source rows.'),
204
+ f('groupBy', false, `${EXPR}[]`, 'Group keys, present only with aggregate.'),
205
+ f('aggregate', false, 'QueryAggregate[]', 'Reductions over groups or the whole result.'),
206
+ f('pagination', false, 'QueryPagination', 'Strategy and page-size ceiling.', { closedEnum: ['cursor', 'offset'] }),
207
+ f('readPolicyId', false, NODE_ID, 'The ReadPolicyDef AND-ed into filter before execution.', { referenceTargets: ['read-policy'] }),
208
+ f('authorizationPolicy', false, NODE_ID, 'The AuthorizationPolicyDef governing query.read (spec15) — whether the principal may run the query at all.', { referenceTargets: ['authorization-policy'] }),
209
+ ],
210
+ template: { kind: 'query', id: '<id>', source: '<entity-id>', rowScopeId: '<row-scope>' },
211
+ },
212
+ {
213
+ kind: 'relationship',
214
+ purpose: 'An explicit, never-inferred entity-to-entity link a query may traverse.',
215
+ fields: [
216
+ f('from', true, 'RelationshipEndpoint', 'The entity + field a traversal starts from.'),
217
+ f('to', true, 'RelationshipEndpoint', 'The entity + field a traversal reaches. For to-one this MUST be the target\'s identityFieldId.'),
218
+ f('cardinality', true, `'to-one' | 'to-many'`, 'Whether one `from` row reaches one or many `to` rows.', { closedEnum: RELATIONSHIP_CARDINALITIES }),
219
+ ],
220
+ template: {
221
+ kind: 'relationship',
222
+ id: '<id>',
223
+ from: { entityId: '<entity-id>', fieldId: '<field-id>' },
224
+ to: { entityId: '<entity-id>', fieldId: '<field-id>' },
225
+ cardinality: 'to-one',
226
+ },
227
+ },
228
+ {
229
+ kind: 'read-policy',
230
+ purpose: 'A row-level filter AND-ed into every query\'s effective filter on the authority. A failed predicate makes a row invisible, never disclosed.',
231
+ fields: [
232
+ f('entityId', true, NODE_ID, 'The entity whose rows this policy governs. At most one per entity.', { referenceTargets: ['entity'] }),
233
+ f('rowScopeId', true, NODE_ID, 'Binds one candidate row while predicate is evaluated.'),
234
+ f('predicate', true, EXPR, 'Boolean. A row is visible when this is true for it, with the caller bound to PRINCIPAL.'),
235
+ ],
236
+ template: { kind: 'read-policy', id: '<id>', entityId: '<entity-id>', rowScopeId: '<row-scope>', predicate: { kind: 'literal', value: true } },
237
+ },
238
+ {
239
+ kind: 'migration',
240
+ purpose: 'A semantic migration between two consecutive schema versions: a closed operation vocabulary and pure Expression transforms.',
241
+ fields: [
242
+ f('fromSchema', true, 'number', 'The schema version this migration upgrades from.'),
243
+ f('toSchema', true, 'number', 'Must equal fromSchema + 1.'),
244
+ f('operations', true, 'MigrationOperation[]', 'The transformations this migration applies.', { closedEnum: MIGRATION_OPERATION_KINDS }),
245
+ f('reversibility', false, `'irreversible' | 'reverse-supplied'`, 'Absent means irreversible — the honest default for anything that may discard information.', { closedEnum: ['irreversible', 'reverse-supplied'] }),
246
+ f('reverseOperations', false, 'MigrationOperation[]', 'An explicit down-migration, present only with reversibility \'reverse-supplied\'.'),
247
+ ],
248
+ template: { kind: 'migration', id: '<id>', fromSchema: 1, toSchema: 2, operations: [] },
249
+ },
250
+ {
251
+ kind: 'workflow',
252
+ purpose: 'A durable, portable multi-step process: six step kinds, no script body, typed single-assignment bindings, closed expression scope (inputs / bindings / EVENT / PRINCIPAL — never StateDef, QueryDef, now/uuid/random).',
253
+ fields: [
254
+ f('inputs', false, 'WorkflowInput[]', 'Typed inputs bound when the workflow starts.'),
255
+ f('bindings', false, 'WorkflowBinding[]', 'Single-assignment values, each produced by exactly one wait-event step.'),
256
+ f('entry', true, NODE_ID, 'The first step id.'),
257
+ f('steps', true, 'WorkflowStep[]', 'action | wait-event | timer | branch | complete | fail.', { closedEnum: WORKFLOW_STEP_TYPES }),
258
+ f('startPolicy', false, NODE_ID, 'The AuthorizationPolicyDef governing workflow.start. Absent means any principal may start it.', { referenceTargets: ['authorization-policy'] }),
259
+ f('instanceAccessPolicy', false, NODE_ID, 'The AuthorizationPolicyDef governing inspect/history/cancel on a running instance. Absent means the 0.14 owner-fingerprint rule.', { referenceTargets: ['authorization-policy'] }),
260
+ ],
261
+ template: {
262
+ kind: 'workflow',
263
+ id: '<id>',
264
+ entry: '<step-id>',
265
+ steps: [{ type: 'complete', id: '<step-id>' }],
266
+ },
267
+ },
268
+ {
269
+ kind: 'authorization-policy',
270
+ purpose: 'The one authorization language: a single boolean `allow` expression over the closed scope PRINCIPAL / RESOURCE / OPERATION. Exactly true is ALLOW; false, absence or any evaluation error is DENY.',
271
+ fields: [
272
+ f('allow', true, EXPR, 'Deterministic — no now/uuid/random, no StateDef/QueryDef reference.', { closedEnum: [...AUTHORIZATION_OPERATIONS] }),
273
+ ],
274
+ template: { kind: 'authorization-policy', id: '<id>', allow: { kind: 'literal', value: false } },
275
+ },
276
+ ];
277
+ const BY_KIND = new Map(KINDS.map((d) => [d.kind, d]));
278
+ /** Every graph-model semantic node kind an agent may author (spec16 §72). */
279
+ export function listAuthorableKinds() {
280
+ return SEMANTIC_NODE_KINDS;
281
+ }
282
+ /** The structural authoring contract for one node kind, or `undefined` for an unknown kind. */
283
+ export function describeAuthoringKind(kind) {
284
+ return BY_KIND.get(kind);
285
+ }
286
+ /** The complete authoring schema — one descriptor per authorable graph-model kind (spec16 §69, §70). */
287
+ export function authoringSchema() {
288
+ return KINDS;
289
+ }
290
+ /** Every edge kind the graph can derive, for tooling that renders `getEdges` results. */
291
+ export function authoringEdgeKinds() {
292
+ return EDGE_KINDS;
293
+ }
@@ -21,6 +21,17 @@
21
21
  * equivalent to an inline `allow` over `PRINCIPAL`; `ReadPolicyDef` (spec10) remains the
22
22
  * row-level read mechanism, unified into query authorization by spec15 Phase D.
23
23
  *
24
+ * **spec15pt3 — one security-absence-aware evaluator for *every* authorization expression.**
25
+ * `AuthorizationPolicyDef.allow` and the legacy `ActionDef.authorization` expression share
26
+ * {@link evaluateAuthorizationExpression}: a `field(ref(PRINCIPAL), …)` / `field(ref(RESOURCE),
27
+ * …)` read of a key the scope object does not carry is **security-scope absence**, not an
28
+ * ordinary `undefined`, and `neq` / `not` / `or` / any comparison whose truth would depend on
29
+ * that absence is *not satisfied* — so a missing principal attribute can never manufacture
30
+ * authority through either surface (spec15pt3 §5, §7, §32). The two modes differ only in
31
+ * their *final interpretation* (a policy allows on exactly `true`; legacy keeps its historical
32
+ * truthiness), which `decideAuthorization` applies downstream — never in how absence, boolean
33
+ * composition or errors propagate.
34
+ *
24
35
  * This module is portable plain data + `Expression` trees; it carries no enforcement (that
25
36
  * is Phases C–F). The accessors are **total over any input** so a hand-tampered policy fails
26
37
  * closed with a structured diagnostic, never a native exception (spec15 §37).
@@ -122,6 +133,10 @@ export interface AuthorizationCheckInput {
122
133
  /**
123
134
  * The legacy `ActionDef.authorization` expression outcome, when that expression is present.
124
135
  * Its historical truthiness rule is kept: a non-empty array or any truthy value allows.
136
+ * spec15pt3 — this part MUST be produced by {@link evaluateAuthorizationExpression} (mode
137
+ * `'legacy-action'`), so `{ ok: true, value: false }` for a decision that depended on a
138
+ * missing security-scope field: absence reaches this combiner as a plain DENY, never as a
139
+ * truthy value.
125
140
  */
126
141
  legacy?: AuthorizationCheckPart;
127
142
  }
@@ -152,21 +167,57 @@ export interface AuthorizationPolicyScope {
152
167
  operation: string;
153
168
  }
154
169
  /**
155
- * spec15pt2 F1 — the **canonical** three-valued `AuthorizationPolicyDef.allow` evaluator,
156
- * used by every policy-bearing surface (spec15pt2 §33). Returns the `{ ok, value }` shape
157
- * `decideAuthorization` already consumes:
170
+ * Which authorization surface an expression is evaluated for (spec15pt3 §32). Both modes
171
+ * share {@link evaluateAuthorizationExpression}'s security-absence, boolean-composition and
172
+ * error propagation; they differ only downstream, in `decideAuthorization` — a `'policy'`
173
+ * result allows on exactly `true`, a `'legacy-action'` result keeps the historical
174
+ * `ActionDef.authorization` truthiness rule.
175
+ */
176
+ export type AuthorizationEvaluationMode = 'policy' | 'legacy-action';
177
+ /**
178
+ * The evaluation context for {@link evaluateAuthorizationExpression}. `'policy'` mode uses the
179
+ * closed `{ principal, resource, operation }` scope only. `'legacy-action'` mode additionally
180
+ * honours the historical `ActionDef.authorization` scope, which could also read an ordinary
181
+ * `StateDef`: {@link resolveExternalRef} resolves such a `ref` through the caller's ordinary
182
+ * evaluator, and an id it cannot resolve fails **closed** (never silently allows), exactly as
183
+ * a throwing `runtime.evaluate` did pre-pt3.
184
+ */
185
+ export interface AuthorizationExpressionContext {
186
+ principal: Record<string, unknown> | null | undefined;
187
+ resource?: Record<string, unknown> | null | undefined;
188
+ operation?: string;
189
+ resolveExternalRef?: (refId: string) => {
190
+ found: boolean;
191
+ value?: unknown;
192
+ };
193
+ }
194
+ /**
195
+ * spec15pt3 §32 — the **one** canonical security-absence-aware authorization-expression
196
+ * evaluator, shared by `AuthorizationPolicyDef.allow` (`mode: 'policy'`) and the legacy
197
+ * `ActionDef.authorization` expression (`mode: 'legacy-action'`). Returns the `{ ok, value }`
198
+ * shape `decideAuthorization` consumes:
158
199
  *
159
- * - `allow` reduces to **exactly `true`** (with every required security input present) ⇒
160
- * `{ ok: true, value: true }` — the only ALLOW;
161
- * - `allow` reduces to any other concrete value ⇒ `{ ok: true, value: false }`;
162
- * - the truth of `allow` depends on a **missing PRINCIPAL / RESOURCE field**
163
- * (`AbsentSecurityValue`) ⇒ `{ ok: true, value: false }` — absence never creates authority
164
- * through `eq` / `neq` / `not` / `or` (spec15pt2 §8-§12);
165
- * - an evaluation error (malformed node reaching the runtime, unsupported builtin) ⇒
166
- * `{ ok: false }`.
200
+ * - the expression reduces to a concrete value ⇒ `{ ok: true, value }` — for `'policy'` only
201
+ * an exact `true` is ALLOW; for `'legacy-action'` the historical truthiness rule (a truthy
202
+ * value / non-empty array) applies, both in `decideAuthorization`;
203
+ * - its truth depends on a **missing PRINCIPAL / RESOURCE field** ⇒ `{ ok: true, value: false }`
204
+ * — absence never creates authority through `eq` / `neq` / `not` / `lt` / `contains` /
205
+ * `or` / any composition (spec15pt2 §8-§12, spec15pt3 §11-§16, §79-§82);
206
+ * - an evaluation error (malformed node reaching the runtime, unsupported builtin, an
207
+ * unresolvable legacy `ref`) ⇒ `{ ok: false }` — fail closed, and the error keeps its
208
+ * provenance through `not` / `or` so it cannot be negated back to ALLOW (spec15pt3 §84).
167
209
  *
168
210
  * A constant `literal(true)` is `{ ok: true, value: true }` regardless of principal — an
169
- * explicitly public policy still admits an anonymous caller (spec15pt2 §13, §14).
211
+ * explicitly public rule still admits an anonymous caller; the invariant is "missing
212
+ * referenced security fields cannot create authority", not "anonymous is forbidden"
213
+ * (spec15pt2 §13, §14; spec15pt3 §17). A `literal` nullish value stays a concrete value,
214
+ * never security absence (spec15pt3 §58).
215
+ */
216
+ export declare function evaluateAuthorizationExpression(expression: unknown, context: AuthorizationExpressionContext, mode: AuthorizationEvaluationMode): AuthorizationCheckPart;
217
+ /**
218
+ * spec15pt2 F1 — the three-valued `AuthorizationPolicyDef.allow` evaluator. A thin, stable
219
+ * wrapper over {@link evaluateAuthorizationExpression} in `'policy'` mode, kept for every
220
+ * existing policy-bearing call site (spec15pt2 §33).
170
221
  */
171
222
  export declare function evaluateAuthorizationPolicyAllow(allow: unknown, scope: AuthorizationPolicyScope): AuthorizationCheckPart;
172
223
  /**
@@ -21,6 +21,17 @@
21
21
  * equivalent to an inline `allow` over `PRINCIPAL`; `ReadPolicyDef` (spec10) remains the
22
22
  * row-level read mechanism, unified into query authorization by spec15 Phase D.
23
23
  *
24
+ * **spec15pt3 — one security-absence-aware evaluator for *every* authorization expression.**
25
+ * `AuthorizationPolicyDef.allow` and the legacy `ActionDef.authorization` expression share
26
+ * {@link evaluateAuthorizationExpression}: a `field(ref(PRINCIPAL), …)` / `field(ref(RESOURCE),
27
+ * …)` read of a key the scope object does not carry is **security-scope absence**, not an
28
+ * ordinary `undefined`, and `neq` / `not` / `or` / any comparison whose truth would depend on
29
+ * that absence is *not satisfied* — so a missing principal attribute can never manufacture
30
+ * authority through either surface (spec15pt3 §5, §7, §32). The two modes differ only in
31
+ * their *final interpretation* (a policy allows on exactly `true`; legacy keeps its historical
32
+ * truthiness), which `decideAuthorization` applies downstream — never in how absence, boolean
33
+ * composition or errors propagate.
34
+ *
24
35
  * This module is portable plain data + `Expression` trees; it carries no enforcement (that
25
36
  * is Phases C–F). The accessors are **total over any input** so a hand-tampered policy fails
26
37
  * closed with a structured diagnostic, never a native exception (spec15 §37).
@@ -377,6 +388,14 @@ function evalAuthz(expression, scope, seen) {
377
388
  return C(scope.resource ?? null);
378
389
  if (t === AUTHZ_OPERATION_SCOPE)
379
390
  return C(scope.operation);
391
+ // spec15pt3 — a legacy `ActionDef.authorization` expression's historical scope also
392
+ // reaches ordinary `StateDef` refs. Resolve them through the caller's ordinary
393
+ // evaluator; an unresolved ref fails closed (`E` ⇒ DENY), never silently allows. A
394
+ // policy expression's scope is closed, so this branch is unreachable in `'policy'` mode.
395
+ if (scope.mode === 'legacy-action' && scope.resolveExternalRef) {
396
+ const resolved = scope.resolveExternalRef(t);
397
+ return resolved.found ? C(resolved.value) : E;
398
+ }
380
399
  return E; // out of the closed scope — validation rejects it; be total
381
400
  }
382
401
  case 'field': {
@@ -541,26 +560,37 @@ function applyPolicyBuiltin(fn, args) {
541
560
  }
542
561
  }
543
562
  /**
544
- * spec15pt2 F1 — the **canonical** three-valued `AuthorizationPolicyDef.allow` evaluator,
545
- * used by every policy-bearing surface (spec15pt2 §33). Returns the `{ ok, value }` shape
546
- * `decideAuthorization` already consumes:
563
+ * spec15pt3 §32 — the **one** canonical security-absence-aware authorization-expression
564
+ * evaluator, shared by `AuthorizationPolicyDef.allow` (`mode: 'policy'`) and the legacy
565
+ * `ActionDef.authorization` expression (`mode: 'legacy-action'`). Returns the `{ ok, value }`
566
+ * shape `decideAuthorization` consumes:
547
567
  *
548
- * - `allow` reduces to **exactly `true`** (with every required security input present) ⇒
549
- * `{ ok: true, value: true }` — the only ALLOW;
550
- * - `allow` reduces to any other concrete value ⇒ `{ ok: true, value: false }`;
551
- * - the truth of `allow` depends on a **missing PRINCIPAL / RESOURCE field**
552
- * (`AbsentSecurityValue`) ⇒ `{ ok: true, value: false }` — absence never creates authority
553
- * through `eq` / `neq` / `not` / `or` (spec15pt2 §8-§12);
554
- * - an evaluation error (malformed node reaching the runtime, unsupported builtin) ⇒
555
- * `{ ok: false }`.
568
+ * - the expression reduces to a concrete value ⇒ `{ ok: true, value }` — for `'policy'` only
569
+ * an exact `true` is ALLOW; for `'legacy-action'` the historical truthiness rule (a truthy
570
+ * value / non-empty array) applies, both in `decideAuthorization`;
571
+ * - its truth depends on a **missing PRINCIPAL / RESOURCE field** ⇒ `{ ok: true, value: false }`
572
+ * — absence never creates authority through `eq` / `neq` / `not` / `lt` / `contains` /
573
+ * `or` / any composition (spec15pt2 §8-§12, spec15pt3 §11-§16, §79-§82);
574
+ * - an evaluation error (malformed node reaching the runtime, unsupported builtin, an
575
+ * unresolvable legacy `ref`) ⇒ `{ ok: false }` — fail closed, and the error keeps its
576
+ * provenance through `not` / `or` so it cannot be negated back to ALLOW (spec15pt3 §84).
556
577
  *
557
578
  * A constant `literal(true)` is `{ ok: true, value: true }` regardless of principal — an
558
- * explicitly public policy still admits an anonymous caller (spec15pt2 §13, §14).
579
+ * explicitly public rule still admits an anonymous caller; the invariant is "missing
580
+ * referenced security fields cannot create authority", not "anonymous is forbidden"
581
+ * (spec15pt2 §13, §14; spec15pt3 §17). A `literal` nullish value stays a concrete value,
582
+ * never security absence (spec15pt3 §58).
559
583
  */
560
- export function evaluateAuthorizationPolicyAllow(allow, scope) {
584
+ export function evaluateAuthorizationExpression(expression, context, mode) {
561
585
  let result;
562
586
  try {
563
- result = evalAuthz(allow, scope, new Set());
587
+ result = evalAuthz(expression, {
588
+ principal: context.principal ?? null,
589
+ resource: context.resource ?? null,
590
+ operation: context.operation ?? 'action.invoke',
591
+ mode,
592
+ ...(context.resolveExternalRef ? { resolveExternalRef: context.resolveExternalRef } : {}),
593
+ }, new Set());
564
594
  }
565
595
  catch {
566
596
  return { ok: false };
@@ -571,6 +601,14 @@ export function evaluateAuthorizationPolicyAllow(allow, scope) {
571
601
  return { ok: true, value: false };
572
602
  return { ok: true, value: result.v };
573
603
  }
604
+ /**
605
+ * spec15pt2 F1 — the three-valued `AuthorizationPolicyDef.allow` evaluator. A thin, stable
606
+ * wrapper over {@link evaluateAuthorizationExpression} in `'policy'` mode, kept for every
607
+ * existing policy-bearing call site (spec15pt2 §33).
608
+ */
609
+ export function evaluateAuthorizationPolicyAllow(allow, scope) {
610
+ return evaluateAuthorizationExpression(allow, { principal: scope.principal, resource: scope.resource, operation: scope.operation }, 'policy');
611
+ }
574
612
  /**
575
613
  * The distinct policy ids a graph node references for authorization — for dependency
576
614
  * analysis and `validateGraph` reference resolution. Total over malformed input.
@@ -3,6 +3,8 @@ import { locationExpressions, locationFieldIds, locationRootStateId, locationSel
3
3
  import { isGroupFieldId } from './group.js';
4
4
  import { isUINode } from './ui.js';
5
5
  import { queryExpressions } from './query.js';
6
+ import { workflowActionIds, workflowEventIds } from './workflows.js';
7
+ import { nodeAuthorizationPolicyRefs } from './authorization.js';
6
8
  /**
7
9
  * Ids a `ref` expression mentions anywhere in the tree.
8
10
  *
@@ -210,12 +212,34 @@ export function deriveEdges(nodes) {
210
212
  link(node.id, node.entityId, 'constrains');
211
213
  reads(node.id, node.predicate, new Map([...rootScope, [node.rowScopeId, statesByEntity.get(node.entityId) ?? []]]));
212
214
  break;
215
+ case 'workflow':
216
+ // Workflow expressions are closed-scope (inputs / bindings / EVENT / PRINCIPAL —
217
+ // spec14 §—, never StateDef), so there is nothing to attribute as a state read here.
218
+ // What the graph *can* say is which actions a step may invoke and which events it
219
+ // waits on, which is exactly what dependency/impact analysis needs (spec16 §12).
220
+ for (const actionId of workflowActionIds(node)) {
221
+ link(node.id, actionId, 'invokes');
222
+ }
223
+ for (const eventId of workflowEventIds(node)) {
224
+ link(node.id, eventId, 'references');
225
+ }
226
+ break;
227
+ case 'authorization-policy':
213
228
  case 'integration':
214
229
  case 'event':
215
230
  break;
216
231
  default:
217
232
  }
218
233
  }
234
+ // Any node that references an `AuthorizationPolicyDef` (an action's or query's
235
+ // `authorizationPolicy`, a workflow's `startPolicy` / `instanceAccessPolicy`) depends on
236
+ // it — one generic pass over the closed set of policy-reference fields (spec15,
237
+ // spec16 §12), rather than a hand-maintained case per node kind that references one.
238
+ for (const node of nodes) {
239
+ for (const policyId of nodeAuthorizationPolicyRefs(node)) {
240
+ link(node.id, policyId, 'references');
241
+ }
242
+ }
219
243
  return [...pending.values()].map((entry) => ({
220
244
  id: `${entry.from}:${entry.kind}:${entry.to}`,
221
245
  from: entry.from,
package/dist/graph.js CHANGED
@@ -23,7 +23,7 @@ export class ApplicationGraph {
23
23
  /** Bumped by every change, so the derived edge index can never serve stale data. */
24
24
  revision = 0;
25
25
  semanticIndex;
26
- constructor(id, name, version = '0.15.0') {
26
+ constructor(id, name, version = '0.16.0') {
27
27
  this.data = { id, name, version, nodes: {}, edges: {} };
28
28
  }
29
29
  get id() {
package/dist/index.d.ts CHANGED
@@ -42,4 +42,6 @@ export * from './derive-edges.js';
42
42
  export * from './authority.js';
43
43
  export * from './ir.js';
44
44
  export * from './server-ir.js';
45
+ export * from './authoring-schema.js';
46
+ export * from './semantic-diff.js';
45
47
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -42,3 +42,5 @@ export * from './derive-edges.js';
42
42
  export * from './authority.js';
43
43
  export * from './ir.js';
44
44
  export * from './server-ir.js';
45
+ export * from './authoring-schema.js';
46
+ export * from './semantic-diff.js';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Canonical semantic graph diffing (spec16 §34-38, §150-160).
3
+ *
4
+ * `diffSchema` (spec11) already classifies changes to entities, fields, states,
5
+ * relationships and read policies — the *persistence-relevant* structure. `semanticDiff`
6
+ * is the superset spec16 asks for: every other executable and presentation node kind,
7
+ * classified into categories an agent or a reviewer can reason about (`semantic`,
8
+ * `authorization`, `provider`, `workflow`, `query`, `presentation`, `metadata`), plus the
9
+ * compatibility impact a change carries — does it move `semanticFingerprint`, does it move
10
+ * `schemaFingerprint`, does it raise the required Server IR contract. It embeds
11
+ * `diffSchema`'s result rather than re-deriving it, so there remains exactly one place that
12
+ * classifies a schema change (spec5.1 §26 "one canonical location per semantic rule").
13
+ *
14
+ * A node compared by full JSON identity **after** stripping human metadata is exactly the
15
+ * notion of "meaning" `semanticFingerprint` uses ({@link stripNonSemanticMetadata}), so a
16
+ * rename or a `metadata` edit is reported as `metadata`, never as `semantic` — and a
17
+ * presentation-only edit to a UI node or a route is `presentation`, never `authorization` or
18
+ * `schema` (spec16 §155).
19
+ */
20
+ import type { SchemaDiff } from './schema-diff.js';
21
+ import type { ServerIRContract } from './server-ir.js';
22
+ import type { ApplicationGraph } from './graph.js';
23
+ /** The classification vocabulary a semantic diff entry may belong to. A change may belong to more than one. */
24
+ export declare const SEMANTIC_DIFF_CATEGORIES: readonly ["semantic", "authorization", "schema", "provider", "workflow", "query", "presentation", "metadata"];
25
+ export type SemanticDiffCategory = (typeof SEMANTIC_DIFF_CATEGORIES)[number];
26
+ export interface SemanticDiffEntry {
27
+ changeKind: 'added' | 'removed' | 'changed';
28
+ nodeId: string;
29
+ nodeKind: string;
30
+ /** One or more classifications this change belongs to (spec16 §36). */
31
+ categories: SemanticDiffCategory[];
32
+ message: string;
33
+ }
34
+ export interface SemanticDiffCompatibilityImpact {
35
+ semanticFingerprintChanged: boolean;
36
+ schemaFingerprintChanged: boolean;
37
+ /** Whether a mixed-build cluster could now disagree — either fingerprint moved. */
38
+ authorityCompatibilityAffected: boolean;
39
+ serverContractBefore: ServerIRContract;
40
+ serverContractAfter: ServerIRContract;
41
+ serverContractChanged: boolean;
42
+ /** Whether the embedded schema diff has any `migration-required` / `destructive` entry. */
43
+ migrationRequired: boolean;
44
+ }
45
+ export interface SemanticDiff {
46
+ /** Every non-schema-owned node kind's add/remove/change, classified. Canonically ordered by id. */
47
+ entries: SemanticDiffEntry[];
48
+ /** The detailed field-level diff for entities, fields, states, relationships and read policies. */
49
+ schema: SchemaDiff;
50
+ compatibility: SemanticDiffCompatibilityImpact;
51
+ /** Entry counts per category, `entries` only (the schema diff has its own `byClass`). */
52
+ byCategory: Record<SemanticDiffCategory, number>;
53
+ /** True when neither `entries` nor the schema diff found anything — a genuine no-op. */
54
+ isNoOp: boolean;
55
+ }
56
+ /**
57
+ * The canonical semantic difference between two graphs (spec16 §34-38). Pure and
58
+ * side-effect-free: it reads both graphs and computes, never mutates either (spec16 §133).
59
+ */
60
+ export declare function semanticDiff(before: ApplicationGraph, after: ApplicationGraph): SemanticDiff;
61
+ //# sourceMappingURL=semantic-diff.d.ts.map
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Canonical semantic graph diffing (spec16 §34-38, §150-160).
3
+ *
4
+ * `diffSchema` (spec11) already classifies changes to entities, fields, states,
5
+ * relationships and read policies — the *persistence-relevant* structure. `semanticDiff`
6
+ * is the superset spec16 asks for: every other executable and presentation node kind,
7
+ * classified into categories an agent or a reviewer can reason about (`semantic`,
8
+ * `authorization`, `provider`, `workflow`, `query`, `presentation`, `metadata`), plus the
9
+ * compatibility impact a change carries — does it move `semanticFingerprint`, does it move
10
+ * `schemaFingerprint`, does it raise the required Server IR contract. It embeds
11
+ * `diffSchema`'s result rather than re-deriving it, so there remains exactly one place that
12
+ * classifies a schema change (spec5.1 §26 "one canonical location per semantic rule").
13
+ *
14
+ * A node compared by full JSON identity **after** stripping human metadata is exactly the
15
+ * notion of "meaning" `semanticFingerprint` uses ({@link stripNonSemanticMetadata}), so a
16
+ * rename or a `metadata` edit is reported as `metadata`, never as `semantic` — and a
17
+ * presentation-only edit to a UI node or a route is `presentation`, never `authorization` or
18
+ * `schema` (spec16 §155).
19
+ */
20
+ import { diffSchema } from './schema-diff.js';
21
+ import { schemaFingerprint, canonicalJSON } from './schema-identity.js';
22
+ import { semanticFingerprint, stripNonSemanticMetadata } from './semantic-identity.js';
23
+ import { requiredServerContractForGraph } from './server-ir.js';
24
+ import { SEMANTIC_NODE_KINDS } from './types.js';
25
+ import { UI_NODE_KINDS } from './ui.js';
26
+ /** The classification vocabulary a semantic diff entry may belong to. A change may belong to more than one. */
27
+ export const SEMANTIC_DIFF_CATEGORIES = [
28
+ 'semantic',
29
+ 'authorization',
30
+ 'schema',
31
+ 'provider',
32
+ 'workflow',
33
+ 'query',
34
+ 'presentation',
35
+ 'metadata',
36
+ ];
37
+ /** Kinds `diffSchema` already classifies in full field-level detail; excluded from the generic node loop. */
38
+ const SCHEMA_OWNED_KINDS = new Set(['entity', 'state', 'relationship', 'read-policy']);
39
+ const PROVIDER_KINDS = new Set(['integration', 'integration-operation', 'subscription', 'storage']);
40
+ const PRESENTATION_KINDS = new Set([...UI_NODE_KINDS, 'route']);
41
+ function kindCategory(kind) {
42
+ if (kind === 'authorization-policy')
43
+ return 'authorization';
44
+ if (kind === 'query')
45
+ return 'query';
46
+ if (kind === 'workflow')
47
+ return 'workflow';
48
+ if (kind === 'migration')
49
+ return 'schema';
50
+ if (PROVIDER_KINDS.has(kind))
51
+ return 'provider';
52
+ if (PRESENTATION_KINDS.has(kind))
53
+ return 'presentation';
54
+ return 'semantic';
55
+ }
56
+ /** Field names whose change on a node makes the change authorization-semantic too (spec16 §156). */
57
+ const AUTHZ_FIELDS = ['authorizationPolicy', 'authorization', 'startPolicy', 'instanceAccessPolicy'];
58
+ function isPlainObject(value) {
59
+ return !!value && typeof value === 'object' && !Array.isArray(value);
60
+ }
61
+ function equalJSON(a, b) {
62
+ return canonicalJSON(a) === canonicalJSON(b);
63
+ }
64
+ function describeChange(kind, id, changeKind) {
65
+ const verb = changeKind === 'added' ? 'added' : changeKind === 'removed' ? 'removed' : 'changed';
66
+ return `${kind} ${id} ${verb}`;
67
+ }
68
+ /**
69
+ * The canonical semantic difference between two graphs (spec16 §34-38). Pure and
70
+ * side-effect-free: it reads both graphs and computes, never mutates either (spec16 §133).
71
+ */
72
+ export function semanticDiff(before, after) {
73
+ const schema = diffSchema(before, after);
74
+ const entries = [];
75
+ const kinds = [...SEMANTIC_NODE_KINDS, ...UI_NODE_KINDS, 'route'].filter((kind, index, all) => all.indexOf(kind) === index);
76
+ for (const kind of kinds) {
77
+ if (SCHEMA_OWNED_KINDS.has(kind)) {
78
+ continue;
79
+ }
80
+ const beforeById = new Map(before.getNodesByKind(kind).map((node) => [String(node.id), node]));
81
+ const afterById = new Map(after.getNodesByKind(kind).map((node) => [String(node.id), node]));
82
+ for (const [id, node] of afterById) {
83
+ if (!beforeById.has(id)) {
84
+ entries.push({
85
+ changeKind: 'added',
86
+ nodeId: id,
87
+ nodeKind: kind,
88
+ categories: [kindCategory(kind)],
89
+ message: describeChange(kind, id, 'added'),
90
+ });
91
+ continue;
92
+ }
93
+ const previous = beforeById.get(id);
94
+ if (equalJSON(previous, node)) {
95
+ continue;
96
+ }
97
+ const strippedEqual = equalJSON(stripNonSemanticMetadata(previous), stripNonSemanticMetadata(node));
98
+ const categories = new Set();
99
+ if (strippedEqual) {
100
+ categories.add('metadata');
101
+ }
102
+ else if (isPlainObject(previous) && isPlainObject(node)) {
103
+ const changedFields = new Set([...Object.keys(previous), ...Object.keys(node)].filter((key) => !equalJSON(previous[key], node[key])));
104
+ const onlyAuthzFieldsChanged = changedFields.size > 0 && [...changedFields].every((key) => AUTHZ_FIELDS.includes(key));
105
+ if (onlyAuthzFieldsChanged) {
106
+ categories.add('authorization');
107
+ }
108
+ else {
109
+ categories.add(kindCategory(kind));
110
+ if ([...changedFields].some((key) => AUTHZ_FIELDS.includes(key))) {
111
+ categories.add('authorization');
112
+ }
113
+ }
114
+ }
115
+ else {
116
+ categories.add(kindCategory(kind));
117
+ }
118
+ entries.push({
119
+ changeKind: 'changed',
120
+ nodeId: id,
121
+ nodeKind: kind,
122
+ categories: [...categories],
123
+ message: describeChange(kind, id, 'changed'),
124
+ });
125
+ }
126
+ for (const id of beforeById.keys()) {
127
+ if (!afterById.has(id)) {
128
+ entries.push({
129
+ changeKind: 'removed',
130
+ nodeId: id,
131
+ nodeKind: kind,
132
+ categories: [kindCategory(kind)],
133
+ message: describeChange(kind, id, 'removed'),
134
+ });
135
+ }
136
+ }
137
+ }
138
+ entries.sort((a, b) => (a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : a.changeKind.localeCompare(b.changeKind)));
139
+ const semanticFingerprintChanged = semanticFingerprint(before) !== semanticFingerprint(after);
140
+ const schemaFingerprintChanged = schemaFingerprint(before) !== schemaFingerprint(after);
141
+ const serverContractBefore = requiredServerContractForGraph(before);
142
+ const serverContractAfter = requiredServerContractForGraph(after);
143
+ const byCategory = Object.fromEntries(SEMANTIC_DIFF_CATEGORIES.map((c) => [c, 0]));
144
+ for (const entry of entries) {
145
+ for (const category of entry.categories) {
146
+ byCategory[category] += 1;
147
+ }
148
+ }
149
+ return {
150
+ entries,
151
+ schema,
152
+ compatibility: {
153
+ semanticFingerprintChanged,
154
+ schemaFingerprintChanged,
155
+ authorityCompatibilityAffected: semanticFingerprintChanged || schemaFingerprintChanged,
156
+ serverContractBefore,
157
+ serverContractAfter,
158
+ serverContractChanged: serverContractBefore !== serverContractAfter,
159
+ migrationRequired: schema.needsMigration.length > 0,
160
+ },
161
+ byCategory,
162
+ isNoOp: entries.length === 0 && schema.entries.length === 0,
163
+ };
164
+ }
@@ -73,6 +73,13 @@ export declare const SEMANTIC_FINGERPRINT_VERSION = 1;
73
73
  */
74
74
  export declare const EXECUTABLE_KINDS: readonly ["action", "integration", "integration-operation", "trigger", "event", "subscription", "read-policy", "query", "expression", "constraint", "transition-constraint", "storage", "relationship", "workflow", "authorization-policy"];
75
75
  export type ExecutableKind = (typeof EXECUTABLE_KINDS)[number];
76
+ /**
77
+ * Removes human metadata (`name` / `description` / `label` / `metadata` / authoring
78
+ * provenance) from a value, recursively. Exported so tooling that needs "does this node's
79
+ * *meaning* differ" — `semanticDiff`, for one — shares the exact same notion of "meaning"
80
+ * the fingerprint uses, rather than reimplementing it (spec16 §35, §154).
81
+ */
82
+ export declare function stripNonSemanticMetadata(value: unknown): unknown;
76
83
  export interface SemanticProjection {
77
84
  fingerprintVersion: number;
78
85
  schemaVersion: number;
@@ -101,12 +108,15 @@ export interface AuthorityCompatibilityKey {
101
108
  serverContract: string;
102
109
  semanticFingerprint: string;
103
110
  /**
104
- * spec15pt2 §35 — the runtime authorization-evaluator semantics version. `0.15.0-alpha.1`
105
- * and `0.15.0-alpha.2` evaluate the *same* Server IR authorization policy differently
106
- * (absent-value safety, F1), yet the graph — and therefore `semanticFingerprint` — is
107
- * identical. This discriminator, present only when the IR carries authorization
108
- * vocabulary, keeps the two builds from silently co-participating in one authority domain.
109
- * Absent on a graph with no authorization policy (its evaluation is unchanged).
111
+ * spec15pt2 §35, spec15pt3 §37-§39 — the runtime authorization-evaluator semantics
112
+ * version. Successive builds evaluate the *same* Server IR authorization expression
113
+ * differently — `alpha.1` → `alpha.2` for `AuthorizationPolicyDef.allow` (absent-value
114
+ * safety, F1), `alpha.2` → `alpha.3` for the legacy `ActionDef.authorization` expression
115
+ * (F1-legacy) — yet the graph, and therefore `semanticFingerprint`, is identical. This
116
+ * discriminator, present whenever the IR carries an authorization *decision* (a policy
117
+ * reference or a legacy `authorization` expression), keeps builds that disagree from
118
+ * silently co-participating in one authority domain. Absent on a graph with no
119
+ * authorization decision (its evaluation is unchanged across every build).
110
120
  */
111
121
  authorizationRuntime?: string;
112
122
  }
@@ -93,6 +93,15 @@ export const EXECUTABLE_KINDS = [
93
93
  ];
94
94
  /** Keys removed everywhere in the tree — human metadata, never executable meaning. */
95
95
  const NON_SEMANTIC_KEYS = new Set(['name', 'description', 'label', 'metadata', AUTHORING_METADATA_KEY]);
96
+ /**
97
+ * Removes human metadata (`name` / `description` / `label` / `metadata` / authoring
98
+ * provenance) from a value, recursively. Exported so tooling that needs "does this node's
99
+ * *meaning* differ" — `semanticDiff`, for one — shares the exact same notion of "meaning"
100
+ * the fingerprint uses, rather than reimplementing it (spec16 §35, §154).
101
+ */
102
+ export function stripNonSemanticMetadata(value) {
103
+ return stripNonSemantic(value);
104
+ }
96
105
  function stripNonSemantic(value) {
97
106
  if (Array.isArray(value)) {
98
107
  return value.map(stripNonSemantic);
@@ -2,6 +2,7 @@ import type { FieldId, NodeId } from './ids.js';
2
2
  import type { ActionDef, ConstraintDef, EntityDef, ExpressionDef, StateDef, TransitionConstraintDef } from './nodes.js';
3
3
  import type { Expression } from './expressions.js';
4
4
  import type { FieldIndexEntry } from './graph.js';
5
+ import type { ApplicationGraph } from './graph.js';
5
6
  import type { EventDef } from './events.js';
6
7
  import type { IntegrationDef, IntegrationOperationDef } from './integrations.js';
7
8
  import type { TriggerDef } from './triggers.js';
@@ -141,6 +142,18 @@ export declare function usesAuthorizationVocabulary(ir: {
141
142
  instanceAccessPolicy?: unknown;
142
143
  }[];
143
144
  }): boolean;
145
+ /**
146
+ * Whether a document carries any legacy `ActionDef.authorization` expression (spec15pt3
147
+ * §39). This is not 0.15 *vocabulary* — it predates the milestone — but its runtime meaning
148
+ * changed in `0.15.0-alpha.3` (security-absence-aware evaluation, F1-legacy), so a mixed
149
+ * `alpha.2` / `alpha.3` cluster over a graph that can exercise it must fail closed. Total
150
+ * over a tampered IR.
151
+ */
152
+ export declare function usesLegacyActionAuthorization(ir: {
153
+ actions?: Record<string, {
154
+ authorization?: unknown;
155
+ }>;
156
+ }): boolean;
144
157
  /**
145
158
  * Authorization vocabulary whose *enforcement* has not shipped yet — the admission gate a
146
159
  * build uses to fail closed rather than run a declared policy as a silent no-op (spec4 §4,
@@ -169,6 +182,14 @@ export declare function usesQueryVocabulary(ir: {
169
182
  }): boolean;
170
183
  /** The higher of two contracts, ordered by `SERVER_IR_CONTRACTS`. */
171
184
  export declare function maxContract(a: ServerIRContract, b: ServerIRContract): ServerIRContract;
185
+ /**
186
+ * The Server IR contract this graph requires, computed straight from its nodes rather than
187
+ * from a compiled `ServerIR` (spec16 §32-33). This is the same "computed from the document,
188
+ * never asserted by hand" rule `compileToServerIR` applies, made available to static
189
+ * tooling that has only an `ApplicationGraph` — a semantic diff or a compatibility
190
+ * explanation should never need to run the compiler to answer "does this graph need v9".
191
+ */
192
+ export declare function requiredServerContractForGraph(graph: ApplicationGraph): ServerIRContract;
172
193
  /** Every expression a Server IR document contains, in no particular order. */
173
194
  export declare function serverIRExpressions(ir: {
174
195
  states: readonly StateDef[];
package/dist/server-ir.js CHANGED
@@ -177,6 +177,21 @@ export function usesAuthorizationVocabulary(ir) {
177
177
  }
178
178
  return false;
179
179
  }
180
+ /**
181
+ * Whether a document carries any legacy `ActionDef.authorization` expression (spec15pt3
182
+ * §39). This is not 0.15 *vocabulary* — it predates the milestone — but its runtime meaning
183
+ * changed in `0.15.0-alpha.3` (security-absence-aware evaluation, F1-legacy), so a mixed
184
+ * `alpha.2` / `alpha.3` cluster over a graph that can exercise it must fail closed. Total
185
+ * over a tampered IR.
186
+ */
187
+ export function usesLegacyActionAuthorization(ir) {
188
+ for (const action of Object.values(ir.actions ?? {})) {
189
+ if (action && typeof action === 'object' && action.authorization !== undefined) {
190
+ return true;
191
+ }
192
+ }
193
+ return false;
194
+ }
180
195
  /**
181
196
  * Authorization vocabulary whose *enforcement* has not shipped yet — the admission gate a
182
197
  * build uses to fail closed rather than run a declared policy as a silent no-op (spec4 §4,
@@ -212,6 +227,55 @@ export function usesQueryVocabulary(ir) {
212
227
  export function maxContract(a, b) {
213
228
  return SERVER_IR_CONTRACTS.indexOf(b) > SERVER_IR_CONTRACTS.indexOf(a) ? b : a;
214
229
  }
230
+ /**
231
+ * The Server IR contract this graph requires, computed straight from its nodes rather than
232
+ * from a compiled `ServerIR` (spec16 §32-33). This is the same "computed from the document,
233
+ * never asserted by hand" rule `compileToServerIR` applies, made available to static
234
+ * tooling that has only an `ApplicationGraph` — a semantic diff or a compatibility
235
+ * explanation should never need to run the compiler to answer "does this graph need v9".
236
+ */
237
+ export function requiredServerContractForGraph(graph) {
238
+ const actionsList = graph.getNodesByKind('action');
239
+ const actions = Object.fromEntries(actionsList.map((a) => [a.id, a]));
240
+ const ir = {
241
+ states: graph.getNodesByKind('state'),
242
+ actions,
243
+ constraints: graph.getNodesByKind('constraint'),
244
+ transitionConstraints: graph.getNodesByKind('transition-constraint'),
245
+ expressionDefs: Object.fromEntries(graph.getNodesByKind('expression').map((e) => [e.id, e])),
246
+ integrations: graph.getNodesByKind('integration'),
247
+ integrationOperations: Object.fromEntries(graph.getNodesByKind('integration-operation').map((o) => [o.id, o])),
248
+ events: graph.getNodesByKind('event'),
249
+ triggers: graph.getNodesByKind('trigger'),
250
+ subscriptions: graph.getNodesByKind('subscription'),
251
+ storages: graph.getNodesByKind('storage'),
252
+ queries: graph.getNodesByKind('query'),
253
+ readPolicies: graph.getNodesByKind('read-policy'),
254
+ authorizationPolicies: graph.getNodesByKind('authorization-policy'),
255
+ migrations: graph.getNodesByKind('migration'),
256
+ workflows: graph.getNodesByKind('workflow'),
257
+ schemaVersion: 1,
258
+ };
259
+ let required = requiredServerContract(serverIRExpressions(ir));
260
+ if (usesIntegrationVocabulary(ir))
261
+ required = maxContract(required, 'axiom.server.v3');
262
+ if (usesInvocationVocabulary(ir))
263
+ required = maxContract(required, 'axiom.server.v2');
264
+ if (usesV4Semantics(ir))
265
+ required = maxContract(required, 'axiom.server.v4');
266
+ if (usesExternalIOVocabulary(ir))
267
+ required = maxContract(required, 'axiom.server.v5');
268
+ if (usesQueryVocabulary(ir))
269
+ required = maxContract(required, 'axiom.server.v6');
270
+ if (usesMigrationVocabulary(ir))
271
+ required = maxContract(required, 'axiom.server.v7');
272
+ if (usesWorkflowVocabulary(ir))
273
+ required = maxContract(required, 'axiom.server.v8');
274
+ if (usesAuthorizationVocabulary(ir) || usesLegacyActionAuthorization(ir)) {
275
+ required = maxContract(required, 'axiom.server.v9');
276
+ }
277
+ return required;
278
+ }
215
279
  /** Every expression a Server IR document contains, in no particular order. */
216
280
  export function serverIRExpressions(ir) {
217
281
  const found = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-core",
3
- "version": "0.15.0-alpha.2",
3
+ "version": "0.16.0-alpha.1",
4
4
  "description": "Application Graph, semantic types, locations and validation for Axiom.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",