@cynodia/axiom-core 0.14.0-alpha.5 → 0.15.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,20 @@ export declare const AUTHORITIES: readonly Authority[];
26
26
  * It is bound only where a server evaluates. A client never sees it.
27
27
  */
28
28
  export declare const PRINCIPAL: NodeId;
29
+ /**
30
+ * The semantic object an authorization decision is made *about* — a provider record, a
31
+ * workflow instance, or (for `action.invoke`, where there is no per-record target) a stable
32
+ * descriptor of the operation's target. Bound only where an authority evaluates an
33
+ * `AuthorizationPolicyDef` (spec15 §34, §99); like `PRINCIPAL` it is never client-visible.
34
+ */
35
+ export declare const RESOURCE: NodeId;
36
+ /**
37
+ * The canonical semantic operation identity an authorization decision is made *for* — one of
38
+ * `AUTHORIZATION_OPERATIONS` (`'action.invoke'`, `'query.read'`, …). `ref(OPERATION)`
39
+ * resolves to that string directly. Bound only where an authority evaluates an
40
+ * `AuthorizationPolicyDef` (spec15 §34, §98).
41
+ */
42
+ export declare const OPERATION: NodeId;
29
43
  /**
30
44
  * The authority of a state. Absent metadata means `client`, so every 0.5.x graph keeps
31
45
  * executing exactly as it did.
package/dist/authority.js CHANGED
@@ -16,6 +16,20 @@ export const AUTHORITIES = ['client', 'server'];
16
16
  * It is bound only where a server evaluates. A client never sees it.
17
17
  */
18
18
  export const PRINCIPAL = 'axiom_principal';
