@cynodia/axiom 0.8.2-alpha.1 → 0.9.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # Actions and transactions
2
2
 
3
- Axiom 0.8.2-alpha.1. An action is behavior expressed as data, executed as a transaction.
3
+ Axiom 0.9.0-alpha.1. An action is behavior expressed as data, executed as a transaction.
4
4
 
5
5
  ```ts
6
6
  {
@@ -210,6 +210,40 @@ like a mutation is. The adapter runs only after the transaction commits, and the
210
210
  this action's caller gets back never waits for it — "committed, effect pending." Never legal
211
211
  inside `for-each`. Full model: [`EFFECTS.md`](EFFECTS.md).
212
212
 
213
+ ### `blob-metadata`
214
+
215
+ ```ts
216
+ { kind: 'blob-metadata', storageId: NodeId, blobKey: Expression, bindAs: NodeId }
217
+ ```
218
+
219
+ Reads a stored object's metadata from a `StorageDef` and binds the `BlobRef` into scope —
220
+ query-like in exactly the sense `integration-query` is, and resolved in the same
221
+ pre-transaction phase. It returns the reference, never the bytes. A key that names nothing,
222
+ or names a still-staged upload, **fails the invocation** rather than binding a plausible
223
+ empty record. Never legal inside `for-each`. Full model: [`STORAGE.md`](STORAGE.md).
224
+
225
+ ### `blob-commit`
226
+
227
+ ```ts
228
+ { kind: 'blob-commit', storageId: NodeId, blobKey: Expression, succeededEventId?: NodeId, failedEventId?: NodeId }
229
+ ```
230
+
231
+ Promotes a staged upload to a stored object. Effect-like, and for the same reason every
232
+ effect is: an object store cannot join an Axiom transaction. Reaching it records intent,
233
+ committed atomically with the state that references the object and dispatched only once that
234
+ state is durable. A rolled-back transaction dispatches nothing and leaves the upload staged.
235
+ Never legal inside `for-each`.
236
+
237
+ ### `blob-delete`
238
+
239
+ ```ts
240
+ { kind: 'blob-delete', storageId: NodeId, blobKey: Expression, succeededEventId?: NodeId, failedEventId?: NodeId }
241
+ ```
242
+
243
+ Removes a stored object, post-commit. The state that stopped referencing it commits first; if
244
+ the external deletion then fails, state is still correct and the orphan is visible in
245
+ `server.blobLog()`. Never legal inside `for-each`.
246
+
213
247
  ## Authorization
214
248
 
215
249
  ```ts
package/docs/AGENT_API.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agent API
2
2
 
3
- Axiom 0.8.2-alpha.1. The machine-facing interface. Agents query semantics and apply
3
+ Axiom 0.9.0-alpha.1. The machine-facing interface. Agents query semantics and apply
4
4
  structural transformations; they never edit generated code.
5
5
 
6
6
  ```ts
@@ -160,6 +160,33 @@ agent.getPersistenceForState(stateId)
160
160
  Authority is derived from what an action writes, so these answers cannot disagree with what
161
161
  the graph does. See [`AUTHORITY.md`](AUTHORITY.md).
162
162
 
163
+ ## External-world queries
164
+
165
+ ```ts
166
+ agent.listIntegrations() / agent.listIntegrationOperations(integrationId?)
167
+ agent.getActionsUsingIntegration(integrationId) / agent.getEffectsForAction(actionId)
168
+ agent.getTriggersForAction(actionId) / agent.getTimedTriggers()
169
+ agent.getActionsTriggeredByEvent(eventId) / agent.getTriggeredEvents()
170
+ agent.getExternalDependencies() // { integrations, operations }
171
+
172
+ agent.listSubscriptions()
173
+ agent.getSubscriptionsForIntegration(integrationId)
174
+ agent.getEventForSubscription(subscriptionId)
175
+ agent.getActionsReachableFromSubscription(subscriptionId)
176
+ agent.getExternalEventSources() // { subscriptions, events, integrations }
177
+
178
+ agent.listStorages()
179
+ agent.getActionsUsingStorage(storageId)
180
+ agent.getStoragesWithoutAccessRules() // stores that serve and accept nothing
181
+ ```
182
+
183
+ All of these are **graph-static**: they answer what the application *can* reach, not what a
184
+ running authority has done. `getActionsReachableFromSubscription` is the one to reach for
185
+ before changing a live feed — it follows the subscription's event through every bound
186
+ trigger, so "what can this feed actually change" needs no traversal by the consumer. Runtime
187
+ answers come from `AxiomServer.subscriptionLog()` and `blobLog()` instead; see
188
+ [`SUBSCRIPTIONS.md`](SUBSCRIPTIONS.md) and [`STORAGE.md`](STORAGE.md).
189
+
163
190
  ## Transactions
164
191
 
165
192
  Every change is staged on a private copy. The graph an agent or a runtime can observe is
@@ -1,6 +1,6 @@
1
1
  # Agent reference
2
2
 
3
- Axiom 0.8.2-alpha.1. Compressed operational contract. Read this plus the `.d.ts`
3
+ Axiom 0.9.0-alpha.1. Compressed operational contract. Read this plus the `.d.ts`
4
4
  declarations before authoring or modifying an Axiom application.
5
5
 
6
6
  Formal guarantees: [`SEMANTIC_CONTRACT.md`](SEMANTIC_CONTRACT.md). Mistakes that compile:
@@ -668,7 +668,8 @@ Portable artifacts, for a runtime written in another language:
668
668
  @cynodia/axiom-server/schema/server-ir.v1.schema.json JSON Schema for axiom.server.v1 (frozen)
669
669
  @cynodia/axiom-server/schema/server-ir.v2.schema.json JSON Schema for axiom.server.v2
670
670
  @cynodia/axiom-server/schema/server-ir.v3.schema.json JSON Schema for axiom.server.v3
671
- @cynodia/axiom-server/schema/server-ir.v4.schema.json JSON Schema for axiom.server.v4 (latest)
671
+ @cynodia/axiom-server/schema/server-ir.v4.schema.json JSON Schema for axiom.server.v4
672
+ @cynodia/axiom-server/schema/server-ir.v5.schema.json JSON Schema for axiom.server.v5 (latest)
672
673
  @cynodia/axiom-server/schema/protocol.v1.schema.json JSON Schema for the protocol
673
674
  ```
674
675
 
