@githolon/dsl 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/aggregate.ts CHANGED
@@ -25,6 +25,46 @@ import type { Field } from "./fields.js";
25
25
  export type AggregateInvariantVerdict = { accept: true } | { reject: string };
26
26
  export type AggregateInvariantFn = (snapshot: Record<string, unknown>) => AggregateInvariantVerdict;
27
27
 
28
+ /**
29
+ * READ VISIBILITY (slice 5.1, `host_routes_only.md` §5.1) — the DECLARED posture
30
+ * of an aggregate's data on the read side. `public` (the DEFAULT, and the only
31
+ * value before this existed) means any peer may read the type; `requires(cap)`
32
+ * makes the type PRIVATE: a read that touches it (a `query` returning it, a
33
+ * `count`/`sum` over it, a by-id read of one of its instances) is served ONLY to a
34
+ * principal that effectively holds `cap` in THIS workspace's own folded
35
+ * role-bindings (the kernel `identity::authz` path, lifted from the write gate).
36
+ *
37
+ * THE LAW DECIDES, NOT THE HOST: the visibility rides IN the law package (the
38
+ * `nomosReadVisibility` USD era key — OMITTED when every aggregate is public, so
39
+ * an all-public domain hashes byte-identically to before this existed) and the
40
+ * githolon enforces it at the one wasm read boundary. The host only STAMPS the
41
+ * presented principal (an impure identity read) and relays the githolon's verdict.
42
+ *
43
+ * `cap` is a CAPABILITY NAME (the same vocabulary as a role's caps / a directive
44
+ * name — see `framework/rbac.ts`): the workspace's owner authors a `grant` of a
45
+ * role carrying `cap` to the reader's principal (e.g. at birth, over the
46
+ * secret-gated author lane). A degenerate identity catalogue (role ≡ cap) means
47
+ * "holds an active, unexpired, non-revoked binding whose role grants `cap`".
48
+ */
49
+ export type ReadVisibility = { readonly requires: string };
50
+
51
+ /** Capability/role names: a non-empty token, no whitespace or quotes (it rides a USD string + a JSON leaf). */
52
+ const READ_CAP_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
53
+
54
+ /**
55
+ * Declare an aggregate PRIVATE behind a capability — the value for
56
+ * `aggregate(id, fields, { visibility: requires("readEntry") })`. The only
57
+ * non-default {@link ReadVisibility}; `public` is the omitted default.
58
+ */
59
+ export function requires(capability: string): ReadVisibility {
60
+ if (!READ_CAP_NAME_RE.test(capability)) {
61
+ throw new Error(
62
+ `requires(): capability must match ${READ_CAP_NAME_RE} (a capability/role name); got ${JSON.stringify(capability)}`,
63
+ );
64
+ }
65
+ return { requires: capability };
66
+ }
67
+
28
68
  /**
29
69
  * An aggregate has two genuinely different kinds of member, and the DSL keeps them
30
70
  * APART so no downstream layer ever has to re-derive the distinction:
@@ -89,17 +129,57 @@ export interface AggregateHandle<
89
129
  * cap-free domains hash byte-identically to before this key existed.
90
130
  */
91
131
  readonly cap?: number;
132
+ /**
133
+ * READ VISIBILITY (slice 5.1). PRESENT only when the type is declared PRIVATE
134
+ * (`visibility: requires(cap)`); OMITTED ENTIRELY for the default `public`, so a
135
+ * public-only domain is byte-identical to before this key existed (the
136
+ * `nomosReadVisibility` era key is likewise omitted). See {@link ReadVisibility}.
137
+ */
138
+ readonly visibility?: ReadVisibility;
139
+ /**
140
+ * E2E ENCRYPTION (e2e_encryption.md slice 2) — the STORED field names whose values
141
+ * are `.encrypted()`, SORTED. PRESENT only when ≥1 field is encrypted; OMITTED
142
+ * otherwise (a plaintext aggregate is byte-identical, and the `nomosEncryption` era
143
+ * key is likewise omitted). `aggregate()` enforces that an encrypted field's
144
+ * aggregate is `visibility: requires(cap)` (the scope) and its driver is Lww-class.
145
+ */
146
+ readonly encryptedFields?: string[];
92
147
  }
93
148
 
94
149
  export function aggregate<const Id extends string, F extends Record<string, Field>>(
95
150
  id: Id,
96
151
  fields: F,
97
- opts?: { invariant?: AggregateInvariantFn; cap?: number },
152
+ opts?: { invariant?: AggregateInvariantFn; cap?: number; visibility?: ReadVisibility },
98
153
  ): AggregateHandle<Id, F> {
99
154
  const { stored, hasMany } = partitionFields(fields);
100
155
  if (opts?.cap !== undefined && (!Number.isInteger(opts.cap) || opts.cap < 1)) {
101
156
  throw new Error(`aggregate '${id}': cap must be a positive integer (got ${opts.cap})`);
102
157
  }
158
+ if (opts?.visibility !== undefined && !READ_CAP_NAME_RE.test(opts.visibility.requires)) {
159
+ throw new Error(`aggregate '${id}': visibility.requires must be a capability name`);
160
+ }
161
+ // E2E ENCRYPTION (slice 2): the fields marked `.encrypted()`, validated. An encrypted
162
+ // field's VALUE is sealed to the aggregate's read scope, so the aggregate MUST be
163
+ // private, and the driver must be Lww-class (the ciphertext envelope is replaced
164
+ // wholesale — additive/counter merges over ciphertext are meaningless).
165
+ const encryptedFields = Object.entries(stored)
166
+ .filter(([, f]) => (f as Field).isEncrypted === true)
167
+ .map(([name]) => name)
168
+ .sort();
169
+ if (encryptedFields.length > 0) {
170
+ if (opts?.visibility === undefined) {
171
+ throw new Error(
172
+ `aggregate '${id}': field(s) ${encryptedFields.join(", ")} are .encrypted() but the aggregate is not private — add { visibility: requires("<cap>") } (the encryption scope IS the read scope)`,
173
+ );
174
+ }
175
+ for (const name of encryptedFields) {
176
+ if (!isLwwClass((stored[name] as Field).driver)) {
177
+ throw new Error(
178
+ `aggregate '${id}': field '${name}' is .encrypted() but its merge driver is not Lww-class — an encrypted value is a ciphertext envelope replaced wholesale, so only Lww / MapOf(Lww) are valid`,
179
+ );
180
+ }
181
+ }
182
+ }
103
183
  return {
104
184
  id,
105
185
  fields: stored as StoredFields<F>,
@@ -109,9 +189,18 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
109
189
  ? { hasInvariant: true as const, invariant: opts.invariant }
110
190
  : {}),
111
191
  ...(opts?.cap !== undefined ? { cap: opts.cap } : {}),
192
+ ...(opts?.visibility !== undefined ? { visibility: opts.visibility } : {}),
193
+ ...(encryptedFields.length > 0 ? { encryptedFields } : {}),
112
194
  };
