@githolon/dsl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +36 -0
- package/compile_package.mjs +50 -0
- package/package.json +59 -0
- package/src/aggregate.ts +167 -0
- package/src/authoring.ts +119 -0
- package/src/build_package.ts +636 -0
- package/src/certified_read.ts +313 -0
- package/src/codegen_dart.ts +2732 -0
- package/src/codegen_dot.ts +466 -0
- package/src/codegen_provider_dart.ts +358 -0
- package/src/codegen_ts.ts +365 -0
- package/src/codegen_usda.ts +388 -0
- package/src/combined.ts +195 -0
- package/src/compile_engine.ts +567 -0
- package/src/compile_package_main.ts +496 -0
- package/src/compose.ts +317 -0
- package/src/count.ts +218 -0
- package/src/ctx.ts +57 -0
- package/src/derived.ts +138 -0
- package/src/directive.ts +306 -0
- package/src/drivers.ts +95 -0
- package/src/emits_guard.ts +123 -0
- package/src/engine_entry.ts +449 -0
- package/src/exists.ts +170 -0
- package/src/extremum.ts +227 -0
- package/src/fields.ts +291 -0
- package/src/framework/bootstrap.ts +22 -0
- package/src/framework/disclosure.ts +108 -0
- package/src/framework/domain_lifecycle.ts +108 -0
- package/src/framework/identity.ts +537 -0
- package/src/framework/impure_capability.ts +643 -0
- package/src/framework/rbac.ts +418 -0
- package/src/framework/repair.ts +150 -0
- package/src/framework/sync_lifecycle.ts +125 -0
- package/src/framework/workspace_invariant.ts +128 -0
- package/src/framework/workspaces.ts +817 -0
- package/src/index.ts +317 -0
- package/src/manifest.ts +947 -0
- package/src/ops.ts +145 -0
- package/src/ordered_read.ts +228 -0
- package/src/predicate.ts +203 -0
- package/src/query/compile.ts +0 -0
- package/src/query/relations.ts +144 -0
- package/src/query.ts +151 -0
- package/src/read.ts +54 -0
- package/src/relation.ts +189 -0
- package/src/report/csv.ts +54 -0
- package/src/report.ts +401 -0
- package/src/spatial.ts +115 -0
- package/src/sum.ts +194 -0
- package/src/usd.ts +563 -0
- package/src/wire.ts +149 -0
- package/src/wire_encode.ts +250 -0
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|