@githolon/dsl 0.49.0 → 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
+ }
@@ -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].
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.49.0",
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"
@@ -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();
@@ -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,24 @@ 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
+
130
148
  const methods: string[] = [];
131
149
 
132
150
  // ── directives → typed offer ──
@@ -148,6 +166,34 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
148
166
  const gateDoc = gate !== undefined
149
167
  ? ` /// GATED: needs an \`actor\` (the \`${gate.relation}\` relation) — throws MissingOfferAuthority otherwise.`
150
168
  : "";
169
+ // E2E SEALING (the sealing client): a directive with `.encrypted()` payload fields SEALS them via the
170
+ // bound [keyStore] before dispatch (a missing key throws [EncryptionKeyUnavailable] — never a plaintext
171
+ // write). One async block covers creates + mutate + gated + stamps; `seal: false` is the escape hatch.
172
+ if (encMap?.directives?.[d.id] !== undefined) {
173
+ const isCreates = (d as { marker?: string }).marker === "creates";
174
+ const retType = isCreates ? "NomosOfferOutcome" : "String";
175
+ const offerFn = isCreates ? "offerCreates" : "offerDirective";
176
+ methods.push(
177
+ ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
178
+ ` /// Encrypted field(s) are SEALED via the bound [keyStore] before dispatch (pass \`seal: false\` to skip).`,
179
+ ...(stamps.length > 0 ? [` /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at offer.`] : []),
180
+ ...(gateDoc ? [gateDoc] : []),
181
+ ` Future<${retType}> ${method}(${payloadClass} payload, {bool seal = true}) async {`,
182
+ ...(gateGuard ? [gateGuard] : []),
183
+ ` final data = payload.toPayloadJson();`,
184
+ ...(stamps.length > 0
185
+ ? [
186
+ ` final now = DateTime.now().toUtc().toIso8601String();`,
187
+ ...stamps.map((s) => ` data.putIfAbsent(${dartStr(s)}, () => now);`),
188
+ ]
189
+ : []),
190
+ ` await _sealPayload(data, ${dartStr(d.id)}, seal);`,
191
+ ` return bridge.${offerFn}(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
192
+ ` }`,
193
+ ``,
194
+ );
195
+ continue;
196
+ }
151
197
  if ((d as { marker?: string }).marker === "creates") {
152
198
  // LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
153
199
  // carries none — callers never supply ids). The bridge's offerCreates hands the minted ids back with the
@@ -213,9 +259,9 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
213
259
  const argMap = `{${q.key.map((k) => `${dartStr(k)}: ${dartKeyParam(k)}`).join(", ")}}`;
214
260
  const m = safeMethod(lcFirst(camel(q.id)));
215
261
  methods.push(
216
- ` /// Declared query \`${q.id}\` (indexed probe — routed by the read manifest).`,
262
+ ` /// 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
263
  ` Future<List<${model}>> ${m}({${params}}) async =>`,
218
- ` (await bridge.query(${dartStr(q.id)}, ${argMap})).map((r) => ${rowToModel(model, "r")}).toList();`,
264
+ ` (await bridge.query(${dartStr(q.id)}, ${argMap}${keysArg(q.returns)})).map((r) => ${rowToModel(model, "r")}).toList();`,
219
265
  ``,
220
266
  ` /// Reactive \`${q.id}\` — re-emits on every local change (cancel the subscription to stop).`,
221
267
  ` Stream<List<${model}>> watch${cap(camel(q.id))}({${params}}) =>`,
@@ -267,9 +313,9 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
267
313
  const lc = lcFirst(camel(agg.id));
268
314
  const Cap = cap(camel(agg.id));
269
315
  methods.push(
270
- ` /// Read one ${agg.id} by aggregate id.`,
316
+ ` /// Read one ${agg.id} by aggregate id.${readScopesFor(agg.id).length ? " Encrypted field(s) decrypt when the bound [keyStore] holds the scope key." : ""}`,
271
317
  ` Future<List<${model}>> ${safeMethod(`${lc}ById`)}(String id) async =>`,
272
- ` (await bridge.queryById(id)).map((r) => ${rowToModel(model, "r")}).toList();`,
318
+ ` (await bridge.queryById(id${keysArg(agg.id)})).map((r) => ${rowToModel(model, "r")}).toList();`,
273
319
  ``,
274
320
  ` /// Watch one ${agg.id} by aggregate id (local reactive read).`,
275
321
  ` Stream<List<${model}>> watch${Cap}ById(String id) =>`,
@@ -277,7 +323,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
277
323
  ``,
278
324
  ` /// Every ${agg.id} (the type-index probe — O(result), not a scan).`,
279
325
  ` Future<List<${model}>> ${safeMethod(`list${Cap}s`)}() async =>`,
280
- ` (await bridge.query('aggregatesByType', {'__type': ${dartStr(agg.id)}})).map((r) => ${rowToModel(model, "r")}).toList();`,
326
+ ` (await bridge.query('aggregatesByType', {'__type': ${dartStr(agg.id)}}${keysArg(agg.id)})).map((r) => ${rowToModel(model, "r")}).toList();`,
281
327
  ``,
282
328
  );
283
329
  }
