@githolon/dsl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +36 -0
- package/compile_package.mjs +50 -0
- package/package.json +59 -0
- package/src/aggregate.ts +167 -0
- package/src/authoring.ts +119 -0
- package/src/build_package.ts +636 -0
- package/src/certified_read.ts +313 -0
- package/src/codegen_dart.ts +2732 -0
- package/src/codegen_dot.ts +466 -0
- package/src/codegen_provider_dart.ts +358 -0
- package/src/codegen_ts.ts +365 -0
- package/src/codegen_usda.ts +388 -0
- package/src/combined.ts +195 -0
- package/src/compile_engine.ts +567 -0
- package/src/compile_package_main.ts +496 -0
- package/src/compose.ts +317 -0
- package/src/count.ts +218 -0
- package/src/ctx.ts +57 -0
- package/src/derived.ts +138 -0
- package/src/directive.ts +306 -0
- package/src/drivers.ts +95 -0
- package/src/emits_guard.ts +123 -0
- package/src/engine_entry.ts +449 -0
- package/src/exists.ts +170 -0
- package/src/extremum.ts +227 -0
- package/src/fields.ts +291 -0
- package/src/framework/bootstrap.ts +22 -0
- package/src/framework/disclosure.ts +108 -0
- package/src/framework/domain_lifecycle.ts +108 -0
- package/src/framework/identity.ts +537 -0
- package/src/framework/impure_capability.ts +643 -0
- package/src/framework/rbac.ts +418 -0
- package/src/framework/repair.ts +150 -0
- package/src/framework/sync_lifecycle.ts +125 -0
- package/src/framework/workspace_invariant.ts +128 -0
- package/src/framework/workspaces.ts +817 -0
- package/src/index.ts +317 -0
- package/src/manifest.ts +947 -0
- package/src/ops.ts +145 -0
- package/src/ordered_read.ts +228 -0
- package/src/predicate.ts +203 -0
- package/src/query/compile.ts +0 -0
- package/src/query/relations.ts +144 -0
- package/src/query.ts +151 -0
- package/src/read.ts +54 -0
- package/src/relation.ts +189 -0
- package/src/report/csv.ts +54 -0
- package/src/report.ts +401 -0
- package/src/spatial.ts +115 -0
- package/src/sum.ts +194 -0
- package/src/usd.ts +563 -0
- package/src/wire.ts +149 -0
- package/src/wire_encode.ts +250 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded
|
|
4
|
+
// QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
|
|
5
|
+
// wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
|
|
6
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* framework/impure_capability.ts — the FRAMEWORK Order→Receipt mechanism (tenant-agnostic).
|
|
10
|
+
*
|
|
11
|
+
* ── Why this module exists (the framework↔tenant line) ───────────────────────
|
|
12
|
+
* A *provider-side intent* in Nomos 1 was an ephemeral job: dispatch → the
|
|
13
|
+
* handler ran a side-effect inline (build a ZIP, render a report, dispatch work
|
|
14
|
+
* across user workspaces) → mutated the HTTP response → fired a follow-up
|
|
15
|
+
* notification. Nothing durable, nothing replayable, and it leaned on the deleted
|
|
16
|
+
* `dispatch(IntentBase)` + response-mutation back-channel.
|
|
17
|
+
*
|
|
18
|
+
* Nomos 2's law is "**every authored intent folds SOME aggregate**" (contract §1) —
|
|
19
|
+
* there is no ephemeral job. The faithful shape of an async impure capability is therefore
|
|
20
|
+
* an **Order → Receipt** pair of ordinary `WireIntent`s with a first-class lifecycle law:
|
|
21
|
+
*
|
|
22
|
+
* • **Order** — a normal intent whose role is the request: a peer dispatches
|
|
23
|
+
* `order…(params, status:'requested')`,
|
|
24
|
+
* `.creates` a task aggregate keyed by a KERNEL-MINTED order id
|
|
25
|
+
* (`<OrderType>_<uuidv7>`, via the marker-driven front-door; idempotent on
|
|
26
|
+
* replay by capture).
|
|
27
|
+
* • **(impure work)** — a capability provider watches `status:'requested'` rows, performs
|
|
28
|
+
* the side-effect (rendering / upload / multi-target provider work — a host/report
|
|
29
|
+
* concern, NOT part of any fold), then authors a Receipt intent.
|
|
30
|
+
* • **Receipt** — a normal intent whose role is the outcome:
|
|
31
|
+
* `complete…(requestId, resultRef, completedAt)` → `status:'completed'`
|
|
32
|
+
* OR `fail…(requestId, failureReason, failedAt)` → `status:'failed'`.
|
|
33
|
+
* Some domains also expose explicit blocked/DLQ receipts using the
|
|
34
|
+
* same envelope fields below.
|
|
35
|
+
*
|
|
36
|
+
* Order and Receipt are roles/facets of `Intent`, not special admission objects, job-table rows,
|
|
37
|
+
* or a privileged mutation channel.
|
|
38
|
+
*
|
|
39
|
+
* **Reporting upward = the authoring peer reactively WATCHES the task aggregate.** The receipt
|
|
40
|
+
* arrives as an ordinary projection update (no polling, no fire-and-forget push, no
|
|
41
|
+
* response-mutation). This is exactly the proven `ExportJobAggregate` shape
|
|
42
|
+
* (the tenant domains package `thing_structures`) generalised into a reusable framework pattern so a
|
|
43
|
+
* domain dev does NOT hand-roll the {aggregate + order + complete + fail} quartet each
|
|
44
|
+
* time — they DECLARE the task's params and get the quartet for free, like `rbac.ts`
|
|
45
|
+
* gives grant/revoke for free.
|
|
46
|
+
*
|
|
47
|
+
* ── What is framework vs tenant ──────────────────────────────────────────────
|
|
48
|
+
* Framework (here): the task LIFECYCLE shape — the `requestId / status / resultRef? /
|
|
49
|
+
* failureReason? / completedAt? / blockedReason? / nextAction? / deadLetterReason?`
|
|
50
|
+
* envelope, the `requested|completed|failed|blocked|deadLettered` status
|
|
51
|
+
* vocabulary, the deterministic instance-id rule (the task is keyed by its own
|
|
52
|
+
* requestId), and the three plan builders that write that envelope. Tenant: the task's
|
|
53
|
+
* NAME (`exportJob`, `thingExport`) and its request PARAMS (which group, which
|
|
54
|
+
* filters) — the domain dev supplies those via `impureCapability({...})`.
|
|
55
|
+
*
|
|
56
|
+
* ── Boundaries this module deliberately does NOT cross ───────────────────────
|
|
57
|
+
* • The side-effect itself (rendering, GCS upload, multi-target provider work) is a
|
|
58
|
+
* host/report concern, never part of a fold — the provider does it, the Receipt intent
|
|
59
|
+
* only records the content-addressed RESULT (a blob ref / url as `resultRef`).
|
|
60
|
+
* • Cross-workspace effects (e.g. propagate-thing-rename to N user workspaces) are the
|
|
61
|
+
* PR tier (contract §5b / conformance X1), not a single impure capability — a provider opens
|
|
62
|
+
* one PR per target workspace. A impure capability is the SAME-workspace async-job half;
|
|
63
|
+
* the §5b PR is the cross-workspace half. They compose; this lifecycle pattern is the former.
|
|
64
|
+
* • Authoring the Receipt intent goes through the ONE write path like any author — the
|
|
65
|
+
* reactor is itself a Nomos peer (`admission-hosting.md`: provider-originated intents author
|
|
66
|
+
* *through* a policy unit too). This module only provides the directive SHAPES.
|
|
67
|
+
*/
|
|
68
|
+
import { z } from "zod";
|
|
69
|
+
import { aggregate, instance, type AggregateHandle } from "../aggregate.js";
|
|
70
|
+
import { t, type Field } from "../fields.js";
|
|
71
|
+
import { Lww } from "../drivers.js";
|
|
72
|
+
import { set, type PlannedOp } from "../ops.js";
|
|
73
|
+
import { directive, type RequirableDirective } from "../directive.js";
|
|
74
|
+
import { executeDirectiveToIntent } from "../wire_encode.js";
|
|
75
|
+
import type { WireIntent } from "../wire.js";
|
|
76
|
+
import type { Ports } from "../ctx.js";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The impure-capability lifecycle vocabulary — framework-fixed. A task is `requested` when
|
|
80
|
+
* the peer orders it; the provider authors the ordinary Receipt intent that flips it
|
|
81
|
+
* to `completed` (with a `resultRef`) or `failed` (with a `failureReason`). One-way after a
|
|
82
|
+
* terminal state: the lifecycle law is terminal-once at the gate/envelope, not a separate
|
|
83
|
+
* privileged mutation channel.
|
|
84
|
+
*/
|
|
85
|
+
export const IMPURE_CAPABILITY_STATUSES = [
|
|
86
|
+
"requested",
|
|
87
|
+
"completed",
|
|
88
|
+
"failed",
|
|
89
|
+
"blocked",
|
|
90
|
+
"deadLettered",
|
|
91
|
+
] as const;
|
|
92
|
+
export type ImpureCapabilityStatus = (typeof IMPURE_CAPABILITY_STATUSES)[number];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The framework envelope fields EVERY impure-capability aggregate carries — the Order→Receipt
|
|
96
|
+
* lifecycle, independent of the tenant's request params:
|
|
97
|
+
*
|
|
98
|
+
* - requestId Lww — the task's own id (== the aggregate instance id; a
|
|
99
|
+
* KERNEL-MINTED `<OrderType>_<uuidv7>`). Immutable-after-create (the
|
|
100
|
+
* create-only gap; modelled Lww).
|
|
101
|
+
* - requestedBy Lww — who ordered the task (provenance). Immutable-after-create.
|
|
102
|
+
* - requestedAt Int — epoch-ms the order was authored. Immutable-after-create.
|
|
103
|
+
* - status Lww enum — `requested` → the reactor flips to a terminal status.
|
|
104
|
+
* - resultRef Lww json (OPTIONAL) — the content-addressed RESULT the reactor
|
|
105
|
+
* records on completion (a blob ref / download url JSON leaf). Absent
|
|
106
|
+
* on a fresh request; absent on failure.
|
|
107
|
+
* - failureReason Lww (OPTIONAL) — set only when the reactor flips to `failed`.
|
|
108
|
+
* - completedAt Int (OPTIONAL) — epoch-ms the task reached its terminal state
|
|
109
|
+
* (completed OR failed). Absent while `requested`.
|
|
110
|
+
* - blockedReason Lww (OPTIONAL) — why the provider cannot proceed without an
|
|
111
|
+
* external condition.
|
|
112
|
+
* - blockedAt Int (OPTIONAL) — epoch-ms the task was parked as blocked.
|
|
113
|
+
* - nextAction Lww (OPTIONAL) — human/host-action hint, e.g. refreshAuth.
|
|
114
|
+
* - deadLetterReason Lww (OPTIONAL) — why the order was moved to the DLQ.
|
|
115
|
+
* - deadLetteredAt Int (OPTIONAL) — epoch-ms the order entered the DLQ.
|
|
116
|
+
*
|
|
117
|
+
* The OPTIONALs are `.optional()` so a freshly-ordered task (which folds none of them)
|
|
118
|
+
* decodes on the generated read type — the same read-decode discipline the rest of the
|
|
119
|
+
* framework uses (identity NOTE 1a / gap 1).
|
|
120
|
+
*/
|
|
121
|
+
export const IMPURE_CAPABILITY_ENVELOPE = {
|
|
122
|
+
requestId: t.string().merge(Lww),
|
|
123
|
+
requestedBy: t.string().merge(Lww),
|
|
124
|
+
requestedAt: t.int().merge(Lww),
|
|
125
|
+
status: t.enum(IMPURE_CAPABILITY_STATUSES).merge(Lww),
|
|
126
|
+
resultRef: t.json().merge(Lww).optional(),
|
|
127
|
+
failureReason: t.string().merge(Lww).optional(),
|
|
128
|
+
completedAt: t.int().merge(Lww).optional(),
|
|
129
|
+
blockedReason: t.string().merge(Lww).optional(),
|
|
130
|
+
blockedAt: t.int().merge(Lww).optional(),
|
|
131
|
+
nextAction: t.string().merge(Lww).optional(),
|
|
132
|
+
deadLetterReason: t.string().merge(Lww).optional(),
|
|
133
|
+
deadLetteredAt: t.int().merge(Lww).optional(),
|
|
134
|
+
// The FRAMEWORK Order↔Receipt CLOSURE link (OPTIONAL): the id of the Order this
|
|
135
|
+
// RECEIPT closes (== the task's own requestId — the reactor mutates the task instance).
|
|
136
|
+
// GENERIC, written by every impureCapability Receipt (complete OR fail), so the read
|
|
137
|
+
// projection answers "is Order O closed, by which Receipt, with what result" in O(1)
|
|
138
|
+
// off the `(closesOrder, order_id)` index — never a ledger scan. Absent while
|
|
139
|
+
// `requested` (a fresh order has no closing receipt). Wire-identical to the Rust
|
|
140
|
+
// reactor's `closesOrder` op (`git-holon::reactor::receipt_intent`,
|
|
141
|
+
// `kernel::manifest_view::IMPURE_CAPABILITY_RECEIPT_FIELDS`).
|
|
142
|
+
closesOrder: t.string().merge(Lww).optional(),
|
|
143
|
+
} as const;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Declare a impure-capability aggregate: the framework {@link IMPURE_CAPABILITY_ENVELOPE} MERGED
|
|
147
|
+
* with the tenant's request-param fields. The tenant supplies the task's wire id (e.g.
|
|
148
|
+
* `"ExportJobAggregate"`) and its params (`{ targetKey: t.string()…, … }`);
|
|
149
|
+
* the framework supplies the lifecycle envelope.
|
|
150
|
+
*
|
|
151
|
+
* A param field name colliding with an envelope field is a COMPILE error (the spread
|
|
152
|
+
* key types overlap) — the envelope is reserved, so a tenant can't shadow `status`.
|
|
153
|
+
*
|
|
154
|
+
* Returns a plain {@link AggregateHandle}, so it lowers, codegens, and is referenced
|
|
155
|
+
* exactly like any hand-authored aggregate (no special-casing downstream).
|
|
156
|
+
*/
|
|
157
|
+
export function impureCapabilityAggregate<
|
|
158
|
+
const Id extends string,
|
|
159
|
+
P extends Record<string, Field>,
|
|
160
|
+
>(id: Id, params: P): AggregateHandle<Id, typeof IMPURE_CAPABILITY_ENVELOPE & P> {
|
|
161
|
+
return aggregate(id, { ...IMPURE_CAPABILITY_ENVELOPE, ...params });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The deterministic instance-id rule: a impure capability is keyed by its OWN order id.
|
|
166
|
+
* That id is KERNEL-MINTED (`<OrderType>_<uuidv7>`, via the marker-driven front-door),
|
|
167
|
+
* captured + committed — so the `order` create is idempotent on replay by capture, and the
|
|
168
|
+
* `complete`/`fail` receipt addresses the SAME instance by reading the committed minted id.
|
|
169
|
+
* Trivial today (identity), but centralised here so the keying rule has ONE home (the
|
|
170
|
+
* symmetry with `rbac.ts`'s `userPermissionsInstanceId`).
|
|
171
|
+
*/
|
|
172
|
+
export function impureCapabilityInstanceId(requestId: string): string {
|
|
173
|
+
return requestId;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The Zod schema for the framework envelope fields the ORDER payload always carries. */
|
|
177
|
+
const orderEnvelopeSchema = z.object({
|
|
178
|
+
requestId: z.string(),
|
|
179
|
+
requestedBy: z.string(),
|
|
180
|
+
requestedAt: z.number().int(),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/** The receipt-COMPLETE payload: the request id + the content-addressed result + when. */
|
|
184
|
+
const completeSchema = z.object({
|
|
185
|
+
requestId: z.string(),
|
|
186
|
+
// The produced artifact's content-addressed ref (blob ref / url) as a JSON string.
|
|
187
|
+
resultRef: z.string(),
|
|
188
|
+
completedAt: z.number().int(),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/** The receipt-FAIL payload: the request id + the failure reason + when. */
|
|
192
|
+
const failSchema = z.object({
|
|
193
|
+
requestId: z.string(),
|
|
194
|
+
failureReason: z.string(),
|
|
195
|
+
failedAt: z.number().int(),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/** The receipt-BLOCKED payload: parked pending external action, not retried blindly. */
|
|
199
|
+
const blockSchema = z.object({
|
|
200
|
+
requestId: z.string(),
|
|
201
|
+
blockedReason: z.string(),
|
|
202
|
+
blockedAt: z.number().int(),
|
|
203
|
+
nextAction: z.string(),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/** The receipt-DLQ payload: terminal dead-letter, requiring a fresh order to recover. */
|
|
207
|
+
const deadLetterSchema = z.object({
|
|
208
|
+
requestId: z.string(),
|
|
209
|
+
deadLetterReason: z.string(),
|
|
210
|
+
deadLetteredAt: z.number().int(),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The three plan builders. Each takes the task aggregate handle + the (already
|
|
215
|
+
* Zod-validated) payload and returns the `PlannedOp[]` writing the envelope. A tenant
|
|
216
|
+
* routes its directive `.plan` through these so the envelope shape can never drift —
|
|
217
|
+
* exactly how `rbac.ts`'s grant/revoke route through `buildBinding`/`writeBinding`.
|
|
218
|
+
*/
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Bind an ENVELOPE-ONLY handle for `aggregateId` at its task instance. The op-builders
|
|
222
|
+
* write ONLY envelope fields (never tenant params), so they resolve `set()` against the
|
|
223
|
+
* fixed {@link IMPURE_CAPABILITY_ENVELOPE} field map — not the tenant's open param generic.
|
|
224
|
+
* Wire-identical: lowering keys off `aggregateId` + field names + values, all of which
|
|
225
|
+
* this carries (the tenant param fields live on the SAME aggregate id via the task
|
|
226
|
+
* handle; the envelope handle just types the envelope subset for `set()`).
|
|
227
|
+
*/
|
|
228
|
+
function envelopeInstance(aggregateId: string, requestId: string) {
|
|
229
|
+
const envelopeHandle = aggregate(aggregateId, IMPURE_CAPABILITY_ENVELOPE);
|
|
230
|
+
return instance(envelopeHandle, impureCapabilityInstanceId(requestId));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* orderOps — the ORDER half. Seeds the envelope provenance + `status:'requested'`,
|
|
235
|
+
* PLUS the tenant's request params (the `paramOps` the caller's `.plan` supplies for
|
|
236
|
+
* its own fields). The task is bound to its requestId instance (#105).
|
|
237
|
+
*/
|
|
238
|
+
export function orderOps(
|
|
239
|
+
aggregateId: string,
|
|
240
|
+
p: z.infer<typeof orderEnvelopeSchema>,
|
|
241
|
+
paramOps: PlannedOp[] = [],
|
|
242
|
+
): PlannedOp[] {
|
|
243
|
+
const taskInstance = envelopeInstance(aggregateId, p.requestId);
|
|
244
|
+
return [
|
|
245
|
+
set(taskInstance, "requestId", p.requestId),
|
|
246
|
+
set(taskInstance, "requestedBy", p.requestedBy),
|
|
247
|
+
set(taskInstance, "requestedAt", p.requestedAt),
|
|
248
|
+
set(taskInstance, "status", "requested"),
|
|
249
|
+
...paramOps,
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* completeOps — the RECEIPT (success) half: flip `status:'completed'`, record the
|
|
255
|
+
* content-addressed `resultRef`, stamp `completedAt`. The reactor's write once the
|
|
256
|
+
* side-effect finishes.
|
|
257
|
+
*/
|
|
258
|
+
export function completeOps(
|
|
259
|
+
aggregateId: string,
|
|
260
|
+
p: z.infer<typeof completeSchema>,
|
|
261
|
+
resultOps: PlannedOp[] = [],
|
|
262
|
+
): PlannedOp[] {
|
|
263
|
+
const taskInstance = envelopeInstance(aggregateId, p.requestId);
|
|
264
|
+
return [
|
|
265
|
+
set(taskInstance, "status", "completed"),
|
|
266
|
+
set(taskInstance, "resultRef", p.resultRef),
|
|
267
|
+
set(taskInstance, "completedAt", p.completedAt),
|
|
268
|
+
// The FRAMEWORK Order↔Receipt CLOSURE link — this Receipt closes the Order whose
|
|
269
|
+
// id == requestId (the task instance the reactor mutates). Indexed by the read
|
|
270
|
+
// projection for the O(1) closure read. Wire-identical to the Rust reactor's op.
|
|
271
|
+
set(taskInstance, "closesOrder", impureCapabilityInstanceId(p.requestId)),
|
|
272
|
+
// The TENANT RESULT FIELDS the Receipt writes into the task aggregate (e.g.
|
|
273
|
+
// `transcription = <text>`). The envelope is auto; these are the tenant's own
|
|
274
|
+
// structured result. Framework writes them verbatim; the tenant owns their meaning.
|
|
275
|
+
...resultOps,
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* failOps — the RECEIPT (failure) half: flip `status:'failed'`, record the
|
|
281
|
+
* `failureReason`, stamp `completedAt` (the terminal time, success or failure). The
|
|
282
|
+
* reactor's write when the side-effect throws — recorded, never a retry storm
|
|
283
|
+
* (conformance X1's "declined is recorded" discipline at the task scale).
|
|
284
|
+
*/
|
|
285
|
+
export function failOps(
|
|
286
|
+
aggregateId: string,
|
|
287
|
+
p: z.infer<typeof failSchema>,
|
|
288
|
+
): PlannedOp[] {
|
|
289
|
+
const taskInstance = envelopeInstance(aggregateId, p.requestId);
|
|
290
|
+
return [
|
|
291
|
+
set(taskInstance, "status", "failed"),
|
|
292
|
+
set(taskInstance, "failureReason", p.failureReason),
|
|
293
|
+
set(taskInstance, "completedAt", p.failedAt),
|
|
294
|
+
// CLOSURE link — a FAILED task is still CLOSED by its receipt (the closure read
|
|
295
|
+
// reports it CLOSED with no result). Same `closesOrder == requestId` as `completeOps`.
|
|
296
|
+
set(taskInstance, "closesOrder", impureCapabilityInstanceId(p.requestId)),
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** blockOps — terminal park with an explicit next action, not a hidden retry loop. */
|
|
301
|
+
export function blockOps(
|
|
302
|
+
aggregateId: string,
|
|
303
|
+
p: z.infer<typeof blockSchema>,
|
|
304
|
+
): PlannedOp[] {
|
|
305
|
+
const taskInstance = envelopeInstance(aggregateId, p.requestId);
|
|
306
|
+
return [
|
|
307
|
+
set(taskInstance, "status", "blocked"),
|
|
308
|
+
set(taskInstance, "blockedReason", p.blockedReason),
|
|
309
|
+
set(taskInstance, "blockedAt", p.blockedAt),
|
|
310
|
+
set(taskInstance, "nextAction", p.nextAction),
|
|
311
|
+
set(taskInstance, "closesOrder", impureCapabilityInstanceId(p.requestId)),
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** deadLetterOps — terminal DLQ receipt. Recovery is a fresh explicit Order. */
|
|
316
|
+
export function deadLetterOps(
|
|
317
|
+
aggregateId: string,
|
|
318
|
+
p: z.infer<typeof deadLetterSchema>,
|
|
319
|
+
): PlannedOp[] {
|
|
320
|
+
const taskInstance = envelopeInstance(aggregateId, p.requestId);
|
|
321
|
+
return [
|
|
322
|
+
set(taskInstance, "status", "deadLettered"),
|
|
323
|
+
set(taskInstance, "deadLetterReason", p.deadLetterReason),
|
|
324
|
+
set(taskInstance, "deadLetteredAt", p.deadLetteredAt),
|
|
325
|
+
set(taskInstance, "closesOrder", impureCapabilityInstanceId(p.requestId)),
|
|
326
|
+
];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The generated directive QUARTET for a impure capability. A domain dev calls
|
|
331
|
+
* {@link impureCapability} ONCE and gets:
|
|
332
|
+
* • `aggregate` — the task aggregate handle (envelope ⊕ params),
|
|
333
|
+
* • `order` — `.creates` directive (the authored Order),
|
|
334
|
+
* • `complete` — `.mutates` directive (the reactor's success Receipt),
|
|
335
|
+
* • `fail` — `.mutates` directive (the reactor's failure Receipt),
|
|
336
|
+
* plus the deterministic `instanceId` rule. The `order` payload is the framework
|
|
337
|
+
* order-envelope MERGED with the tenant's `paramsSchema`; `complete`/`fail` are fixed.
|
|
338
|
+
*/
|
|
339
|
+
export interface ImpureCapability<
|
|
340
|
+
Id extends string,
|
|
341
|
+
ParamsShape extends z.ZodRawShape,
|
|
342
|
+
F extends Record<string, Field>,
|
|
343
|
+
> {
|
|
344
|
+
readonly aggregate: AggregateHandle<Id, typeof IMPURE_CAPABILITY_ENVELOPE & F>;
|
|
345
|
+
readonly order: RequirableDirective<OrderPayload<ParamsShape>>;
|
|
346
|
+
readonly complete: RequirableDirective<z.infer<typeof completeSchema>>;
|
|
347
|
+
readonly fail: RequirableDirective<z.infer<typeof failSchema>>;
|
|
348
|
+
readonly block: RequirableDirective<z.infer<typeof blockSchema>>;
|
|
349
|
+
readonly deadLetter: RequirableDirective<z.infer<typeof deadLetterSchema>>;
|
|
350
|
+
readonly instanceId: (requestId: string) => string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The inferred ORDER payload type = the framework envelope schema EXTENDED with the
|
|
355
|
+
* tenant's param shape — the EXACT inference `orderEnvelopeSchema.extend(paramsSchema)`
|
|
356
|
+
* produces (so the declared `ImpureCapability.order` directive type and the value the factory
|
|
357
|
+
* builds are the same type, with no brittle hand-written intersection).
|
|
358
|
+
*/
|
|
359
|
+
export type OrderPayload<ParamsShape extends z.ZodRawShape> = z.infer<
|
|
360
|
+
ReturnType<typeof orderEnvelopeSchema.extend<ParamsShape>>
|
|
361
|
+
>;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* impureCapability — the framework FACTORY. Declares an Order→Receipt task in ONE call:
|
|
365
|
+
*
|
|
366
|
+
* ```ts
|
|
367
|
+
* // Build the aggregate handle FIRST so `writeParams` references it without a
|
|
368
|
+
* // self-cycle (a `const` can't reference itself in its own initializer).
|
|
369
|
+
* const exportParams = { targetKey: t.string().merge(Lww), format: t.enum(EXPORT_FORMATS).merge(Lww) };
|
|
370
|
+
* const exportTaskAgg = impureCapabilityAggregate("ExportJobAggregate", exportParams);
|
|
371
|
+
*
|
|
372
|
+
* export const exportJob = impureCapability({
|
|
373
|
+
* aggregateId: "ExportJobAggregate",
|
|
374
|
+
* orderDirectiveId: "createExportJob",
|
|
375
|
+
* completeDirectiveId: "completeExportJob",
|
|
376
|
+
* failDirectiveId: "failExportJob",
|
|
377
|
+
* blockDirectiveId: "blockExportJob",
|
|
378
|
+
* deadLetterDirectiveId: "deadLetterExportJob",
|
|
379
|
+
* params: exportParams,
|
|
380
|
+
* paramsSchema: { targetKey: z.string(), format: z.enum(EXPORT_FORMATS) },
|
|
381
|
+
* writeParams: (taskInstanceId, p) => [
|
|
382
|
+
* set(instance(exportTaskAgg, taskInstanceId), "targetKey", p.targetKey),
|
|
383
|
+
* set(instance(exportTaskAgg, taskInstanceId), "format", p.format),
|
|
384
|
+
* ],
|
|
385
|
+
* });
|
|
386
|
+
* ```
|
|
387
|
+
*
|
|
388
|
+
* The tenant declares the wire ids (so they're stable, minify-safe DATA — the same
|
|
389
|
+
* declared-once rule as `aggregate()`/`directive()`), the param FIELDS (for the
|
|
390
|
+
* aggregate shape + codegen) and the param SCHEMA (for the order payload + the
|
|
391
|
+
* `writeParams` plan fragment). The framework wires the lifecycle: status defaulting,
|
|
392
|
+
* the requestId keying, and the complete/fail receipt directives.
|
|
393
|
+
*
|
|
394
|
+
* `writeParams(taskInstanceId, params)` returns the `PlannedOp[]` for the tenant's OWN
|
|
395
|
+
* fields (the framework already writes the envelope) — the param half of the order
|
|
396
|
+
* plan, kept type-checked against the tenant's param fields via the `set()` helper.
|
|
397
|
+
*/
|
|
398
|
+
export function impureCapability<
|
|
399
|
+
const Id extends string,
|
|
400
|
+
const OrderId extends string,
|
|
401
|
+
const CompleteId extends string,
|
|
402
|
+
const FailId extends string,
|
|
403
|
+
ParamsShape extends z.ZodRawShape,
|
|
404
|
+
F extends Record<string, Field>,
|
|
405
|
+
>(spec: {
|
|
406
|
+
aggregateId: Id;
|
|
407
|
+
orderDirectiveId: OrderId;
|
|
408
|
+
completeDirectiveId: CompleteId;
|
|
409
|
+
failDirectiveId: FailId;
|
|
410
|
+
blockDirectiveId: string;
|
|
411
|
+
deadLetterDirectiveId: string;
|
|
412
|
+
/** The task's request-param FIELDS (merged onto the envelope for the aggregate shape). */
|
|
413
|
+
params: F;
|
|
414
|
+
/** The task's request-param Zod SHAPE (merged onto the order envelope schema). */
|
|
415
|
+
paramsSchema: ParamsShape;
|
|
416
|
+
/** Plan fragment writing the tenant's param fields for the order (envelope is auto). */
|
|
417
|
+
writeParams: (
|
|
418
|
+
taskInstanceId: string,
|
|
419
|
+
params: z.infer<z.ZodObject<ParamsShape>>,
|
|
420
|
+
) => PlannedOp[];
|
|
421
|
+
/**
|
|
422
|
+
* OPTIONAL plan fragment writing the tenant's STRUCTURED RESULT fields onto the task
|
|
423
|
+
* aggregate when the `complete` Receipt is authored (the lifecycle envelope + the
|
|
424
|
+
* `closesOrder` link are auto). The receipt's `resultRef` carries the content-addressed
|
|
425
|
+
* Evidence ref (the authoritative artifact); a tenant that ALSO wants the result in a
|
|
426
|
+
* typed, queryable field (e.g. a voice-note's `transcription`) supplies it here. The
|
|
427
|
+
* `resultRef` string is passed through so the tenant can derive the field value from the
|
|
428
|
+
* result when it is the same data (e.g. `transcription = resultRef`). Wire-identical to
|
|
429
|
+
* the Rust reactor's `result_fields` (`HandlerSuccess::with_result_field`).
|
|
430
|
+
*/
|
|
431
|
+
completeResult?: (taskInstanceId: string, resultRef: string) => PlannedOp[];
|
|
432
|
+
}): ImpureCapability<Id, ParamsShape, F> {
|
|
433
|
+
const taskAggregate = impureCapabilityAggregate(spec.aggregateId, spec.params);
|
|
434
|
+
|
|
435
|
+
// The order payload = the framework provenance envelope ⊕ the tenant's params.
|
|
436
|
+
const orderPayload = orderEnvelopeSchema.extend(spec.paramsSchema);
|
|
437
|
+
|
|
438
|
+
const order: RequirableDirective<OrderPayload<ParamsShape>> = directive(
|
|
439
|
+
spec.orderDirectiveId,
|
|
440
|
+
)
|
|
441
|
+
.creates(taskAggregate)
|
|
442
|
+
.payload(orderPayload)
|
|
443
|
+
.plan((p) => {
|
|
444
|
+
// The validated payload carries BOTH the envelope (required string/int) and the
|
|
445
|
+
// tenant params. `orderOps` writes the envelope; the tenant's `writeParams`
|
|
446
|
+
// writes its own fields. Extract the envelope explicitly (it is required on the
|
|
447
|
+
// schema, so these are present non-undefined values).
|
|
448
|
+
const envelope = orderEnvelopeSchema.parse(p);
|
|
449
|
+
const params = p as z.infer<z.ZodObject<ParamsShape>>;
|
|
450
|
+
return orderOps(
|
|
451
|
+
spec.aggregateId,
|
|
452
|
+
envelope,
|
|
453
|
+
spec.writeParams(impureCapabilityInstanceId(envelope.requestId), params),
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const complete = directive(spec.completeDirectiveId)
|
|
458
|
+
.mutates(taskAggregate)
|
|
459
|
+
.payload(completeSchema)
|
|
460
|
+
.plan((p) =>
|
|
461
|
+
completeOps(
|
|
462
|
+
spec.aggregateId,
|
|
463
|
+
p,
|
|
464
|
+
// Tenant structured-result ops (e.g. `transcription`) if the task declares them.
|
|
465
|
+
spec.completeResult?.(impureCapabilityInstanceId(p.requestId), p.resultRef) ?? [],
|
|
466
|
+
),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const fail = directive(spec.failDirectiveId)
|
|
470
|
+
.mutates(taskAggregate)
|
|
471
|
+
.payload(failSchema)
|
|
472
|
+
.plan((p) => failOps(spec.aggregateId, p));
|
|
473
|
+
|
|
474
|
+
const block = directive(spec.blockDirectiveId)
|
|
475
|
+
.mutates(taskAggregate)
|
|
476
|
+
.payload(blockSchema)
|
|
477
|
+
.plan((p) => blockOps(spec.aggregateId, p));
|
|
478
|
+
|
|
479
|
+
const deadLetter = directive(spec.deadLetterDirectiveId)
|
|
480
|
+
.mutates(taskAggregate)
|
|
481
|
+
.payload(deadLetterSchema)
|
|
482
|
+
.plan((p) => deadLetterOps(spec.aggregateId, p));
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
aggregate: taskAggregate,
|
|
486
|
+
order,
|
|
487
|
+
complete,
|
|
488
|
+
fail,
|
|
489
|
+
block,
|
|
490
|
+
deadLetter,
|
|
491
|
+
instanceId: impureCapabilityInstanceId,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ─────────────────────────── THE CAPABILITY PROVIDER ─────────────────────────
|
|
496
|
+
//
|
|
497
|
+
// The Order half (`order`) is an ordinary Intent authored by the peer through the one write
|
|
498
|
+
// path. The Receipt half (`complete`/`fail`) is an ordinary Intent authored by a capability
|
|
499
|
+
// PROVIDER — itself a Nomos author (`admission-hosting.md`: hosted/provider-originated
|
|
500
|
+
// intents author *through* a policy unit too"). The provider is the runtime ROLE; this
|
|
501
|
+
// section is its host-agnostic, PURE contract (the impure parts — watching + the side-effect —
|
|
502
|
+
// are the host's, and deliberately are NOT in the fold).
|
|
503
|
+
//
|
|
504
|
+
// THE LOOP (host pseudo-code; the host is the Rust git-holon or a Dart provider peer):
|
|
505
|
+
//
|
|
506
|
+
// for await (const task of watchRequestedTasks(taskAggregate)) { // impure: read
|
|
507
|
+
// let outcome: TaskOutcome;
|
|
508
|
+
// try {
|
|
509
|
+
// const resultRef = await runSideEffect(task); // impure: render/upload/provider work
|
|
510
|
+
// outcome = { kind: "completed", resultRef };
|
|
511
|
+
// } catch (e) {
|
|
512
|
+
// outcome = { kind: "failed", failureReason: String(e) }; // recorded, NOT retried
|
|
513
|
+
// }
|
|
514
|
+
// const receipt = impureCapabilityReceipt(impureCapability, task.requestId, outcome, hostClock());
|
|
515
|
+
// await dispatchTyped(receipt.directive, receipt.payload); // ordinary Intent, one gate
|
|
516
|
+
// }
|
|
517
|
+
//
|
|
518
|
+
// The reactor MUST be idempotent on a re-run of the SAME requested task: it should act
|
|
519
|
+
// only while `status === 'requested'` (a task already `completed`/`failed` is skipped),
|
|
520
|
+
// so a redelivered watch event or a replayed log produces no duplicate side-effect — the
|
|
521
|
+
// terminal-once gate/envelope law prevents a second terminal fact.
|
|
522
|
+
|
|
523
|
+
/** The result of running a impure capability's side-effect — the input to {@link impureCapabilityReceipt}. */
|
|
524
|
+
export type TaskOutcome =
|
|
525
|
+
| {
|
|
526
|
+
readonly kind: "completed";
|
|
527
|
+
/** The content-addressed result (a blob ref / download url) as a JSON string. */
|
|
528
|
+
readonly resultRef: string;
|
|
529
|
+
}
|
|
530
|
+
| {
|
|
531
|
+
readonly kind: "failed";
|
|
532
|
+
readonly failureReason: string;
|
|
533
|
+
}
|
|
534
|
+
| {
|
|
535
|
+
readonly kind: "blocked";
|
|
536
|
+
readonly blockedReason: string;
|
|
537
|
+
readonly nextAction: string;
|
|
538
|
+
}
|
|
539
|
+
| {
|
|
540
|
+
readonly kind: "deadLettered";
|
|
541
|
+
readonly deadLetterReason: string;
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* A Receipt intent the reactor should author: the `complete`/`fail` directive to dispatch +
|
|
546
|
+
* its typed payload. The directive and payload are INTERNALLY CORRELATED (built
|
|
547
|
+
* together), so the host dispatches them as a pair — `run(agg, ports)` lowers them
|
|
548
|
+
* through their own directive (keeping the directive↔payload pairing sound; a consumer
|
|
549
|
+
* never re-pairs a union-typed directive with a union-typed payload). `kind` is the
|
|
550
|
+
* discriminant a host can switch on without inspecting the directive.
|
|
551
|
+
*/
|
|
552
|
+
export interface TaskReceipt<P> {
|
|
553
|
+
/** Which receipt this is — success, failure, blocked, or DLQ. */
|
|
554
|
+
readonly kind: "complete" | "fail" | "block" | "deadLetter";
|
|
555
|
+
readonly directive: RequirableDirective<P>;
|
|
556
|
+
readonly payload: P;
|
|
557
|
+
/** Lower this receipt through its own directive (the correlated, sound pairing). */
|
|
558
|
+
readonly run: (agg: AggregateHandle, ports: Ports) => WireIntent;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* impureCapabilityReceipt — the PURE map from a side-effect {@link TaskOutcome} to the
|
|
563
|
+
* ordinary Receipt intent the reactor authors. Picks `complete` (with `resultRef` +
|
|
564
|
+
* `completedAt`) for a success and `fail` (with `failureReason` + `failedAt`) for a failure,
|
|
565
|
+
* stamping the
|
|
566
|
+
* host-supplied terminal time `now` (epoch-ms). Returning the {@link TaskReceipt}
|
|
567
|
+
* keeps the reactor's DECISION pure + unit-testable; the host does the impure
|
|
568
|
+
* `dispatchTyped(receipt.directive, receipt.payload)` (or `receipt.run(...)` in a
|
|
569
|
+
* typed harness).
|
|
570
|
+
*
|
|
571
|
+
* `now` is the host's clock reading (the reactor is an authoring peer; its authoring HLC comes
|
|
572
|
+
* from the host port like any author) — passed in so this function stays pure.
|
|
573
|
+
*/
|
|
574
|
+
export function impureCapabilityReceipt<
|
|
575
|
+
Id extends string,
|
|
576
|
+
ParamsShape extends z.ZodRawShape,
|
|
577
|
+
F extends Record<string, Field>,
|
|
578
|
+
>(
|
|
579
|
+
task: ImpureCapability<Id, ParamsShape, F>,
|
|
580
|
+
requestId: string,
|
|
581
|
+
outcome: TaskOutcome,
|
|
582
|
+
now: number,
|
|
583
|
+
):
|
|
584
|
+
| TaskReceipt<z.infer<typeof completeSchema>>
|
|
585
|
+
| TaskReceipt<z.infer<typeof failSchema>>
|
|
586
|
+
| TaskReceipt<z.infer<typeof blockSchema>>
|
|
587
|
+
| TaskReceipt<z.infer<typeof deadLetterSchema>> {
|
|
588
|
+
if (outcome.kind === "completed") {
|
|
589
|
+
const directive = task.complete;
|
|
590
|
+
const payload: z.infer<typeof completeSchema> = {
|
|
591
|
+
requestId,
|
|
592
|
+
resultRef: outcome.resultRef,
|
|
593
|
+
completedAt: now,
|
|
594
|
+
};
|
|
595
|
+
return {
|
|
596
|
+
kind: "complete",
|
|
597
|
+
directive,
|
|
598
|
+
payload,
|
|
599
|
+
run: (agg, ports) => executeDirectiveToIntent(directive, agg, payload, ports),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
if (outcome.kind === "failed") {
|
|
603
|
+
const directive = task.fail;
|
|
604
|
+
const payload: z.infer<typeof failSchema> = {
|
|
605
|
+
requestId,
|
|
606
|
+
failureReason: outcome.failureReason,
|
|
607
|
+
failedAt: now,
|
|
608
|
+
};
|
|
609
|
+
return {
|
|
610
|
+
kind: "fail",
|
|
611
|
+
directive,
|
|
612
|
+
payload,
|
|
613
|
+
run: (agg, ports) => executeDirectiveToIntent(directive, agg, payload, ports),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
if (outcome.kind === "blocked") {
|
|
617
|
+
const directive = task.block;
|
|
618
|
+
const payload: z.infer<typeof blockSchema> = {
|
|
619
|
+
requestId,
|
|
620
|
+
blockedReason: outcome.blockedReason,
|
|
621
|
+
blockedAt: now,
|
|
622
|
+
nextAction: outcome.nextAction,
|
|
623
|
+
};
|
|
624
|
+
return {
|
|
625
|
+
kind: "block",
|
|
626
|
+
directive,
|
|
627
|
+
payload,
|
|
628
|
+
run: (agg, ports) => executeDirectiveToIntent(directive, agg, payload, ports),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
const directive = task.deadLetter;
|
|
632
|
+
const payload: z.infer<typeof deadLetterSchema> = {
|
|
633
|
+
requestId,
|
|
634
|
+
deadLetterReason: outcome.deadLetterReason,
|
|
635
|
+
deadLetteredAt: now,
|
|
636
|
+
};
|
|
637
|
+
return {
|
|
638
|
+
kind: "deadLetter",
|
|
639
|
+
directive,
|
|
640
|
+
payload,
|
|
641
|
+
run: (agg, ports) => executeDirectiveToIntent(directive, agg, payload, ports),
|
|
642
|
+
};
|
|
643
|
+
}
|