@@ -686,6 +687,82 @@ Boundary diagnostics: `UNKNOWN_SERVER_ACTION` `ARGUMENT_TYPE_MISMATCH` `AUTHORIZ
686
687
  `AUTHORITY_UNREACHABLE`, plus `SERVER_STATE_WRITE` and `REMOTE_ACTION_UNAVAILABLE` on the
687
688
  client.
688
689
 
690
+ ## SUBSCRIPTIONS AND STORAGE
691
+
692
+ Full model: [`SUBSCRIPTIONS.md`](SUBSCRIPTIONS.md), [`STORAGE.md`](STORAGE.md). The external
693
+ world reaching *in*, and binary data, which 0.8 had no vocabulary for.
694
+
695
+ The external-interaction model is exactly three directions. Anything else is one of them
696
+ wearing a different name.
697
+
698
+ | Direction | Shape | Vocabulary |
699
+ | --- | --- | --- |
700
+ | Query | Ask; wait for a finite answer. | `integration-query`, `blob-metadata` |
701
+ | Effect | Tell; no answer joins the transaction. | `integration-effect`, `blob-commit`, `blob-delete` |
702
+ | Subscription | The world tells you, while you are listening. | `SubscriptionDef` → `EventDef` |
703
+
704
+ 1. **SUBSCRIPTION INVARIANT** — a long-lived external source is a `SubscriptionDef`, never a client, a socket or a callback in the graph. A delivery becomes an `EventDef` payload and enters the existing `EventDef → TriggerDef → ActionDef` pipeline; there is no second event system.
705
+ 2. **RAW-I/O INVARIANT** — OS I/O primitives are not graph vocabulary. No `readFile(path)`, `openSocket(host, port)`, `exec(command)`, `spawn(process)`, file descriptor, Node stream or POSIX path exists or will. They live inside an adapter, which is exactly what lets a Rust runtime implement the same graph with different primitives.
706
+ 3. **DELIVERY INVARIANT** — at-least-once; effectively-once where `delivery.deduplicateBy` names an external identity, and that deduplication survives a restart when the persistence adapter is durable. Per-subscription ordering is guaranteed; cross-subscription ordering is guaranteed to be **nothing**.
707
+ 4. **BACKPRESSURE INVARIANT** — the queue is always bounded (`maxQueued`, default 64) and the default policy (`block`) cannot lose an event. A policy that may discard one (`drop-oldest`/`drop-newest`) is declared in the graph and reports `SUBSCRIPTION_DELIVERY_DROPPED` every time. Loss is never silent and never a default.
708
+ 5. **SHUTDOWN INVARIANT** — after `server.stop()`, no delivery reaches application state. A stopped subscription answers `stopped` to everything, including deliveries already in flight.
709
+ 6. **BLOB INVARIANT** — bytes never enter the graph, the Server IR or canonical state. A `BlobRef` (`blobRefEntity()` — key, media type, size, filename?, checksum?) is what state holds, and it discloses nothing about the provider.
710
+ 7. **BLOB AUTHORIZATION INVARIANT** — possession of a key is not permission. `StorageDef.readAuthorization` is evaluated with the caller bound to `PRINCIPAL` and the `BlobRef` bound to `ref(<storageId>)`; a store with no rule serves nothing. A key that names nothing is refused identically to one the caller may not read.
711
+ 8. **STAGED-COMMIT INVARIANT** — an object store does not join an Axiom transaction, and nothing pretends it does. An upload lands `staged`; `blob-commit` promotes it post-commit. A refused transaction leaves a sweepable staged object, never a state referencing bytes that were never claimed. A failed `blob-delete` leaves state correct and the orphan visible in `blobLog()`.
712
+
713
+ ```ts
714
+ { kind: 'subscription', id: SUB_STATUS, integrationId: INTEGRATION, source: 'device-status',
715
+ eventId: EVENT_STATUS_CHANGED,
716
+ lifecycle: { autoStart: true, required: false, reconnect: { policy: 'exponential', maxAttempts: 5, delayMs: 1000 } },
717
+ delivery: { maxQueued: 32, backpressure: 'block', deduplicateBy: F_DELIVERY_ID, maxAttempts: 1, onFailure: 'report' } }
718
+
719
+ { kind: 'storage', id: STORAGE_LOGS, blobEntityId: ENTITY_BLOB,
720
+ readAuthorization: <Expression>, uploadAuthorization: <Expression>,
721
+ acceptedMediaTypes: ['text/plain'], maxSizeBytes: 8388608 }
722
+
723
+ { kind: 'blob-metadata', storageId: STORAGE_LOGS, blobKey: <Expression>, bindAs: SCOPE_BLOB }
724
+ { kind: 'blob-commit', storageId: STORAGE_LOGS, blobKey: <Expression> }
725
+ { kind: 'blob-delete', storageId: STORAGE_LOGS, blobKey: <Expression> }
726
+ ```
727
+
728
+ Lifecycle: `inactive → starting → active → reconnecting → failed`, plus `stopped`. Startup
729
+ decides what activates; application code never calls `start()`. A failed source leaves the
730
+ application running unless `lifecycle.required`.
731
+
732
+ Subscription vs. webhook vs. polling — three different things, do not conflate them:
733
+
734
+ | | What it is | Vocabulary |
735
+ | --- | --- | --- |
736
+ | Webhook | Externally initiated finite request; each delivery enters independently. | host `webhooks` → `EventRequest` |
737
+ | Subscription | Standing semantic interest in a long-lived source. | `SubscriptionDef` |
738
+ | Polling | You ask, repeatedly, on a schedule. | `interval` `TriggerDef` → `integration-query` |
739
+
740
+ Upload is `POST /axiom/blob/<storageId>` and download is `GET /axiom/blob/<storageId>/<key>`
741
+ — one host transport for every Axiom application. Application-authored upload/download
742
+ routes: zero.
743
+
744
+ ```ts
745
+ agent.listSubscriptions() / agent.getSubscriptionsForIntegration(id);
746
+ agent.getEventForSubscription(id) / agent.getActionsReachableFromSubscription(id);
747
+ agent.getExternalEventSources(); // { subscriptions, events, integrations }
748
+ agent.listStorages() / agent.getActionsUsingStorage(id) / agent.getStoragesWithoutAccessRules();
749
+
750
+ server.subscriptionLog() / server.subscriptionStatus(id); // state, counters, last delivery, last failure
751
+ server.blobLog(); // storage effects and their outcomes
752
+ server.stageBlob(storageId, principal, upload);
753
+ server.authorizeBlobRead(storageId, key, principal);
754
+ server.authorizeBlobUpload(storageId, principal, { mediaType, size });
755
+ ```
756
+
757
+ Adapters: `SubscriptionAdapter` (`createScriptedSubscriptionAdapter` is the deterministic
758
+ fake) and `BlobStorageAdapter` (`createMemoryBlobStore`). A declared subscription or store
759
+ with no registered adapter fails `start()` rather than staying silently inert.
760
+
761
+ Diagnostics: `SUBSCRIPTION_ADAPTER_MISSING` `SUBSCRIPTION_START_FAILED`
762
+ `SUBSCRIPTION_DELIVERY_DROPPED` `SUBSCRIPTION_DELIVERY_FAILED` `BLOB_STORE_MISSING`
763
+ `BLOB_NOT_FOUND` `BLOB_ACCESS_DENIED` `BLOB_TOO_LARGE` `BLOB_MEDIA_TYPE_REJECTED`
764
+ `BLOB_OPERATION_FAILED` `BLOB_STORAGE_UNAVAILABLE` `BLOB_METADATA_FAILED`.
765
+
689
766
  ## Metadata classes
690
767
 
691
768
  ```ts
@@ -1,6 +1,6 @@
1
1
  # Anti-patterns
2
2
 
3
- Axiom 0.8.2-alpha.1. Each of these compiles. Each is wrong. Each is followed by the correct
3
+ Axiom 0.9.0-alpha.1. Each of these compiles. Each is wrong. Each is followed by the correct
4
4
  alternative.
5
5
 
6
6
  ## 1. Field names as entity runtime keys
@@ -405,3 +405,97 @@ a side effect nothing can roll back. See [`INTEGRATIONS.md`](INTEGRATIONS.md) an
405
405
 
406
406
  // RIGHT — declare `required: true` on the FieldDef and let the renderer mark it.