113
195
  }
114
196
 
197
+ /** Lww-class driver: a wholesale-replace merge (`Lww`, or `MapOf` wrapping an Lww-class inner). */
198
+ function isLwwClass(driver: { kind: string; inner?: { kind: string } }): boolean {
199
+ if (driver.kind === "Lww") return true;
200
+ if (driver.kind === "MapOf" && driver.inner) return isLwwClass(driver.inner as { kind: string });
201
+ return false;
202
+ }
203
+
115
204
  /**
116
205
  * A bound aggregate reference — a handle pinned to a concrete INSTANCE id.
117
206
  *
@@ -188,6 +188,77 @@ export interface TsProofOptions {
188
188
  readonly domainHash: string;
189
189
  }
190
190
 
191
+ /** The machine-readable OFFLINE legs for ONE domain (the `@nomos-proof-legs`
192
+ * marker shape; the `proof-legs.json` index is a map of these by domain key). */
193
+ export interface ProofLegs {
194
+ readonly domain: string;
195
+ readonly directiveId: string;
196
+ readonly aggregateId: string;
197
+ readonly payload: Record<string, unknown>;
198
+ readonly autoStamped: readonly string[];
199
+ readonly mintField?: string;
200
+ readonly query?: { readonly id: string; readonly params: Record<string, unknown> };
201
+ readonly count?: { readonly id: string; readonly group: string };
202
+ }
203
+
204
+ /** Synthesize the offline legs for one domain module, or `null` when it has no
205
+ * creating write (nothing to prove). The SINGLE source of the leg-selection
206
+ * logic — `generateTsProof` (the .mts marker) and `proofLegsIndex` (the
207
+ * per-domain index) both call this, so the offline lane proves any domain the
208
+ * same way the generated proof proves its primary one. */
209
+ export function proofLegsForModule(mod: DomainModule): ProofLegs | null {
210
+ const createDir = (mod.directives as Directive<unknown>[]).find(
211
+ (d) => d?.marker === "creates" && typeof d.payloadSchema === "object",
212
+ );
213
+ if (createDir === undefined) return null;
214
+ const domain = mod.domain ?? mod.name;
215
+ const agg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === createDir.aggregateId);
216
+ if (agg === undefined) return null;
217
+ const synth = synthesizePayload(createDir.payloadSchema as z.ZodTypeAny, `${domain}/${createDir.id}`);
218
+ const mintField = mintedCreateField(createDir, agg);
219
+ const baseValue: Record<string, unknown> = { ...synth.value };
220
+ if (mintField !== undefined) delete baseValue[mintField];
221
+ const query = ((mod.queries ?? []) as QueryDecl[]).find(
222
+ (q) => q.returns === agg.id && q.key.length > 0 && q.key.every((k) => !k.includes(".") && baseValue[k] !== undefined),
223
+ );
224
+ const count = (mod.counts ?? [])
225
+ .map((raw) => finishCount(raw))
226
+ .find((c) => c.of === agg.id && (c as { where?: unknown }).where == null && (c.by == null || baseValue[c.by] !== undefined));
227
+ return {
228
+ domain,
229
+ directiveId: createDir.id,
230
+ aggregateId: agg.id,
231
+ payload: baseValue,
232
+ autoStamped: synth.autoStamped,
233
+ ...(mintField !== undefined ? { mintField } : {}),
234
+ ...(query ? { query: { id: query.id, params: Object.fromEntries(query.key.map((k) => [k, baseValue[k]])) } } : {}),
235
+ ...(count ? { count: { id: count.id, group: count.by != null ? String(baseValue[count.by]) : "" } } : {}),
236
+ };
237
+ }
238
+
239
+ /** The per-domain legs index for `build/<name>.proof-legs.json` — every domain
240
+ * with a creating write, plus the default (the first, which the .mts proves).
241
+ * `githolon proof [--domain <key>]` runs any of these offline. */
242
+ export function proofLegsIndex(modules: readonly DomainModule[]): { default: string; domains: Record<string, ProofLegs> } | null {
243
+ const domains: Record<string, ProofLegs> = {};
244
+ let first: string | undefined;
245
+ for (const mod of modules) {
246
+ // FAIL SOFT per-module: a sibling domain whose payload can't be synthesized
247
+ // (an exotic zod shape synthesizePayload throws on) must not lose the index
248
+ // for the domains that DO synthesize — skip it, keep the rest.
249
+ let legs: ProofLegs | null;
250
+ try {
251
+ legs = proofLegsForModule(mod);
252
+ } catch {
253
+ continue;
254
+ }
255
+ if (legs === null) continue;
256
+ domains[legs.domain] = legs;
257
+ if (first === undefined) first = legs.domain;
258
+ }
259
+ return first === undefined ? null : { default: first, domains };
260
+ }
261
+
191
262
  export function generateTsProof(modules: readonly DomainModule[], opts: TsProofOptions): string {
192
263
  // The proof drives the FIRST domain that has a creating write — without one
193
264
  // there is nothing to author, so there is nothing to prove.
@@ -203,7 +274,11 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
203
274
  const factory = tsClientFactoryName(domain);
204
275
  const hashConst = tsHashConstName(opts.packageName);
205
276
 
206
- const createDir = (mod.directives as Directive<unknown>[]).find((d) => d?.marker === "creates")!;
277
+ // SAME predicate as the mod selection above AND proofLegsForModule — so the
278
+ // proof body, its marker, and the index can never describe different directives.
279
+ const createDir = (mod.directives as Directive<unknown>[]).find(
280
+ (d) => d?.marker === "creates" && typeof d.payloadSchema === "object",
281
+ )!;
207
282
  const agg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === createDir.aggregateId);