19
+ /**
20
+ * The semantic object an authorization decision is made *about* — a provider record, a
21
+ * workflow instance, or (for `action.invoke`, where there is no per-record target) a stable
22
+ * descriptor of the operation's target. Bound only where an authority evaluates an
23
+ * `AuthorizationPolicyDef` (spec15 §34, §99); like `PRINCIPAL` it is never client-visible.
24
+ */
25
+ export const RESOURCE = 'axiom_resource';
26
+ /**
27
+ * The canonical semantic operation identity an authorization decision is made *for* — one of
28
+ * `AUTHORIZATION_OPERATIONS` (`'action.invoke'`, `'query.read'`, …). `ref(OPERATION)`
29
+ * resolves to that string directly. Bound only where an authority evaluates an
30
+ * `AuthorizationPolicyDef` (spec15 §34, §98).
31
+ */
32
+ export const OPERATION = 'axiom_operation';
19
33
  /**
20
34
  * The authority of a state. Absent metadata means `client`, so every 0.5.x graph keeps
21
35
  * executing exactly as it did.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Authorization completeness (spec15) — the canonical, portable authorization policy model.
3
+ *
4
+ * Authorization in Axiom is **semantic**: whether a principal may perform a semantic
5
+ * operation is executable meaning, not presentation. It therefore lives in the graph, in
6
+ * `semanticFingerprint`, in authority compatibility, in the Server IR and in conformance —
7
+ * never as a host-language callback (spec15 §6, §7).
8
+ *
9
+ * There is exactly **one** authorization language: an `AuthorizationPolicyDef` with a single
10
+ * boolean `allow` expression over a **closed scope** — `ref('PRINCIPAL')` (the canonical
11
+ * caller), `ref('RESOURCE')` (the semantic object the operation targets, where one exists)
12
+ * and `ref('OPERATION')` (the canonical operation identity). No `StateDef`, no `QueryDef`,
13
+ * no `now` / `uuid` / `random`, no ambient runtime state (spec15 §34). `allow` evaluating to
14
+ * exactly `true` is **ALLOW**; `false`, absence, or any evaluation error is **DENY** — the
15
+ * safe direction is always refusal (spec15 §8, §123).
16
+ *
17
+ * A policy is referenced by id from the surface it protects — `ActionDef.authorizationPolicy`,
18
+ * `QueryDef.authorizationPolicy`, `WorkflowDef.startPolicy` / `instanceAccessPolicy` — so the
19
+ * same evaluator and the same decision model apply everywhere (spec15 §5, §96). The legacy
20
+ * `ActionDef.authorization` boolean expression remains supported and is canonically
21
+ * equivalent to an inline `allow` over `PRINCIPAL`; `ReadPolicyDef` (spec10) remains the
22
+ * row-level read mechanism, unified into query authorization by spec15 Phase D.
23
+ *
24
+ * This module is portable plain data + `Expression` trees; it carries no enforcement (that
25
+ * is Phases C–F). The accessors are **total over any input** so a hand-tampered policy fails
26
+ * closed with a structured diagnostic, never a native exception (spec15 §37).
27
+ */
28
+ import type { Expression } from './expressions.js';
29
+ import type { NodeId } from './ids.js';
30
+ import type { NodeBase } from './nodes.js';
31
+ /**
32
+ * The canonical caller. The **same** reserved id `ActionDef.authorization` uses
33
+ * (`'axiom_principal'`, exported from `authority.ts` as `PRINCIPAL`) — write a policy with
34
+ * `field(ref(PRINCIPAL), F_ROLE)`, exactly as a legacy authorization expression.
35
+ */
36
+ export declare const AUTHZ_PRINCIPAL_SCOPE: NodeId;
37
+ /** The semantic object the operation targets (a record, a workflow instance, …), where one exists. */
38
+ export declare const AUTHZ_RESOURCE_SCOPE: NodeId;
39
+ /** The canonical operation identity — `ref(OPERATION)` resolves to an `AuthorizationOperation` string. */
40
+ export declare const AUTHZ_OPERATION_SCOPE: NodeId;
41
+ /** Every id a policy `allow` expression's `ref` may resolve. Nothing else is in scope. */
42
+ export declare const AUTHORIZATION_SCOPE_IDS: readonly string[];
43
+ /** Builtins forbidden in a policy expression — authorization must be deterministic (spec15 §34). */
44
+ export declare const AUTHORIZATION_NONDETERMINISTIC_BUILTINS: ReadonlySet<string>;
45
+ /**
46
+ * The closed set of canonical semantic operations an authorization decision is made about
47
+ * (spec15 §98). Policies reason over these, never over transport method names. Enforcement
48
+ * of each arrives with its phase (C: `action.invoke` / `record.*` / `state.*`; D:
49
+ * `query.read`; E: `workflow.*`; F: `live.*` / `subscription.*`).
50
+ */
51
+ export declare const AUTHORIZATION_OPERATIONS: readonly ["action.invoke", "query.read", "record.read", "record.mutate", "state.read", "state.mutate", "workflow.start", "workflow.inspect", "workflow.history", "workflow.cancel", "live.open", "live.resume", "subscription.open", "event.ingress"];
52
+ export type AuthorizationOperation = (typeof AUTHORIZATION_OPERATIONS)[number];
53
+ export interface AuthorizationPolicyDef extends NodeBase {
54
+ kind: 'authorization-policy';
55
+ /**
56
+ * Boolean, closed scope (`PRINCIPAL` / `RESOURCE` / `OPERATION` only). Exactly `true` ⇒
57
+ * ALLOW; `false` / absent / evaluation error ⇒ DENY (spec15 §8). Deterministic — no
58
+ * `now` / `uuid` / `random`, no `StateDef` / `QueryDef` (spec15 §34).
59
+ */
60
+ allow: Expression;
61
+ }
62
+ /**
63
+ * What a protected surface does when it has **no** attached policy (spec15 §9). Deliberately
64
+ * one canonical rule, applied consistently, never left to the runtime:
65
+ *
66
+ * - a surface whose *current* (pre-0.15) contract is public — an unrestricted `ActionDef`,
67
+ * an unrestricted `QueryDef` — keeps that public contract (`'public'`);
68
+ * - a surface whose current contract is already restricted — a `WorkflowDef` instance
69
+ * operation (0.14 owner-fingerprint), an `ActionDef` with a legacy `authorization`
70
+ * expression, a `QueryDef` with a `ReadPolicyDef` — keeps that restriction;
71
+ * - a *new* privileged surface with no policy fails closed (`'deny'`).
72
+ *
73
+ * These constants name the rule so docs, tests and a future independent runtime agree.
74
+ */
75
+ export declare const AUTHORIZATION_DEFAULT: {
76
+ /** No policy, previously-public surface ⇒ still public. */
77
+ readonly PUBLIC_SURFACE: "public";
78
+ /** No policy, previously-restricted surface ⇒ keep the prior restriction. */
79
+ readonly KEEP_PRIOR_RESTRICTION: "keep-prior-restriction";
80
+ /** No policy, new privileged surface ⇒ DENY. */
81
+ readonly FAIL_CLOSED: "deny";
82
+ };
83
+ /** Every `Expression` a policy embeds — total over malformed input. */
84
+ export declare function authorizationPolicyExpressions(policy: unknown): Expression[];
85
+ export interface AuthorizationPolicyProblem {
86
+ code: 'AUTHORIZATION_INVALID_POLICY' | 'AUTHORIZATION_INVALID_SCOPE' | 'AUTHORIZATION_NONDETERMINISTIC';
87
+ message: string;
88
+ }
89
+ export declare function authorizationPolicyProblems(policy: unknown): AuthorizationPolicyProblem[];
90
+ export interface AuthorizationPolicyDependencies {
91
+ /** Field ids the policy's `allow` expression reads off `PRINCIPAL`. */
92
+ principalFields: string[];
93
+ /** Field ids it reads off `RESOURCE`. */
94
+ resourceFields: string[];
95
+ /** Whether it references `OPERATION` at all. */
96
+ readsOperation: boolean;
97
+ /**
98
+ * A verdict when `allow` is a constant `literal` (spec15 §8): a literal `true` always
99
+ * allows, anything else always denies. `null` when the decision depends on the inputs.
100
+ */
101
+ constant: 'always-allow' | 'always-deny' | null;
102
+ }
103
+ /**
104
+ * What an `AuthorizationPolicyDef` depends on — for explainability, static coverage analysis
105
+ * and AI authoring (spec15 §35, §44). Total over malformed input (empty result). Reports
106
+ * *structure only*, never a runtime value.
107
+ */
108
+ export declare function authorizationPolicyDependencies(policy: unknown): AuthorizationPolicyDependencies;
109
+ export declare const AUTHORIZATION_DECISIONS: readonly ["ALLOW", "DENY"];
110
+ export type AuthorizationDecision = (typeof AUTHORIZATION_DECISIONS)[number];
111
+ /** One evaluated boolean input to an authorization decision. `ok: false` ⇒ evaluation failed. */
112
+ export interface AuthorizationCheckPart {
113
+ ok: boolean;
114
+ value?: unknown;
115
+ }
116
+ export interface AuthorizationCheckInput {
117
+ /**
118
+ * The `AuthorizationPolicyDef.allow` outcome, when a policy is attached. Per spec15 §8 a
119
+ * policy allows only when it evaluates to **exactly `true`**.
120
+ */
121
+ policy?: AuthorizationCheckPart;
122
+ /**
123
+ * The legacy `ActionDef.authorization` expression outcome, when that expression is present.
124
+ * Its historical truthiness rule is kept: a non-empty array or any truthy value allows.
125
+ */
126
+ legacy?: AuthorizationCheckPart;
127
+ }
128
+ export interface AuthorizationCheckResult {
129
+ decision: AuthorizationDecision;
130
+ reason: 'no-policy' | 'allowed' | 'policy-denied' | 'policy-error' | 'legacy-denied' | 'legacy-error';
131
+ }
132
+ /**
133
+ * The canonical ALLOW/DENY combination (spec15 §8, §123) — a pure function of already
134
+ * evaluated inputs, so it is identical on every surface and independently checkable.
135
+ *
136
+ * - neither a policy nor a legacy expression ⇒ ALLOW (`no-policy`);
137
+ * - a policy that failed to evaluate ⇒ DENY (`policy-error`) — an evaluation error never allows;
138
+ * - a policy whose value is not exactly `true` ⇒ DENY (`policy-denied`);
139
+ * - a legacy expression that failed to evaluate ⇒ DENY (`legacy-error`);
140
+ * - a legacy expression that is falsy / an empty collection ⇒ DENY (`legacy-denied`);
141
+ * - otherwise ALLOW (`allowed`). Both, when present, must pass (conjunction).
142
+ */
143
+ export declare function decideAuthorization(input: AuthorizationCheckInput): AuthorizationCheckResult;
144
+ /**
145
+ * The closed scope an `AuthorizationPolicyDef.allow` expression is evaluated against. Each
146
+ * field is a plain record or `null` (anonymous / no resource); a `field` read of a key that
147
+ * is not present is **security-scope absence**, not an ordinary `undefined` (spec15pt2 §5-§7).
148
+ */
149
+ export interface AuthorizationPolicyScope {
150
+ principal: Record<string, unknown> | null | undefined;
151
+ resource: Record<string, unknown> | null | undefined;
152
+ operation: string;
153
+ }
154
+ /**
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:
158
+ *
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 }`.
167
+ *
168
+ * 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).
170
+ */
171
+ export declare function evaluateAuthorizationPolicyAllow(allow: unknown, scope: AuthorizationPolicyScope): AuthorizationCheckPart;
172
+ /**
173
+ * The distinct policy ids a graph node references for authorization — for dependency
174
+ * analysis and `validateGraph` reference resolution. Total over malformed input.
175
+ */
176
+ export declare function nodeAuthorizationPolicyRefs(node: unknown): string[];
177
+ //# sourceMappingURL=authorization.d.ts.map
@@ -0,0 +1,588 @@
1
+ /**
2
+ * Authorization completeness (spec15) — the canonical, portable authorization policy model.
3
+ *
4
+ * Authorization in Axiom is **semantic**: whether a principal may perform a semantic
5
+ * operation is executable meaning, not presentation. It therefore lives in the graph, in
6
+ * `semanticFingerprint`, in authority compatibility, in the Server IR and in conformance —
7
+ * never as a host-language callback (spec15 §6, §7).
8
+ *
9
+ * There is exactly **one** authorization language: an `AuthorizationPolicyDef` with a single
10
+ * boolean `allow` expression over a **closed scope** — `ref('PRINCIPAL')` (the canonical
11
+ * caller), `ref('RESOURCE')` (the semantic object the operation targets, where one exists)
12
+ * and `ref('OPERATION')` (the canonical operation identity). No `StateDef`, no `QueryDef`,
13
+ * no `now` / `uuid` / `random`, no ambient runtime state (spec15 §34). `allow` evaluating to
14
+ * exactly `true` is **ALLOW**; `false`, absence, or any evaluation error is **DENY** — the
15
+ * safe direction is always refusal (spec15 §8, §123).
16
+ *
17
+ * A policy is referenced by id from the surface it protects — `ActionDef.authorizationPolicy`,
18
+ * `QueryDef.authorizationPolicy`, `WorkflowDef.startPolicy` / `instanceAccessPolicy` — so the
19
+ * same evaluator and the same decision model apply everywhere (spec15 §5, §96). The legacy
20
+ * `ActionDef.authorization` boolean expression remains supported and is canonically
21
+ * equivalent to an inline `allow` over `PRINCIPAL`; `ReadPolicyDef` (spec10) remains the
22
+ * row-level read mechanism, unified into query authorization by spec15 Phase D.
23
+ *
24
+ * This module is portable plain data + `Expression` trees; it carries no enforcement (that
25
+ * is Phases C–F). The accessors are **total over any input** so a hand-tampered policy fails
26
+ * closed with a structured diagnostic, never a native exception (spec15 §37).
27
+ */
28
+ import { OPERATION, PRINCIPAL, RESOURCE } from './authority.js';
29
+ import { EXPRESSION_KINDS } from './expressions.js';
30
+ // --------------------------------------------------------------------- reserved scope ids
31
+ /**
32
+ * The canonical caller. The **same** reserved id `ActionDef.authorization` uses
33
+ * (`'axiom_principal'`, exported from `authority.ts` as `PRINCIPAL`) — write a policy with
34
+ * `field(ref(PRINCIPAL), F_ROLE)`, exactly as a legacy authorization expression.
35
+ */
36
+ export const AUTHZ_PRINCIPAL_SCOPE = PRINCIPAL;
37
+ /** The semantic object the operation targets (a record, a workflow instance, …), where one exists. */
38
+ export const AUTHZ_RESOURCE_SCOPE = RESOURCE;
39
+ /** The canonical operation identity — `ref(OPERATION)` resolves to an `AuthorizationOperation` string. */
40
+ export const AUTHZ_OPERATION_SCOPE = OPERATION;
41
+ /** Every id a policy `allow` expression's `ref` may resolve. Nothing else is in scope. */
42
+ export const AUTHORIZATION_SCOPE_IDS = [
43
+ AUTHZ_PRINCIPAL_SCOPE,
44
+ AUTHZ_RESOURCE_SCOPE,
45
+ AUTHZ_OPERATION_SCOPE,
46
+ ];
47
+ /** Builtins forbidden in a policy expression — authorization must be deterministic (spec15 §34). */
48
+ export const AUTHORIZATION_NONDETERMINISTIC_BUILTINS = new Set([
49
+ 'now',
50
+ 'uuid',
51
+ 'random',
52
+ ]);
53
+ // ----------------------------------------------------------------------- operation identity
54
+ /**
55
+ * The closed set of canonical semantic operations an authorization decision is made about
56
+ * (spec15 §98). Policies reason over these, never over transport method names. Enforcement
57
+ * of each arrives with its phase (C: `action.invoke` / `record.*` / `state.*`; D:
58
+ * `query.read`; E: `workflow.*`; F: `live.*` / `subscription.*`).
59
+ */
60
+ export const AUTHORIZATION_OPERATIONS = [
61
+ 'action.invoke',
62
+ 'query.read',
63
+ 'record.read',
64
+ 'record.mutate',
65
+ 'state.read',
66
+ 'state.mutate',
67
+ 'workflow.start',
68
+ 'workflow.inspect',
69
+ 'workflow.history',
70
+ 'workflow.cancel',
71
+ 'live.open',
72
+ 'live.resume',
73
+ 'subscription.open',
74
+ 'event.ingress',
75
+ ];
76
+ // --------------------------------------------------------------------------- default rule
77
+ /**
78
+ * What a protected surface does when it has **no** attached policy (spec15 §9). Deliberately
79
+ * one canonical rule, applied consistently, never left to the runtime:
80
+ *
81
+ * - a surface whose *current* (pre-0.15) contract is public — an unrestricted `ActionDef`,
82
+ * an unrestricted `QueryDef` — keeps that public contract (`'public'`);
83
+ * - a surface whose current contract is already restricted — a `WorkflowDef` instance
84
+ * operation (0.14 owner-fingerprint), an `ActionDef` with a legacy `authorization`
85
+ * expression, a `QueryDef` with a `ReadPolicyDef` — keeps that restriction;
86
+ * - a *new* privileged surface with no policy fails closed (`'deny'`).
87
+ *
88
+ * These constants name the rule so docs, tests and a future independent runtime agree.
89
+ */
90
+ export const AUTHORIZATION_DEFAULT = {
91
+ /** No policy, previously-public surface ⇒ still public. */
92
+ PUBLIC_SURFACE: 'public',
93
+ /** No policy, previously-restricted surface ⇒ keep the prior restriction. */
94
+ KEEP_PRIOR_RESTRICTION: 'keep-prior-restriction',
95
+ /** No policy, new privileged surface ⇒ DENY. */
96
+ FAIL_CLOSED: 'deny',
97
+ };
98
+ // -------------------------------------------------------------------------------- accessors
99
+ function isPlainObject(value) {
100
+ return !!value && typeof value === 'object' && !Array.isArray(value);
101
+ }
102
+ /** Every `Expression` a policy embeds — total over malformed input. */
103
+ export function authorizationPolicyExpressions(policy) {
104
+ if (!isPlainObject(policy))
105
+ return [];
106
+ const allow = policy.allow;
107
+ return isPlainObject(allow) ? [allow] : [];
108
+ }
109
+ /** Walk an expression tree collecting every scope `ref` id and every nondeterministic call. */
110
+ function walkPolicyExpression(expression, refs, nondeterministic, seen = new Set()) {
111
+ if (!isPlainObject(expression) || seen.has(expression))
112
+ return;
113
+ seen.add(expression);
114
+ if (expression.kind === 'ref' && expression.targetId !== undefined) {
115
+ refs.add(String(expression.targetId));
116
+ }
117
+ if (expression.kind === 'call' &&
118
+ typeof expression.function === 'string' &&
119
+ AUTHORIZATION_NONDETERMINISTIC_BUILTINS.has(expression.function)) {
120
+ nondeterministic.add(expression.function);
121
+ }
122
+ if (expression.kind === 'literal')
123
+ return;
124
+ for (const value of Object.values(expression)) {
125
+ if (Array.isArray(value)) {
126
+ for (const entry of value)
127
+ walkPolicyExpression(entry, refs, nondeterministic, seen);
128
+ }
129
+ else if (isPlainObject(value)) {
130
+ walkPolicyExpression(value, refs, nondeterministic, seen);
131
+ }
132
+ }
133
+ }
134
+ /**
135
+ * A **total** structural + scope check on an `AuthorizationPolicyDef` value — for any input,
136
+ * including a hand-tampered Server IR where the policy is the wrong shape entirely (spec15
137
+ * §37). Cross-node reference resolution (does an `authorizationPolicy` id point at a real
138
+ * policy) is `validateGraph`'s job, not this function's.
139
+ */
140
+ /**
141
+ * spec15pt2 §39-§41 (F2) — a **total** structural check on an `allow` expression tree.
142
+ * Every node must be a plain object whose `kind` is a real `EXPRESSION_KINDS` member and
143
+ * whose per-kind required children are present and themselves structurally valid. Returns a
144
+ * short reason on the first problem, `null` when the tree is well-formed. Never throws.
145
+ */
146
+ function malformedExpressionReason(expression, seen = new Set()) {
147
+ if (!isPlainObject(expression))
148
+ return 'a policy expression node is not an object';
149
+ if (seen.has(expression))
150
+ return 'a policy expression contains a cycle';
151
+ seen.add(expression);
152
+ const kind = expression.kind;
153
+ if (typeof kind !== 'string' || !EXPRESSION_KINDS.includes(kind)) {
154
+ return `unknown expression kind ${JSON.stringify(kind)}`;
155
+ }
156
+ const child = (name) => malformedExpressionReason(expression[name], seen);
157
+ const list = (name) => {
158
+ const arr = expression[name];
159
+ if (!Array.isArray(arr))
160
+ return `${kind}.${name} is not an array`;
161
+ for (const entry of arr) {
162
+ const r = malformedExpressionReason(entry, seen);
163
+ if (r)
164
+ return r;
165
+ }
166
+ return null;
167
+ };
168
+ switch (kind) {
169
+ case 'literal':
170
+ return 'value' in expression ? null : 'literal has no value';
171
+ case 'ref':
172
+ return typeof expression.targetId === 'string' ? null : 'ref has no targetId';
173
+ case 'field':
174
+ return (typeof expression.fieldId === 'string' ? null : 'field has no fieldId') ?? child('source');
175
+ case 'unary':
176
+ return (typeof expression.operator === 'string' ? null : 'unary has no operator') ?? child('operand');
177
+ case 'binary':
178
+ return (typeof expression.operator === 'string' ? null : 'binary has no operator') ?? child('left') ?? child('right');
179
+ case 'call':
180
+ return (typeof expression.function === 'string' ? null : 'call has no function') ?? list('arguments');
181
+ case 'conditional':
182
+ return child('condition') ?? child('whenTrue') ?? child('whenFalse');
183
+ case 'object': {
184
+ const entries = expression.entries;
185
+ if (!Array.isArray(entries))
186
+ return 'object has no entries array';
187
+ for (const e of entries) {
188
+ if (!isPlainObject(e) || typeof e.fieldId !== 'string')
189
+ return 'an object entry is malformed';
190
+ const r = malformedExpressionReason(e.value, seen);
191
+ if (r)
192
+ return r;
193
+ }
194
+ return null;
195
+ }
196
+ case 'filter':
197
+ case 'find':
198
+ case 'every':
199
+ case 'some':
200
+ return child('source') ?? child('predicate');
201
+ case 'map':
202
+ return child('source') ?? child('projection');
203
+ case 'sort':
204
+ case 'group':
205
+ return child('source') ?? child('by');
206
+ case 'flatten':
207
+ return child('source');
208
+ case 'expression-ref':
209
+ return typeof expression.targetId === 'string' ? null : 'expression-ref has no targetId';
210
+ default:
211
+ return `unsupported expression kind ${kind}`;
212
+ }
213
+ }
214
+ export function authorizationPolicyProblems(policy) {
215
+ const problems = [];
216
+ const pid = isPlainObject(policy) ? String(policy.id ?? '<unknown>') : '<unknown>';
217
+ if (!isPlainObject(policy)) {
218
+ return [{ code: 'AUTHORIZATION_INVALID_POLICY', message: `Authorization policy ${pid} is not an object` }];
219
+ }
220
+ if (!isPlainObject(policy.allow)) {
221
+ problems.push({
222
+ code: 'AUTHORIZATION_INVALID_POLICY',
223
+ message: `Authorization policy ${pid} has no boolean 'allow' expression`,
224
+ });
225
+ return problems;
226
+ }
227
+ // spec15pt2 F2 — a malformed `allow` tree is rejected structurally here, before compile,
228
+ // rather than surviving to a native exception at evaluation.
229
+ const malformed = malformedExpressionReason(policy.allow);
230
+ if (malformed) {
231
+ return [{ code: 'AUTHORIZATION_INVALID_POLICY', message: `Authorization policy ${pid} has a malformed 'allow' expression: ${malformed}` }];
232
+ }
233
+ const refs = new Set();
234
+ const nondeterministic = new Set();
235
+ walkPolicyExpression(policy.allow, refs, nondeterministic);
236
+ for (const id of refs) {
237
+ if (!AUTHORIZATION_SCOPE_IDS.includes(id)) {
238
+ problems.push({
239
+ code: 'AUTHORIZATION_INVALID_SCOPE',
240
+ message: `Authorization policy ${pid} references ${id}, which is not in the policy scope (PRINCIPAL / RESOURCE / OPERATION)`,
241
+ });
242
+ }
243
+ }
244
+ for (const fn of nondeterministic) {
245
+ problems.push({
246
+ code: 'AUTHORIZATION_NONDETERMINISTIC',
247
+ message: `Authorization policy ${pid} calls ${fn}(), which is not deterministic and not allowed in a policy expression`,
248
+ });
249
+ }
250
+ return problems;
251
+ }
252
+ function collectScopeFieldReads(expression, principal, resource, operation, seen = new Set()) {
253
+ if (!isPlainObject(expression) || seen.has(expression))
254
+ return;
255
+ seen.add(expression);
256
+ if (expression.kind === 'ref' && String(expression.targetId) === AUTHZ_OPERATION_SCOPE) {
257
+ operation.seen = true;
258
+ }
259
+ if (expression.kind === 'field' &&
260
+ isPlainObject(expression.source) &&
261
+ expression.source.kind === 'ref' &&
262
+ expression.fieldId !== undefined) {
263
+ const target = String(expression.source.targetId);
264
+ if (target === AUTHZ_PRINCIPAL_SCOPE)
265
+ principal.add(String(expression.fieldId));
266
+ else if (target === AUTHZ_RESOURCE_SCOPE)
267
+ resource.add(String(expression.fieldId));
268
+ }
269
+ if (expression.kind === 'literal')
270
+ return;
271
+ for (const value of Object.values(expression)) {
272
+ if (Array.isArray(value)) {
273
+ for (const entry of value)
274
+ collectScopeFieldReads(entry, principal, resource, operation, seen);
275
+ }
276
+ else if (isPlainObject(value)) {
277
+ collectScopeFieldReads(value, principal, resource, operation, seen);
278
+ }
279
+ }
280
+ }
281
+ /**
282
+ * What an `AuthorizationPolicyDef` depends on — for explainability, static coverage analysis
283
+ * and AI authoring (spec15 §35, §44). Total over malformed input (empty result). Reports
284
+ * *structure only*, never a runtime value.
285
+ */
286
+ export function authorizationPolicyDependencies(policy) {
287
+ const principal = new Set();
288
+ const resource = new Set();
289
+ const operation = { seen: false };
290
+ const allow = isPlainObject(policy) ? policy.allow : undefined;
291
+ if (isPlainObject(allow)) {
292
+ collectScopeFieldReads(allow, principal, resource, operation);
293
+ }
294
+ let constant = null;
295
+ if (isPlainObject(allow) && allow.kind === 'literal') {
296
+ constant = allow.value === true ? 'always-allow' : 'always-deny';
297
+ }
298
+ return {
299
+ principalFields: [...principal].sort(),
300
+ resourceFields: [...resource].sort(),
301
+ readsOperation: operation.seen,
302
+ constant,
303
+ };
304
+ }
305
+ // --------------------------------------------------------------- the decision (spec15 §8)
306
+ export const AUTHORIZATION_DECISIONS = ['ALLOW', 'DENY'];
307
+ /**
308
+ * The canonical ALLOW/DENY combination (spec15 §8, §123) — a pure function of already
309
+ * evaluated inputs, so it is identical on every surface and independently checkable.
310
+ *
311
+ * - neither a policy nor a legacy expression ⇒ ALLOW (`no-policy`);
312
+ * - a policy that failed to evaluate ⇒ DENY (`policy-error`) — an evaluation error never allows;
313
+ * - a policy whose value is not exactly `true` ⇒ DENY (`policy-denied`);
314
+ * - a legacy expression that failed to evaluate ⇒ DENY (`legacy-error`);
315
+ * - a legacy expression that is falsy / an empty collection ⇒ DENY (`legacy-denied`);
316
+ * - otherwise ALLOW (`allowed`). Both, when present, must pass (conjunction).
317
+ */
318
+ export function decideAuthorization(input) {
319
+ const { policy, legacy } = input;
320
+ if (!policy && !legacy)
321
+ return { decision: 'ALLOW', reason: 'no-policy' };
322
+ if (policy) {
323
+ if (!policy.ok)
324
+ return { decision: 'DENY', reason: 'policy-error' };
325
+ if (policy.value !== true)
326
+ return { decision: 'DENY', reason: 'policy-denied' };
327
+ }
328
+ if (legacy) {
329
+ if (!legacy.ok)
330
+ return { decision: 'DENY', reason: 'legacy-error' };
331
+ const permitted = Array.isArray(legacy.value) ? legacy.value.length > 0 : Boolean(legacy.value);
332
+ if (!permitted)
333
+ return { decision: 'DENY', reason: 'legacy-denied' };
334
+ }
335
+ return { decision: 'ALLOW', reason: 'allowed' };
336
+ }
337
+ const U = { t: 'u' };
338
+ const E = { t: 'e' };
339
+ const C = (v) => ({ t: 'c', v });
340
+ function asBool(v) {
341
+ return v === true ? true : v === false ? false : Boolean(v);
342
+ }
343
+ function deepEq(a, b) {
344
+ if (a === b)
345
+ return true;
346
+ try {
347
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
348
+ }
349
+ catch {
350
+ return false;
351
+ }
352
+ }
353
+ /** Whether an expression tree reads only through `field` / `ref` rooted at PRINCIPAL / RESOURCE. */
354
+ function isSecurityRooted(expression) {
355
+ if (!isPlainObject(expression))
356
+ return false;
357
+ if (expression.kind === 'ref') {
358
+ const t = String(expression.targetId);
359
+ return t === AUTHZ_PRINCIPAL_SCOPE || t === AUTHZ_RESOURCE_SCOPE;
360
+ }
361
+ if (expression.kind === 'field')
362
+ return isSecurityRooted(expression.source);
363
+ return false;
364
+ }
365
+ function evalAuthz(expression, scope, seen) {
366
+ if (!isPlainObject(expression) || seen.has(expression))
367
+ return E;
368
+ seen.add(expression);
369
+ switch (expression.kind) {
370
+ case 'literal':
371
+ return C(expression.value);
372
+ case 'ref': {
373
+ const t = String(expression.targetId);
374
+ if (t === AUTHZ_PRINCIPAL_SCOPE)
375
+ return C(scope.principal ?? null);
376
+ if (t === AUTHZ_RESOURCE_SCOPE)
377
+ return C(scope.resource ?? null);
378
+ if (t === AUTHZ_OPERATION_SCOPE)
379
+ return C(scope.operation);
380
+ return E; // out of the closed scope — validation rejects it; be total
381
+ }
382
+ case 'field': {
383
+ const src = evalAuthz(expression.source, scope, seen);
384
+ if (src.t === 'e')
385
+ return E;
386
+ if (src.t === 'u')
387
+ return U;
388
+ const key = String(expression.fieldId);
389
+ const rooted = isSecurityRooted(expression.source);
390
+ const obj = src.v;
391
+ if (obj === null || obj === undefined || typeof obj !== 'object') {
392
+ return rooted ? U : C(null);
393
+ }
394
+ const present = Object.prototype.hasOwnProperty.call(obj, key) && obj[key] !== undefined;
395
+ if (!present)
396
+ return rooted ? U : C(null);
397
+ return C(obj[key]);
398
+ }
399
+ case 'unary': {
400
+ const operand = evalAuthz(expression.operand, scope, seen);
401
+ if (operand.t !== 'c')
402
+ return operand; // NOT unknown ⇒ unknown; NOT error ⇒ error (§9, §12)
403
+ return expression.operator === 'not' ? C(!asBool(operand.v)) : C(-Number(operand.v));
404
+ }
405
+ case 'binary': {
406
+ const op = String(expression.operator);
407
+ if (op === 'and') {
408
+ const l = evalAuthz(expression.left, scope, seen);
409
+ if (l.t === 'c' && !asBool(l.v))
410
+ return C(false); // FALSE AND _ ⇒ FALSE (short-circuit-independent)
411
+ const r = evalAuthz(expression.right, scope, seen);
412
+ if (l.t === 'e' || r.t === 'e')
413
+ return E;
414
+ if (r.t === 'c' && !asBool(r.v))
415
+ return C(false);
416
+ if (l.t === 'c' && r.t === 'c')
417
+ return C(true); // both concrete-true
418
+ return U;
419
+ }
420
+ if (op === 'or') {
421
+ const l = evalAuthz(expression.left, scope, seen);
422
+ if (l.t === 'c' && asBool(l.v))
423
+ return C(true); // TRUE OR _ ⇒ TRUE
424
+ const r = evalAuthz(expression.right, scope, seen);
425
+ if (l.t === 'e' || r.t === 'e')
426
+ return E;
427
+ if (r.t === 'c' && asBool(r.v))
428
+ return C(true);
429
+ if (l.t === 'c' && r.t === 'c')
430
+ return C(false); // both concrete-false
431
+ return U;
432
+ }
433
+ const l = evalAuthz(expression.left, scope, seen);
434
+ const r = evalAuthz(expression.right, scope, seen);
435
+ if (l.t === 'e' || r.t === 'e')
436
+ return E;
437
+ if (l.t === 'u' || r.t === 'u')
438
+ return U; // any comparison touching absence ⇒ non-satisfied (§8)
439
+ const a = l.v;
440
+ const b = r.v;
441
+ switch (op) {
442
+ case 'eq':
443
+ return C(deepEq(a, b));
444
+ case 'neq':
445
+ return C(!deepEq(a, b));
446
+ case 'lt':
447
+ return C(a < b);
448
+ case 'lte':
449
+ return C(a <= b);
450
+ case 'gt':
451
+ return C(a > b);
452
+ case 'gte':
453
+ return C(a >= b);
454
+ case 'add':
455
+ return C(a + b);
456
+ case 'subtract':
457
+ return C(a - b);
458
+ case 'multiply':
459
+ return C(a * b);
460
+ case 'divide':
461
+ return C(a / b);
462
+ default:
463
+ return E;
464
+ }
465
+ }
466
+ case 'conditional': {
467
+ const c = evalAuthz(expression.condition, scope, seen);
468
+ if (c.t !== 'c')
469
+ return c;
470
+ return asBool(c.v) ? evalAuthz(expression.whenTrue, scope, seen) : evalAuthz(expression.whenFalse, scope, seen);
471
+ }
472
+ case 'object': {
473
+ const out = {};
474
+ for (const entry of expression.entries ?? []) {
475
+ const v = evalAuthz(entry.value, scope, seen);
476
+ if (v.t === 'e')
477
+ return E;
478
+ if (v.t === 'u')
479
+ return U;
480
+ out[String(entry.fieldId)] = v.v;
481
+ }
482
+ return C(out);
483
+ }
484
+ case 'call': {
485
+ const args = [];
486
+ for (const argument of expression.arguments ?? []) {
487
+ const v = evalAuthz(argument, scope, seen);
488
+ if (v.t === 'e')
489
+ return E;
490
+ if (v.t === 'u')
491
+ return U; // conservative: any absent input ⇒ non-satisfied (spec15pt2 §57, §85)
492
+ args.push(v.v);
493
+ }
494
+ return applyPolicyBuiltin(String(expression.function), args);
495
+ }
496
+ default:
497
+ return E;
498
+ }
499
+ }
500
+ function applyPolicyBuiltin(fn, args) {
501
+ switch (fn) {
502
+ case 'required':
503
+ return C(args[0] !== null && args[0] !== undefined);
504
+ case 'is-empty':
505
+ return C(args[0] === null || args[0] === undefined || args[0] === '' || (Array.isArray(args[0]) && args[0].length === 0));
506
+ case 'non-empty':
507
+ return C(!(args[0] === null || args[0] === undefined || args[0] === '' || (Array.isArray(args[0]) && args[0].length === 0)));
508
+ case 'length':
509
+ return C(Array.isArray(args[0]) || typeof args[0] === 'string' ? args[0].length : 0);
510
+ case 'contains':
511
+ return C(typeof args[0] === 'string'
512
+ ? args[0].includes(String(args[1]))
513
+ : Array.isArray(args[0])
514
+ ? args[0].some((x) => deepEq(x, args[1]))
515
+ : false);
516
+ case 'one-of':
517
+ return C(args.slice(1).some((x) => deepEq(x, args[0])));
518
+ case 'concat':
519
+ return C(args.map((x) => (x === null || x === undefined ? '' : String(x))).join(''));
520
+ case 'to-string':
521
+ return C(args[0] === null || args[0] === undefined ? '' : String(args[0]));
522
+ case 'lowercase':
523
+ return C(String(args[0] ?? '').toLowerCase());
524
+ case 'trim':
525
+ return C(String(args[0] ?? '').trim());
526
+ case 'substring-before': {
527
+ const s = String(args[0] ?? '');
528
+ const i = s.indexOf(String(args[1] ?? ''));
529
+ return C(i < 0 ? '' : s.slice(0, i));
530
+ }
531
+ case 'substring-after': {
532
+ const s = String(args[0] ?? '');
533
+ const needle = String(args[1] ?? '');
534
+ const i = s.indexOf(needle);
535
+ return C(i < 0 ? '' : s.slice(i + needle.length));
536
+ }
537
+ case 'coalesce':
538
+ return C(args.find((x) => x !== null && x !== undefined) ?? null);
539
+ default:
540
+ return E; // an unknown / non-deterministic builtin fails closed
541
+ }
542
+ }
543
+ /**
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:
547
+ *
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 }`.
556
+ *
557
+ * 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).
559
+ */
560
+ export function evaluateAuthorizationPolicyAllow(allow, scope) {
561
+ let result;
562
+ try {
563
+ result = evalAuthz(allow, scope, new Set());
564
+ }
565
+ catch {
566
+ return { ok: false };
567
+ }
568
+ if (result.t === 'e')
569
+ return { ok: false };
570
+ if (result.t === 'u')
571
+ return { ok: true, value: false };
572
+ return { ok: true, value: result.v };
573
+ }
574
+ /**
575
+ * The distinct policy ids a graph node references for authorization — for dependency
576
+ * analysis and `validateGraph` reference resolution. Total over malformed input.
577
+ */
578
+ export function nodeAuthorizationPolicyRefs(node) {
579
+ if (!isPlainObject(node))
580
+ return [];
581
+ const out = [];
582
+ for (const key of ['authorizationPolicy', 'startPolicy', 'instanceAccessPolicy']) {
583
+ const value = node[key];
584
+ if (typeof value === 'string')
585
+ out.push(value);
586
+ }
587
+ return out;
588
+ }
@@ -204,5 +204,13 @@ export declare const VALIDATION_CODES: {
204
204
  readonly migrationTransformImpure: "MIGRATION_TRANSFORM_IMPURE";
205
205
  /** A `transform-field` whose declared `toType` does not match the field's type in the target schema, or an `add-field` `populate` whose value cannot satisfy the field (spec11 §77). */
206
206
  readonly migrationTransformTypeMismatch: "MIGRATION_TRANSFORM_TYPE_MISMATCH";
207
+ /** An `AuthorizationPolicyDef` with no boolean `allow` expression, or a non-object policy value. */
208
+ readonly authorizationInvalidPolicy: "AUTHORIZATION_INVALID_POLICY";
209
+ /** An `AuthorizationPolicyDef.allow` expression references an id outside the policy scope (`PRINCIPAL` / `RESOURCE` / `OPERATION`). */
210
+ readonly authorizationInvalidScope: "AUTHORIZATION_INVALID_SCOPE";
211
+ /** An `authorizationPolicy` / `startPolicy` / `instanceAccessPolicy` id that does not resolve to an `authorization-policy` node. */
212
+ readonly authorizationUnknownPolicy: "AUTHORIZATION_UNKNOWN_POLICY";
213
+ /** An `AuthorizationPolicyDef.allow` expression calls `now` / `uuid` / `random` — authorization must be deterministic (spec15 §34). */
214
+ readonly authorizationNondeterministic: "AUTHORIZATION_NONDETERMINISTIC";
207
215
  };
