@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.
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
  : []),
@@ -121,6 +121,7 @@ import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
121
121
  import { generateOpenApiFragment, mergeOpenApiFragments } from "./codegen_openapi.js";
122
122
  import { domainInterface, type DomainInterface } from "./shape_compat.js";
123
123
  import { generateTsProof, proofLegsIndex } from "./codegen_proof.js";
124
+ import { checkDomainModules, formatFindings, hasRefusal } from "./law_check.js";
124
125
  import type { NomosCompat } from "./compat_manifest.js";
125
126
 
126
127
  const DSL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -1816,6 +1817,24 @@ async function main(): Promise<void> {
1816
1817
  for (const w of birthWarnings) {
1817
1818
  console.log(` ⚠ births ${w}`);
1818
1819
  }
1820
+ // ── THE AUTHOR-TIME GATE (architecture/author_time_gate.md) — the static twins of the kernel
1821
+ // gate's refusal vocabulary, unified into ONE report. `githolon check` runs the same pass;
1822
+ // compile runs it implicitly and REFUSES on any refuse-level finding (a statically-provable
1823
+ // runtime refusal shipped as law). Warns/notes are printed but never block the compile.
1824
+ const checkFindings = checkDomainModules(domainModules);
1825
+ if (checkFindings.length > 0) {
1826
+ console.log(` check ${checkFindings.length} finding(s) (githolon check):`);
1827
+ for (const line of formatFindings(checkFindings)) {
1828
+ const mark = line.includes("REFUSE") ? "✗" : line.includes("WARN") ? "⚠" : "•";
1829
+ console.log(` ${mark} ${line}`);
1830
+ }
1831
+ if (hasRefusal(checkFindings)) {
1832
+ // Emit the artifacts (already written) but REFUSE the compile: a refuse-level finding is a
1833
+ // statically-provable runtime gate refusal — shipping it as law would fail at first dispatch.
1834
+ console.error(` ✗ check REFUSED — fix the refuse-level finding(s) above (or run \`githolon check\`)`);
1835
+ process.exitCode = 1;
1836
+ }
1837
+ }
1819
1838
  if (dartOut !== undefined) {
1820
1839
  const supportText =
1821
1840
  dartSupportImport === "vendored" ? "vendored nomos_client support" : "package nomos_client imports";