@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.
Files changed (53) hide show
  1. package/LICENSE.md +36 -0
  2. package/compile_package.mjs +50 -0
  3. package/package.json +59 -0
  4. package/src/aggregate.ts +167 -0
  5. package/src/authoring.ts +119 -0
  6. package/src/build_package.ts +636 -0
  7. package/src/certified_read.ts +313 -0
  8. package/src/codegen_dart.ts +2732 -0
  9. package/src/codegen_dot.ts +466 -0
  10. package/src/codegen_provider_dart.ts +358 -0
  11. package/src/codegen_ts.ts +365 -0
  12. package/src/codegen_usda.ts +388 -0
  13. package/src/combined.ts +195 -0
  14. package/src/compile_engine.ts +567 -0
  15. package/src/compile_package_main.ts +496 -0
  16. package/src/compose.ts +317 -0
  17. package/src/count.ts +218 -0
  18. package/src/ctx.ts +57 -0
  19. package/src/derived.ts +138 -0
  20. package/src/directive.ts +306 -0
  21. package/src/drivers.ts +95 -0
  22. package/src/emits_guard.ts +123 -0
  23. package/src/engine_entry.ts +449 -0
  24. package/src/exists.ts +170 -0
  25. package/src/extremum.ts +227 -0
  26. package/src/fields.ts +291 -0
  27. package/src/framework/bootstrap.ts +22 -0
  28. package/src/framework/disclosure.ts +108 -0
  29. package/src/framework/domain_lifecycle.ts +108 -0
  30. package/src/framework/identity.ts +537 -0
  31. package/src/framework/impure_capability.ts +643 -0
  32. package/src/framework/rbac.ts +418 -0
  33. package/src/framework/repair.ts +150 -0
  34. package/src/framework/sync_lifecycle.ts +125 -0
  35. package/src/framework/workspace_invariant.ts +128 -0
  36. package/src/framework/workspaces.ts +817 -0
  37. package/src/index.ts +317 -0
  38. package/src/manifest.ts +947 -0
  39. package/src/ops.ts +145 -0
  40. package/src/ordered_read.ts +228 -0
  41. package/src/predicate.ts +203 -0
  42. package/src/query/compile.ts +0 -0
  43. package/src/query/relations.ts +144 -0
  44. package/src/query.ts +151 -0
  45. package/src/read.ts +54 -0
  46. package/src/relation.ts +189 -0
  47. package/src/report/csv.ts +54 -0
  48. package/src/report.ts +401 -0
  49. package/src/spatial.ts +115 -0
  50. package/src/sum.ts +194 -0
  51. package/src/usd.ts +563 -0
  52. package/src/wire.ts +149 -0
  53. package/src/wire_encode.ts +250 -0
