@githolon/dsl 0.102.4 → 0.103.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 (131) hide show
  1. package/compile_package.mjs +43 -2
  2. package/compile_proof.d.mts +47 -0
  3. package/compile_proof.mjs +343 -0
  4. package/dart/CHANGELOG.md +7 -0
  5. package/dart/lib/src/dev_cloud.dart +8 -8
  6. package/dart/lib/src/nms_generated_runtime.dart +2 -0
  7. package/dart/lib/src/runner/node-entry.mjs +5504 -1236
  8. package/dart/lib/src/runner/offline-install.mjs +4578 -528
  9. package/dart/lib/src/test_harness.dart +206 -152
  10. package/dart/lib/src/webview_bridge.dart +196 -89
  11. package/dart/pubspec.lock +1 -1
  12. package/dart/pubspec.yaml +2 -2
  13. package/dart/test/acquire_session_credentials_test.dart +4 -3
  14. package/dart/test/adv_realm_interference_test.dart +7 -6
  15. package/dart/test/cloud_bound_topology_test.dart +3 -3
  16. package/dart/test/cluster_births_test.dart +4 -3
  17. package/dart/test/cluster_device_keyed_acceptance_test.dart +10 -16
  18. package/dart/test/cluster_local_autosync_test.dart +3 -2
  19. package/dart/test/cluster_scale_test.dart +3 -2
  20. package/dart/test/dlq_session_surface_test.dart +7 -6
  21. package/dart/test/first_app_gauntlet_test.dart +14 -21
  22. package/dart/test/fixtures/wedge/fake-install.mjs +1 -1
  23. package/dart/test/four_layer_nested_topology_test.dart +4 -4
  24. package/dart/test/harness_phase_timeout_test.dart +2 -2
  25. package/dart/test/local_sync_inert_test.dart +1 -1
  26. package/dart/test/parent_signer_recovery_test.dart +2 -2
  27. package/dart/test/sealing_client_test.dart +4 -3
  28. package/dart/test/session_bound_test.dart +8 -7
  29. package/dart/test/test_harness_test.dart +13 -12
  30. package/dart/test/vo_read_typing_test.dart +4 -3
  31. package/dart/test/watch_after_offer_stall_test.dart +4 -3
  32. package/dart/test/webview_bridge_test.dart +102 -5
  33. package/dart_types/CHANGELOG.md +5 -0
  34. package/dart_types/lib/nomos_types.dart +9 -0
  35. package/dart_types/lib/src/nomos_ref.dart +20 -0
  36. package/dart_types/lib/src/optional_ref.dart +85 -0
  37. package/dart_types/lib/src/sha256_digest.dart +41 -0
  38. package/dart_types/lib/src/versioned_nomos_ref.dart +81 -0
  39. package/dart_types/pubspec.yaml +1 -1
  40. package/dart_types/test/optional_ref_test.dart +44 -0
  41. package/dart_types/test/sha256_digest_test.dart +18 -0
  42. package/package.json +9 -5
  43. package/runtime/generated-application-host.js +5365 -1105
  44. package/src/aggregate.ts +18 -0
  45. package/src/application_composition.ts +6 -32
  46. package/src/attested_core.ts +6 -3
  47. package/src/birth_meter.ts +1 -0
  48. package/src/birth_meter_ensure.ts +28 -2
  49. package/src/births.ts +1 -1
  50. package/src/build_package.ts +162 -538
  51. package/src/business_birth.ts +19 -3
  52. package/src/canonical_json.ts +21 -0
  53. package/src/check_package_main.ts +76 -35
  54. package/src/clear_directive.ts +19 -8
  55. package/src/codegen_dart.ts +305 -22
  56. package/src/codegen_dart_application.ts +844 -292
  57. package/src/codegen_dart_client.ts +1084 -407
  58. package/src/codegen_dart_scenario.ts +12 -12
  59. package/src/codegen_proof.ts +169 -39
  60. package/src/codegen_provider_dart.ts +17 -1
  61. package/src/codegen_ts.ts +298 -106
  62. package/src/compat_manifest.ts +2 -2
  63. package/src/compat_package_main.ts +8 -12
  64. package/src/compile_package_main.ts +1866 -597
  65. package/src/compose.ts +1 -1
  66. package/src/directive.ts +208 -15
  67. package/src/engine_entry.ts +165 -111
  68. package/src/evolve_preview.ts +43 -81
  69. package/src/extremum.ts +1 -1
  70. package/src/fields.ts +183 -4
  71. package/src/flow_gate.ts +75 -2
  72. package/src/framework/application_home.ts +2 -0
  73. package/src/framework/auth_provider_law.ts +2 -2
  74. package/src/framework/birth_meter.ts +7 -19
  75. package/src/framework/bootstrap.ts +0 -2
  76. package/src/framework/business_corrections.ts +9 -5
  77. package/src/framework/domain_lifecycle.ts +15 -29
  78. package/src/framework/encryption_scope.ts +2 -1
  79. package/src/framework/framework_aggregates.ts +2 -0
  80. package/src/framework/genesis.ts +16 -14
  81. package/src/framework/home.ts +20 -14
  82. package/src/framework/owned_custody.ts +9 -3
  83. package/src/framework/platform_admin.ts +3 -3
  84. package/src/framework/relations.ts +21 -13
  85. package/src/framework/signing_identity.ts +45 -40
  86. package/src/framework/workspace_invariant.ts +245 -0
  87. package/src/framework/workspaces.ts +23 -29
  88. package/src/index.ts +36 -16
  89. package/src/intent.ts +178 -6
  90. package/src/law_check.ts +32 -11
  91. package/src/law_graph.ts +2902 -0
  92. package/src/law_graph_usda.ts +1799 -0
  93. package/src/law_identity.ts +435 -0
  94. package/src/law_value_type.ts +230 -0
  95. package/src/lsp_server.ts +61 -31
  96. package/src/ops.ts +7 -0
  97. package/src/package_config.ts +141 -0
  98. package/src/package_dependencies.ts +108 -0
  99. package/src/package_dependency_usage.ts +150 -0
  100. package/src/package_source_imports.ts +71 -0
  101. package/src/plan_source_slice.ts +335 -0
  102. package/src/point_guard.ts +192 -0
  103. package/src/precondition.ts +44 -9
  104. package/src/read_projection_namespace.ts +1 -1
  105. package/src/runtime_zod.ts +44 -0
  106. package/src/secondary_ensure.ts +67 -14
  107. package/src/shape_compat.ts +1 -1
  108. package/src/stable_id_identity_bridge.ts +149 -0
  109. package/src/stable_id_lockfile.ts +278 -0
  110. package/src/stable_ids.ts +7 -2
  111. package/src/stable_ids_for_module.ts +348 -0
  112. package/src/stable_ids_types.ts +1 -1
  113. package/src/sum.ts +1 -1
  114. package/src/usd_state.ts +1 -1
  115. package/src/usda_relationship_integrity.ts +66 -0
  116. package/src/usda_text.ts +18 -0
  117. package/src/usdz.ts +78 -0
  118. package/src/value_object.ts +95 -0
  119. package/src/wire.ts +19 -0
  120. package/src/wire_encode.ts +22 -0
  121. package/src/workspace_sharding.ts +52 -36
  122. package/src/workspace_sharing.ts +28 -4
  123. package/src/zod_introspection.ts +1 -1
  124. package/dart/test/cluster_attested_test.dart +0 -201
  125. package/src/compile_engine.ts +0 -595
  126. package/src/manifest.ts +0 -1476
  127. package/src/read_projection_composition.ts +0 -117
  128. package/src/read_projection_manifest.ts +0 -115
  129. package/src/relation_composition.ts +0 -286
  130. package/src/usd.ts +0 -1967
  131. package/src/usd_layers.ts +0 -1776
