@cynodia/axiom-core 0.8.2-alpha.1 → 0.9.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.
@@ -45,6 +45,8 @@ export declare function authorityContext(nodes: readonly AnyNode[], principalEnt
45
45
  export declare function statesReadBy(expressions: readonly Expression[], context: AuthorityContext): Set<NodeId>;
46
46
  /** Whether an action calls out to an integration anywhere in its top-level operations. */
47
47
  export declare function actionUsesIntegration(action: ActionDef): boolean;
48
+ /** Whether an action reaches an object store anywhere in its top-level operations. */
49
+ export declare function actionUsesStorage(action: ActionDef): boolean;
48
50
  /** The states an action writes, following `for-each`, `invoke` and declared native effects. */
49
51
  export declare function statesWrittenBy(action: ActionDef, context: AuthorityContext, visited?: Set<NodeId>): Set<NodeId>;
50
52
  /** The states an action reads: its guards, its values, its selectors, its authorization. */
package/dist/authority.js CHANGED
@@ -121,6 +121,11 @@ function operationExpressions(operation) {
121
121
  found.push(operation.idempotencyKey);
122
122
  }
123
123
  break;
124
+ case 'blob-metadata':
125
+ case 'blob-commit':
126
+ case 'blob-delete':
127
+ found.push(operation.blobKey);
128
+ break;
124
129
  default:
125
130
  }
126
131
  return found;
