@githolon/dsl 0.115.0 → 0.116.4

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 (39) hide show
  1. package/assets/operation-contract.wasm +0 -0
  2. package/dart/CHANGELOG.md +19 -0
  3. package/dart/lib/src/nms_generated_runtime.dart +1 -0
  4. package/dart/lib/src/result_usda.dart +64 -27
  5. package/dart/lib/src/runner/node-entry.mjs +701 -207
  6. package/dart/lib/src/sync_status_projection.dart +11 -0
  7. package/dart/pubspec.lock +1 -1
  8. package/dart/pubspec.yaml +2 -2
  9. package/dart/test/kernel_refused_follow_test.dart +36 -0
  10. package/dart/test/result_usda_test.dart +67 -0
  11. package/dart_types/CHANGELOG.md +6 -0
  12. package/dart_types/lib/src/proto/nomos/runtime/v2/observability_desk.pbenum.dart +11 -1
  13. package/dart_types/lib/src/vo_canonical.dart +185 -0
  14. package/dart_types/pubspec.lock +1 -1
  15. package/dart_types/pubspec.yaml +3 -1
  16. package/dart_types/test/vo_canonical_test.dart +91 -0
  17. package/package.json +2 -2
  18. package/src/aggregate.ts +3 -1
  19. package/src/baseline_usda.ts +2 -3
  20. package/src/build_package.ts +47 -7
  21. package/src/codegen_call_usda.ts +6 -3
  22. package/src/codegen_dart.ts +19 -57
  23. package/src/codegen_result_usda.ts +8 -4
  24. package/src/compile_package_main.ts +11 -4
  25. package/src/engine_entry.ts +12 -4
  26. package/src/identity_usda.ts +3 -52
  27. package/src/index.ts +10 -0
  28. package/src/intent.ts +28 -3
  29. package/src/law_check.ts +34 -0
  30. package/src/law_graph_usda.ts +2 -3
  31. package/src/law_layer_prims.ts +3 -28
  32. package/src/law_units.ts +7 -6
  33. package/src/law_version.ts +2 -1
  34. package/src/point_guard.ts +5 -1
  35. package/src/release_inspection.ts +6 -4
  36. package/src/release_publication.ts +6 -5
  37. package/src/usda_text.ts +78 -3
  38. package/src/value_identity.ts +355 -0
  39. package/src/value_object.ts +29 -1
@@ -11,6 +11,7 @@
11
11
  // hash and canonical manifest follow `law_units.ts` (`lawUnitHash`, `unitManifestUsda`) and the kernel's
12
12
  // `DomainLawUnit`; `release_publication.test.ts` proves agreement on compiled units.
13
13
 
14
+ import { usdaString, usdaUnquote } from "./usda_text.js";
14
15
  import { lawUnitSetIdentityPreimage } from "./law_unit_set_identity.ts";
15
16
 
16
17
  export const RELEASE_IDENTITY_TAG = "nomos-domain-release-v1";