208
216
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -200,4 +200,15 @@ export const VALIDATION_CODES = {
200
200
  migrationTransformImpure: 'MIGRATION_TRANSFORM_IMPURE',
201
201
  /** A `transform-field` whose declared `toType` does not match the field's type in the target schema, or an `add-field` `populate` whose value cannot satisfy the field (spec11 §77). */
202
202
  migrationTransformTypeMismatch: 'MIGRATION_TRANSFORM_TYPE_MISMATCH',
203
+ // Authorization completeness (0.15). `validateGraph` rejects a malformed or inconsistent
204
+ // authorization policy before it can be compiled or executed; every failure is structured,
205
+ // never a native exception (spec15 §36, §37, §38).
206
+ /** An `AuthorizationPolicyDef` with no boolean `allow` expression, or a non-object policy value. */
207
+ authorizationInvalidPolicy: 'AUTHORIZATION_INVALID_POLICY',
208
+ /** An `AuthorizationPolicyDef.allow` expression references an id outside the policy scope (`PRINCIPAL` / `RESOURCE` / `OPERATION`). */
209
+ authorizationInvalidScope: 'AUTHORIZATION_INVALID_SCOPE',
210
+ /** An `authorizationPolicy` / `startPolicy` / `instanceAccessPolicy` id that does not resolve to an `authorization-policy` node. */
211
+ authorizationUnknownPolicy: 'AUTHORIZATION_UNKNOWN_POLICY',
212
+ /** An `AuthorizationPolicyDef.allow` expression calls `now` / `uuid` / `random` — authorization must be deterministic (spec15 §34). */
213
+ authorizationNondeterministic: 'AUTHORIZATION_NONDETERMINISTIC',
203
214
  };
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.14.0') {
26
+ constructor(id, name, version = '0.15.0') {
27
27
  this.data = { id, name, version, nodes: {}, edges: {} };
28
28
  }
