@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 +1 -1
- package/src/aggregate.ts +90 -1
- package/src/codegen_proof.ts +79 -12
- package/src/compile_package_main.ts +14 -1
- package/src/fields.ts +22 -0
- package/src/framework/encryption_identity.ts +92 -0
- package/src/framework/encryption_scope.ts +158 -0
- package/src/index.ts +26 -0
- package/src/manifest.ts +39 -0
- package/src/usd.ts +50 -0
- package/src/usd_layers.ts +54 -0
- package/src/wire_encode.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "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
|
*
|
package/src/codegen_proof.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
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));
|
package/src/wire_encode.ts
CHANGED
|
@@ -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
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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>(
|