@@ -305,7 +351,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
305
351
  `// client file gives an app the whole typed surface.`,
306
352
  `export '${mod.name}.dart';`,
307
353
  ...(opts.frameworkImportUri !== undefined ? [`export '${opts.frameworkImportUri}';`] : []),
308
- `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion;`,
354
+ `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion${encPresent ? ", NomosScopeKey, NomosKeyStore, EncryptionKeyUnavailable, WrappedKeyStore" : ""};`,
309
355
  ``,
310
356
  `/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
311
357
  `const String ${hashConst} = ${dartStr(opts.domainHash)};`,
@@ -344,8 +390,18 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
344
390
  ` /// The verified principal uid every offer is authored as (sealed as \`security.actor\` by the one encoder).`,
345
391
  ` /// Null ⇒ open (un-gated) authoring; a gated directive then throws [MissingOfferAuthority].`,
346
392
  ` final String? actor;`,
347
- ` ${className}(this.bridge, {this.domainHash = ${hashConst}, this.actor});`,
393
+ ...(encPresent
394
+ ? [
395
+ ``,
396
+ ` /// THE KEY CUSTODY surface (E2E sealing). When set, encrypted directive fields SEAL automatically`,
397
+ ` /// before dispatch and encrypted read fields DECRYPT automatically; null ⇒ ciphertext passes through`,
398
+ ` /// unchanged (today's behavior). See [WrappedKeyStore] for the default over the on-chain WrappedKeys.`,
399
+ ` final NomosKeyStore? keyStore;`,
400
+ ]
401
+ : []),
402
+ ` ${className}(this.bridge, {this.domainHash = ${hashConst}, this.actor${encPresent ? ", this.keyStore" : ""}});`,
348
403
  ``,
404
+ ...(encPresent ? dartEncClassMembers(encMap!) : []),
349
405
  ...(opts.interface !== undefined
350
406
  ? [
351
407
  ` /// This client's structural interface requirement (#72) as JSON — hand it to the`,
@@ -370,3 +426,42 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
370
426
  ``,
371
427
  ].join("\n");
372
428
  }
429
+
430
+ /** The in-class E2E-sealing members: the baked map + the seal/read private helpers. */
431
+ function dartEncClassMembers(encMap: EncDomainMap): string[] {
432
+ return [
433
+ ` /// The baked E2E-sealing map (compile-time, from the SAME sids the law was compiled with): which`,
434
+ ` /// directive payload fields seal → { scope, aggSid, fieldSid }, and which read-model fields decrypt.`,
435
+ ` static final Map<String, dynamic> encryptionMap = (jsonDecode(${dartRawJson(encMap)}) as Map).cast<String, dynamic>();`,
436
+ ``,
437
+ ` /// Seal the mapped encrypted fields of \`data\` in place before dispatch. No keyStore / \`seal == false\``,
438
+ ` /// ⇒ passthrough (today's behavior); a held key seals; a missing key throws [EncryptionKeyUnavailable].`,
439
+ ` Future<void> _sealPayload(Map<String, dynamic> data, String directiveId, bool seal) async {`,
440
+ ` if (!seal || keyStore == null) return;`,
441
+ ` final fields = (encryptionMap['directives'] as Map)[directiveId] as Map?;`,
442
+ ` if (fields == null) return;`,
443
+ ` for (final entry in fields.entries) {`,
444
+ ` final field = entry.key as String;`,
445
+ ` final m = entry.value as Map;`,
446
+ ` if (data[field] == null) continue;`,
447
+ ` final scope = m['scope'] as String;`,
448
+ ` final sk = await keyStore!.scopeKey(scope);`,
449
+ ` if (sk == null) throw EncryptionKeyUnavailable(scope, field);`,
450
+ ` data[field] = await bridge.cryptoSealField(`,
451
+ ` scopeKey: sk.key, scope: scope, epoch: sk.epoch,`,
452
+ ` aggSid: m['aggSid'] as String, fieldSid: m['fieldSid'] as String,`,
453
+ ` plaintext: data[field].toString(),`,
454
+ ` );`,
455
+ ` }`,
456
+ ` }`,
457
+ ``,
458
+ ` /// The read keys to inject for \`scopes\`, or null when there is nothing to inject (no keyStore / no`,
459
+ ` /// held keys) — the read then behaves exactly as today (encrypted fields stay opaque envelopes).`,
460
+ ` Future<List<NomosScopeKey>?> _readKeys(List<String> scopes) async {`,
461
+ ` if (keyStore == null || scopes.isEmpty) return null;`,
462
+ ` final keys = await keyStore!.readKeys(scopes);`,
463
+ ` return keys.isEmpty ? null : keys;`,
464
+ ` }`,
465
+ ``,
466
+ ];
467
+ }
@@ -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;