208
283
  if (agg === undefined) {
209
284
  throw new Error(
@@ -260,17 +335,9 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
260
335
  const listAccessor = `list${agg.id}s`;
261
336
 
262
337
  // THE MACHINE-READABLE LEGS (parsed by `githolon`'s offline proof runner —
263
- // cli/src/proof_offline.ts — instead of regex-scraping the generated source).
264
- const legsMarker = JSON.stringify({
265
- domain,
266
- directiveId: createDir.id,
267
- aggregateId: agg.id,
268
- payload: baseValue,
269
- autoStamped: synth.autoStamped,
270
- ...(mintField !== undefined ? { mintField } : {}),
271
- ...(query && queryParamsObj ? { query: { id: query.id, params: queryParamsObj } } : {}),
272
- ...(count ? { count: { id: count.id, group: count.by != null ? String(baseValue[count.by]) : "" } } : {}),
273
- });
338
+ // cli/src/proof_offline.ts). The ONE leg-synthesis (`proofLegsForModule`) feeds
339
+ // both this marker and the per-domain `proof-legs.json` index, so they never drift.
340
+ const legsMarker = JSON.stringify(proofLegsForModule(mod));
274
341
 
275
342
  let step = 0;
276
343
  const n = () => ++step;
@@ -104,7 +104,7 @@ import {
104
104
  } from "./stable_ids.js";
105
105
  import type { UsdLayer } from "./usd.js";
106
106
  import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
107
- import { generateTsProof } from "./codegen_proof.js";
107
+ import { generateTsProof, proofLegsIndex } from "./codegen_proof.js";
108
108
 
109
109
  const DSL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
110
110
 
@@ -1287,10 +1287,20 @@ async function main(): Promise<void> {
1287
1287
  // synthesizer can't sample yet still compiles — the skip names its remedy.
1288
1288
  let proofPath: string | undefined;
1289
1289
  let proofSkip: string | undefined;
1290
+ let proofDomains: string[] = [];
1290
1291
  try {
1291
1292
  const proofSrc = generateTsProof(domainModules, { packageName: cfg.name, domainHash });
1292
1293
  proofPath = path.join(outDir, `${cfg.name}.proof.mts`);
1293
1294
  writeFileSync(proofPath, proofSrc, "utf8");
1295
+ // THE PER-DOMAIN LEGS INDEX: every domain with a creating write, so
1296
+ // `githolon proof --domain <key>` proves ANY of them offline (the .mts
1297
+ // proves the primary one live). A multi-domain package no longer locks the
1298
+ // proof to whichever domain happens to be first.
1299
+ const legsIndex = proofLegsIndex(domainModules);
1300
+ if (legsIndex !== null) {
1301
+ writeFileSync(path.join(outDir, `${cfg.name}.proof-legs.json`), JSON.stringify(legsIndex, null, 2) + "\n", "utf8");
1302
+ proofDomains = Object.keys(legsIndex.domains);
1303
+ }
1294
1304
  } catch (e) {
1295
1305
  proofSkip = (e as Error).message;
1296
1306
  }
@@ -1385,6 +1395,9 @@ async function main(): Promise<void> {
1385
1395
  console.log(`prove it OFFLINE first: npx githolon proof # the generated proof's offline legs on a LOCAL holon (no cloud workspace)`);
1386
1396
  console.log(` inner loop: npx githolon dev # watch -> recompile -> law live locally -> offline proof, on every save`);
1387
1397
  console.log(` then the cloud loop: npx githolon proof --live # throwaway workspace, retired on exit (--keep keeps it)`);
1398
+ if (proofDomains.length > 1) {
1399
+ console.log(` this package proves ${proofDomains.length} domains (${proofDomains.join(", ")}) — default '${proofDomains[0]}'; pick another: npx githolon proof --domain <key>`);
1400
+ }
1388
1401
  } else {
1389
1402
  console.log(`prove it: npm run e2e # offline write -> sync -> admission -> cloud query, live`);
1390
1403
  }
package/src/fields.ts CHANGED
@@ -115,8 +115,22 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
115
115
  * `Schema` (which is driver-only, see `encodeKernelSchema`), so it changes no hash.
116
116
  */
117
117
  readonly isOptional: boolean;
118
+ /**
119
+ * E2E ENCRYPTION (e2e_encryption.md slice 2): mark this field's VALUE encrypted — its
120
+ * plaintext is sealed (XChaCha20-Poly1305) by the author client and rides as an opaque
121
+ * ciphertext envelope in the `Str` leaf. PRESENT only when declared; OMITTED otherwise
122
+ * so a plaintext domain is byte-identical (the `nomosEncryption` era key is likewise
123
+ * omitted). VALID ONLY on a field whose aggregate is `visibility: requires(cap)` (the
124
+ * encryption scope ≡ the read scope) and whose driver is Lww-class (the envelope is
125
+ * replaced wholesale; additive/counter merges over ciphertext are meaningless) —
126
+ * `aggregate()` enforces both, else a compile-time throw. (Flag named `isEncrypted` —
127
+ * the `encrypted()` METHOD sets it, mirroring `optional()`/`isOptional`.)
128
+ */
129
+ readonly isEncrypted?: true;
118
130
  /** Returns a copy with the chosen driver (kind literal preserved). */
119
131
  merge(driver: Driver): Field<T, K>;
132
+ /** Mark this field's value ENCRYPTED (sets {@link Field.isEncrypted}). Chainable + non-destructive. */
133
+ encrypted(): Field<T, K>;
120
134
  /**
121
135
  * Mark this (scalar) field OPTIONAL: a folded aggregate may lack it, so the
122
136
  * generated Dart type is nullable + tolerant. Chainable + non-destructive (kind
@@ -139,6 +153,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
139
153
  isOptional?: boolean;
140
154
  mapValueKind?: FieldKind;
141
155
  jsonShape?: "object";
156
+ encrypted?: true;
142
157
  } = {},
143
158
  ): Field<T, K> {
144
159
  const isOptional = extra.isOptional ?? false;
@@ -152,6 +167,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
152
167
  ...(extra.viaField !== undefined ? { viaField: extra.viaField } : {}),
153
168
  ...(extra.mapValueKind !== undefined ? { mapValueKind: extra.mapValueKind } : {}),
154
169
  ...(extra.jsonShape !== undefined ? { jsonShape: extra.jsonShape } : {}),
170
+ ...(extra.encrypted ? { isEncrypted: true as const } : {}),
155
171
  merge(d: Driver) {
156
172
  return makeField<T, K>(kind, zod, d, extra);
157
173
  },
@@ -164,6 +180,12 @@ function makeField<T, K extends FieldKind = FieldKind>(
164
180
  isOptional: true,
165
181
  });
166
182
  },
183
+ encrypted(): Field<T, K> {
184
+ // Carry the flag through merge/optional (extra is threaded), so `.merge(Lww).encrypted()`
185
+ // and `.encrypted().merge(Lww)` agree. The wire schema is UNCHANGED (driver-only) — the
186
+ // `nomosEncryption` era key, built by aggregate()/manifest, is what carries the fact.
187
+ return makeField<T, K>(kind, zod, driver, { ...extra, encrypted: true });
188
+ },
167
189
  };
168
190
  return f;
169
191
  }
@@ -0,0 +1,92 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/encryption_identity.ts — the E2E ENCRYPTION KEY REGISTRY (slice 1 of the
8
+ * `architecture/e2e_encryption.md` arc). Tenant-agnostic framework MECHANISM, the
9
+ * encryption sibling of `framework/rbac.ts`'s authority registry.
10
+ *
11
+ * THE ROLE: the key-handoff lane (slice 4) wraps a scope's epoch secret TO a grant
12
+ * recipient's X25519 PUBLIC key. To do that it must look up "principal → encryption
13
+ * public key" on-chain. This module is that registry: a principal PUBLISHES its X25519
14
+ * public key (the secret stays client-side — WebCrypto non-extractable / native keystore
15
+ * / ~/.holon, never on any chain), and `grant`/the fan-out gate resolve it by principal.
16
+ *
17
+ * PRINCIPAL-KEYED, NOT device-keyed — deliberately. The read-visibility grant
18
+ * (`rbac.ts`) targets a `principal` (the auth-provider uid), and the launch platforms use
19
+ * bare auth-provider principals with no full Actor/DeviceKey model. So the encryption key
20
+ * is keyed by the SAME principal the grant targets — one decoupled registry a tenant
21
+ * composes WITHOUT the full `identity.ts` domain. (Multi-device per principal, and the
22
+ * ed25519 `encKeyProof` binding the X25519 key to a signing identity, are refinements;
23
+ * v1 binds publication to the authoring principal via the intent's own author signature.)
24
+ *
25
+ * SECURITY NOTE — publication is only as strong as the authoring identity. A
26
+ * `publishEncKey` authored under a VERIFIED `x-nomos-auth` token genuinely binds the
27
+ * X25519 key to that principal; under the transitional bare-claim lane it is spoofable
28
+ * exactly like every other bare write. The key-distribution gate (slice 4) must treat a
29
+ * recipient with no published key as "not yet a member" (fail-closed), never wrap to a
30
+ * default. Auth-as-capability (slice 5.3) hardens the binding.
31
+ */
32
+ import { z } from "zod";
33
+ import { aggregate, instance } from "../aggregate.js";
34
+ import { t } from "../fields.js";
35
+ import { Lww } from "../drivers.js";
36
+ import { directive } from "../directive.js";
37
+ import { set, type PlannedOp } from "../ops.js";
38
+ import { query } from "../query.js";
39
+
40
+ /** Base64 of a 32-byte X25519 public key (≈44 chars). The wrap target. */
41
+ const X25519_PUB_B64_RE = /^[A-Za-z0-9+/]{42,44}={0,2}$/;
42
+
43
+ /**
44
+ * The per-principal encryption-key record. Multi-instance, keyed
45
+ * `enc-key-<principal>` (mirrors `rbac.ts`'s `user-permissions-<userId>`). `encPublicKey`
46
+ * is the published X25519 public key; a re-publish (Lww) is a client-driven key rotation
47
+ * (which the owner should follow with a scope epoch rotation so new wraps target it).
48
+ */
49
+ export const EncKey = aggregate("EncKey", {
50
+ principal: t.string().merge(Lww),
51
+ encPublicKey: t.string().merge(Lww), // base64 X25519 (32 bytes)
52
+ publishedAt: t.string().merge(Lww), // ISO-8601, caller-stamped (determinism)
53
+ });
54
+ export type EncKeyHandle = typeof EncKey;
55
+
56
+ /** Deterministic per-principal instance id — `enc-key-<principal>`. */
57
+ export function encKeyInstanceId(principal: string): string {
58
+ return `enc-key-${principal}`;
59
+ }
60
+
61
+ /** The framework PRIMITIVE plan: publish (or rotate) `principal`'s X25519 public key. */
62
+ export function writeEncKey(p: { principal: string; encPublicKey: string; publishedAt: string }): PlannedOp[] {
63
+ if (!X25519_PUB_B64_RE.test(p.encPublicKey)) {
64
+ throw new Error(`publishEncKey: encPublicKey must be base64 of a 32-byte X25519 key`);
65
+ }
66
+ const k = instance(EncKey, encKeyInstanceId(p.principal));
67
+ return [
68
+ set(k, "principal", p.principal),
69
+ set(k, "encPublicKey", p.encPublicKey),
70
+ set(k, "publishedAt", p.publishedAt),
71
+ ];
72
+ }
73
+
74
+ /**
75
+ * `publishEncKey` — the framework directive a principal authors to publish its X25519
76
+ * encryption public key. `.ensures(EncKey)` (upsert-by-deterministic-key — first publish
77
+ * materializes, a re-publish rotates). Generic (not tenant-specific), so it ships as a
78
+ * full directive, not just a plan builder.
79
+ */
80
+ export const publishEncKey = directive("publishEncKey")
81
+ .ensures(EncKey)
82
+ .payload(
83
+ z.object({
84
+ principal: z.string().min(1),
85
+ encPublicKey: z.string().min(1),
86
+ publishedAt: z.string().min(1),
87
+ }),
88
+ )
89
+ .plan((p) => writeEncKey(p));
90
+
91
+ /** Resolve a principal's published encryption key — the wrap-target lookup (slice 4). */
92
+ export const encKeyByPrincipal = query("encKeyByPrincipal").key("principal").returns(EncKey);
@@ -0,0 +1,158 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/encryption_scope.ts — THE GROUP-KEY EVENTS (e2e_encryption.md slice 4).
8
+ * RFC 9420 (MLS) mapped onto the event-sourced ledger, SENDER-KEYS variant (flat
9
+ * per-epoch direct-wrap; TreeKEM dropped — small scopes, §4 of the sheet).
10
+ *
11
+ * A SCOPE is the read cap (`requires("readNote")` ≡ the MLS group). Each scope has
12
+ * EPOCHS; a write encrypts a field with the scope's CURRENT epoch key. The epoch key
13
+ * itself NEVER lands on-chain — only its WRAPS do: per member, the epoch key
14
+ * HPKE-sealed to that member's published X25519 key (`encryption_identity.ts`).
15
+ * Wrapped keys are CIPHERTEXT, safe to publish. A reader replays the WrappedKey
16
+ * events addressed to its principal, unwraps with its device key (host-side, never
17
+ * in the wasm), and decrypts the fields it is authorized for.
18
+ *
19
+ * THE EVENTS (this module's aggregates):
20
+ * - `KeyScope` — the per-scope head: `currentEpoch`. A write's envelope epoch must
21
+ * equal this at the write's position (the gate enforces it — slice 5).
22
+ * - `WrappedKey` — one epoch-key wrap to one member (keyed scope|epoch|principal).
23
+ *
24
+ * THE DIRECTIVE: `advanceKeyEpoch` opens a new epoch and FANS OUT the wraps to every
25
+ * member IN ONE INTENT (the membership change is a single visible, owner-authored
26
+ * event). The wraps are computed CLIENT-SIDE by the committer (who holds the epoch
27
+ * secret + looks up each recipient's pubkey) and ride in the payload as opaque
28
+ * ciphertext; the plan only stores the facts. Forward-only revocation = an advance
29
+ * whose fan-out OMITS the removed member (slice 5 gates the roster + the rotation).
30
+ *
31
+ * NON-DELEGATABLE (critical #2): an encrypted scope's `nomosEncryption` carries
32
+ * `delegatable:false`; the slice-5 fan-out gate derives the roster from DIRECT grants
33
+ * only and refuses a wrap to a delegation-sourced member.
34
+ */
35
+ import { z } from "zod";
36
+ import { aggregate, instance } from "../aggregate.js";
37
+ import { t } from "../fields.js";
38
+ import { Lww } from "../drivers.js";
39
+ import { directive } from "../directive.js";
40
+ import { set, withMarker, type PlannedOp } from "../ops.js";
41
+ import { query } from "../query.js";
42
+
43
+ const CAP_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
44
+ const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
45
+
46
+ /** Deterministic ids — the scope head + a per-member wrap, both replay-stable. */
47
+ export function keyScopeInstanceId(scope: string): string {
48
+ return `keyscope-${scope}`;
49
+ }
50
+ export function wrappedKeyInstanceId(scope: string, epoch: number, principal: string): string {
51
+ return `wrapkey-${scope}-${epoch}-${principal}`;
52
+ }
53
+
54
+ /**
55
+ * The per-scope key-schedule HEAD. `currentEpoch` is advisory-read here; the gate
56
+ * (slice 5) enforces that an encrypted-field write's envelope epoch == this value at
57
+ * the write's position, and that an advance only ever increments it.
58
+ */
59
+ export const KeyScope = aggregate("KeyScope", {
60
+ scope: t.string().merge(Lww),
61
+ currentEpoch: t.int().merge(Lww),
62
+ openedAt: t.string().merge(Lww),
63
+ });
64
+ export type KeyScopeHandle = typeof KeyScope;
65
+
66
+ /**
67
+ * ONE wrapped epoch key, addressed to ONE member. `hpkeEpk` is the ephemeral HPKE
68
+ * public key, `ct` the sealed epoch secret — both opaque base64 ciphertext, safe
69
+ * on-chain. `recipientPubKey` pins which device key it was sealed to (audit). Keyed
70
+ * `wrapkey-<scope>-<epoch>-<principal>` so a re-offer is idempotent.
71
+ */
72
+ export const WrappedKey = aggregate("WrappedKey", {
73
+ scope: t.string().merge(Lww),
74
+ epoch: t.int().merge(Lww),
75
+ recipientPrincipal: t.string().merge(Lww),
76
+ recipientPubKey: t.string().merge(Lww), // base64 X25519 the wrap was sealed to
77
+ hpkeEpk: t.string().merge(Lww), // base64 HPKE ephemeral public key
78
+ ct: t.string().merge(Lww), // base64 sealed epoch secret
79
+ });
80
+ export type WrappedKeyHandle = typeof WrappedKey;
81
+
82
+ /** One wrap entry in an `advanceKeyEpoch` payload (computed client-side). */
83
+ const wrapEntrySchema = z.object({
84
+ recipientPrincipal: z.string().min(1),
85
+ recipientPubKey: z.string().regex(B64_RE),
86
+ hpkeEpk: z.string().regex(B64_RE),
87
+ ct: z.string().regex(B64_RE),
88
+ });
89
+ export type WrapEntry = z.infer<typeof wrapEntrySchema>;
90
+
91
+ /** Build the plan ops for one wrap — a deterministic-keyed WrappedKey record. */
92
+ function writeWrap(scope: string, epoch: number, w: WrapEntry): PlannedOp[] {
93
+ const wk = instance(WrappedKey, wrappedKeyInstanceId(scope, epoch, w.recipientPrincipal));
94
+ // The first op MATERIALIZES this new sibling bucket (`ensures` — a re-offer is idempotent);
95
+ // the rest mutate it. Without the marker the referential gate rejects a set on an absent id.
96
+ return [
97
+ withMarker(set(wk, "scope", scope), "ensures"),
98
+ set(wk, "epoch", epoch),
99
+ set(wk, "recipientPrincipal", w.recipientPrincipal),
100
+ set(wk, "recipientPubKey", w.recipientPubKey),
101
+ set(wk, "hpkeEpk", w.hpkeEpk),
102
+ set(wk, "ct", w.ct),
103
+ ];
104
+ }
105
+
106
+ /**
107
+ * `advanceKeyEpoch` — open a new epoch for `scope` and FAN OUT the wraps to every
108
+ * member, in ONE intent. `epoch` is the new epoch number; `wraps` are the
109
+ * client-computed per-member seals of the new epoch secret. The plan bumps the
110
+ * `KeyScope` head and writes each `WrappedKey`. (Slice 5's fan-out gate verifies the
111
+ * roster == the direct-grant members at this position, that the epoch increments, and
112
+ * — for a revoke — that the removed member is omitted.)
113
+ */
114
+ export const advanceKeyEpoch = directive("advanceKeyEpoch")
115
+ .ensures(KeyScope)
116
+ .payload(
117
+ z.object({
118
+ scope: z.string().regex(CAP_RE),
119
+ epoch: z.number().int().nonnegative(),
120
+ openedAt: z.string().min(1), // ISO-8601, caller-stamped (determinism)
121
+ wraps: z.array(wrapEntrySchema),
122
+ }),
123
+ )
124
+ .plan((p) => {
125
+ const ks = instance(KeyScope, keyScopeInstanceId(p.scope));
126
+ const ops: PlannedOp[] = [
127
+ withMarker(set(ks, "scope", p.scope), "ensures"), // upsert the scope head (first advance creates it)
128
+ set(ks, "currentEpoch", p.epoch),
129
+ set(ks, "openedAt", p.openedAt),
130
+ ];
131
+ for (const w of p.wraps) ops.push(...writeWrap(p.scope, p.epoch, w));
132
+ return ops;
133
+ });
134
+
135
+ /**
136
+ * `wrapKey` — write ONE wrap (the single-member handoff). Beyond its use as a primitive,
137
+ * its `.ensures(WrappedKey)` DECLARATION makes `WrappedKey` an ENSURED type, so the
138
+ * deterministic-keyed `WrappedKey` sibling rows that `advanceKeyEpoch` fans out are
139
+ * admitted by the id-mint gate (the sanctioned upsert-by-deterministic-key category).
140
+ */
141
+ export const wrapKey = directive("wrapKey")
142
+ .ensures(WrappedKey)
143
+ .payload(
144
+ z.object({
145
+ scope: z.string().regex(CAP_RE),
146
+ epoch: z.number().int().nonnegative(),
147
+ recipientPrincipal: z.string().min(1),
148
+ recipientPubKey: z.string().regex(B64_RE),
149
+ hpkeEpk: z.string().regex(B64_RE),
150
+ ct: z.string().regex(B64_RE),
151
+ }),
152
+ )
153
+ .plan((p) => writeWrap(p.scope, p.epoch, p));
154
+
155
+ /** A reader fetches the wraps addressed to it for a scope+epoch (then unwraps host-side). */
156
+ export const wrappedKeysByRecipient = query("wrappedKeysByRecipient").key("recipientPrincipal").returns(WrappedKey);
157
+ /** The current epoch of a scope (the writer reads it to tag its envelope). */
158
+ export const keyScopeByScope = query("keyScopeByScope").key("scope").returns(KeyScope);
package/src/index.ts CHANGED
@@ -13,12 +13,14 @@ export {
13
13
  aggregate,
14
14
  instance,
15
15
  forwardRef,
16
+ requires,
16
17
  type AggregateHandle,
17
18
  type BoundAggregate,
18
19
  type StoredFields,
19
20
  type HasManyFields,
20
21
  type AggregateInvariantFn,
21
22
  type AggregateInvariantVerdict,
23
+ type ReadVisibility,
22
24
  } from "./aggregate.js";
23
25
  export {
24
26
  t,
@@ -202,6 +204,30 @@ export {
202
204
  type Scope,
203
205
  type RoleCatalogue,
204
206
  } from "./framework/rbac.js";
207
+ // E2E ENCRYPTION (e2e_encryption.md slice 1) — the published per-principal X25519
208
+ // encryption-key registry; the wrap-target lookup the key-handoff fan-out resolves.
209
+ export {
210
+ EncKey,
211
+ encKeyInstanceId,
212
+ writeEncKey,
213
+ publishEncKey,
214
+ encKeyByPrincipal,
215
+ type EncKeyHandle,
216
+ } from "./framework/encryption_identity.js";
217
+ // E2E ENCRYPTION (slice 4) — the group-key events (RFC 9420 sender-keys on the ledger).
218
+ export {
219
+ KeyScope,
220
+ WrappedKey,
221
+ advanceKeyEpoch,
222
+ wrapKey,
223
+ wrappedKeysByRecipient,
224
+ keyScopeByScope,
225
+ keyScopeInstanceId,
226
+ wrappedKeyInstanceId,
227
+ type KeyScopeHandle,
228
+ type WrappedKeyHandle,
229
+ type WrapEntry,
230
+ } from "./framework/encryption_scope.js";
205
231
  export {
206
232
  IMPURE_CAPABILITY_STATUSES,
207
233
  IMPURE_CAPABILITY_ENVELOPE,
package/src/manifest.ts CHANGED
@@ -89,6 +89,22 @@ export interface CanonicalAggregate {
89
89
  readonly hasInvariant?: true;
90
90
  /** The LAW-DECLARED instance cap; omitted when uncapped (hash-stable). */
91
91
  readonly cap?: number;
92
+ /**
93
+ * READ VISIBILITY (slice 5.1) — the capability a read TOUCHING this type requires.
94
+ * PRESENT only when the type is declared `visibility: requires(cap)` (PRIVATE);
95
+ * OMITTED ENTIRELY for the default `public`, so a public-only domain is
96
+ * byte-identical in the manifest to before this key existed (golden hashes
97
+ * UNCHANGED — mirrors `cap`/`hasInvariant`). It IS law (the githolon refuses an
98
+ * unauthorized read on it), so adding/changing it MOVES the hash.
99
+ */
100
+ readonly requiresRead?: string;
101
+ /**
102
+ * E2E ENCRYPTION (slice 2) — the STORED field names whose values are `.encrypted()`,
103
+ * SORTED. PRESENT only when ≥1 field is encrypted; OMITTED otherwise (a plaintext
104
+ * aggregate is byte-identical). It IS law (the gate enforces the envelope + the scope),
105
+ * so adding/changing it MOVES the hash. The scope is the aggregate's `requiresRead`.
106
+ */
107
+ readonly encryptedFields?: string[];
92
108
  }
93
109
 
94
110
  /** One captured directive contract — NO `plan`/`scopeFrom` behaviour. */
@@ -1003,6 +1019,12 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1003
1019
  ...(agg.hasInvariant === true ? { hasInvariant: true as const } : {}),
1004
1020
  // The LAW-DECLARED instance cap — hash-bearing (it IS law), omit-when-absent.
1005
1021
  ...(agg.cap !== undefined ? { cap: agg.cap } : {}),
1022
+ // READ VISIBILITY (slice 5.1) — the capability a read of this type requires.
1023
+ // Hash-bearing (it IS law); omit-when-public so all-public domains are byte-stable.
1024
+ ...(agg.visibility !== undefined ? { requiresRead: agg.visibility.requires } : {}),
1025
+ // E2E ENCRYPTION (slice 2) — the encrypted field names. Hash-bearing law;
1026
+ // omit-when-none so plaintext aggregates are byte-stable.
1027
+ ...(agg.encryptedFields !== undefined ? { encryptedFields: [...agg.encryptedFields] } : {}),
1006
1028
  }))
1007
1029
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1008
1030
 
@@ -1026,6 +1048,23 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1026
1048
  }))
1027
1049
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1028
1050
 
1051
+ // E2E ENCRYPTION — critical #5 (e2e_encryption.md §5), the captured-read seal, COMPILE
1052
+ // half. A captured read (`read()` on the write path) of a PRIVATE query would record its
1053
+ // plaintext result onto the intent as PUBLIC, signed, replay-verified chain data —
1054
+ // bypassing the read gate AND the encryption envelope. Forbid it: a `requires(cap)`
1055
+ // query can NEVER be a captured read. (The one gate re-asserts this at admission/replay
1056
+ // via `cap_for_query`, so a hand-crafted raw intent is refused on every peer too.)
1057
+ const privateTypes = new Set(aggregates.filter((a) => a.requiresRead !== undefined).map((a) => a.id));
1058
+ for (const d of directives) {
1059
+ for (const cr of d.capturedReads ?? []) {
1060
+ if (privateTypes.has(cr.returns)) {
1061
+ throw new Error(
1062
+ `directive '${d.id}': captured read '${cr.queryId}' returns the PRIVATE type '${cr.returns}' — a .reads() of a requires(cap) query would write its plaintext onto the chain as public data. Private data may only leave via the gated read path (query/qbyid), never a captured read.`,
1063
+ );
1064
+ }
1065
+ }
1066
+ }
1067
+
1029
1068
  // exists_ entries are COUNT declarations in the IR — merge them into `counts` so the
1030
1069
  // Rust engine sees them as ordinary counts (the marker lives only in the DSL/codegen
1031
1070
  // layer). Combine mod.counts + normalized exists_ before canonicalizing counts.
package/src/usd.ts CHANGED
@@ -213,6 +213,37 @@ export interface UsdLayer {
213
213
  /** directive id → the SORTED query ids its plan may read. */
214
214
  readonly directives: Record<string, string[]>;
215
215
  };
216
+ /**
217
+ * THE READ-VISIBILITY GATE DECLARATION (slice 5.1, `host_routes_only.md` §5.1).
218
+ * Present IFF ≥1 aggregate of this layer is declared PRIVATE (`visibility:
219
+ * requires(cap)`): the law's EXPLICIT opt-in to the githolon gating its own
220
+ * reads. The one wasm read boundary serves a read touching a private type ONLY to
221
+ * a principal that effectively holds the required `cap` in this workspace's own
222
+ * folded role-bindings (the kernel `identity::authz` path). OMITTED for all-public
223
+ * law (byte-identical hash — the EXACT `nomosReadGate`/`nomosInvariantGate` era
224
+ * discipline; every pre-slice-5.1 chain serves reads exactly as before, and a
225
+ * public type is never gated). Keyed by aggregate TYPE name → required capability.
226
+ */
227
+ readonly nomosReadVisibility?: {
228
+ readonly v: 1;
229
+ /** Aggregate TYPE name → the capability a read touching it requires. SORTED by type name. */
230
+ readonly aggregates: Record<string, { requires: string }>;
231
+ };
232
+ /**
233
+ * THE ENCRYPTION GATE DECLARATION (e2e_encryption.md slice 2). Present IFF ≥1 field of
234
+ * this layer is `.encrypted()`: the law's EXPLICIT opt-in to at-rest field ciphertext.
235
+ * The gate enforces the envelope structure + `scope == the aggregate's read cap` +
236
+ * `epoch ≤ active` on every encrypted-field write (key-free, every peer); a key-holder
237
+ * decrypts at read time. OMITTED for plaintext law (byte-identical hash — the EXACT
238
+ * `nomosReadVisibility` era discipline; every pre-encryption chain replays as before).
239
+ * `scope` is the aggregate's `requires(cap)`; `delegatable:false` — an encrypted scope
240
+ * is NON-DELEGATABLE (the adversarial-review fix: a delegated read cap must NOT mint a
241
+ * key), so the key roster is direct grants only. Keyed by aggregate TYPE, SORTED.
242
+ */
243
+ readonly nomosEncryption?: {
244
+ readonly v: 1;
245
+ readonly aggregates: Record<string, { scope: string; fields: string[]; delegatable: false }>;
246
+ };
216
247
  }
217
248
 
218
249
  /** The reserved variant name declaring a set's DEFAULT (USD has a default variant). */
@@ -310,6 +341,19 @@ export function emitUsd(
310
341
  for (const k of [...rgQueries.keys()].sort()) readGateQueries[k] = rgQueries.get(k)!;
311
342
  const readGateDirectives: Record<string, string[]> = {};
312
343
  for (const k of [...rgDirectives.keys()].sort()) readGateDirectives[k] = rgDirectives.get(k)!;
344
+ // THE READ-VISIBILITY GATE (slice 5.1): every aggregate the manifest carries a
345
+ // `requiresRead` for, keyed by TYPE name → required cap. SORTED keys
346
+ // (byte-determinism); omit-when-empty so an all-public layer is byte-identical.
347
+ const visAggregates: Record<string, { requires: string }> = {};
348
+ const encAggregates: Record<string, { scope: string; fields: string[]; delegatable: false }> = {};
349
+ for (const a of [...manifest.aggregates].sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0))) {
350
+ if (a.requiresRead !== undefined) visAggregates[a.id] = { requires: a.requiresRead };
351
+ // THE ENCRYPTION GATE (slice 2): an aggregate with encrypted field(s) — its scope
352
+ // is its read cap (aggregate()/the gate guarantee they coincide). delegatable:false.
353
+ if (a.encryptedFields !== undefined && a.encryptedFields.length > 0 && a.requiresRead !== undefined) {
354
+ encAggregates[a.id] = { scope: a.requiresRead, fields: [...a.encryptedFields], delegatable: false };
355
+ }
356
+ }
313
357
  return {
314
358
  path: l.path,
315
359
  prims: encodeModuleToPrims(l.path, l.module),
@@ -336,6 +380,12 @@ export function emitUsd(
336
380
  },
337
381
  }