407
407
  ```
408
+
409
+ ## 32. OS I/O primitives are not graph vocabulary
410
+
411
+ ```ts
412
+ // WRONG — every one of these. None exists, and none will.
413
+ readFile(path); writeFile(path); openSocket(host, port); exec(command); spawn(process);
414
+ openSerialPort(device);
415
+ { kind: 'native', implementationId: 'app.readAttachment', inputs: { path: literal('/var/uploads/x') } }
416
+ ```
417
+
418
+ An `ApplicationGraph` exposes no filesystem path, no socket, no stream, no file descriptor
419
+ and no subprocess. That is not squeamishness about I/O — the Node host uses all of them
420
+ freely. It is that a graph naming one stops being:
421
+
422
+ | | Why |
423
+ | --- | --- |
424
+ | **portable** | A Rust runtime, or a browser, has different primitives — or none. |
425
+ | **analyzable for authority** | "What can this action reach?" has no answer once `exec` is in the vocabulary. |
426
+ | **secure** | A path is a capability the graph hands out; a key checked against a declared rule is not. |
427
+ | **deterministically testable** | A conformance fixture cannot script a real socket. |
428
+ | **introspectable** | `getExternalDependencies()` can enumerate typed operations. It cannot enumerate what a shell command does. |
429
+
430
+ Low-level I/O is permitted, and expected, **inside an adapter**:
431
+
432
+ ```
433
+ PrinterIntegration.print() → adapter implementation → TCP
434
+ VideoIntegration.transcode() → adapter implementation → ffmpeg subprocess
435
+ DeviceStream subscription → adapter implementation → serial port, MQTT, WebSocket
436
+ DiagnosticLogs storage → adapter implementation → local directory, or S3
437
+ ```
438
+
439
+ The graph says *what the interaction means*; the adapter decides *how*. Replace Node with
440
+ Rust, MQTT with a WebSocket, or a local directory with S3, and the graph does not change —
441
+ which is the test that the abstraction is at the right level.
442
+
443
+ Use [`INTEGRATIONS.md`](INTEGRATIONS.md), [`SUBSCRIPTIONS.md`](SUBSCRIPTIONS.md) or
444
+ [`STORAGE.md`](STORAGE.md) instead.
445
+
446
+ ## 33. `setInterval` + `fetch` for polling, or a client in application code
447
+
448
+ ```ts
449
+ // WRONG — all four, in application code.
450
+ setInterval(() => fetch('/api/devices').then(apply), 5000);
451
+ const socket = new WebSocket('wss://…');
452
+ const client = mqtt.connect('mqtt://…');
453
+ socket.onmessage = (event) => applyStatus(JSON.parse(event.data));
454
+ ```
455
+
456
+ Each of these puts scheduling, transport and a callback-driven mutation path into the
457
+ application, where nothing can analyze, test or authorize them.
458
+
459
+ | Instead of | Declare |
460
+ | --- | --- |
461
+ | `setInterval` + `fetch` | `TriggerDef{when:{kind:'interval'}}` → `integration-query` |
462
+ | `new WebSocket` / an MQTT client | `SubscriptionDef` → `EventDef` → `TriggerDef` |
463
+ | `socket.onmessage = handler` | The trigger's target action. Deliveries never invoke a callback. |
464
+ | A hand-rolled webhook route | The host's `webhooks` option → `EventRequest` |
465
+
466
+ ## 34. A client-authored subscription event
467
+
468
+ ```ts
469
+ // WRONG — an action a subscription's trigger invokes, left open to clients.
470
+ { kind: 'action', id: ACTION_APPLY_STATUS, /* no `invocation` */ operations: [ … ] }
471
+ ```
472
+
473
+ Any anonymous client that guesses the id can then assert whatever the live feed asserts.
474
+ Declare `invocation: { allowedSources: ['system'] }`: a client-sourced call is refused with
475
+ `INVOCATION_SOURCE_NOT_ALLOWED` before identity is even consulted.
476
+
477
+ ## 35. base64 bytes in canonical state
478
+
479
+ ```ts
480
+ // WRONG — the attachment's contents in the record, in the Server IR, in every snapshot.
481
+ { id: F_DOCUMENT_ATTACHMENT, valueType: primitiveType('string') } // "data:application/pdf;base64,…"
482
+ ```
483
+
484
+ Every read, every snapshot, every persisted write and every `changes` map then carries the
485
+ whole object. Store a `BlobRef` (`blobRefEntity()`) and let the bytes move through the
486
+ host's own upload and download transport — see [`STORAGE.md`](STORAGE.md). A 5MB attachment
487
+ leaves the record exactly as large as a 5-byte one.
488
+
489
+ ## 36. An application-authored upload or download route
490
+
491
+ ```ts
492
+ // WRONG — a route the graph does not know about, guarding data the graph does own.
493
+ express.post('/upload', (request, response) => { /* … */ });
494
+ express.get('/files/:key', (request, response) => response.sendFile(`/var/uploads/${request.params.key}`));
495
+ ```
496
+
497
+ The second is also a path traversal waiting to happen, and neither can be reached by
498
+ `validateGraph`, by `AgentAPI`, or by a conformance fixture. `POST /axiom/blob/<storageId>`
499
+ and `GET /axiom/blob/<storageId>/<key>` already exist, for every Axiom application, with
500
+ `StorageDef.uploadAuthorization` and `StorageDef.readAuthorization` enforced by the
501
+ authority. Application-authored upload/download routes: zero.
package/docs/AUTHORITY.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Authority
2
2
 
3
- Axiom 0.8.2-alpha.1. How an application crosses the trust boundary.
3
+ Axiom 0.9.0-alpha.1. How an application crosses the trust boundary.
4
4
 
5
5
  Until 0.5.x an Axiom application executed locally. 0.6 adds an **authority**: a generic
6
6
  runtime that owns state, decides mutations and persists them. The same semantic graph
@@ -477,6 +477,16 @@ does for a local failure.
477
477
  | `INTEGRATION_ADAPTER_MISSING` | The Server IR requires an integration with no registered adapter — refused at `start()`, never deferred to first invocation. |
478
478
  | `EVENT_DISPATCH_DEPTH_EXCEEDED` | An event → action → effect → event chain was stopped before it could recurse unboundedly. |
479
479
  | `WEBHOOK_VERIFICATION_FAILED` | A webhook delivery failed provider signature verification and was refused before an event was ever constructed. |
480
+ | `SUBSCRIPTION_ADAPTER_MISSING` | The Server IR declares a subscription whose integration has no registered `SubscriptionAdapter`. Startup refuses, rather than leaving a declared live source permanently inactive. |
481
+ | `SUBSCRIPTION_START_FAILED` | A subscription's source could not be established, after every attempt its declared reconnect policy allows. |
482
+ | `SUBSCRIPTION_DELIVERY_DROPPED` | A delivery was discarded by a declared lossy backpressure policy. Loss is always declared and never silent. |
483
+ | `SUBSCRIPTION_DELIVERY_FAILED` | A delivery's triggered action failed after every permitted attempt. |
484
+ | `BLOB_STORE_MISSING` | The Server IR declares a `StorageDef` with no registered `BlobStorageAdapter`. |
485
+ | `BLOB_NOT_FOUND` | No object with that key, or the key names a still-staged upload. |
486
+ | `BLOB_ACCESS_DENIED` | The caller may not read, download or upload this object. Also the answer for a key that names nothing at all, so the endpoint is not an oracle for enumerating keys. |
487
+ | `BLOB_TOO_LARGE` | An upload exceeded the store's declared `maxSizeBytes`. |
488
+ | `BLOB_MEDIA_TYPE_REJECTED` | An upload's media type is not in the store's declared `acceptedMediaTypes`. |
489
+ | `BLOB_OPERATION_FAILED` | A `blob-commit` or `blob-delete` failed at the store, after its retry policy. |
480
490
  | `INVOCATION_SOURCE_NOT_ALLOWED` | The action's `invocation.allowedSources` does not include this invocation's source (spec 8.1 §3-9) — refused before `authorization` is even evaluated, because no caller reaching the authority this way may invoke the action at all. |
481
491
 
482
492
  Two client-side codes belong to the boundary as well:
@@ -626,8 +636,9 @@ page plus the conformance fixtures.
626
636
  | `axiom.server.v2` | the expression kinds `group` and `expression-ref`, and the `expressionDefs` they resolve against | 0.7.0 |
627
637
  | `axiom.server.v3` | integrations, integration operations, events, triggers, and the `integration-query`/`integration-effect` operation kinds | 0.8.0 |
628
638
  | `axiom.server.v4` | `ActionDef.invocation.allowedSources` invocation-source restriction, and the structured effect-outcome envelope (`effectOutcomeEntity`, `EFFECT_ID_FIELD` and its sibling reserved fields) that every effect dispatch uses from 8.1 onward | 0.8.1 |
639
+ | `axiom.server.v5` | `SubscriptionDef` and `StorageDef`, and the `blob-metadata`/`blob-commit`/`blob-delete` operation kinds — the inbound external-I/O direction and binary object storage | 0.9.0 |
629
640
 
630
- `SERVER_IR_CONTRACTS` enumerates all four, and is the single source of truth this table is
641
+ `SERVER_IR_CONTRACTS` enumerates all five, and is the single source of truth this table is
631
642
  tested against — `packages/demo/test/documentation.test.ts` fails if a contract in
632
643
  `SERVER_IR_CONTRACTS` has no row here, or a row here names a contract the code does not
633
644
  declare (spec 8.2 §7-8). The rules:
@@ -635,11 +646,20 @@ declare (spec 8.2 §7-8). The rules:
635
646
  - **A document declares the oldest contract that can carry it.** `compileToServerIR` computes the label from the vocabulary the document actually uses, so an application that uses nothing from 0.7 or 0.8 produces a byte-identical `axiom.server.v1` document, and the committed v1 conformance fixtures are unchanged. `usesV4Semantics` computes the v4 case specifically: an action's `invocation.allowedSources` genuinely restricting the default two-source set, or any `integration-operation` with `mode: 'effect'` (since every effect dispatch uses the structured v4 envelope) — a document that merely mentions `invocation` without restricting it only needs `axiom.server.v2`, the same tier `group`/`expression-ref` occupy.
636
647
  - **A runtime MUST refuse a contract it does not implement**, and MUST refuse a document whose vocabulary exceeds its declared contract. A v2 runtime executing a v1-labelled document that uses `group` would accept what a conforming v1 runtime elsewhere refuses, and the two would then disagree about the same file. `createAxiomServer` raises rather than executing one — including refusing a document that **understates** its own contract (`understatedContract`).
637
648
  - **A frozen contract gains nothing.** `axiom.server.v1` does not contain `group`, `expression-ref`, `expressionDefs`, an integration, a trigger, an event, the `integration-query`/`integration-effect` operation kinds, `invocation`, or the structured effect-outcome envelope, and `server-ir.v1.schema.json` is byte-frozen. Vocabulary arrives under a new identifier or not at all.
638
- - **`axiom.server.v4` is the latest contract as of 0.8.2** (`SERVER_IR_LATEST_CONTRACT`). 0.8.2 is polish-only — documentation, effect observability timing, fixture coverage and AgentAPI aliasing — and introduces no incompatible IR vocabulary change, so no `axiom.server.v5` was created (spec 8.2 §55-56).
649
+ - **`axiom.server.v5` is the latest contract as of 0.9.0** (`SERVER_IR_LATEST_CONTRACT`). `usesExternalIOVocabulary` computes it from the document: any `subscriptions`, any `storages`, or any of the three blob operation kinds. The reason it is an incompatible change rather than an additive one is exact — a v4 runtime that ignored `subscriptions` would start an application whose declared live event source never activates, and one that ignored a `blob-commit` would leave state referencing an object that stays staged forever. Both are silent divergence, which is what a contract label exists to prevent.
639
650
 
640
651
  There is one JSON Schema per contract, each generated from the runtime's own vocabulary and
641
652
  each shipped: `server-ir.v1.schema.json`, `server-ir.v2.schema.json`, `server-ir.v3.schema.json`,
642
- `server-ir.v4.schema.json`.
653
+ `server-ir.v4.schema.json`, `server-ir.v5.schema.json`.
654
+
655
+ **`SubscriptionDef`, `StorageDef` and the blob operations.** Their normative semantics —
656
+ lifecycle states and transitions, at-least-once delivery, per-subscription ordering and the
657
+ explicit absence of cross-subscription ordering, bounded queues and every backpressure policy,
658
+ deduplication and its restart durability, poison-delivery bounds, staged-then-committed object
659
+ lifecycle, and the authorization rule a store evaluates before serving a byte — are documented
660
+ in [`SUBSCRIPTIONS.md`](SUBSCRIPTIONS.md) and [`STORAGE.md`](STORAGE.md), and are executable in
661
+ the `subscription-*` and `blob-*` conformance fixtures. An independent implementer needs those
662
+ two documents, the v5 schema and those fixtures, and no TypeScript.
643
663
 
644
664
  **`group`.** Partitions a collection: `Collection<A>` → `Collection<Group<K, A>>`. Groups appear
645
665
  in the order their key was **first seen** in the source; members keep source order; two keys are
@@ -1,6 +1,6 @@
1
1
  # Constraints
2
2
 
3
- Axiom 0.8.2-alpha.1. Two constructs, answering different questions. They are not
3
+ Axiom 0.9.0-alpha.1. Two constructs, answering different questions. They are not
4
4
  interchangeable.
5
5
 
6
6
  | | Question | Sees |
package/docs/EFFECTS.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # Effects
2
2
 
3
- Axiom 0.8.2-alpha.1. External effects are not rollback-capable state mutations. This file
3
+ Axiom 0.9.0-alpha.1. External effects are not rollback-capable state mutations. This file
4
4
  is the delivery model; [`AUTHORITY.md`](AUTHORITY.md#external-effects) is the load-bearing
5
5
  statement of why, and [`INTEGRATIONS.md`](INTEGRATIONS.md) is the operation vocabulary this
6
6
  builds on.
7
7
 
8
+ Storage effects — `blob-commit` and `blob-delete` — ride this same outbox rather than a
9
+ second durability system; see [`STORAGE.md`](STORAGE.md).
10
+
8
11
  ## The operation
9
12
 
10
13
  ```ts