package/src/manifest.ts DELETED
@@ -1,1476 +0,0 @@
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 { encodeDriver } from "./drivers.js";
32
- import type { WireSchema, WireRefMode } from "./wire.js";
33
- import type { ReferentialMarker } from "./directive.js";
34
- import type { DomainModule } from "./codegen_dart.js";
35
- import type { Field } from "./fields.js";
36
- import {
37
- BUSINESS_BIRTH_SEED_PAYLOAD_FIELD,
38
- OWNED_CUSTODY_PAYLOAD_FIELD,
39
- OWNED_KEY_PAYLOAD_FIELD,
40
- OWNED_PREMINTED_RECORDS_PAYLOAD_FIELD,
41
- OWNED_READ_RELATION,
42
- } from "./aggregate.js";
43
- import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
44
- import type { QueryDecl } from "./query.js";
45
- import { finishCount, type AnyCount } from "./count.js";
46
- import type { SpatialDecl } from "./spatial.js";
47
- import { finishSum, type AnySum } from "./sum.js";
48
- import { finishExtremum, type AnyExtremum } from "./extremum.js";
49
- import { existsAsCount, type AnyExists } from "./exists.js";
50
- import type { OrderedReadDecl } from "./ordered_read.js";
51
- import type { CanonicalPred } from "./predicate.js";
52
- import { assertFlowGate } from "./flow_gate.js";
53
- import type { DerivedDecl } from "./derived.js";
54
- import type { CombinedDecl } from "./combined.js";
55
- import type { RelationDecl } from "./relation.js";
56
- import { type CanonicalWorkspaceType } from "./workspace_type.js";
57
- import {
58
- canonicalTaxonomyFragment,
59
- type CanonicalDirectiveRoute,
60
- } from "./workspace_routing.js";
61
- import {
62
- mintAggregateSid,
63
- mintDomainSid,
64
- mintFieldSid,
65
- type StableAggregateId,
66
- type StableFieldId,
67
- type StableFieldEvolution,
68
- type StableIds,
69
- } from "./stable_ids.js";
70
-
71
- /** One captured `[fieldName, zodKind]` payload field, with optionality. */
72
- export interface CanonicalPayloadField {
73
- /** The payload field name. */
74
- readonly name: string;
75
- /** The Zod 4 `def.type` of the field's unwrapped type, e.g. "string". */
76
- readonly type: string;
77
- /** True when the field is `optional` / `default` wrapped. */
78
- readonly optional: boolean;
79
- }
80
-
81
- /** One captured projected read-field return schema. */
82
- export interface CanonicalProjectionReturn {
83
- /** The Zod 4 `def.type` of the unwrapped return type, e.g. "boolean". */
84
- readonly type: string;
85
- /** True when the projected value may be null/absent. */
86
- readonly nullable: boolean;
87
- /** The return value as standard JSON Schema, emitted from the same TS/Zod source. */
88
- readonly jsonSchema: unknown;
89
- }
90
-
91
- /** One captured aggregate: its id + its field→driver schema (keys SORTED). */
92
- export interface CanonicalAggregate {
93
- readonly id: string;
94
- readonly schema: WireSchema;
95
- /**
96
- * PRESENCE ONLY — the body is executable (like a directive `plan`), NOT hashed.
97
- * OMITTED ENTIRELY when absent, so a predicate-free aggregate is byte-identical
98
- * in the manifest to before this field existed (golden hashes UNCHANGED).
99
- */
100
- readonly hasInvariant?: true;
101
- /** The LAW-DECLARED instance cap; omitted when uncapped (hash-stable). */
102
- readonly cap?: number;
103
- /**
104
- * READ VISIBILITY (slice 5.1) — the capability a read TOUCHING this type requires.
105
- * PRESENT only when the type is declared `visibility: requires(cap)` (PRIVATE);
106
- * OMITTED ENTIRELY for the default `public`, so a public-only domain is
107
- * byte-identical in the manifest to before this key existed (golden hashes
108
- * UNCHANGED — mirrors `cap`/`hasInvariant`). It IS law (the githolon refuses an
109
- * unauthorized read on it), so adding/changing it MOVES the hash.
110
- */
111
- readonly requiresRead?: string;
112
- /**
113
- * E2E ENCRYPTION (slice 2) — the STORED field names whose values are `.encrypted()`,
114
- * SORTED. PRESENT only when ≥1 field is encrypted; OMITTED otherwise (a plaintext
115
- * aggregate is byte-identical). It IS law (the gate enforces the envelope + the scope),
116
- * so adding/changing it MOVES the hash. The scope is the aggregate's `requiresRead`.
117
- */
118
- readonly encryptedFields?: string[];
119
- /** Secure-by-default owned lowering. Presence means `requiresRead` is a PER-RECORD relation (not a
120
- * workspace-wide capability), `ownerField` is framework-stamped, and the encrypted slots use the
121
- * stateful-client owned-key lane. */
122
- readonly owned?: { readonly ownerField: string; readonly relation: string };
123
- /** Stored framework fields intentionally hidden from generated business models. */
124
- readonly internalFields?: readonly string[];
125
- /**
126
- * ATTRIBUTED READS (architecture/field_provenance.md) — the STORED field names whose
127
- * folded values keep author provenance (`.attributed()`), SORTED. PRESENT only when
128
- * ≥1 field is attributed; OMITTED otherwise (an unattributed aggregate is
129
- * byte-identical). The hash-bearing LAW is the wrapped `{"Attributed": <inner>}`
130
- * driver in `schema`; this key is the self-describing mirror codegen/openapi read.
131
- */
132
- readonly attributedFields?: string[];
133
- }
134
-
135
- /** One captured directive contract — NO `plan`/`scopeFrom` behaviour. */
136
- export interface CanonicalDirective {
137
- readonly id: string;
138
- /** The target aggregate id. */
139
- readonly target: string;
140
- readonly marker: WireRefMode;
141
- /**
142
- * MULTI-OUTPUT DIRECTIVES: additional aggregates this directive declares it writes, beyond the primary
143
- * `target`/`marker` — `{ target, marker }` pairs SORTED by (target, marker). Descriptive law metadata
144
- * (docs/proof/client name every output). OMITTED ENTIRELY when the directive declares none, so a
145
- * single-output directive is byte-identical to before this key existed (pinned golden hashes unchanged).
146
- */
147
- readonly secondaryOutputs?: readonly { target: string; marker: WireRefMode }[];
148
- /** The payload's top-level object shape as SORTED `[name, type]` pairs. */
149
- readonly payload: CanonicalPayloadField[];
150
- /**
151
- * The directive payload as standard JSON Schema, emitted from the same TS/Zod source
152
- * that the sealed GitHolon engine validates. This is the cross-language validation
153
- * artifact: Dart/hosts can validate dynamic maps with a JSON Schema library without
154
- * re-authoring the schema in Dart.
155
- */
156
- readonly payloadJsonSchema: unknown;
157
- /**
158
- * The directive's DECLARED read boundary (sorted ref types). The "IR for law
159
- * boundaries": the boundary is part of the domain identity. OMITTED ENTIRELY when
160
- * the directive declares no reads — so a boundary-free directive is byte-identical
161
- * to before this key existed (pinned golden hashes stay UNCHANGED).
162
- */
163
- readonly reads?: string[];
164
- /**
165
- * The directive's DECLARED emit boundary: event type → `{ max? }`. Keys sorted by
166
- * `canonicalJson`. OMITTED ENTIRELY when the directive declares no emits, for the
167
- * same omit-when-empty hash-invariance reason as {@link reads}.
168
- */
169
- readonly emits?: Record<string, { max?: number }>;
170
- /**
171
- * The directive's DECLARED CAPTURED-READ queries (#58 — the captured-read lane):
172
- * the O(1) DSL queries its `plan` may `read()` on the write path, each carried as
173
- * `{queryId, key, returns}` (the read RECIPE is the contract — a different key or
174
- * returns-type is a different read, so it moves the hash). SORTED by queryId.
175
- * OMITTED ENTIRELY when the directive declares none — a captured-read-free
176
- * directive is byte-identical in the manifest to before this key existed.
177
- */
178
- readonly capturedReads?: CanonicalCapturedRead[];
179
- /**
180
- * The directive's DECLARED required HOST CAPABILITY ports (`OFFER_KERNEL_ARCHITECTURE.dot` cluster_ports;
181
- * the HOST/PORT axis, invariant 3): the capability ports the host must PROVIDE for the
182
- * directive to run, checked FAIL-CLOSED (`required ⊆ provided`) at policy LOAD. SORTED (the
183
- * SET is the contract; order is incidental). DISTINCT from {@link requiresCapability} (the
184
- * AUTHZ axis "may this actor?", a single string) — this is the HOST axis "can this host
185
- * physically?" (a set of open-set port ids). Part of the domain identity: a different required
186
- * port is a different contract, so adding/changing one MOVES the hash. OMITTED ENTIRELY when
187
- * the directive declares no host capability — so a host-capability-free directive is
188
- * byte-identical in the manifest to before this key existed (pinned golden hashes stay
189
- * UNCHANGED), exactly how {@link reads}/{@link relations} stay hash-neutral.
190
- */
191
- readonly requiresHostCapability?: string[];
192
- /**
193
- * A law-declared business conflict resolver. The generated client carries an immutable
194
- * framework snapshot beside this directive's payload, and every admission/replay door
195
- * compare-and-sets it against the exact pending observation set. Omitted for ordinary
196
- * directives, so ordinary business actions carry no conflict vocabulary.
197
- */
198
- readonly conflictResolution?: {
199
- readonly aggregate: string;
200
- readonly field: string;
201
- };
202
- /**
203
- * The directive's DECLARED cross-workspace relations (`cross_workspace.md` §2): the relations
204
- * a cross-workspace PR proposing this directive is adjudicated against. Each carries the
205
- * source/target endpoints, the bounded evidence read it discloses (the disclosure contract +
206
- * anti-smuggle key, §2.4), and whether an invariant guards it (PRESENCE only — the predicate
207
- * body is executable behaviour, NOT hashed, exactly like a directive `plan`). SORTED BY the
208
- * relation `id`. Part of the domain identity: the gate (`kernel::manifest_view`) resolves the
209
- * relation by the proposed directive + verifies a carried witness against the declared evidence
210
- * read; a recipe change (different aggregate/fields/predicate) MOVES the hash. OMITTED ENTIRELY
211
- * when the directive declares no relation — so a relation-free directive is byte-identical in
212
- * the manifest to before this key existed (pinned golden hashes stay UNCHANGED).
213
- */
214
- readonly relations?: CanonicalRelation[];
215
- }
216
-
217
- /** One captured cross-workspace relation — the SEMANTIC half of X1 in the manifest
218
- * (`cross_workspace.md` §2.1 / §2.4). `id`/`source`/`target`/`proposes` are the endpoints +
219
- * the proposed directive; `evidence` is the bounded declared read (disclosure contract +
220
- * anti-smuggle key); `hasInvariant` is the guard's PRESENCE (the body is executable, NOT
221
- * hashed). A recipe change moves the domain hash. */
222
- export interface CanonicalRelation {
223
- readonly id: string;
224
- readonly source: string;
225
- readonly target: string;
226
- readonly proposes: string;
227
- readonly evidence: CanonicalRelationEvidence;
228
- readonly hasInvariant: boolean;
229
- }
230
-
231
- /** The bounded evidence read a relation discloses over the SOURCE, in the manifest: the source
232
- * aggregate, the exact SELECT fields (SORTED — the set is the contract), the WHERE predicate,
233
- * and the freshness `kind`. This IS the disclosure contract + the anti-smuggle key. */
234
- export interface CanonicalRelationEvidence {
235
- readonly aggregate: string;
236
- readonly select: string[];
237
- readonly where: string;
238
- readonly kind: string;
239
- }
240
-
241
- /** One DECLARED captured-read query (#58) — the write-path `read()` recipe in the
242
- * manifest: the query id, its index key (DECLARED order — index column order), and the
243
- * aggregate TYPE it returns. */
244
- export interface CanonicalCapturedRead {
245
- readonly queryId: string;
246
- readonly key: string[];
247
- readonly returns: string;
248
- }
249
-
250
-
251
- /**
252
- * One captured query declaration — the read-side closure's named, INDEXED read.
253
- * `key` is the index column order, PRESERVED (NOT sorted): the order of the index
254
- * key fields is part of the contract, so two queries differing only in key order
255
- * are distinct identities.
256
- */
257
- export interface CanonicalQuery {
258
- readonly id: string;
259
- /** Index key fields, in DECLARED order (index column order — preserved). */
260
- readonly key: string[];
261
- /** The aggregate TYPE id the query returns, e.g. `SampleThingAggregate`. */
262
- readonly returns: string;
263
- }
264
-
265
- /**
266
- * One captured count declaration — the read-side closure's named, MAINTAINED tally.
267
- * Mirrors {@link CanonicalQuery}: a count's `of`-type + (optional) `where` predicate +
268
- * (optional) `by` group-by field are part of the domain CONTRACT (changing them changes
269
- * the maintained counter), so they are part of the identity.
270
- *
271
- * OMIT-WHEN-ABSENT:
272
- * `where` is OMITTED ENTIRELY for a predicate-free count — so an unfiltered count is
273
- * byte-identical in the manifest to before `where` existed (golden hashes UNCHANGED).
274
- * `by` is OMITTED ENTIRELY for a grand-total count (same discipline, always was).
275
- */
276
- export interface CanonicalCount {
277
- readonly id: string;
278
- /** The aggregate TYPE id the count tallies, e.g. `SiteRootAggregate`. */
279
- readonly of: string;
280
- /**
281
- * The predicate filtering which aggregates are counted. OMITTED when no predicate
282
- * (an unfiltered count is byte-identical in the manifest to before this existed).
283
- * The canonical form is insertion-order–independent (clauses sorted deterministically
284
- * by `canonicalizePred`), so byte-identical predicate logic → byte-identical hash.
285
- */
286
- readonly where?: CanonicalPred;
287
- /** The group-by field, in DECLARED form. OMITTED for a grand-total count. */
288
- readonly by?: string;
289
- /**
290
- * THE ESTATE SCOPE (sharding §5, slice 3): the workspace-type id this read is
291
- * lifted to via `scoped(read, Ws)` — its per-shard values ride the §5.2 delta
292
- * lane as gate-recomputed coordinator subtotals. OMITTED for home-scope reads
293
- * (every pre-slice-3 count is byte-identical — the `cap(n)` discipline).
294
- */
295
- readonly scope?: string;
296
- }
297
-
298
- /**
299
- * One captured SPATIAL index declaration — the read-side closure's named, MAINTAINED
300
- * GEOSPATIAL (R*Tree) index. Mirrors {@link CanonicalCount}: a spatial index's `of`-type and
301
- * the geometry field it indexes (`on`) are part of the domain CONTRACT (changing either
302
- * changes the maintained bounding-box index), so they are part of the identity. There is no
303
- * optional/omit-when-absent sub-field — both `id`/`of`/`on` are always present.
304
- */
305
- export interface CanonicalSpatial {
306
- readonly id: string;
307
- /** The aggregate TYPE id the spatial index covers, e.g. `TrackableAsset`. */
308
- readonly of: string;
309
- /** The GeoJSON geometry field whose bounding box is indexed, e.g. `geoPosition`. */
310
- readonly on: string;
311
- }
312
-
313
- /**
314
- * One captured sum declaration — the read-side closure's named, MAINTAINED numeric
315
- * aggregation. Analogous to {@link CanonicalCount}: a sum's `of`-type, `sumField`,
316
- * optional `where` predicate, and optional `by` group-by field are all part of the
317
- * domain CONTRACT. Changing any of them changes the meaning of the maintained total.
318
- *
319
- * OMIT-WHEN-ABSENT: `where` and `by` are OMITTED ENTIRELY when not declared — so a
320
- * predicate-free, grand-total sum is byte-identical to the minimal `{id, of, sumField}`
321
- * form (golden hashes stable). Same discipline as {@link CanonicalCount}.
322
- */
323
- export interface CanonicalSum {
324
- readonly id: string;
325
- /** The aggregate TYPE id the sum tallies. */
326
- readonly of: string;
327
- /**
328
- * The `int`-kind field whose values are accumulated, e.g. `"itemValue"`. MUST be
329
- * an `int`-kind field of the `of`-aggregate (validated at manifest-load in Rust).
330
- */
331
- readonly sumField: string;
332
- /**
333
- * The predicate filtering which aggregates contribute. OMITTED when no predicate.
334
- */
335
- readonly where?: CanonicalPred;
336
- /** The group-by field. OMITTED for a grand-total sum. */
337
- readonly by?: string;
338
- /** THE ESTATE SCOPE (sharding §5, slice 3) — see {@link CanonicalCount.scope}. */
339
- readonly scope?: string;
340
- }
341
-
342
- /**
343
- * One captured DERIVED read field — the read-side closure's engine-projected pure field.
344
- * Mirrors {@link CanonicalCount}: a derived field's NAME (`id`) + the aggregate it derives
345
- * from (`of`) are part of the domain CONTRACT (the read schema), so they are part of the
346
- * identity. The fn BODY is DELIBERATELY NOT captured — it is executable behaviour, exactly
347
- * like a directive's `plan` (which `domainManifest` also omits), so refactoring the derive
348
- * fn while keeping its `(id, of)` identical does NOT change the domain hash.
349
- */
350
- export interface CanonicalDerived {
351
- readonly id: string;
352
- /** The aggregate TYPE id the field is derived from, e.g. `SupportSessionAggregate`. */
353
- readonly of: string;
354
- /** The projected value schema. The fn body is not hashed; the value contract is. */
355
- readonly returns: CanonicalProjectionReturn;
356
- }
357
-
358
- /**
359
- * One captured COMBINED read field — the read-side closure's engine-projected CROSS-
360
- * AGGREGATE JOIN field. Mirrors {@link CanonicalDerived} but with the JOIN axis: a
361
- * combined field's NAME (`id`), the OWNER aggregate (`of`), the REF FIELD it joins
362
- * through (`refField`), and the RELATED type it queries (`reads`) are all part of the
363
- * domain CONTRACT (the read schema + the cross-aggregate invalidation edge), so they are
364
- * part of the identity. The fn BODY is DELIBERATELY NOT captured — executable behaviour,
365
- * exactly like a directive's `plan` / a derived field's fn, so refactoring the compose fn
366
- * while keeping its `(id, of, refField, reads)` identical does NOT change the domain hash.
367
- */
368
- export interface CanonicalCombined {
369
- readonly id: string;
370
- /** The OWNER aggregate TYPE id the field lives on, e.g. `BuildingAggregate`. */
371
- readonly of: string;
372
- /** The owner's REF FIELD whose value is the related aggregate's id, e.g. `siteId`. */
373
- readonly refField: string;
374
- /** The RELATED aggregate TYPE the field reads (the invalidation key), e.g. `SiteRootAggregate`. */
375
- readonly reads: string;
376
- /** The projected value schema. The fn body is not hashed; the value contract is. */
377
- readonly returns: CanonicalProjectionReturn;
378
- }
379
-
380
- /**
381
- * One captured MIN or MAX declaration — structurally identical to {@link CanonicalSum}
382
- * (same `of`/`valueField`/`where?`/`by?` shape) except it carries an explicit `kind`
383
- * discriminant (`"min"` | `"max"`). OMIT-WHEN-ABSENT: `where` and `by` follow the same
384
- * omit-when-not-declared discipline as `CanonicalSum`.
385
- */
386
- export interface CanonicalExtremum {
387
- readonly id: string;
388
- readonly kind: "min" | "max";
389
- /** The aggregate TYPE id the extremum tallies. */
390
- readonly of: string;
391
- /** The `int`-kind field being extremised. */
392
- readonly valueField: string;
393
- /** The predicate filtering which aggregates contribute. OMITTED when no predicate. */
394
- readonly where?: CanonicalPred;
395
- /** The group-by field. OMITTED for a grand-total extremum. */
396
- readonly by?: string;
397
- /** THE ESTATE SCOPE (sharding §5, slice 8) — see {@link CanonicalCount.scope}:
398
- * per-shard extremums ride the §5.2 delta lane as gate-adjudicated coordinator
399
- * subtotals (extremize-on-monotone; retractions re-derive). OMITTED for
400
- * home-scope extremums, so an unscoped declaration is byte-identical to before
401
- * this key existed (golden hashes UNCHANGED). */
402
- readonly scope?: string;
403
- }
404
-
405
- /**
406
- * One captured ORDERED READ declaration — an order-sensitive row read with a MANDATORY
407
- * declared total order. `orderKey` is NON-OPTIONAL (a missing key is a manifest
408
- * validation error, rejected LOUDLY at parse with no fallback). `limit` = 1 for `first`,
409
- * `n` for `take(n)`. OMIT-WHEN-ABSENT: `where` is omitted when no predicate.
410
- */
411
- export interface CanonicalOrderedRead {
412
- readonly id: string;
413
- /** The aggregate TYPE id the read returns. */
414
- readonly of: string;
415
- /** The REQUIRED total-order key (a declared scalar field of `of`). */
416
- readonly orderKey: string;
417
- /** Whether to sort descending. Default absent = ascending. */
418
- readonly orderDesc?: true;
419
- /** The optional predicate. OMITTED when no predicate. */
420
- readonly where?: CanonicalPred;
421
- /** LIMIT — 1 for `first`, n for `take(n)`. */
422
- readonly limit: number;
423
- }
424
-
425
- /**
426
- * A workspace invariant's PRESENCE in the hashed identity surface (#266): its id + the
427
- * directive it fires `on`. PRESENCE ONLY — the executable `reads`/`assert` bodies are NOT
428
- * hashed (they ship in the engine bundle), exactly like the aggregate `hasInvariant` flag.
429
- * Changing a body does NOT move the domain hash; adding/removing an invariant DOES (it is law).
430
- */
431
- export interface CanonicalWorkspaceInvariant {
432
- readonly id: string;
433
- readonly on: string;
434
- }
435
-
436
- /** A domain's canonical semantic manifest — the hashed identity surface. */
437
- export interface CanonicalManifest {
438
- readonly domain: string;
439
- readonly aggregates: CanonicalAggregate[];
440
- readonly directives: CanonicalDirective[];
441
- /**
442
- * STABLE IDENTIFIERS (#58 — the foundation: names are LABELS, identity is MINTED by
443
- * the compiler at first appearance, never dev-assigned). Carries `{stableId, name}`
444
- * for the domain, every aggregate, and every field — keyed by CURRENT name, each
445
- * entry holding its `sid` plus the label lineage `was` (prior names, oldest first;
446
- * compiler-derived from continuity, never dev-written). ALWAYS EMITTED by the post-
447
- * #58 compiler — this key MOVES every enforcement-era identity hash (named and
448
- * expected; inert-era artifacts already built stay byte-identical untouched). The
449
- * EVOLVE GATE diffs old vs new law BY SID; the projection folds via the sid lineage
450
- * so a renamed entity's old-era data coheres under the new label.
451
- */
452
- readonly stableIds?: StableIds;
453
- /**
454
- * The domain's WORKSPACE INVARIANTS (#266), SORTED by id, PRESENCE ONLY (id + `on`).
455
- * OMITTED ENTIRELY when the domain declares none — so an invariant-free domain is
456
- * byte-identical to before this key existed (pinned golden hashes UNCHANGED).
457
- */
458
- readonly workspaceInvariants?: CanonicalWorkspaceInvariant[];
459
- /**
460
- * The domain's NAMED, INDEXED read declarations, SORTED by id. OMITTED ENTIRELY
461
- * when the domain declares no query — so a query-free domain is byte-identical to
462
- * before this key existed (the pinned golden hash stays UNCHANGED). Each query's
463
- * `key` preserves its DECLARED order (index column order), unlike a directive's
464
- * incidental read SET.
465
- */
466
- readonly queries?: CanonicalQuery[];
467
- /**
468
- * The domain's NAMED, MAINTAINED count declarations, SORTED by id. OMITTED ENTIRELY
469
- * when the domain declares no count — so a count-free domain is byte-identical to
470
- * before this key existed (the pinned golden hash stays UNCHANGED), exactly like
471
- * {@link queries}. The aggregation analogue of the read-side closure. Each entry may
472
- * carry an optional `where` predicate (Slice 1 — omit-when-absent; predicate-free
473
- * counts are byte-identical to before `where` was introduced).
474
- */
475
- readonly counts?: CanonicalCount[];
476
- /**
477
- * The domain's NAMED, MAINTAINED SPATIAL (R*Tree) index declarations, SORTED by id.
478
- * OMITTED ENTIRELY when the domain declares no spatial index — so a spatial-free domain is
479
- * byte-identical to before this key existed (the pinned golden hash stays UNCHANGED),
480
- * exactly like {@link counts}. The geospatial analogue of the read-side closure. Each entry
481
- * carries `{id, of, on}` — the geometry field whose bounding box the engine maintains.
482
- */
483
- readonly spatials?: CanonicalSpatial[];
484
- /**
485
- * The domain's NAMED, MAINTAINED sum declarations, SORTED by id. OMITTED ENTIRELY
486
- * when the domain declares no sum — so a sum-free domain is byte-identical to before
487
- * this key existed (the pinned golden hash stays UNCHANGED), exactly like
488
- * {@link counts}. The numeric-accumulation analogue of count (Slice 1).
489
- */
490
- readonly sums?: CanonicalSum[];
491
- /**
492
- * The domain's DERIVED read-field declarations, SORTED by id. OMITTED ENTIRELY when the
493
- * domain declares no derived field — so a derived-free domain is byte-identical to before
494
- * this key existed (the pinned golden hash stays UNCHANGED), exactly like {@link counts}.
495
- * Each carries ONLY `{id, of}` — the fn body is NOT hashed (executable behaviour, like a
496
- * directive's `plan`).
497
- */
498
- readonly deriveds?: CanonicalDerived[];
499
- /**
500
- * The domain's COMBINED read-field declarations, SORTED by id. OMITTED ENTIRELY when the
501
- * domain declares no combined field — so a combined-free domain is byte-identical to
502
- * before this key existed (the pinned golden hash stays UNCHANGED), exactly like
503
- * {@link deriveds}. Each carries `{id, of, refField, reads}` — the fn body is NOT hashed.
504
- */
505
- readonly combineds?: CanonicalCombined[];
506
- /**
507
- * The domain's MAINTAINED MIN declarations, SORTED by id. OMITTED ENTIRELY when the
508
- * domain declares no min — so a min-free domain is byte-identical to before this key
509
- * existed (golden hashes unchanged). Same omit-when-absent discipline as `sums`.
510
- */
511
- readonly mins?: CanonicalExtremum[];
512
- /**
513
- * The domain's MAINTAINED MAX declarations, SORTED by id. OMITTED ENTIRELY when the
514
- * domain declares no max. Same discipline as `mins`.
515
- */
516
- readonly maxes?: CanonicalExtremum[];
517
- /**
518
- * The domain's ORDERED ROW READ declarations (first/take.orderBy), SORTED by id.
519
- * OMITTED ENTIRELY when the domain declares none. Each carries the REQUIRED `orderKey`
520
- * (non-optional at both TS and manifest levels), optional predicate, and `limit`.
521
- */
522
- readonly orderedReads?: CanonicalOrderedRead[];
523
- /**
524
- * The domain's FIRST-CLASS WORKSPACE TYPES (sharding §10 RATIFIED, slice 1), SORTED
525
- * by id. OMITTED ENTIRELY when the domain declares none — so a taxonomy-free domain
526
- * is byte-identical to before this key existed (pinned golden hashes UNCHANGED; the
527
- * `cap(n)` discipline). The taxonomy IS law: a type's root/mode/children/globals/
528
- * pool/cap all move the domain hash (per-type optional keys omitted-when-absent).
529
- */
530
- readonly workspaceTypes?: CanonicalWorkspaceType[];
531
- /**
532
- * The DERIVED HOMING TABLE (aggregate wire id → workspace-type id) — present iff
533
- * {@link workspaceTypes} is. Derived (never hand-written) by the fail-closed homing
534
- * walk over the aggregates' `t.ref` chains, and HASH-BEARING: a homing move is a
535
- * law change (placement of FACTS is custody, but which chain a fact is BORN into
536
- * is law — the shard gate's `wrong-home` invariant pins to this table).
537
- */
538
- readonly homes?: Record<string, string>;
539
- /**
540
- * THE DERIVED DIRECTIVE ROUTING TABLE (sharding slice 2) — present only when the
541
- * domain declares a taxonomy with at least one packed-homed directive, SORTED by
542
- * directive id, HASH-BEARING (the client routes by it; the shard gate's typed
543
- * `wrong-home` refusal pins to it). OMITTED ENTIRELY otherwise (hash-stable).
544
- */
545
- readonly routes?: CanonicalDirectiveRoute[];
546
- }
547
-
548
- /** Minimal structural view of Zod 4 schema internals we read deterministically. */
549
- interface ZodLike {
550
- _def?: {
551
- type?: string;
552
- innerType?: ZodLike;
553
- shape?: Record<string, ZodLike> | (() => Record<string, ZodLike>);
554
- };
555
- def?: {
556
- type?: string;
557
- innerType?: ZodLike;
558
- shape?: Record<string, ZodLike> | (() => Record<string, ZodLike>);
559
- };
560
- shape?: Record<string, ZodLike>;
561
- }
562
-
563
- function zodDef(zod: ZodLike): NonNullable<ZodLike["_def"]> {
564
- return zod._def ?? zod.def ?? {};
565
- }
566
-
567
- function zodTypeName(zod: ZodLike): string {
568
- const def = zodDef(zod);
569
- return def.type ?? "unknown";
570
- }
571
-
572
- function zodObjectShape(zod: ZodLike): Record<string, ZodLike> | undefined {
573
- const shape = zodDef(zod).shape;
574
- if (typeof shape === "function") return shape();
575
- if (shape !== undefined) return shape;
576
- return zod.shape;
577
- }
578
-
579
- /**
580
- * Sort an object's keys so the schema's field order is irrelevant to identity. The
581
- * authored field order on an aggregate or payload is incidental; only the SET of
582
- * field→driver / field→type pairs is the contract.
583
- */
584
- function sortedSchema(schema: WireSchema): WireSchema {
585
- const out: WireSchema = {};
586
- for (const key of Object.keys(schema).sort()) out[key] = schema[key]!;
587
- return out;
588
- }
589
-
590
- /**
591
- * Unwrap ZodOptional / ZodDefault to recover `{ optional, type }`. The OPTIONALITY
592
- * is part of the contract (an optional field is a different shape); the wrapper
593
- * identity (Optional vs Default) is NOT — both mean "may be absent" to a caller.
594
- */
595
- function unwrapPayloadType(zod: ZodLike): { optional: boolean; type: string } {
596
- let cur = zod;
597
- let optional = false;
598
- // Peel optional/default wrappers (possibly nested) to the inner contract type.
599
- while (zodTypeName(cur) === "optional" || zodTypeName(cur) === "default") {
600
- optional = true;
601
- const inner = zodDef(cur).innerType;
602
- if (inner === undefined) break;
603
- cur = inner;
604
- }
605
- return { optional, type: zodTypeName(cur) };
606
- }
607
-
608
- function unwrapProjectionReturn(zod: ZodLike): { nullable: boolean; type: string } {
609
- let cur = zod;
610
- let nullable = false;
611
- while (
612
- zodTypeName(cur) === "optional" ||
613
- zodTypeName(cur) === "default" ||
614
- zodTypeName(cur) === "nullable"
615
- ) {
616
- nullable = true;
617
- const inner = zodDef(cur).innerType;
618
- if (inner === undefined) break;
619
- cur = inner;
620
- }
621
- return { nullable, type: zodTypeName(cur) };
622
- }
623
-
624
- function canonicalProjectionReturn(schema: unknown): CanonicalProjectionReturn {
625
- const { nullable, type } = unwrapProjectionReturn(schema as ZodLike);
626
- return {
627
- type,
628
- nullable,
629
- jsonSchema: z.toJSONSchema(schema as z.ZodTypeAny),
630
- };
631
- }
632
-
633
- /**
634
- * Canonically capture a directive's payload: the top-level object's fields as
635
- * SORTED `[name, type]` pairs (with optionality). A non-object top-level schema
636
- * (rare) captures as a single synthetic field carrying its type name.
637
- */
638
- function canonicalPayload(payloadSchema: unknown): CanonicalPayloadField[] {
639
- const zod = payloadSchema as ZodLike;
640
- const shape = zodObjectShape(zod);
641
- if (zodTypeName(zod) !== "object" || shape === undefined) {
642
- // Not a top-level object: thing the bare type so a shape change still moves
643
- // the hash, without inventing field structure.
644
- return [{ name: "", type: zodTypeName(zod), optional: false }];
645
- }
646
- return Object.keys(shape)
647
- .sort()
648
- .map((name) => {
649
- const { optional, type } = unwrapPayloadType(shape[name]!);
650
- return { name, type, optional };
651
- });
652
- }
653
-
654
- function canonicalPayloadJsonSchema(payloadSchema: unknown): unknown {
655
- return z.toJSONSchema(payloadSchema as z.ZodTypeAny);
656
- }
657
-
658
- /**
659
- * Canonicalize a directive's declared READ boundary into a manifest fragment.
660
- * OMIT-WHEN-EMPTY: returns `{}` (no `reads` key at all) when nothing is declared —
661
- * the omission, not an empty array, is what keeps boundary-free hashes unchanged.
662
- * When non-empty, returns a SORTED `reads` array (order is incidental to the boundary).
663
- */
664
- function canonicalReads(declared: string[]): { reads?: string[] } {
665
- if (declared.length === 0) return {};
666
- return { reads: [...declared].sort() };
667
- }
668
-
669
- /** Canonicalize a directive's MULTI-OUTPUT secondary writes → `{ target, marker }` sorted by (target,
670
- * marker). Omit-when-empty (the era rule — a single-output directive contributes no key). */
671
- function canonicalSecondaryOutputs(
672
- declared: readonly { marker: ReferentialMarker; aggregateId: string }[] | undefined,
673
- ): { secondaryOutputs?: { target: string; marker: WireRefMode }[] } {
674
- if (declared === undefined || declared.length === 0) return {};
675
- const out = declared
676
- .map((s) => ({ target: s.aggregateId, marker: encodeRefMode(s.marker) }))
677
- .sort((a, b) => (a.target !== b.target ? (a.target < b.target ? -1 : 1) : a.marker < b.marker ? -1 : a.marker > b.marker ? 1 : 0));
678
- return { secondaryOutputs: out };
679
- }
680
-
681
- /**
682
- * Canonicalize a directive's declared required HOST CAPABILITY ports into a manifest fragment
683
- * (the HOST/PORT axis, invariant 3). OMIT-WHEN-EMPTY: returns `{}` (no `requiresHostCapability`
684
- * key at all) when the directive declares none — the omission, not an empty array, is what keeps
685
- * host-capability-free hashes UNCHANGED. When non-empty, returns a SORTED `requiresHostCapability`
686
- * array (order is incidental to the port SET). DISTINCT from `canonicalReads`/`requiresCapability`
687
- * (the two axes — HOST vs AUTHZ — never conflate in the manifest).
688
- */
689
- function canonicalHostCapabilities(declared: string[]): { requiresHostCapability?: string[] } {
690
- if (declared.length === 0) return {};
691
- return { requiresHostCapability: [...declared].sort() };
692
- }
693
-
694
- /**
695
- * Canonicalize a directive's declared EMIT boundary into a manifest fragment.
696
- * OMIT-WHEN-EMPTY: returns `{}` (no `emits` key) when nothing is declared. When
697
- * non-empty, returns an `emits` object whose keys `canonicalJson` will sort, each
698
- * value `{ max }` only when a bound is set (so `{}` vs `{max}` differs in identity).
699
- */
700
- function canonicalEmits(
701
- declared: Record<string, { max?: number }>,
702
- ): { emits?: Record<string, { max?: number }> } {
703
- const keys = Object.keys(declared);
704
- if (keys.length === 0) return {};
705
- const emits: Record<string, { max?: number }> = {};
706
- for (const key of keys.sort()) {
707
- const max = declared[key]!.max;
708
- emits[key] = max !== undefined ? { max } : {};
709
- }
710
- return { emits };
711
- }
712
-
713
- /**
714
- * Canonicalize a directive's DECLARED captured-read queries (#58) into a manifest
715
- * fragment. OMIT-WHEN-EMPTY: returns `{}` (no `capturedReads` key) when the directive
716
- * declares none — the omission keeps read-free hashes unchanged. When non-empty,
717
- * returns a `capturedReads` array SORTED by queryId, each carrying the read recipe
718
- * `{queryId, key, returns}` (key in DECLARED order — index column order).
719
- */
720
- function canonicalCapturedReads(
721
- declared: QueryDecl[],
722
- ): { capturedReads?: CanonicalCapturedRead[] } {
723
- if (declared.length === 0) return {};
724
- const capturedReads: CanonicalCapturedRead[] = declared
725
- .map((q) => ({ queryId: q.id, key: [...q.key], returns: q.returns }))
726
- .sort((a, b) => (a.queryId < b.queryId ? -1 : a.queryId > b.queryId ? 1 : 0));
727
- return { capturedReads };
728
- }
729
-
730
- /**
731
- * Canonicalize a directive's declared cross-workspace relations into a manifest fragment
732
- * (`cross_workspace.md` §2). OMIT-WHEN-EMPTY: returns `{}` (no `relations` key) when the
733
- * directive declares none — the omission keeps relation-free hashes unchanged. When non-empty,
734
- * a `relations` array SORTED BY relation `id`, each carrying the endpoints + the bounded
735
- * evidence read (SELECT fields SORTED) + `hasInvariant` (presence only). A recipe change
736
- * (aggregate/fields/predicate) MOVES the domain hash — a different relation is a different
737
- * contract. The invariant PREDICATE body is NOT captured (executable behaviour, like `plan`).
738
- */
739
- function canonicalRelations(
740
- declared: RelationDecl[],
741
- ): { relations?: CanonicalRelation[] } {
742
- if (declared.length === 0) return {};
743
- const relations: CanonicalRelation[] = declared
744
- .map((r) => ({
745
- id: r.id,
746
- source: r.source,
747
- target: r.target,
748
- proposes: r.proposes,
749
- evidence: {
750
- aggregate: r.evidence.aggregate,
751
- select: [...r.evidence.select].sort(),
752
- where: r.evidence.where,
753
- kind: r.evidence.kind,
754
- },
755
- hasInvariant: r.hasInvariant,
756
- }))
757
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
758
- return { relations };
759
- }
760
-
761
- /**
762
- * Canonicalize a domain's declared QUERIES into a manifest fragment.
763
- * OMIT-WHEN-EMPTY: returns `{}` (no `queries` key at all) when the domain declares
764
- * none — the omission, not an empty array, is what keeps query-free hashes unchanged.
765
- * When non-empty, returns a `queries` array SORTED by id (the SET of queries is the
766
- * contract), each carrying its `key` in DECLARED order (index column order, NOT
767
- * sorted) and its `returns` aggregate type id.
768
- */
769
- function canonicalQueries(
770
- declared: QueryDecl[] | undefined,
771
- ): { queries?: CanonicalQuery[] } {
772
- if (declared === undefined || declared.length === 0) return {};
773
- const queries: CanonicalQuery[] = [...declared]
774
- .map((q) => ({ id: q.id, key: [...q.key], returns: q.returns }))
775
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
776
- return { queries };
777
- }
778
-
779
- /**
780
- * Canonicalize a domain's WORKSPACE INVARIANTS into a manifest fragment (#266) — the
781
- * presence-only analogue of {@link canonicalQueries}. OMIT-WHEN-EMPTY: returns `{}` (no key)
782
- * when none are declared, so an invariant-free domain hashes identically to before. Each entry
783
- * is PRESENCE ONLY (id + the directive it is `on`), SORTED by id; the executable `reads`/
784
- * `assert` bodies are NEVER hashed (they ship in the engine bundle). So changing a body leaves
785
- * the hash unchanged, while adding/removing an invariant moves it (it is law — #131).
786
- */
787
- function canonicalWorkspaceInvariants(
788
- declared: WorkspaceInvariantDecl[] | undefined,
789
- ): { workspaceInvariants?: CanonicalWorkspaceInvariant[] } {
790
- if (declared === undefined || declared.length === 0) return {};
791
- const workspaceInvariants: CanonicalWorkspaceInvariant[] = [...declared]
792
- .map((wi) => ({ id: wi.id, on: wi.on }))
793
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
794
- return { workspaceInvariants };
795
- }
796
-
797
- /**
798
- * Canonicalize a domain's declared COUNTS into a manifest fragment — the aggregation
799
- * analogue of {@link canonicalQueries}.
800
- * OMIT-WHEN-EMPTY: returns `{}` (no `counts` key at all) when the domain declares none —
801
- * the omission, not an empty array, is what keeps count-free hashes unchanged. When
802
- * non-empty, returns a `counts` array SORTED by id (the SET of counts is the contract),
803
- * each carrying its `of`-type and — only when declared — its `by` group-by field (a
804
- * grand-total count omits `by` entirely, so it adds no phantom key to the hash).
805
- */
806
- function canonicalCounts(
807
- declared: AnyCount[] | undefined,
808
- ): { counts?: CanonicalCount[] } {
809
- if (declared === undefined || declared.length === 0) return {};
810
- const counts: CanonicalCount[] = [...declared]
811
- .map((raw) => ({ finished: finishCount(raw), scope: (raw as { scope?: unknown }).scope }))
812
- .map(({ finished: c, scope }) => ({
813
- id: c.id,
814
- of: c.of,
815
- // OMIT-WHEN-ABSENT: `where`, `by` and `scope` are omitted when not declared so
816
- // a predicate-free / grand-total / home-scope count is byte-identical to the
817
- // legacy form (hash-stable — the same discipline as every other optional key).
818
- ...(c.where !== undefined ? { where: c.where } : {}),
819
- ...(c.by !== undefined ? { by: c.by } : {}),
820
- ...(typeof scope === "string" ? { scope } : {}),
821
- }))
822
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
823
- return { counts };
824
- }
825
-
826
- /**
827
- * Canonicalize a domain's declared SPATIAL indexes into a manifest fragment — the
828
- * geospatial analogue of {@link canonicalCounts}.
829
- * OMIT-WHEN-EMPTY: returns `{}` (no `spatials` key at all) when the domain declares none —
830
- * the omission, not an empty array, is what keeps spatial-free hashes unchanged. When
831
- * non-empty, returns a `spatials` array SORTED by id (the SET of spatial indexes is the
832
- * contract), each carrying its `of`-type and the `on` geometry field whose bounding box is
833
- * indexed.
834
- */
835
- function canonicalSpatials(
836
- declared: SpatialDecl[] | undefined,
837
- ): { spatials?: CanonicalSpatial[] } {
838
- if (declared === undefined || declared.length === 0) return {};
839
- const spatials: CanonicalSpatial[] = [...declared]
840
- .map((s) => ({ id: s.id, of: s.of, on: s.on }))
841
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
842
- return { spatials };
843
- }
844
-
845
- /**
846
- * Canonicalize a domain's declared SUMS into a manifest fragment — the numeric-
847
- * accumulation analogue of {@link canonicalCounts}.
848
- * OMIT-WHEN-EMPTY: returns `{}` (no `sums` key at all) when the domain declares none.
849
- * When non-empty, returns a `sums` array SORTED by id. `where` and `by` are omitted
850
- * when not declared (same omit-when-absent discipline as `CanonicalCount`).
851
- */
852
- function canonicalSums(declared: AnySum[] | undefined): { sums?: CanonicalSum[] } {
853
- if (declared === undefined || declared.length === 0) return {};
854
- const sums: CanonicalSum[] = [...declared]
855
- .map((raw) => ({ finished: finishSum(raw), scope: (raw as { scope?: unknown }).scope }))
856
- .map(({ finished: s, scope }) => ({
857
- id: s.id,
858
- of: s.of,
859
- sumField: s.sumField,
860
- ...(s.where !== undefined ? { where: s.where } : {}),
861
- ...(s.by !== undefined ? { by: s.by } : {}),
862
- ...(typeof scope === "string" ? { scope } : {}),
863
- }))
864
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
865
- return { sums };
866
- }
867
-
868
- /**
869
- * Canonicalize a domain's declared DERIVED read fields into a manifest fragment — the
870
- * engine-projected analogue of {@link canonicalCounts}.
871
- * OMIT-WHEN-EMPTY: returns `{}` (no `deriveds` key at all) when the domain declares none —
872
- * the omission, not an empty array, is what keeps derived-free hashes unchanged. When
873
- * non-empty, returns a `deriveds` array SORTED by id (the SET of derived fields is the
874
- * contract), each carrying ONLY its `{id, of}` — the fn BODY is NOT hashed (executable
875
- * behaviour, exactly like a directive's `plan` body, which this manifest also omits).
876
- */
877
- function canonicalDeriveds(
878
- declared: DerivedDecl[] | undefined,
879
- ): { deriveds?: CanonicalDerived[] } {
880
- if (declared === undefined || declared.length === 0) return {};
881
- const deriveds: CanonicalDerived[] = [...declared]
882
- .map((d) => ({ id: d.id, of: d.of, returns: canonicalProjectionReturn(d.returns) }))
883
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
884
- return { deriveds };
885
- }
886
-
887
- /**
888
- * Canonicalize a domain's declared COMBINED read fields into a manifest fragment — the
889
- * cross-aggregate JOIN analogue of {@link canonicalDeriveds}.
890
- * OMIT-WHEN-EMPTY: returns `{}` (no `combineds` key at all) when the domain declares none —
891
- * the omission, not an empty array, is what keeps combined-free hashes unchanged. When
892
- * non-empty, returns a `combineds` array SORTED by id (the SET of combined fields is the
893
- * contract), each carrying `{id, of, refField, reads}` — the JOIN edge + invalidation key
894
- * are part of the contract; the fn BODY is NOT hashed (executable behaviour, like a
895
- * directive's `plan` body, which this manifest also omits).
896
- */
897
- function canonicalCombineds(
898
- declared: CombinedDecl[] | undefined,
899
- ): { combineds?: CanonicalCombined[] } {
900
- if (declared === undefined || declared.length === 0) return {};
901
- const combineds: CanonicalCombined[] = [...declared]
902
- .map((c) => ({
903
- id: c.id,
904
- of: c.of,
905
- refField: c.refField,
906
- reads: c.reads,
907
- returns: canonicalProjectionReturn(c.returns),
908
- }))
909
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
910
- return { combineds };
911
- }
912
-
913
- /**
914
- * Canonicalize a domain's declared MINS or MAXES into a manifest fragment.
915
- * OMIT-WHEN-EMPTY: returns `{}` (no key at all) when the domain declares none.
916
- * When non-empty, returns the array SORTED by id. `where` and `by` follow the same
917
- * omit-when-not-declared discipline as {@link canonicalSums}.
918
- */
919
- function canonicalExtrema(
920
- declared: AnyExtremum[] | undefined,
921
- keyName: "mins" | "maxes",
922
- ): { mins?: CanonicalExtremum[] } | { maxes?: CanonicalExtremum[] } {
923
- if (declared === undefined || declared.length === 0) return {};
924
- const items: CanonicalExtremum[] = [...declared]
925
- .map((raw) => ({ finished: finishExtremum(raw), scope: (raw as { scope?: unknown }).scope }))
926
- .map(({ finished: e, scope }) => ({
927
- id: e.id,
928
- kind: e.kind,
929
- of: e.of,
930
- valueField: e.valueField,
931
- ...(e.where !== undefined ? { where: e.where } : {}),
932
- ...(e.by !== undefined ? { by: e.by } : {}),
933
- // OMIT-WHEN-ABSENT (the canonicalSums discipline): a home-scope extremum is
934
- // byte-identical to before the scope key existed — hash-stable by default.
935
- ...(typeof scope === "string" ? { scope } : {}),
936
- }))
937
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
938
- return { [keyName]: items };
939
- }
940
-
941
- /**
942
- * Canonicalize a domain's declared ORDERED READS (first/take) into a manifest fragment.
943
- * OMIT-WHEN-EMPTY: returns `{}` (no key at all) when the domain declares none.
944
- * When non-empty, returns an `orderedReads` array SORTED by id. `where` is omitted when
945
- * not declared; `orderDesc` is omitted when false (default = ascending). `orderKey` is
946
- * ALWAYS present (it is required — never optional).
947
- */
948
- function canonicalOrderedReads(
949
- declared: OrderedReadDecl[] | undefined,
950
- ): { orderedReads?: CanonicalOrderedRead[] } {
951
- if (declared === undefined || declared.length === 0) return {};
952
- const items: CanonicalOrderedRead[] = [...declared]
953
- .map((d) => ({
954
- id: d.id,
955
- of: d.of,
956
- orderKey: d.orderKey,
957
- // OMIT-WHEN-DEFAULT: only include `orderDesc` when it is `true` (non-default).
958
- ...(d.orderDesc ? { orderDesc: true as const } : {}),
959
- ...(d.where !== undefined ? { where: d.where } : {}),
960
- limit: d.limit,
961
- }))
962
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
963
- return { orderedReads: items };
964
- }
965
-
966
- /**
967
- * The domain's STABLE-ID surface (#58): continuity-carried when the compiler attached
968
- * `mod.stableIdContinuity` (renames resolved, sids + lineage carried), deterministic
969
- * first-appearance minting otherwise — so the manifest stays a pure function of the
970
- * module (+ its attached continuity). Any aggregate/field the continuity does not
971
- * cover (a genuinely new appearance) mints fresh.
972
- */
973
- function stableIdsForModule(
974
- mod: DomainModule,
975
- aggregates: CanonicalAggregate[],
976
- ): StableIds {
977
- const continuity = mod.stableIdContinuity;
978
- const domainSid = continuity?.domain ?? mintDomainSid(mod.name);
979
- // The CURRENT field KINDS off the live handles (the retype arm's identity input —
980
- // a `t.string()` → `t.int()` retype moves `kind` while the driver stays Lww).
981
- const handlesById = new Map(mod.aggregates.map((a) => [a.id, a]));
982
- const out: Record<string, StableAggregateId> = {};
983
- for (const agg of aggregates) {
984
- const prior = continuity?.aggregates[agg.id];
985
- const aggSid = prior?.sid ?? mintAggregateSid(domainSid, agg.id);
986
- const handleFields = (handlesById.get(agg.id)?.fields ?? {}) as Record<string, Field | undefined>;
987
- const fields: Record<string, StableFieldId> = {};
988
- for (const fieldName of Object.keys(agg.schema).sort()) {
989
- const pf = prior?.fields[fieldName];
990
- const kind = handleFields[fieldName]?.kind;
991
- const handle = handleFields[fieldName];
992
- const details = handle === undefined ? undefined : fieldValueContractDetails(handle);
993
- const driver = handle === undefined ? undefined : encodeDriver(handle.driver);
994
- const schema =
995
- handle === undefined || kind === undefined || details === undefined || driver === undefined
996
- ? undefined
997
- : fieldSchemaIdentity(aggSid, pf?.sid ?? mintFieldSid(aggSid, fieldName), kind, details.contract, driver);
998
- const evolutions =
999
- handle === undefined || schema === undefined || driver === undefined || details === undefined
1000
- ? []
1001
- : fieldEvolutionRules(handle, pf, aggSid, pf?.sid ?? mintFieldSid(aggSid, fieldName), details, driver, schema);
1002
- fields[fieldName] = {
1003
- sid: pf?.sid ?? mintFieldSid(aggSid, fieldName),
1004
- ...(pf?.was !== undefined && pf.was.length > 0 ? { was: pf.was } : {}),
1005
- // The CURRENT kind, always from the live module (continuity never supplies it).
1006
- ...(kind !== undefined ? { kind } : {}),
1007
- ...(details !== undefined ? { contract: details.contract, shape: details.shape } : {}),
1008
- ...(schema !== undefined ? { schema } : {}),
1009
- ...(driver !== undefined ? { driver } : {}),
1010
- ...(evolutions.length > 0 ? { evolutions } : {}),
1011
- // Birth-presence law rides identity: the evolve guard reads it (fromBirth may not be
1012
- // NEWLY declared on a sid that existed without it).
1013
- ...(handleFields[fieldName]?.isFromBirth === true ? { fromBirth: true as const } : {}),
1014
- };
1015
- }
1016
- out[agg.id] = {
1017
- sid: aggSid,
1018
- ...(prior?.was !== undefined && prior.was.length > 0 ? { was: prior.was } : {}),
1019
- fields,
1020
- };
1021
- }
1022
- return { v: 1, domain: domainSid, aggregates: out };
1023
- }
1024
-
1025
- /**
1026
- * Identity of the value a generated TS/Dart conflict resolver is allowed to call `T`.
1027
- * This is deliberately narrower than the domain hash (unrelated law may roll out without
1028
- * invalidating a conflict) and richer than `Field.kind` (enum arms, value-object members,
1029
- * nullability, references and encrypted/plain decoding all matter to the public type).
1030
- */
1031
- function fieldValueContractDetails(field: Field): { contract: string; shape: unknown } {
1032
- const valueObjectContract = (meta: NonNullable<Field["valueObject"]>) => ({
1033
- descriptor: meta.descriptor,
1034
- dependencies: meta.dependencies,
1035
- ...(meta.geometry !== undefined ? { geometry: meta.geometry } : {}),
1036
- });
1037
- const contractShape = normalizeFieldContract({
1038
- v: "nomos-field-value-contract-v1",
1039
- kind: field.kind,
1040
- jsonSchema: z.toJSONSchema(field.zod as z.ZodTypeAny),
1041
- ...(field.mapValueKind !== undefined ? { mapValueKind: field.mapValueKind } : {}),
1042
- ...(field.jsonShape !== undefined ? { jsonShape: field.jsonShape } : {}),
1043
- ...(field.valueObject !== undefined
1044
- ? { valueObject: valueObjectContract(field.valueObject) }
1045
- : {}),
1046
- ...(field.mapValueVo !== undefined
1047
- ? { mapValueVo: valueObjectContract(field.mapValueVo) }
1048
- : {}),
1049
- ...(field.refAggregateId !== undefined ? { refAggregateId: field.refAggregateId } : {}),
1050
- ...(field.foreignRef !== undefined ? { foreignRef: field.foreignRef } : {}),
1051
- ...(field.nullSemantic !== undefined ? { nullSemantic: field.nullSemantic } : {}),
1052
- ...(field.isClearable === true ? { clearable: true } : {}),
1053
- ...(field.isEncrypted === true ? { encrypted: true } : {}),
1054
- });
1055
- const contract = createHash("sha256")
1056
- .update(
1057
- `nomos-field-value-contract:v1:${canonicalJson(contractShape)}`,
1058
- "utf8",
1059
- )
1060
- .digest("hex");
1061
- return { contract, shape: contractShape };
1062
- }
1063
-
1064
- function fieldSchemaIdentity(
1065
- aggregateSid: string,
1066
- fieldSid: string,
1067
- kind: string,
1068
- valueContract: string,
1069
- driver: unknown,
1070
- ): string {
1071
- return createHash("sha256")
1072
- .update(
1073
- canonicalJson({
1074
- v: "nomos-field-schema-v2",
1075
- aggregate: aggregateSid,
1076
- field: fieldSid,
1077
- kind,
1078
- valueContract,
1079
- driver,
1080
- }),
1081
- "utf8",
1082
- )
1083
- .digest("hex");
1084
- }
1085
-
1086
- function fieldEvolutionRules(
1087
- field: Field,
1088
- prior: StableFieldId | undefined,
1089
- aggregateSid: string,
1090
- fieldSid: string,
1091
- target: { contract: string; shape: unknown },
1092
- driver: unknown,
1093
- targetSchema: string,
1094
- ): StableFieldEvolution[] {
1095
- const bySource = new Map<string, StableFieldEvolution>();
1096
- const add = (rule: StableFieldEvolution, origin: string) => {
1097
- if (rule.from === rule.to) return;
1098
- const existing = bySource.get(rule.from);
1099
- if (existing !== undefined && canonicalJson(existing) !== canonicalJson(rule)) {
1100
- throw new Error(
1101
- `field evolution collision for source contract ${rule.from}: ${origin} disagrees with an existing route. ` +
1102
- `One source contract must have exactly one deterministic destination.`,
1103
- );
1104
- }
1105
- bySource.set(rule.from, rule);
1106
- };
1107
-
1108
- for (const rule of prior?.evolutions ?? []) add(rule, "the prior release");
1109
-
1110
- for (const declaration of field.evolutions ?? []) {
1111
- const source = fieldValueContractDetails(declaration.source);
1112
- const sourceSchema = fieldSchemaIdentity(
1113
- aggregateSid,
1114
- fieldSid,
1115
- declaration.source.kind,
1116
- source.contract,
1117
- driver,
1118
- );
1119
- if (declaration.mode === "identity" && !safeIdentityProjection(source.shape, target.shape)) {
1120
- throw new Error(
1121
- `.evolvesFrom(source) is not a safe identity widening: some source values are not valid ` +
1122
- `under the target field. Supply a finite business transform as the second argument.`,
1123
- );
1124
- }
1125
- add(
1126
- {
1127
- from: sourceSchema,
1128
- to: targetSchema,
1129
- source: {
1130
- kind: declaration.source.kind,
1131
- ...(declaration.source.mapValueKind !== undefined
1132
- ? { mapValueKind: declaration.source.mapValueKind }
1133
- : {}),
1134
- },
1135
- mode: declaration.mode,
1136
- ...(declaration.cases !== undefined ? { cases: declaration.cases } : {}),
1137
- },
1138
- ".evolvesFrom()",
1139
- );
1140
- }
1141
-
1142
- // The compiler silently owns genuinely compatible widening between consecutive releases.
1143
- // The prior lock carries the full value descriptor, so this is a proof, not a cast.
1144
- if (
1145
- prior?.schema !== undefined &&
1146
- prior.schema !== targetSchema &&
1147
- prior.shape !== undefined &&
1148
- canonicalJson(prior.driver) === canonicalJson(driver) &&
1149
- safeAutomaticWidening(prior.shape, target.shape) &&
1150
- !bySource.has(prior.schema)
1151
- ) {
1152
- add(
1153
- {
1154
- from: prior.schema,
1155
- to: targetSchema,
1156
- source: {
1157
- kind: prior.kind ?? field.kind,
1158
- ...((prior.shape as { mapValueKind?: string } | undefined)?.mapValueKind !== undefined
1159
- ? { mapValueKind: (prior.shape as { mapValueKind: string }).mapValueKind }
1160
- : {}),
1161
- },
1162
- mode: "identity",
1163
- },
1164
- "the compiler's compatible-widening proof",
1165
- );
1166
- }
1167
-
1168
- // Every retained source route must reach the one current target. This is the compile-time
1169
- // completeness gate for all still-supported eras, not merely the immediately prior law.
1170
- for (const source of bySource.keys()) {
1171
- const seen = new Set<string>();
1172
- let cursor = source;
1173
- while (cursor !== targetSchema) {
1174
- if (seen.has(cursor)) {
1175
- throw new Error(`field evolution graph cycles at ${cursor}; every source must terminate at ${targetSchema}`);
1176
- }
1177
- seen.add(cursor);
1178
- const next = bySource.get(cursor);
1179
- if (next === undefined) {
1180
- throw new Error(
1181
- `field evolution route from ${source} stops at ${cursor}, not the current contract ${targetSchema}. ` +
1182
- `Carry the intermediate source with .evolvesFrom(...) before this rollout.`,
1183
- );
1184
- }
1185
- cursor = next.to;
1186
- }
1187
- }
1188
-
1189
- return [...bySource.values()].sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
1190
- }
1191
-
1192
- /** Explicit identity projection: the tenant declares the business meaning is continuous;
1193
- * the compiler still proves every old value is accepted and its physical decoding is unchanged. */
1194
- function safeIdentityProjection(sourceShape: unknown, targetShape: unknown): boolean {
1195
- if (sourceShape === null || targetShape === null || typeof sourceShape !== "object" || typeof targetShape !== "object") {
1196
- return canonicalJson(sourceShape) === canonicalJson(targetShape);
1197
- }
1198
- const source = sourceShape as Record<string, unknown>;
1199
- const target = targetShape as Record<string, unknown>;
1200
- const physical = (shape: Record<string, unknown>) => {
1201
- const { kind, mapValueKind, jsonShape, encrypted } = shape;
1202
- return { kind, mapValueKind, jsonShape, encrypted };
1203
- };
1204
- return canonicalJson(physical(source)) === canonicalJson(physical(target))
1205
- && jsonSchemaSubset(source.jsonSchema, target.jsonSchema);
1206
- }
1207
-
1208
- /** Automatic widening is stricter: every non-schema business descriptor must remain
1209
- * identical. Brands, units, references and null meaning require an explicit declaration. */
1210
- function safeAutomaticWidening(sourceShape: unknown, targetShape: unknown): boolean {
1211
- if (sourceShape === null || targetShape === null || typeof sourceShape !== "object" || typeof targetShape !== "object") {
1212
- return canonicalJson(sourceShape) === canonicalJson(targetShape);
1213
- }
1214
- const source = sourceShape as Record<string, unknown>;
1215
- const target = targetShape as Record<string, unknown>;
1216
- const metadata = (shape: Record<string, unknown>) => {
1217
- const { jsonSchema: _jsonSchema, ...rest } = shape;
1218
- return rest;
1219
- };
1220
- return canonicalJson(metadata(source)) === canonicalJson(metadata(target))
1221
- && jsonSchemaSubset(source.jsonSchema, target.jsonSchema);
1222
- }
1223
-
1224
- function jsonSchemaSubset(source: unknown, target: unknown): boolean {
1225
- if (canonicalJson(source) === canonicalJson(target)) return true;
1226
- if (source === null || target === null || typeof source !== "object" || typeof target !== "object") return false;
1227
- const s = source as Record<string, unknown>;
1228
- const t = target as Record<string, unknown>;
1229
- if (Array.isArray(s.enum)) return s.enum.every((value) => jsonSchemaAcceptsLiteral(t, value));
1230
- if (Object.prototype.hasOwnProperty.call(s, "const")) return jsonSchemaAcceptsLiteral(t, s.const);
1231
- const sUnion = (Array.isArray(s.anyOf) ? s.anyOf : Array.isArray(s.oneOf) ? s.oneOf : undefined);
1232
- if (sUnion !== undefined) return sUnion.every((arm) => jsonSchemaSubset(arm, target));
1233
- const tUnion = (Array.isArray(t.anyOf) ? t.anyOf : Array.isArray(t.oneOf) ? t.oneOf : undefined);
1234
- if (tUnion !== undefined) return tUnion.some((arm) => jsonSchemaSubset(source, arm));
1235
- const sourceTypes = new Set(Array.isArray(s.type) ? s.type : s.type === undefined ? [] : [s.type]);
1236
- const targetTypes = new Set(Array.isArray(t.type) ? t.type : t.type === undefined ? [] : [t.type]);
1237
- if ([...sourceTypes].some((type) => !targetTypes.has(type))) return false;
1238
- if (sourceTypes.has("object")) {
1239
- const sp = (s.properties ?? {}) as Record<string, unknown>;
1240
- const tp = (t.properties ?? {}) as Record<string, unknown>;
1241
- const sr = new Set(Array.isArray(s.required) ? s.required as string[] : []);
1242
- const tr = new Set(Array.isArray(t.required) ? t.required as string[] : []);
1243
- if ([...tr].some((name) => !sr.has(name))) return false;
1244
- for (const [name, schema] of Object.entries(sp)) {
1245
- if (tp[name] === undefined) {
1246
- if (t.additionalProperties === false) return false;
1247
- } else if (!jsonSchemaSubset(schema, tp[name])) {
1248
- return false;
1249
- }
1250
- }
1251
- if (s.additionalProperties !== false && t.additionalProperties === false) return false;
1252
- }
1253
- if (sourceTypes.has("array") && s.items !== undefined && t.items !== undefined) {
1254
- if (!jsonSchemaSubset(s.items, t.items)) return false;
1255
- }
1256
- return sourceTypes.size > 0 && [...sourceTypes].every((type) => targetTypes.has(type));
1257
- }
1258
-
1259
- function jsonSchemaAcceptsLiteral(schema: Record<string, unknown>, value: unknown): boolean {
1260
- if (Array.isArray(schema.enum)) return schema.enum.some((candidate) => canonicalJson(candidate) === canonicalJson(value));
1261
- if (Object.prototype.hasOwnProperty.call(schema, "const")) return canonicalJson(schema.const) === canonicalJson(value);
1262
- const union = Array.isArray(schema.anyOf) ? schema.anyOf : Array.isArray(schema.oneOf) ? schema.oneOf : undefined;
1263
- if (union !== undefined) return union.some((arm) => arm !== null && typeof arm === "object" && jsonSchemaAcceptsLiteral(arm as Record<string, unknown>, value));
1264
- const actual = value === null ? "null" : Array.isArray(value) ? "array" : typeof value === "number" ? "number" : typeof value;
1265
- const types = new Set(Array.isArray(schema.type) ? schema.type : schema.type === undefined ? [] : [schema.type]);
1266
- return types.has(actual) || (actual === "number" && types.has("integer") && Number.isInteger(value));
1267
- }
1268
-
1269
- /** JSON Schema contains a handful of set-valued arrays whose authored order is not part
1270
- * of the accepted value language. Normalize those (and VO enum `values`) so a cosmetic
1271
- * enum/union reorder does not manufacture an N/N+1 incompatibility. */
1272
- function normalizeFieldContract(value: unknown, parentKey = ""): unknown {
1273
- if (Array.isArray(value)) {
1274
- const items = value.map((item) => normalizeFieldContract(item));
1275
- if (["enum", "required", "type", "values", "oneOf", "anyOf", "allOf"].includes(parentKey)) {
1276
- return items.sort((a, b) => {
1277
- const left = canonicalJson(a);
1278
- const right = canonicalJson(b);
1279
- return left < right ? -1 : left > right ? 1 : 0;
1280
- });
1281
- }
1282
- return items;
1283
- }
1284
- if (value !== null && typeof value === "object") {
1285
- return Object.fromEntries(
1286
- Object.entries(value as Record<string, unknown>).map(([key, child]) => [
1287
- key,
1288
- normalizeFieldContract(child, key),
1289
- ]),
1290
- );
1291
- }
1292
- return value;
1293
- }
1294
-
1295
- /**
1296
- * Build the canonical semantic manifest for a domain module — purely from the
1297
- * in-memory DSL objects. No file reads, no bundle, no esbuild: the manifest is a
1298
- * function of the module alone, so it is bundler- AND location-independent.
1299
- */
1300
- export function domainManifest(mod: DomainModule): CanonicalManifest {
1301
- for (const agg of mod.aggregates) {
1302
- if (agg.audience === undefined) {
1303
- throw new Error(
1304
- `aggregate '${agg.id}' has no audience. Nomos will not guess who may read business state: ` +
1305
- `finish the declaration with .owned(), .readableBy("<business relation>"), or the deliberate ` +
1306
- `.public() exception.`,
1307
- );
1308
- }
1309
- }
1310
- for (const directive of mod.directives) {
1311
- const shape = zodObjectShape(directive.payloadSchema as ZodLike);
1312
- if (shape === undefined) continue;
1313
- for (const reserved of [
1314
- OWNED_KEY_PAYLOAD_FIELD,
1315
- OWNED_CUSTODY_PAYLOAD_FIELD,
1316
- OWNED_PREMINTED_RECORDS_PAYLOAD_FIELD,
1317
- BUSINESS_BIRTH_SEED_PAYLOAD_FIELD,
1318
- ]) {
1319
- if (!Object.prototype.hasOwnProperty.call(shape, reserved)) continue;
1320
- const frameworkBirthCarrier = reserved === BUSINESS_BIRTH_SEED_PAYLOAD_FIELD
1321
- && (directive as { businessBirth?: unknown }).businessBirth !== undefined;
1322
- if (frameworkBirthCarrier) continue;
1323
- throw new Error(
1324
- `directive '${directive.id}': payload field '${reserved}' is reserved by Nomos ownership. ` +
1325
- `Sorry mate, that concept is already taken by the framework; give the business payload field ` +
1326
- `a different name.`,
1327
- );
1328
- }
1329
- }
1330
- const aggregates: CanonicalAggregate[] = mod.aggregates
1331
- .map((agg) => {
1332
- return {
1333
- id: agg.id,
1334
- schema: sortedSchema(encodeKernelSchema(agg)),
1335
- // OMIT-WHEN-ABSENT: only emit `hasInvariant` when the aggregate declares one,
1336
- // so a predicate-free aggregate is byte-identical in the manifest to before this
1337
- // key existed (golden hashes UNCHANGED — mirrors directive `plan` omission).
1338
- ...(agg.hasInvariant === true ? { hasInvariant: true as const } : {}),
1339
- // The LAW-DECLARED instance cap — hash-bearing (it IS law), omit-when-absent.
1340
- ...(agg.cap !== undefined ? { cap: agg.cap } : {}),
1341
- // READ VISIBILITY (slice 5.1) — the capability a read of this type requires.
1342
- // Hash-bearing (it IS law); omit-when-public so all-public domains are byte-stable.
1343
- ...(agg.visibility !== undefined ? { requiresRead: agg.visibility.requires } : {}),
1344
- // E2E ENCRYPTION (slice 2) — the encrypted field names. Hash-bearing law;
1345
- // omit-when-none so plaintext aggregates are byte-stable.
1346
- ...(agg.encryptedFields !== undefined ? { encryptedFields: [...agg.encryptedFields] } : {}),
1347
- ...(agg.audience?.kind === "owned"
1348
- ? { owned: { ownerField: agg.audience.ownerField, relation: OWNED_READ_RELATION } }
1349
- : {}),
1350
- ...(agg.internalFields !== undefined ? { internalFields: [...agg.internalFields].sort() } : {}),
1351
- // ATTRIBUTED READS — the attributed field names (the driver wrap in `schema` is
1352
- // the hash-bearing law; this mirror is what codegen reads). Omit-when-none.
1353
- ...(agg.attributedFields !== undefined ? { attributedFields: [...agg.attributedFields] } : {}),
1354
- };
1355
- })
1356
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1357
-
1358
- const conflictResolutionByDirective = new Map(
1359
- (mod.surfacedResolvers ?? []).map((binding) => [binding.directiveId, binding] as const),
1360
- );
1361
- const directives: CanonicalDirective[] = mod.directives
1362
- .map((d) => ({
1363
- id: d.id,
1364
- target: d.aggregateId,
1365
- marker: encodeRefMode(d.marker),
1366
- ...canonicalSecondaryOutputs(d.secondaryOutputs),
1367
- payload: canonicalPayload(d.payloadSchema),
1368
- payloadJsonSchema: canonicalPayloadJsonSchema(d.payloadSchema),
1369
- // Omit-when-empty: a directive declaring NO boundary contributes neither key,
1370
- // so its canonical manifest is byte-identical to before the boundary existed.
1371
- ...canonicalReads(d.declaredReads),
1372
- ...canonicalCapturedReads(d.declaredQueryReads ?? []),
1373
- ...canonicalEmits(d.declaredEmits),
1374
- ...canonicalRelations(d.declaredRelations),
1375
- ...canonicalHostCapabilities(d.declaredHostCapabilities),
1376
- ...(conflictResolutionByDirective.has(d.id)
1377
- ? {
1378
- conflictResolution: {
1379
- aggregate: conflictResolutionByDirective.get(d.id)!.aggregateId,
1380
- field: conflictResolutionByDirective.get(d.id)!.field,
1381
- },
1382
- }
1383
- : {}),
1384
- }))
1385
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1386
-
1387
- // THE INFORMATION-FLOW GATE (architecture/security_model.md): rule 2 (no private
1388
- // captured read on the chain) + rule 1 (no encrypt-then-index) are enforced as ONE
1389
- // pass over the assembled canonical manifest, just before return (see assertFlowGate).
1390
- // E2E ENCRYPTION — critical #5 (e2e_encryption.md §5): the captured-read seal is rule 2.
1391
-
1392
- // exists_ entries are COUNT declarations in the IR — merge them into `counts` so the
1393
- // Rust engine sees them as ordinary counts (the marker lives only in the DSL/codegen
1394
- // layer). Combine mod.counts + normalized exists_ before canonicalizing counts.
1395
- const existsAsCountDecls: AnyCount[] = (mod.exists_ ?? []).map(existsAsCount);
1396
- const allCounts: AnyCount[] = [...(mod.counts ?? []), ...existsAsCountDecls];
1397
-
1398
- // Omit-when-empty: a domain declaring NO query / NO count / NO sum contributes neither
1399
- // key, so its canonical manifest is byte-identical to before the read-closure layer
1400
- // existed. `sums` follows the same omit-when-empty discipline as `counts`.
1401
- const manifest: CanonicalManifest = {
1402
- domain: mod.name,
1403
- aggregates,
1404
- directives,
1405
- // STABLE IDS (#58): always emitted by this compiler — the one deliberate
1406
- // hash-mover (enforcement-era identity hashes move; named + expected).
1407
- stableIds: stableIdsForModule(mod, aggregates),
1408
- ...canonicalWorkspaceInvariants(mod.workspaceInvariants),
1409
- ...canonicalQueries(mod.queries),
1410
- ...canonicalCounts(allCounts.length > 0 ? allCounts : undefined),
1411
- ...canonicalSpatials(mod.spatials),
1412
- ...canonicalSums(mod.sums),
1413
- ...canonicalDeriveds(mod.deriveds),
1414
- ...canonicalCombineds(mod.combineds),
1415
- ...(canonicalExtrema(mod.mins, "mins") as { mins?: CanonicalExtremum[] }),
1416
- ...(canonicalExtrema(mod.maxes, "maxes") as { maxes?: CanonicalExtremum[] }),
1417
- ...canonicalOrderedReads(mod.orderedReads),
1418
- // Omit-when-empty: a taxonomy-free domain contributes NO taxonomy key (hash-
1419
- // stable). When declared, this RUNS the fail-closed homing walk AND the slice-2
1420
- // route derivation — an unhomeable or unroutable taxonomy never produces an
1421
- // identity (workspace_type.ts + workspace_routing.ts).
1422
- ...canonicalTaxonomyFragment({
1423
- name: mod.name,
1424
- aggregates: mod.aggregates,
1425
- directives: mod.directives,
1426
- ...(mod.workspaceTypes !== undefined ? { workspaceTypes: mod.workspaceTypes } : {}),
1427
- }),
1428
- };
1429
- // THE STATIC INFORMATION-FLOW GATE — leaky law is unrepresentable (a typed compile
1430
- // error naming the field, the sink, and the rule). Rules 1/2/5 read the manifest IR;
1431
- // rule 4 also needs the raw module (the executable invariant reads + declassify waivers).
1432
- assertFlowGate(manifest, mod as never);
1433
- return manifest;
1434
- }
1435
-
1436
- /** Recursively sort object keys; arrays are already in sorted/stable order. */
1437
- function canonicalize(value: unknown): unknown {
1438
- if (Array.isArray(value)) return value.map(canonicalize);
1439
- if (value !== null && typeof value === "object") {
1440
- const obj = value as Record<string, unknown>;
1441
- const out: Record<string, unknown> = {};
1442
- for (const key of Object.keys(obj).sort()) out[key] = canonicalize(obj[key]);
1443
- return out;
1444
- }
1445
- return value;
1446
- }
1447
-
1448
- /**
1449
- * Serialize ANY value to JSON with recursively SORTED object keys (byte-stable).
1450
- * Used for the domain manifest here AND reused by the composed-IR layer (#137) to
1451
- * hash a flattened stage — the canonicalizer is value-generic, so the input type is
1452
- * `unknown` rather than narrowed to `CanonicalManifest`.
1453
- */
1454
- export function canonicalJson(manifest: unknown): string {
1455
- return JSON.stringify(canonicalize(manifest));
1456
- }
1457
-
1458
- /** The domain's content-hash identity: sha256 hex of its canonical manifest JSON. */
1459
- export function domainHash(mod: DomainModule): string {
1460
- return createHash("sha256").update(canonicalJson(domainManifest(mod)), "utf8").digest("hex");
1461
- }
1462
-
1463
- /**
1464
- * The SHIPPED ARTIFACT bytes for a domain (#136) — the EXACT canonical-manifest JSON
1465
- * bytes whose sha256 IS the domain's identity. This is the byte string an emit step
1466
- * writes to `<domain>.manifest.json` and the gate's reproducibility check recomputes
1467
- * `hash_code` over. The invariant the whole gate rests on:
1468
- *
1469
- * sha256(emitManifestBytes(mod)) === domainHash(mod)
1470
- *
1471
- * i.e. the file bytes equal the bytes `domainHash` hashes (compact, recursively
1472
- * sorted keys, UTF-8). "The bundle is not the law" — THIS is.
1473
- */
1474
- export function emitManifestBytes(mod: DomainModule): Buffer {
1475
- return Buffer.from(canonicalJson(domainManifest(mod)), "utf8");
1476
- }