@@ -0,0 +1,947 @@
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
+ * Domain identity = canonical SEMANTIC manifest hash (#136).
10
+ *
11
+ * "The bundle is not the law." A domain's IDENTITY in the certification gate is a
12
+ * content hash. Historically that hash was `sha256(stripPathMarkers(esbuild_bundle))`
13
+ * (see `golden/build_golden.mjs`): it depended on esbuild's output format — version,
14
+ * whitespace, declaration ordering — and on the domain's file LOCATION. That is
15
+ * fragile: a bundler upgrade or a file move changes the identity of unchanged logic.
16
+ *
17
+ * This module derives identity instead from the domain's CANONICAL MANIFEST — built
18
+ * directly from the in-memory DSL objects (`DomainModule`). It captures the domain's
19
+ * CONTRACT: its aggregates' field→driver schemas, and each directive's target /
20
+ * marker / required capability / scope-presence / payload SHAPE. It deliberately does
21
+ * NOT capture executable `plan`/`scopeFrom` BEHAVIOUR — that is a separate
22
+ * `artifact_hash` (out of scope here). The consequence is the desired property:
23
+ * refactoring a directive's `plan` body while keeping its signature identical does
24
+ * NOT change the domain hash. The hash tracks the contract, not the code, and is
25
+ * independent of both bundling AND file location while remaining sensitive to real
26
+ * semantic/contract changes.
27
+ */
28
+ import { createHash } from "node:crypto";
29
+ import { z } from "zod";
30
+ import { encodeKernelSchema, encodeRefMode } from "./wire_encode.js";
31
+ import type { WireSchema, WireRefMode } from "./wire.js";
32
+ import type { DomainModule } from "./codegen_dart.js";
33
+ import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
34
+ import type { QueryDecl } from "./query.js";
35
+ import { finishCount, type AnyCount } from "./count.js";
36
+ import type { SpatialDecl } from "./spatial.js";
37
+ import { finishSum, type AnySum } from "./sum.js";
38
+ import { finishExtremum, type AnyExtremum } from "./extremum.js";
39
+ import { existsAsCount, type AnyExists } from "./exists.js";
40
+ import type { OrderedReadDecl } from "./ordered_read.js";
41
+ import type { CanonicalPred } from "./predicate.js";
42
+ import type { DerivedDecl } from "./derived.js";
43
+ import type { CombinedDecl } from "./combined.js";
44
+ import type { CertifiedReadDecl } from "./certified_read.js";
45
+ import type { RelationDecl } from "./relation.js";
46
+
47
+ /** One captured `[fieldName, zodKind]` payload field, with optionality. */
48
+ export interface CanonicalPayloadField {
49
+ /** The payload field name. */
50
+ readonly name: string;
51
+ /** The Zod 4 `def.type` of the field's unwrapped type, e.g. "string". */
52
+ readonly type: string;
53
+ /** True when the field is `optional` / `default` wrapped. */
54
+ readonly optional: boolean;
55
+ }
56
+
57
+ /** One captured projected read-field return schema. */
58
+ export interface CanonicalProjectionReturn {
59
+ /** The Zod 4 `def.type` of the unwrapped return type, e.g. "boolean". */
60
+ readonly type: string;
61
+ /** True when the projected value may be null/absent. */
62
+ readonly nullable: boolean;
63
+ /** The return value as standard JSON Schema, emitted from the same TS/Zod source. */
64
+ readonly jsonSchema: unknown;
65
+ }
66
+
67
+ /** One captured aggregate: its id + its field→driver schema (keys SORTED). */
68
+ export interface CanonicalAggregate {
69
+ readonly id: string;
70
+ readonly schema: WireSchema;
71
+ /**
72
+ * PRESENCE ONLY — the body is executable (like a directive `plan`), NOT hashed.
73
+ * OMITTED ENTIRELY when absent, so a predicate-free aggregate is byte-identical
74
+ * in the manifest to before this field existed (golden hashes UNCHANGED).
75
+ */
76
+ readonly hasInvariant?: true;
77
+ /** The LAW-DECLARED instance cap; omitted when uncapped (hash-stable). */
78
+ readonly cap?: number;
79
+ }
80
+
81
+ /** One captured directive contract — NO `plan`/`scopeFrom` behaviour. */
82
+ export interface CanonicalDirective {
83
+ readonly id: string;
84
+ /** The target aggregate id. */
85
+ readonly target: string;
86
+ readonly marker: WireRefMode;
87
+ /** The required capability — defaults to the directive id. */
88
+ readonly requiresCapability: string;
89
+ /** Whether the directive declares a `scopeFrom` (presence only, not behaviour). */
90
+ readonly scopeFrom: boolean;
91
+ /** The payload's top-level object shape as SORTED `[name, type]` pairs. */
92
+ readonly payload: CanonicalPayloadField[];
93
+ /**
94
+ * The directive payload as standard JSON Schema, emitted from the same TS/Zod source
95
+ * that the sealed GitHolon engine validates. This is the cross-language validation
96
+ * artifact: Dart/hosts can validate dynamic maps with a JSON Schema library without
97
+ * re-authoring the schema in Dart.
98
+ */
99
+ readonly payloadJsonSchema: unknown;
100
+ /**
101
+ * The directive's DECLARED read boundary (sorted ref types). The "IR for law
102
+ * boundaries": the boundary is part of the domain identity. OMITTED ENTIRELY when
103
+ * the directive declares no reads — so a boundary-free directive is byte-identical
104
+ * to before this key existed (pinned golden hashes stay UNCHANGED).
105
+ */
106
+ readonly reads?: string[];
107
+ /**
108
+ * The directive's DECLARED emit boundary: event type → `{ max? }`. Keys sorted by
109
+ * `canonicalJson`. OMITTED ENTIRELY when the directive declares no emits, for the
110
+ * same omit-when-empty hash-invariance reason as {@link reads}.
111
+ */
112
+ readonly emits?: Record<string, { max?: number }>;
113
+ /**
114
+ * The directive's DECLARED CertifiedReads (survival Constraint 5; `certified_read.md`): the
115
+ * profiled write-path reads its `decide()` consults, as `query_id → { sql, multiRow,
116
+ * uniqueTieBreakers }`. SORTED BY `query_id` (the SET of declared reads is the contract).
117
+ * Part of the domain identity: the gate DERIVES the required-read set from these `query_id`s
118
+ * (the vacuous-pass close), and the SQL RECIPE + shape are captured so a recipe change moves
119
+ * the hash (a different read = a different contract). OMITTED ENTIRELY when the directive
120
+ * declares no write-path read — so a read-free directive is byte-identical in the manifest to
121
+ * before this key existed (pinned golden hashes stay UNCHANGED).
122
+ */
123
+ readonly certifiedReads?: CanonicalCertifiedRead[];
124
+ /**
125
+ * The directive's DECLARED required HOST CAPABILITY ports (`TARGET_deps.dot` cluster_ports;
126
+ * the HOST/PORT axis, invariant 3): the capability ports the host must PROVIDE for the
127
+ * directive to run, checked FAIL-CLOSED (`required ⊆ provided`) at policy LOAD. SORTED (the
128
+ * SET is the contract; order is incidental). DISTINCT from {@link requiresCapability} (the
129
+ * AUTHZ axis "may this actor?", a single string) — this is the HOST axis "can this host
130
+ * physically?" (a set of open-set port ids). Part of the domain identity: a different required
131
+ * port is a different contract, so adding/changing one MOVES the hash. OMITTED ENTIRELY when
132
+ * the directive declares no host capability — so a host-capability-free directive is
133
+ * byte-identical in the manifest to before this key existed (pinned golden hashes stay
134
+ * UNCHANGED), exactly how {@link reads}/{@link relations} stay hash-neutral.
135
+ */
136
+ readonly requiresHostCapability?: string[];
137
+ /**
138
+ * The directive's DECLARED cross-workspace relations (`cross_workspace.md` §2): the relations
139
+ * a cross-workspace PR proposing this directive is adjudicated against. Each carries the
140
+ * source/target endpoints, the bounded evidence read it discloses (the disclosure contract +
141
+ * anti-smuggle key, §2.4), and whether an invariant guards it (PRESENCE only — the predicate
142
+ * body is executable behaviour, NOT hashed, exactly like a directive `plan`). SORTED BY the
143
+ * relation `id`. Part of the domain identity: the gate (`kernel::manifest_view`) resolves the
144
+ * relation by the proposed directive + verifies a carried witness against the declared evidence
145
+ * read; a recipe change (different aggregate/fields/predicate) MOVES the hash. OMITTED ENTIRELY
146
+ * when the directive declares no relation — so a relation-free directive is byte-identical in
147
+ * the manifest to before this key existed (pinned golden hashes stay UNCHANGED).
148
+ */
149
+ readonly relations?: CanonicalRelation[];
150
+ }
151
+
152
+ /** One captured cross-workspace relation — the SEMANTIC half of X1 in the manifest
153
+ * (`cross_workspace.md` §2.1 / §2.4). `id`/`source`/`target`/`proposes` are the endpoints +
154
+ * the proposed directive; `evidence` is the bounded declared read (disclosure contract +
155
+ * anti-smuggle key); `hasInvariant` is the guard's PRESENCE (the body is executable, NOT
156
+ * hashed). A recipe change moves the domain hash. */
157
+ export interface CanonicalRelation {
158
+ readonly id: string;
159
+ readonly source: string;
160
+ readonly target: string;
161
+ readonly proposes: string;
162
+ readonly evidence: CanonicalRelationEvidence;
163
+ readonly hasInvariant: boolean;
164
+ }
165
+
166
+ /** The bounded evidence read a relation discloses over the SOURCE, in the manifest: the source
167
+ * aggregate, the exact SELECT fields (SORTED — the set is the contract), the WHERE predicate,
168
+ * and the freshness `kind`. This IS the disclosure contract + the anti-smuggle key. */
169
+ export interface CanonicalRelationEvidence {
170
+ readonly aggregate: string;
171
+ readonly select: string[];
172
+ readonly where: string;
173
+ readonly kind: string;
174
+ }
175
+
176
+ /** One captured DECLARED CertifiedRead — the profiled write-path read identity in the manifest.
177
+ * `queryId` is the gate's required-read key; `sql`/`multiRow`/`uniqueTieBreakers` are the recipe
178
+ * + shape the admission re-run + the profile pin (a recipe change moves the domain hash). */
179
+ export interface CanonicalCertifiedRead {
180
+ readonly queryId: string;
181
+ readonly sql: string;
182
+ readonly multiRow: boolean;
183
+ readonly uniqueTieBreakers: string[];
184
+ }
185
+
186
+ /**
187
+ * One captured query declaration — the read-side closure's named, INDEXED read.
188
+ * `key` is the index column order, PRESERVED (NOT sorted): the order of the index
189
+ * key fields is part of the contract, so two queries differing only in key order
190
+ * are distinct identities.
191
+ */
192
+ export interface CanonicalQuery {
193
+ readonly id: string;
194
+ /** Index key fields, in DECLARED order (index column order — preserved). */
195
+ readonly key: string[];
196
+ /** The aggregate TYPE id the query returns, e.g. `SampleThingAggregate`. */
197
+ readonly returns: string;
198
+ }
199
+
200
+ /**
201
+ * One captured count declaration — the read-side closure's named, MAINTAINED tally.
202
+ * Mirrors {@link CanonicalQuery}: a count's `of`-type + (optional) `where` predicate +
203
+ * (optional) `by` group-by field are part of the domain CONTRACT (changing them changes
204
+ * the maintained counter), so they are part of the identity.
205
+ *
206
+ * OMIT-WHEN-ABSENT:
207
+ * `where` is OMITTED ENTIRELY for a predicate-free count — so an unfiltered count is
208
+ * byte-identical in the manifest to before `where` existed (golden hashes UNCHANGED).
209
+ * `by` is OMITTED ENTIRELY for a grand-total count (same discipline, always was).
210
+ */
211
+ export interface CanonicalCount {
212
+ readonly id: string;
213
+ /** The aggregate TYPE id the count tallies, e.g. `SiteRootAggregate`. */
214
+ readonly of: string;
215
+ /**
216
+ * The predicate filtering which aggregates are counted. OMITTED when no predicate
217
+ * (an unfiltered count is byte-identical in the manifest to before this existed).
218
+ * The canonical form is insertion-order–independent (clauses sorted deterministically
219
+ * by `canonicalizePred`), so byte-identical predicate logic → byte-identical hash.
220
+ */
221
+ readonly where?: CanonicalPred;
222
+ /** The group-by field, in DECLARED form. OMITTED for a grand-total count. */
223
+ readonly by?: string;
224
+ }
225
+
226
+ /**
227
+ * One captured SPATIAL index declaration — the read-side closure's named, MAINTAINED
228
+ * GEOSPATIAL (R*Tree) index. Mirrors {@link CanonicalCount}: a spatial index's `of`-type and
229
+ * the geometry field it indexes (`on`) are part of the domain CONTRACT (changing either
230
+ * changes the maintained bounding-box index), so they are part of the identity. There is no
231
+ * optional/omit-when-absent sub-field — both `id`/`of`/`on` are always present.
232
+ */
233
+ export interface CanonicalSpatial {
234
+ readonly id: string;
235
+ /** The aggregate TYPE id the spatial index covers, e.g. `TrackableAsset`. */
236
+ readonly of: string;
237
+ /** The GeoJSON geometry field whose bounding box is indexed, e.g. `geoPosition`. */
238
+ readonly on: string;
239
+ }
240
+
241
+ /**
242
+ * One captured sum declaration — the read-side closure's named, MAINTAINED numeric
243
+ * aggregation. Analogous to {@link CanonicalCount}: a sum's `of`-type, `sumField`,
244
+ * optional `where` predicate, and optional `by` group-by field are all part of the
245
+ * domain CONTRACT. Changing any of them changes the meaning of the maintained total.
246
+ *
247
+ * OMIT-WHEN-ABSENT: `where` and `by` are OMITTED ENTIRELY when not declared — so a
248
+ * predicate-free, grand-total sum is byte-identical to the minimal `{id, of, sumField}`
249
+ * form (golden hashes stable). Same discipline as {@link CanonicalCount}.
250
+ */
251
+ export interface CanonicalSum {
252
+ readonly id: string;
253
+ /** The aggregate TYPE id the sum tallies. */
254
+ readonly of: string;
255
+ /**
256
+ * The `int`-kind field whose values are accumulated, e.g. `"itemValue"`. MUST be
257
+ * an `int`-kind field of the `of`-aggregate (validated at manifest-load in Rust).
258
+ */
259
+ readonly sumField: string;
260
+ /**
261
+ * The predicate filtering which aggregates contribute. OMITTED when no predicate.
262
+ */
263
+ readonly where?: CanonicalPred;
264
+ /** The group-by field. OMITTED for a grand-total sum. */
265
+ readonly by?: string;
266
+ }
267
+
268
+ /**
269
+ * One captured DERIVED read field — the read-side closure's engine-projected pure field.
270
+ * Mirrors {@link CanonicalCount}: a derived field's NAME (`id`) + the aggregate it derives
271
+ * from (`of`) are part of the domain CONTRACT (the read schema), so they are part of the
272
+ * identity. The fn BODY is DELIBERATELY NOT captured — it is executable behaviour, exactly
273
+ * like a directive's `plan` (which `domainManifest` also omits), so refactoring the derive
274
+ * fn while keeping its `(id, of)` identical does NOT change the domain hash.
275
+ */
276
+ export interface CanonicalDerived {
277
+ readonly id: string;
278
+ /** The aggregate TYPE id the field is derived from, e.g. `SupportSessionAggregate`. */
279
+ readonly of: string;
280
+ /** The projected value schema. The fn body is not hashed; the value contract is. */
281
+ readonly returns: CanonicalProjectionReturn;
282
+ }
283
+
284
+ /**
285
+ * One captured COMBINED read field — the read-side closure's engine-projected CROSS-
286
+ * AGGREGATE JOIN field. Mirrors {@link CanonicalDerived} but with the JOIN axis: a
287
+ * combined field's NAME (`id`), the OWNER aggregate (`of`), the REF FIELD it joins
288
+ * through (`refField`), and the RELATED type it queries (`reads`) are all part of the
289
+ * domain CONTRACT (the read schema + the cross-aggregate invalidation edge), so they are
290
+ * part of the identity. The fn BODY is DELIBERATELY NOT captured — executable behaviour,
291
+ * exactly like a directive's `plan` / a derived field's fn, so refactoring the compose fn
292
+ * while keeping its `(id, of, refField, reads)` identical does NOT change the domain hash.
293
+ */
294
+ export interface CanonicalCombined {
295
+ readonly id: string;
296
+ /** The OWNER aggregate TYPE id the field lives on, e.g. `BuildingAggregate`. */
297
+ readonly of: string;
298
+ /** The owner's REF FIELD whose value is the related aggregate's id, e.g. `siteId`. */
299
+ readonly refField: string;
300
+ /** The RELATED aggregate TYPE the field reads (the invalidation key), e.g. `SiteRootAggregate`. */
301
+ readonly reads: string;
302
+ /** The projected value schema. The fn body is not hashed; the value contract is. */
303
+ readonly returns: CanonicalProjectionReturn;
304
+ }
305
+
306
+ /**
307
+ * One captured MIN or MAX declaration — structurally identical to {@link CanonicalSum}
308
+ * (same `of`/`valueField`/`where?`/`by?` shape) except it carries an explicit `kind`
309
+ * discriminant (`"min"` | `"max"`). OMIT-WHEN-ABSENT: `where` and `by` follow the same
310
+ * omit-when-not-declared discipline as `CanonicalSum`.
311
+ */
312
+ export interface CanonicalExtremum {
313
+ readonly id: string;
314
+ readonly kind: "min" | "max";
315
+ /** The aggregate TYPE id the extremum tallies. */
316
+ readonly of: string;
317
+ /** The `int`-kind field being extremised. */
318
+ readonly valueField: string;
319
+ /** The predicate filtering which aggregates contribute. OMITTED when no predicate. */
320
+ readonly where?: CanonicalPred;
321
+ /** The group-by field. OMITTED for a grand-total extremum. */
322
+ readonly by?: string;
323
+ }
324
+
325
+ /**
326
+ * One captured ORDERED READ declaration — an order-sensitive row read with a MANDATORY
327
+ * declared total order. `orderKey` is NON-OPTIONAL (a missing key is a manifest
328
+ * validation error, rejected LOUDLY at parse with no fallback). `limit` = 1 for `first`,
329
+ * `n` for `take(n)`. OMIT-WHEN-ABSENT: `where` is omitted when no predicate.
330
+ */
331
+ export interface CanonicalOrderedRead {
332
+ readonly id: string;
333
+ /** The aggregate TYPE id the read returns. */
334
+ readonly of: string;
335
+ /** The REQUIRED total-order key (a declared scalar field of `of`). */
336
+ readonly orderKey: string;
337
+ /** Whether to sort descending. Default absent = ascending. */
338
+ readonly orderDesc?: true;
339
+ /** The optional predicate. OMITTED when no predicate. */
340
+ readonly where?: CanonicalPred;
341
+ /** LIMIT — 1 for `first`, n for `take(n)`. */
342
+ readonly limit: number;
343
+ }
344
+
345
+ /**
346
+ * A workspace invariant's PRESENCE in the hashed identity surface (#266): its id + the
347
+ * directive it fires `on`. PRESENCE ONLY — the executable `reads`/`assert` bodies are NOT
348
+ * hashed (they ship in the engine bundle), exactly like the aggregate `hasInvariant` flag.
349
+ * Changing a body does NOT move the domain hash; adding/removing an invariant DOES (it is law).
350
+ */
351
+ export interface CanonicalWorkspaceInvariant {
352
+ readonly id: string;
353
+ readonly on: string;
354
+ }
355
+
356
+ /** A domain's canonical semantic manifest — the hashed identity surface. */
357
+ export interface CanonicalManifest {
358
+ readonly domain: string;
359
+ readonly aggregates: CanonicalAggregate[];
360
+ readonly directives: CanonicalDirective[];
361
+ /**
362
+ * The domain's WORKSPACE INVARIANTS (#266), SORTED by id, PRESENCE ONLY (id + `on`).
363
+ * OMITTED ENTIRELY when the domain declares none — so an invariant-free domain is
364
+ * byte-identical to before this key existed (pinned golden hashes UNCHANGED).
365
+ */
366
+ readonly workspaceInvariants?: CanonicalWorkspaceInvariant[];
367
+ /**
368
+ * The domain's NAMED, INDEXED read declarations, SORTED by id. OMITTED ENTIRELY
369
+ * when the domain declares no query — so a query-free domain is byte-identical to
370
+ * before this key existed (the pinned golden hash stays UNCHANGED). Each query's
371
+ * `key` preserves its DECLARED order (index column order), unlike a directive's
372
+ * incidental read SET.
373
+ */
374
+ readonly queries?: CanonicalQuery[];
375
+ /**
376
+ * The domain's NAMED, MAINTAINED count declarations, SORTED by id. OMITTED ENTIRELY
377
+ * when the domain declares no count — so a count-free domain is byte-identical to
378
+ * before this key existed (the pinned golden hash stays UNCHANGED), exactly like
379
+ * {@link queries}. The aggregation analogue of the read-side closure. Each entry may
380
+ * carry an optional `where` predicate (Slice 1 — omit-when-absent; predicate-free
381
+ * counts are byte-identical to before `where` was introduced).
382
+ */
383
+ readonly counts?: CanonicalCount[];
384
+ /**
385
+ * The domain's NAMED, MAINTAINED SPATIAL (R*Tree) index declarations, SORTED by id.
386
+ * OMITTED ENTIRELY when the domain declares no spatial index — so a spatial-free domain is
387
+ * byte-identical to before this key existed (the pinned golden hash stays UNCHANGED),
388
+ * exactly like {@link counts}. The geospatial analogue of the read-side closure. Each entry
389
+ * carries `{id, of, on}` — the geometry field whose bounding box the engine maintains.
390
+ */
391
+ readonly spatials?: CanonicalSpatial[];
392
+ /**
393
+ * The domain's NAMED, MAINTAINED sum declarations, SORTED by id. OMITTED ENTIRELY
394
+ * when the domain declares no sum — so a sum-free domain is byte-identical to before
395
+ * this key existed (the pinned golden hash stays UNCHANGED), exactly like
396
+ * {@link counts}. The numeric-accumulation analogue of count (Slice 1).
397
+ */
398
+ readonly sums?: CanonicalSum[];
399
+ /**
400
+ * The domain's DERIVED read-field declarations, SORTED by id. OMITTED ENTIRELY when the
401
+ * domain declares no derived field — so a derived-free domain is byte-identical to before
402
+ * this key existed (the pinned golden hash stays UNCHANGED), exactly like {@link counts}.
403
+ * Each carries ONLY `{id, of}` — the fn body is NOT hashed (executable behaviour, like a
404
+ * directive's `plan`).
405
+ */
406
+ readonly deriveds?: CanonicalDerived[];
407
+ /**
408
+ * The domain's COMBINED read-field declarations, SORTED by id. OMITTED ENTIRELY when the
409
+ * domain declares no combined field — so a combined-free domain is byte-identical to
410
+ * before this key existed (the pinned golden hash stays UNCHANGED), exactly like
411
+ * {@link deriveds}. Each carries `{id, of, refField, reads}` — the fn body is NOT hashed.
412
+ */
413
+ readonly combineds?: CanonicalCombined[];
414
+ /**
415
+ * The domain's MAINTAINED MIN declarations, SORTED by id. OMITTED ENTIRELY when the
416
+ * domain declares no min — so a min-free domain is byte-identical to before this key
417
+ * existed (golden hashes unchanged). Same omit-when-absent discipline as `sums`.
418
+ */
419
+ readonly mins?: CanonicalExtremum[];
420
+ /**
421
+ * The domain's MAINTAINED MAX declarations, SORTED by id. OMITTED ENTIRELY when the
422
+ * domain declares no max. Same discipline as `mins`.
423
+ */
424
+ readonly maxes?: CanonicalExtremum[];
425
+ /**
426
+ * The domain's ORDERED ROW READ declarations (first/take.orderBy), SORTED by id.
427
+ * OMITTED ENTIRELY when the domain declares none. Each carries the REQUIRED `orderKey`
428
+ * (non-optional at both TS and manifest levels), optional predicate, and `limit`.
429
+ */
430
+ readonly orderedReads?: CanonicalOrderedRead[];
431
+ }
432
+
433
+ /** Minimal structural view of Zod 4 schema internals we read deterministically. */
434
+ interface ZodLike {
435
+ _def?: {
436
+ type?: string;
437
+ innerType?: ZodLike;
438
+ shape?: Record<string, ZodLike> | (() => Record<string, ZodLike>);
439
+ };
440
+ def?: {
441
+ type?: string;
442
+ innerType?: ZodLike;
443
+ shape?: Record<string, ZodLike> | (() => Record<string, ZodLike>);
444
+ };
445
+ shape?: Record<string, ZodLike>;
446
+ }
447
+
448
+ function zodDef(zod: ZodLike): NonNullable<ZodLike["_def"]> {
449
+ return zod._def ?? zod.def ?? {};
450
+ }
451
+
452
+ function zodTypeName(zod: ZodLike): string {
453
+ const def = zodDef(zod);
454
+ return def.type ?? "unknown";
455
+ }
456
+
457
+ function zodObjectShape(zod: ZodLike): Record<string, ZodLike> | undefined {
458
+ const shape = zodDef(zod).shape;
459
+ if (typeof shape === "function") return shape();
460
+ if (shape !== undefined) return shape;
461
+ return zod.shape;
462
+ }
463
+
464
+ /**
465
+ * Sort an object's keys so the schema's field order is irrelevant to identity. The
466
+ * authored field order on an aggregate or payload is incidental; only the SET of
467
+ * field→driver / field→type pairs is the contract.
468
+ */
469
+ function sortedSchema(schema: WireSchema): WireSchema {
470
+ const out: WireSchema = {};
471
+ for (const key of Object.keys(schema).sort()) out[key] = schema[key]!;
472
+ return out;
473
+ }
474
+
475
+ /**
476
+ * Unwrap ZodOptional / ZodDefault to recover `{ optional, type }`. The OPTIONALITY
477
+ * is part of the contract (an optional field is a different shape); the wrapper
478
+ * identity (Optional vs Default) is NOT — both mean "may be absent" to a caller.
479
+ */
480
+ function unwrapPayloadType(zod: ZodLike): { optional: boolean; type: string } {
481
+ let cur = zod;
482
+ let optional = false;
483
+ // Peel optional/default wrappers (possibly nested) to the inner contract type.
484
+ while (zodTypeName(cur) === "optional" || zodTypeName(cur) === "default") {
485
+ optional = true;
486
+ const inner = zodDef(cur).innerType;
487
+ if (inner === undefined) break;
488
+ cur = inner;
489
+ }
490
+ return { optional, type: zodTypeName(cur) };
491
+ }
492
+
493
+ function unwrapProjectionReturn(zod: ZodLike): { nullable: boolean; type: string } {
494
+ let cur = zod;
495
+ let nullable = false;
496
+ while (
497
+ zodTypeName(cur) === "optional" ||
498
+ zodTypeName(cur) === "default" ||
499
+ zodTypeName(cur) === "nullable"
500
+ ) {
501
+ nullable = true;
502
+ const inner = zodDef(cur).innerType;
503
+ if (inner === undefined) break;
504
+ cur = inner;
505
+ }
506
+ return { nullable, type: zodTypeName(cur) };
507
+ }
508
+
509
+ function canonicalProjectionReturn(schema: unknown): CanonicalProjectionReturn {
510
+ const { nullable, type } = unwrapProjectionReturn(schema as ZodLike);
511
+ return {
512
+ type,
513
+ nullable,
514
+ jsonSchema: z.toJSONSchema(schema as z.ZodTypeAny),
515
+ };
516
+ }
517
+
518
+ /**
519
+ * Canonically capture a directive's payload: the top-level object's fields as
520
+ * SORTED `[name, type]` pairs (with optionality). A non-object top-level schema
521
+ * (rare) captures as a single synthetic field carrying its type name.
522
+ */
523
+ function canonicalPayload(payloadSchema: unknown): CanonicalPayloadField[] {
524
+ const zod = payloadSchema as ZodLike;
525
+ const shape = zodObjectShape(zod);
526
+ if (zodTypeName(zod) !== "object" || shape === undefined) {
527
+ // Not a top-level object: thing the bare type so a shape change still moves
528
+ // the hash, without inventing field structure.
529
+ return [{ name: "", type: zodTypeName(zod), optional: false }];
530
+ }
531
+ return Object.keys(shape)
532
+ .sort()
533
+ .map((name) => {
534
+ const { optional, type } = unwrapPayloadType(shape[name]!);
535
+ return { name, type, optional };
536
+ });
537
+ }
538
+
539
+ function canonicalPayloadJsonSchema(payloadSchema: unknown): unknown {
540
+ return z.toJSONSchema(payloadSchema as z.ZodTypeAny);
541
+ }
542
+
543
+ /**
544
+ * Canonicalize a directive's declared READ boundary into a manifest fragment.
545
+ * OMIT-WHEN-EMPTY: returns `{}` (no `reads` key at all) when nothing is declared —
546
+ * the omission, not an empty array, is what keeps boundary-free hashes unchanged.
547
+ * When non-empty, returns a SORTED `reads` array (order is incidental to the boundary).
548
+ */
549
+ function canonicalReads(declared: string[]): { reads?: string[] } {
550
+ if (declared.length === 0) return {};
551
+ return { reads: [...declared].sort() };
552
+ }
553
+
554
+ /**
555
+ * Canonicalize a directive's declared required HOST CAPABILITY ports into a manifest fragment
556
+ * (the HOST/PORT axis, invariant 3). OMIT-WHEN-EMPTY: returns `{}` (no `requiresHostCapability`
557
+ * key at all) when the directive declares none — the omission, not an empty array, is what keeps
558
+ * host-capability-free hashes UNCHANGED. When non-empty, returns a SORTED `requiresHostCapability`
559
+ * array (order is incidental to the port SET). DISTINCT from `canonicalReads`/`requiresCapability`
560
+ * (the two axes — HOST vs AUTHZ — never conflate in the manifest).
561
+ */
562
+ function canonicalHostCapabilities(declared: string[]): { requiresHostCapability?: string[] } {
563
+ if (declared.length === 0) return {};
564
+ return { requiresHostCapability: [...declared].sort() };
565
+ }
566
+
567
+ /**
568
+ * Canonicalize a directive's declared EMIT boundary into a manifest fragment.
569
+ * OMIT-WHEN-EMPTY: returns `{}` (no `emits` key) when nothing is declared. When
570
+ * non-empty, returns an `emits` object whose keys `canonicalJson` will sort, each
571
+ * value `{ max }` only when a bound is set (so `{}` vs `{max}` differs in identity).
572
+ */
573
+ function canonicalEmits(
574
+ declared: Record<string, { max?: number }>,
575
+ ): { emits?: Record<string, { max?: number }> } {
576
+ const keys = Object.keys(declared);
577
+ if (keys.length === 0) return {};
578
+ const emits: Record<string, { max?: number }> = {};
579
+ for (const key of keys.sort()) {
580
+ const max = declared[key]!.max;
581
+ emits[key] = max !== undefined ? { max } : {};
582
+ }
583
+ return { emits };
584
+ }
585
+
586
+ /**
587
+ * Canonicalize a directive's declared CertifiedReads into a manifest fragment (survival
588
+ * Constraint 5). OMIT-WHEN-EMPTY: returns `{}` (no `certifiedReads` key) when the directive
589
+ * declares none — the omission keeps read-free hashes unchanged. When non-empty, returns a
590
+ * `certifiedReads` array SORTED BY `queryId` (the SET of declared reads is the contract), each
591
+ * carrying its `queryId` + the profiled `sql`/`multiRow`/`uniqueTieBreakers` recipe (so a recipe
592
+ * change moves the domain hash — a different read is a different contract). The local binding
593
+ * NAME (the `reads:{}` key) is incidental to identity (it is `decide`'s ergonomics), so it is
594
+ * NOT captured — only the `query_id` + recipe.
595
+ */
596
+ function canonicalCertifiedReads(
597
+ declared: Record<string, CertifiedReadDecl>,
598
+ ): { certifiedReads?: CanonicalCertifiedRead[] } {
599
+ const decls = Object.values(declared);
600
+ if (decls.length === 0) return {};
601
+ const certifiedReads: CanonicalCertifiedRead[] = decls
602
+ .map((d) => ({
603
+ queryId: d.queryId,
604
+ sql: d.sql,
605
+ multiRow: d.multiRow,
606
+ uniqueTieBreakers: [...d.uniqueTieBreakers],
607
+ }))
608
+ .sort((a, b) => (a.queryId < b.queryId ? -1 : a.queryId > b.queryId ? 1 : 0));
609
+ return { certifiedReads };
610
+ }
611
+
612
+ /**
613
+ * Canonicalize a directive's declared cross-workspace relations into a manifest fragment
614
+ * (`cross_workspace.md` §2). OMIT-WHEN-EMPTY: returns `{}` (no `relations` key) when the
615
+ * directive declares none — the omission keeps relation-free hashes unchanged. When non-empty,
616
+ * a `relations` array SORTED BY relation `id`, each carrying the endpoints + the bounded
617
+ * evidence read (SELECT fields SORTED) + `hasInvariant` (presence only). A recipe change
618
+ * (aggregate/fields/predicate) MOVES the domain hash — a different relation is a different
619
+ * contract. The invariant PREDICATE body is NOT captured (executable behaviour, like `plan`).
620
+ */
621
+ function canonicalRelations(
622
+ declared: RelationDecl[],
623
+ ): { relations?: CanonicalRelation[] } {
624
+ if (declared.length === 0) return {};
625
+ const relations: CanonicalRelation[] = declared
626
+ .map((r) => ({
627
+ id: r.id,
628
+ source: r.source,
629
+ target: r.target,
630
+ proposes: r.proposes,
631
+ evidence: {
632
+ aggregate: r.evidence.aggregate,
633
+ select: [...r.evidence.select].sort(),
634
+ where: r.evidence.where,
635
+ kind: r.evidence.kind,
636
+ },
637
+ hasInvariant: r.hasInvariant,
638
+ }))
639
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
640
+ return { relations };
641
+ }
642
+
643
+ /**
644
+ * Canonicalize a domain's declared QUERIES into a manifest fragment.
645
+ * OMIT-WHEN-EMPTY: returns `{}` (no `queries` key at all) when the domain declares
646
+ * none — the omission, not an empty array, is what keeps query-free hashes unchanged.
647
+ * When non-empty, returns a `queries` array SORTED by id (the SET of queries is the
648
+ * contract), each carrying its `key` in DECLARED order (index column order, NOT
649
+ * sorted) and its `returns` aggregate type id.
650
+ */
651
+ function canonicalQueries(
652
+ declared: QueryDecl[] | undefined,
653
+ ): { queries?: CanonicalQuery[] } {
654
+ if (declared === undefined || declared.length === 0) return {};
655
+ const queries: CanonicalQuery[] = [...declared]
656
+ .map((q) => ({ id: q.id, key: [...q.key], returns: q.returns }))
657
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
658
+ return { queries };
659
+ }
660
+
661
+ /**
662
+ * Canonicalize a domain's WORKSPACE INVARIANTS into a manifest fragment (#266) — the
663
+ * presence-only analogue of {@link canonicalQueries}. OMIT-WHEN-EMPTY: returns `{}` (no key)
664
+ * when none are declared, so an invariant-free domain hashes identically to before. Each entry
665
+ * is PRESENCE ONLY (id + the directive it is `on`), SORTED by id; the executable `reads`/
666
+ * `assert` bodies are NEVER hashed (they ship in the engine bundle). So changing a body leaves
667
+ * the hash unchanged, while adding/removing an invariant moves it (it is law — #131).
668
+ */
669
+ function canonicalWorkspaceInvariants(
670
+ declared: WorkspaceInvariantDecl[] | undefined,
671
+ ): { workspaceInvariants?: CanonicalWorkspaceInvariant[] } {
672
+ if (declared === undefined || declared.length === 0) return {};
673
+ const workspaceInvariants: CanonicalWorkspaceInvariant[] = [...declared]
674
+ .map((wi) => ({ id: wi.id, on: wi.on }))
675
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
676
+ return { workspaceInvariants };
677
+ }
678
+
679
+ /**
680
+ * Canonicalize a domain's declared COUNTS into a manifest fragment — the aggregation
681
+ * analogue of {@link canonicalQueries}.
682
+ * OMIT-WHEN-EMPTY: returns `{}` (no `counts` key at all) when the domain declares none —
683
+ * the omission, not an empty array, is what keeps count-free hashes unchanged. When
684
+ * non-empty, returns a `counts` array SORTED by id (the SET of counts is the contract),
685
+ * each carrying its `of`-type and — only when declared — its `by` group-by field (a
686
+ * grand-total count omits `by` entirely, so it adds no phantom key to the hash).
687
+ */
688
+ function canonicalCounts(
689
+ declared: AnyCount[] | undefined,
690
+ ): { counts?: CanonicalCount[] } {
691
+ if (declared === undefined || declared.length === 0) return {};
692
+ const counts: CanonicalCount[] = [...declared]
693
+ .map(finishCount)
694
+ .map((c) => ({
695
+ id: c.id,
696
+ of: c.of,
697
+ // OMIT-WHEN-ABSENT: `where` and `by` are omitted when not declared so a
698
+ // predicate-free or grand-total count is byte-identical to the legacy form
699
+ // (hash-stable — the same discipline as every other optional manifest key).
700
+ ...(c.where !== undefined ? { where: c.where } : {}),
701
+ ...(c.by !== undefined ? { by: c.by } : {}),
702
+ }))
703
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
704
+ return { counts };
705
+ }
706
+
707
+ /**
708
+ * Canonicalize a domain's declared SPATIAL indexes into a manifest fragment — the
709
+ * geospatial analogue of {@link canonicalCounts}.
710
+ * OMIT-WHEN-EMPTY: returns `{}` (no `spatials` key at all) when the domain declares none —
711
+ * the omission, not an empty array, is what keeps spatial-free hashes unchanged. When
712
+ * non-empty, returns a `spatials` array SORTED by id (the SET of spatial indexes is the
713
+ * contract), each carrying its `of`-type and the `on` geometry field whose bounding box is
714
+ * indexed.
715
+ */
716
+ function canonicalSpatials(
717
+ declared: SpatialDecl[] | undefined,
718
+ ): { spatials?: CanonicalSpatial[] } {
719
+ if (declared === undefined || declared.length === 0) return {};
720
+ const spatials: CanonicalSpatial[] = [...declared]
721
+ .map((s) => ({ id: s.id, of: s.of, on: s.on }))
722
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
723
+ return { spatials };
724
+ }
725
+
726
+ /**
727
+ * Canonicalize a domain's declared SUMS into a manifest fragment — the numeric-
728
+ * accumulation analogue of {@link canonicalCounts}.
729
+ * OMIT-WHEN-EMPTY: returns `{}` (no `sums` key at all) when the domain declares none.
730
+ * When non-empty, returns a `sums` array SORTED by id. `where` and `by` are omitted
731
+ * when not declared (same omit-when-absent discipline as `CanonicalCount`).
732
+ */
733
+ function canonicalSums(declared: AnySum[] | undefined): { sums?: CanonicalSum[] } {
734
+ if (declared === undefined || declared.length === 0) return {};
735
+ const sums: CanonicalSum[] = [...declared]
736
+ .map(finishSum)
737
+ .map((s) => ({
738
+ id: s.id,
739
+ of: s.of,
740
+ sumField: s.sumField,
741
+ ...(s.where !== undefined ? { where: s.where } : {}),
742
+ ...(s.by !== undefined ? { by: s.by } : {}),
743
+ }))
744
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
745
+ return { sums };
746
+ }
747
+
748
+ /**
749
+ * Canonicalize a domain's declared DERIVED read fields into a manifest fragment — the
750
+ * engine-projected analogue of {@link canonicalCounts}.
751
+ * OMIT-WHEN-EMPTY: returns `{}` (no `deriveds` key at all) when the domain declares none —
752
+ * the omission, not an empty array, is what keeps derived-free hashes unchanged. When
753
+ * non-empty, returns a `deriveds` array SORTED by id (the SET of derived fields is the
754
+ * contract), each carrying ONLY its `{id, of}` — the fn BODY is NOT hashed (executable
755
+ * behaviour, exactly like a directive's `plan` body, which this manifest also omits).
756
+ */
757
+ function canonicalDeriveds(
758
+ declared: DerivedDecl[] | undefined,
759
+ ): { deriveds?: CanonicalDerived[] } {
760
+ if (declared === undefined || declared.length === 0) return {};
761
+ const deriveds: CanonicalDerived[] = [...declared]
762
+ .map((d) => ({ id: d.id, of: d.of, returns: canonicalProjectionReturn(d.returns) }))
763
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
764
+ return { deriveds };
765
+ }
766
+
767
+ /**
768
+ * Canonicalize a domain's declared COMBINED read fields into a manifest fragment — the
769
+ * cross-aggregate JOIN analogue of {@link canonicalDeriveds}.
770
+ * OMIT-WHEN-EMPTY: returns `{}` (no `combineds` key at all) when the domain declares none —
771
+ * the omission, not an empty array, is what keeps combined-free hashes unchanged. When
772
+ * non-empty, returns a `combineds` array SORTED by id (the SET of combined fields is the
773
+ * contract), each carrying `{id, of, refField, reads}` — the JOIN edge + invalidation key
774
+ * are part of the contract; the fn BODY is NOT hashed (executable behaviour, like a
775
+ * directive's `plan` body, which this manifest also omits).
776
+ */
777
+ function canonicalCombineds(
778
+ declared: CombinedDecl[] | undefined,
779
+ ): { combineds?: CanonicalCombined[] } {
780
+ if (declared === undefined || declared.length === 0) return {};
781
+ const combineds: CanonicalCombined[] = [...declared]
782
+ .map((c) => ({
783
+ id: c.id,
784
+ of: c.of,
785
+ refField: c.refField,
786
+ reads: c.reads,
787
+ returns: canonicalProjectionReturn(c.returns),
788
+ }))
789
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
790
+ return { combineds };
791
+ }
792
+
793
+ /**
794
+ * Canonicalize a domain's declared MINS or MAXES into a manifest fragment.
795
+ * OMIT-WHEN-EMPTY: returns `{}` (no key at all) when the domain declares none.
796
+ * When non-empty, returns the array SORTED by id. `where` and `by` follow the same
797
+ * omit-when-not-declared discipline as {@link canonicalSums}.
798
+ */
799
+ function canonicalExtrema(
800
+ declared: AnyExtremum[] | undefined,
801
+ keyName: "mins" | "maxes",
802
+ ): { mins?: CanonicalExtremum[] } | { maxes?: CanonicalExtremum[] } {
803
+ if (declared === undefined || declared.length === 0) return {};
804
+ const items: CanonicalExtremum[] = [...declared]
805
+ .map(finishExtremum)
806
+ .map((e) => ({
807
+ id: e.id,
808
+ kind: e.kind,
809
+ of: e.of,
810
+ valueField: e.valueField,
811
+ ...(e.where !== undefined ? { where: e.where } : {}),
812
+ ...(e.by !== undefined ? { by: e.by } : {}),
813
+ }))
814
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
815
+ return { [keyName]: items };
816
+ }
817
+
818
+ /**
819
+ * Canonicalize a domain's declared ORDERED READS (first/take) into a manifest fragment.
820
+ * OMIT-WHEN-EMPTY: returns `{}` (no key at all) when the domain declares none.
821
+ * When non-empty, returns an `orderedReads` array SORTED by id. `where` is omitted when
822
+ * not declared; `orderDesc` is omitted when false (default = ascending). `orderKey` is
823
+ * ALWAYS present (it is required — never optional).
824
+ */
825
+ function canonicalOrderedReads(
826
+ declared: OrderedReadDecl[] | undefined,
827
+ ): { orderedReads?: CanonicalOrderedRead[] } {
828
+ if (declared === undefined || declared.length === 0) return {};
829
+ const items: CanonicalOrderedRead[] = [...declared]
830
+ .map((d) => ({
831
+ id: d.id,
832
+ of: d.of,
833
+ orderKey: d.orderKey,
834
+ // OMIT-WHEN-DEFAULT: only include `orderDesc` when it is `true` (non-default).
835
+ ...(d.orderDesc ? { orderDesc: true as const } : {}),
836
+ ...(d.where !== undefined ? { where: d.where } : {}),
837
+ limit: d.limit,
838
+ }))
839
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
840
+ return { orderedReads: items };
841
+ }
842
+
843
+ /**
844
+ * Build the canonical semantic manifest for a domain module — purely from the
845
+ * in-memory DSL objects. No file reads, no bundle, no esbuild: the manifest is a
846
+ * function of the module alone, so it is bundler- AND location-independent.
847
+ */
848
+ export function domainManifest(mod: DomainModule): CanonicalManifest {
849
+ const aggregates: CanonicalAggregate[] = mod.aggregates
850
+ .map((agg) => ({
851
+ id: agg.id,
852
+ schema: sortedSchema(encodeKernelSchema(agg)),
853
+ // OMIT-WHEN-ABSENT: only emit `hasInvariant` when the aggregate declares one,
854
+ // so a predicate-free aggregate is byte-identical in the manifest to before this
855
+ // key existed (golden hashes UNCHANGED — mirrors directive `plan` omission).
856
+ ...(agg.hasInvariant === true ? { hasInvariant: true as const } : {}),
857
+ // The LAW-DECLARED instance cap — hash-bearing (it IS law), omit-when-absent.
858
+ ...(agg.cap !== undefined ? { cap: agg.cap } : {}),
859
+ }))
860
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
861
+
862
+ const directives: CanonicalDirective[] = mod.directives
863
+ .map((d) => ({
864
+ id: d.id,
865
+ target: d.aggregateId,
866
+ marker: encodeRefMode(d.marker),
867
+ requiresCapability: d.requiresCapability ?? d.id,
868
+ scopeFrom: d.scopeFrom !== undefined,
869
+ payload: canonicalPayload(d.payloadSchema),
870
+ payloadJsonSchema: canonicalPayloadJsonSchema(d.payloadSchema),
871
+ // Omit-when-empty: a directive declaring NO boundary contributes neither key,
872
+ // so its canonical manifest is byte-identical to before the boundary existed.
873
+ ...canonicalReads(d.declaredReads),
874
+ ...canonicalEmits(d.declaredEmits),
875
+ ...canonicalCertifiedReads(d.declaredCertifiedReads),
876
+ ...canonicalRelations(d.declaredRelations),
877
+ ...canonicalHostCapabilities(d.declaredHostCapabilities),
878
+ }))
879
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
880
+
881
+ // exists_ entries are COUNT declarations in the IR — merge them into `counts` so the
882
+ // Rust engine sees them as ordinary counts (the marker lives only in the DSL/codegen
883
+ // layer). Combine mod.counts + normalized exists_ before canonicalizing counts.
884
+ const existsAsCountDecls: AnyCount[] = (mod.exists_ ?? []).map(existsAsCount);
885
+ const allCounts: AnyCount[] = [...(mod.counts ?? []), ...existsAsCountDecls];
886
+
887
+ // Omit-when-empty: a domain declaring NO query / NO count / NO sum contributes neither
888
+ // key, so its canonical manifest is byte-identical to before the read-closure layer
889
+ // existed. `sums` follows the same omit-when-empty discipline as `counts`.
890
+ return {
891
+ domain: mod.name,
892
+ aggregates,
893
+ directives,
894
+ ...canonicalWorkspaceInvariants(mod.workspaceInvariants),
895
+ ...canonicalQueries(mod.queries),
896
+ ...canonicalCounts(allCounts.length > 0 ? allCounts : undefined),
897
+ ...canonicalSpatials(mod.spatials),
898
+ ...canonicalSums(mod.sums),
899
+ ...canonicalDeriveds(mod.deriveds),
900
+ ...canonicalCombineds(mod.combineds),
901
+ ...(canonicalExtrema(mod.mins, "mins") as { mins?: CanonicalExtremum[] }),
902
+ ...(canonicalExtrema(mod.maxes, "maxes") as { maxes?: CanonicalExtremum[] }),
903
+ ...canonicalOrderedReads(mod.orderedReads),
904
+ };
905
+ }
906
+
907
+ /** Recursively sort object keys; arrays are already in sorted/stable order. */
908
+ function canonicalize(value: unknown): unknown {
909
+ if (Array.isArray(value)) return value.map(canonicalize);
910
+ if (value !== null && typeof value === "object") {
911
+ const obj = value as Record<string, unknown>;
912
+ const out: Record<string, unknown> = {};
913
+ for (const key of Object.keys(obj).sort()) out[key] = canonicalize(obj[key]);
914
+ return out;
915
+ }
916
+ return value;
917
+ }
918
+
919
+ /**
920
+ * Serialize ANY value to JSON with recursively SORTED object keys (byte-stable).
921
+ * Used for the domain manifest here AND reused by the composed-IR layer (#137) to
922
+ * hash a flattened stage — the canonicalizer is value-generic, so the input type is
923
+ * `unknown` rather than narrowed to `CanonicalManifest`.
924
+ */
925
+ export function canonicalJson(manifest: unknown): string {
926
+ return JSON.stringify(canonicalize(manifest));
927
+ }
928
+
929
+ /** The domain's content-hash identity: sha256 hex of its canonical manifest JSON. */
930
+ export function domainHash(mod: DomainModule): string {
931
+ return createHash("sha256").update(canonicalJson(domainManifest(mod)), "utf8").digest("hex");
932
+ }
933
+
934
+ /**
935
+ * The SHIPPED ARTIFACT bytes for a domain (#136) — the EXACT canonical-manifest JSON
936
+ * bytes whose sha256 IS the domain's identity. This is the byte string an emit step
937
+ * writes to `<domain>.manifest.json` and the gate's reproducibility check recomputes
938
+ * `hash_code` over. The invariant the whole gate rests on:
939
+ *
940
+ * sha256(emitManifestBytes(mod)) === domainHash(mod)
941
+ *
942
+ * i.e. the file bytes equal the bytes `domainHash` hashes (compact, recursively
943
+ * sorted keys, UTF-8). "The bundle is not the law" — THIS is.
944
+ */
945
+ export function emitManifestBytes(mod: DomainModule): Buffer {
946
+ return Buffer.from(canonicalJson(domainManifest(mod)), "utf8");
947
+ }