@@ -136,14 +137,14 @@ async function canonicalManifest(unit: ParsedManifest): Promise<string> {
136
137
  const assetPrims = await Promise.all(unit.assets.map(async (asset) => [
137
138
  ` def NomosLawAsset "a_${await sha256Hex(asset.logicalPath)}"`,
138
139
  " {",
139
- ` custom string nomos:logicalPath = ${JSON.stringify(asset.logicalPath)}`,
140
+ ` custom string nomos:logicalPath = ${usdaString(asset.logicalPath)}`,
140
141
  ` custom string nomos:sha256 = "${asset.sha256}"`,
141
142
  " }",
142
143
  ]));
143
144
  const routePrims = await Promise.all(unit.routes.map(async (route) => [
144
145
  ` def NomosLawRoute "s_${await sha256Hex(route.selector)}"`,
145
146
  " {",
146
- ` custom string nomos:selector = ${JSON.stringify(route.selector)}`,
147
+ ` custom string nomos:selector = ${usdaString(route.selector)}`,
147
148
  ` custom string nomos:plan = "${route.planSha256}"`,
148
149
  " }",
149
150
  ]));
@@ -158,9 +159,9 @@ async function canonicalManifest(unit: ParsedManifest): Promise<string> {
158
159
  "",
159
160
  'def NomosLawUnit "__NomosLawUnit_7e9c"',
160
161
  "{",
161
- ` custom string nomos:unit = ${JSON.stringify(unit.key)}`,
162
+ ` custom string nomos:unit = ${usdaString(unit.key)}`,
162
163
  ` custom token nomos:unitKind = "${unit.kind}"`,
163
- ...(unit.domain === undefined ? [] : [` custom string nomos:domain = ${JSON.stringify(unit.domain)}`]),
164
+ ...(unit.domain === undefined ? [] : [` custom string nomos:domain = ${usdaString(unit.domain)}`]),
164
165
  ` custom string nomos:unitHash = "${unit.hash}"`,
165
166
  "",
166
167
  ' def Scope "Assets"',
@@ -181,7 +182,7 @@ const LITERAL = '("(?:[^"\\\\]|\\\\.)*")';
181
182
  function attribute(source: string, name: string, where: string): string {
182
183
  const values = [...source.matchAll(new RegExp(`nomos:${name} = ${LITERAL}`, "g"))];
183
184
  if (values.length !== 1) refuse("manifest-invalid", `${where}: expected one nomos:${name}`);
184
- return JSON.parse(values[0]![1]!) as string;
185
+ return usdaUnquote(values[0]![1]!);
185
186
  }
186
187
 
187
188
  /**
package/src/usda_text.ts CHANGED
@@ -1,7 +1,16 @@
1
1
  // NOMOS — minimal deterministic USDA text literals for build-time emitters.
2
- // This module deliberately has no parser, generic value tree, flattening model or law vocabulary.
2
+ // This module deliberately has no document parser, generic value tree, flattening model or law
3
+ // vocabulary; it owns only the string-literal spelling every emitter shares.
3
4
 
4
- /** Escape a string for a double-quoted USDA string literal. */
5
+ /**
6
+ * The ONE canonical USDA string literal (CAP-862), byte-identical with the kernel's
7
+ * `kernel/src/usda_string.rs` and the generated call encoders (`codegen_call_usda.ts`):
8
+ * `"` `\` LF CR TAB BS FF take `\"` `\\` `\n` `\r` `\t` `\b` `\f`; any other U+0000–U+001F and
9
+ * U+007F take `\xHH` (two lowercase hex digits); C1 and everything else is raw UTF-8. Every
10
+ * escape is one OpenUSD reads. `\uXXXX` (OpenUSD has none) is legacy INPUT only, never written.
11
+ * A lone surrogate is not a Unicode scalar and is refused. Pinned by
12
+ * `dsl/conformance/usda_string.v1.json`.
13
+ */
5
14
  export function usdaString(value: string): string {
6
15
  let encoded = '"';
7
16
  for (const character of value) {
@@ -16,7 +25,9 @@ export function usdaString(value: string): string {
16
25
  default: {
17
26
  const codePoint = character.codePointAt(0)!;
18
27
  if (codePoint < 0x20 || codePoint === 0x7f) {
19
- encoded += `\\u${codePoint.toString(16).padStart(4, "0")}`;
28
+ encoded += `\\x${codePoint.toString(16).padStart(2, "0")}`;
29
+ } else if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
30
+ throw new TypeError(`a USDA string cannot carry the lone surrogate U+${codePoint.toString(16).toUpperCase()}`);
20
31
  } else {
21
32
  encoded += character;
22
33
  }
@@ -26,6 +37,70 @@ export function usdaString(value: string): string {
26
37
  return `${encoded}"`;
27
38
  }
28
39
 
40
+ /**
41
+ * Expand the escapes of a quoted USDA string's body exactly as OpenUSD (`TfEscapeString`) does —
42
+ * `\a \b \f \n \r \t \v`, `\x` with up to two hex digits, `\NNN` with up to three octal digits,
43
+ * any other escaped character is itself — plus the LEGACY `\uXXXX` the pre-CAP-862 writers spelled
44
+ * controls with, read as the scalar it names (as the kernel normalises it before parsing).
45
+ */
46
+ export function usdaUnescape(body: string): string {
47
+ let out = "";
48
+ for (let index = 0; index < body.length; index += 1) {
49
+ const character = body[index]!;
50
+ if (character !== "\\") {
51
+ out += character;
52
+ continue;
53
+ }
54
+ index += 1;
55
+ const escaped = body[index];
56
+ if (escaped === undefined) break;
57
+ switch (escaped) {
58
+ case "a": out += "\x07"; break;
59
+ case "b": out += "\b"; break;
60
+ case "f": out += "\f"; break;
61
+ case "n": out += "\n"; break;
62
+ case "r": out += "\r"; break;
63
+ case "t": out += "\t"; break;
64
+ case "v": out += "\v"; break;
65
+ case "x": {
66
+ const digits = /^[0-9a-fA-F]{0,2}/u.exec(body.slice(index + 1))![0];
67
+ out += String.fromCharCode(digits === "" ? 0 : Number.parseInt(digits, 16));
68
+ index += digits.length;
69
+ break;
70
+ }
71
+ case "u": {
72
+ const digits = body.slice(index + 1, index + 5);
73
+ const codePoint = /^[0-9a-fA-F]{4}$/u.test(digits) ? Number.parseInt(digits, 16) : undefined;
74
+ if (codePoint !== undefined && (codePoint < 0xd800 || codePoint > 0xdfff)) {
75
+ out += String.fromCharCode(codePoint);
76
+ index += 4;
77
+ } else {
78
+ out += "u";
79
+ }
80
+ break;
81
+ }
82
+ default:
83
+ if (escaped >= "0" && escaped <= "7") {
84
+ const digits = /^[0-7]{1,3}/u.exec(body.slice(index))![0];
85
+ out += String.fromCharCode(Number.parseInt(digits, 8));
86
+ index += digits.length - 1;
87
+ } else {
88
+ out += escaped;
89
+ }
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** Decode one double-quoted USDA string literal (see {@link usdaUnescape}). */
96
+ export function usdaUnquote(raw: string): string {
97
+ const value = raw.trim();
98
+ if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) {
99
+ throw new Error(`expected a quoted USDA string, got ${value}`);
100
+ }
101
+ return usdaUnescape(value.slice(1, -1));
102
+ }
103
+
29
104
  /** Emit a USDA `string[]` literal. */
30
105
  export function usdaStringArray(items: readonly string[]): string {
31
106
  return `[${items.map(usdaString).join(", ")}]`;
@@ -0,0 +1,355 @@
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
+ * VALUE IDENTITY FOR TYPED VALUE OBJECTS — `nomos.vo-canonical.v1` (CAP-853).
10
+ *
11
+ * A value object's DESCRIPTOR is the canonical identity of its SHAPE. This module gives its
12
+ * VALUES one canonical identity too, so law can ask "is this the same value?" and "what is this
13
+ * value's content digest?" without `JSON.stringify` (whose bytes depend on key insertion order,
14
+ * and so change when a value round-trips through storage).
15
+ *
16
+ * THE ENCODING (`nomos.vo-canonical.v1`) is RFC 8785 (JSON Canonicalization Scheme) applied to the
17
+ * value AFTER the VO's descriptor has judged it:
18
+ *
19
+ * - object members are emitted in UTF-16 code-unit order of their names (RFC 8785 §3.2.3);
20
+ * - numbers use the ECMAScript Number-to-String spelling (RFC 8785 §3.2.2.3): `1`, `1.5`,
21
+ * `1e+21`, `1e-7`; integer and float fields share that one spelling, so a float stored as a
22
+ * double and read back as `2` is the same value as the authored `2`; `-0` is `0`;
23
+ * NaN and ±Infinity are refused;
24
+ * - strings use the RFC 8785 escapes (`\"`, `\\`, `\b \f \n \r \t`, other controls `\u00xx`,
25
+ * everything else verbatim); a string that is not well-formed UTF-16 (a lone surrogate) is
26
+ * refused, so its UTF-8 bytes are always defined;
27
+ * - ABSENT IS NOT NULL. An absent optional member (missing, or present as `undefined`) is
28
+ * omitted, exactly as storage omits it (`wire_encode.ts`); `null` is emitted only where the
29
+ * descriptor declares the member nullable and is otherwise refused. `{}` and `{"a":null}` are
30
+ * different values;
31
+ * - a `variant(...)` carries its case in the `type` member (and a named-VO arm nests its value
32
+ * under `value`), so the chosen case is always part of the encoding;
33
+ * - `idOf(...)`, `brandedId(...)` and units are their wire strings/numbers; a `sha256Digest()`
34
+ * member must be 64 lowercase hex characters;
35
+ * - list order is significant; map (`z.record`) entry order is not.
36
+ *
37
+ * Validation is part of the encoding: a value the descriptor does not admit (an undeclared member,
38
+ * a missing required member, an enum outside its values, a non-integer in an `.int()` field, a
39
+ * wrong variant case) has NO canonical form and throws. Equality is byte equality of the encoding.
40
+ * The DIGEST is SHA-256 over the UTF-8 bytes of the encoding, computed through the deterministic
41
+ * `nomos.sha256` port of the sealed engine (the same port that names keyed births), so every peer
42
+ * and every replay derives the same digest. Outside an engine there is no fallback: a test installs
43
+ * the port. A future change to any rule above is a NEW encoding id; a stored digest should keep
44
+ * {@link VO_CANONICAL_ENCODING} beside it.
45
+ *
46
+ * Conformance vectors: `dsl/conformance/vo_canonical.v1.json`, replayed by the TypeScript suite,
47
+ * the sealed engine (`testing/test/value_contract.test.mjs`) and the Dart runtime
48
+ * (`dsl/dart/test/vo_canonical_test.dart`). Keep this file node-builtin-free: it rides the runtime
49
+ * barrel into the engine bundle.
50
+ */
51
+
52
+ import type {
53
+ GeometryKind,
54
+ Sha256Digest,
55
+ ValueObjectDescriptor,
56
+ ValueObjectFieldDescriptor,
57
+ } from "./fields.js";
58
+
59
+ /** The versioned id of the canonical value encoding. A change to the encoding is a new id. */
60
+ export const VO_CANONICAL_ENCODING = "nomos.vo-canonical.v1" as const;
61
+ export type VoCanonicalEncoding = typeof VO_CANONICAL_ENCODING;
62
+
63
+ /**
64
+ * The value-identity operations every value object carries (and {@link valueIdentity} derives for
65
+ * lists and maps of them). Usable inside `.plan()` and invariant `.assert(...)` bodies.
66
+ */
67
+ export interface ValueIdentity<T> {
68
+ /** The encoding these operations implement (`nomos.vo-canonical.v1`). */
69
+ readonly encoding: VoCanonicalEncoding;
70
+ /** The canonical encoding of `value` (throws when the descriptor does not admit the value). */
71
+ canonical(value: T): string;
72
+ /**
73
+ * Value equality over the canonical encoding. `undefined` (an absent optional) equals only
74
+ * `undefined`; a value the descriptor does not admit throws rather than comparing unequal.
75
+ */
76
+ equals(a: T | undefined, b: T | undefined): boolean;
77
+ /** SHA-256 (lowercase hex) of the canonical encoding's UTF-8 bytes, via the engine's port. */
78
+ digest(value: T): Sha256Digest;
79
+ }
80
+
81
+ /** The value-object descriptors a value may reference, by name. */
82
+ export type ValueObjectTypes = ReadonlyMap<string, ValueObjectDescriptor>;
83
+
84
+ export class VoCanonicalError extends TypeError {
85
+ constructor(at: string, problem: string) {
86
+ super(`${VO_CANONICAL_ENCODING}: ${at} ${problem}`);
87
+ this.name = "VoCanonicalError";
88
+ }
89
+ }
90
+
91
+ // ── RFC 8785 primitives ───────────────────────────────────────────────────────────────
92
+
93
+ const PLAIN_STRING = /^[^"\\\u0000-\u001f\ud800-\udfff]*$/;
94
+
95
+ function canonicalString(value: string, at: string): string {
96
+ if (PLAIN_STRING.test(value)) return `"${value}"`;
97
+ let out = '"';
98
+ for (let i = 0; i < value.length; i++) {
99
+ const c = value.charCodeAt(i);
100
+ if (c >= 0xd800 && c <= 0xdbff) {
101
+ const next = i + 1 < value.length ? value.charCodeAt(i + 1) : -1;
102
+ if (next < 0xdc00 || next > 0xdfff) throw new VoCanonicalError(at, "is not well-formed UTF-16 (a lone high surrogate)");
103
+ out += value[i]! + value[i + 1]!;
104
+ i++;
105
+ continue;
106
+ }
107
+ if (c >= 0xdc00 && c <= 0xdfff) throw new VoCanonicalError(at, "is not well-formed UTF-16 (a lone low surrogate)");
108
+ switch (c) {
109
+ case 0x22: out += '\\"'; break;
110
+ case 0x5c: out += "\\\\"; break;
111
+ case 0x08: out += "\\b"; break;
112
+ case 0x0c: out += "\\f"; break;
113
+ case 0x0a: out += "\\n"; break;
114
+ case 0x0d: out += "\\r"; break;
115
+ case 0x09: out += "\\t"; break;
116
+ default:
117
+ out += c < 0x20 ? `\\u00${c < 0x10 ? "0" : ""}${c.toString(16)}` : value[i]!;
118
+ }
119
+ }
120
+ return `${out}"`;
121
+ }
122
+
123
+ function canonicalNumber(value: number, at: string): string {
124
+ if (!Number.isFinite(value)) throw new VoCanonicalError(at, "is not a finite number (NaN and Infinity have no canonical form)");
125
+ // ECMAScript Number::toString IS the RFC 8785 number spelling; String(-0) is "0".
126
+ return String(value);
127
+ }
128
+
129
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
130
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
131
+ const proto = Object.getPrototypeOf(value);
132
+ return proto === Object.prototype || proto === null;
133
+ }
134
+
135
+ function members(entries: readonly (readonly [string, string])[]): string {
136
+ // Keys are compared as UTF-16 code units: the default Array#sort order, as RFC 8785 requires.
137
+ const sorted = [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
138
+ return `{${sorted.map(([k, v]) => `${canonicalString(k, "member name")}:${v}`).join(",")}}`;
139
+ }
140
+
141
+ /**
142
+ * @internal RFC 8785 canonical JSON of an UNTYPED json value (no descriptor): object members sorted,
143
+ * `undefined` members omitted, ECMAScript number spelling. Used where law compares plain json leaves
144
+ * (point guards, birth keys); typed value objects use their descriptor-driven identity instead.
145
+ */
146
+ export function canonicalJsonValue(value: unknown, at = "value"): string {
147
+ if (value === null) return "null";
148
+ switch (typeof value) {
149
+ case "string": return canonicalString(value, at);
150
+ case "number": return canonicalNumber(value, at);
151
+ case "boolean": return value ? "true" : "false";
152
+ case "object": {
153
+ if (Array.isArray(value)) {
154
+ const out: string[] = [];
155
+ for (let i = 0; i < value.length; i++) {
156
+ if (value[i] === undefined) throw new VoCanonicalError(`${at}[${i}]`, "is undefined (a list has no absent elements)");
157
+ out.push(canonicalJsonValue(value[i], `${at}[${i}]`));
158
+ }
159
+ return `[${out.join(",")}]`;
160
+ }
161
+ if (!isPlainObject(value)) throw new VoCanonicalError(at, "is not a plain json object");
162
+ return members(Object.keys(value)
163
+ .filter((k) => value[k] !== undefined)
164
+ .map((k) => [k, canonicalJsonValue(value[k], `${at}.${k}`)] as const));
165
+ }
166
+ default:
167
+ throw new VoCanonicalError(at, `is a ${typeof value}, which json cannot carry`);
168
+ }
169
+ }
170
+
171
+ // ── descriptor-driven encoding ───────────────────────────────────────────────────────
172
+
173
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
174
+
175
+ function canonicalField(
176
+ d: ValueObjectFieldDescriptor,
177
+ value: unknown,
178
+ types: ValueObjectTypes,
179
+ at: string,
180
+ ): string {
181
+ if (value === undefined) throw new VoCanonicalError(at, "is absent, but only an optional member may be absent");
182
+ if (value === null) {
183
+ if (d.nullable === true) return "null";
184
+ throw new VoCanonicalError(at, "is null, but its descriptor does not declare it nullable");
185
+ }
186
+ switch (d.type) {
187
+ case "string": {
188
+ if (typeof value !== "string") throw new VoCanonicalError(at, "must be a string");
189
+ if (d.digest === "sha256" && !SHA256_HEX.test(value)) {
190
+ throw new VoCanonicalError(at, "must be a SHA-256 digest: 64 lowercase hexadecimal characters");
191
+ }
192
+ if (d.idOf !== undefined && value.length === 0) throw new VoCanonicalError(at, `must be a non-empty ${d.idOf} id`);
193
+ return canonicalString(value, at);
194
+ }
195
+ case "enum":
196
+ if (typeof value !== "string" || !d.values.includes(value)) {
197
+ throw new VoCanonicalError(at, `must be one of ${d.values.map((v) => `'${v}'`).join(", ")}`);
198
+ }
199
+ return canonicalString(value, at);
200
+ case "literal":
201
+ if (value !== d.value) throw new VoCanonicalError(at, `must be the literal '${d.value}'`);
202
+ return canonicalString(value, at);
203
+ case "boolean":
204
+ if (typeof value !== "boolean") throw new VoCanonicalError(at, "must be a boolean");
205
+ return value ? "true" : "false";
206
+ case "number":
207
+ if (typeof value !== "number") throw new VoCanonicalError(at, "must be a number");
208
+ if (d.int === true && !Number.isSafeInteger(value)) throw new VoCanonicalError(at, "must be an exact (safe) integer");
209
+ return canonicalNumber(value, at);
210
+ case "array": {
211
+ if (!Array.isArray(value)) throw new VoCanonicalError(at, "must be a list");
212
+ const out: string[] = [];
213
+ for (let i = 0; i < value.length; i++) out.push(canonicalField(d.items, value[i], types, `${at}[${i}]`));
214
+ return `[${out.join(",")}]`;
215
+ }
216
+ case "map": {
217
+ if (!isPlainObject(value)) throw new VoCanonicalError(at, "must be a string-keyed map (a plain object)");
218
+ return members(Object.keys(value).map((k) => [k, canonicalField(d.values, value[k], types, `${at}[${JSON.stringify(k)}]`)] as const));
219
+ }
220
+ case "object":
221
+ case "geometry": {
222
+ const target = types.get(d.ref);
223
+ if (target === undefined) throw new VoCanonicalError(at, `references the undeclared value object '${d.ref}'`);
224
+ return canonicalValueObject(target, value, types, at);
225
+ }
226
+ }
227
+ }
228
+
229
+ function canonicalMembers(
230
+ fields: Readonly<Record<string, ValueObjectFieldDescriptor>>,
231
+ object: Record<string, unknown>,
232
+ types: ValueObjectTypes,
233
+ at: string,
234
+ extra: readonly (readonly [string, string])[] = [],
235
+ reserved: readonly string[] = [],
236
+ ): string {
237
+ for (const key of Object.keys(object)) {
238
+ if (!(key in fields) && !reserved.includes(key) && object[key] !== undefined) {
239
+ throw new VoCanonicalError(`${at}.${key}`, "is not a declared member");
240
+ }
241
+ }
242
+ const out: (readonly [string, string])[] = [...extra];
243
+ for (const key of Object.keys(fields)) {
244
+ const member = fields[key]!;
245
+ const value = Object.prototype.hasOwnProperty.call(object, key) ? object[key] : undefined;
246
+ if (value === undefined) {
247
+ if (member.optional === true) continue; // ABSENT: omitted, exactly as storage omits it
248
+ throw new VoCanonicalError(`${at}.${key}`, "is a required member but is absent");
249
+ }
250
+ out.push([key, canonicalField(member, value, types, `${at}.${key}`)]);
251
+ }
252
+ return members(out);
253
+ }
254
+
255
+ const POSITION_DEPTH: Record<GeometryKind, number> = { Point: 0, LineString: 1, MultiPoint: 1, Polygon: 2 };
256
+ const MIN_LENGTH: Record<GeometryKind, readonly number[]> = { Point: [], LineString: [2], MultiPoint: [0], Polygon: [0, 4] };
257
+
258
+ function canonicalCoordinates(kind: GeometryKind, value: unknown, depth: number, at: string): string {
259
+ if (!Array.isArray(value)) throw new VoCanonicalError(at, "must be a list");
260
+ if (depth === 0) {
261
+ if (value.length < 2 || value.length > 3) throw new VoCanonicalError(at, "must be a position: [lng, lat] or [lng, lat, altitude]");
262
+ return `[${value.map((n, i) => {
263
+ if (typeof n !== "number") throw new VoCanonicalError(`${at}[${i}]`, "must be a number");
264
+ return canonicalNumber(n, `${at}[${i}]`);
265
+ }).join(",")}]`;
266
+ }
267
+ const min = MIN_LENGTH[kind][POSITION_DEPTH[kind] - depth] ?? 0;
268
+ if (value.length < min) throw new VoCanonicalError(at, `must hold at least ${min} entries`);
269
+ return `[${value.map((v, i) => canonicalCoordinates(kind, v, depth - 1, `${at}[${i}]`)).join(",")}]`;
270
+ }
271
+
272
+ /**
273
+ * The `nomos.vo-canonical.v1` encoding of `value` as an instance of the value object `descriptor`
274
+ * (nested value objects resolve through `types`). Throws {@link VoCanonicalError} when the
275
+ * descriptor does not admit the value.
276
+ */
277
+ export function canonicalValueObject(
278
+ descriptor: ValueObjectDescriptor,
279
+ value: unknown,
280
+ types: ValueObjectTypes,
281
+ at: string = descriptor.name,
282
+ ): string {
283
+ if (!isPlainObject(value)) throw new VoCanonicalError(at, `must be a ${descriptor.name} object`);
284
+ if (descriptor.fields !== undefined) return canonicalMembers(descriptor.fields, value, types, at);
285
+ if (descriptor.union !== undefined) {
286
+ const kase = value["type"];
287
+ if (typeof kase !== "string" || !Object.prototype.hasOwnProperty.call(descriptor.union, kase)) {
288
+ throw new VoCanonicalError(`${at}.type`, `must name a declared case of ${descriptor.name} (${Object.keys(descriptor.union).join(", ")})`);
289
+ }
290
+ const arm = descriptor.union[kase]!;
291
+ const type = [["type", canonicalString(kase, `${at}.type`)]] as const;
292
+ if ("ref" in arm) {
293
+ const target = types.get(arm.ref);
294
+ if (target === undefined) throw new VoCanonicalError(at, `references the undeclared value object '${arm.ref}'`);
295
+ return canonicalMembers(
296
+ { value: { type: "object", ref: arm.ref } },
297
+ value, types, at, type, ["type"],
298
+ );
299
+ }
300
+ return canonicalMembers(arm.fields, value, types, at, type, ["type"]);
301
+ }
302
+ if (descriptor.geometry !== undefined) {
303
+ const kind = descriptor.geometry;
304
+ for (const key of Object.keys(value)) {
305
+ if (key !== "type" && key !== "coordinates" && value[key] !== undefined) {
306
+ throw new VoCanonicalError(`${at}.${key}`, "is not a member of an RFC 7946 geometry");
307
+ }
308
+ }
309
+ if (value["type"] !== kind) throw new VoCanonicalError(`${at}.type`, `must be '${kind}'`);
310
+ return members([
311
+ ["type", canonicalString(kind, `${at}.type`)],
312
+ ["coordinates", canonicalCoordinates(kind, value["coordinates"], POSITION_DEPTH[kind], `${at}.coordinates`)],
313
+ ]);
314
+ }
315
+ throw new VoCanonicalError(at, `has no fields, cases or geometry in its descriptor`);
316
+ }
317
+
318
+ /** The canonical encoding of a value typed by one descriptor FIELD (a list, map, or nested VO). */
319
+ export function canonicalDescribedValue(
320
+ field: ValueObjectFieldDescriptor,
321
+ value: unknown,
322
+ types: ValueObjectTypes,
323
+ at = "value",
324
+ ): string {
325
+ return canonicalField(field, value, types, at);
326
+ }
327
+
328
+ /** SHA-256 of `text`'s UTF-8 bytes through the ONE deterministic host port, `nomos.sha256`. */
329
+ function portSha256(text: string): Sha256Digest {
330
+ const host = (globalThis as { nomos?: { sha256?: (s: string) => string } }).nomos;
331
+ if (host === undefined || typeof host.sha256 !== "function") {
332
+ throw new Error(
333
+ `${VO_CANONICAL_ENCODING}: digest() needs the deterministic \`nomos.sha256\` port of the Nomos engine. ` +
334
+ "It runs inside .plan() and invariant bodies; a test installs globalThis.nomos.sha256.",
335
+ );
336
+ }
337
+ const digest = host.sha256(text);
338
+ if (typeof digest !== "string" || !SHA256_HEX.test(digest)) {
339
+ throw new Error(`${VO_CANONICAL_ENCODING}: the nomos.sha256 port returned a malformed digest`);
340
+ }
341
+ return digest as Sha256Digest;
342
+ }
343
+
344
+ /** Build the identity operations over one canonical-encoding function. */
345
+ export function identityOf<T>(encode: (value: unknown) => string): ValueIdentity<T> {
346
+ return {
347
+ encoding: VO_CANONICAL_ENCODING,
348
+ canonical: (value: T) => encode(value),
349
+ equals(a: T | undefined, b: T | undefined): boolean {
350
+ if (a === undefined || b === undefined) return a === b;
351
+ return encode(a) === encode(b);
352
+ },
353
+ digest: (value: T) => portSha256(encode(value)),
354
+ };
355
+ }
@@ -68,6 +68,12 @@ import {
68
68
  type ValueObjectFieldMeta,
69
69
  type TypedId,
70
70
  } from "./fields.js";
71
+ import {
72
+ canonicalDescribedValue,
73
+ canonicalValueObject,
74
+ identityOf,
75
+ type ValueIdentity,
76
+ } from "./value_identity.js";
71
77
  // LOCAL zod introspection (the same `_def ?? def` convention as codegen_ts.ts) —
72
78
  // deliberately NOT imported from codegen_ts.js: this module rides the RUNTIME barrel
73
79
  // (the engine lump bundles it, `--platform=neutral`), and codegen_ts transitively
@@ -132,7 +138,7 @@ export interface OptionalValueObject<T = unknown> {
132
138
  * Carries the zod (validation + TS inference), the canonical descriptor (the IR/codegen
133
139
  * shape), and lowers onto an aggregate via {@link ValueObject.field}.
134
140
  */
135
- export interface ValueObject<T = unknown> {
141
+ export interface ValueObject<T = unknown> extends ValueIdentity<T> {
136
142
  readonly __isValueObject: true;
137
143
  /** The declared name — the generated TS interface / Dart class name. */
138
144
  readonly name: string;
@@ -168,6 +174,10 @@ export interface ValueObject<T = unknown> {
168
174
  merge(driver: Driver): Field<T, "json">;
169
175
  /** Phantom to keep T load-bearing for inference. */
170
176
  readonly _t?: T;
177
+ // VALUE IDENTITY (CAP-853, `value_identity.ts`): `canonical(v)` is the `nomos.vo-canonical.v1`
178
+ // encoding (RFC 8785 over the descriptor-judged value), `equals(a, b)` compares encodings,
179
+ // `digest(v)` is its SHA-256 through the engine's deterministic port. Use them in `.plan()` and
180
+ // invariant bodies instead of `JSON.stringify`, whose bytes depend on key order.
171
181
  }
172
182
 
173
183
  /** Infer the authored TS type of a ValueObject. */
@@ -648,6 +658,20 @@ export function voMetaOf(zt: z.ZodTypeAny): ValueObjectFieldMeta | undefined {
648
658
  return undefined;
649
659
  }
650
660
 
661
+ /**
662
+ * VALUE IDENTITY for a constrained schema built FROM value objects — `valueIdentity(z.array(Site))`,
663
+ * `valueIdentity(z.record(z.string(), Reading))`, `valueIdentity(Site.zod.optional())` — the same
664
+ * `nomos.vo-canonical.v1` encoding a value object's own `canonical` / `equals` / `digest` use, derived
665
+ * from the same descriptor rules (an out-of-set schema throws, exactly as in `valueObject(...)`).
666
+ * Declare it at module scope and call it inside `.plan()` / invariant bodies.
667
+ */
668
+ export function valueIdentity<T>(schema: z.ZodType<T> | ValueObject<T>): ValueIdentity<T> {
669
+ if (isValueObject(schema)) return schema as ValueObject<T>;
670
+ const deps = new Map<string, ValueObjectDescriptor>();
671
+ const field = describeZod("valueIdentity", "value", schema as z.ZodTypeAny, deps);
672
+ return identityOf<T>((value) => canonicalDescribedValue(field, value, deps));
673
+ }
674
+
651
675
  /** The blessed lawful sample per RFC 7946 geometry kind (proof synthesis + docs). */
652
676
  const GEOMETRY_SAMPLES: Record<GeometryKind, unknown> = {
653
677
  Point: { type: "Point", coordinates: [0, 0] },
@@ -767,7 +791,11 @@ function makeValueObject<T>(
767
791
  // serialized directly. voMetaOf() is the one resolver every introspector uses; aggregate
768
792
  // fields separately copy the canonical descriptor into their stable value contract.
769
793
  Object.defineProperty(zodSchema, NOMOS_VO_BRAND, { value: meta, enumerable: false });
794
+ const types = new Map<string, ValueObjectDescriptor>(
795
+ [descriptor, ...dependencies].map((d) => [d.name, d] as const),
796
+ );
770
797
  const vo: ValueObject<T> = {
798
+ ...identityOf<T>((value) => canonicalValueObject(descriptor, value, types)),
771
799
  __isValueObject: true,
772
800
  name,
773
801
  zod: zodSchema,