29
29
  get id() {
package/dist/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export * from './storage.js';
16
16
  export * from './query.js';
17
17
  export * from './live-query.js';
18
18
  export * from './workflows.js';
19
+ export * from './authorization.js';
19
20
  export * from './relationships.js';
20
21
  export * from './read-policy.js';
21
22
  export * from './migration.js';
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export * from './storage.js';
16
16
  export * from './query.js';
17
17
  export * from './live-query.js';
18
18
  export * from './workflows.js';
19
+ export * from './authorization.js';
19
20
  export * from './relationships.js';
20
21
  export * from './read-policy.js';
21
22
  export * from './migration.js';
package/dist/nodes.d.ts CHANGED
@@ -229,6 +229,13 @@ export interface ActionDef extends NodeBase {
229
229
  * claiming to. `requiresConfirmation` is UX and is not an authorization mechanism.
230
230
  */
231
231
  authorization?: Expression;
232
+ /**
233
+ * The `AuthorizationPolicyDef` id governing `action.invoke` (spec15). The canonical 0.15
234
+ * form of `authorization` above: one policy language, one evaluator. If both are present
235
+ * the effective decision is their conjunction (ALLOW iff both ALLOW). Absent + no legacy
236
+ * `authorization` ⇒ the action's pre-0.15 public contract is preserved (spec15 §9).
237
+ */
238
+ authorizationPolicy?: NodeId;
232
239
  /** What the confirmation says, when a plain message is not enough. */
233
240
  confirmation?: ConfirmationPresentation;
234
241
  /** Restricts which invocation sources may reach this action. Absent means both. */
package/dist/query.d.ts CHANGED
@@ -200,6 +200,12 @@ export interface QueryDef extends NodeBase {
200
200
  pagination?: QueryPagination;
201
201
  /** The `ReadPolicyDef` whose predicate is AND-ed into `filter` before execution. */
202
202
  readPolicyId?: NodeId;
203
+ /**
204
+ * The `AuthorizationPolicyDef` id governing `query.read` — whether this principal may run
205
+ * the query at all (spec15), distinct from `readPolicyId` which filters *which rows* the
206
+ * result contains. Absent ⇒ the query's pre-0.15 contract is preserved (spec15 §9).
207
+ */
208
+ authorizationPolicy?: NodeId;
203
209
  }
204
210
  export declare function queryPaginationStrategy(query: QueryDef): QueryPaginationStrategy;
205
211
  export declare function queryMaxPageSize(query: QueryDef): number;
@@ -33,6 +33,12 @@ import type { ApplicationGraph } from './graph.js';
33
33
  * - `StorageDef` — `readAuthorization` / `uploadAuthorization` expressions, `retry`.
34
34
  * - `RelationshipDef` — endpoints and cardinality (also in the schema fingerprint; repeated
35
35
  * here so a semantic-only comparison is self-contained).
36
+ * - `AuthorizationPolicyDef` — the `allow` expression (spec15). Whether a principal may
37
+ * perform a semantic operation is executable meaning: a policy edited from ALLOW to DENY
38
+ * moves the fingerprint and makes a mixed-build authority incompatible (spec15 §6, §45,
39
+ * §46). The `authorizationPolicy` / `startPolicy` / `instanceAccessPolicy` id an action /
40
+ * query / workflow references is a field of those already-projected nodes, so a re-pointed
41
+ * reference moves the fingerprint too.
36
42
  * - `WorkflowDef` — `inputs`, `bindings`, `entry`, and every step's kind, control-flow edges
37
43
  * and step-specific executable semantics (the `ActionDef` / `EventDef` a step targets, an
38
44
  * `action` step's argument expressions / `retry` policy, a `wait-event` step's correlation
@@ -65,7 +71,7 @@ export declare const SEMANTIC_FINGERPRINT_VERSION = 1;
65
71
  * so a future primitive cannot be added to one and silently omitted from the other
66
72
  * (spec14pt3 §189, §190 — the deeper architectural correction behind Phase 22 F3).
67
73
  */
68
- export declare const EXECUTABLE_KINDS: readonly ["action", "integration", "integration-operation", "trigger", "event", "subscription", "read-policy", "query", "expression", "constraint", "transition-constraint", "storage", "relationship", "workflow"];
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"];
69
75
  export type ExecutableKind = (typeof EXECUTABLE_KINDS)[number];
70
76
  export interface SemanticProjection {
71
77
  fingerprintVersion: number;
@@ -94,6 +100,15 @@ export interface AuthorityCompatibilityKey {
94
100
  schemaFingerprint: string;
95
101
  serverContract: string;
96
102
  semanticFingerprint: string;
103
+ /**
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).
110
+ */
111
+ authorizationRuntime?: string;
97
112
  }
98
113
  export declare function authorityCompatibilityKey(parts: AuthorityCompatibilityKey): AuthorityCompatibilityKey;
99
114
  /** A stable, comparable string form for storing on a durable work item (spec12 §43). */
@@ -36,6 +36,12 @@ import { canonicalWorkflowForFingerprint } from './workflows.js';
36
36
  * - `StorageDef` — `readAuthorization` / `uploadAuthorization` expressions, `retry`.
37
37
  * - `RelationshipDef` — endpoints and cardinality (also in the schema fingerprint; repeated
38
38
  * here so a semantic-only comparison is self-contained).
39
+ * - `AuthorizationPolicyDef` — the `allow` expression (spec15). Whether a principal may
40
+ * perform a semantic operation is executable meaning: a policy edited from ALLOW to DENY
41
+ * moves the fingerprint and makes a mixed-build authority incompatible (spec15 §6, §45,
42
+ * §46). The `authorizationPolicy` / `startPolicy` / `instanceAccessPolicy` id an action /
43
+ * query / workflow references is a field of those already-projected nodes, so a re-pointed
44
+ * reference moves the fingerprint too.
39
45
  * - `WorkflowDef` — `inputs`, `bindings`, `entry`, and every step's kind, control-flow edges
40
46
  * and step-specific executable semantics (the `ActionDef` / `EventDef` a step targets, an
41
47
  * `action` step's argument expressions / `retry` policy, a `wait-event` step's correlation
@@ -83,6 +89,7 @@ export const EXECUTABLE_KINDS = [
83
89
  'storage',
84
90
  'relationship',
85
91
  'workflow',
92
+ 'authorization-policy',
86
93
  ];
87
94
  /** Keys removed everywhere in the tree — human metadata, never executable meaning. */
88
95
  const NON_SEMANTIC_KEYS = new Set(['name', 'description', 'label', 'metadata', AUTHORING_METADATA_KEY]);
@@ -131,6 +138,7 @@ export function authorityCompatibilityKey(parts) {
131
138
  schemaFingerprint: parts.schemaFingerprint,
132
139
  serverContract: parts.serverContract,
133
140
  semanticFingerprint: parts.semanticFingerprint,
141
+ ...(parts.authorizationRuntime !== undefined ? { authorizationRuntime: parts.authorizationRuntime } : {}),
134
142
  };
135
143
  }
136
144
  /** A stable, comparable string form for storing on a durable work item (spec12 §43). */
@@ -148,7 +156,10 @@ export function compareAuthorityCompatibility(a, b) {
148
156
  'schemaFingerprint',
149
157
  'serverContract',
150
158
  'semanticFingerprint',
159
+ 'authorizationRuntime',
151
160
  ];
152
- const mismatches = fields.filter((field) => a[field] !== b[field]);
161
+ // `authorizationRuntime` absent on both sides (a non-authorization graph) is a match;
162
+ // present-vs-absent (alpha.1 stored key vs alpha.2) is a mismatch (spec15pt2 §35, §76).
163
+ const mismatches = fields.filter((field) => (a[field] ?? null) !== (b[field] ?? null));
153
164
  return { compatible: mismatches.length === 0, mismatches };
154
165
  }
@@ -12,6 +12,7 @@ import type { RelationshipDef } from './relationships.js';
12
12
  import type { ReadPolicyDef } from './read-policy.js';
13
13
  import type { MigrationDef } from './migration.js';
14
14
  import type { WorkflowDef } from './workflows.js';
15
+ import type { AuthorizationPolicyDef } from './authorization.js';
15
16
  /**
16
17
  * The contracts a Server IR may declare. A runtime that does not recognize the value MUST
17
18
  * refuse the IR rather than interpret it partially.
@@ -27,7 +28,7 @@ import type { WorkflowDef } from './workflows.js';
27
28
  * Every existing application therefore still compiles to a byte-identical
28
29
  * `axiom.server.v1` document, and the frozen conformance fixtures stay frozen.
29
30
  */
30
- export declare const SERVER_IR_CONTRACTS: readonly ["axiom.server.v1", "axiom.server.v2", "axiom.server.v3", "axiom.server.v4", "axiom.server.v5", "axiom.server.v6", "axiom.server.v7", "axiom.server.v8"];
31
+ export declare const SERVER_IR_CONTRACTS: readonly ["axiom.server.v1", "axiom.server.v2", "axiom.server.v3", "axiom.server.v4", "axiom.server.v5", "axiom.server.v6", "axiom.server.v7", "axiom.server.v8", "axiom.server.v9"];
31
32
  export type ServerIRContract = (typeof SERVER_IR_CONTRACTS)[number];
32
33
  /** The oldest contract, and the one a document declares unless it needs more. */
33
34
  export declare const SERVER_IR_CONTRACT: ServerIRContract;
@@ -121,6 +122,37 @@ export declare function usesMigrationVocabulary(ir: {
121
122
  export declare function usesWorkflowVocabulary(ir: {
122
123
  workflows?: readonly unknown[];
123
124
  }): boolean;
125
+ /**
126
+ * Whether a document uses 0.15 authorization vocabulary — any `AuthorizationPolicyDef`, or
127
+ * any `authorizationPolicy` / `startPolicy` / `instanceAccessPolicy` reference on an action,
128
+ * query or workflow. Any of it present requires `axiom.server.v9`; a document with none is
129
+ * byte-identical to the v1–v8 document it always was (spec15 §70). Total over a tampered IR.
130
+ */
131
+ export declare function usesAuthorizationVocabulary(ir: {
132
+ authorizationPolicies?: readonly unknown[];
133
+ actions?: Record<string, {
134
+ authorizationPolicy?: unknown;
135
+ }>;
136
+ queries?: readonly {
137
+ authorizationPolicy?: unknown;
138
+ }[];
139
+ workflows?: readonly {
140
+ startPolicy?: unknown;
141
+ instanceAccessPolicy?: unknown;
142
+ }[];
143
+ }): boolean;
144
+ /**
145
+ * Authorization vocabulary whose *enforcement* has not shipped yet — the admission gate a
146
+ * build uses to fail closed rather than run a declared policy as a silent no-op (spec4 §4,
147
+ * spec15 §128). spec15 landed enforcement in phases: C — `ActionDef.authorizationPolicy`
148
+ * (and every state / provider-record mutation it drives); D — `QueryDef.authorizationPolicy`
149
+ * (`query.read`, one-shot / live-open / query-operation); E — `WorkflowDef.startPolicy` /
150
+ * `instanceAccessPolicy` (`workflow.start` / `.inspect` / `.history` / `.cancel`). Every
151
+ * `AuthorizationPolicyDef` reference the graph vocabulary defines is now enforced, so this
152
+ * predicate is `false` for every valid IR. It is kept as the extension point: a later phase
153
+ * that introduces authorization vocabulary ahead of its enforcement re-populates it.
154
+ */
155
+ export declare function usesUnenforcedAuthorizationVocabulary(_ir: unknown): boolean;
124
156
  /**
125
157
  * Whether a document uses 0.10's semantic data-access vocabulary — a `QueryDef`, a
126
158
  * `RelationshipDef`, a `ReadPolicyDef`, or a `query` operation inside an action. A v5
@@ -149,6 +181,7 @@ export declare function serverIRExpressions(ir: {
149
181
  storages?: readonly StorageDef[];
150
182
  queries?: readonly QueryDef[];
151
183
  readPolicies?: readonly ReadPolicyDef[];
184
+ authorizationPolicies?: readonly AuthorizationPolicyDef[];
152
185
  migrations?: readonly MigrationDef[];
153
186
  workflows?: readonly WorkflowDef[];
154
187
  }): Expression[];
@@ -243,6 +276,14 @@ export interface ServerIR {
243
276
  * cannot satisfy one by claiming to.
244
277
  */
245
278
  readPolicies?: ReadPolicyDef[];
279
+ /**
280
+ * Canonical authorization policies (spec15, `axiom.server.v9`). Each is a boolean `allow`
281
+ * expression over the closed scope `PRINCIPAL` / `RESOURCE` / `OPERATION`; a surface points
282
+ * at one by id (`ActionDef.authorizationPolicy`, `QueryDef.authorizationPolicy`,
283
+ * `WorkflowDef.startPolicy` / `instanceAccessPolicy`). Portable plain data — no callback,
284
+ * no host object. Present only in an `axiom.server.v9` document.
285
+ */
286
+ authorizationPolicies?: AuthorizationPolicyDef[];
246
287
  /**
247
288
  * Semantic migrations between consecutive schema versions (spec11 §14). Present only in an
248
289
  * `axiom.server.v7` document. Portable plain data — a closed operation vocabulary and
package/dist/server-ir.js CHANGED
@@ -2,6 +2,7 @@ import { walkExpression } from './expressions.js';
2
2
  import { queryExpressions } from './query.js';
3
3
  import { migrationExpressions } from './migration.js';
4
4
  import { workflowExpressions } from './workflows.js';
5
+ import { authorizationPolicyExpressions } from './authorization.js';
5
6
  /**
6
7
  * The contracts a Server IR may declare. A runtime that does not recognize the value MUST
7
8
  * refuse the IR rather than interpret it partially.
@@ -26,11 +27,12 @@ export const SERVER_IR_CONTRACTS = [
26
27
  'axiom.server.v6',
27
28
  'axiom.server.v7',
28
29
  'axiom.server.v8',
30
+ 'axiom.server.v9',
29
31
  ];
30
32
  /** The oldest contract, and the one a document declares unless it needs more. */
31
33
  export const SERVER_IR_CONTRACT = 'axiom.server.v1';
32
34
  /** The newest contract this implementation produces and executes. */
33
- export const SERVER_IR_LATEST_CONTRACT = 'axiom.server.v8';
35
+ export const SERVER_IR_LATEST_CONTRACT = 'axiom.server.v9';
34
36
  /** Operation kinds no contract before `axiom.server.v5` contains. */
35
37
  export const SERVER_IR_V5_OPERATION_KINDS = [
36
38
  'blob-metadata',
@@ -146,6 +148,49 @@ export function usesWorkflowVocabulary(ir) {
146
148
  // but is not workflow vocabulary); a malformed present value is refused at admission.
147
149
  return Array.isArray(ir.workflows) && ir.workflows.length > 0;
148
150
  }
151
+ /**
152
+ * Whether a document uses 0.15 authorization vocabulary — any `AuthorizationPolicyDef`, or
153
+ * any `authorizationPolicy` / `startPolicy` / `instanceAccessPolicy` reference on an action,
154
+ * query or workflow. Any of it present requires `axiom.server.v9`; a document with none is
155
+ * byte-identical to the v1–v8 document it always was (spec15 §70). Total over a tampered IR.
156
+ */
157
+ export function usesAuthorizationVocabulary(ir) {
158
+ if (Array.isArray(ir.authorizationPolicies) && ir.authorizationPolicies.length > 0)
159
+ return true;
160
+ for (const action of Object.values(ir.actions ?? {})) {
161
+ if (action && typeof action === 'object' && action.authorizationPolicy !== undefined) {
162
+ return true;
163
+ }
164
+ }
165
+ for (const query of Array.isArray(ir.queries) ? ir.queries : []) {
166
+ if (query && typeof query === 'object' && query.authorizationPolicy !== undefined) {
167
+ return true;
168
+ }
169
+ }
170
+ for (const workflow of Array.isArray(ir.workflows) ? ir.workflows : []) {
171
+ if (workflow &&
172
+ typeof workflow === 'object' &&
173
+ (workflow.startPolicy !== undefined ||
174
+ workflow.instanceAccessPolicy !== undefined)) {
175
+ return true;
176
+ }
177
+ }
178
+ return false;
179
+ }
180
+ /**
181
+ * Authorization vocabulary whose *enforcement* has not shipped yet — the admission gate a
182
+ * build uses to fail closed rather than run a declared policy as a silent no-op (spec4 §4,
183
+ * spec15 §128). spec15 landed enforcement in phases: C — `ActionDef.authorizationPolicy`
184
+ * (and every state / provider-record mutation it drives); D — `QueryDef.authorizationPolicy`
185
+ * (`query.read`, one-shot / live-open / query-operation); E — `WorkflowDef.startPolicy` /
186
+ * `instanceAccessPolicy` (`workflow.start` / `.inspect` / `.history` / `.cancel`). Every
187
+ * `AuthorizationPolicyDef` reference the graph vocabulary defines is now enforced, so this
188
+ * predicate is `false` for every valid IR. It is kept as the extension point: a later phase
189
+ * that introduces authorization vocabulary ahead of its enforcement re-populates it.
190
+ */
191
+ export function usesUnenforcedAuthorizationVocabulary(_ir) {
192
+ return false;
193
+ }
149
194
  /**
150
195
  * Whether a document uses 0.10's semantic data-access vocabulary — a `QueryDef`, a
151
196
  * `RelationshipDef`, a `ReadPolicyDef`, or a `query` operation inside an action. A v5
@@ -210,6 +255,10 @@ export function serverIRExpressions(ir) {
210
255
  for (const policy of ir.readPolicies ?? []) {
211
256
  found.push(policy.predicate);
212
257
  }
258
+ const authorizationPolicies = ir.authorizationPolicies;
259
+ for (const policy of Array.isArray(authorizationPolicies) ? authorizationPolicies : []) {
260
+ found.push(...authorizationPolicyExpressions(policy));
261
+ }
213
262
  for (const migration of ir.migrations ?? []) {
214
263
  found.push(...migrationExpressions(migration));
215
264
  }
package/dist/types.d.ts CHANGED
@@ -12,11 +12,12 @@ import type { RelationshipDef } from './relationships.js';
12
12
  import type { ReadPolicyDef } from './read-policy.js';
13
13
  import type { MigrationDef } from './migration.js';
14
14
  import type { WorkflowDef } from './workflows.js';
15
- export type SemanticNodeKind = 'entity' | 'state' | 'action' | 'constraint' | 'transition-constraint' | 'route' | 'expression' | 'integration' | 'integration-operation' | 'event' | 'trigger' | 'subscription' | 'storage' | 'query' | 'relationship' | 'read-policy' | 'migration' | 'workflow';
15
+ import type { AuthorizationPolicyDef } from './authorization.js';
16
+ export type SemanticNodeKind = 'entity' | 'state' | 'action' | 'constraint' | 'transition-constraint' | 'route' | 'expression' | 'integration' | 'integration-operation' | 'event' | 'trigger' | 'subscription' | 'storage' | 'query' | 'relationship' | 'read-policy' | 'migration' | 'workflow' | 'authorization-policy';
16
17
  /** Every semantic node kind, enumerated so tests can walk them. */
17
18
  export declare const SEMANTIC_NODE_KINDS: readonly SemanticNodeKind[];
18
19
  export type NodeKind = SemanticNodeKind | UINodeKind;
19
- export type AnyNode = EntityDef | StateDef | ActionDef | ConstraintDef | TransitionConstraintDef | RouteDef | ExpressionDef | IntegrationDef | IntegrationOperationDef | EventDef | TriggerDef | SubscriptionDef | StorageDef | QueryDef | RelationshipDef | ReadPolicyDef | MigrationDef | WorkflowDef | UINode;
20
+ export type AnyNode = EntityDef | StateDef | ActionDef | ConstraintDef | TransitionConstraintDef | RouteDef | ExpressionDef | IntegrationDef | IntegrationOperationDef | EventDef | TriggerDef | SubscriptionDef | StorageDef | QueryDef | RelationshipDef | ReadPolicyDef | MigrationDef | WorkflowDef | AuthorizationPolicyDef | UINode;
20
21
  export type NodeOfKind<K extends NodeKind> = Extract<AnyNode, {
21
22
  kind: K;
22
23
  }>;
package/dist/types.js CHANGED
@@ -18,4 +18,5 @@ export const SEMANTIC_NODE_KINDS = [
18
18
  'read-policy',
19
19
  'migration',
20
20
  'workflow',
21
+ 'authorization-policy',
21
22
  ];
package/dist/validate.js CHANGED
@@ -6,6 +6,7 @@ import { BLOB_REF_FIELDS } from './storage.js';
6
6
  import { queryPaginationStrategy, sortKeyDirection } from './query.js';
7
7
  import { queryStateReferences } from './live-query.js';
8
8
  import { WORKFLOW_EVENT_SCOPE, WORKFLOW_PRINCIPAL_SCOPE, WORKFLOW_STEP_TYPES, workflowHasCycle, workflowReachableSteps, workflowStepById, workflowStepExpressions, workflowStepSuccessors, } from './workflows.js';
9
+ import { authorizationPolicyProblems } from './authorization.js';
9
10
  import { relationshipIsToOne } from './relationships.js';
10
11
  import { validateMigrations } from './validate-migration.js';
11
12
  import { collectionType, entityType } from './type-ref.js';
@@ -176,6 +177,9 @@ function validateNode(node, context) {
176
177
  case 'workflow':
177
178
  validateWorkflow(node, context);
178
179
  return;
180
+ case 'authorization-policy':
181
+ validateAuthorizationPolicyNode(node, context);
182
+ return;
179
183
  case 'migration':
180
184
  // A `MigrationDef` is validated by the graph-level `validateMigrations` pass, which
181
185
  // needs every migration and `graph.schemaVersion` at once — chain contiguity and
@@ -265,6 +269,7 @@ function validateAction(action, context) {
265
269
  if (action.authorization) {
266
270
  validateExpression(action.authorization, action.id, context, new Set());
267
271
  }
272
+ requireAuthorizationPolicy(action.authorizationPolicy, action.id, context);
268
273
  if (action.invocation?.allowedSources && action.invocation.allowedSources.length === 0) {
269
274
  context.errors.push({
270
275
  code: VALIDATION_CODES.invalidInvocationSource,
@@ -836,6 +841,35 @@ function validateStorage(storage, context) {
836
841
  * its row scope does not collide. Whether the predicate may read `PRINCIPAL` is the
837
842
  * authority boundary's job, checked in `validate-authority.ts`.
838
843
  */
844
+ /**
845
+ * spec15 — an `AuthorizationPolicyDef`. Total over any input: a malformed policy produces a
846
+ * structured `AUTHORIZATION_*` diagnostic, never a native exception (spec15 §36, §37).
847
+ * `authorizationPolicyProblems` (core) covers structure + closed scope + determinism; the
848
+ * cross-node ref check (does an `authorizationPolicy` id point here) is on the referencing
849
+ * node's validator.
850
+ */
851
+ function validateAuthorizationPolicyNode(policy, context) {
852
+ for (const problem of authorizationPolicyProblems(policy)) {
853
+ context.errors.push({
854
+ code: problem.code,
855
+ message: problem.message,
856
+ nodeId: policy?.id,
857
+ });
858
+ }
859
+ }
860
+ /** Resolve an `authorizationPolicy` / `startPolicy` / `instanceAccessPolicy` id, if present. */
861
+ function requireAuthorizationPolicy(id, ownerId, context) {
862
+ if (id === undefined || id === null)
863
+ return;
864
+ const node = typeof id === 'string' ? context.nodes.get(id) : undefined;
865
+ if (!node || node.kind !== 'authorization-policy') {
866
+ context.errors.push({
867
+ code: VALIDATION_CODES.authorizationUnknownPolicy,
868
+ message: `${ownerId} references authorization policy ${String(id)}, which is not an authorization-policy node`,
869
+ nodeId: ownerId,
870
+ });
871
+ }
872
+ }
839
873
  function validateReadPolicy(policy, context) {
840
874
  requireKind(policy.entityId, 'entity', policy.id, context, VALIDATION_CODES.unknownQueryEntity);
841
875
  const entity = context.nodes.get(policy.entityId);
@@ -903,6 +937,8 @@ function policyRowScope(rowScopeId, entityId, ownerId, context, entity) {
903
937
  * diagnostic — never a thrown `TypeError` on a malformed step.
904
938
  */
905
939
  function validateWorkflow(workflow, context) {
940
+ requireAuthorizationPolicy(workflow.startPolicy, workflow.id, context);
941
+ requireAuthorizationPolicy(workflow.instanceAccessPolicy, workflow.id, context);
906
942
  const steps = Array.isArray(workflow.steps) ? workflow.steps : [];
907
943
  const stepIds = new Set();
908
944
  for (const step of steps) {
@@ -1217,6 +1253,7 @@ function fieldTypeOf(fieldId, context) {
1217
1253
  */
1218
1254
  function validateQuery(query, context) {
1219
1255
  requireKind(query.source, 'entity', query.id, context, VALIDATION_CODES.unknownQueryEntity);
1256
+ requireAuthorizationPolicy(query.authorizationPolicy, query.id, context);
1220
1257
  const source = context.nodes.get(query.source);
1221
1258
  const sourceEntity = source?.kind === 'entity' ? source : undefined;
1222
1259
  // The base scope every query expression is evaluated in: one source row, plus the typed
@@ -108,6 +108,21 @@ export interface WorkflowDef extends NodeBase {
108
108
  bindings?: WorkflowBinding[];
109
109
  entry: NodeId;
110
110
  steps: WorkflowStep[];
111
+ /**
112
+ * The `AuthorizationPolicyDef` id governing `workflow.start` (spec15 §100). Ability to
113
+ * discover a `WorkflowDef` is not ability to start it. Absent ⇒ any principal may start
114
+ * it (its pre-0.15 contract), separate from each action step's own authorization, which
115
+ * is always re-evaluated (spec15 §10, §101).
116
+ */
117
+ startPolicy?: NodeId;
118
+ /**
119
+ * The `AuthorizationPolicyDef` id governing `workflow.inspect` / `workflow.history` /
120
+ * `workflow.cancel` on a *running instance* (spec15 §13-§15). Absent ⇒ the 0.14
121
+ * owner-fingerprint rule (the caller's principal fingerprint must equal the instance's).
122
+ * A policy may broaden this, but only explicitly — a role like `admin` never bypasses
123
+ * owner-only unless the policy says so (spec15 §14, §74).
124
+ */
125
+ instanceAccessPolicy?: NodeId;
111
126
  }
112
127
  /**
113
128
  * Whether a value is a structurally recognizable workflow step — an object with an `id` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-core",
3
- "version": "0.14.0-alpha.5",
3
+ "version": "0.15.0-alpha.2",
4
4
  "description": "Application Graph, semantic types, locations and validation for Axiom.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",