@githolon/dsl 0.49.0 → 0.51.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,194 @@
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, readFileSync, readdirSync, statSync } 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, type Finding } from "./law_check.js";
20
+ import {
21
+ currentEvolveLaw,
22
+ evolveDiff,
23
+ evolveLawFromDeployJson,
24
+ evolveLawFromLockfile,
25
+ type EvolveLaw,
26
+ type StableIdLockfile,
27
+ } from "./evolve_preview.js";
28
+
29
+ interface DomainConfig {
30
+ key: string;
31
+ name?: string;
32
+ modules: string[];
33
+ }
34
+ interface PackageConfig {
35
+ name: string;
36
+ domains: DomainConfig[];
37
+ }
38
+
39
+ function fail(msg: string): never {
40
+ console.error(`githolon check: ${msg}`);
41
+ process.exit(2);
42
+ }
43
+
44
+ /**
45
+ * Compose the package's domain modules from `nomos.package.mjs` — the check-time subset of what
46
+ * `compile_package_main` does: import the config, import each domain's TS modules, and hand them to
47
+ * `composeDomainModule` (the shared composition primitive that discovers directives/aggregates and
48
+ * applies the secondary-ensure auto-declare). Returns the composed modules ready for the twins.
49
+ */
50
+ export async function loadComposedModules(configArg: string): Promise<DomainModule[]> {
51
+ const configPath = path.resolve(process.cwd(), configArg);
52
+ if (!existsSync(configPath)) fail(`config not found: ${configPath}`);
53
+ const cfgDir = path.dirname(configPath);
54
+ const cfgModule = (await import(pathToFileURL(configPath).href)) as { default?: PackageConfig };
55
+ const cfg = cfgModule.default;
56
+ if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
57
+ fail(`config must default-export { name, domains: [{ key, modules }, ...] }`);
58
+ }
59
+ const composed: DomainModule[] = [];
60
+ for (const d of cfg.domains) {
61
+ if (typeof d.key !== "string" || !Array.isArray(d.modules) || d.modules.length === 0) {
62
+ fail(`each domain needs { key, modules: [path, ...] }; got ${JSON.stringify(d)}`);
63
+ }
64
+ const mods: Mod[] = [];
65
+ for (const rel of d.modules) {
66
+ const abs = path.resolve(cfgDir, rel);
67
+ if (!existsSync(abs)) fail(`domain '${d.key}': module not found: ${abs}`);
68
+ mods.push((await import(pathToFileURL(abs).href)) as Mod);
69
+ }
70
+ composed.push(composeDomainModule({ name: d.name ?? d.key, domain: d.key, modules: mods }));
71
+ }
72
+ return composed;
73
+ }
74
+
75
+ /**
76
+ * Load the PRIOR law surface for `--against <path>` (the evolve preview's baseline). The supported
77
+ * prior is a DEPLOY.JSON (`build/<name>.deploy.json`), a BUILD DIR / package dir containing one, or
78
+ * the committed `nomos.stable-ids.json` LOCKFILE (aggregate identity only — no directive payloads).
79
+ * A live `https://…` prior is NOT supported (the installed package's stable-ids aren't retrievable
80
+ * from GET /domains); point `--against` at the deployed law's committed deploy.json instead.
81
+ */
82
+ function loadPriorEvolveLaw(arg: string): { law: EvolveLaw; source: string; note?: string } {
83
+ if (/^https?:\/\//.test(arg)) {
84
+ fail(
85
+ `--against does not support a live URL (${arg}) — the installed package's #58 stable-ids are ` +
86
+ `not retrievable from GET /domains. Point --against at the deployed law's build/<name>.deploy.json.`,
87
+ );
88
+ }
89
+ const abs = path.resolve(process.cwd(), arg);
90
+ if (!existsSync(abs)) fail(`--against: prior not found: ${abs}`);
91
+ // Resolve a directory to a deploy.json (build dir or package dir) or its lockfile.
92
+ let file = abs;
93
+ if (statSync(abs).isDirectory()) {
94
+ const deploy = findDeployJson(abs);
95
+ const lock = path.join(abs, "nomos.stable-ids.json");
96
+ if (deploy !== undefined) file = deploy;
97
+ else if (existsSync(lock)) file = lock;
98
+ else fail(`--against: no *.deploy.json or nomos.stable-ids.json under ${abs}`);
99
+ }
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(readFileSync(file, "utf8"));
103
+ } catch (e) {
104
+ fail(`--against: could not parse ${file}: ${(e as Error).message}`);
105
+ }
106
+ const p = parsed as Record<string, unknown>;
107
+ if (p.domains !== undefined && p.interface === undefined && p.packageUsda === undefined) {
108
+ return {
109
+ law: evolveLawFromLockfile(parsed as StableIdLockfile),
110
+ source: file,
111
+ note: `prior is a lockfile — aggregate/field identity is diffed, but directive payload changes cannot be (deploy.json carries the interface).`,
112
+ };
113
+ }
114
+ return { law: evolveLawFromDeployJson(parsed as never), source: file };
115
+ }
116
+
117
+ /** Find a `*.deploy.json` under a directory — directly, or one level down in `build/`. */
118
+ function findDeployJson(dir: string): string | undefined {
119
+ const scan = (d: string): string | undefined => {
120
+ if (!existsSync(d)) return undefined;
121
+ const hit = readdirSync(d).find((f) => f.endsWith(".deploy.json"));
122
+ return hit !== undefined ? path.join(d, hit) : undefined;
123
+ };
124
+ return scan(dir) ?? scan(path.join(dir, "build"));
125
+ }
126
+
127
+ /** Read the committed `nomos.stable-ids.json` lockfile beside the config (the CURRENT identity). */
128
+ function loadCwdLockfile(configArg: string): StableIdLockfile | undefined {
129
+ const cfgDir = path.dirname(path.resolve(process.cwd(), configArg));
130
+ const lockPath = path.join(cfgDir, "nomos.stable-ids.json");
131
+ if (!existsSync(lockPath)) return undefined;
132
+ try {
133
+ return JSON.parse(readFileSync(lockPath, "utf8")) as StableIdLockfile;
134
+ } catch {
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ async function main(): Promise<void> {
140
+ const argv = process.argv.slice(2);
141
+ // The positional config must SKIP flag VALUES (`check --against prior.json` has no
142
+ // positional — `prior.json` belongs to the flag, not the config slot).
143
+ const againstIdx = argv.findIndex((a) => a === "--against");
144
+ const positionals = argv.filter((a, i) => !a.startsWith("--") && i !== againstIdx + 1);
145
+ const configArg = positionals[0] ?? "nomos.package.mjs";
146
+ const againstArg =
147
+ againstIdx >= 0
148
+ ? argv[againstIdx + 1]
149
+ : argv.find((a) => a.startsWith("--against="))?.slice("--against=".length);
150
+
151
+ const modules = await loadComposedModules(configArg);
152
+ const findings: Finding[] = [...checkDomainModules(modules)];
153
+
154
+ // ── EVOLVE PREVIEW (--against): diff the current build against a prior law + pre-fire the gate. ──
155
+ let evolveDispositions: Record<string, unknown> | undefined;
156
+ if (againstIdx >= 0 || againstArg !== undefined) {
157
+ if (againstArg === undefined || againstArg.startsWith("--")) {
158
+ fail(`--against needs a path: --against <build/<name>.deploy.json | build dir | nomos.stable-ids.json>`);
159
+ }
160
+ const lock = loadCwdLockfile(configArg);
161
+ if (lock === undefined) {
162
+ console.log(
163
+ "githolon check: --against — no nomos.stable-ids.json in the config dir; the current law's stable ids " +
164
+ "fall back to NAMES (a rename would read as removal+add). Run `githolon compile` first for a sid-accurate preview.",
165
+ );
166
+ }
167
+ const prior = loadPriorEvolveLaw(againstArg);
168
+ if (prior.note !== undefined) console.log(`githolon check: --against note — ${prior.note}`);
169
+ const current = currentEvolveLaw(modules, lock);
170
+ const diff = evolveDiff(prior.law, current);
171
+ findings.push(...diff.findings);
172
+ if (Object.keys(diff.dispositions).length > 0) evolveDispositions = diff.dispositions;
173
+ console.log(`githolon check: evolve preview against ${prior.source}`);
174
+ }
175
+
176
+ console.log(`githolon check: ${findings.length} finding(s)`);
177
+ for (const line of formatFindings(findings)) {
178
+ const level = line.includes("REFUSE") ? " ✗ " : line.includes("WARN") ? " ⚠ " : " • ";
179
+ console.log(level + line);
180
+ }
181
+ if (findings.length === 0) console.log(" all clear — no static twin flagged anything");
182
+
183
+ // The consolidated, pasteable dispositions the destructive evolves demand (per domain).
184
+ if (evolveDispositions !== undefined) {
185
+ console.log(
186
+ "\ngitholon check: to admit the destructive evolve, add `dispositions` to the domain's deploy JSON:",
187
+ );
188
+ console.log(JSON.stringify(evolveDispositions, null, 2));
189
+ }
190
+
191
+ process.exit(hasRefusal(findings) ? 1 : 0);
192
+ }
193
+
194
+ void main();
@@ -36,7 +36,7 @@ import type { DomainModule } from "./codegen_dart.js";
36
36
  import { cap, readModelClassName } from "./codegen_dart.js";
37
37
  import { finishCount } from "./count.js";
38
38
  import { finishSum } from "./sum.js";
39
- import { autoStampFields } from "./codegen_ts.js";
39
+ import { autoStampFields, domainEncryptionMap, type EncDomainMap, type InterfaceAggShape } from "./codegen_ts.js";
40
40
 
41
41
  const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
42
42
  const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
@@ -127,6 +127,33 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
127
127
  const aggById = new Map<string, AggregateHandle>();
128
128
  for (const a of mod.aggregates) aggById.set(a.id, a);
129
129
 
130
+ // THE E2E-SEALING MAP (the sealing client) — non-null iff a field is `.encrypted()`. When null NOTHING
131
+ // below emits (the generated Dart client is byte-identical to a pre-sealing compile). Same map + sids as
132
+ // the TS client (one source of truth: `domainEncryptionMap`); requires the baked interface for the sids.
133
+ const encMap: EncDomainMap | null =
134
+ opts.interface !== undefined
135
+ ? domainEncryptionMap(mod, opts.interface as { aggregates?: Record<string, InterfaceAggShape> })
136
+ : null;
137
+ const encPresent = encMap !== null;
138
+ /** The distinct scopes a read of `typeId` must inject keys for (empty ⇒ nothing encrypted on it). */
139
+ const readScopesFor = (typeId: string): string[] => {
140
+ const r = encMap?.reads?.[typeId];
141
+ return r === undefined ? [] : [...new Set(Object.values(r).map((x) => x.scope))].sort();
142
+ };
143
+ const keysArg = (typeId: string): string => {
144
+ const scopes = readScopesFor(typeId);
145
+ return scopes.length ? `, await _readKeys([${scopes.map((s) => dartStr(s)).join(", ")}])` : "";
146
+ };
147
+ // The DISTINCT encryption scopes this domain defines (across encrypted reads + sealing directives) — the
148
+ // set the owner ceremony rotates. Empty ⇒ no `encryption` getter is emitted for this domain.
149
+ const domScopes: string[] = (() => {
150
+ if (encMap === null) return [];
151
+ const s = new Set<string>();
152
+ for (const r of Object.values(encMap.reads)) for (const f of Object.values(r)) s.add(f.scope);
153
+ for (const dir of Object.values(encMap.directives)) for (const f of Object.values(dir)) s.add(f.scope);
154
+ return [...s].sort();
155
+ })();
156
+
130
157
  const methods: string[] = [];
131
158
 
132
159
  // ── directives → typed offer ──
@@ -148,6 +175,34 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
148
175
  const gateDoc = gate !== undefined
149
176
  ? ` /// GATED: needs an \`actor\` (the \`${gate.relation}\` relation) — throws MissingOfferAuthority otherwise.`
150
177
  : "";
178
+ // E2E SEALING (the sealing client): a directive with `.encrypted()` payload fields SEALS them via the
179
+ // bound [keyStore] before dispatch (a missing key throws [EncryptionKeyUnavailable] — never a plaintext
180
+ // write). One async block covers creates + mutate + gated + stamps; `seal: false` is the escape hatch.
181
+ if (encMap?.directives?.[d.id] !== undefined) {
182
+ const isCreates = (d as { marker?: string }).marker === "creates";
183
+ const retType = isCreates ? "NomosOfferOutcome" : "String";
184
+ const offerFn = isCreates ? "offerCreates" : "offerDirective";
185
+ methods.push(
186
+ ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
187
+ ` /// Encrypted field(s) are SEALED via the bound [keyStore] before dispatch (pass \`seal: false\` to skip).`,
188
+ ...(stamps.length > 0 ? [` /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at offer.`] : []),
189
+ ...(gateDoc ? [gateDoc] : []),
190
+ ` Future<${retType}> ${method}(${payloadClass} payload, {bool seal = true}) async {`,
191
+ ...(gateGuard ? [gateGuard] : []),
192
+ ` final data = payload.toPayloadJson();`,
193
+ ...(stamps.length > 0
194
+ ? [
195
+ ` final now = DateTime.now().toUtc().toIso8601String();`,
196
+ ...stamps.map((s) => ` data.putIfAbsent(${dartStr(s)}, () => now);`),
197
+ ]
198
+ : []),
199
+ ` await _sealPayload(data, ${dartStr(d.id)}, seal);`,
200
+ ` return bridge.${offerFn}(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
201
+ ` }`,
202
+ ``,
203
+ );
204
+ continue;
205
+ }
151
206
  if ((d as { marker?: string }).marker === "creates") {
152
207
  // LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
153
208
  // carries none — callers never supply ids). The bridge's offerCreates hands the minted ids back with the
@@ -213,9 +268,9 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
213
268
  const argMap = `{${q.key.map((k) => `${dartStr(k)}: ${dartKeyParam(k)}`).join(", ")}}`;
214
269
  const m = safeMethod(lcFirst(camel(q.id)));
215
270
  methods.push(
216
- ` /// Declared query \`${q.id}\` (indexed probe — routed by the read manifest).`,
271
+ ` /// Declared query \`${q.id}\` (indexed probe — routed by the read manifest).${readScopesFor(q.returns).length ? " Encrypted field(s) decrypt when the bound [keyStore] holds the scope key; else they stay opaque envelopes." : ""}`,
217
272
  ` Future<List<${model}>> ${m}({${params}}) async =>`,
218
- ` (await bridge.query(${dartStr(q.id)}, ${argMap})).map((r) => ${rowToModel(model, "r")}).toList();`,
273
+ ` (await bridge.query(${dartStr(q.id)}, ${argMap}${keysArg(q.returns)})).map((r) => ${rowToModel(model, "r")}).toList();`,
219
274
  ``,
220
275
  ` /// Reactive \`${q.id}\` — re-emits on every local change (cancel the subscription to stop).`,
221
276
  ` Stream<List<${model}>> watch${cap(camel(q.id))}({${params}}) =>`,
@@ -267,9 +322,9 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
267
322
  const lc = lcFirst(camel(agg.id));
268
323
  const Cap = cap(camel(agg.id));
269
324
  methods.push(
270
- ` /// Read one ${agg.id} by aggregate id.`,
325
+ ` /// Read one ${agg.id} by aggregate id.${readScopesFor(agg.id).length ? " Encrypted field(s) decrypt when the bound [keyStore] holds the scope key." : ""}`,
271
326
  ` Future<List<${model}>> ${safeMethod(`${lc}ById`)}(String id) async =>`,
272
- ` (await bridge.queryById(id)).map((r) => ${rowToModel(model, "r")}).toList();`,
327
+ ` (await bridge.queryById(id${keysArg(agg.id)})).map((r) => ${rowToModel(model, "r")}).toList();`,
273
328
  ``,
274
329
  ` /// Watch one ${agg.id} by aggregate id (local reactive read).`,
275
330
  ` Stream<List<${model}>> watch${Cap}ById(String id) =>`,
@@ -277,7 +332,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
277
332
  ``,
278
333
  ` /// Every ${agg.id} (the type-index probe — O(result), not a scan).`,
279
334
  ` Future<List<${model}>> ${safeMethod(`list${Cap}s`)}() async =>`,
280
- ` (await bridge.query('aggregatesByType', {'__type': ${dartStr(agg.id)}})).map((r) => ${rowToModel(model, "r")}).toList();`,
335
+ ` (await bridge.query('aggregatesByType', {'__type': ${dartStr(agg.id)}}${keysArg(agg.id)})).map((r) => ${rowToModel(model, "r")}).toList();`,
281
336
  ``,
282
337
  );
283
338
  }
@@ -305,7 +360,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
305
360
  `// client file gives an app the whole typed surface.`,
306
361
  `export '${mod.name}.dart';`,
307
362
  ...(opts.frameworkImportUri !== undefined ? [`export '${opts.frameworkImportUri}';`] : []),
308
- `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion;`,
363
+ `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion${encPresent ? ", NomosScopeKey, NomosKeyStore, EncryptionKeyUnavailable, WrappedKeyStore, NomosEncryption, NomosEpochResult, EncryptionMemberKeyMissing, EncryptionRosterMismatch" : ""};`,
309
364
  ``,
310
365
  `/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
311
366
  `const String ${hashConst} = ${dartStr(opts.domainHash)};`,
@@ -344,8 +399,30 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
344
399
  ` /// The verified principal uid every offer is authored as (sealed as \`security.actor\` by the one encoder).`,
345
400
  ` /// Null ⇒ open (un-gated) authoring; a gated directive then throws [MissingOfferAuthority].`,
346
401
  ` final String? actor;`,
347
- ` ${className}(this.bridge, {this.domainHash = ${hashConst}, this.actor});`,
402
+ ...(encPresent
403
+ ? [
404
+ ``,
405
+ ` /// THE KEY CUSTODY surface (E2E sealing). When set, encrypted directive fields SEAL automatically`,
406
+ ` /// before dispatch and encrypted read fields DECRYPT automatically; null ⇒ ciphertext passes through`,
407
+ ` /// unchanged (today's behavior). See [WrappedKeyStore] for the default over the on-chain WrappedKeys.`,
408
+ ` final NomosKeyStore? keyStore;`,
409
+ ]
410
+ : []),
411
+ ` ${className}(this.bridge, {this.domainHash = ${hashConst}, this.actor${encPresent ? ", this.keyStore" : ""}});`,
348
412
  ``,
413
+ ...(encPresent ? dartEncClassMembers(encMap!) : []),
414
+ // THE OWNER CEREMONY (slice 2): grantAndWrap / revokeAndRotate / rotate — the operator-blind key-epoch
415
+ // handshake as one call. Bind `actor` (the owner) to author the fan-out. Only for a domain with a scope.
416
+ ...(domScopes.length > 0
417
+ ? [
418
+ ` /// OWNER CEREMONY (slice 2): \`grantAndWrap\` / \`revokeAndRotate\` / \`rotate\` — the operator-blind`,
419
+ ` /// key-epoch handshake as one call. Bind \`actor\` (the owner) to author the fan-out.`,
420
+ ` NomosEncryption get encryption => NomosEncryption(`,
421
+ ` bridge: bridge, domain: ${dartStr(domain)}, domainHash: domainHash, actor: actor,`,
422
+ ` scopes: const [${domScopes.map((s) => dartStr(s)).join(", ")}]);`,
423
+ ``,
424
+ ]
425
+ : []),
349
426
  ...(opts.interface !== undefined
350
427
  ? [
351
428
  ` /// This client's structural interface requirement (#72) as JSON — hand it to the`,
@@ -370,3 +447,42 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
370
447
  ``,
371
448
  ].join("\n");
372
449
  }
450
+
451
+ /** The in-class E2E-sealing members: the baked map + the seal/read private helpers. */
452
+ function dartEncClassMembers(encMap: EncDomainMap): string[] {
453
+ return [
454
+ ` /// The baked E2E-sealing map (compile-time, from the SAME sids the law was compiled with): which`,
455
+ ` /// directive payload fields seal → { scope, aggSid, fieldSid }, and which read-model fields decrypt.`,
456
+ ` static final Map<String, dynamic> encryptionMap = (jsonDecode(${dartRawJson(encMap)}) as Map).cast<String, dynamic>();`,
457
+ ``,
458
+ ` /// Seal the mapped encrypted fields of \`data\` in place before dispatch. No keyStore / \`seal == false\``,
459
+ ` /// ⇒ passthrough (today's behavior); a held key seals; a missing key throws [EncryptionKeyUnavailable].`,
460
+ ` Future<void> _sealPayload(Map<String, dynamic> data, String directiveId, bool seal) async {`,
461
+ ` if (!seal || keyStore == null) return;`,
462
+ ` final fields = (encryptionMap['directives'] as Map)[directiveId] as Map?;`,
463
+ ` if (fields == null) return;`,
464
+ ` for (final entry in fields.entries) {`,
465
+ ` final field = entry.key as String;`,
466
+ ` final m = entry.value as Map;`,
467
+ ` if (data[field] == null) continue;`,
468
+ ` final scope = m['scope'] as String;`,
469
+ ` final sk = await keyStore!.scopeKey(scope);`,
470
+ ` if (sk == null) throw EncryptionKeyUnavailable(scope, field);`,
471
+ ` data[field] = await bridge.cryptoSealField(`,
472
+ ` scopeKey: sk.key, scope: scope, epoch: sk.epoch,`,
473
+ ` aggSid: m['aggSid'] as String, fieldSid: m['fieldSid'] as String,`,
474
+ ` plaintext: data[field].toString(),`,
475
+ ` );`,
476
+ ` }`,
477
+ ` }`,
478
+ ``,
479
+ ` /// The read keys to inject for \`scopes\`, or null when there is nothing to inject (no keyStore / no`,
480
+ ` /// held keys) — the read then behaves exactly as today (encrypted fields stay opaque envelopes).`,
481
+ ` Future<List<NomosScopeKey>?> _readKeys(List<String> scopes) async {`,
482
+ ` if (keyStore == null || scopes.isEmpty) return null;`,
483
+ ` final keys = await keyStore!.readKeys(scopes);`,
484
+ ` return keys.isEmpty ? null : keys;`,
485
+ ` }`,
486
+ ``,
487
+ ];
488
+ }
@@ -1384,7 +1384,7 @@ export interface ChildBirthParentSetup {
1384
1384
  /** A stub `nomos` global (mint + sha256) so a `.births()` plan that mints / hashes a child name runs to
1385
1385
  * completion at codegen. The hash is a deterministic STUB (the codegen never needs the real digest — the
1386
1386
  * recipe's workspace is read at RUNTIME from the offer's `born`); mint mirrors `harvestPrincipalFlows`. */
1387
- function withBirthProbeGlobals<T>(run: () => T): T {
1387
+ export function withBirthProbeGlobals<T>(run: () => T): T {
1388
1388
  const g = globalThis as unknown as { nomos?: { mint?: (t: string) => string; sha256?: (s: string) => string; read?: unknown } };
1389
1389
  const had = "nomos" in g;
1390
1390
  const prior = g.nomos;