@githolon/dsl 0.48.1 → 0.50.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.
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
3
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
4
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
5
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
6
+
7
+ // `githolon check` launcher — the twin of `compile_package.mjs`: resolve `tsx`
8
+ // (the caller's, else the DSL's own dependency) and run `src/check_package_main.ts`
9
+ // under it (the main dynamically imports the caller's TS domain modules, which
10
+ // needs the tsx ESM loader). Resolution goes through Node's resolver (createRequire),
11
+ // NOT node_modules/.bin paths — hoisted/workspace installs put the bin shims elsewhere.
12
+ import { execFileSync } from "node:child_process";
13
+ import { readFileSync } from "node:fs";
14
+ import { createRequire } from "node:module";
15
+ import path from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+
18
+ const DSL_DIR = path.dirname(fileURLToPath(import.meta.url));
19
+ const MAIN = path.join(DSL_DIR, "src", "check_package_main.ts");
20
+
21
+ function resolvePkgDir(name, fromDir) {
22
+ try {
23
+ const req = createRequire(pathToFileURL(path.join(fromDir, "noop.js")));
24
+ return path.dirname(req.resolve(`${name}/package.json`));
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ const tsxDir = resolvePkgDir("tsx", process.cwd()) ?? resolvePkgDir("tsx", DSL_DIR);
31
+ if (!tsxDir) {
32
+ console.error("githolon check: tsx not found — reinstall @githolon/dsl (tsx is one of its dependencies)");
33
+ process.exit(1);
34
+ }
35
+ const tsxPkg = JSON.parse(readFileSync(path.join(tsxDir, "package.json"), "utf8"));
36
+ const tsxBinRel = typeof tsxPkg.bin === "string" ? tsxPkg.bin : tsxPkg.bin?.tsx;
37
+ const tsxCli = path.join(tsxDir, tsxBinRel ?? "dist/cli.mjs");
38
+
39
+ try {
40
+ execFileSync(process.execPath, [tsxCli, MAIN, ...process.argv.slice(2)], {
41
+ stdio: "inherit",
42
+ cwd: process.cwd(),
43
+ });
44
+ } catch (e) {
45
+ process.exit(e.status ?? 1);
46
+ }
@@ -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) {
@@ -292,6 +292,96 @@ class NomosBbox {
292
292
  {'minLng': minLng, 'minLat': minLat, 'maxLng': maxLng, 'maxLat': maxLat};
293
293
  }
294
294
 
295
+ // ── E2E SEALING (the sealing client) — the GENERIC key-custody surface. Generated domain clients bake the
296
+ // per-domain map + seal/read helpers, but these types are domain-agnostic, so they live here (in the
297
+ // nomos_client library) exactly like [MissingOfferAuthority] — one definition, re-exported per client. ──
298
+
299
+ /// A held E2E scope key: the base64 symmetric key + the epoch it belongs to (forward-only rotation).
300
+ class NomosScopeKey {
301
+ final String scope;
302
+ final int epoch;
303
+ final String key;
304
+ const NomosScopeKey(
305
+ {required this.scope, required this.epoch, required this.key});
306
+ Map<String, Object?> toJson() => {'scope': scope, 'epoch': epoch, 'key': key};
307
+ }
308
+
309
+ /// The app-supplied KEY CUSTODY surface (E2E sealing). Supply one to a generated `<Domain>Client` and its
310
+ /// encrypted directive fields SEAL automatically before dispatch + encrypted read fields DECRYPT
311
+ /// automatically. [WrappedKeyStore] is the default over the on-chain WrappedKeys; an app may implement this
312
+ /// to source keys from secure storage (the key custody policy stays the app's; the protocol plumbing does not).
313
+ abstract class NomosKeyStore {
314
+ /// The CURRENT (highest-epoch) scope key, for SEALING a write. null ⇒ EncryptionKeyUnavailable.
315
+ Future<NomosScopeKey?> scopeKey(String scope);
316
+
317
+ /// EVERY held key for these scopes (all epochs), injected on reads so old ciphertext also decrypts.
318
+ Future<List<NomosScopeKey>> readKeys(List<String> scopes);
319
+ }
320
+
321
+ /// Thrown when a write must seal an encrypted field but the KeyStore holds no key for its scope — the write
322
+ /// NEVER falls through to a plaintext dispatch (the sid-envelope gate would refuse it anyway; we pre-fire).
323
+ class EncryptionKeyUnavailable implements Exception {
324
+ final String scope;
325
+ final String field;
326
+ String get code => 'nomos.crypto.key_unavailable';
327
+ String get recommendedAction => 'obtainScopeKey';
328
+ EncryptionKeyUnavailable(this.scope, this.field);
329
+ @override
330
+ String toString() =>
331
+ 'EncryptionKeyUnavailable: cannot seal "$field": no scope key held for "$scope" (obtain the cap + wrap the scope epoch first)';
332
+ }
333
+
334
+ /// THE DEFAULT KeyStore — unwraps this principal's on-chain WrappedKeys with its device secret, caching per
335
+ /// scope. Mirror of the documented reader handshake (e2e_encryption.md): the secret stays client-side, the
336
+ /// operator stays blind. Key custody (where `deviceSecret` lives — enclave, password-derived, …) is the app's.
337
+ class WrappedKeyStore implements NomosKeyStore {
338
+ final NomosBridge bridge;
339
+ final String principal;
340
+ final String deviceSecret;
341
+ final Map<String, Future<List<NomosScopeKey>>> _cache = {};
342
+ WrappedKeyStore(
343
+ {required this.bridge,
344
+ required this.principal,
345
+ required this.deviceSecret});
346
+
347
+ Future<List<NomosScopeKey>> _loadScope(String scope) {
348
+ return _cache.putIfAbsent(scope, () async {
349
+ final rows = await bridge
350
+ .query('wrappedKeysByRecipient', {'recipientPrincipal': principal});
351
+ final held = <NomosScopeKey>[];
352
+ for (final row in rows) {
353
+ final d = ((row['data'] ?? row) as Map);
354
+ if (d['scope']?.toString() != scope) continue;
355
+ final key = await bridge.cryptoUnwrapKey(
356
+ secret: deviceSecret,
357
+ hpkeEpk: d['hpkeEpk'].toString(),
358
+ ct: d['ct'].toString());
359
+ held.add(NomosScopeKey(
360
+ scope: scope,
361
+ epoch: (d['epoch'] as num?)?.toInt() ?? 0,
362
+ key: key));
363
+ }
364
+ held.sort((a, b) => b.epoch - a.epoch);
365
+ return held;
366
+ });
367
+ }
368
+
369
+ @override
370
+ Future<NomosScopeKey?> scopeKey(String scope) async {
371
+ final ks = await _loadScope(scope);
372
+ return ks.isEmpty ? null : ks.first;
373
+ }
374
+
375
+ @override
376
+ Future<List<NomosScopeKey>> readKeys(List<String> scopes) async {
377
+ final all = <NomosScopeKey>[];
378
+ for (final s in scopes) {
379
+ all.addAll(await _loadScope(s));
380
+ }
381
+ return all;
382
+ }
383
+ }
384
+
295
385
  /// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
296
386
  class NomosOfferOutcome {
297
387
  final String head;
@@ -771,14 +861,78 @@ class NomosBridge {
771
861
  Map<String, Object?> args) async =>
772
862
  _asMap(await _call('attestationSign', {'args': args}));
773
863
 
774
- /// A declared query's rows from the local projection.
864
+ /// A declared query's rows from the local projection. `keys` (E2E sealing) injects the reader's held
865
+ /// scope keys so encrypted fields AEAD-open in-wasm; absent ⇒ they stay opaque envelopes (today's
866
+ /// behavior). `principal` stamps the private-read gate (defaults to the connection's bound principal).
867
+ /// Both are OMITTED from the wire when absent, so a public un-keyed read is byte-identical to before.
775
868
  Future<List<Map<String, dynamic>>> query(String queryId,
776
- [Map<String, Object?> params = const {}]) async =>
777
- _asRows(await _call('query', {'queryId': queryId, 'params': params}));
869
+ [Map<String, Object?> params = const {},
870
+ List<NomosScopeKey>? keys,
871
+ String? principal]) async =>
872
+ _asRows(await _call('query', {
873
+ 'queryId': queryId,
874
+ 'params': params,
875
+ ...?_readAuthArgs(keys, principal),
876
+ }));
877
+
878
+ /// A single aggregate by id (the by-id read). `keys`/`principal`: see [query].
879
+ Future<List<Map<String, dynamic>>> queryById(String id,
880
+ [List<NomosScopeKey>? keys, String? principal]) async =>
881
+ _asRows(await _call('queryById', {
882
+ 'id': id,
883
+ ...?_readAuthArgs(keys, principal),
884
+ }));
885
+
886
+ /// The read auth sidecar (private reads + E2E): `keys`/`principal` as top-level call args, or null when
887
+ /// neither is present (so the wire is byte-identical to before this existed).
888
+ Map<String, Object?>? _readAuthArgs(List<NomosScopeKey>? keys, String? principal) {
889
+ final out = <String, Object?>{};
890
+ if (principal != null && principal.isNotEmpty) out['principal'] = principal;
891
+ if (keys != null && keys.isNotEmpty) out['keys'] = keys.map((k) => k.toJson()).toList();
892
+ return out.isEmpty ? null : out;
893
+ }
778
894
 
779
- /// A single aggregate by id (the by-id read).
780
- Future<List<Map<String, dynamic>>> queryById(String id) async =>
781
- _asRows(await _call('queryById', {'id': id}));
895
+ /// E2E CRYPTO OPS (operator-blind sealing) — routed to the kernel via the WebView runner. XChaCha isn't in
896
+ /// WebCrypto/Dart, so these drive the wasm; the scope key + device secret are INJECTED per call and never
897
+ /// touch the chain. Mirror of the TS holon's crypto seam (@githolon/client).
898
+ /// Mint an X25519 device keypair for the handshake: `{secret, public}` (base64). Secret stays on device.
899
+ Future<Map<String, Object?>> cryptoKeygen() async =>
900
+ _asMap(await _call('cryptoKeygen'));
901
+
902
+ /// AUTHOR-SIDE SEAL: encrypt `plaintext` into its on-chain ciphertext envelope, bound to the scope + #58
903
+ /// stable sids (rename-safe). Returns the envelope string the encrypted field value is written as.
904
+ Future<String> cryptoSealField(
905
+ {required String scopeKey,
906
+ required String scope,
907
+ required int epoch,
908
+ required String aggSid,
909
+ required String fieldSid,
910
+ required String plaintext}) async =>
911
+ (_asMap(await _call('cryptoSealField', {
912
+ 'scopeKey': scopeKey,
913
+ 'scope': scope,
914
+ 'epoch': epoch,
915
+ 'aggSid': aggSid,
916
+ 'fieldSid': fieldSid,
917
+ 'plaintext': plaintext,
918
+ }))['envelope'] ?? '')
919
+ .toString();
920
+
921
+ /// Wrap a scope key TO a recipient's public key (committer side): `{hpkeEpk, ct}` base64 ciphertext.
922
+ Future<Map<String, Object?>> cryptoWrapKey(
923
+ {required String recipientPub, required String scopeKey}) async =>
924
+ _asMap(await _call('cryptoWrapKey',
925
+ {'recipientPub': recipientPub, 'scopeKey': scopeKey}));
926
+
927
+ /// Unwrap an on-chain WrappedKey with this device's secret → the base64 scope key (reader side).
928
+ Future<String> cryptoUnwrapKey(
929
+ {required String secret,
930
+ required String hpkeEpk,
931
+ required String ct}) async =>
932
+ (_asMap(await _call('cryptoUnwrapKey',
933
+ {'secret': secret, 'hpkeEpk': hpkeEpk, 'ct': ct}))['scopeKey'] ??
934
+ '')
935
+ .toString();
782
936
 
783
937
  /// THE SPATIAL READ: rows of the declared spatial index's of-type whose geometry
784
938
  /// intersects [bbox] — an R*Tree probe on the local projection, same row shape as [query].
@@ -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.50.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",
@@ -46,7 +46,8 @@
46
46
  "dart",
47
47
  "!dart/.dart_tool",
48
48
  "!dart/build",
49
- "compile_package.mjs"
49
+ "compile_package.mjs",
50
+ "check_package.mjs"
50
51
  ],
51
52
  "publishConfig": {
52
53
  "access": "public"
@@ -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 = {
@@ -0,0 +1,83 @@
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
+ * `githolon check` — the author-time gate as a standalone command
8
+ * (architecture/author_time_gate.md, slice 1). Loads `nomos.package.mjs`, composes each
9
+ * domain the SAME way `compile` does (`composeDomainModule` — the one owner of directive
10
+ * discovery), runs {@link checkDomainModules}, prints the unified report, and exits
11
+ * non-zero on any REFUSE-level finding. `compile` runs the same twins implicitly.
12
+ */
13
+ import { existsSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+
17
+ import { composeDomainModule, type Mod } from "./build_package.js";
18
+ import type { DomainModule } from "./codegen_dart.js";
19
+ import { checkDomainModules, formatFindings, hasRefusal } from "./law_check.js";
20
+
21
+ interface DomainConfig {
22
+ key: string;
23
+ name?: string;
24
+ modules: string[];
25
+ }
26
+ interface PackageConfig {
27
+ name: string;
28
+ domains: DomainConfig[];
29
+ }
30
+
31
+ function fail(msg: string): never {
32
+ console.error(`githolon check: ${msg}`);
33
+ process.exit(2);
34
+ }
35
+
36
+ /**
37
+ * Compose the package's domain modules from `nomos.package.mjs` — the check-time subset of what
38
+ * `compile_package_main` does: import the config, import each domain's TS modules, and hand them to
39
+ * `composeDomainModule` (the shared composition primitive that discovers directives/aggregates and
40
+ * applies the secondary-ensure auto-declare). Returns the composed modules ready for the twins.
41
+ */
42
+ export async function loadComposedModules(configArg: string): Promise<DomainModule[]> {
43
+ const configPath = path.resolve(process.cwd(), configArg);
44
+ if (!existsSync(configPath)) fail(`config not found: ${configPath}`);
45
+ const cfgDir = path.dirname(configPath);
46
+ const cfgModule = (await import(pathToFileURL(configPath).href)) as { default?: PackageConfig };
47
+ const cfg = cfgModule.default;
48
+ if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
49
+ fail(`config must default-export { name, domains: [{ key, modules }, ...] }`);
50
+ }
51
+ const composed: DomainModule[] = [];
52
+ for (const d of cfg.domains) {
53
+ if (typeof d.key !== "string" || !Array.isArray(d.modules) || d.modules.length === 0) {
54
+ fail(`each domain needs { key, modules: [path, ...] }; got ${JSON.stringify(d)}`);
55
+ }
56
+ const mods: Mod[] = [];
57
+ for (const rel of d.modules) {
58
+ const abs = path.resolve(cfgDir, rel);
59
+ if (!existsSync(abs)) fail(`domain '${d.key}': module not found: ${abs}`);
60
+ mods.push((await import(pathToFileURL(abs).href)) as Mod);
61
+ }
62
+ composed.push(composeDomainModule({ name: d.name ?? d.key, domain: d.key, modules: mods }));
63
+ }
64
+ return composed;
65
+ }
66
+
67
+ async function main(): Promise<void> {
68
+ const argv = process.argv.slice(2);
69
+ const configArg = argv.find((a) => !a.startsWith("--")) ?? "nomos.package.mjs";
70
+ const modules = await loadComposedModules(configArg);
71
+ const findings = checkDomainModules(modules);
72
+
73
+ console.log(`githolon check: ${findings.length} finding(s)`);
74
+ for (const line of formatFindings(findings)) {
75
+ const level = line.includes("REFUSE") ? " ✗ " : line.includes("WARN") ? " ⚠ " : " • ";
76
+ console.log(level + line);
77
+ }
78
+ if (findings.length === 0) console.log(" all clear — no static twin flagged anything");
79
+
80
+ process.exit(hasRefusal(findings) ? 1 : 0);
81
+ }
82
+
83
+ void main();
@@ -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