@githolon/dsl 0.48.1 → 0.49.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.
@@ -66,7 +66,11 @@ class NomosTestHarness {
66
66
  /// [runtimeDir] — the runtime cache (default `~/.holon/runtime`); must hold `holon.wasm` + `packages.json`.
67
67
  /// [nodeEntry] — path to `node-entry.mjs`; when omitted it is resolved from [resolveFrom] via
68
68
  /// `require.resolve('@githolon/client/runner/node-entry.mjs')`.
69
- /// [installHelper] — path to `offline-install.mjs`; defaults to the file beside [nodeEntry].
69
+ /// [installHelper] — the offline law-install lane. An EXISTING `.mjs` file is run as a node script (the
70
+ /// in-repo `offline-install.mjs`, the default when it exists beside [nodeEntry]); anything else is a
71
+ /// githolon COMMAND LINE whose `harness-install` subcommand is driven (the default `npx githolon` when the
72
+ /// in-repo helper is absent — a published tenant needs nothing but `githolon`). E.g. force the subcommand
73
+ /// with `installHelper: 'node /abs/cli/dist/cli.mjs'`.
70
74
  /// [registerTearDown] — pass the test framework's `addTearDown` to auto-[dispose] when the test ends
71
75
  /// (feature-detecting `package:test` from a library is not clean in Dart, so this is explicit and safe).
72
76
  /// [autoTearDown] — when true (default) and [registerTearDown] is given, registers [dispose] as a teardown.
@@ -90,8 +94,18 @@ class NomosTestHarness {
90
94
  }
91
95
  final entry = nodeEntry ??
92
96
  await _resolveEntry(node, resolveFrom ?? Directory.current.path);
93
- final helper = installHelper ??
94
- '${File(entry).parent.path}/offline-install.mjs';
97
+ // THE INSTALL LANE. [installHelper] is the one seam (PACKAGING NOTE above): either the in-repo
98
+ // `offline-install.mjs` node script, OR the published `githolon harness-install` subcommand (the CLI
99
+ // bundles the engine + this exact install sequence, so a published tenant needs nothing but `githolon`).
100
+ // • an EXISTING `.mjs` file ⇒ run it as a node script (the in-repo default when present);
101
+ // • anything else ⇒ treat it as a githolon COMMAND LINE and drive `harness-install`
102
+ // (default `npx githolon` when the in-repo helper is absent; the test forces this lane by passing
103
+ // e.g. `installHelper: 'node /abs/cli/dist/cli.mjs'`).
104
+ final resolvedHelper =
105
+ installHelper ?? '${File(entry).parent.path}/offline-install.mjs';
106
+ final useInRepoScript =
107
+ resolvedHelper.endsWith('.mjs') && File(resolvedHelper).existsSync();
108
+ final githolonCmd = installHelper == null ? 'npx githolon' : resolvedHelper;
95
109
 
96
110
  // A FRESH temp dir per harness — the snapshot + any transient runner state live here and nowhere else,
97
111
  // so dispose() leaves nothing behind.
@@ -102,7 +116,9 @@ class NomosTestHarness {
102
116
  final snapshot = '${workdir.path}/snapshot.bin';
103
117
  await _runInstall(
104
118
  node: node,
105
- helper: helper,
119
+ useInRepoScript: useInRepoScript,
120
+ inRepoScript: resolvedHelper,
121
+ githolonCmd: githolonCmd,
106
122
  deployJsonPaths: deployJsonPaths,
107
123
  workspace: workspace,
108
124
  snapshot: snapshot,
@@ -182,29 +198,45 @@ class NomosTestHarness {
182
198
 
183
199
  static Future<void> _runInstall({
184
200
  required String node,
185
- required String helper,
201
+ required bool useInRepoScript,
202
+ required String inRepoScript,
203
+ required String githolonCmd,
186
204
  required List<String> deployJsonPaths,
187
205
  required String workspace,
188
206
  required String snapshot,
189
207
  String? runtimeDir,
190
208
  void Function(String line)? onDiagnostic,
191
209
  }) async {
192
- if (!File(helper).existsSync()) {
193
- throw NomosTestHarnessException(
194
- 'offline-install helper not found at $helper — pass installHelper: explicitly '
195
- '(it ships beside node-entry.mjs)');
196
- }
197
- final r = await Process.run(node, [
198
- helper,
210
+ // Both lanes take the SAME flags (--deploy/--workspace/--out/--runtime) and print the SAME one-line JSON
211
+ // contract; only the leading command differs (node <script> vs `<githolon...> harness-install`).
212
+ final common = <String>[
199
213
  '--deploy', deployJsonPaths.join(','),
200
214
  '--workspace', workspace,
201
215
  '--out', snapshot,
202
216
  if (runtimeDir != null) ...['--runtime', runtimeDir],
203
- ]);
217
+ ];
218
+ final String executable;
219
+ final List<String> args;
220
+ if (useInRepoScript) {
221
+ executable = node;
222
+ args = [inRepoScript, ...common];
223
+ } else {
224
+ // `githolonCmd` is a command line (e.g. `npx githolon` or `node /abs/cli.mjs`) — split into tokens.
225
+ final tokens =
226
+ githolonCmd.trim().split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
227
+ if (tokens.isEmpty) {
228
+ throw NomosTestHarnessException(
229
+ 'no install helper resolved — pass installHelper: (an offline-install.mjs path, or a githolon command)');
230
+ }
231
+ executable = tokens.first;
232
+ args = [...tokens.skip(1), 'harness-install', ...common];
233
+ }
234
+ final tag = useInRepoScript ? 'offline-install' : 'harness-install';
235
+ final r = await Process.run(executable, args);
204
236
  final err = (r.stderr as String).trim();
205
237
  if (err.isNotEmpty) {
206
238
  for (final line in err.split('\n')) {
207
- (onDiagnostic ?? stderr.writeln)('[offline-install] $line');
239
+ (onDiagnostic ?? stderr.writeln)('[$tag] $line');
208
240
  }
209
241
  }
210
242
  if (r.exitCode != 0) {
@@ -20,6 +20,9 @@ String get _guestbookDeployJson =>
20
20
  '${Directory.current.path}/../../examples/guestbook/build/guestbook.deploy.json';
21
21
  String get _runtimeDir =>
22
22
  '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
23
+ // The built CLI bin — drives the `githolon harness-install` SUBCOMMAND lane (the published-harness path).
24
+ String get _cliDist =>
25
+ '${Directory.current.path}/../../cli/dist/cli.mjs';
23
26
 
24
27
  /// The prerequisite that isn't installable in-test (a warm runtime cache + the compiled fixture). Returns a
25
28
  /// skip reason, or null when everything the harness needs is present.
@@ -100,4 +103,43 @@ void main() {
100
103
  await expectLater(
101
104
  harness.bridge.query('entriesByAuthor', {'author': 'x'}), throwsA(anything));
102
105
  }, timeout: const Timeout(Duration(minutes: 2)));
106
+
107
+ test('OFFLINE: the `githolon harness-install` SUBCOMMAND lane installs law and drives a read', () async {
108
+ final skip = _missingPrereq();
109
+ if (skip != null) {
110
+ markTestSkipped(skip);
111
+ return;
112
+ }
113
+ if (!File(_cliDist).existsSync()) {
114
+ markTestSkipped('build the CLI first: cd cli && npm run build (needs dist/cli.mjs)');
115
+ return;
116
+ }
117
+ // FORCE the published subcommand lane via the installHelper seam: a githolon command line (here the
118
+ // in-repo built bin) whose `harness-install` subcommand does the offline install a published tenant gets.
119
+ final harness = await NomosTestHarness.ephemeral(
120
+ deployJsonPaths: [_guestbookDeployJson],
121
+ nodeEntry: _repoEntry,
122
+ runtimeDir: _runtimeDir,
123
+ installHelper: 'node $_cliDist', // NOT an existing .mjs file ⇒ the subcommand lane
124
+ registerTearDown: addTearDown,
125
+ );
126
+
127
+ final outcome = await harness.bridge.offerCreates(
128
+ domain: 'guestbook',
129
+ directiveId: 'signGuestbook',
130
+ payload: {
131
+ 'author': 'subcommand',
132
+ 'message': 'installed via githolon harness-install',
133
+ 'mood': 'happy',
134
+ 'signedAt': DateTime.now().toUtc().toIso8601String(),
135
+ },
136
+ );
137
+ expect(outcome.created, hasLength(1));
138
+ final rows = await harness.bridge.query('entriesByAuthor', {'author': 'subcommand'});
139
+ expect(rows, hasLength(1));
140
+ expect((rows.first['data'] as Map)['message'], 'installed via githolon harness-install');
141
+ expect((rows.first['data'] as Map)['moodEmoji'], '😊'); // the law's plan ran through the subcommand-built snapshot
142
+
143
+ stdout.writeln('subcommand harness green: ws=${harness.workspace}');
144
+ }, timeout: const Timeout(Duration(minutes: 2)));
103
145
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.48.1",
3
+ "version": "0.49.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -68,6 +68,7 @@ import { emitUsd } from "./usd.js";
68
68
  import { expandCapabilityExports } from "./capability_exports.js";
69
69
  import { RelationTuple } from "./framework/relations.js";
70
70
  import { parentAttestedBirthMarker } from "./births.js";
71
+ import { discoverUnsanctionedEnsures, sanctioningEnsureDirective } from "./secondary_ensure.js";
71
72
 
72
73
  // ─────────────────────────────────────────────────────────────────────────────────
73
74
  // composeDomainModule — the named-keys `module(...)`
@@ -185,6 +186,24 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
185
186
  if (!byId.has(a.id)) byId.set(a.id, a);
186
187
  }
187
188
  }
189
+ // SECONDARY-CREATE AUTO-DECLARE (secondary_ensure.ts — the id-mint-gate DX): a plan may `ensure(B, "det:id")`
190
+ // a SECOND aggregate at a deterministic id of a type its directive does NOT declare `.ensures()` for. The
191
+ // id-mint gate refuses such an id unless the certified manifest carries an `Ensure → B` declaration. Plan-
192
+ // probe the directives, and for every ensured type NO directive already declares, inject a NO-OP sanctioning
193
+ // `.ensures(B)` directive (modelled on `nomosBirthRecordEnsure`) so the manifest opens the declared-ensures
194
+ // lane. Best-effort + era-safe: a domain with no (unsanctioned) secondary ensure synthesizes nothing and is
195
+ // byte-identical; a domain that DOES could not have deployed before without the author hand-writing this.
196
+ const autoDeclaredEnsures: string[] = [];
197
+ for (const ty of discoverUnsanctionedEnsures(directives, spec.domain ?? spec.name)) {
198
+ const agg = byId.get(ty);
199
+ // Only sanction a type whose handle this domain actually carries (its schema must be present for the
200
+ // upsert to fold); a probe pointing at a foreign/unknown type is left to the runtime gate (no schema).
201
+ if (agg === undefined) continue;
202
+ const dir = sanctioningEnsureDirective(agg);
203
+ if (directives.some((d) => d.id === dir.id)) continue;
204
+ directives.push(dir);
205
+ autoDeclaredEnsures.push(ty);
206
+ }
188
207
  const aggregates = [...byId.values()];
189
208
  const directiveIds = new Set(directives.map((d) => d.id));
190
209
  for (const task of spec.impureCapabilities ?? []) {
@@ -330,6 +349,8 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
330
349
  domain: spec.domain ?? spec.name,
331
350
  aggregates,
332
351
  directives,
352
+ // Diagnostic metadata for the compile summary — NOT law (not hashed). Omit-when-empty.
353
+ ...(autoDeclaredEnsures.length > 0 ? { autoDeclaredEnsures } : {}),
333
354
  ...(queries.length > 0 ? { queries } : {}),
334
355
  ...(counts.length > 0 ? { counts } : {}),
335
356
  ...(spatials.length > 0 ? { spatials } : {}),
@@ -878,7 +899,29 @@ export function hexUtf8(text: string): string {
878
899
  * domainHash the deploy names is sha256 over the WHOLE returned string (trailing
879
900
  * newline included) exactly as committed.
880
901
  */
881
- export function packageUsda(usdJson: string, javascript: string): string {
902
+ export function packageUsda(usdJson: string, javascript: string, opts: { compact?: boolean } = {}): string {
903
+ // THE COMPACT ERA (opt-in, 2026-07-04): base64 blob fields (`*B64`, 1.33× text) instead of hex
904
+ // (2×). Requires every consumer to carry the decode arm (kernel domain_package.rs extract_blob +
905
+ // the JS parsers) — shipped ahead of this emit, so `compact: true` is the tenant's explicit
906
+ // choice once their stack is current. Hex stays the default emit until a future major; hex
907
+ // packages parse FOREVER (the era rule). sha256(the returned string) is the domainHash either
908
+ // way — a compact recompile is ONE named hash move (batched with minify + locale-strip).
909
+ if (opts.compact) {
910
+ return `#usda 1.0
911
+ (
912
+ customData = {
913
+ string nomos:format = "${NOMOS_DOMAIN_PACKAGE_FORMAT}"
914
+ }
915
+ )
916
+
917
+ def "NomosDomainPackage"
918
+ {
919
+ custom string nomos:usdJsonB64 = "${Buffer.from(usdJson, "utf8").toString("base64")}"
920
+ custom string nomos:executable:language = "javascript"
921
+ custom string nomos:executable:javascriptB64 = "${Buffer.from(javascript, "utf8").toString("base64")}"
922
+ }
923
+ `;
924
+ }
882
925
  return `#usda 1.0
883
926
  (
884
927
  customData = {
@@ -2985,6 +2985,14 @@ export interface DomainModule {
2985
2985
  domain?: string;
2986
2986
  aggregates: AggregateHandle[];
2987
2987
  directives: AnyDirective[];
2988
+ /**
2989
+ * SECONDARY-CREATE AUTO-DECLARE (secondary_ensure.ts) — the aggregate TYPES whose deterministic
2990
+ * secondary `ensure(...)` `composeDomainModule` plan-probed and SANCTIONED by injecting a no-op
2991
+ * `.ensures(Type)` directive into {@link directives}. Diagnostic metadata for the compile summary
2992
+ * ONLY — NOT law, NOT hashed (the manifest reads named fields; this one is not among them). Absent
2993
+ * when the domain has no unsanctioned secondary ensure (the common case).
2994
+ */
2995
+ autoDeclaredEnsures?: string[];
2988
2996
  /**
2989
2997
  * STABLE-ID CONTINUITY (#58 — names are labels, identity is minted). When present
2990
2998
  * (attached by `nomos-compile` from the PRIOR build's identity manifests), the
@@ -659,6 +659,33 @@ function probePlanOps(
659
659
  return { ops, synth, probePayload };
660
660
  }
661
661
 
662
+ /**
663
+ * Compile-side plan-probe of a directive's SECONDARY deterministic ENSURES — the aggregate TYPES its
664
+ * plan introduces via `ensure(Agg, "det:id")` (an `ensures`-marked op at a dev-chosen deterministic id),
665
+ * beyond whatever its PRIMARY referential marker declares. This is the discovery half of the "secondary
666
+ * create auto-declare" DX (`secondary_ensure.ts`): the kernel's id-mint gate admits an Ensure-introduced
667
+ * typed id ONLY when the certified manifest declares `Ensure → Type`, so `composeDomainModule` sanctions
668
+ * each discovered type with a no-op ensures directive.
669
+ *
670
+ * BEST-EFFORT (the reliability caveat): runs the plan ONCE under a synthesized payload + the deterministic
671
+ * probe ports. A plan that can't be probed (unsynthesizable payload / throws) or that ensures CONDITIONALLY
672
+ * on a payload shape the synthesis didn't hit yields fewer (or no) types — in which case the runtime
673
+ * id-mint gate still refuses the un-sanctioned ensure exactly as before (NO regression, NO weakening: the
674
+ * courtesy simply didn't fire). Returns the sorted set of ensured types (INCLUDING the directive's own
675
+ * primary target if it marks `ensures`; the caller subtracts the domain-wide declared set).
676
+ */
677
+ export function probeEnsuredTypes(dir: Directive<unknown>, where: string): string[] {
678
+ const probed = probePlanOps(dir, where);
679
+ if (probed === undefined) return [];
680
+ const out = new Set<string>();
681
+ for (const o of probed.ops) {
682
+ if ((o as { marker?: string }).marker !== "ensures") continue;
683
+ const ty = o.aggregateType;
684
+ if (typeof ty === "string" && ty.length > 0) out.add(ty);
685
+ }
686
+ return [...out].sort();
687
+ }
688
+
662
689
  /** The `status` of a `subjects` setEntry leaf (`writeTuple`/`removeTuple` lowers a JSON `{status, …}` leaf).
663
690
  * `"active"` = a POSITIVE grant; `"revoked"` = a REVOKE. `undefined` ⇒ not a parseable subject leaf. */
664
691
  function subjectLeafStatus(op: FieldOp): string | undefined {
@@ -212,6 +212,18 @@ interface PackageConfig {
212
212
  * `docs/usd_layered_composition.md`).
213
213
  */
214
214
  readonly layered?: boolean;
215
+ /**
216
+ * THE COMPACT PACKAGE ERA (opt-in; also `--compact` / NOMOS_COMPACT=1). ONE batched,
217
+ * NAMED domainHash move that shrinks the package ~4x (guestbook 1.48MB → ~0.35MB):
218
+ * • minified engine lump (esbuild minify — string literals, and so refusal codes, unchanged);
219
+ * • zod locales stripped to `en` only (the locale barrel is 35% of every lump and is
220
+ * namespace-re-exported so tree-shaking can't drop it; `en` keeps error messages
221
+ * byte-identical to today's default);
222
+ * • base64 blob fields (`*B64`, 1.33x text) instead of hex (2x).
223
+ * REQUIRES the whole consuming stack (cloud kernel + clients) at ≥ the release that shipped
224
+ * the decode arms. Default stays off until a future major; hex packages parse forever.
225
+ */
226
+ readonly compact?: boolean;
215
227
  }
216
228
 
217
229
  function fail(msg: string): never {
@@ -608,6 +620,7 @@ function emitDart(
608
620
  async function main(): Promise<void> {
609
621
  const argv = process.argv.slice(2);
610
622
  const layeredFlag = argv.includes("--layered");
623
+ const compactFlag = argv.includes("--compact") || process.env["NOMOS_COMPACT"] === "1";
611
624
  // §"Shipped (1–4)" follow-up: `githolon compile --analyze` (or NOMOS_ANALYZE=1) runs
612
625
  // `dart analyze` on the generated Dart package after emission and fails the compile on red.
613
626
  const analyzeFlag = argv.includes("--analyze") || process.env.NOMOS_ANALYZE === "1";
@@ -1346,12 +1359,44 @@ async function main(): Promise<void> {
1346
1359
  // JS shim with the platform-native binary, which only an exec can run.
1347
1360
  const esbuildBin = path.join(path.dirname(esbuildPkgJson), esbuildBinRel ?? "bin/esbuild");
1348
1361
 
1349
- const javascript =
1350
- execFileSync(
1362
+ const compact = compactFlag || cfg.compact === true;
1363
+ let javascript: string;
1364
+ if (compact) {
1365
+ // COMPACT LUMP (see PackageConfig.compact): the programmatic esbuild API (same pinned
1366
+ // package as the binary) so a resolve-plugin can stub zod's locale barrel to en-only —
1367
+ // the CLI binary has no plugin surface. Same bundle options + minify; deterministic
1368
+ // (pinned esbuild version, deterministic minifier).
1369
+ const esbuildMod = (await import(pathToFileURL(path.join(path.dirname(esbuildPkgJson), "lib", "main.js")).href)) as typeof import("esbuild");
1370
+ const localeStripToEn = {
1371
+ name: "nomos-zod-locales-en-only",
1372
+ setup(build: import("esbuild").PluginBuild) {
1373
+ build.onResolve({ filter: /locales\/index\.js$/ }, (args) => {
1374
+ const resolved = path.resolve(args.resolveDir, args.path);
1375
+ if (!/[\/\\]zod[\/\\]/.test(resolved)) return null; // only zod's own barrel
1376
+ return { path: resolved, namespace: "nomos-zod-locales-en" };
1377
+ });
1378
+ build.onLoad({ filter: /.*/, namespace: "nomos-zod-locales-en" }, (args) => ({
1379
+ // en ONLY: the default error map — messages stay byte-identical to today's default,
1380
+ // so typed refusal payloads (which carry codes + en text) replay unchanged.
1381
+ contents: 'export { default as en } from "./en.js";',
1382
+ resolveDir: path.dirname(args.path),
1383
+ loader: "js" as const,
1384
+ }));
1385
+ },
1386
+ };
1387
+ const built = await esbuildMod.build({
1388
+ entryPoints: [entryPath], bundle: true, format: "iife", target: "es2020",
1389
+ platform: "neutral", charset: "utf8", minify: true, write: false,
1390
+ absWorkingDir: cfgDir, plugins: [localeStripToEn], logLevel: "silent",
1391
+ });
1392
+ javascript = built.outputFiles![0]!.text.trimEnd() + "\n";
1393
+ } else {
1394
+ javascript = execFileSync(
1351
1395
  esbuildBin,
1352
1396
  [entryPath, "--bundle", "--format=iife", "--target=es2020", "--platform=neutral", "--charset=utf8"],
1353
1397
  { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], cwd: cfgDir, maxBuffer: 256 * 1024 * 1024 },
1354
1398
  ).trimEnd() + "\n";
1399
+ }
1355
1400
  if (!/globalThis\.plan\s*=/.test(javascript) && !/globalThis\["plan"\]\s*=/.test(javascript)) {
1356
1401
  fail(`bundled engine entry does not assign globalThis.plan — refusing to package`);
1357
1402
  }
@@ -1462,7 +1507,7 @@ async function main(): Promise<void> {
1462
1507
 
1463
1508
  // ── 2. USD IR + the package envelope ──
1464
1509
  const usdJson = emitUsdJsonForModules(domainModules);
1465
- const text = packageUsda(usdJson, javascript);
1510
+ const text = packageUsda(usdJson, javascript, { compact });
1466
1511
  const pkgPath = path.join(outDir, `${cfg.name}.package.usda`);
1467
1512
  writeFileSync(pkgPath, text, "utf8");
1468
1513
  const domainHash = sha256HexUtf8(text);
@@ -1732,6 +1777,14 @@ async function main(): Promise<void> {
1732
1777
  domainModules.reduce((n, m) => n + (pick(m)?.length ?? 0), 0);
1733
1778
  console.log(` reads ${sum((m) => m.queries)} quer(y/ies), ${sum((m) => m.counts)} count(s), ${sum((m) => m.sums)} sum(s) — declared (routed via the openapi)`);
1734
1779
  }
1780
+ // SECONDARY-CREATE AUTO-DECLARE (secondary_ensure.ts): surface every type the compiler sanctioned
1781
+ // from a plan's deterministic secondary `ensure(...)` — the id-mint-gate DX made visible (the author
1782
+ // wrote `ensure(B, …)`; the compiler declared `Ensure → B` so the deterministic id admits).
1783
+ for (const m of domainModules) {
1784
+ for (const ty of m.autoDeclaredEnsures ?? []) {
1785
+ console.log(` ensure auto-declared Ensure→${ty} in '${m.domain ?? m.name}' from a plan's secondary ensure(...) (id-mint gate opened)`);
1786
+ }
1787
+ }
1735
1788
  console.log(` identity ${Object.keys(identity.manifests).length} domain(s) (carried in the package IR + nomos.stable-ids.json; no sidecar files)${identity.excluded.length ? ` — ${identity.excluded.length} EXCLUDED (palette gap)` : ""}`);
1736
1789
  for (const [dom, h] of Object.entries(identity.hashes)) console.log(` ${dom.padEnd(20)} ${h}`);
1737
1790
  for (const ex of identity.excluded) console.log(` EXCLUDED ${ex.domain}: ${ex.reason}`);
package/src/fields.ts CHANGED
@@ -404,6 +404,18 @@ export const t = {
404
404
  position(): Field<string, "position"> {
405
405
  return makeField<string, "position">("position", z.string(), LastPosition);
406
406
  },
407
+ /**
408
+ * An ARRAY of a value object (or any zod) as a VALUE-OBJECT-SHAPE / directive-PAYLOAD
409
+ * element — the ergonomic sugar for `z.array(SomeVo.zod)`. Accepts a VO handle
410
+ * (`t.array(DrawingGeoControlPoint)`), a Field, or a raw zod, and returns the branded
411
+ * `z.array(...)` so the nested VO's descriptor tree threads (vo_first_class.md slice 1).
412
+ * NOTE: this returns a ZOD SCHEMA — a shape/payload element — NOT a `t.*` aggregate field
413
+ * (an array of VOs is stored as one wholesale-replace json leaf; place it via a value object).
414
+ */
415
+ array(inner: { readonly zod: z.ZodTypeAny } | z.ZodTypeAny): z.ZodArray<z.ZodTypeAny> {
416
+ const el = (inner as { zod?: z.ZodTypeAny }).zod ?? (inner as z.ZodTypeAny);
417
+ return z.array(el);
418
+ },
407
419
  /** Enum via `as const` literal union — value typos squeal at compile time. */
408
420
  enum<const E extends readonly [string, ...string[]]>(values: E): Field<E[number], "enum"> {
409
421
  return makeField<E[number], "enum">(
@@ -0,0 +1,86 @@
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}. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * SECONDARY-CREATE AUTO-DECLARE (the co2-dev id-mint-gate DX).
8
+ *
9
+ * A directive carries ONE referential marker for its PRIMARY aggregate (`.creates(A)` /
10
+ * `.ensures(A)` / …). But a plan may ALSO introduce a SECOND aggregate at a caller-chosen
11
+ * DETERMINISTIC id — `ensure(B, "b:singleton")` — of a type the directive does NOT declare
12
+ * `.ensures()` for. The kernel's id-mint gate admits an Ensure-introduced typed id ONLY through
13
+ * the sanctioned declared-ensures lane: the CERTIFIED manifest must carry a directive whose marker
14
+ * is `Ensure` targeting that type (exactly how `birth_meter_ensure.ts` sanctions `NomosBirthRecord`).
15
+ * With no such declaration the deterministic id is REFUSED ("[id-mint gate]") — correct BY DESIGN
16
+ * (an unsanctioned dev-chosen id can't be forged) but an opaque footgun for the author.
17
+ *
18
+ * This module closes the footgun WITHOUT weakening the gate: `composeDomainModule` PLAN-PROBES each
19
+ * directive (the same probe machinery `codegen_proof.ts` uses), discovers the secondary types its
20
+ * plan ensures, and — for any type NO directive in the domain already declares `.ensures()` for —
21
+ * injects a NO-OP sanctioning `.ensures(Type)` directive (a {@link sanctioningEnsureDirective},
22
+ * modelled on `nomosBirthRecordEnsure`). The manifest/USD then carries the `Ensure → Type`
23
+ * declaration the id-mint gate reads, so the deterministic id is admitted. The synthesized directive
24
+ * is DELIBERATELY USELESS TO DISPATCH (its plan writes nothing); its only load-bearing property is
25
+ * its manifest presence.
26
+ *
27
+ * ERA-SAFE: a domain whose plans do NO secondary ensures is returned untouched (byte-identical). A
28
+ * domain that DOES gains the sanction — and such a domain could not have deployed before this without
29
+ * the author hand-writing the declaration, so no already-shippable manifest hash silently drifts.
30
+ *
31
+ * BEST-EFFORT (graceful degradation): the probe runs each plan ONCE under a synthesized payload. A
32
+ * plan that ensures CONDITIONALLY on a payload shape the synthesis didn't hit (or that can't be
33
+ * probed) simply gets no auto-sanction — and the runtime id-mint gate refuses the ensure exactly as
34
+ * before (no regression, no forged-id hole). Auto-declare only ever ADDS a sanction the author's own
35
+ * plan already proves it intends; it never blocks a valid deploy.
36
+ */
37
+ import { z } from "zod";
38
+ import { directive } from "./directive.js";
39
+ import type { Directive } from "./directive.js";
40
+ import type { AggregateHandle } from "./aggregate.js";
41
+ import { probeEnsuredTypes } from "./codegen_proof.js";
42
+
43
+ /** The synthesized directive id sanctioning `Ensure → <aggId>`. Derived purely from the aggregate id
44
+ * so recompiles are stable; non-id chars are flattened so the id stays route-safe. */
45
+ export function sanctioningEnsureDirectiveId(aggId: string): string {
46
+ return `nomosEnsure_${aggId.replace(/[^A-Za-z0-9_]/g, "_")}`;
47
+ }
48
+
49
+ /**
50
+ * A no-op sanctioning `.ensures(agg)` directive — the twin of `nomosBirthRecordEnsure`, parameterized
51
+ * by an arbitrary aggregate. Its plan emits ZERO ops (it cannot forge a row); its ONLY purpose is the
52
+ * `Ensure → agg` marker the certified manifest carries so the id-mint gate opens the declared-ensures
53
+ * lane for a plan's secondary deterministic `ensure(agg, …)`.
54
+ */
55
+ export function sanctioningEnsureDirective(agg: AggregateHandle): Directive<unknown> {
56
+ return directive(sanctioningEnsureDirectiveId(agg.id))
57
+ .ensures(agg)
58
+ .payload(z.object({}).strict())
59
+ .plan(() => []) as unknown as Directive<unknown>;
60
+ }
61
+
62
+ /**
63
+ * Discover the secondary-ensured types a domain's directives introduce that NO directive already
64
+ * declares `.ensures()` for — the SORTED gap set the compose seam must sanction. Pure discovery:
65
+ * probes every directive, unions the ensured types, subtracts the domain-wide DECLARED-ensures set.
66
+ */
67
+ export function discoverUnsanctionedEnsures(
68
+ directives: readonly Directive<unknown>[],
69
+ domain: string,
70
+ ): string[] {
71
+ // Types some directive already DECLARES as its primary `.ensures(...)` target — already sanctioned.
72
+ const declared = new Set<string>();
73
+ for (const d of directives) {
74
+ if ((d as { marker?: string }).marker === "ensures") declared.add(d.aggregateId);
75
+ }
76
+ const gap = new Set<string>();
77
+ for (const d of directives) {
78
+ // A `.births()` directive's genesis-recipe plan runs under a different probe surface; its meter
79
+ // record is sanctioned by `birth_meter_ensure.ts`, so leave it to that lane (avoid double probing).
80
+ if ((d as { birthsChild?: boolean }).birthsChild === true) continue;
81
+ for (const ty of probeEnsuredTypes(d, `${domain}/${d.id}`)) {
82
+ if (!declared.has(ty)) gap.add(ty);
83
+ }
84
+ }
85
+ return [...gap].sort();
86
+ }
@@ -52,6 +52,7 @@
52
52
  */
53
53
  import { z } from "zod";
54
54
 
55
+ import type { Driver } from "./drivers.js";
55
56
  import {
56
57
  valueObjectField,
57
58
  type Field,
@@ -129,6 +130,17 @@ export interface ValueObject<T = unknown> {
129
130
  * whole), `.optional()`, `.encrypted()` (under the existing `requires(cap)` rules).
130
131
  */
131
132
  field(): Field<T, "json">;
133
+ /**
134
+ * SUGAR for `.field().merge(driver)` — lower this VO DIRECTLY to an aggregate field with
135
+ * the chosen merge driver: `location: SiteLocation.merge(Lww).optional()`. A VO field is
136
+ * ONE Lww-class json leaf replaced WHOLESALE, so only the Lww-class family
137
+ * (`Lww` / `LwwCausal` / `LwwBy`) is accepted — any other driver throws (field-level merge
138
+ * composition of a VO's subfields is a later slice; model those as aggregate fields / a
139
+ * `t.map(...)`). The returned `Field` chains the usual modifiers (`.optional()`,
140
+ * `.encrypted()`, …), so `SiteLocation.merge(Lww).optional()` == `SiteLocation.field()
141
+ * .merge(Lww).optional()` byte-for-byte.
142
+ */
143
+ merge(driver: Driver): Field<T, "json">;
132
144
  /** Phantom to keep T load-bearing for inference. */
133
145
  readonly _t?: T;
134
146
  }
@@ -255,12 +267,48 @@ export function idOfMetaOf(zt: z.ZodTypeAny): string | undefined {
255
267
  return undefined;
256
268
  }
257
269
 
270
+ /** Thread a nested VO's own descriptor + transitive dependencies into `deps` (divergent
271
+ * same-name shapes throw — one name, one shape), then return its ref field descriptor. This
272
+ * is what makes `z.array(SomeVo.zod)` / a raw `SomeVo.zod` entry plug-and-play: the VO brand
273
+ * rides the `.zod`, so the descriptor TREE is threaded rather than lost (vo_first_class.md
274
+ * slice 0). */
275
+ function takeVoField(
276
+ meta: ValueObjectFieldMeta,
277
+ deps: Map<string, ValueObjectDescriptor>,
278
+ ): ValueObjectFieldDescriptor {
279
+ for (const d of [meta.descriptor, ...meta.dependencies]) {
280
+ const existing = deps.get(d.name);
281
+ if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
282
+ throw new Error(
283
+ `valueObject: two DIVERGENT value objects share the name '${d.name}' — one name, one shape. ` +
284
+ `Rename one of them (the descriptor is identity-bearing).`,
285
+ );
286
+ }
287
+ deps.set(d.name, d);
288
+ }
289
+ return meta.geometry !== undefined
290
+ ? { type: "geometry", ref: meta.name, geometry: meta.geometry }
291
+ : { type: "object", ref: meta.name };
292
+ }
293
+
258
294
  /**
259
295
  * Describe one CONSTRAINED zod schema as a descriptor field (NO FALLBACK — an
260
296
  * out-of-set shape throws with the field's path so the author fixes the declaration,
261
- * never ships an undescribed structure).
297
+ * never ships an undescribed structure). `deps` collects every nested VO the schema
298
+ * references BY BRAND (`z.array(SomeVo.zod)`, a raw `SomeVo.zod`) so its descriptor rides
299
+ * alongside — the caller unions it into the VO's `dependencies`.
262
300
  */
263
- function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueObjectFieldDescriptor {
301
+ function describeZod(
302
+ voName: string,
303
+ path: string,
304
+ schema: z.ZodTypeAny,
305
+ deps: Map<string, ValueObjectDescriptor>,
306
+ ): ValueObjectFieldDescriptor {
307
+ // A VO referenced through its RAW zod (its own brand, no optional wrapper) — emit the
308
+ // ref + thread its descriptor tree. (Optional wrappers are handled by the switch below,
309
+ // which recurses to the branded inner schema.)
310
+ const ownBrand = (schema as unknown as Record<symbol, unknown>)[NOMOS_VO_BRAND];
311
+ if (ownBrand !== undefined) return takeVoField(ownBrand as ValueObjectFieldMeta, deps);
264
312
  switch (zodKind(schema)) {
265
313
  case "string":
266
314
  return { type: "string" };
@@ -285,7 +333,7 @@ function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueO
285
333
  return { type: "literal", value: v };
286
334
  }
287
335
  case "array": {
288
- const items = describeZod(voName, `${path}[]`, zodArrayElement(schema));
336
+ const items = describeZod(voName, `${path}[]`, zodArrayElement(schema), deps);
289
337
  if (items.optional === true) {
290
338
  throw new Error(
291
339
  `valueObject '${voName}': field '${path}' is an array of optionals — not expressible in the v1 set.`,
@@ -295,7 +343,7 @@ function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueO
295
343
  }
296
344
  case "optional":
297
345
  case "default": {
298
- const inner = describeZod(voName, path, zodDef(schema).innerType!);
346
+ const inner = describeZod(voName, path, zodDef(schema).innerType!, deps);
299
347
  return { ...inner, optional: true };
300
348
  }
301
349
  default:
@@ -452,7 +500,29 @@ function makeValueObject<T>(
452
500
  field(): Field<T, "json"> {
453
501
  return valueObjectField(zodSchema, meta);
454
502
  },
503
+ merge(driver: Driver): Field<T, "json"> {
504
+ const k = driver.kind;
505
+ if (k !== "Lww" && k !== "LwwCausal" && k !== "LwwBy") {
506
+ throw new Error(
507
+ `valueObject '${name}': .merge(${k}) is not a valid value-object field merge — a VO is ONE ` +
508
+ `Lww-class json leaf replaced WHOLESALE, so only Lww / LwwCausal / LwwBy apply. Field-level ` +
509
+ `merge composition of a VO's subfields is a later slice; model independently-merging subfields ` +
510
+ `as aggregate fields or a t.map(...).`,
511
+ );
512
+ }
513
+ return valueObjectField(zodSchema, meta).merge(driver);
514
+ },
455
515
  };
516
+ // THE HANDLE IS A FIRST-CLASS ZOD SCHEMA (vo_first_class.md slice 1): delegate zod's
517
+ // internals so the raw HANDLE composes DIRECTLY where a zod is expected — `z.array(Vo)`,
518
+ // `z.object({ f: Vo })`, a directive payload — with NO `.zod` peeling. Non-enumerable (never
519
+ // serialized, domainHash-inert) and stamped with the VO brand so describeZod / voMetaOf resolve
520
+ // the handle exactly like its `.zod`.
521
+ const inner = zodSchema as unknown as { _zod?: unknown; _def?: unknown; def?: unknown };
522
+ Object.defineProperty(vo, "_zod", { get: () => inner._zod, enumerable: false });
523
+ Object.defineProperty(vo, "_def", { get: () => inner._def, enumerable: false });
524
+ Object.defineProperty(vo, "def", { get: () => inner.def, enumerable: false });
525
+ Object.defineProperty(vo, NOMOS_VO_BRAND, { value: meta, enumerable: false });
456
526
  return vo;
457
527
  }
458
528
 
@@ -495,7 +565,7 @@ export function valueObject<const Name extends string, S extends Record<string,
495
565
  : { type: "object", ref: entry.name };
496
566
  collectDescriptors(entry, deps);
497
567
  } else {
498
- fields[key] = describeZod(name, key, entry);
568
+ fields[key] = describeZod(name, key, entry, deps);
499
569
  zodShape[key] = entry;
500
570
  }
501
571
  }
@@ -584,7 +654,7 @@ export function variant<const Name extends string, Arms extends Record<string, U
584
654
  : { type: "object", ref: entry.name };
585
655
  collectDescriptors(entry, deps);
586
656
  } else {
587
- fields[fieldKey] = describeZod(name, `${key}.${fieldKey}`, entry);
657
+ fields[fieldKey] = describeZod(name, `${key}.${fieldKey}`, entry, deps);
588
658
  zodShape[fieldKey] = entry;
589
659
  }
590
660
  }