@githolon/dsl 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,27 @@ 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
+ ` /** OWNER CEREMONY (slice 2): HPKE-wrap a scope epoch secret TO a member's published X25519 pubkey. */`,
586
+ ` cryptoWrapKey?(args: { recipientPub: string; scopeKey: string }): Promise<{ hpkeEpk: string; ct: string }>;`,
587
+ ` /** OWNER CEREMONY (slice 2): mint a fresh X25519 keypair — the source of a fresh epoch secret (32 bytes of its secret). */`,
588
+ ` cryptoKeygen?(): Promise<{ public: string; secret: string }>;`,
589
+ ]
590
+ : [
591
+ ` query(queryId: string, params?: Record<string, unknown>): Promise<unknown>;`,
592
+ ` queryById(aggregateId: string): Promise<unknown>;`,
593
+ ]),
455
594
  ` watchById(aggregateId: string, cb: (rows: unknown) => void, opts?: { intervalMs?: number }): () => void;`,
456
595
  ` /** Declared-count / declared-sum O(1) reads (maintained by the read engine). */`,
457
596
  ` count(countId: string, groupKey?: string): Promise<number>;`,
@@ -489,6 +628,265 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
489
628
  ``,
490
629
  );
491
630
 
631
+ if (encPresent) {
632
+ out.push(
633
+ `// ── E2E SEALING (the sealing client) — baked map + KeyStore + seal/read helpers ──`,
634
+ `// The map below is baked at COMPILE time from the SAME descriptors + stable-id lockfile the law was`,
635
+ `// compiled with (never fetched). A sealing write is byte-indistinguishable from a hand-plumbed one.`,
636
+ ``,
637
+ `/** Per directive: which payload fields seal → their { scope, aggSid, fieldSid }. Per aggregate: which`,
638
+ ` * read-model fields are encrypted (+ their scope). Keyed by domain. */`,
639
+ `export const ${encConst} = ${JSON.stringify(encMap)} as const;`,
640
+ ``,
641
+ `/** A held scope key: the base64 symmetric key + the epoch it belongs to (forward-only rotation). */`,
642
+ `export interface NomosScopeKey { scope: string; epoch: number; key: string }`,
643
+ ``,
644
+ `/** The app-supplied KEY CUSTODY surface. Supply one to \`<domain>Client(holon, { keyStore })\` and the`,
645
+ ` * generated methods seal writes + decrypt reads automatically. \`wrappedKeyStore\` is the default over`,
646
+ ` * the on-chain WrappedKeys; an app may implement this to source keys from a secure enclave. */`,
647
+ `export interface NomosKeyStore {`,
648
+ ` /** The CURRENT (highest-epoch) scope key, for SEALING a write. undefined ⇒ EncryptionKeyUnavailable. */`,
649
+ ` scopeKey(scope: string): Promise<NomosScopeKey | undefined>;`,
650
+ ` /** EVERY held key for these scopes (all epochs), injected on reads so old ciphertext also decrypts. */`,
651
+ ` readKeys(scopes: readonly string[]): Promise<NomosScopeKey[]>;`,
652
+ `}`,
653
+ ``,
654
+ `/** Thrown when a write must seal an encrypted field but the KeyStore holds no key for its scope — the`,
655
+ ` * write NEVER falls through to a plaintext dispatch (the sid-envelope gate would refuse it anyway). */`,
656
+ `export class EncryptionKeyUnavailable extends Error {`,
657
+ ` readonly code = "nomos.crypto.key_unavailable";`,
658
+ ` readonly recommendedAction = "obtainScopeKey";`,
659
+ ` constructor(readonly scope: string, readonly field: string) {`,
660
+ " super(`cannot seal encrypted field \"${field}\": no scope key held for \"${scope}\" — obtain it (be granted the cap + wrapped the scope epoch) before writing`);",
661
+ ` this.name = "EncryptionKeyUnavailable";`,
662
+ ` }`,
663
+ `}`,
664
+ ``,
665
+ `/** Seal the mapped encrypted fields of a payload before dispatch. No keyStore / \`seal:false\` ⇒ passthrough`,
666
+ ` * (today's behavior). A held key seals; a missing key throws EncryptionKeyUnavailable (never plaintext). */`,
667
+ `const _sealFields = async (`,
668
+ ` holon: NomosHolon,`,
669
+ ` keyStore: NomosKeyStore | undefined,`,
670
+ ` fieldMap: Record<string, { scope: string; aggSid: string; fieldSid: string }>,`,
671
+ ` payload: Record<string, unknown>,`,
672
+ ` seal: boolean,`,
673
+ `): Promise<Record<string, unknown>> => {`,
674
+ ` if (seal === false || keyStore == null) return payload;`,
675
+ ` let out: Record<string, unknown> | undefined;`,
676
+ ` for (const field of Object.keys(fieldMap)) {`,
677
+ ` const v = payload[field];`,
678
+ ` if (v === undefined || v === null) continue;`,
679
+ ` const m = fieldMap[field]!;`,
680
+ ` const sk = await keyStore.scopeKey(m.scope);`,
681
+ ` if (!sk) throw new EncryptionKeyUnavailable(m.scope, field);`,
682
+ ` if (!holon.cryptoSealField) throw new Error("this holon predates E2E sealing — upgrade @githolon/client");`,
683
+ ` const envelope = await holon.cryptoSealField({ scopeKey: sk.key, scope: m.scope, epoch: sk.epoch, aggSid: m.aggSid, fieldSid: m.fieldSid, plaintext: String(v) });`,
684
+ ` (out ??= { ...payload })[field] = envelope;`,
685
+ ` }`,
686
+ ` return out ?? payload;`,
687
+ `};`,
688
+ ``,
689
+ `/** The read keys to inject for the given scopes, or undefined when there is nothing to inject`,
690
+ ` * (no keyStore, or no held keys) — the read then behaves exactly as today (opaque envelopes). */`,
691
+ `const _readKeys = async (keyStore: NomosKeyStore | undefined, scopes: readonly string[]): Promise<NomosScopeKey[] | undefined> => {`,
692
+ ` if (keyStore == null || scopes.length === 0) return undefined;`,
693
+ ` const keys = await keyStore.readKeys(scopes);`,
694
+ ` return keys.length ? keys : undefined;`,
695
+ `};`,
696
+ ``,
697
+ `/** THE DEFAULT KeyStore — unwraps this principal's on-chain WrappedKeys with its device secret, caching`,
698
+ ` * per scope. Mirror of the documented reader handshake (e2e_encryption.md): the secret stays client-side,`,
699
+ ` * the operator stays blind. Key custody (where \`deviceSecret\` lives) remains the app's concern. */`,
700
+ `export function wrappedKeyStore(opts: { holon: NomosHolon; principal: string; deviceSecret: string }): NomosKeyStore {`,
701
+ ` const { holon, principal, deviceSecret } = opts;`,
702
+ ` const cache = new Map<string, Promise<NomosScopeKey[]>>();`,
703
+ ` const loadScope = (scope: string): Promise<NomosScopeKey[]> => {`,
704
+ ` let p = cache.get(scope);`,
705
+ ` if (p !== undefined) return p;`,
706
+ ` p = (async () => {`,
707
+ ` const rows = (await holon.query("wrappedKeysByRecipient", { recipientPrincipal: principal })) as { data?: Record<string, unknown> }[];`,
708
+ ` const held: NomosScopeKey[] = [];`,
709
+ ` for (const row of rows ?? []) {`,
710
+ ` const d = (row.data ?? row) as Record<string, unknown>;`,
711
+ ` if (String(d.scope) !== scope) continue;`,
712
+ ` if (!holon.cryptoUnwrapKey) throw new Error("this holon predates E2E unwrap — upgrade @githolon/client");`,
713
+ ` const key = await holon.cryptoUnwrapKey({ secret: deviceSecret, hpkeEpk: String(d.hpkeEpk), ct: String(d.ct) });`,
714
+ ` held.push({ scope, epoch: Number(d.epoch) || 0, key });`,
715
+ ` }`,
716
+ ` held.sort((a, b) => b.epoch - a.epoch);`,
717
+ ` return held;`,
718
+ ` })();`,
719
+ ` cache.set(scope, p);`,
720
+ ` return p;`,
721
+ ` };`,
722
+ ` return {`,
723
+ ` async scopeKey(scope) { return (await loadScope(scope))[0]; },`,
724
+ ` async readKeys(scopes) { const all: NomosScopeKey[] = []; for (const s of scopes) all.push(...(await loadScope(s))); return all; },`,
725
+ ` };`,
726
+ `}`,
727
+ ``,
728
+ `// ── E2E OWNER CEREMONY (the sealing client, slice 2) — grantAndWrap / revokeAndRotate / rotate ──`,
729
+ `// The owner-side twin of the KeyStore. It composes the framework key-epoch fan-out (\`advanceKeyEpoch\`,`,
730
+ `// dispatched by its REAL id — never an \`export const alias\`) with the domain's own grant/revoke`,
731
+ `// directive, resolving the roster + each member's wrap-target pubkey FROM THE CHAIN, so the`,
732
+ `// operator-blind handshake (grant → advance epoch → wrap to every member) is ONE call.`,
733
+ ``,
734
+ `/** Base64 ⇄ bytes that work in both the browser (atob/btoa) and Node (Buffer) — the generated`,
735
+ ` * client is zero-import, so it derives a fresh 32-byte epoch secret from a keygen without a lib. */`,
736
+ `const _b64ToBytes = (b64: string): Uint8Array => {`,
737
+ ` if (typeof atob === "function") { const s = atob(b64); const u = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u[i] = s.charCodeAt(i); return u; }`,
738
+ ` return Uint8Array.from((globalThis as unknown as { Buffer: { from(s: string, e: string): Uint8Array } }).Buffer.from(b64, "base64"));`,
739
+ `};`,
740
+ `const _bytesToB64 = (u: Uint8Array): string => {`,
741
+ ` if (typeof btoa === "function") { let s = ""; for (const b of u) s += String.fromCharCode(b); return btoa(s); }`,
742
+ ` return (globalThis as unknown as { Buffer: { from(u: Uint8Array): { toString(e: string): string } } }).Buffer.from(u).toString("base64");`,
743
+ `};`,
744
+ ``,
745
+ `/** Thrown by the owner ceremony when a roster member has NO wrap-target key (no publishEncKey on chain,`,
746
+ ` * no prior wrap, no supplied override) — the ceremony NEVER wraps to a default (fail-closed; a member`,
747
+ ` * with no key is "not yet a member", per e2e_encryption.md's FOOTGUNS). */`,
748
+ `export class EncryptionMemberKeyMissing extends Error {`,
749
+ ` readonly code = "nomos.crypto.member_key_missing";`,
750
+ ` readonly recommendedAction = "publishEncKey";`,
751
+ ` constructor(readonly principal: string, readonly scope: string) {`,
752
+ " super(`cannot wrap the \"${scope}\" scope key to member \"${principal}\": no encryption public key on chain (publishEncKey) and none supplied — a keyless member is not yet wrappable (fail-closed)`);",
753
+ ` this.name = "EncryptionMemberKeyMissing";`,
754
+ ` }`,
755
+ `}`,
756
+ ``,
757
+ `/** Thrown when the resolved roster != the caller's declared \`expectRoster\` BEFORE dispatch — pre-firing`,
758
+ ` * the kernel fan-out gate (wrap set == roster) so a rotation against a stale roster view never spends`,
759
+ ` * an epoch on the chain. */`,
760
+ `export class EncryptionRosterMismatch extends Error {`,
761
+ ` readonly code = "nomos.crypto.roster_mismatch";`,
762
+ ` readonly recommendedAction = "reconcileRoster";`,
763
+ ` constructor(readonly scope: string, readonly missing: readonly string[], readonly extra: readonly string[]) {`,
764
+ " super(`roster for \"${scope}\" != expected (the fan-out gate would refuse): missing [${missing.join(\", \")}], unexpected [${extra.join(\", \")}]`);",
765
+ ` this.name = "EncryptionRosterMismatch";`,
766
+ ` }`,
767
+ `}`,
768
+ ``,
769
+ `/** The owner-ceremony surface — attached as \`<domain>Client(holon, { actor }).encryption\`. All three`,
770
+ ` * verbs open a FRESH epoch (a fresh 32-byte secret) and fan the wraps to the resolved roster; the`,
771
+ ` * membership change (if any) is the domain grant/revoke dispatched first, in the same call. */`,
772
+ `export interface NomosEncryption {`,
773
+ ` /** The encryption scopes this domain defines (each a \`requires(cap)\`). */`,
774
+ ` readonly scopes: readonly string[];`,
775
+ ` /** The CURRENT roster of a scope (active direct-grant members, principal-form). Defaults to the sole scope. */`,
776
+ ` roster(scope?: string): Promise<string[]>;`,
777
+ ` /** Grant a newcomer + open a fresh epoch wrapping the key to EVERY member (incl. the newcomer). Dispatches`,
778
+ ` * \`grantDirective(grantPayload)\` first, then \`advanceKeyEpoch\`. \`publicKey\` is the newcomer's X25519 key`,
779
+ ` * (else resolved from an on-chain publishEncKey). Existing members' keys come from their prior wraps. */`,
780
+ ` grantAndWrap(args: { principal: string; publicKey?: string; scope?: string; grantDirective: string; grantPayload: Record<string, unknown>; scopeKey?: string; memberKeys?: Record<string, string>; expectRoster?: readonly string[]; openedAt?: string }): Promise<{ head: string; epoch: number; roster: string[] }>;`,
781
+ ` /** Revoke a member + open a fresh epoch re-wrapping the key to the SURVIVORS only. FORWARD-ONLY: the removed`,
782
+ ` * member gets no NEW reads, but old ciphertext it already holds a key for stays readable (not re-encrypted). */`,
783
+ ` revokeAndRotate(args: { principal: string; scope?: string; revokeDirective: string; revokePayload: Record<string, unknown>; scopeKey?: string; memberKeys?: Record<string, string>; expectRoster?: readonly string[]; openedAt?: string }): Promise<{ head: string; epoch: number; roster: string[] }>;`,
784
+ ` /** Routine rotation — open a fresh epoch re-wrapping the key to the CURRENT roster (no membership change). */`,
785
+ ` rotate(args?: { scope?: string; scopeKey?: string; memberKeys?: Record<string, string>; expectRoster?: readonly string[]; openedAt?: string }): Promise<{ head: string; epoch: number; roster: string[] }>;`,
786
+ `}`,
787
+ ``,
788
+ `/** Build the owner ceremony for one domain (thin: all mechanism lives here; the factory just wires it). */`,
789
+ `function _ownerEncryption(holon: NomosHolon, cfg: { domain: string; domainHash?: string; actor?: string; scopes: readonly string[] }): NomosEncryption {`,
790
+ ` const { domain, domainHash, actor, scopes } = cfg;`,
791
+ ` const defScope = (scope?: string): string => {`,
792
+ ` if (scope) return scope;`,
793
+ ` if (scopes.length === 1) return scopes[0]!;`,
794
+ " throw new Error(`this domain defines ${scopes.length} encryption scopes [${scopes.join(\", \")}] — pass { scope } to pick one`);",
795
+ ` };`,
796
+ ` // A BARE scope IS \`workspace:self#<relation>\` (the era rule); a per-record scope is \`<type>:<id>#<relation>\`.`,
797
+ ` const parseScope = (scope: string): { object: string; relation: string } => {`,
798
+ ` const h = scope.indexOf("#");`,
799
+ ` return h < 0 ? { object: "workspace:self", relation: scope } : { object: scope.slice(0, h), relation: scope.slice(h + 1) };`,
800
+ ` };`,
801
+ ` const readRoster = async (scope: string): Promise<string[]> => {`,
802
+ ` const { object, relation } = parseScope(scope);`,
803
+ " const rows = (await holon.queryById(`reltuple-${object}-${relation}`)) as { data?: { subjects?: Record<string, unknown> } }[] | undefined;",
804
+ ` const subjects = rows?.[0]?.data?.subjects ?? {};`,
805
+ ` const out: string[] = [];`,
806
+ ` for (const [subj, leafRaw] of Object.entries(subjects)) {`,
807
+ ` let leaf: { status?: string };`,
808
+ ` try { leaf = typeof leafRaw === "string" ? JSON.parse(leafRaw) : (leafRaw as { status?: string }); } catch { leaf = {}; }`,
809
+ ` if (leaf?.status !== undefined && leaf.status !== "active") continue;`,
810
+ ` out.push(subj.startsWith("user:") ? subj.slice(5) : subj);`,
811
+ ` }`,
812
+ ` return out.sort();`,
813
+ ` };`,
814
+ ` const currentEpoch = async (scope: string): Promise<number> => {`,
815
+ " const rows = (await holon.queryById(`keyscope-${scope}`)) as { data?: { currentEpoch?: number } }[] | undefined;",
816
+ ` const e = rows?.[0]?.data?.currentEpoch;`,
817
+ ` return typeof e === "number" ? e : -1;`,
818
+ ` };`,
819
+ ` const freshScopeKey = async (): Promise<string> => {`,
820
+ ` if (!holon.cryptoKeygen) throw new Error("this holon predates E2E keygen — upgrade @githolon/client");`,
821
+ ` const kp = await holon.cryptoKeygen();`,
822
+ ` return _bytesToB64(_b64ToBytes(kp.secret).subarray(0, 32)); // the 32-byte epoch secret; NEVER lands on chain`,
823
+ ` };`,
824
+ ` const memberPubKey = async (principal: string, hints: { publicKey?: string; principal?: string; memberKeys?: Record<string, string> }): Promise<string | undefined> => {`,
825
+ ` if (hints.memberKeys?.[principal]) return hints.memberKeys[principal];`,
826
+ ` if (hints.principal === principal && hints.publicKey) return hints.publicKey;`,
827
+ ` // Published enc key (encKeyByPrincipal) — present only when the domain composed the registry; tolerate absence.`,
828
+ ` try {`,
829
+ ` const reg = (await holon.query("encKeyByPrincipal", { principal })) as { data?: { encPublicKey?: string } }[] | undefined;`,
830
+ ` const pub = Array.isArray(reg) ? reg[0]?.data?.encPublicKey : undefined;`,
831
+ ` if (pub) return pub;`,
832
+ ` } catch { /* not routed — fall through to the prior-wrap lane */ }`,
833
+ ` // The member's most-recent WrappedKey pins the pubkey it was last sealed to (rotation needs no re-publish).`,
834
+ ` try {`,
835
+ ` const wraps = (await holon.query("wrappedKeysByRecipient", { recipientPrincipal: principal })) as { data?: { epoch?: number; recipientPubKey?: string } }[] | undefined;`,
836
+ ` let best: { epoch: number; pub: string } | undefined;`,
837
+ ` for (const w of Array.isArray(wraps) ? wraps : []) { const d = w.data ?? {}; if (d.recipientPubKey && (best === undefined || (d.epoch ?? 0) >= best.epoch)) best = { epoch: d.epoch ?? 0, pub: d.recipientPubKey }; }`,
838
+ ` if (best) return best.pub;`,
839
+ ` } catch { /* not routed */ }`,
840
+ ` return undefined;`,
841
+ ` };`,
842
+ ` const advance = async (scope: string, opts: { add?: { principal: string; publicKey?: string }; remove?: readonly string[]; scopeKey?: string; memberKeys?: Record<string, string>; expectRoster?: readonly string[]; openedAt?: string }): Promise<{ head: string; epoch: number; roster: string[] }> => {`,
843
+ ` const roster = new Set(await readRoster(scope));`,
844
+ ` if (opts.add) roster.add(opts.add.principal);`,
845
+ ` for (const p of opts.remove ?? []) roster.delete(p);`,
846
+ ` const members = [...roster].sort();`,
847
+ ` // PRE-FIRE the fan-out gate: if the caller declared the roster it means to wrap, refuse on drift.`,
848
+ ` if (opts.expectRoster !== undefined) {`,
849
+ ` const want = new Set(opts.expectRoster);`,
850
+ ` const missing = [...want].filter((m) => !roster.has(m)).sort();`,
851
+ ` const extra = members.filter((m) => !want.has(m));`,
852
+ ` if (missing.length || extra.length) throw new EncryptionRosterMismatch(scope, missing, extra);`,
853
+ ` }`,
854
+ ` const key = opts.scopeKey ?? await freshScopeKey();`,
855
+ ` if (!holon.cryptoWrapKey) throw new Error("this holon predates E2E wrap — upgrade @githolon/client");`,
856
+ ` const wraps: { recipientPrincipal: string; recipientPubKey: string; hpkeEpk: string; ct: string }[] = [];`,
857
+ ` for (const principal of members) {`,
858
+ ` const pub = await memberPubKey(principal, { publicKey: opts.add?.publicKey, principal: opts.add?.principal, memberKeys: opts.memberKeys });`,
859
+ ` if (!pub) throw new EncryptionMemberKeyMissing(principal, scope);`,
860
+ ` const w = await holon.cryptoWrapKey({ recipientPub: pub, scopeKey: key });`,
861
+ ` wraps.push({ recipientPrincipal: principal, recipientPubKey: pub, hpkeEpk: w.hpkeEpk, ct: w.ct });`,
862
+ ` }`,
863
+ ` const epoch = (await currentEpoch(scope)) + 1; // forward-only: strictly increments`,
864
+ " const head = await holon.offerDirective(domain, \"advanceKeyEpoch\", { scope, epoch, openedAt: opts.openedAt ?? new Date().toISOString(), wraps }, { domainHash, actor });",
865
+ ` return { head, epoch, roster: members };`,
866
+ ` };`,
867
+ ` return {`,
868
+ ` scopes,`,
869
+ ` roster: (scope) => readRoster(defScope(scope)),`,
870
+ ` async grantAndWrap(args) {`,
871
+ ` const scope = defScope(args.scope);`,
872
+ ` await holon.offerDirective(domain, args.grantDirective, args.grantPayload, { domainHash, actor });`,
873
+ ` return advance(scope, { add: { principal: args.principal, publicKey: args.publicKey }, scopeKey: args.scopeKey, memberKeys: args.memberKeys, expectRoster: args.expectRoster, openedAt: args.openedAt });`,
874
+ ` },`,
875
+ ` async revokeAndRotate(args) {`,
876
+ ` const scope = defScope(args.scope);`,
877
+ ` await holon.offerDirective(domain, args.revokeDirective, args.revokePayload, { domainHash, actor });`,
878
+ ` return advance(scope, { remove: [args.principal], scopeKey: args.scopeKey, memberKeys: args.memberKeys, expectRoster: args.expectRoster, openedAt: args.openedAt });`,
879
+ ` },`,
880
+ ` async rotate(args) {`,
881
+ ` const scope = defScope(args?.scope);`,
882
+ ` return advance(scope, { scopeKey: args?.scopeKey, memberKeys: args?.memberKeys, expectRoster: args?.expectRoster, openedAt: args?.openedAt });`,
883
+ ` },`,
884
+ ` };`,
885
+ `}`,
886
+ ``,
887
+ );
888
+ }
889
+
492
890
  const anyAutoStamp = modules.some((mod) =>
493
891
  (mod.directives as Directive<unknown>[]).some(
494
892
  (d) =>
@@ -769,6 +1167,23 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
769
1167
  } catch { /* the compile gate already failed a bad taxonomy loudly */ }
770
1168
  }