@@ -129,6 +134,12 @@ function operationExpressions(operation) {
129
134
  export function actionUsesIntegration(action) {
130
135
  return (action.operations ?? []).some((operation) => operation.kind === 'integration-query' || operation.kind === 'integration-effect');
131
136
  }
137
+ /** Whether an action reaches an object store anywhere in its top-level operations. */
138
+ export function actionUsesStorage(action) {
139
+ return (action.operations ?? []).some((operation) => operation.kind === 'blob-metadata' ||
140
+ operation.kind === 'blob-commit' ||
141
+ operation.kind === 'blob-delete');
142
+ }
132
143
  /** The states an action writes, following `for-each`, `invoke` and declared native effects. */
133
144
  export function statesWrittenBy(action, context, visited = new Set()) {
134
145
  const found = new Set();
@@ -167,9 +178,12 @@ export function statesWrittenBy(action, context, visited = new Set()) {
167
178
  break;
168
179
  case 'integration-query':
169
180
  case 'integration-effect':
170
- // Neither writes Axiom state directly: a query's result is a transaction-local
171
- // scope binding, and an effect's outcome reaches state only through a follow-up
172
- // action invoked from its success/failure event.
181
+ case 'blob-metadata':
182
+ case 'blob-commit':
183
+ case 'blob-delete':
184
+ // None of these writes Axiom state directly: a query's (or a metadata lookup's)
185
+ // result is a transaction-local scope binding, and an effect's outcome reaches
186
+ // state only through a follow-up action invoked from its success/failure event.
173
187
  break;
174
188
  default:
175
189
  }
@@ -213,10 +227,10 @@ export function statesReadByAction(action, context, visited = new Set()) {
213
227
  * cannot disagree with what the action actually does.
214
228
  */
215
229
  export function actionAuthority(action, context) {
216
- // Integrations default server-only (spec §65: secrets, trust, CORS, auditability,
217
- // deterministic authority), so an action that calls one is unconditionally server —
218
- // independent of what it writes.
219
- if (actionUsesIntegration(action)) {
230
+ // Integrations and object stores are both server-only by default (spec §65: secrets,
231
+ // trust, CORS, auditability, deterministic authority), so an action that reaches either
232
+ // is unconditionally server — independent of what it writes.
233
+ if (actionUsesIntegration(action) || actionUsesStorage(action)) {
220
234
  return 'server';
221
235
  }
222
236
  for (const stateId of statesWrittenBy(action, context)) {
@@ -161,6 +161,22 @@ export function deriveEdges(nodes) {
161
161
  reads(node.id, node.enabledWhen, rootScope);
162
162
  }
163
163
  break;
164
+ case 'subscription':
165
+ link(node.id, node.integrationId, 'references');
166
+ link(node.id, node.eventId, 'references');
167
+ for (const argument of Object.values(node.arguments ?? {})) {
168
+ reads(node.id, argument, rootScope);
169
+ }
170
+ break;
171
+ case 'storage':
172
+ link(node.id, node.blobEntityId, 'references');
173
+ if (node.readAuthorization) {
174
+ reads(node.id, node.readAuthorization, new Map([...rootScope, [node.id, []]]));
175
+ }
176
+ if (node.uploadAuthorization) {
177
+ reads(node.id, node.uploadAuthorization, rootScope);
178
+ }
179
+ break;
164
180
  case 'integration':
165
181
  case 'event':
166
182
  break;
@@ -457,6 +473,21 @@ function linkOperations(actionId, operations, linker, scope) {
457
473
  linker.link(actionId, operation.failedEventId, 'references');
458
474
  }
459
475
  break;
476
+ case 'blob-metadata':
477
+ linker.link(actionId, operation.storageId, 'references');
478
+ linker.reads(actionId, operation.blobKey, scope);
479
+ break;
480
+ case 'blob-commit':
481
+ case 'blob-delete':
482
+ linker.link(actionId, operation.storageId, 'references');
483
+ linker.reads(actionId, operation.blobKey, scope);
484
+ if (operation.succeededEventId) {
485
+ linker.link(actionId, operation.succeededEventId, 'references');
486
+ }
487
+ if (operation.failedEventId) {
488
+ linker.link(actionId, operation.failedEventId, 'references');
489
+ }
490
+ break;
460
491
  default:
461
492
  }
462
493
  }
@@ -107,5 +107,17 @@ export declare const VALIDATION_CODES: {
107
107
  readonly invalidInvocationSource: "INVALID_INVOCATION_SOURCE";
108
108
  /** A client-authority trigger of a kind the intended trigger runtime does not execute — it would validate and compile but never fire (spec 8.1 §31-36). */
109
109
  readonly clientTriggerUnsupported: "CLIENT_TRIGGER_UNSUPPORTED";
110
+ /** A `SubscriptionDef` whose event has no trigger bound to it: a live source feeding nothing. */
111
+ readonly subscriptionEventUnreachable: "SUBSCRIPTION_EVENT_UNREACHABLE";
112
+ /** A `SubscriptionDef` in a graph with no server authority — nothing would ever activate it. */
113
+ readonly subscriptionWithoutAuthority: "SUBSCRIPTION_WITHOUT_AUTHORITY";
114
+ /** A delivery or lifecycle policy that cannot be executed as written — a non-positive queue, a dedup field that is not on the payload entity. */
115
+ readonly subscriptionInvalidPolicy: "SUBSCRIPTION_INVALID_POLICY";
116
+ /** A `StorageDef.blobEntityId`, or a blob operation's `storageId`, that does not resolve. */
117
+ readonly unknownStorage: "UNKNOWN_STORAGE";
118
+ /** A `StorageDef` whose `blobEntityId` is not the canonical `blobRefEntity()` shape. */
119
+ readonly invalidBlobEntity: "INVALID_BLOB_ENTITY";
120
+ /** A blob operation that cannot execute as written — inside a `for-each`, or with no readable key. */
121
+ readonly invalidBlobOperation: "INVALID_BLOB_OPERATION";
110
122
  };
111
123
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -96,4 +96,17 @@ export const VALIDATION_CODES = {
96
96
  invalidInvocationSource: 'INVALID_INVOCATION_SOURCE',
97
97
  /** A client-authority trigger of a kind the intended trigger runtime does not execute — it would validate and compile but never fire (spec 8.1 §31-36). */
98
98
  clientTriggerUnsupported: 'CLIENT_TRIGGER_UNSUPPORTED',
99
+ // Subscriptions and blob storage (0.9).
100
+ /** A `SubscriptionDef` whose event has no trigger bound to it: a live source feeding nothing. */
101
+ subscriptionEventUnreachable: 'SUBSCRIPTION_EVENT_UNREACHABLE',
102
+ /** A `SubscriptionDef` in a graph with no server authority — nothing would ever activate it. */
103
+ subscriptionWithoutAuthority: 'SUBSCRIPTION_WITHOUT_AUTHORITY',
104
+ /** A delivery or lifecycle policy that cannot be executed as written — a non-positive queue, a dedup field that is not on the payload entity. */
105
+ subscriptionInvalidPolicy: 'SUBSCRIPTION_INVALID_POLICY',
106
+ /** A `StorageDef.blobEntityId`, or a blob operation's `storageId`, that does not resolve. */
107
+ unknownStorage: 'UNKNOWN_STORAGE',
108
+ /** A `StorageDef` whose `blobEntityId` is not the canonical `blobRefEntity()` shape. */
109
+ invalidBlobEntity: 'INVALID_BLOB_ENTITY',
110
+ /** A blob operation that cannot execute as written — inside a `for-each`, or with no readable key. */
111
+ invalidBlobOperation: 'INVALID_BLOB_OPERATION',
99
112
  };
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.8.2') {
26
+ constructor(id, name, version = '0.9.0') {
27
27
  this.data = { id, name, version, nodes: {}, edges: {} };
28
28
  }
29
29
  get id() {
package/dist/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export * from './integrations.js';
11
11
  export * from './effect-outcome.js';
12
12
  export * from './events.js';
13
13
  export * from './triggers.js';
14
+ export * from './subscriptions.js';
15
+ export * from './storage.js';
14
16
  export * from './renderer-capabilities.js';
15
17
  export * from './trigger-capabilities.js';
16
18
  export * from './authoring-metadata.js';
package/dist/index.js CHANGED
@@ -11,6 +11,8 @@ export * from './integrations.js';
11
11
  export * from './effect-outcome.js';
12
12
  export * from './events.js';
13
13
  export * from './triggers.js';
14
+ export * from './subscriptions.js';
15
+ export * from './storage.js';
14
16
  export * from './renderer-capabilities.js';
15
17
  export * from './trigger-capabilities.js';
16
18
  export * from './authoring-metadata.js';
package/dist/nodes.d.ts CHANGED
@@ -245,10 +245,12 @@ export declare function allowedInvocationSources(action: ActionDef): readonly In
245
245
  export declare function isClientInvocable(action: ActionDef): boolean;
246
246
  /** Whether this action accepts only trigger-, event- or effect-outcome-originated calls. */
247
247
  export declare function isSystemOnlyAction(action: ActionDef): boolean;
248
- export type Operation = SetOperation | InsertOperation | RemoveOperation | ForEachOperation | InvokeOperation | NavigateOperation | NativeOperation | IntegrationQueryOperation | IntegrationEffectOperation;
248
+ export type Operation = SetOperation | InsertOperation | RemoveOperation | ForEachOperation | InvokeOperation | NavigateOperation | NativeOperation | IntegrationQueryOperation | IntegrationEffectOperation | BlobMetadataOperation | BlobCommitOperation | BlobDeleteOperation;
249
249
  export type OperationKind = Operation['kind'];
250
250
  /** Every operation kind the runtime is required to execute. */
251
251
  export declare const OPERATION_KINDS: readonly OperationKind[];
252
+ /** The storage operations, which address a `StorageDef` rather than an integration. */
253
+ export declare const BLOB_OPERATION_KINDS: readonly OperationKind[];
252
254
  /** Every mutation is a set, an insert or a remove against an addressed Location. */
253
255
  export type MutationOperation = SetOperation | InsertOperation | RemoveOperation;
254
256
  /**
@@ -357,6 +359,55 @@ export interface IntegrationEffectOperation {
357
359
  succeededEventId?: NodeId;
358
360
  failedEventId?: NodeId;
359
361
  }
362
+ /**
363
+ * Reads a stored object's metadata and binds it into scope, exactly as an
364
+ * `integration-query` binds a query result.
365
+ *
366
+ * It is query-like because it is: a finite question with a finite answer, resolved before
367
+ * the transaction opens, whose result may inform the mutations that follow. It returns the
368
+ * `BlobRef` — key, media type, size, filename, checksum — and never the bytes. A key that
369
+ * names nothing, or names a `staged` object, fails the invocation rather than binding a
370
+ * plausible-looking empty record. Never legal inside `for-each`.
371
+ */
372
+ export interface BlobMetadataOperation {
373
+ kind: 'blob-metadata';
374
+ storageId: NodeId;
375
+ /** The opaque key. Usually `field(ref(…), BLOB_KEY_FIELD)` of a stored `BlobRef`. */
376
+ blobKey: Expression;
377
+ bindAs: NodeId;
378
+ }
379
+ /**
380
+ * Promotes a staged upload to a stored object, post-commit.
381
+ *
382
+ * It is effect-like for the reason every effect is: external object storage cannot join an
383
+ * Axiom transaction, so the promotion is recorded as intent, committed atomically with the
384
+ * state that references the object, and dispatched only once that state is durable. A
385
+ * transaction that rolls back dispatches nothing and leaves the upload `staged`, where the
386
+ * host's sweep reclaims it. Never legal inside `for-each`.
387
+ */
388
+ export interface BlobCommitOperation {
389
+ kind: 'blob-commit';
390
+ storageId: NodeId;
391
+ blobKey: Expression;
392
+ succeededEventId?: NodeId;
393
+ failedEventId?: NodeId;
394
+ }
395
+ /**
396
+ * Removes a stored object, post-commit.
397
+ *
398
+ * The inverse asymmetry to `blob-commit`, and just as deliberate: the authoritative state
399
+ * that stopped referencing the object is committed first, and the external deletion follows.
400
+ * If the deletion fails, state is still correct and the orphan is visible in
401
+ * `AxiomServer.blobLog()` — state correctness and external cleanup stay separately
402
+ * observable rather than being falsely coupled. Never legal inside `for-each`.
403
+ */
404
+ export interface BlobDeleteOperation {
405
+ kind: 'blob-delete';
406
+ storageId: NodeId;
407
+ blobKey: Expression;
408
+ succeededEventId?: NodeId;
409
+ failedEventId?: NodeId;
410
+ }
360
411
  export type NativeEffect = {
361
412
  kind: 'reads-state';
362
413
  stateId: NodeId;
package/dist/nodes.js CHANGED
@@ -38,6 +38,15 @@ export const OPERATION_KINDS = [
38
38
  'native',
39
39
  'integration-query',
40
40
  'integration-effect',
41
+ 'blob-metadata',
42
+ 'blob-commit',
43
+ 'blob-delete',
44
+ ];
45
+ /** The storage operations, which address a `StorageDef` rather than an integration. */
46
+ export const BLOB_OPERATION_KINDS = [
47
+ 'blob-metadata',
48
+ 'blob-commit',
49
+ 'blob-delete',
41
50
  ];
42
51
  export function isMutationOperation(operation) {
43
52
  return operation.kind === 'set' || operation.kind === 'insert' || operation.kind === 'remove';
@@ -5,6 +5,8 @@ import type { FieldIndexEntry } from './graph.js';
5
5
  import type { EventDef } from './events.js';
6
6
  import type { IntegrationDef, IntegrationOperationDef } from './integrations.js';
7
7
  import type { TriggerDef } from './triggers.js';
8
+ import type { SubscriptionDef } from './subscriptions.js';
9
+ import type { StorageDef } from './storage.js';
8
10
  /**
9
11
  * The contracts a Server IR may declare. A runtime that does not recognize the value MUST
10
12
  * refuse the IR rather than interpret it partially.
@@ -20,12 +22,14 @@ import type { TriggerDef } from './triggers.js';
20
22
  * Every existing application therefore still compiles to a byte-identical
21
23
  * `axiom.server.v1` document, and the frozen conformance fixtures stay frozen.
22
24
  */
23
- export declare const SERVER_IR_CONTRACTS: readonly ["axiom.server.v1", "axiom.server.v2", "axiom.server.v3", "axiom.server.v4"];
25
+ export declare const SERVER_IR_CONTRACTS: readonly ["axiom.server.v1", "axiom.server.v2", "axiom.server.v3", "axiom.server.v4", "axiom.server.v5"];
24
26
  export type ServerIRContract = (typeof SERVER_IR_CONTRACTS)[number];
25
27
  /** The oldest contract, and the one a document declares unless it needs more. */
26
28
  export declare const SERVER_IR_CONTRACT: ServerIRContract;
27
29
  /** The newest contract this implementation produces and executes. */
28
30
  export declare const SERVER_IR_LATEST_CONTRACT: ServerIRContract;
31
+ /** Operation kinds no contract before `axiom.server.v5` contains. */
32
+ export declare const SERVER_IR_V5_OPERATION_KINDS: readonly string[];
29
33
  /** Expression kinds that `axiom.server.v1` does not contain. */
30
34
  export declare const SERVER_IR_V2_EXPRESSION_KINDS: readonly string[];
31
35
  /**
@@ -68,6 +72,19 @@ export declare function usesV4Semantics(ir: {
68
72
  actions: Record<string, ActionDef>;
69
73
  integrationOperations?: Record<string, IntegrationOperationDef>;
70
74
  }): boolean;
75
+ /**
76
+ * Whether a document uses 0.9's external-I/O vocabulary — subscriptions, object stores, or
77
+ * any of the three blob operations. A v4 runtime knows none of it: it would start an
78
+ * application with a declared live event source it never activates, or execute an action
79
+ * whose `blob-commit` it silently skips, leaving state referencing an object that stays
80
+ * staged forever. Both are exactly the silent divergence a contract label exists to
81
+ * prevent, so any of it present is enough to require `axiom.server.v5`.
82
+ */
83
+ export declare function usesExternalIOVocabulary(ir: {
84
+ subscriptions?: readonly unknown[];
85
+ storages?: readonly unknown[];
86
+ actions: Record<string, ActionDef>;
87
+ }): boolean;
71
88
  /** The higher of two contracts, ordered by `SERVER_IR_CONTRACTS`. */
72
89
  export declare function maxContract(a: ServerIRContract, b: ServerIRContract): ServerIRContract;
73
90
  /** Every expression a Server IR document contains, in no particular order. */
@@ -78,6 +95,8 @@ export declare function serverIRExpressions(ir: {
78
95
  transitionConstraints: readonly TransitionConstraintDef[];
79
96
  expressionDefs?: Record<NodeId, ExpressionDef>;
80
97
  triggers?: readonly TriggerDef[];
98
+ subscriptions?: readonly SubscriptionDef[];
99
+ storages?: readonly StorageDef[];
81
100
  }): Expression[];
82
101
  /**
83
102
  * The normalized form an authority executes: everything required to decide a mutation, and
@@ -132,5 +151,17 @@ export interface ServerIR {
132
151
  events?: EventDef[];
133
152
  /** Server-authority triggers only — interval, delay, lifecycle and event triggers whose target action executes here. */
134
153
  triggers?: TriggerDef[];
154
+ /**
155
+ * Long-lived external event sources this authority maintains. Absent below
156
+ * `axiom.server.v5`, which has no way to describe one. Names a capability domain and a
157
+ * semantic source, never a broker, a topic, a URL or a socket.
158
+ */
159
+ subscriptions?: SubscriptionDef[];
160
+ /**
161
+ * Object stores this document reads, commits into or deletes from. Carries the
162
+ * authorization rules a host evaluates before serving a byte, and nothing about the
163
+ * provider that holds the bytes.
164
+ */
165
+ storages?: StorageDef[];
135
166
  }
136
167
  //# sourceMappingURL=server-ir.d.ts.map
package/dist/server-ir.js CHANGED
@@ -19,11 +19,18 @@ export const SERVER_IR_CONTRACTS = [
19
19
  'axiom.server.v2',
20
20
  'axiom.server.v3',
21
21
  'axiom.server.v4',
22
+ 'axiom.server.v5',
22
23
  ];
23
24
  /** The oldest contract, and the one a document declares unless it needs more. */
24
25
  export const SERVER_IR_CONTRACT = 'axiom.server.v1';
25
26
  /** The newest contract this implementation produces and executes. */
26
- export const SERVER_IR_LATEST_CONTRACT = 'axiom.server.v4';
27
+ export const SERVER_IR_LATEST_CONTRACT = 'axiom.server.v5';
28
+ /** Operation kinds no contract before `axiom.server.v5` contains. */
29
+ export const SERVER_IR_V5_OPERATION_KINDS = [
30
+ 'blob-metadata',
31
+ 'blob-commit',
32
+ 'blob-delete',
33
+ ];
27
34
  /** Expression kinds that `axiom.server.v1` does not contain. */
28
35
  export const SERVER_IR_V2_EXPRESSION_KINDS = ['group', 'expression-ref'];
29
36
  /**
@@ -80,6 +87,20 @@ export function usesV4Semantics(ir) {
80
87
  const usesEffects = Object.values(ir.integrationOperations ?? {}).some((operation) => operation.mode === 'effect');
81
88
  return restrictsInvocation || usesEffects;
82
89
  }
90
+ /**
91
+ * Whether a document uses 0.9's external-I/O vocabulary — subscriptions, object stores, or
92
+ * any of the three blob operations. A v4 runtime knows none of it: it would start an
93
+ * application with a declared live event source it never activates, or execute an action
94
+ * whose `blob-commit` it silently skips, leaving state referencing an object that stays
95
+ * staged forever. Both are exactly the silent divergence a contract label exists to
96
+ * prevent, so any of it present is enough to require `axiom.server.v5`.
97
+ */
98
+ export function usesExternalIOVocabulary(ir) {
99
+ if ((ir.subscriptions?.length ?? 0) > 0 || (ir.storages?.length ?? 0) > 0) {
100
+ return true;
101
+ }
102
+ return Object.values(ir.actions).some((action) => (action.operations ?? []).some((operation) => SERVER_IR_V5_OPERATION_KINDS.includes(operation.kind)));
103
+ }
83
104
  /** The higher of two contracts, ordered by `SERVER_IR_CONTRACTS`. */
84
105
  export function maxContract(a, b) {
85
106
  return SERVER_IR_CONTRACTS.indexOf(b) > SERVER_IR_CONTRACTS.indexOf(a) ? b : a;
@@ -110,6 +131,17 @@ export function serverIRExpressions(ir) {
110
131
  found.push(trigger.enabledWhen);
111
132
  }
112
133
  }
134
+ for (const subscription of ir.subscriptions ?? []) {
135
+ found.push(...Object.values(subscription.arguments ?? {}));
136
+ }
137
+ for (const storage of ir.storages ?? []) {
138
+ if (storage.readAuthorization) {
139
+ found.push(storage.readAuthorization);
140
+ }
141
+ if (storage.uploadAuthorization) {
142
+ found.push(storage.uploadAuthorization);
143
+ }
144
+ }
113
145
  return found;
114
146
  }
115
147
  function actionExpressions(action) {
@@ -148,6 +180,11 @@ function actionExpressions(action) {
148
180
  found.push(operation.idempotencyKey);
149
181
  }
150
182
  break;
183
+ case 'blob-metadata':
184
+ case 'blob-commit':
185
+ case 'blob-delete':
186
+ found.push(operation.blobKey);
187
+ break;
151
188
  default:
152
189
  }
153
190
  }
@@ -0,0 +1,101 @@
1
+ import type { Expression } from './expressions.js';
2
+ import type { FieldId, NodeId } from './ids.js';
3
+ import type { RetryPolicy } from './integrations.js';
4
+ import type { EntityDef, NodeBase } from './nodes.js';
5
+ /**
6
+ * Binary application data — an attachment, a document, a photograph, a diagnostic log —
7
+ * given a semantic home.
8
+ *
9
+ * The abstraction is a **stored object addressed by an opaque key**, never a path, an
10
+ * inode or a file descriptor. That is what lets the same graph run against a local
11
+ * directory in development, an S3-like store in production and an in-memory store in a
12
+ * test without a single graph edit, and it is why `readFile(path)` is not, and will not
13
+ * become, graph vocabulary.
14
+ *
15
+ * Bytes never enter the graph, the Server IR or canonical state. What state holds is a
16
+ * `BlobRef`: a small record of key, media type, size and optional filename/checksum.
17
+ * Transfer of the bytes themselves is out of band, through the host's own upload and
18
+ * download transport — which is why an application declares no HTTP route for either.
19
+ */
20
+ /**
21
+ * Reserved field ids of the canonical `BlobRef` record.
22
+ *
23
+ * They follow the `EFFECT_*` convention rather than the `GROUP_*` one: a naming convention
24
+ * plus the builder below, not a runtime-enforced reservation. Field ids are graph-global,
25
+ * so one graph declares **one** blob entity with `blobRefEntity()` and every store and every
26
+ * attachment field references it.
27
+ *
28
+ * `key` is the whole public identity of a stored object. It is opaque: nothing may parse
29
+ * it, and it deliberately reveals no bucket, container, region, account, path or provider —
30
+ * a client that holds one has learned nothing about where the bytes live, and holding one
31
+ * grants no permission (see `StorageDef.readAuthorization`).
32
+ */
33
+ export declare const BLOB_KEY_FIELD: FieldId;
34
+ export declare const BLOB_MEDIA_TYPE_FIELD: FieldId;
35
+ export declare const BLOB_SIZE_FIELD: FieldId;
36
+ export declare const BLOB_FILENAME_FIELD: FieldId;
37
+ export declare const BLOB_CHECKSUM_FIELD: FieldId;
38
+ /** Every field of a canonical `BlobRef`, in declaration order. */
39
+ export declare const BLOB_REF_FIELDS: readonly FieldId[];
40
+ /**
41
+ * The canonical reference-to-a-stored-object entity. Declare it once with
42
+ * `graph.addNode(blobRefEntity(ENTITY_ID))`, name it from every `StorageDef.blobEntityId`,
43
+ * and store it wherever an attachment belongs:
44
+ *
45
+ * ```ts
46
+ * // Document { id, title, attachment: BlobRef }
47
+ * { id: F_DOCUMENT_ATTACHMENT, valueType: optionalType(entityType(ENTITY_BLOB)) }
48
+ * ```
49
+ *
50
+ * `key` is the identity field, so an attachment can be addressed by it the way any other
51
+ * entity instance can. `checksum` is offered and never required: content addressing is a
52
+ * legitimate storage model and a poor universal one, so a store that computes a digest
53
+ * publishes it here and a store that does not simply omits it.
54
+ */
55
+ export declare function blobRefEntity(id: NodeId): EntityDef;
56
+ /**
57
+ * Where a stored object is in its lifecycle.
58
+ *
59
+ * An upload lands `staged`: it exists, it has a key, and nothing references it yet. A
60
+ * `blob-commit` operation promotes it to `stored`. That two-step exists because external
61
+ * object storage does **not** participate in an Axiom transaction and pretending otherwise
62
+ * would be a lie: if the transaction that meant to reference the upload rolls back, the
63
+ * commit never dispatches, the object stays `staged`, and staged objects are swept. The
64
+ * failure mode is a temporary orphan the host can enumerate, not a state referencing bytes
65
+ * that were never stored.
66
+ */
67
+ export type BlobLifecycle = 'staged' | 'stored';
68
+ export declare const BLOB_LIFECYCLES: readonly BlobLifecycle[];
69
+ /**
70
+ * A named object store.
71
+ *
72
+ * It is to blobs what `IntegrationDef` is to external operations: the semantic capability,
73
+ * never the provider. No bucket, endpoint, region, credential or directory appears here or
74
+ * anywhere else in a graph — a `BlobStorageAdapter` supplies all of it.
75
+ *
76
+ * **Authorization is declared, not routed.** `readAuthorization` is evaluated by the host
77
+ * before a single byte is served, with the caller bound to `PRINCIPAL` and the requested
78
+ * `BlobRef` bound to `ref(<this storage's id>)`. That is what makes "possession of a key is
79
+ * not permission" enforceable: a guessed or leaked key still has to satisfy a rule written
80
+ * over authoritative state.
81
+ */
82
+ export interface StorageDef extends NodeBase {
83
+ kind: 'storage';
84
+ /** The entity every `BlobRef` of this store conforms to — built with `blobRefEntity()`. */
85
+ blobEntityId: NodeId;
86
+ /**
87
+ * Who may read this object's bytes or metadata through the transport. Absent means **no
88
+ * one**: a store that declares no rule serves nothing, because the safe default for a
89
+ * missing access rule is refusal, not disclosure.
90
+ */
91
+ readAuthorization?: Expression;
92
+ /** Who may upload into this store. Absent means no one, for the same reason. */
93
+ uploadAuthorization?: Expression;
94
+ /** Media types the upload transport accepts. Absent accepts any. */
95
+ acceptedMediaTypes?: string[];
96
+ /** The largest upload the transport accepts, in bytes. */
97
+ maxSizeBytes?: number;
98
+ /** Retry policy for a failed `blob-commit`/`blob-delete`. Absent means `'none'`. */
99
+ retry?: RetryPolicy;
100
+ }
101
+ //# sourceMappingURL=storage.d.ts.map
@@ -0,0 +1,74 @@
1
+ import { fieldId } from './ids.js';
2
+ import { primitiveType } from './type-ref.js';
3
+ /**
4
+ * Binary application data — an attachment, a document, a photograph, a diagnostic log —
5
+ * given a semantic home.
6
+ *
7
+ * The abstraction is a **stored object addressed by an opaque key**, never a path, an
8
+ * inode or a file descriptor. That is what lets the same graph run against a local
9
+ * directory in development, an S3-like store in production and an in-memory store in a
10
+ * test without a single graph edit, and it is why `readFile(path)` is not, and will not
11
+ * become, graph vocabulary.
12
+ *
13
+ * Bytes never enter the graph, the Server IR or canonical state. What state holds is a
14
+ * `BlobRef`: a small record of key, media type, size and optional filename/checksum.
15
+ * Transfer of the bytes themselves is out of band, through the host's own upload and
16
+ * download transport — which is why an application declares no HTTP route for either.
17
+ */
18
+ /**
19
+ * Reserved field ids of the canonical `BlobRef` record.
20
+ *
21
+ * They follow the `EFFECT_*` convention rather than the `GROUP_*` one: a naming convention
22
+ * plus the builder below, not a runtime-enforced reservation. Field ids are graph-global,
23
+ * so one graph declares **one** blob entity with `blobRefEntity()` and every store and every
24
+ * attachment field references it.
25
+ *
26
+ * `key` is the whole public identity of a stored object. It is opaque: nothing may parse
27
+ * it, and it deliberately reveals no bucket, container, region, account, path or provider —
28
+ * a client that holds one has learned nothing about where the bytes live, and holding one
29
+ * grants no permission (see `StorageDef.readAuthorization`).
30
+ */
31
+ export const BLOB_KEY_FIELD = fieldId('field_blob_key');
32
+ export const BLOB_MEDIA_TYPE_FIELD = fieldId('field_blob_media_type');
33
+ export const BLOB_SIZE_FIELD = fieldId('field_blob_size');
34
+ export const BLOB_FILENAME_FIELD = fieldId('field_blob_filename');
35
+ export const BLOB_CHECKSUM_FIELD = fieldId('field_blob_checksum');
36
+ /** Every field of a canonical `BlobRef`, in declaration order. */
37
+ export const BLOB_REF_FIELDS = [
38
+ BLOB_KEY_FIELD,
39
+ BLOB_MEDIA_TYPE_FIELD,
40
+ BLOB_SIZE_FIELD,
41
+ BLOB_FILENAME_FIELD,
42
+ BLOB_CHECKSUM_FIELD,
43
+ ];
44
+ /**
45
+ * The canonical reference-to-a-stored-object entity. Declare it once with
46
+ * `graph.addNode(blobRefEntity(ENTITY_ID))`, name it from every `StorageDef.blobEntityId`,
47
+ * and store it wherever an attachment belongs:
48
+ *
49
+ * ```ts
50
+ * // Document { id, title, attachment: BlobRef }
51
+ * { id: F_DOCUMENT_ATTACHMENT, valueType: optionalType(entityType(ENTITY_BLOB)) }
52
+ * ```
53
+ *
54
+ * `key` is the identity field, so an attachment can be addressed by it the way any other
55
+ * entity instance can. `checksum` is offered and never required: content addressing is a
56
+ * legitimate storage model and a poor universal one, so a store that computes a digest
57
+ * publishes it here and a store that does not simply omits it.
58
+ */
59
+ export function blobRefEntity(id) {
60
+ return {
61
+ id,
62
+ kind: 'entity',
63
+ name: 'BlobRef',
64
+ identityFieldId: BLOB_KEY_FIELD,
65
+ fields: [
66
+ { id: BLOB_KEY_FIELD, name: 'Key', valueType: primitiveType('string'), required: true },
67
+ { id: BLOB_MEDIA_TYPE_FIELD, name: 'Media type', valueType: primitiveType('string'), required: true },
68
+ { id: BLOB_SIZE_FIELD, name: 'Size', valueType: primitiveType('number'), required: true },
69
+ { id: BLOB_FILENAME_FIELD, name: 'Filename', valueType: primitiveType('string') },
70
+ { id: BLOB_CHECKSUM_FIELD, name: 'Checksum', valueType: primitiveType('string') },
71
+ ],
72
+ };
73
+ }
74
+ export const BLOB_LIFECYCLES = ['staged', 'stored'];
@@ -0,0 +1,173 @@
1
+ import type { Expression } from './expressions.js';
2
+ import type { FieldId, NodeId } from './ids.js';
3
+ import type { RetryPolicy } from './integrations.js';
4
+ import type { NodeBase } from './nodes.js';
5
+ /**
6
+ * The third direction of external interaction.
7
+ *
8
+ * A **query** asks the outside world a question and waits for the answer; an **effect**
9
+ * tells it to do something and does not; a **subscription** is the opposite direction
10
+ * entirely — Axiom declares a standing semantic interest in a long-lived external source,
11
+ * and deliveries arrive while that interest is active.
12
+ *
13
+ * It is a node of its own rather than a third `IntegrationOperationMode`, because a
14
+ * subscription has correctness concerns neither of the other two has: activation, stopping,
15
+ * reconnection, duplicate delivery, ordering, backpressure and a lifecycle state an
16
+ * operator can observe. Folding all of that onto `IntegrationOperationDef` would have made
17
+ * most of its fields meaningless for most of its values.
18
+ *
19
+ * Nothing here names a socket, a topic, a broker, a URL, a file descriptor or a serial
20
+ * port. Those are host configuration, supplied to a `SubscriptionAdapter`; the graph says
21
+ * only *which capability domain*, *which semantic source within it*, and *which `EventDef`
22
+ * a delivery becomes.
23
+ */
24
+ /**
25
+ * What a subscription is doing, as an operator or agent can observe it.
26
+ *
27
+ * The state machine is deliberately small — six states, and only the transitions below:
28
+ *
29
+ * ```
30
+ * inactive ──start──▶ starting ──ok──▶ active ──stop──▶ stopped
31
+ * │ │
32
+ * │ fail │ transport lost
33
+ * ▼ ▼
34
+ * failed ◀──exhausted── reconnecting ──ok──▶ active
35
+ * ```
36
+ *
37
+ * `inactive` is a subscription the graph declares but startup did not activate
38
+ * (`lifecycle.autoStart: false`). `failed` is terminal for this process: the reconnect
39
+ * policy is spent. `stopped` is a deliberate shutdown, and a stopped subscription never
40
+ * delivers again — that is what makes shutdown observable rather than merely likely.
41
+ */
42
+ export type SubscriptionLifecycleState = 'inactive' | 'starting' | 'active' | 'reconnecting' | 'failed' | 'stopped';
43
+ export declare const SUBSCRIPTION_LIFECYCLE_STATES: readonly SubscriptionLifecycleState[];
44
+ /**
45
+ * What happens when deliveries arrive faster than the authority commits the actions they
46
+ * cause. Every mode is explicit and none of them is unbounded buffering.
47
+ *
48
+ * | Policy | Behaviour when the queue is full | Loses events? |
49
+ * | ------ | -------------------------------- | ------------- |
50
+ * | `block` | The adapter's `deliver` call does not resolve until there is room. | No |
51
+ * | `reject` | The delivery is refused; the adapter decides whether to redeliver. | No — the source still holds it |
52
+ * | `drop-oldest` | The oldest queued delivery is discarded to make room. | **Yes** |
53
+ * | `drop-newest` | The arriving delivery is discarded. | **Yes** |
54
+ *
55
+ * `block` is the default, because the default may not silently lose an authoritative
56
+ * event. The two dropping modes are legitimate for genuinely lossy sources — a sensor
57
+ * feed where only the newest reading matters — and both report
58
+ * `SUBSCRIPTION_DELIVERY_DROPPED`, so a discarded event is never silent.
59
+ */
60
+ export type SubscriptionBackpressurePolicy = 'block' | 'reject' | 'drop-oldest' | 'drop-newest';
61
+ export declare const SUBSCRIPTION_BACKPRESSURE_POLICIES: readonly SubscriptionBackpressurePolicy[];
62
+ /** Modes that may discard an accepted delivery. Declaring one is declaring loss. */
63
+ export declare const LOSSY_BACKPRESSURE_POLICIES: readonly SubscriptionBackpressurePolicy[];
64
+ /**
65
+ * What to do with a delivery whose action keeps failing — a poison event.
66
+ *
67
+ * `report` (the default) records the failure, reports `SUBSCRIPTION_DELIVERY_FAILED` and
68
+ * moves on to the next delivery; `pause` additionally stops the subscription, so a source
69
+ * producing payloads this application cannot process does not spin. Neither ever retries
70
+ * without bound: `delivery.maxAttempts` is the ceiling, and it defaults to 1.
71
+ */
72
+ export type SubscriptionFailurePolicy = 'report' | 'pause';
73
+ export declare const SUBSCRIPTION_FAILURE_POLICIES: readonly SubscriptionFailurePolicy[];
74
+ /** Queue depth a subscription gets when it declares none. */
75
+ export declare const DEFAULT_SUBSCRIPTION_QUEUE_LIMIT = 64;
76
+ /** Delivery keys a subscription remembers when it declares no window. */
77
+ export declare const DEFAULT_SUBSCRIPTION_DEDUPLICATION_WINDOW = 512;
78
+ /** Reconnect policy a subscription gets when it declares none. */
79
+ export declare const DEFAULT_SUBSCRIPTION_RECONNECT: RetryPolicy;
80
+ export interface SubscriptionDeliveryPolicy {
81
+ /** Accepted-but-unprocessed deliveries held at once. Must be above zero. */
82
+ maxQueued?: number;
83
+ /** What a full queue does. Defaults to `'block'`, which cannot lose an event. */
84
+ backpressure?: SubscriptionBackpressurePolicy;
85
+ /**
86
+ * The field of the event's payload entity carrying the **external** delivery identity —
87
+ * a broker message id, a webhook delivery id, a sequence number. Two deliveries with the
88
+ * same value are one event: the second is acknowledged and never dispatched.
89
+ *
90
+ * Deliberately a payload field rather than an Axiom transaction id: the provider decides
91
+ * what identifies a delivery, and Axiom cannot invent one that survives a redelivery it
92
+ * did not cause. Absent, no deduplication is performed and the documented at-least-once
93
+ * guarantee applies unchanged.
94
+ */
95
+ deduplicateBy?: FieldId;
96
+ /** How many recent keys are remembered. Deduplication is bounded, never unbounded. */
97
+ deduplicationWindow?: number;
98
+ /** How many times one delivery's action may be attempted. Defaults to 1 — no retry. */
99
+ maxAttempts?: number;
100
+ /** What a delivery that exhausts `maxAttempts` does to the subscription. */
101
+ onFailure?: SubscriptionFailurePolicy;
102
+ }
103
+ export interface SubscriptionLifecyclePolicy {
104
+ /** Whether startup activates it. Defaults to true. */
105
+ autoStart?: boolean;
106
+ /**
107
+ * Whether the application may be considered ready without it.
108
+ *
109
+ * `false` (the default) is spec 0.9 §79's preferred behaviour: a source that cannot be
110
+ * reached leaves the application running and the subscription `failed`/`reconnecting`,
111
+ * because an unreachable feed is not a reason to refuse every request. `true` says this
112
+ * application is not meaningfully running without this source, and `start()` rejects.
113
+ */
114
+ required?: boolean;
115
+ /**
116
+ * Semantic reconnect policy — Axiom's, not the adapter's. The adapter owns transport
117
+ * mechanics (what a reconnect *is* for MQTT versus a WebSocket); Axiom owns how many
118
+ * times and how long apart, so the answer does not change with the provider.
119
+ */
120
+ reconnect?: RetryPolicy;
121
+ }
122
+ /**
123
+ * A long-lived external event source, declared semantically.
124
+ *
125
+ * ```ts
126
+ * graph.addNode<SubscriptionDef>({
127
+ * id: SUBSCRIPTION_DEVICE_STATUS,
128
+ * kind: 'subscription',
129
+ * integrationId: INTEGRATION_DEVICE_PROVIDER,
130
+ * source: 'device-status',
131
+ * eventId: EVENT_DEVICE_STATUS_CHANGED,
132
+ * delivery: { deduplicateBy: F_CHANGE_DELIVERY_ID, maxQueued: 32 },
133
+ * });
134
+ * ```
135
+ *
136
+ * A delivery becomes an `EventDef` payload and enters the existing
137
+ * `EventDef → TriggerDef → ActionDef` pipeline. There is no second event system, no
138
+ * callback and no application-authored handler.
139
+ */
140
+ export interface SubscriptionDef extends NodeBase {
141
+ kind: 'subscription';
142
+ /** The capability domain whose adapter maintains the source. */
143
+ integrationId: NodeId;
144
+ /**
145
+ * Which semantic source within that integration — `'device-status'`, `'inbound-orders'`.
146
+ * It is a name the adapter maps to a topic, a URL, a queue or a device; the graph never
147
+ * learns which. Absent, the subscription's own id is the name.
148
+ */
149
+ source?: string;
150
+ /**
151
+ * Configuration evaluated once, when the subscription activates: a filter, a device set,
152
+ * a starting offset. Expressions, so configuration can follow authoritative state — but
153
+ * evaluated at activation, not per delivery, because a live source is not re-negotiated
154
+ * on every message.
155
+ */
156
+ arguments?: Record<string, Expression>;
157
+ /** The `EventDef` a delivery becomes. Its `payloadType` is what a payload must satisfy. */
158
+ eventId: NodeId;
159
+ lifecycle?: SubscriptionLifecyclePolicy;
160
+ delivery?: SubscriptionDeliveryPolicy;
161
+ }
162
+ export declare function subscriptionSourceName(subscription: SubscriptionDef): string;
163
+ export declare function subscriptionQueueLimit(subscription: SubscriptionDef): number;
164
+ export declare function subscriptionBackpressure(subscription: SubscriptionDef): SubscriptionBackpressurePolicy;
165
+ export declare function subscriptionMaxAttempts(subscription: SubscriptionDef): number;
166
+ export declare function subscriptionFailurePolicy(subscription: SubscriptionDef): SubscriptionFailurePolicy;
167
+ export declare function subscriptionDeduplicationWindow(subscription: SubscriptionDef): number;
168
+ export declare function subscriptionAutoStart(subscription: SubscriptionDef): boolean;
169
+ export declare function subscriptionIsRequired(subscription: SubscriptionDef): boolean;
170
+ export declare function subscriptionReconnectPolicy(subscription: SubscriptionDef): RetryPolicy;
171
+ /** Whether this subscription's declared backpressure policy may discard an event. */
172
+ export declare function subscriptionMayLoseEvents(subscription: SubscriptionDef): boolean;
173
+ //# sourceMappingURL=subscriptions.d.ts.map
@@ -0,0 +1,61 @@
1
+ export const SUBSCRIPTION_LIFECYCLE_STATES = [
2
+ 'inactive',
3
+ 'starting',
4
+ 'active',
5
+ 'reconnecting',
6
+ 'failed',
7
+ 'stopped',
8
+ ];
9
+ export const SUBSCRIPTION_BACKPRESSURE_POLICIES = [
10
+ 'block',
11
+ 'reject',
12
+ 'drop-oldest',
13
+ 'drop-newest',
14
+ ];
15
+ /** Modes that may discard an accepted delivery. Declaring one is declaring loss. */
16
+ export const LOSSY_BACKPRESSURE_POLICIES = [
17
+ 'drop-oldest',
18
+ 'drop-newest',
19
+ ];
20
+ export const SUBSCRIPTION_FAILURE_POLICIES = ['report', 'pause'];
21
+ /** Queue depth a subscription gets when it declares none. */
22
+ export const DEFAULT_SUBSCRIPTION_QUEUE_LIMIT = 64;
23
+ /** Delivery keys a subscription remembers when it declares no window. */
24
+ export const DEFAULT_SUBSCRIPTION_DEDUPLICATION_WINDOW = 512;
25
+ /** Reconnect policy a subscription gets when it declares none. */
26
+ export const DEFAULT_SUBSCRIPTION_RECONNECT = {
27
+ policy: 'exponential',
28
+ maxAttempts: 5,
29
+ delayMs: 1000,
30
+ };
31
+ export function subscriptionSourceName(subscription) {
32
+ return subscription.source ?? String(subscription.id);
33
+ }
34
+ export function subscriptionQueueLimit(subscription) {
35
+ return subscription.delivery?.maxQueued ?? DEFAULT_SUBSCRIPTION_QUEUE_LIMIT;
36
+ }
37
+ export function subscriptionBackpressure(subscription) {
38
+ return subscription.delivery?.backpressure ?? 'block';
39
+ }
40
+ export function subscriptionMaxAttempts(subscription) {
41
+ return Math.max(1, subscription.delivery?.maxAttempts ?? 1);
42
+ }
43
+ export function subscriptionFailurePolicy(subscription) {
44
+ return subscription.delivery?.onFailure ?? 'report';
45
+ }
46
+ export function subscriptionDeduplicationWindow(subscription) {
47
+ return subscription.delivery?.deduplicationWindow ?? DEFAULT_SUBSCRIPTION_DEDUPLICATION_WINDOW;
48
+ }
49
+ export function subscriptionAutoStart(subscription) {
50
+ return subscription.lifecycle?.autoStart !== false;
51
+ }
52
+ export function subscriptionIsRequired(subscription) {
53
+ return subscription.lifecycle?.required === true;
54
+ }
55
+ export function subscriptionReconnectPolicy(subscription) {
56
+ return subscription.lifecycle?.reconnect ?? DEFAULT_SUBSCRIPTION_RECONNECT;
57
+ }
58
+ /** Whether this subscription's declared backpressure policy may discard an event. */
59
+ export function subscriptionMayLoseEvents(subscription) {
60
+ return LOSSY_BACKPRESSURE_POLICIES.includes(subscriptionBackpressure(subscription));
61
+ }
package/dist/types.d.ts CHANGED
@@ -5,11 +5,13 @@ import type { ThemeInput } from './theme.js';
5
5
  import type { EventDef } from './events.js';
6
6
  import type { IntegrationDef, IntegrationOperationDef } from './integrations.js';
7
7
  import type { TriggerDef } from './triggers.js';
8
- export type SemanticNodeKind = 'entity' | 'state' | 'action' | 'constraint' | 'transition-constraint' | 'route' | 'expression' | 'integration' | 'integration-operation' | 'event' | 'trigger';
8
+ import type { SubscriptionDef } from './subscriptions.js';
9
+ import type { StorageDef } from './storage.js';
10
+ export type SemanticNodeKind = 'entity' | 'state' | 'action' | 'constraint' | 'transition-constraint' | 'route' | 'expression' | 'integration' | 'integration-operation' | 'event' | 'trigger' | 'subscription' | 'storage';
9
11
  /** Every semantic node kind, enumerated so tests can walk them. */
10
12
  export declare const SEMANTIC_NODE_KINDS: readonly SemanticNodeKind[];
11
13
  export type NodeKind = SemanticNodeKind | UINodeKind;
12
- export type AnyNode = EntityDef | StateDef | ActionDef | ConstraintDef | TransitionConstraintDef | RouteDef | ExpressionDef | IntegrationDef | IntegrationOperationDef | EventDef | TriggerDef | UINode;
14
+ export type AnyNode = EntityDef | StateDef | ActionDef | ConstraintDef | TransitionConstraintDef | RouteDef | ExpressionDef | IntegrationDef | IntegrationOperationDef | EventDef | TriggerDef | SubscriptionDef | StorageDef | UINode;
13
15
  export type NodeOfKind<K extends NodeKind> = Extract<AnyNode, {
14
16
  kind: K;
15
17
  }>;
package/dist/types.js CHANGED
@@ -11,4 +11,6 @@ export const SEMANTIC_NODE_KINDS = [
11
11
  'integration-operation',
12
12
  'event',
13
13
  'trigger',
14
+ 'subscription',
15
+ 'storage',
14
16
  ];
@@ -192,6 +192,34 @@ export function validateAuthority(nodes, principalEntityId, triggerRuntime = ALL
192
192
  });
193
193
  }
194
194
  }
195
+ // A subscription is an authority-side construct with no client half at all: the browser
196
+ // runtime maintains no long-lived external source, and 0.9 deliberately declines to
197
+ // invent a portable client lifecycle for one (spec 0.9 §62-64). Both of the ways a
198
+ // subscription could validate and then do nothing are rejected here rather than
199
+ // discovered as silence at run time.
200
+ const boundEventIds = new Set(nodes
201
+ .filter((node) => node.kind === 'trigger' && node.when.kind === 'event')
202
+ .map((node) => node.when.eventId));
203
+ for (const node of nodes) {
204
+ if (node.kind !== 'subscription') {
205
+ continue;
206
+ }
207
+ if (!hasServerState) {
208
+ errors.push({
209
+ code: VALIDATION_CODES.subscriptionWithoutAuthority,
210
+ message: `Subscription ${node.name ?? node.id} declares an external event source, but this graph has no server-authoritative state, so no authority would ever activate it`,
211
+ nodeId: node.id,
212
+ });
213
+ }
214
+ if (!boundEventIds.has(node.eventId)) {
215
+ errors.push({
216
+ code: VALIDATION_CODES.subscriptionEventUnreachable,
217
+ message: `Subscription ${node.name ?? node.id} delivers ${node.eventId}, which no trigger is bound to, so every delivery would be validated and then discarded`,
218
+ nodeId: node.id,
219
+ details: { eventId: node.eventId },
220
+ });
221
+ }
222
+ }
195
223
  // The principal exists only where an authority evaluates. Reading it anywhere a client
196
224
  // evaluates would be a rule the client could simply not apply.
197
225
  reportPrincipalOnClient(nodes, context, errors);
package/dist/validate.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { AGGREGATE_FUNCTIONS, BUILTIN_FUNCTIONS, expressionDefsIn } from './expressions.js';
2
2
  import { VALIDATION_CODES } from './diagnostics.js';
3
3
  import { EDGE_KINDS, actionGuards, isMutationOperation } from './nodes.js';
4
+ import { SUBSCRIPTION_BACKPRESSURE_POLICIES, subscriptionBackpressure, subscriptionQueueLimit, } from './subscriptions.js';
5
+ import { BLOB_REF_FIELDS } from './storage.js';
4
6
  import { entityType } from './type-ref.js';
5
7
  import { GROUP_ITEMS_FIELD, GROUP_KEY_FIELD, isGroupFieldId } from './group.js';
6
8
  import { isUINode, uiChildIds } from './ui.js';
@@ -146,6 +148,12 @@ function validateNode(node, context) {
146
148
  case 'trigger':
147
149
  validateTrigger(node, context);
148
150
  return;
151
+ case 'subscription':
152
+ validateSubscription(node, context);
153
+ return;
154
+ case 'storage':
155
+ validateStorage(node, context);
156
+ return;
149
157
  default:
150
158
  context.errors.push({
151
159
  code: VALIDATION_CODES.danglingNodeRef,
@@ -404,6 +412,25 @@ function validateOperation(operation, action, context, local) {
404
412
  }
405
413
  return local;
406
414
  }
415
+ case 'blob-metadata': {
416
+ requireKind(operation.storageId, 'storage', action.id, context, VALIDATION_CODES.unknownStorage);
417
+ validateExpression(operation.blobKey, action.id, context, local);
418
+ const storage = context.nodes.get(operation.storageId);
419
+ const resultType = storage?.kind === 'storage' ? entityType(storage.blobEntityId) : undefined;
420
+ return resultScope(local, operation.bindAs, resultType, context, action.id);
421
+ }
422
+ case 'blob-commit':
423
+ case 'blob-delete': {
424
+ requireKind(operation.storageId, 'storage', action.id, context, VALIDATION_CODES.unknownStorage);
425
+ validateExpression(operation.blobKey, action.id, context, local);
426
+ if (operation.succeededEventId) {
427
+ requireKind(operation.succeededEventId, 'event', action.id, context, VALIDATION_CODES.unknownEvent);
428
+ }
429
+ if (operation.failedEventId) {
430
+ requireKind(operation.failedEventId, 'event', action.id, context, VALIDATION_CODES.unknownEvent);
431
+ }
432
+ return local;
433
+ }
407
434
  default:
408
435
  context.errors.push({
409
436
  code: VALIDATION_CODES.danglingNodeRef,
@@ -662,6 +689,91 @@ function validateTrigger(trigger, context) {
662
689
  }
663
690
  }
664
691
  /** Missing required arguments, and arguments the operation declares no parameter for. */
692
+ /**
693
+ * A subscription's own declaration. Whether it can actually reach an action — and whether
694
+ * this graph even has an authority to activate it — is decided in `validate-authority.ts`,
695
+ * with the rest of the authority boundary.
696
+ */
697
+ function validateSubscription(subscription, context) {
698
+ requireKind(subscription.integrationId, 'integration', subscription.id, context, VALIDATION_CODES.unknownIntegration);
699
+ requireKind(subscription.eventId, 'event', subscription.id, context, VALIDATION_CODES.unknownEvent);
700
+ // Configuration is evaluated once, at activation, in the root scope: there is no delivery
701
+ // to read yet, so nothing beyond state can be in scope.
702
+ for (const argument of Object.values(subscription.arguments ?? {})) {
703
+ validateExpression(argument, subscription.id, context, emptyScope());
704
+ }
705
+ if (subscriptionQueueLimit(subscription) < 1) {
706
+ context.errors.push({
707
+ code: VALIDATION_CODES.subscriptionInvalidPolicy,
708
+ message: `Subscription ${subscription.name ?? subscription.id} declares a queue depth below one, which could hold no delivery at all`,
709
+ nodeId: subscription.id,
710
+ details: { maxQueued: subscription.delivery?.maxQueued },
711
+ });
712
+ }
713
+ const backpressure = subscriptionBackpressure(subscription);
714
+ if (!SUBSCRIPTION_BACKPRESSURE_POLICIES.includes(backpressure)) {
715
+ context.errors.push({
716
+ code: VALIDATION_CODES.subscriptionInvalidPolicy,
717
+ message: `Subscription ${subscription.name ?? subscription.id} declares an unknown backpressure policy "${String(backpressure)}"`,
718
+ nodeId: subscription.id,
719
+ details: { known: [...SUBSCRIPTION_BACKPRESSURE_POLICIES] },
720
+ });
721
+ }
722
+ if ((subscription.delivery?.maxAttempts ?? 1) < 1) {
723
+ context.errors.push({
724
+ code: VALIDATION_CODES.subscriptionInvalidPolicy,
725
+ message: `Subscription ${subscription.name ?? subscription.id} declares fewer than one delivery attempt, so no delivery could ever be processed`,
726
+ nodeId: subscription.id,
727
+ });
728
+ }
729
+ // A deduplication key names a field of the payload, so it has to be one. A key that
730
+ // resolved to nothing would silently deduplicate every delivery against `undefined`.
731
+ const deduplicateBy = subscription.delivery?.deduplicateBy;
732
+ if (deduplicateBy !== undefined) {
733
+ const event = context.nodes.get(subscription.eventId);
734
+ const payloadType = event?.kind === 'event' ? event.payloadType : undefined;
735
+ const resolved = payloadType ? resolveKnownType(payloadType) : undefined;
736
+ const entity = resolved?.kind === 'entity' ? context.nodes.get(resolved.entityId) : undefined;
737
+ const declared = entity?.kind === 'entity' && entity.fields.some((field) => field.id === deduplicateBy);
738
+ if (!declared) {
739
+ context.errors.push({
740
+ code: VALIDATION_CODES.subscriptionInvalidPolicy,
741
+ message: `Subscription ${subscription.name ?? subscription.id} deduplicates on ${deduplicateBy}, which is not a field of ${subscription.eventId}'s payload entity`,
742
+ nodeId: subscription.id,
743
+ fieldId: deduplicateBy,
744
+ });
745
+ }
746
+ }
747
+ }
748
+ /** A store's own declaration: a canonical blob entity, and rules written over real state. */
749
+ function validateStorage(storage, context) {
750
+ requireKind(storage.blobEntityId, 'entity', storage.id, context, VALIDATION_CODES.unknownStorage);
751
+ const entity = context.nodes.get(storage.blobEntityId);
752
+ if (entity?.kind === 'entity') {
753
+ const declared = new Set(entity.fields.map((field) => field.id));
754
+ const missing = BLOB_REF_FIELDS.filter((field) => !declared.has(field));
755
+ if (missing.length > 0) {
756
+ context.errors.push({
757
+ code: VALIDATION_CODES.invalidBlobEntity,
758
+ message: `Storage ${storage.name ?? storage.id} names ${storage.blobEntityId} as its BlobRef entity, but it does not declare ${missing.join(', ')}; build it with blobRefEntity()`,
759
+ nodeId: storage.id,
760
+ details: { missing: missing.map(String) },
761
+ });
762
+ }
763
+ }
764
+ // The requested blob is bound to the store's own id, the way an event trigger binds its
765
+ // payload to the trigger's id.
766
+ const scope = emptyScope(new Set([storage.id]));
767
+ if (entity?.kind === 'entity') {
768
+ scope.types.set(storage.id, entityType(storage.blobEntityId));
769
+ }
770
+ if (storage.readAuthorization) {
771
+ validateExpression(storage.readAuthorization, storage.id, context, scope);
772
+ }
773
+ if (storage.uploadAuthorization) {
774
+ validateExpression(storage.uploadAuthorization, storage.id, context, emptyScope());
775
+ }
776
+ }
665
777
  function checkIntegrationArguments(operation, args, ownerId, context) {
666
778
  const declared = new Set((operation.parameters ?? []).map((parameter) => String(parameter.id)));
667
779
  const missing = (operation.parameters ?? [])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-core",
3
- "version": "0.8.2-alpha.1",
3
+ "version": "0.9.0-alpha.2",
4
4
  "description": "Application Graph, semantic types, locations and validation for Axiom.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",