338
382
  : {}),
383
+ ...(Object.keys(visAggregates).length > 0
384
+ ? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
385
+ : {}),
386
+ ...(Object.keys(encAggregates).length > 0
387
+ ? { nomosEncryption: { v: 1 as const, aggregates: encAggregates } }
388
+ : {}),
339
389
  };
340
390
  })
341
391
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
package/src/usd_layers.ts CHANGED
@@ -348,6 +348,12 @@ export function usdaLayerText(
348
348
  ...(layer.nomosReadGate !== undefined
349
349
  ? [` custom string nomos:readGateHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosReadGate)))}`]
350
350
  : []),
351
+ ...(layer.nomosReadVisibility !== undefined
352
+ ? [` custom string nomos:readVisibilityHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosReadVisibility)))}`]
353
+ : []),
354
+ ...(layer.nomosEncryption !== undefined
355
+ ? [` custom string nomos:encryptionHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosEncryption)))}`]
356
+ : []),
351
357
  ``,
352
358
  ...body,
353
359
  ` }`,
@@ -502,6 +508,36 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
502
508
  readGate = { v: 1, queries: queriesRec, directives: directivesRec };
503
509
  }
504
510
  }
511
+ // The read-visibility gate (slice 5.1): per-aggregate required-read caps merged
512
+ // later-wins, SORTED keys (matching the composed emission). Omit-when-empty.
513
+ let readVisibility: UsdLayer["nomosReadVisibility"];
514
+ {
515
+ const a = new Map<string, { requires: string }>();
516
+ for (const l of stack) {
517
+ if (l.nomosReadVisibility === undefined) continue;
518
+ for (const [type, spec] of Object.entries(l.nomosReadVisibility.aggregates)) a.set(type, spec);
519
+ }
520
+ if (a.size > 0) {
521
+ const aggregatesRec: Record<string, { requires: string }> = {};
522
+ for (const k of [...a.keys()].sort()) aggregatesRec[k] = a.get(k)!;
523
+ readVisibility = { v: 1, aggregates: aggregatesRec };
524
+ }
525
+ }
526
+ // The encryption gate (slice 2): per-aggregate encrypted-field sets merged later-wins,
527
+ // SORTED keys (matching the composed emission). Omit-when-empty.
528
+ let encryption: UsdLayer["nomosEncryption"];
529
+ {
530
+ const a = new Map<string, { scope: string; fields: string[]; delegatable: false }>();
531
+ for (const l of stack) {
532
+ if (l.nomosEncryption === undefined) continue;
533
+ for (const [type, spec] of Object.entries(l.nomosEncryption.aggregates)) a.set(type, spec);
534
+ }
535
+ if (a.size > 0) {
536
+ const aggregatesRec: Record<string, { scope: string; fields: string[]; delegatable: false }> = {};
537
+ for (const k of [...a.keys()].sort()) aggregatesRec[k] = a.get(k)!;
538
+ encryption = { v: 1, aggregates: aggregatesRec };
539
+ }
540
+ }
505
541
  return {
506
542
  path,
507
543
  prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
@@ -510,6 +546,8 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
510
546
  ...(combineds !== undefined ? { combineds } : {}),
511
547
  ...(stableIds !== undefined ? { nomosStableIds: stableIds } : {}),
512
548
  ...(readGate !== undefined ? { nomosReadGate: readGate } : {}),
549
+ ...(readVisibility !== undefined ? { nomosReadVisibility: readVisibility } : {}),
550
+ ...(encryption !== undefined ? { nomosEncryption: encryption } : {}),
513
551
  };