771
1169
  const methods: string[] = [];
1170
+ // E2E READ INJECTION: the distinct encryption scopes a read of `typeId` must inject keys for (empty
1171
+ // ⇒ nothing encrypted on that type ⇒ the accessor is emitted plain, unchanged). The `_readKeys` helper
1172
+ // returns undefined when no keyStore is bound, so an injected accessor still behaves as today offline.
1173
+ const readScopesFor = (typeId: string): string[] => {
1174
+ const r = encMap?.[domain]?.reads?.[typeId];
1175
+ return r === undefined ? [] : [...new Set(Object.values(r).map((x) => x.scope))].sort();
1176
+ };
1177
+ // The DISTINCT encryption scopes this domain defines (across encrypted reads + sealing directives) —
1178
+ // the set the owner ceremony rotates. Empty ⇒ no `encryption` surface is emitted for this domain.
1179
+ const domScopes: string[] = (() => {
1180
+ const dm = encMap?.[domain];
1181
+ if (dm === undefined) return [];
1182
+ const s = new Set<string>();
1183
+ for (const r of Object.values(dm.reads)) for (const f of Object.values(r)) s.add(f.scope);
1184
+ for (const dir of Object.values(dm.directives)) for (const f of Object.values(dir)) s.add(f.scope);
1185
+ return [...s].sort();
1186
+ })();
772
1187
  for (const d of dirs) {
773
1188
  let payloadName = `${cap(d.id)}Payload`;
774
1189
  if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
@@ -784,6 +1199,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
784
1199
  const gateGuard = gate !== undefined
785
1200
  ? ` if (!actor || !actor.trim()) throw new MissingOfferAuthority(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${JSON.stringify(gate.relation)});\n`
786
1201
  : "";
1202
+ // E2E SEALING (the sealing client): a directive with `.encrypted()` payload fields seals them
1203
+ // (via the KeyStore) BEFORE dispatch. `wantSeal` forces an async block body + a `callOpts.seal`
1204
+ // escape hatch, and `pv` (the sealed copy) replaces `payload` downstream. Non-encrypted directives
1205
+ // are byte-identical (wantSeal === false ⇒ no prelude, no extra param, plain `payload`).
1206
+ const wantSeal = encMap?.[domain]?.directives?.[d.id] !== undefined;
1207
+ const paramSig = wantSeal ? `payload: ${payloadName}, callOpts?: { seal?: boolean }` : `payload: ${payloadName}`;
1208
+ const sealPrelude = wantSeal
1209
+ ? ` 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};`
1210
+ : "";
1211
+ const pv = wantSeal ? "_sealed" : "payload";
787
1212
  out.push(
788
1213
  `/** \`${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
1214
  `export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed)};`,
@@ -797,13 +1222,14 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
797
1222
  ? `, { tagHex: routeTagOfId(payload.${mint.tagFromIdField}) }`
798
1223
  : "";
799
1224
  const body = stamps.length
800
- ? `stampNow({ ...payload, ${mint.field} }, ${JSON.stringify(stamps)})`
801
- : `{ ...payload, ${mint.field} }`;
1225
+ ? `stampNow({ ...${pv}, ${mint.field} }, ${JSON.stringify(stamps)})`
1226
+ : `{ ...${pv}, ${mint.field} }`;
802
1227
  methods.push(
803
1228
  ` /** Author \`${domain}/${d.id}\`: the front-door MINTS \`${mint.field}\` (route-tagged) when omitted`,
804
1229
  ` * — 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 }> => {`,
1230
+ ` ${d.id}: async (${paramSig}): Promise<{ head: string; ${mint.field}: string }> => {`,
806
1231
  ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
1232
+ ...(sealPrelude ? [sealPrelude] : []),
807
1233
  ` const ${mint.field} = payload.${mint.field} ?? (await (holon as NomosRoutedHolon).mint(${JSON.stringify(mint.mintType)}${mintOpts}));`,
808
1234
  ` const head = await holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, { domainHash, actor });`,
809
1235
  ` return { head, ${mint.field} };`,
@@ -811,7 +1237,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
811
1237
  );
812
1238
  continue;
813
1239
  }
814
- const offerPayload = stamps.length ? `stampNow(payload, ${JSON.stringify(stamps)})` : `payload`;
1240
+ const offerPayload = stamps.length ? `stampNow(${pv}, ${JSON.stringify(stamps)})` : pv;
815
1241
  if (d.marker === "creates") {
816
1242
  // LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
817
1243
  // carries none). The holon hands the minted ids back on the offer (`returnCreated`), so the create
@@ -820,8 +1246,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
820
1246
  methods.push(
821
1247
  ` /** Author \`${domain}/${d.id}\`: the LAW mints the ${d.aggregateId ?? "aggregate"} id inside the sealed intent`,
822
1248
  ` * 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 }> => {`,
1249
+ ` ${d.id}: async (${paramSig}): Promise<{ head: string; id: string }> => {`,
824
1250
  ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
1251
+ ...(sealPrelude ? [sealPrelude] : []),
825
1252
  ` 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
1253
  ` return typeof r === "object" && r !== null ? { head: r.head, id: r.created?.[0]?.id ?? "" } : { head: String(r), id: "" };`,
827
1254
  ` },`,
@@ -829,7 +1256,19 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
829
1256
  continue;
830
1257
  }
831
1258
  const offerCall = `holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor })`;
832
- if (gateGuard) {
1259
+ if (wantSeal) {
1260
+ // SEALING directive (optionally also gated): an ASYNC block — seal the encrypted fields via the
1261
+ // bound { keyStore } (+ throw MissingOfferAuthority first if gated) before dispatch.
1262
+ methods.push(
1263
+ ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
1264
+ ` * Encrypted field(s) are SEALED via the bound { keyStore } before dispatch (pass { seal: false } to skip).${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
1265
+ ` ${d.id}: async (${paramSig}): Promise<string> => {`,
1266
+ ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
1267
+ sealPrelude,
1268
+ ` return ${offerCall};`,
1269
+ ` },`,
1270
+ );
1271
+ } else if (gateGuard) {
833
1272
  // GATED directive: a block body that throws MissingOfferAuthority when no actor is bound — an
834
1273
  // actorless gated offer can't be constructed (the war: the gate is structural, not just kernel-side).
835
1274
  methods.push(
@@ -864,9 +1303,12 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
864
1303
  return `${JSON.stringify(k)}: ${t}`;
865
1304
  })
866
1305
  .join("; ");
1306
+ const qScopes = readScopesFor(q.returns);
867
1307
  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>[]>,`,
1308
+ ` /** 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." : ""} */`,
1309
+ qScopes.length
1310
+ ? ` ${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>[]>,`
1311
+ : ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]>,`,
870
1312
  );