package/docs/EVENTS.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # Events
2
2
 
3
- Axiom 0.8.2-alpha.1. An event is a typed fact — something that happened — never work
3
+ Axiom 0.9.0-alpha.1. An event is a typed fact — something that happened — never work
4
4
  itself. [`AUTHORITY.md`](AUTHORITY.md#external-events) is the load-bearing statement;
5
- this file is the vocabulary and the webhook delivery mechanism.
5
+ this file is the vocabulary and the webhook delivery mechanism. A **subscription** is the
6
+ other way an external fact becomes an `EventDef` payload — see
7
+ [`SUBSCRIPTIONS.md`](SUBSCRIPTIONS.md), which also states the distinction between a webhook,
8
+ a subscription and polling.
6
9
 
7
10
  ## The model
8
11
 
@@ -1,6 +1,6 @@
1
1
  # Expressions
2
2
 
3
- Axiom 0.8.2-alpha.1. An expression describes **what value is computed**. It is a tree of
3
+ Axiom 0.9.0-alpha.1. An expression describes **what value is computed**. It is a tree of
4
4
  plain data, never source text and never a callback. Evaluation is pure: an expression MUST
5
5
  NOT change state.
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Graph model
2
2
 
3
- Axiom 0.8.2-alpha.1. The `ApplicationGraph` is the authoritative representation of an
3
+ Axiom 0.9.0-alpha.1. The `ApplicationGraph` is the authoritative representation of an
4
4
  application. Everything else — the IR, the page, the DOM — is derived from it and is never
5
5
  edited.
6
6
 
@@ -1,9 +1,13 @@
1
1
  # Integrations
2
2
 
3
- Axiom 0.8.2-alpha.1. How an application declares and calls an external system, without
3
+ Axiom 0.9.0-alpha.1. How an application declares and calls an external system, without
4
4
  embedding a transport, an SDK or a secret in the graph. The authority boundary this
5
5
  depends on is [`AUTHORITY.md`](AUTHORITY.md#external-systems); this file is the vocabulary.
6
6
 
7
+ This covers the two **outbound** directions — query and effect. The inbound one, a
8
+ long-lived source that delivers to you, is [`SUBSCRIPTIONS.md`](SUBSCRIPTIONS.md); binary
9
+ object storage is [`STORAGE.md`](STORAGE.md).
10
+
7
11
  ## The model
8
12
 
9
13
  ```ts
package/docs/LOCATIONS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Locations
2
2
 
3
- Axiom 0.8.2-alpha.1.
3
+ Axiom 0.9.0-alpha.1.
4
4
 
5
5
  ```text
6
6
  Expression = a value
@@ -1,6 +1,6 @@
1
1
  # Presentation
2
2
 
3
- Axiom 0.8.2-alpha.1. Presentation is **semantic UX intent**, expressed as data on a UI
3
+ Axiom 0.9.0-alpha.1. Presentation is **semantic UX intent**, expressed as data on a UI
4
4
  node. It names roles, tokens and device classes. It never names a colour, a length, a media
5
5
  query or a CSS property.
6
6
 
package/docs/RUNTIME.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Runtime
2
2
 
3
- Axiom 0.8.2-alpha.1. The runtime executes an `ApplicationIR`. It is domain-independent: it
3
+ Axiom 0.9.0-alpha.1. The runtime executes an `ApplicationIR`. It is domain-independent: it
4
4
  contains no knowledge of any application.
5
5
 
6
6
  ## Constructing
@@ -226,6 +226,8 @@ interface RuntimeDiagnostic {
226
226
  | `INTEGRATION_TIMEOUT` | An integration query did not answer within its declared `timeoutMs`. | `integration-query` | `operationId` |
227
227
  | `INTEGRATION_RESULT_INVALID` | A provider's response did not conform to the operation's declared `resultType`. | `integration-query` | `operationId` |
228
228
  | `INTEGRATION_QUERY_FAILED` | An integration query failed for any other reason. Never carries a provider secret. | `integration-query` | `operationId`, `retryable` |
229
+ | `BLOB_STORAGE_UNAVAILABLE` | No host capable of reaching an object store is configured. Only the authoritative runtime ever reaches one. | `blob-metadata` | `storageId` |
230
+ | `BLOB_METADATA_FAILED` | A `blob-metadata` lookup failed: no such key, a still-staged object, or the store refused. The store's own code is in `details.code` rather than replacing the diagnostic code — a provider's vocabulary is not Axiom's. | `blob-metadata` | `storageId`, `code` |
229
231
  | `UI_NODE_MISSING` | A child id that is not a UI node in the IR. | render | — |
230
232
  | `UNSUPPORTED_UI_NODE` | An unknown UI node kind. | render | — |
231
233
  | `PERSISTED_STATE_UNREADABLE` | **Warning.** A stored value could not be parsed; the initial value was used. | startup | — |
@@ -1,6 +1,6 @@
1
1
  # Semantic contract
2
2
 
3
- Axiom 0.8.2-alpha.1. Runtime guarantees, stated formally. This file defines behavior; it
3
+ Axiom 0.9.0-alpha.1. Runtime guarantees, stated formally. This file defines behavior; it
4
4
  does not teach. Where this file and any specification in `../specs/` disagree, this file
5
5
  describes the implementation and is authoritative.
6
6
 
package/docs/STATE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # State
2
2
 
3
- Axiom 0.8.2-alpha.1. A `StateDef` is a named application value: stored, or computed from
3
+ Axiom 0.9.0-alpha.1. A `StateDef` is a named application value: stored, or computed from
4
4
  other state.
5
5
 
6
6
  ```ts
@@ -0,0 +1,253 @@
1
+ # Storage and blobs
2
+
3
+ Axiom 0.9.0-alpha.1. How an application stores, references, serves and deletes binary data —
4
+ an attachment, a document, a photograph, a diagnostic log — with no filesystem path, no
5
+ upload route and no download route anywhere in it.
6
+
7
+ [`SUBSCRIPTIONS.md`](SUBSCRIPTIONS.md) is the other half of 0.9; [`EFFECTS.md`](EFFECTS.md)
8
+ is the outbox machinery this reuses rather than duplicating.
9
+
10
+ ## The abstraction
11
+
12
+ A **stored object addressed by an opaque key**. Never a path, an inode or a descriptor.
13
+
14
+ That is what lets the same graph run against a local directory in development, an S3-like
15
+ store in production and an in-memory store in a test without a single edit — and it is why
16
+ `readFile(path)` is not, and will not become, graph vocabulary. See
17
+ [`ANTI_PATTERNS.md`](ANTI_PATTERNS.md#os-io-primitives-are-not-graph-vocabulary).
18
+
19
+ **Bytes never enter the graph, the Server IR or canonical state.** What state holds is a
20
+ `BlobRef`. Transfer of the bytes is out of band, through the host's own transport.
21
+
22
+ ## BlobRef
23
+
24
+ ```ts
25
+ graph.addNode<EntityDef>(blobRefEntity(ENTITY_BLOB));
26
+ ```
27
+
28
+ An ordinary entity, built once per graph, with reserved field ids:
29
+
30
+ | Field | Type | Required | Meaning |
31
+ | --- | --- | --- | --- |
32
+ | `BLOB_KEY_FIELD` | string | yes | The opaque key. The identity field. |
33
+ | `BLOB_MEDIA_TYPE_FIELD` | string | yes | The object's media type. |
34
+ | `BLOB_SIZE_FIELD` | number | yes | Bytes. |
35
+ | `BLOB_FILENAME_FIELD` | string | no | The name it was uploaded under. |
36
+ | `BLOB_CHECKSUM_FIELD` | string | no | A digest, when the store computes one. |
37
+
38
+ Those five fields are the **whole** public contract. A `BlobRef` crossing to a client carries
39
+ no bucket, container, region, account, path, provider name or lifecycle bookkeeping. Field ids
40
+ are graph-global, so one graph declares one blob entity and every store and every attachment
41
+ field references it.
42
+
43
+ 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
+ A `BlobRef` is ordinary authoritative state: it persists, it survives a restart, it is
51
+ observable, and its identity is stable enough to hold in a record.
52
+
53
+ **Checksums are offered, never required.** Content addressing is a legitimate storage model
54
+ and a poor universal one, so a store that computes a digest publishes it and one that does
55
+ not omits it.
56
+
57
+ ## StorageDef
58
+
59
+ ```ts
60
+ interface StorageDef {
61
+ id: NodeId;
62
+ kind: 'storage';
63
+ blobEntityId: NodeId;
64
+ readAuthorization?: Expression;
65
+ uploadAuthorization?: Expression;
66
+ acceptedMediaTypes?: string[];
67
+ maxSizeBytes?: number;
68
+ retry?: { policy: 'none' | 'fixed' | 'exponential'; maxAttempts?: number; delayMs?: number };
69
+ }
70
+ ```
71
+
72
+ `StorageDef` is to blobs what `IntegrationDef` is to 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. A local filesystem
75
+ adapter is perfectly legitimate; its paths are host configuration.
76
+
77
+ ## Authorization
78
+
79
+ **Possession of a key is not permission.** `readAuthorization` is evaluated by the authority
80
+ before a single byte is served, with the caller bound to `PRINCIPAL` and the requested
81
+ `BlobRef` bound to `ref(<this storage's id>)`:
82
+
83
+ ```ts
84
+ graph.addNode<StorageDef>({
85
+ id: STORAGE_DIAGNOSTICS,
86
+ kind: 'storage',
87
+ blobEntityId: ENTITY_BLOB,
88
+ // Readable only while some device actually references it.
89
+ readAuthorization: some(ref(STATE_DEVICES), SCOPE, binary('eq',
90
+ field(field(ref(SCOPE), F_DEVICE_LOG), BLOB_KEY_FIELD),
91
+ field(ref(STORAGE_DIAGNOSTICS), BLOB_KEY_FIELD))),
92
+ uploadAuthorization: binary('eq', field(ref(PRINCIPAL), F_ROLE), literal('operator')),
93
+ });
94
+ ```
95
+
96
+ MUST: a store with **no** rule serves nothing and accepts nothing. The safe default for a
97
+ missing access rule is refusal, so an author who forgets one gets a closed door.
98
+
99
+ A key that names nothing and a key the caller may not read are answered **identically**
100
+ (`BLOB_ACCESS_DENIED`), deliberately: distinguishing them would turn the endpoint into an
101
+ oracle a hostile client could enumerate keys with.
102
+
103
+ Delete goes through an action, so an action's ordinary `authorization` governs it.
104
+
105
+ ## Operations
106
+
107
+ | Operation | Shape | When it runs |
108
+ | --- | --- | --- |
109
+ | `blob-metadata` | query-like | Resolved **before** the transaction opens, binds the `BlobRef` into scope via `bindAs`. |
110
+ | `blob-commit` | effect-like | Recorded as intent; dispatched **after** the transaction commits. |
111
+ | `blob-delete` | effect-like | The same. |
112
+
113
+ None is legal inside a `for-each`. All three make their action server-authority, exactly as an
114
+ integration operation does.
115
+
116
+ ```ts
117
+ { kind: 'blob-metadata', storageId: STORAGE, blobKey: <Expression>, bindAs: SCOPE }
118
+ { kind: 'blob-commit', storageId: STORAGE, blobKey: <Expression>, succeededEventId?, failedEventId? }
119
+ { kind: 'blob-delete', storageId: STORAGE, blobKey: <Expression>, succeededEventId?, failedEventId? }
120
+ ```
121
+
122
+ `blob-metadata` returns the `BlobRef` and never the bytes. A key that names nothing, or names
123
+ a still-staged object, **fails the invocation** rather than binding a plausible empty record —
124
+ `BLOB_METADATA_FAILED`, with the store's own code in `details.code`.
125
+
126
+ There is no `list`. Add one only when a real application scenario requires it.
127
+
128
+ ## Lifecycle: staged, then committed
129
+
130
+ An object has two lifecycle values:
131
+
132
+ | | Meaning |
133
+ | --- | --- |
134
+ | `staged` | It exists and has a key. **Nothing references it.** |
135
+ | `stored` | A committed transaction claimed it, through `blob-commit`. |
136
+
137
+ This exists because **external object storage does not participate in an Axiom transaction**,
138
+ and pretending otherwise would be a lie. The consequences are stated rather than hidden:
139
+
140
+ **Storage succeeds, state commit fails.** The upload is already staged. The transaction that
141
+ meant to reference it rolls back, so the `blob-commit` intent is discarded with every other
142
+ effect of that transaction and the object stays `staged`. `BlobStorageAdapter.listStaged()`
143
+ enumerates them, so an orphan is a sweepable staged object rather than a committed object
144
+ nothing points at.
145
+
146
+ **State deletion succeeds, blob deletion fails.** The transaction committed: the record no
147
+ longer references the object, and that is correct. The `blob-delete` effect then failed, and
148
+ it is visible in `server.blobLog()` with its status and `lastError`. State correctness and
149
+ external cleanup remain **separately observable** rather than falsely coupled — the alternative
150
+ would be rolling back correct state because a remote store was briefly unreachable.
151
+
152
+ Orphan policy, therefore: **staged upload + commit, plus a host-side sweep of staged objects.**
153
+ Nothing pretends the store joined the transaction.
154
+
155
+ ## Upload
156
+
157
+ ```
158
+ user selects a file
159
+ ↓
160
+ POST /axiom/blob/<storageId> ← the host's transport, identical for every application
161
+ ↓ (uploadAuthorization, acceptedMediaTypes, maxSizeBytes all checked here)
162
+ BlobRef ← staged
163
+ ↓
164
+ action argument
165
+ ↓
166
+ authoritative state + blob-commit
167
+ ```
168
+
169
+ Application-authored upload routes: **zero**. `POST /axiom/blob/<storageId>` is the endpoint
170
+ for every Axiom application there will ever be, exactly as `POST /axiom` is the semantic one.
171
+ The request body is the bytes; `content-type` is the media type; the optional
172
+ `x-axiom-filename` header carries the filename. The response is `{ kind: 'blob', ref }`.
173
+
174
+ Programmatically, the same path is `server.stageBlob(storageId, principal, upload)`.
175
+
176
+ ## Download
177
+
178
+ ```
179
+ GET /axiom/blob/<storageId>/<key>
180
+ ```
181
+
182
+ Application-authored download routes: **zero**. The authority evaluates the store's
183
+ `readAuthorization` against the caller, then the host streams the bytes with the object's
184
+ media type and, when it has one, a `content-disposition` naming the stored filename. The
185
+ opaque key is never offered as a filename.
186
+
187
+ Programmatically: `server.authorizeBlobRead(storageId, key, principal)`.
188
+
189
+ ## Blobs are never base64 in state
190
+
191
+ MUST NOT: encode an object's contents into a `LiteralValue`, a state value, an action
192
+ argument or the Server IR. A 5MB attachment leaves the record it is attached to exactly as
193
+ large as a 5-byte one, because what the record holds is five scalars.
194
+
195
+ ## Adapters
196
+
197
+ ```ts
198
+ interface BlobStorageAdapter {
199
+ stage(upload): Promise<BlobStoreResult<StoredBlob>>;
200
+ commit(key): Promise<BlobStoreResult<StoredBlob>>;
201
+ metadata(key): Promise<BlobStoreResult<StoredBlob>>;
202
+ read(key): Promise<BlobStoreResult<{ blob: StoredBlob; data: Uint8Array }>>;
203
+ delete(key): Promise<BlobStoreResult<null>>;
204
+ listStaged?(olderThanMs?): Promise<StoredBlob[]>;
205
+ }
206
+ ```
207
+
208
+ Registered per `StorageDef.id` on `createAxiomServer({ blobStores })` or
209
+ `serveAxiomApplication({ blobStores })`. A declared store with no adapter fails `start()`.
210
+
211
+ `stage` is the only write path, deliberately: there is no point at which a store could be
212
+ told to write an object that the transaction referencing it might still refuse.
213
+
214
+ `createMemoryBlobStore()` implements the complete lifecycle deterministically, with seeding
215
+ and per-operation failure injection for tests and conformance fixtures.
216
+
217
+ ## Idempotency and retries
218
+
219
+ Storage effects ride the **same** transactional outbox integration effects do: committed
220
+ atomically with the state that references the object, dispatched post-commit, retried under
221
+ `StorageDef.retry`, and reported through `server.blobLog()` (a filtered view of
222
+ `effectLog()`). There is no second durability system.
223
+
224
+ ## Observability
225
+
226
+ ```ts
227
+ server.blobLog(); // EffectRecord[] whose `storage` is set
228
+ ```
229
+
230
+ Each carries `storage.operation` (`'commit'` / `'delete'`), `storage.key`, `status`,
231
+ `attempts` and `lastError`. An uncommitted upload and a failed external deletion are both
232
+ visible here; neither is a silent orphan.
233
+
234
+ ## Contract
235
+
236
+ Storage vocabulary requires `axiom.server.v5`, computed from the document. See
237
+ [`AUTHORITY.md`](AUTHORITY.md#contract-identifiers).
238
+
239
+ ## Diagnostic codes
240
+
241
+ | Code | Meaning |
242
+ | --- | --- |
243
+ | `UNKNOWN_STORAGE` | (validation) A `storageId` or `blobEntityId` that does not resolve. |
244
+ | `INVALID_BLOB_ENTITY` | (validation) The named entity is not the `blobRefEntity()` shape. |
245
+ | `INVALID_BLOB_OPERATION` | (validation) A blob operation that cannot execute as written. |
246
+ | `BLOB_STORE_MISSING` | No adapter is registered for the store. |
247
+ | `BLOB_NOT_FOUND` | No object with that key, or the key names a still-staged upload. |
248
+ | `BLOB_ACCESS_DENIED` | The caller may not read, download or upload. Also the answer for a key that names nothing. |
249
+ | `BLOB_TOO_LARGE` | The upload exceeds `maxSizeBytes`. |
250
+ | `BLOB_MEDIA_TYPE_REJECTED` | The media type is not in `acceptedMediaTypes`. |
251
+ | `BLOB_OPERATION_FAILED` | A `blob-commit` or `blob-delete` failed at the store after its retry policy. |
252
+ | `BLOB_STORAGE_UNAVAILABLE` | (runtime) The host provides no object storage at all. |
253
+ | `BLOB_METADATA_FAILED` | (runtime) A `blob-metadata` lookup failed; the store's own code is in `details.code`. |
@@ -0,0 +1,315 @@
1
+ # Subscriptions
2
+
3
+ Axiom 0.9.0-alpha.1. How an application receives a stream of external events — an MQTT
4
+ topic, a WebSocket feed, a queue consumer, a filesystem watcher, a serial port — without a
5
+ client, a socket or a callback anywhere in the graph.
6
+
7
+ [`INTEGRATIONS.md`](INTEGRATIONS.md) is the outbound half (query and effect); this is the
8
+ inbound one. [`AUTHORITY.md`](AUTHORITY.md) is the trust boundary all three sit behind.
9
+
10
+ ## The three directions
11
+
12
+ Axiom's whole external-interaction model is three directions and no more:
13
+
14
+ | | Shape | Vocabulary |
15
+ | --- | --- | --- |
16
+ | **Query** | Ask, and wait for a finite answer. | `integration-query` on an `IntegrationOperationDef{mode:'query'}` |
17
+ | **Effect** | Tell the world to do something. No answer joins the transaction. | `integration-effect` on an `IntegrationOperationDef{mode:'effect'}` |
18
+ | **Subscription** | The world tells *you*, for as long as you are listening. | `SubscriptionDef` → `EventDef` |
19
+
20
+ Storage is not a fourth direction: a metadata lookup is a query, a commit or a delete is an
21
+ effect. See [`STORAGE.md`](STORAGE.md).
22
+
23
+ ## The model
24
+
25
+ ```ts
26
+ interface SubscriptionDef {
27
+ id: NodeId;
28
+ kind: 'subscription';
29
+ integrationId: NodeId;
30
+ source?: string;
31
+ arguments?: Record<string, Expression>;
32
+ eventId: NodeId;
33
+ lifecycle?: {
34
+ autoStart?: boolean;
35
+ required?: boolean;
36
+ reconnect?: { policy: 'none' | 'fixed' | 'exponential'; maxAttempts?: number; delayMs?: number };
37
+ };
38
+ delivery?: {
39
+ maxQueued?: number;
40
+ backpressure?: 'block' | 'reject' | 'drop-oldest' | 'drop-newest';
41
+ deduplicateBy?: FieldId;
42
+ deduplicationWindow?: number;
43
+ maxAttempts?: number;
44
+ onFailure?: 'report' | 'pause';
45
+ };
46
+ }
47
+ ```
48
+
49
+ `integrationId` names the capability domain whose adapter maintains the source. `source` is
50
+ a **semantic** name — `'device-status'`, `'inbound-orders'` — that the adapter maps to a
51
+ topic, a URL, a queue or a device; the graph never learns which. Absent, the subscription's
52
+ own id is the name.
53
+
54
+ ```ts
55
+ graph.addNode<SubscriptionDef>({
56
+ id: SUBSCRIPTION_DEVICE_STATUS,
57
+ kind: 'subscription',
58
+ name: 'live device status',
59
+ integrationId: INTEGRATION_DEVICE_PROVIDER,
60
+ source: 'device-status',
61
+ eventId: EVENT_DEVICE_STATUS_CHANGED,
62
+ delivery: { deduplicateBy: F_CHANGE_DELIVERY_ID, maxQueued: 32 },
63
+ });
64
+ ```
65
+
66
+ ## The pipeline
67
+
68
+ There is no second event system. A delivery enters the one that already exists:
69
+
70
+ ```
71
+ external source → SubscriptionAdapter → SubscriptionDef → EventDef → TriggerDef → ActionDef
72
+ ```
73
+
74
+ MUST NOT: invoke an application callback, register a handler, or mutate state from the
75
+ adapter. An adapter's only semantic act is calling `deliver`.
76
+
77
+ ## Rules
78
+
79
+ - A `SubscriptionDef` MUST name an `EventDef` that at least one `TriggerDef{when:{kind:'event'}}`
80
+ is bound to. A live source feeding nothing is `SUBSCRIPTION_EVENT_UNREACHABLE`.
81
+ - A `SubscriptionDef` MUST appear in a graph with server-authoritative state, or it is
82
+ `SUBSCRIPTION_WITHOUT_AUTHORITY` — nothing would activate it.
83
+ - Subscriptions are **server-only**. `compileToIR` strips them from the client IR exactly as
84
+ it strips integrations. There is no client-side subscription and none is compiled inert;
85
+ `validateForBrowser` and `compileToIR` therefore agree by construction.
86
+ - `arguments` are evaluated **once, at activation**, in the root scope. A live source is not
87
+ re-negotiated per message, so nothing per-delivery is in scope there.
88
+ - A subscription-originated action runs with `source: 'system'`. Declare
89
+ `invocation: { allowedSources: ['system'] }` on it — see **Security** below.
90
+
91
+ ## Lifecycle
92
+
93
+ Six states, and only these transitions:
94
+
95
+ ```
96
+ inactive ──start──▶ starting ──ok──▶ active ──stop──▶ stopped
97
+ │ │
98
+ │ fail │ transport lost
99
+ ▼ ▼
100
+ failed ◀─exhausted─ reconnecting ──ok──▶ active
101
+ ```
102
+
103
+ | State | Meaning |
104
+ | --- | --- |
105
+ | `inactive` | Declared, but `lifecycle.autoStart: false` — startup did not activate it. |
106
+ | `starting` | The first activation attempt is in flight. |
107
+ | `active` | The source is established and deliveries are being accepted. |
108
+ | `reconnecting` | The transport was lost, or an attempt failed; the reconnect policy is running. |
109
+ | `failed` | The reconnect policy is spent, or `onFailure: 'pause'` stopped it. Terminal for this process. |
110
+ | `stopped` | `server.stop()`. Accepts nothing, ever again. |
111
+
112
+ **Startup owns activation.** `AxiomServer.start()` activates every `autoStart` subscription,
113
+ after persistence has loaded and pending effects have resumed. Application code MUST NOT —
114
+ and cannot — call `subscription.start()`: there is no such method.
115
+
116
+ **A failed source does not stop the application.** `lifecycle.required` is false by default,
117
+ so an unreachable feed leaves the application serving requests with that subscription
118
+ observably `reconnecting` then `failed`. Set `required: true` only when the application is
119
+ not meaningfully running without the source; `start()` then rejects.
120
+
121
+ **Restart recreates from the graph.** A live connection is not persisted. The graph is the
122
+ declaration of desired subscription state, so a restarted process reactivates every
123
+ `autoStart` subscription from the Server IR alone.
124
+
125
+ **Reconnect policy is Axiom's; reconnect mechanics are the adapter's.** An adapter reports a
126
+ lost transport through `connectionLost()`; how many attempts, how far apart and when to give
127
+ up come from `lifecycle.reconnect`, so the answer does not change with the provider. Default:
128
+ exponential, 5 attempts, 1000ms base.
129
+
130
+ **A transport failure is not a domain event.** It moves the lifecycle state and reports a
131
+ diagnostic. It becomes an `EventDef` only if an author deliberately declares one for it.
132
+
133
+ ## Delivery guarantees
134
+
135
+ **Delivery is at-least-once.** A provider that redelivers, a reconnect that replays, and a
136
+ crash between dispatch and commit can each present the same external event twice. Axiom does
137
+ not claim exactly-once, because it cannot deliver it across all three.
138
+
139
+ **Deduplication makes it effectively-once where an external identity exists.**
140
+ `delivery.deduplicateBy` names a field of the event's payload entity carrying the provider's
141
+ own delivery identity — a message id, an offset, a delivery tag. A repeated value is
142
+ acknowledged and never dispatched. It is deliberately a payload field and not an Axiom
143
+ transaction id: the provider decides what identifies a delivery, and Axiom cannot invent one
144
+ that survives a redelivery it did not cause.
145
+
146
+ **Deduplication survives a restart** when the persistence adapter implements `hasDelivery`
147
+ and `recordDelivery` — `createMemoryPersistence` does. Without them the window is
148
+ in-process only and does **not** survive a restart; that is a real limitation, not a
149
+ guarantee to build on.
150
+
151
+ Deduplication is bounded by `deduplicationWindow` (default 512). A redelivery older than the
152
+ window is dispatched again.
153
+
154
+ ## Ordering
155
+
156
+ | | Guaranteed? |
157
+ | --- | --- |
158
+ | Deliveries of **one** subscription | **Yes** — dispatched one at a time, in accepted order, each in its own transaction. |
159
+ | Deliveries of **two** subscriptions | **No.** None. They are independent sources. |
160
+ | A delivery against a client request or a trigger tick | Only that each takes its own serialized turn. No relative order. |
161
+
162
+ Two subscriptions do enter the same serialized authority queue, so their *commits* never
163
+ interleave — but nothing orders their *arrival*, and a consumer MUST NOT build on an
164
+ accident of interleaving.
165
+
166
+ ## Transactions
167
+
168
+ Each accepted delivery enters authoritative execution through **one** transaction boundary.
169
+ Multiple deliveries never share a transaction: the runtime awaits each dispatch before taking
170
+ the next off the queue.
171
+
172
+ If the triggered action fails, its transaction rolls back like any other — but the external
173
+ provider cannot un-send the event. What happens next is `delivery.maxAttempts` (default 1,
174
+ i.e. no retry) and then `delivery.onFailure`.
175
+
176
+ ## Backpressure
177
+
178
+ The queue is **bounded**, always. `maxQueued` defaults to 64.
179
+
180
+ | `backpressure` | When the queue is full | Loses events? |
181
+ | --- | --- | --- |
182
+ | `block` (default) | The adapter's `deliver` call does not resolve until there is room. | **No** |
183
+ | `reject` | The delivery is refused; the source still holds it. | **No** |
184
+ | `drop-oldest` | The oldest queued delivery is discarded. | **Yes** |
185
+ | `drop-newest` | The arriving delivery is discarded. | **Yes** |
186
+
187
+ The default cannot lose an authoritative event: an adapter that awaits `deliver` applies real
188
+ backpressure to its own transport instead of buffering without limit. The two dropping modes
189
+ are legitimate for a genuinely lossy source — a sensor feed where only the newest reading
190
+ matters — and both report `SUBSCRIPTION_DELIVERY_DROPPED` and increment
191
+ `SubscriptionRecord.dropped`. **Loss is always declared and never silent.**
192
+
193
+ ## Poison deliveries
194
+
195
+ A delivery whose action keeps failing is bounded by `delivery.maxAttempts` and then handled by
196
+ `delivery.onFailure`:
197
+
198
+ | `onFailure` | Behaviour |
199
+ | --- | --- |
200
+ | `report` (default) | Count it as `failed`, answer the adapter `failed`, take the next delivery. |
201
+ | `pause` | The above, then stop the subscription — state `failed`. |
202
+
203
+ Neither ever retries without bound. There is no dead-letter queue: Axiom does not invent a
204
+ workflow system, and an adapter that needs one translates a `failed` outcome into its
205
+ provider's own.
206
+
207
+ ## Acknowledgement
208
+
209
+ `deliver` resolves with a `DeliveryOutcome` describing what became of the delivery, and the
210
+ adapter translates it into whatever its provider needs — an `ack`, a `nack`, an offset
211
+ commit, or nothing:
212
+
213
+ | Status | Meaning | Typical translation |
214
+ | --- | --- | --- |
215
+ | `applied` | Dispatched; its action committed. | ack / commit offset |
216
+ | `duplicate` | Already seen; not dispatched again. | ack |
217
+ | `rejected` | The payload did not conform to the `EventDef`. | ack, or dead-letter |
218
+ | `failed` | Dispatched; the action failed after every permitted attempt. | nack, or dead-letter |
219
+ | `dropped` | Discarded under a declared lossy policy. | ack |
220
+ | `refused` | Refused by backpressure; the source still holds it. | nack |
221
+ | `stopped` | The subscription is no longer running. | nack |
222
+
223
+ Ack vocabulary lives in the adapter contract and MUST NOT appear in an `ApplicationGraph`:
224
+ every provider spells it differently, and a graph that named one would stop being portable.
225
+
226
+ ## Payload validation
227
+
228
+ A delivered payload is **untrusted data**, even though the adapter is trusted infrastructure.
229
+ It is validated against the target `EventDef.payloadType` **before** anything else. An invalid
230
+ payload:
231
+
232
+ - does not dispatch the event,
233
+ - does not invoke any action,
234
+ - does not mutate authoritative state,
235
+ - reports `EVENT_PAYLOAD_INVALID` and increments `SubscriptionRecord.rejected`.
236
+
237
+ ## Security
238
+
239
+ - A client **cannot** forge a delivery. There is no protocol message that delivers into a
240
+ subscription; a delivery exists only inside the authority, from an adapter.
241
+ - A client **cannot** invoke a subscription-only action. Declare
242
+ `invocation: { allowedSources: ['system'] }` and `INVOCATION_SOURCE_NOT_ALLOWED` refuses a
243
+ client-sourced call before identity is consulted.
244
+ - An external source **cannot** bypass authorization, preconditions, constraints, transition
245
+ constraints or rollback. A subscription-originated action runs through exactly the same
246
+ `invokeCore` a client request does, under `principal: null`.
247
+ - After shutdown, **no** delivery reaches state. `stop()` moves every subscription to
248
+ `stopped`, and a stopped subscription answers `stopped` to everything.
249
+
250
+ ## Observability
251
+
252
+ ```ts
253
+ server.subscriptionLog(); // SubscriptionRecord[]
254
+ server.subscriptionStatus(SUBSCRIPTION_ID); // SubscriptionRecord | undefined
255
+ ```
256
+
257
+ `SubscriptionRecord` carries `state`, `source`, `attempts`, `received`, `applied`,
258
+ `rejected`, `failed`, `dropped`, `queued`, `lastDeliveryAt` and `lastFailure`. That is the
259
+ whole operational picture — configured, active, reconnecting, failed, arriving, being
260
+ refused — and it is host observability, not an application-authored health route.
261
+
262
+ ## Adapters
263
+
264
+ ```ts
265
+ interface SubscriptionAdapter {
266
+ start(context: SubscriptionContext): Promise<SubscriptionHandle>;
267
+ }
268
+ ```
269
+
270
+ Registered per integration id on `createAxiomServer({ subscriptions })` or
271
+ `serveAxiomApplication({ subscriptions })`. A declared subscription with no registered
272
+ adapter fails `start()`, rather than staying silently inactive.
273
+
274
+ Sockets, `EventEmitter`s, Node streams, subprocesses, serial ports and provider SDKs are all
275
+ permitted **inside** an adapter and nowhere above it. One adapter may multiplex many
276
+ subscriptions over one connection: nothing in Axiom assumes `SubscriptionDef` and TCP session
277
+ correspond.
278
+
279
+ `createScriptedSubscriptionAdapter(scripts, host)` is the deterministic fake — connect
280
+ success and failure, deliveries, duplicates, delayed deliveries, disconnection and
281
+ reconnection, all as data against a virtual clock, with no network and no wall-clock wait.
282
+ The same script shape appears in the portable conformance fixtures.
283
+
284
+ ## Subscription vs. webhook vs. polling
285
+
286
+ | | What it is | How it reaches Axiom |
287
+ | --- | --- | --- |
288
+ | **Webhook** | An externally initiated **finite request**. Each delivery independently enters Axiom. | The host verifies it, then sends one `EventRequest`. See [`EVENTS.md`](EVENTS.md). |
289
+ | **Subscription** | Axiom maintains a standing **semantic interest** in a long-lived source. Deliveries occur while it is active. | The adapter calls `deliver` while the subscription is `active`. |
290
+ | **Polling** | Axiom asks, repeatedly, on a schedule. | `TriggerDef{when:{kind:'interval'}}` → `integration-query`. See [`TRIGGERS.md`](TRIGGERS.md). |
291
+
292
+ Do not hide polling behind subscription syntax: a source you have to ask is a query on a
293
+ timer, and saying so keeps the graph honest about what it does to the provider.
294
+
295
+ `TriggerDef` keeps timer and lifecycle semantics; `SubscriptionDef` keeps external long-lived
296
+ sources. Neither replaces the other.
297
+
298
+ ## Contract
299
+
300
+ Subscription vocabulary requires `axiom.server.v5`, computed from the document — a graph that
301
+ uses none of it compiles to the byte-identical older document it always did. See
302
+ [`AUTHORITY.md`](AUTHORITY.md#contract-identifiers).
303
+
304
+ ## Diagnostic codes
305
+
306
+ | Code | Meaning |
307
+ | --- | --- |
308
+ | `SUBSCRIPTION_EVENT_UNREACHABLE` | (validation) The event has no trigger bound to it. |
309
+ | `SUBSCRIPTION_WITHOUT_AUTHORITY` | (validation) The graph has no server-authoritative state. |
310
+ | `SUBSCRIPTION_INVALID_POLICY` | (validation) A queue below one, no attempts, an unknown backpressure policy, or a `deduplicateBy` that is not a field of the payload entity. |
311
+ | `SUBSCRIPTION_ADAPTER_MISSING` | No adapter is registered for the integration. |
312
+ | `SUBSCRIPTION_START_FAILED` | The source could not be established after every reconnect attempt. |
313
+ | `SUBSCRIPTION_DELIVERY_DROPPED` | A delivery was discarded under a declared lossy policy. |
314
+ | `SUBSCRIPTION_DELIVERY_FAILED` | A delivery's action failed after every permitted attempt. |
315
+ | `EVENT_PAYLOAD_INVALID` | The payload did not conform to the `EventDef.payloadType`. |
package/docs/TRIGGERS.md CHANGED
@@ -1,10 +1,16 @@
1
1
  # Triggers
2
2
 
3
- Axiom 0.8.2-alpha.1. A `TriggerDef` says **when** an action should be invoked, without
3
+ Axiom 0.9.0-alpha.1. A `TriggerDef` says **when** an action should be invoked, without
4
4
  embedding callback code. `docs/AUTHORITY.md`
5
5
  [§ Triggers](AUTHORITY.md#triggers) is the load-bearing statement of the execution model;
6
6
  this file is the vocabulary.
7
7
 
8
+ A trigger covers time (`interval`, `delay`), the application lifecycle, and a dispatched
9
+ event. It does **not** cover a long-lived external source: that is a
10
+ [`SubscriptionDef`](SUBSCRIPTIONS.md), whose deliveries become `EventDef` payloads that
11
+ `event` triggers then react to. Keep the two separate — an interval trigger driving an
12
+ `integration-query` is polling, and polling is not a subscription.
13
+
8
14
  ## The model
9
15
 
10
16
  ```ts
package/docs/UI.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # UI
2
2
 
3
- Axiom 0.8.2-alpha.1. Eleven semantic UI node kinds describe **what exists and what it does**.
3
+ Axiom 0.9.0-alpha.1. Eleven semantic UI node kinds describe **what exists and what it does**.
4
4
  How it looks is [presentation](PRESENTATION.md).
5
5
 
6
6
  All eleven share `UIBase`:
@@ -1,6 +1,6 @@
1
1
  # Validation
2
2
 
3
- Axiom 0.8.2-alpha.1. Validation is authoring-time structural checking. It is not the same
3
+ Axiom 0.9.0-alpha.1. Validation is authoring-time structural checking. It is not the same
4
4
  as runtime constraint evaluation — see [`CONSTRAINTS.md`](CONSTRAINTS.md) for the four
5
5
  layers of correctness.
6
6
 
@@ -134,6 +134,19 @@ Full model: [`INTEGRATIONS.md`](INTEGRATIONS.md), [`EFFECTS.md`](EFFECTS.md),
134
134
  | `INVALID_INVOCATION_SOURCE` | `ActionDef.invocation.allowedSources` is present but empty — the action could never be invoked at all. |
135
135
  | `CLIENT_TRIGGER_UNSUPPORTED` | A client-authority trigger of a kind the intended trigger runtime does not execute — before 8.1 this validated and compiled anyway, and simply never fired (spec 8.1 §31-36). The browser trigger runtime executes no kind today. Only raised when a `triggerRuntime` is actually named — `validateGraph(graph)` with no options is target-neutral and never raises it; `validateForBrowser(graph)`/`compileToIR(graph)` name the real browser capabilities and do (spec 8.2 §2-6). The message states the remediation: move the target action to server authority, or compile for a trigger runtime that publishes the kind. |
136
136
 
137
+ ### Subscriptions and object storage
138
+
139
+ Full model: [`SUBSCRIPTIONS.md`](SUBSCRIPTIONS.md) and [`STORAGE.md`](STORAGE.md).
140
+
141
+ | Code | Raised when |
142
+ | --- | --- |
143
+ | `SUBSCRIPTION_EVENT_UNREACHABLE` | A `SubscriptionDef` whose `eventId` no `TriggerDef{when:{kind:'event'}}` is bound to. Every delivery would be validated and then discarded — a live source feeding nothing. |
144
+ | `SUBSCRIPTION_WITHOUT_AUTHORITY` | A `SubscriptionDef` in a graph with no server-authoritative state. Subscriptions are authority-side; nothing would ever activate it. |
145
+ | `SUBSCRIPTION_INVALID_POLICY` | A delivery or lifecycle policy that cannot be executed as written: `maxQueued` below one, `maxAttempts` below one, an unknown backpressure policy, or a `deduplicateBy` that is not a field of the event's payload entity. |
146
+ | `UNKNOWN_STORAGE` | A blob operation's `storageId`, or a `StorageDef.blobEntityId`, that does not resolve. |
147
+ | `INVALID_BLOB_ENTITY` | A `StorageDef.blobEntityId` naming an entity that is not the canonical `blobRefEntity()` shape. The message names the missing fields. |
148
+ | `INVALID_BLOB_OPERATION` | A blob operation that cannot execute as written. |
149
+
137
150
  ### UI and routing
138
151
 
139
152
  | Code | Raised when | Severity |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom",
3
- "version": "0.8.2-alpha.1",
3
+ "version": "0.9.0-alpha.1",
4
4
  "description": "AI-native semantic web application framework.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",
@@ -32,10 +32,10 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@cynodia/axiom-core": "0.8.2-alpha.1",
36
- "@cynodia/axiom-runtime": "0.8.2-alpha.1",
37
- "@cynodia/axiom-compiler": "0.8.2-alpha.1",
38
- "@cynodia/axiom-agent-api": "0.8.2-alpha.1"
35
+ "@cynodia/axiom-core": "0.9.0-alpha.1",
36
+ "@cynodia/axiom-runtime": "0.9.0-alpha.1",
37
+ "@cynodia/axiom-compiler": "0.9.0-alpha.1",
38
+ "@cynodia/axiom-agent-api": "0.9.0-alpha.1"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "tsc -b tsconfig.json"