@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.
@@ -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
+ }
@@ -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 {
@@ -1357,7 +1384,7 @@ export interface ChildBirthParentSetup {
1357
1384
  /** A stub `nomos` global (mint + sha256) so a `.births()` plan that mints / hashes a child name runs to
1358
1385
  * completion at codegen. The hash is a deterministic STUB (the codegen never needs the real digest — the
1359
1386
  * recipe's workspace is read at RUNTIME from the offer's `born`); mint mirrors `harvestPrincipalFlows`. */
1360
- function withBirthProbeGlobals<T>(run: () => T): T {
1387
+ export function withBirthProbeGlobals<T>(run: () => T): T {
1361
1388
  const g = globalThis as unknown as { nomos?: { mint?: (t: string) => string; sha256?: (s: string) => string; read?: unknown } };
1362
1389
  const had = "nomos" in g;
1363
1390
  const prior = g.nomos;
package/src/codegen_ts.ts CHANGED
@@ -72,6 +72,118 @@ export const interfaceConstName = (packageName: string): string => `${upperSnake
72
72
  /** `tool-library` → `TOOL_LIBRARY_COMPAT` — the baked client/law compat manifest (§5). */
73
73
  export const compatConstName = (packageName: string): string => `${upperSnake(packageName)}_COMPAT`;
74
74
 
75
+ /** `tool-library` → `TOOL_LIBRARY_ENCRYPTION` — the baked E2E-sealing map (the sealing client). */
76
+ export const encryptionConstName = (packageName: string): string => `${upperSnake(packageName)}_ENCRYPTION`;
77
+
78
+ // ── E2E SEALING MAP (the sealing client) ──────────────────────────────────────────
79
+ // The compiler already knows everything the sealing plumbing needs: which fields are
80
+ // `.encrypted()` (`agg.encryptedFields`), their read/encryption SCOPE (`agg.visibility.requires`,
81
+ // the `requires(cap)` that IS the scope), and their #58 STABLE SIDS (via the baked structural
82
+ // interface — sid-keyed, carrying each field's label). This map is baked into the generated
83
+ // client like the domainHash — never fetched at runtime — so a sealing write is byte-identical
84
+ // to a hand-plumbed one. ADDITIVE: a domain with no encrypted field contributes NOTHING (the
85
+ // map is null and no const/helper/keyStore surface is emitted — byte-identical generated client).
86
+
87
+ /** One encrypted field's seal coordinates: the scope cap + the stable (agg,field) sids. */
88
+ export interface EncFieldMap {
89
+ readonly scope: string;
90
+ readonly aggSid: string;
91
+ readonly fieldSid: string;
92
+ }
93
+ /** Per-domain: which directive payload fields seal, and which read-model fields decrypt. */
94
+ export interface EncDomainMap {
95
+ /** directiveId → (payload field → seal coordinates). */
96
+ readonly directives: Record<string, Record<string, EncFieldMap>>;
97
+ /** aggregate type id → (field → { scope }) — the fields a read decrypts under `scope`. */
98
+ readonly reads: Record<string, Record<string, { scope: string }>>;
99
+ }
100
+
101
+ /** The sid-keyed read shape of ONE domain's interface (mirrors `shape_compat.ts` DomainInterface). */
102
+ export interface InterfaceAggShape {
103
+ readonly sid: string;
104
+ readonly label: string;
105
+ readonly fields: Record<string, { kind: string; label: string }>;
106
+ }
107
+
108
+ /** Resolve (aggLabel, fieldLabel) → their STABLE SIDS from the baked domain interface. The seal binds
109
+ * the ciphertext to these sids so a rename of the type/field never breaks decryption (#58). */
110
+ function resolveSids(
111
+ domainIface: { aggregates?: Record<string, InterfaceAggShape> } | undefined,
112
+ aggLabel: string,
113
+ fieldLabel: string,
114
+ ): { aggSid: string; fieldSid: string } | undefined {
115
+ const aggs = domainIface?.aggregates;
116
+ if (aggs === undefined) return undefined;
117
+ for (const [aggSid, a] of Object.entries(aggs)) {
118
+ if (a.label !== aggLabel) continue;
119
+ for (const [fieldSid, f] of Object.entries(a.fields ?? {})) {
120
+ if (f.label === fieldLabel) return { aggSid: a.sid ?? aggSid, fieldSid };
121
+ }
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ /** Build ONE domain's E2E-sealing map, or `null` when no field is `.encrypted()`. Resolves sids from the
127
+ * domain's baked interface — REQUIRES it when any field is encrypted (an encrypted aggregate is always
128
+ * private, so the interface is present). Shared by the TS + Dart client generators (one source of truth). */
129
+ export function domainEncryptionMap(
130
+ mod: DomainModule,
131
+ domainIface: { aggregates?: Record<string, InterfaceAggShape> } | undefined,
132
+ ): EncDomainMap | null {
133
+ const domain = mod.domain ?? mod.name;
134
+ const aggs = mod.aggregates as AggregateHandle[];
135
+ const directives: Record<string, Record<string, EncFieldMap>> = {};
136
+ const reads: Record<string, Record<string, { scope: string }>> = {};
137
+ for (const agg of aggs) {
138
+ const scope = agg.visibility?.requires;
139
+ const encFields = agg.encryptedFields;
140
+ if (encFields === undefined || encFields.length === 0 || scope === undefined) continue;
141
+ const r: Record<string, { scope: string }> = {};
142
+ for (const f of encFields) r[f] = { scope };
143
+ reads[agg.id] = r;
144
+ }
145
+ for (const d of mod.directives as Directive<unknown>[]) {
146
+ const aggId = (d as { aggregateId?: string }).aggregateId;
147
+ const agg = aggs.find((a) => a.id === aggId);
148
+ const scope = agg?.visibility?.requires;
149
+ const encFields = agg?.encryptedFields;
150
+ if (agg === undefined || encFields === undefined || encFields.length === 0 || scope === undefined) continue;
151
+ const schema = (d as { payloadSchema?: unknown }).payloadSchema;
152
+ if (typeof schema !== "object" || schema === null || zodKind(schema as z.ZodTypeAny) !== "object") continue;
153
+ const shapeKeys = new Set(Object.keys(zodObjectShape(schema as z.ZodTypeAny)));
154
+ const fmap: Record<string, EncFieldMap> = {};
155
+ for (const f of encFields) {
156
+ if (!shapeKeys.has(f)) continue; // the directive doesn't carry this field on its payload
157
+ const sids = resolveSids(domainIface, agg.id, f);
158
+ if (sids === undefined) {
159
+ throw new Error(
160
+ `codegen: cannot resolve stable ids for encrypted field ${agg.id}.${f} in domain '${domain}' — ` +
161
+ `the package structural interface (with stable ids) is required to bake the E2E-sealing map`,
162
+ );
163
+ }
164
+ fmap[f] = { scope, aggSid: sids.aggSid, fieldSid: sids.fieldSid };
165
+ }
166
+ if (Object.keys(fmap).length > 0) directives[d.id] = fmap;
167
+ }
168
+ return Object.keys(directives).length > 0 || Object.keys(reads).length > 0 ? { directives, reads } : null;
169
+ }
170
+
171
+ /** Build the package's E2E-sealing map keyed by domain, or `null` when no field is `.encrypted()`
172
+ * anywhere (the additive-zero case). */
173
+ function buildEncryptionMap(
174
+ modules: readonly DomainModule[],
175
+ ifaceByDomain: Record<string, { aggregates?: Record<string, InterfaceAggShape> }> | undefined,
176
+ ): Record<string, EncDomainMap> | null {
177
+ const out: Record<string, EncDomainMap> = {};
178
+ let any = false;
179
+ for (const mod of modules) {
180
+ const domain = mod.domain ?? mod.name;
181
+ const m = domainEncryptionMap(mod, ifaceByDomain?.[domain]);
182
+ if (m !== null) { out[domain] = m; any = true; }
183
+ }
184
+ return any ? out : null;
185
+ }
186
+
75
187
  // ── zod → TS (the same `_def ?? def` convention as codegen_dart's walker) ─────────
76
188
  // The walkers are exported: codegen_proof.ts synthesizes SAMPLE payloads from the
77
189
  // same zod shapes this file derives TYPES from — one introspection, two emitters.
@@ -405,6 +517,14 @@ function anyPackedTaxonomy(modules: readonly DomainModule[]): boolean {
405
517
 
406
518
  export function generateTsClient(modules: readonly DomainModule[], opts: TsClientOptions): string {
407
519
  const hashConst = tsHashConstName(opts.packageName);
520
+ // THE E2E-SEALING MAP (the sealing client): non-null iff any field is `.encrypted()`. When null,
521
+ // NOTHING below emits — the generated client is byte-identical to a pre-sealing compile.
522
+ const encMap = buildEncryptionMap(
523
+ modules,
524
+ opts.interface as Record<string, { aggregates?: Record<string, InterfaceAggShape> }> | undefined,
525
+ );
526
+ const encPresent = encMap !== null;
527
+ const encConst = encryptionConstName(opts.packageName);
408
528
  const out: string[] = [];
409
529
  out.push(
410
530
  `// AUTO-GENERATED by nomos-compile — typed TS client for package "${opts.packageName}".`,
@@ -450,8 +570,23 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
450
570
  ` offerDirective(domain: string, directiveId: string, payload: unknown, opts?: { domainHash?: string; actor?: string; returnCreated?: boolean }): Promise<string>;`,
451
571
  ` /** Opaque-bytes offer — retained for carriers that already hold an encoded envelope (back-compat). */`,
452
572
  ` offer(intentBytes: string | Uint8Array): Promise<string>;`,
453
- ` query(queryId: string, params?: Record<string, unknown>): Promise<unknown>;`,
454
- ` queryById(aggregateId: string): Promise<unknown>;`,
573
+ ...(encPresent
574
+ ? [
575
+ ` /** \`keys\` (E2E sealing): the reader's held scope keys, injected so encrypted fields decrypt;`,
576
+ ` * absent ⇒ encrypted fields stay opaque envelopes (today's behavior). */`,
577
+ ` query(queryId: string, params?: Record<string, unknown>, keys?: readonly NomosScopeKey[]): Promise<unknown>;`,
578
+ ` queryById(aggregateId: string, keys?: readonly NomosScopeKey[]): Promise<unknown>;`,
579
+ ` /** THE AUTHOR-SIDE SEAL: encrypt a plaintext field value into its on-chain ciphertext envelope`,
580
+ ` * (bound to the scope + stable sids). OPTIONAL: structural binding tolerates a pre-sealing holon`,
581
+ ` * (the write path then throws with an upgrade hint before any plaintext dispatch). */`,
582
+ ` cryptoSealField?(args: { scopeKey: string; scope: string; epoch: number; aggSid: string; fieldSid: string; plaintext: string }): Promise<string>;`,
583
+ ` /** Unwrap an on-chain WrappedKey with this device's secret → the base64 scope key (default KeyStore). */`,
584
+ ` cryptoUnwrapKey?(args: { secret: string; hpkeEpk: string; ct: string }): Promise<string>;`,
585
+ ]
586
+ : [
587
+ ` query(queryId: string, params?: Record<string, unknown>): Promise<unknown>;`,
588
+ ` queryById(aggregateId: string): Promise<unknown>;`,
589
+ ]),
455
590
  ` watchById(aggregateId: string, cb: (rows: unknown) => void, opts?: { intervalMs?: number }): () => void;`,
456
591
  ` /** Declared-count / declared-sum O(1) reads (maintained by the read engine). */`,
457
592
  ` count(countId: string, groupKey?: string): Promise<number>;`,
@@ -489,6 +624,106 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
489
624
  ``,
490
625
  );
491
626
 
627
+ if (encPresent) {
628
+ out.push(
629
+ `// ── E2E SEALING (the sealing client) — baked map + KeyStore + seal/read helpers ──`,
630
+ `// The map below is baked at COMPILE time from the SAME descriptors + stable-id lockfile the law was`,
631
+ `// compiled with (never fetched). A sealing write is byte-indistinguishable from a hand-plumbed one.`,
632
+ ``,
633
+ `/** Per directive: which payload fields seal → their { scope, aggSid, fieldSid }. Per aggregate: which`,
634
+ ` * read-model fields are encrypted (+ their scope). Keyed by domain. */`,
635
+ `export const ${encConst} = ${JSON.stringify(encMap)} as const;`,
636
+ ``,
637
+ `/** A held scope key: the base64 symmetric key + the epoch it belongs to (forward-only rotation). */`,
638
+ `export interface NomosScopeKey { scope: string; epoch: number; key: string }`,
639
+ ``,
640
+ `/** The app-supplied KEY CUSTODY surface. Supply one to \`<domain>Client(holon, { keyStore })\` and the`,
641
+ ` * generated methods seal writes + decrypt reads automatically. \`wrappedKeyStore\` is the default over`,
642
+ ` * the on-chain WrappedKeys; an app may implement this to source keys from a secure enclave. */`,
643
+ `export interface NomosKeyStore {`,
644
+ ` /** The CURRENT (highest-epoch) scope key, for SEALING a write. undefined ⇒ EncryptionKeyUnavailable. */`,
645
+ ` scopeKey(scope: string): Promise<NomosScopeKey | undefined>;`,
646
+ ` /** EVERY held key for these scopes (all epochs), injected on reads so old ciphertext also decrypts. */`,
647
+ ` readKeys(scopes: readonly string[]): Promise<NomosScopeKey[]>;`,
648
+ `}`,
649
+ ``,
650
+ `/** Thrown when a write must seal an encrypted field but the KeyStore holds no key for its scope — the`,
651
+ ` * write NEVER falls through to a plaintext dispatch (the sid-envelope gate would refuse it anyway). */`,
652
+ `export class EncryptionKeyUnavailable extends Error {`,
653
+ ` readonly code = "nomos.crypto.key_unavailable";`,
654
+ ` readonly recommendedAction = "obtainScopeKey";`,
655
+ ` constructor(readonly scope: string, readonly field: string) {`,
656
+ " super(`cannot seal encrypted field \"${field}\": no scope key held for \"${scope}\" — obtain it (be granted the cap + wrapped the scope epoch) before writing`);",
657
+ ` this.name = "EncryptionKeyUnavailable";`,
658
+ ` }`,
659
+ `}`,
660
+ ``,
661
+ `/** Seal the mapped encrypted fields of a payload before dispatch. No keyStore / \`seal:false\` ⇒ passthrough`,
662
+ ` * (today's behavior). A held key seals; a missing key throws EncryptionKeyUnavailable (never plaintext). */`,
663
+ `const _sealFields = async (`,
664
+ ` holon: NomosHolon,`,
665
+ ` keyStore: NomosKeyStore | undefined,`,
666
+ ` fieldMap: Record<string, { scope: string; aggSid: string; fieldSid: string }>,`,
667
+ ` payload: Record<string, unknown>,`,
668
+ ` seal: boolean,`,
669
+ `): Promise<Record<string, unknown>> => {`,
670
+ ` if (seal === false || keyStore == null) return payload;`,
671
+ ` let out: Record<string, unknown> | undefined;`,
672
+ ` for (const field of Object.keys(fieldMap)) {`,
673
+ ` const v = payload[field];`,
674
+ ` if (v === undefined || v === null) continue;`,
675
+ ` const m = fieldMap[field]!;`,
676
+ ` const sk = await keyStore.scopeKey(m.scope);`,
677
+ ` if (!sk) throw new EncryptionKeyUnavailable(m.scope, field);`,
678
+ ` if (!holon.cryptoSealField) throw new Error("this holon predates E2E sealing — upgrade @githolon/client");`,
679
+ ` const envelope = await holon.cryptoSealField({ scopeKey: sk.key, scope: m.scope, epoch: sk.epoch, aggSid: m.aggSid, fieldSid: m.fieldSid, plaintext: String(v) });`,
680
+ ` (out ??= { ...payload })[field] = envelope;`,
681
+ ` }`,
682
+ ` return out ?? payload;`,
683
+ `};`,
684
+ ``,
685
+ `/** The read keys to inject for the given scopes, or undefined when there is nothing to inject`,
686
+ ` * (no keyStore, or no held keys) — the read then behaves exactly as today (opaque envelopes). */`,
687
+ `const _readKeys = async (keyStore: NomosKeyStore | undefined, scopes: readonly string[]): Promise<NomosScopeKey[] | undefined> => {`,
688
+ ` if (keyStore == null || scopes.length === 0) return undefined;`,
689
+ ` const keys = await keyStore.readKeys(scopes);`,
690
+ ` return keys.length ? keys : undefined;`,
691
+ `};`,
692
+ ``,
693
+ `/** THE DEFAULT KeyStore — unwraps this principal's on-chain WrappedKeys with its device secret, caching`,
694
+ ` * per scope. Mirror of the documented reader handshake (e2e_encryption.md): the secret stays client-side,`,
695
+ ` * the operator stays blind. Key custody (where \`deviceSecret\` lives) remains the app's concern. */`,
696
+ `export function wrappedKeyStore(opts: { holon: NomosHolon; principal: string; deviceSecret: string }): NomosKeyStore {`,
697
+ ` const { holon, principal, deviceSecret } = opts;`,
698
+ ` const cache = new Map<string, Promise<NomosScopeKey[]>>();`,
699
+ ` const loadScope = (scope: string): Promise<NomosScopeKey[]> => {`,
700
+ ` let p = cache.get(scope);`,
701
+ ` if (p !== undefined) return p;`,
702
+ ` p = (async () => {`,
703
+ ` const rows = (await holon.query("wrappedKeysByRecipient", { recipientPrincipal: principal })) as { data?: Record<string, unknown> }[];`,
704
+ ` const held: NomosScopeKey[] = [];`,
705
+ ` for (const row of rows ?? []) {`,
706
+ ` const d = (row.data ?? row) as Record<string, unknown>;`,
707
+ ` if (String(d.scope) !== scope) continue;`,
708
+ ` if (!holon.cryptoUnwrapKey) throw new Error("this holon predates E2E unwrap — upgrade @githolon/client");`,
709
+ ` const key = await holon.cryptoUnwrapKey({ secret: deviceSecret, hpkeEpk: String(d.hpkeEpk), ct: String(d.ct) });`,
710
+ ` held.push({ scope, epoch: Number(d.epoch) || 0, key });`,
711
+ ` }`,
712
+ ` held.sort((a, b) => b.epoch - a.epoch);`,
713
+ ` return held;`,
714
+ ` })();`,
715
+ ` cache.set(scope, p);`,
716
+ ` return p;`,
717
+ ` };`,
718
+ ` return {`,
719
+ ` async scopeKey(scope) { return (await loadScope(scope))[0]; },`,
720
+ ` async readKeys(scopes) { const all: NomosScopeKey[] = []; for (const s of scopes) all.push(...(await loadScope(s))); return all; },`,
721
+ ` };`,
722
+ `}`,
723
+ ``,
724
+ );
725
+ }
726
+
492
727
  const anyAutoStamp = modules.some((mod) =>
493
728
  (mod.directives as Directive<unknown>[]).some(
494
729
  (d) =>
@@ -769,6 +1004,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
769
1004
  } catch { /* the compile gate already failed a bad taxonomy loudly */ }
770
1005
  }
771
1006
  const methods: string[] = [];
1007
+ // E2E READ INJECTION: the distinct encryption scopes a read of `typeId` must inject keys for (empty
1008
+ // ⇒ nothing encrypted on that type ⇒ the accessor is emitted plain, unchanged). The `_readKeys` helper
1009
+ // returns undefined when no keyStore is bound, so an injected accessor still behaves as today offline.
1010
+ const readScopesFor = (typeId: string): string[] => {
1011
+ const r = encMap?.[domain]?.reads?.[typeId];
1012
+ return r === undefined ? [] : [...new Set(Object.values(r).map((x) => x.scope))].sort();
1013
+ };
772
1014
  for (const d of dirs) {
773
1015
  let payloadName = `${cap(d.id)}Payload`;
774
1016
  if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
@@ -784,6 +1026,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
784
1026
  const gateGuard = gate !== undefined
785
1027
  ? ` if (!actor || !actor.trim()) throw new MissingOfferAuthority(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${JSON.stringify(gate.relation)});\n`
786
1028
  : "";
1029
+ // E2E SEALING (the sealing client): a directive with `.encrypted()` payload fields seals them
1030
+ // (via the KeyStore) BEFORE dispatch. `wantSeal` forces an async block body + a `callOpts.seal`
1031
+ // escape hatch, and `pv` (the sealed copy) replaces `payload` downstream. Non-encrypted directives
1032
+ // are byte-identical (wantSeal === false ⇒ no prelude, no extra param, plain `payload`).
1033
+ const wantSeal = encMap?.[domain]?.directives?.[d.id] !== undefined;
1034
+ const paramSig = wantSeal ? `payload: ${payloadName}, callOpts?: { seal?: boolean }` : `payload: ${payloadName}`;
1035
+ const sealPrelude = wantSeal
1036
+ ? ` const _sealed = await _sealFields(holon, keyStore, ${encConst}[${JSON.stringify(domain)}].directives[${JSON.stringify(d.id)}] as Record<string, { scope: string; aggSid: string; fieldSid: string }>, payload as Record<string, unknown>, callOpts?.seal !== false) as ${payloadName};`
1037
+ : "";
1038
+ const pv = wantSeal ? "_sealed" : "payload";
787
1039
  out.push(
788
1040
  `/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema).${stamps.length ? ` Omitted ${stamps.map((s) => `\`${s}\``).join("/")} auto-stamp(s) ISO now() at offer.` : ""}${mint ? ` Omitted \`${mint.field}\` is KERNEL-MINTED at offer (returned on the result).` : ""}${placed.length ? " Omitted `shard` is picked LEAST-LOADED at offer (the routed holon)." : ""} */`,
789
1041
  `export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed)};`,
@@ -797,13 +1049,14 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
797
1049
  ? `, { tagHex: routeTagOfId(payload.${mint.tagFromIdField}) }`
798
1050
  : "";
799
1051
  const body = stamps.length
800
- ? `stampNow({ ...payload, ${mint.field} }, ${JSON.stringify(stamps)})`
801
- : `{ ...payload, ${mint.field} }`;
1052
+ ? `stampNow({ ...${pv}, ${mint.field} }, ${JSON.stringify(stamps)})`
1053
+ : `{ ...${pv}, ${mint.field} }`;
802
1054
  methods.push(
803
1055
  ` /** Author \`${domain}/${d.id}\`: the front-door MINTS \`${mint.field}\` (route-tagged) when omitted`,
804
1056
  ` * — ids stay internal plumbing — and returns it with the new local head.${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
805
- ` ${d.id}: async (payload: ${payloadName}): Promise<{ head: string; ${mint.field}: string }> => {`,
1057
+ ` ${d.id}: async (${paramSig}): Promise<{ head: string; ${mint.field}: string }> => {`,
806
1058
  ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
1059
+ ...(sealPrelude ? [sealPrelude] : []),
807
1060
  ` const ${mint.field} = payload.${mint.field} ?? (await (holon as NomosRoutedHolon).mint(${JSON.stringify(mint.mintType)}${mintOpts}));`,
808
1061
  ` const head = await holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, { domainHash, actor });`,
809
1062
  ` return { head, ${mint.field} };`,
@@ -811,7 +1064,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
811
1064
  );
812
1065
  continue;
813
1066
  }
814
- const offerPayload = stamps.length ? `stampNow(payload, ${JSON.stringify(stamps)})` : `payload`;
1067
+ const offerPayload = stamps.length ? `stampNow(${pv}, ${JSON.stringify(stamps)})` : pv;
815
1068
  if (d.marker === "creates") {
816
1069
  // LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
817
1070
  // carries none). The holon hands the minted ids back on the offer (`returnCreated`), so the create
@@ -820,8 +1073,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
820
1073
  methods.push(
821
1074
  ` /** Author \`${domain}/${d.id}\`: the LAW mints the ${d.aggregateId ?? "aggregate"} id inside the sealed intent`,
822
1075
  ` * and it returns here with the new local head — no id in the payload, no query-after.${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
823
- ` ${d.id}: async (payload: ${payloadName}): Promise<{ head: string; id: string }> => {`,
1076
+ ` ${d.id}: async (${paramSig}): Promise<{ head: string; id: string }> => {`,
824
1077
  ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
1078
+ ...(sealPrelude ? [sealPrelude] : []),
825
1079
  ` const r = await (holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor, returnCreated: true }) as Promise<string | { head: string; created?: { type: string; id: string }[] }>);`,
826
1080
  ` return typeof r === "object" && r !== null ? { head: r.head, id: r.created?.[0]?.id ?? "" } : { head: String(r), id: "" };`,
827
1081
  ` },`,
@@ -829,7 +1083,19 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
829
1083
  continue;
830
1084
  }
831
1085
  const offerCall = `holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor })`;
832
- if (gateGuard) {
1086
+ if (wantSeal) {
1087
+ // SEALING directive (optionally also gated): an ASYNC block — seal the encrypted fields via the
1088
+ // bound { keyStore } (+ throw MissingOfferAuthority first if gated) before dispatch.
1089
+ methods.push(
1090
+ ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
1091
+ ` * Encrypted field(s) are SEALED via the bound { keyStore } before dispatch (pass { seal: false } to skip).${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
1092
+ ` ${d.id}: async (${paramSig}): Promise<string> => {`,
1093
+ ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
1094
+ sealPrelude,
1095
+ ` return ${offerCall};`,
1096
+ ` },`,
1097
+ );
1098
+ } else if (gateGuard) {
833
1099
  // GATED directive: a block body that throws MissingOfferAuthority when no actor is bound — an
834
1100
  // actorless gated offer can't be constructed (the war: the gate is structural, not just kernel-side).
835
1101
  methods.push(
@@ -864,9 +1130,12 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
864
1130
  return `${JSON.stringify(k)}: ${t}`;
865
1131
  })
866
1132
  .join("; ");
1133
+ const qScopes = readScopesFor(q.returns);
867
1134
  methods.push(
868
- ` /** Declared query \`${q.id}\` (indexed probe — routed by the read manifest). */`,
869
- ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]>,`,
1135
+ ` /** Declared query \`${q.id}\` (indexed probe — routed by the read manifest).${qScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key; else they stay opaque envelopes." : ""} */`,
1136
+ qScopes.length
1137
+ ? ` ${lcFirst(camel(q.id))}: async (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params, await _readKeys(keyStore, ${JSON.stringify(qScopes)})) as Promise<Row<${q.returns}Data>[]>,`
1138
+ : ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]>,`,
870
1139
  );
871
1140
  // THE LOGICAL PAGED ROW SURFACE (sharding §5.3, ruling 2 — slice 4): emitted
872
1141
  // ONLY for packed-taxonomy domains (taxonomy-free clients stay byte-identical).
@@ -901,11 +1170,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
901
1170
  );
902
1171
  }
903
1172
  for (const agg of mod.aggregates as AggregateHandle[]) {
1173
+ const aScopes = readScopesFor(agg.id);
904
1174
  methods.push(
905
- ` /** Read one ${agg.id} by aggregate id. */`,
906
- ` ${lcFirst(camel(agg.id))}ById: (id: string) => holon.queryById(id) as Promise<Row<${agg.id}Data>[]>,`,
1175
+ ` /** Read one ${agg.id} by aggregate id.${aScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key." : ""} */`,
1176
+ aScopes.length
1177
+ ? ` ${lcFirst(camel(agg.id))}ById: async (id: string) => holon.queryById(id, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>,`
1178
+ : ` ${lcFirst(camel(agg.id))}ById: (id: string) => holon.queryById(id) as Promise<Row<${agg.id}Data>[]>,`,
907
1179
  ` /** Every ${agg.id} (the type-index probe — O(result), not a scan). */`,
908
- ` list${agg.id}s: () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]>,`,
1180
+ aScopes.length
1181
+ ? ` list${agg.id}s: async () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>,`
1182
+ : ` list${agg.id}s: () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]>,`,
909
1183
  ` /** Watch one ${agg.id} by aggregate id (local reactive read; returns stop()). */`,
910
1184
  ` watch${agg.id}ById: (id: string, cb: (rows: Row<${agg.id}Data>[]) => void, opts?: { intervalMs?: number }) =>`,
911
1185
  ` holon.watchById(id, cb as (rows: unknown) => void, opts),`,
@@ -917,9 +1191,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
917
1191
  ` * Pass \`{ actor }\` (the verified principal uid) to author AS that user — every write seals it as`,
918
1192
  ` * the captured principal, so the ReBAC \`.requires(...)\` gate + the integrity-dual endorse model`,
919
1193
  ` * judge the real author. Omit it for open (un-gated) domains. */`,
920
- `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string } = {}) {`,
1194
+ `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string${encPresent ? "; keyStore?: NomosKeyStore" : ""} } = {}) {`,
921
1195
  ` const domainHash = opts.domainHash ?? ${hashConst};`,
922
1196
  ` const actor = opts.actor;`,
1197
+ ...(encPresent ? [` const keyStore = opts.keyStore;`] : []),
923
1198
  ...(opts.compat !== undefined
924
1199
  ? [` holon.checkCompat?.(${compatConstName(opts.packageName)});`]
925
1200
  : []),