514
552
  });
515
553
 
@@ -629,6 +667,8 @@ export function parseUsdaDocument(text: string): UsdDocument {
629
667
  const domainByScope = new Map<string, string>();
630
668
  const stableIdsByDomain = new Map<string, UsdLayer["nomosStableIds"]>();
631
669
  const readGateByDomain = new Map<string, UsdLayer["nomosReadGate"]>();
670
+ const readVisibilityByDomain = new Map<string, UsdLayer["nomosReadVisibility"]>();
671
+ const encryptionByDomain = new Map<string, UsdLayer["nomosEncryption"]>();
632
672
  for (const p of prims) {
633
673
  if (p.attrs.get("nomos:kind") === "domain") {
634
674
  const domain = attrString(p, "nomos:domain");
@@ -642,6 +682,16 @@ export function parseUsdaDocument(text: string): UsdDocument {
642
682
  if (typeof rgHex === "string") {
643
683
  readGateByDomain.set(domain, JSON.parse(unhexUtf8(rgHex)) as UsdLayer["nomosReadGate"]);
644
684
  }
685
+ // THE READ-VISIBILITY GATE (slice 5.1): the hex-carried per-aggregate cap map.
686
+ const rvHex = p.attrs.get("nomos:readVisibilityHex");
687
+ if (typeof rvHex === "string") {
688
+ readVisibilityByDomain.set(domain, JSON.parse(unhexUtf8(rvHex)) as UsdLayer["nomosReadVisibility"]);
689
+ }
690
+ // THE ENCRYPTION GATE (slice 2): the hex-carried per-aggregate encrypted-field map.
691
+ const encHex = p.attrs.get("nomos:encryptionHex");
692
+ if (typeof encHex === "string") {
693
+ encryptionByDomain.set(domain, JSON.parse(unhexUtf8(encHex)) as UsdLayer["nomosEncryption"]);
694
+ }
645
695
  }
646
696
  }
647
697
 
@@ -731,6 +781,8 @@ export function parseUsdaDocument(text: string): UsdDocument {
731
781
  const b = buckets.get(domain)!;
732
782
  const sids = stableIdsByDomain.get(domain);
733
783
  const rg = readGateByDomain.get(domain);
784
+ const rv = readVisibilityByDomain.get(domain);
785
+ const enc = encryptionByDomain.get(domain);
734
786
  return {
735
787
  path: `/Nomos/${domain}`,
736
788
  prims: [...b.prims].sort(byPath),
@@ -739,6 +791,8 @@ export function parseUsdaDocument(text: string): UsdDocument {
739
791
  ...(b.combineds.length > 0 ? { combineds: [...b.combineds].sort(byId) } : {}),
740
792
  ...(sids !== undefined ? { nomosStableIds: sids } : {}),
741
793
  ...(rg !== undefined ? { nomosReadGate: rg } : {}),
794
+ ...(rv !== undefined ? { nomosReadVisibility: rv } : {}),
795
+ ...(enc !== undefined ? { nomosEncryption: enc } : {}),
742
796
  };
743
797
  })
744
798
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
@@ -139,9 +139,12 @@ function encodeKernelFieldOp(op: FieldOp, field: Field | undefined): WireFieldOp
139
139
  * to test domain LIFECYCLE.
140
140
  *
141
141
  * For a KERNEL-BACKED local harness — admission through the real gate, folds,
142
- * reads, the whole lifecycle on the same engine plane the cloud edge runs — use
143
- * the CLI: `githolon proof` (the generated proof's offline legs on a local
144
- * holon) and `githolon dev` (the watch→recompile→law-live→proof inner loop).
142
+ * reads, minted ids, the whole lifecycle on the same engine plane the cloud
143
+ * edge runs — use the CLI, NOT this function:
144
+ * • `githolon dev --once` — ONE compile → law-live-locally → proof cycle, then
145
+ * exit (the CI/smoke lane for lifecycle + id-mint tests — a non-zero exit
146
+ * names the jam). `githolon dev` (no flag) is the same on a watch loop.
147
+ * • `githolon proof` — the generated proof's offline legs on a local holon.
145
148
  * Those mint ids, run the gate, and fold — exactly what this function does NOT.
146
149
  */
147
150
  export function executeDirectiveToIntent<P>(