871
1313
  // THE LOGICAL PAGED ROW SURFACE (sharding §5.3, ruling 2 — slice 4): emitted
872
1314
  // ONLY for packed-taxonomy domains (taxonomy-free clients stay byte-identical).
@@ -901,11 +1343,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
901
1343
  );
902
1344
  }
903
1345
  for (const agg of mod.aggregates as AggregateHandle[]) {
1346
+ const aScopes = readScopesFor(agg.id);
904
1347
  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>[]>,`,
1348
+ ` /** Read one ${agg.id} by aggregate id.${aScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key." : ""} */`,
1349
+ aScopes.length
1350
+ ? ` ${lcFirst(camel(agg.id))}ById: async (id: string) => holon.queryById(id, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>,`
1351
+ : ` ${lcFirst(camel(agg.id))}ById: (id: string) => holon.queryById(id) as Promise<Row<${agg.id}Data>[]>,`,
907
1352
  ` /** 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>[]>,`,
1353
+ aScopes.length
1354
+ ? ` 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>[]>,`
1355
+ : ` list${agg.id}s: () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]>,`,
909
1356
  ` /** Watch one ${agg.id} by aggregate id (local reactive read; returns stop()). */`,
910
1357
  ` watch${agg.id}ById: (id: string, cb: (rows: Row<${agg.id}Data>[]) => void, opts?: { intervalMs?: number }) =>`,
911
1358
  ` holon.watchById(id, cb as (rows: unknown) => void, opts),`,
@@ -917,9 +1364,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
917
1364
  ` * Pass \`{ actor }\` (the verified principal uid) to author AS that user — every write seals it as`,
918
1365
  ` * the captured principal, so the ReBAC \`.requires(...)\` gate + the integrity-dual endorse model`,
919
1366
  ` * judge the real author. Omit it for open (un-gated) domains. */`,
920
- `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string } = {}) {`,
1367
+ `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string${encPresent ? "; keyStore?: NomosKeyStore" : ""} } = {}) {`,
921
1368
  ` const domainHash = opts.domainHash ?? ${hashConst};`,
922
1369
  ` const actor = opts.actor;`,
1370
+ ...(encPresent ? [` const keyStore = opts.keyStore;`] : []),
923
1371
  ...(opts.compat !== undefined
924
1372
  ? [` holon.checkCompat?.(${compatConstName(opts.packageName)});`]
925
1373
  : []),
@@ -929,6 +1377,14 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
929
1377
  ...(opts.interface !== undefined
930
1378
  ? [` interface: ${interfaceConstName(opts.packageName)}[${JSON.stringify(domain)}],`]
931
1379
  : []),
1380
+ // THE OWNER CEREMONY (slice 2): only for a domain that actually defines an encryption scope.
1381
+ ...(domScopes.length > 0
1382
+ ? [
1383
+ ` /** OWNER CEREMONY (slice 2): grantAndWrap / revokeAndRotate / rotate — the operator-blind`,
1384
+ ` * key-epoch handshake as one call. Bind \`{ actor }\` (the owner) to author the fan-out. */`,
1385
+ ` encryption: _ownerEncryption(holon, { domain: ${JSON.stringify(domain)}, domainHash, actor, scopes: ${JSON.stringify(domScopes)} }) as NomosEncryption,`,
1386
+ ]
1387
+ : []),
932
1388
  ...methods,
933
1389
  ` };`,
934
1390
  `}`,
@@ -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";