@githolon/dsl 0.5.1 → 0.7.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 +168 -4
- package/src/authoring.ts +18 -6
- package/src/build_package.ts +10 -1
- package/src/codegen_dart.ts +10 -0
- package/src/codegen_ts.ts +2 -0
- package/src/drivers.ts +48 -2
- package/src/engine_entry.ts +7 -0
- package/src/fields.ts +46 -0
- package/src/framework/encryption_identity.ts +92 -0
- package/src/framework/encryption_scope.ts +158 -0
- package/src/framework/relations.ts +205 -0
- package/src/framework/workspaces.ts +41 -2
- package/src/index.ts +47 -1
- package/src/manifest.ts +54 -2
- package/src/usd.ts +50 -0
- package/src/usd_layers.ts +68 -3
- package/src/wire.ts +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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,66 @@ 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
|
+
* THE READ AUDIENCE (secure-by-default rework — `architecture/secure_by_default_dsl.md`). Every
|
|
56
|
+
* aggregate DECLARES who may read it; there is no silent default (the compiler refuses an undeclared
|
|
57
|
+
* aggregate). Three choices:
|
|
58
|
+
* • `public` — anyone reads (plaintext). The deliberate exception.
|
|
59
|
+
* • `readableBy` — holders of `cap` read (gate-enforced). Sugar over the shipped read-visibility
|
|
60
|
+
* (`requires(cap)`); per-field `.secret()` escalates to team-E2E.
|
|
61
|
+
* • `owned` — the owner (+ per-record grantees) read; owner = the verified authoring principal,
|
|
62
|
+
* stamped into the `ownerField` (default `__owner`); operator-blind by definition.
|
|
63
|
+
* `.public()`/`.readableBy(cap)`/`.owned()` are the fluent declarations on the aggregate handle.
|
|
64
|
+
*/
|
|
65
|
+
export type Audience =
|
|
66
|
+
| { readonly kind: "public" }
|
|
67
|
+
| { readonly kind: "readableBy"; readonly cap: string }
|
|
68
|
+
| { readonly kind: "owned"; readonly ownerField: string };
|
|
69
|
+
|
|
70
|
+
/** The framework-reserved OWNERSHIP field (`.owned()`) — the verified authoring principal, kernel-style
|
|
71
|
+
* like `__type`/`__id`. The admission gate enforces it is never set to a non-author (slice 0). */
|
|
72
|
+
export const OWNER_FIELD = "__owner";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Declare an aggregate PRIVATE behind a capability — the value for
|
|
76
|
+
* `aggregate(id, fields, { visibility: requires("readEntry") })`. The only
|
|
77
|
+
* non-default {@link ReadVisibility}; `public` is the omitted default.
|
|
78
|
+
*/
|
|
79
|
+
export function requires(capability: string): ReadVisibility {
|
|
80
|
+
if (!READ_CAP_NAME_RE.test(capability)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`requires(): capability must match ${READ_CAP_NAME_RE} (a capability/role name); got ${JSON.stringify(capability)}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return { requires: capability };
|
|
86
|
+
}
|
|
87
|
+
|
|
28
88
|
/**
|
|
29
89
|
* An aggregate has two genuinely different kinds of member, and the DSL keeps them
|
|
30
90
|
* APART so no downstream layer ever has to re-derive the distinction:
|
|
@@ -89,27 +149,131 @@ export interface AggregateHandle<
|
|
|
89
149
|
* cap-free domains hash byte-identically to before this key existed.
|
|
90
150
|
*/
|
|
91
151
|
readonly cap?: number;
|
|
152
|
+
/**
|
|
153
|
+
* READ VISIBILITY (slice 5.1). PRESENT only when the type is declared PRIVATE
|
|
154
|
+
* (`visibility: requires(cap)`); OMITTED ENTIRELY for the default `public`, so a
|
|
155
|
+
* public-only domain is byte-identical to before this key existed (the
|
|
156
|
+
* `nomosReadVisibility` era key is likewise omitted). See {@link ReadVisibility}.
|
|
157
|
+
*/
|
|
158
|
+
readonly visibility?: ReadVisibility;
|
|
159
|
+
/**
|
|
160
|
+
* THE DECLARED READ AUDIENCE (secure-by-default rework). PRESENT once the aggregate declares
|
|
161
|
+
* `.public()`/`.readableBy(cap)`/`.owned()` (or the legacy `{ visibility: requires(cap) }`, which is
|
|
162
|
+
* `readableBy` sugar). UNDEFINED ⇒ no audience declared — `nomos-compile` flags it (the mandatory
|
|
163
|
+
* rule); silence can never ship data. See {@link Audience}.
|
|
164
|
+
*/
|
|
165
|
+
readonly audience?: Audience;
|
|
166
|
+
/**
|
|
167
|
+
* E2E ENCRYPTION (e2e_encryption.md slice 2) — the STORED field names whose values
|
|
168
|
+
* are `.encrypted()`, SORTED. PRESENT only when ≥1 field is encrypted; OMITTED
|
|
169
|
+
* otherwise (a plaintext aggregate is byte-identical, and the `nomosEncryption` era
|
|
170
|
+
* key is likewise omitted). `aggregate()` enforces that an encrypted field's
|
|
171
|
+
* aggregate is `visibility: requires(cap)` (the scope) and its driver is Lww-class.
|
|
172
|
+
*/
|
|
173
|
+
readonly encryptedFields?: string[];
|
|
174
|
+
/**
|
|
175
|
+
* THE `.meta()` FIELDS (secure-by-default) — the STORED field names marked `.meta()` (plaintext
|
|
176
|
+
* metadata, the indexable opt-out on an owned aggregate), SORTED. PRESENT only when ≥1; OMITTED
|
|
177
|
+
* otherwise (era-safe). Indexing/grouping/summing is restricted to these on an owned/secret aggregate.
|
|
178
|
+
*/
|
|
179
|
+
readonly metaFields?: string[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The fluent AUDIENCE declarations returned by {@link aggregate} (secure-by-default). Each returns a
|
|
183
|
+
* copy of the handle with the read audience set; you call exactly one at the declaration site. */
|
|
184
|
+
export interface AudienceMethods<Id extends string, F extends Record<string, Field>> {
|
|
185
|
+
/** Anyone may read (plaintext) — the deliberate public exception. */
|
|
186
|
+
public(): AggregateHandle<Id, F>;
|
|
187
|
+
/** Holders of `capability` may read (gate-enforced) — sugar over `requires(cap)`. */
|
|
188
|
+
readableBy(capability: string): AggregateHandle<Id, F>;
|
|
189
|
+
/** The owner (+ per-record grantees) read; owner = the verified author, stamped into `ownerField`
|
|
190
|
+
* (default `__owner`); operator-blind. NOTE: full owned read-scoping + E2E land in the next slice. */
|
|
191
|
+
owned(ownerField?: string): AggregateHandle<Id, F>;
|
|
92
192
|
}
|
|
93
193
|
|
|
94
194
|
export function aggregate<const Id extends string, F extends Record<string, Field>>(
|
|
95
195
|
id: Id,
|
|
96
196
|
fields: F,
|
|
97
|
-
opts?: { invariant?: AggregateInvariantFn; cap?: number },
|
|
98
|
-
): AggregateHandle<Id, F> {
|
|
197
|
+
opts?: { invariant?: AggregateInvariantFn; cap?: number; visibility?: ReadVisibility },
|
|
198
|
+
): AggregateHandle<Id, F> & AudienceMethods<Id, F> {
|
|
99
199
|
const { stored, hasMany } = partitionFields(fields);
|
|
100
200
|
if (opts?.cap !== undefined && (!Number.isInteger(opts.cap) || opts.cap < 1)) {
|
|
101
201
|
throw new Error(`aggregate '${id}': cap must be a positive integer (got ${opts.cap})`);
|
|
102
202
|
}
|
|
103
|
-
|
|
203
|
+
if (opts?.visibility !== undefined && !READ_CAP_NAME_RE.test(opts.visibility.requires)) {
|
|
204
|
+
throw new Error(`aggregate '${id}': visibility.requires must be a capability name`);
|
|
205
|
+
}
|
|
206
|
+
// E2E ENCRYPTION (slice 2): the fields marked `.encrypted()`, validated. An encrypted
|
|
207
|
+
// field's VALUE is sealed to the aggregate's read scope, so the aggregate MUST be
|
|
208
|
+
// private, and the driver must be Lww-class (the ciphertext envelope is replaced
|
|
209
|
+
// wholesale — additive/counter merges over ciphertext are meaningless).
|
|
210
|
+
const encryptedFields = Object.entries(stored)
|
|
211
|
+
.filter(([, f]) => (f as Field).isEncrypted === true)
|
|
212
|
+
.map(([name]) => name)
|
|
213
|
+
.sort();
|
|
214
|
+
if (encryptedFields.length > 0) {
|
|
215
|
+
if (opts?.visibility === undefined) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`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)`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
for (const name of encryptedFields) {
|
|
221
|
+
if (!isLwwClass((stored[name] as Field).driver)) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`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`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// The `.meta()` fields (plaintext-metadata opt-out on owned aggregates) — recorded for the compiler.
|
|
229
|
+
const metaFields = Object.entries(stored)
|
|
230
|
+
.filter(([, f]) => (f as Field).isMeta === true)
|
|
231
|
+
.map(([name]) => name)
|
|
232
|
+
.sort();
|
|
233
|
+
|
|
234
|
+
// The audience-free DATA shape; the audience is attached below (from the legacy `{visibility}` opt or a
|
|
235
|
+
// fluent `.public()`/`.readableBy()`/`.owned()` call).
|
|
236
|
+
const base = {
|
|
104
237
|
id,
|
|
105
238
|
fields: stored as StoredFields<F>,
|
|
106
239
|
hasMany: hasMany as HasManyFields<F>,
|
|
107
|
-
__isAggregateHandle: true,
|
|
240
|
+
__isAggregateHandle: true as const,
|
|
108
241
|
...(opts?.invariant !== undefined
|
|
109
242
|
? { hasInvariant: true as const, invariant: opts.invariant }
|
|
110
243
|
: {}),
|
|
111
244
|
...(opts?.cap !== undefined ? { cap: opts.cap } : {}),
|
|
245
|
+
...(encryptedFields.length > 0 ? { encryptedFields } : {}),
|
|
246
|
+
...(metaFields.length > 0 ? { metaFields } : {}),
|
|
112
247
|
};
|
|
248
|
+
|
|
249
|
+
// Build a handle with a given audience + the fluent declarations re-attached (so a re-declaration
|
|
250
|
+
// is harmless and the handle the directives reference is always audience-bearing).
|
|
251
|
+
const build = (
|
|
252
|
+
audience: Audience | undefined,
|
|
253
|
+
visibility: ReadVisibility | undefined,
|
|
254
|
+
): AggregateHandle<Id, F> & AudienceMethods<Id, F> => ({
|
|
255
|
+
...base,
|
|
256
|
+
...(audience !== undefined ? { audience } : {}),
|
|
257
|
+
...(visibility !== undefined ? { visibility } : {}),
|
|
258
|
+
public: () => build({ kind: "public" }, undefined),
|
|
259
|
+
readableBy: (cap: string) => {
|
|
260
|
+
const v = requires(cap); // validates the capability name
|
|
261
|
+
return build({ kind: "readableBy", cap: v.requires }, v);
|
|
262
|
+
},
|
|
263
|
+
owned: (ownerField: string = OWNER_FIELD) => build({ kind: "owned", ownerField }, undefined),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Legacy `{ visibility: requires(cap) }` IS `readableBy(cap)` — carry both so existing law is unchanged.
|
|
267
|
+
const initialAudience: Audience | undefined =
|
|
268
|
+
opts?.visibility !== undefined ? { kind: "readableBy", cap: opts.visibility.requires } : undefined;
|
|
269
|
+
return build(initialAudience, opts?.visibility);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Lww-class driver: a wholesale-replace merge (`Lww`, or `MapOf` wrapping an Lww-class inner). */
|
|
273
|
+
function isLwwClass(driver: { kind: string; inner?: { kind: string } }): boolean {
|
|
274
|
+
if (driver.kind === "Lww") return true;
|
|
275
|
+
if (driver.kind === "MapOf" && driver.inner) return isLwwClass(driver.inner as { kind: string });
|
|
276
|
+
return false;
|
|
113
277
|
}
|
|
114
278
|
|
|
115
279
|
/**
|
package/src/authoring.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import type { AggregateHandle } from "./aggregate.js";
|
|
27
27
|
import type { Field } from "./fields.js";
|
|
28
|
-
import type { FieldOp, PlannedOp } from "./ops.js";
|
|
28
|
+
import type { FieldOp, PlannedOp, PlannedEventMarker } from "./ops.js";
|
|
29
29
|
|
|
30
30
|
// ── the per-dispatch authoring sink — library state the engine never sees (NOT a side channel out of
|
|
31
31
|
// the sandbox; it is internal to the directive runtime, drained synchronously by `executeDirectiveToIntent`). ──
|
|
@@ -67,15 +67,17 @@ class Ref implements AggregateRef {
|
|
|
67
67
|
constructor(
|
|
68
68
|
private readonly handle: AggregateHandle,
|
|
69
69
|
readonly __id: string,
|
|
70
|
-
|
|
70
|
+
// The referential marker stamped on EVERY op this ref emits: `creates` (minted birth),
|
|
71
|
+
// `ensures` (deterministic birth-or-update), or null (rides the directive's own marker).
|
|
72
|
+
private readonly marker: PlannedEventMarker | null,
|
|
71
73
|
) {}
|
|
72
74
|
get __type(): string {
|
|
73
75
|
return this.handle.id;
|
|
74
76
|
}
|
|
75
|
-
/** Emit a field op TARGETING THIS aggregate,
|
|
77
|
+
/** Emit a field op TARGETING THIS aggregate, stamped with this ref's marker (if any). */
|
|
76
78
|
private emit(field: string, kind: FieldOp["kind"], extra: Partial<FieldOp>): void {
|
|
77
79
|
const op: FieldOp = { aggregateId: this.__id, aggregateType: this.__type, field, kind, ...extra };
|
|
78
|
-
sink.push(this.
|
|
80
|
+
sink.push(this.marker ? { ...op, marker: this.marker } : op);
|
|
79
81
|
}
|
|
80
82
|
set(field: string, value: unknown): this {
|
|
81
83
|
this.emit(field, "set", { value });
|
|
@@ -110,10 +112,20 @@ class Ref implements AggregateRef {
|
|
|
110
112
|
|
|
111
113
|
/** Ask Nomos to CREATE a new aggregate of the handle's type → a fluent reference (id minted, no choice). */
|
|
112
114
|
export function create(agg: AggregateHandle): AggregateRef {
|
|
113
|
-
return new Ref(agg, mint(agg.id),
|
|
115
|
+
return new Ref(agg, mint(agg.id), "creates");
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
/** A reference to an EXISTING aggregate the dev already holds an id for (e.g. from the payload / a read). */
|
|
117
119
|
export function existing(agg: AggregateHandle, id: string): AggregateRef {
|
|
118
|
-
return new Ref(agg, id,
|
|
120
|
+
return new Ref(agg, id, null);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* ENSURE a sibling aggregate at a caller-chosen DETERMINISTIC id (born-or-updated) — every op it emits is
|
|
125
|
+
* stamped `ensures`, so a multi-aggregate plan needs NO per-op `withMarker(set(...), "ensures")`. A directive
|
|
126
|
+
* carries ONE referential marker for its primary aggregate; the SECOND+ aggregate it touches declares its
|
|
127
|
+
* own birth through this (the fluent sibling of the typed `withMarker(set(instance(Agg, id), …), "ensures")`).
|
|
128
|
+
*/
|
|
129
|
+
export function ensure(agg: AggregateHandle, id: string): AggregateRef {
|
|
130
|
+
return new Ref(agg, id, "ensures");
|
|
119
131
|
}
|
package/src/build_package.ts
CHANGED
|
@@ -55,6 +55,7 @@ import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js"
|
|
|
55
55
|
import { domainHash, emitManifestBytes } from "./manifest.js";
|
|
56
56
|
import { emitUsd } from "./usd.js";
|
|
57
57
|
import { expandCapabilityExports } from "./capability_exports.js";
|
|
58
|
+
import { RelationTuple } from "./framework/relations.js";
|
|
58
59
|
|
|
59
60
|
// ─────────────────────────────────────────────────────────────────────────────────
|
|
60
61
|
// composeDomainModule — the named-keys `module(...)`
|
|
@@ -141,8 +142,16 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
141
142
|
|
|
142
143
|
const byId = new Map<string, AggregateHandle>();
|
|
143
144
|
for (const a of [...aggregatesOf(merged), ...(spec.extraAggregates ?? [])]) byId.set(a.id, a);
|
|
144
|
-
const aggregates = [...byId.values()];
|
|
145
145
|
const directives = directivesOf(merged);
|
|
146
|
+
// RELATIONS AUTO-ENSURE: a directive that `.ensures(RelationTuple)` (the relate-family shape — its plan
|
|
147
|
+
// `writeTuple`s) needs the framework RelationTuple aggregate IN the schema, or the deploy gate never
|
|
148
|
+
// learns the type and the tuple write silently fails admission. Inject it so the dev needn't re-export
|
|
149
|
+
// the framework handle — the same "discoverable without re-exports" courtesy a `capability()` bundle
|
|
150
|
+
// gets. Era-safe: only a domain that authors tuples gains the aggregate, so no existing manifest hash drifts.
|
|
151
|
+
if (!byId.has(RelationTuple.id) && directives.some((d) => d.aggregateId === RelationTuple.id)) {
|
|
152
|
+
byId.set(RelationTuple.id, RelationTuple);
|
|
153
|
+
}
|
|
154
|
+
const aggregates = [...byId.values()];
|
|
146
155
|
const directiveIds = new Set(directives.map((d) => d.id));
|
|
147
156
|
for (const task of spec.impureCapabilities ?? []) {
|
|
148
157
|
for (const directive of [task.order, task.complete, task.fail, task.block, task.deadLetter]) {
|
package/src/codegen_dart.ts
CHANGED
|
@@ -611,6 +611,8 @@ function aggFieldSpec(
|
|
|
611
611
|
}
|
|
612
612
|
case "int":
|
|
613
613
|
return { name, wireName, kind: field.kind, dartType: "int", optional };
|
|
614
|
+
case "bool":
|
|
615
|
+
return { name, wireName, kind: field.kind, dartType: "bool", optional };
|
|
614
616
|
case "enum": {
|
|
615
617
|
// Pull the enum values out of the Zod schema (unwrapping `.optional()`).
|
|
616
618
|
const values = zodEnumValues(unwrapZod(field.zod));
|
|
@@ -1210,6 +1212,10 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
|
|
|
1210
1212
|
return opt
|
|
1211
1213
|
? `readJsonStrOrNull(data, '${s.wireName}')`
|
|
1212
1214
|
: `readJsonStr(data, '${aggregate}', '${s.wireName}')`;
|
|
1215
|
+
case "bool":
|
|
1216
|
+
// The projection decodes a bool field to a real JSON boolean — read it directly (no runtime
|
|
1217
|
+
// helper). Optional → nullable cast; required → a plain cast (absent ⇒ throws, like readInt).
|
|
1218
|
+
return opt ? `(data['${s.wireName}'] as bool?)` : `(data['${s.wireName}'] as bool)`;
|
|
1213
1219
|
case "int":
|
|
1214
1220
|
return opt
|
|
1215
1221
|
? `readIntOrNull(data, '${aggregate}', '${s.wireName}')`
|
|
@@ -1274,6 +1280,8 @@ function readModelDartType(s: AggFieldSpec): string {
|
|
|
1274
1280
|
return s.valueClass ?? "String";
|
|
1275
1281
|
case "int":
|
|
1276
1282
|
return "int";
|
|
1283
|
+
case "bool":
|
|
1284
|
+
return "bool";
|
|
1277
1285
|
case "enum":
|
|
1278
1286
|
return s.dartType; // the generated enum type
|
|
1279
1287
|
case "evidence":
|
|
@@ -1344,6 +1352,8 @@ function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
|
|
|
1344
1352
|
return opt
|
|
1345
1353
|
? `readJsonObjectOrNull(data, '${aggregate}', '${s.wireName}')`
|
|
1346
1354
|
: `readJsonObject(data, '${aggregate}', '${s.wireName}')`;
|
|
1355
|
+
case "bool":
|
|
1356
|
+
return opt ? `(data['${s.wireName}'] as bool?)` : `(data['${s.wireName}'] as bool)`;
|
|
1347
1357
|
case "int":
|
|
1348
1358
|
return opt
|
|
1349
1359
|
? `readIntOrNull(data, '${aggregate}', '${s.wireName}')`
|
package/src/codegen_ts.ts
CHANGED
|
@@ -229,6 +229,8 @@ function tsTypeOfField(field: Field): string {
|
|
|
229
229
|
return "string";
|
|
230
230
|
case "int":
|
|
231
231
|
return "number";
|
|
232
|
+
case "bool":
|
|
233
|
+
return "boolean";
|
|
232
234
|
case "enum":
|
|
233
235
|
try {
|
|
234
236
|
return union(zodEnumValues(field.zod as z.ZodTypeAny).map((v) => JSON.stringify(v)));
|
package/src/drivers.ts
CHANGED
|
@@ -23,6 +23,14 @@ import type { WireDriver } from "./wire.js";
|
|
|
23
23
|
*/
|
|
24
24
|
export type KernelDriverKind =
|
|
25
25
|
| "Lww"
|
|
26
|
+
// ── EXPLICIT TIME-AUTHORITY Lww drivers (the "whose clock?" axis) ──
|
|
27
|
+
// Nomos has no servers, so "do we trust the clock?" is the WRONG question — the real
|
|
28
|
+
// one is WHOSE clock orders THIS field, and a dev assigns it per field. `Lww` trusts
|
|
29
|
+
// the WRITER's wall clock; `LwwCausal` trusts NO clock (causal order only, skew-proof);
|
|
30
|
+
// `LwwBy(principal)` names ONE principal as the field's SOLE time-authority. All three
|
|
31
|
+
// are real `merge_field` arms in `nomos2/kernel/src/lib.rs`.
|
|
32
|
+
| "LwwCausal"
|
|
33
|
+
| "LwwBy"
|
|
26
34
|
| "AddWins"
|
|
27
35
|
| "MapOf"
|
|
28
36
|
| "Conflict"
|
|
@@ -42,6 +50,8 @@ export interface Driver {
|
|
|
42
50
|
readonly kind: DriverKind;
|
|
43
51
|
/** Only present for MapOf. */
|
|
44
52
|
readonly inner?: Driver;
|
|
53
|
+
/** Only present for `LwwBy`: the named time-authority principal (a verified userId). */
|
|
54
|
+
readonly principal?: string;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
export const Lww: Driver = { kind: "Lww" };
|
|
@@ -49,6 +59,34 @@ export const AddWins: Driver = { kind: "AddWins" };
|
|
|
49
59
|
export const Conflict: Driver = { kind: "Conflict" };
|
|
50
60
|
export const MapOf = (inner: Driver): Driver => ({ kind: "MapOf", inner });
|
|
51
61
|
|
|
62
|
+
// ── EXPLICIT TIME-AUTHORITY Lww drivers ──
|
|
63
|
+
// One-liner field tags answering "whose clock orders this field?" — each a real kernel
|
|
64
|
+
// `merge_field` arm (`hlc_lww_cmp` / `lww_authority_admits` in `kernel/src/lib.rs`).
|
|
65
|
+
/**
|
|
66
|
+
* `merge(LwwCausal)` — trust NO wall clock. The field resolves by CAUSAL order (the HLC's
|
|
67
|
+
* `(logical, replica)`, IGNORING `physical`), so a writer with a skewed / fast / lying
|
|
68
|
+
* physical clock can never win merely by stamping a large time. Deterministic + convergent
|
|
69
|
+
* (a total order); clock-skew-proof. Same value space as `Lww` (a wholesale-replace scalar).
|
|
70
|
+
*/
|
|
71
|
+
export const LwwCausal: Driver = { kind: "LwwCausal" };
|
|
72
|
+
/**
|
|
73
|
+
* `merge(LwwBy("coordinator"))` — name ONE principal as the field's time-authority. Only
|
|
74
|
+
* writes AUTHORED BY that principal are honoured (a write by anyone else is ignored), so the
|
|
75
|
+
* named principal is the field's sole writer and therefore the sole clock that orders it.
|
|
76
|
+
* Time-trust is a declared RELATIONSHIP — you say WHOSE clock, per field. The principal is a
|
|
77
|
+
* verified `userId` (the engine's `principal()` port / the intent's captured principal);
|
|
78
|
+
* an empty string is rejected (a time-authority must be named). Same value space as `Lww`.
|
|
79
|
+
*/
|
|
80
|
+
export const LwwBy = (principal: string): Driver => {
|
|
81
|
+
if (typeof principal !== "string" || principal.length === 0) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`LwwBy(principal) needs a non-empty principal — it NAMES the field's sole ` +
|
|
84
|
+
`time-authority. An empty authority can never be matched (fail-closed).`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return { kind: "LwwBy", principal };
|
|
88
|
+
};
|
|
89
|
+
|
|
52
90
|
// Structural CRDT drivers — each encodes to its kernel `merge_field` arm
|
|
53
91
|
// (`kernel/src/lib.rs`): LastPosition = LWW-by-HLC over a position value,
|
|
54
92
|
// RemoveWins = 2P-set union(adds)−union(removes), Counter = PN-counter
|
|
@@ -67,6 +105,14 @@ export function encodeDriver(d: Driver): WireDriver {
|
|
|
67
105
|
switch (d.kind) {
|
|
68
106
|
case "Lww":
|
|
69
107
|
return "Lww";
|
|
108
|
+
// ── EXPLICIT TIME-AUTHORITY Lww drivers (kernel `merge_field` arms) ──
|
|
109
|
+
case "LwwCausal":
|
|
110
|
+
return "LwwCausal";
|
|
111
|
+
case "LwwBy":
|
|
112
|
+
// serde newtype variant `Driver::LwwBy(String)` → `{ "LwwBy": "<principal>" }`.
|
|
113
|
+
if (!d.principal)
|
|
114
|
+
throw new Error("LwwBy driver is missing its time-authority principal");
|
|
115
|
+
return { LwwBy: d.principal };
|
|
70
116
|
case "AddWins":
|
|
71
117
|
return "AddWins";
|
|
72
118
|
case "Conflict":
|
|
@@ -86,8 +132,8 @@ export function encodeDriver(d: Driver): WireDriver {
|
|
|
86
132
|
throw new Error(
|
|
87
133
|
`Driver '${d.kind}' is in the Nomos palette but not yet in the kernel — ` +
|
|
88
134
|
`cannot encode. Implement its merge_field arm in nomos2/kernel first, ` +
|
|
89
|
-
`or pick one of: Lww,
|
|
90
|
-
`LastPosition.`,
|
|
135
|
+
`or pick one of: Lww, LwwCausal, LwwBy, AddWins, MapOf, Conflict, ` +
|
|
136
|
+
`RemoveWins, Counter, LastPosition.`,
|
|
91
137
|
);
|
|
92
138
|
}
|
|
93
139
|
throw new Error(`Unknown driver kind '${(d as Driver).kind}'`);
|
package/src/engine_entry.ts
CHANGED
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
*/
|
|
54
54
|
import { executeDirectiveToIntent } from "./wire_encode.js";
|
|
55
55
|
import { expandCapabilityExports } from "./capability_exports.js";
|
|
56
|
+
import { RelationTuple } from "./framework/relations.js";
|
|
56
57
|
import type { Directive } from "./directive.js";
|
|
57
58
|
import type { AggregateHandle, AggregateInvariantFn, AggregateInvariantVerdict } from "./aggregate.js";
|
|
58
59
|
import type { Ports } from "./ctx.js";
|
|
@@ -233,6 +234,12 @@ function buildDomainSlice(mod: DomainModuleExports): DomainSlice {
|
|
|
233
234
|
const registry = new Map<string, RegistryEntry>();
|
|
234
235
|
const aggs = aggregatesOf(mod);
|
|
235
236
|
const dirs = directivesOf(mod);
|
|
237
|
+
// RELATIONS AUTO-ENSURE (mirror of composeDomainModule): a relate-family directive `.ensures(RelationTuple)`
|
|
238
|
+
// needs the framework aggregate registered for DISPATCH too — without it the directive is silently dropped
|
|
239
|
+
// below. Inject iff a directive targets it and the dev didn't already export the handle.
|
|
240
|
+
if (!aggs.has(RelationTuple.id) && [...dirs.values()].some((d) => d.aggregateId === RelationTuple.id)) {
|
|
241
|
+
aggs.set(RelationTuple.id, RelationTuple);
|
|
242
|
+
}
|
|
236
243
|
for (const [dirId, directive] of dirs) {
|
|
237
244
|
const agg = aggs.get(directive.aggregateId);
|
|
238
245
|
if (agg === undefined) {
|
package/src/fields.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type { AggregateHandle } from "./aggregate.js";
|
|
|
21
21
|
export type FieldKind =
|
|
22
22
|
| "string"
|
|
23
23
|
| "int"
|
|
24
|
+
| "bool"
|
|
24
25
|
| "enum"
|
|
25
26
|
| "set"
|
|
26
27
|
| "map"
|
|
@@ -115,8 +116,32 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
|
|
115
116
|
* `Schema` (which is driver-only, see `encodeKernelSchema`), so it changes no hash.
|
|
116
117
|
*/
|
|
117
118
|
readonly isOptional: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* E2E ENCRYPTION (e2e_encryption.md slice 2): mark this field's VALUE encrypted — its
|
|
121
|
+
* plaintext is sealed (XChaCha20-Poly1305) by the author client and rides as an opaque
|
|
122
|
+
* ciphertext envelope in the `Str` leaf. PRESENT only when declared; OMITTED otherwise
|
|
123
|
+
* so a plaintext domain is byte-identical (the `nomosEncryption` era key is likewise
|
|
124
|
+
* omitted). VALID ONLY on a field whose aggregate is `visibility: requires(cap)` (the
|
|
125
|
+
* encryption scope ≡ the read scope) and whose driver is Lww-class (the envelope is
|
|
126
|
+
* replaced wholesale; additive/counter merges over ciphertext are meaningless) —
|
|
127
|
+
* `aggregate()` enforces both, else a compile-time throw. (Flag named `isEncrypted` —
|
|
128
|
+
* the `encrypted()` METHOD sets it, mirroring `optional()`/`isOptional`.)
|
|
129
|
+
*/
|
|
130
|
+
readonly isEncrypted?: true;
|
|
131
|
+
/**
|
|
132
|
+
* METADATA (secure-by-default rework — `secure_by_default_dsl.md`): on an `.owned()` aggregate the
|
|
133
|
+
* default is that content fields are E2E (operator-blind); `.meta()` opts THIS field OUT to plaintext,
|
|
134
|
+
* operator-visible metadata — and metadata fields are the ONLY ones you may index / group / sum BY
|
|
135
|
+
* (indexing an encrypted field would leak the very plaintext you sealed). A deliberate, declared leak.
|
|
136
|
+
* Flag named `isMeta` — the `meta()` METHOD sets it, mirroring `encrypted()`/`optional()`.
|
|
137
|
+
*/
|
|
138
|
+
readonly isMeta?: true;
|
|
118
139
|
/** Returns a copy with the chosen driver (kind literal preserved). */
|
|
119
140
|
merge(driver: Driver): Field<T, K>;
|
|
141
|
+
/** Mark this field's value ENCRYPTED (sets {@link Field.isEncrypted}). Chainable + non-destructive. */
|
|
142
|
+
encrypted(): Field<T, K>;
|
|
143
|
+
/** Mark this field plaintext METADATA (sets {@link Field.isMeta}) — the indexable opt-out on an owned aggregate. */
|
|
144
|
+
meta(): Field<T, K>;
|
|
120
145
|
/**
|
|
121
146
|
* Mark this (scalar) field OPTIONAL: a folded aggregate may lack it, so the
|
|
122
147
|
* generated Dart type is nullable + tolerant. Chainable + non-destructive (kind
|
|
@@ -139,6 +164,8 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
139
164
|
isOptional?: boolean;
|
|
140
165
|
mapValueKind?: FieldKind;
|
|
141
166
|
jsonShape?: "object";
|
|
167
|
+
encrypted?: true;
|
|
168
|
+
meta?: true;
|
|
142
169
|
} = {},
|
|
143
170
|
): Field<T, K> {
|
|
144
171
|
const isOptional = extra.isOptional ?? false;
|
|
@@ -152,6 +179,8 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
152
179
|
...(extra.viaField !== undefined ? { viaField: extra.viaField } : {}),
|
|
153
180
|
...(extra.mapValueKind !== undefined ? { mapValueKind: extra.mapValueKind } : {}),
|
|
154
181
|
...(extra.jsonShape !== undefined ? { jsonShape: extra.jsonShape } : {}),
|
|
182
|
+
...(extra.encrypted ? { isEncrypted: true as const } : {}),
|
|
183
|
+
...(extra.meta ? { isMeta: true as const } : {}),
|
|
155
184
|
merge(d: Driver) {
|
|
156
185
|
return makeField<T, K>(kind, zod, d, extra);
|
|
157
186
|
},
|
|
@@ -164,6 +193,17 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
164
193
|
isOptional: true,
|
|
165
194
|
});
|
|
166
195
|
},
|
|
196
|
+
encrypted(): Field<T, K> {
|
|
197
|
+
// Carry the flag through merge/optional (extra is threaded), so `.merge(Lww).encrypted()`
|
|
198
|
+
// and `.encrypted().merge(Lww)` agree. The wire schema is UNCHANGED (driver-only) — the
|
|
199
|
+
// `nomosEncryption` era key, built by aggregate()/manifest, is what carries the fact.
|
|
200
|
+
return makeField<T, K>(kind, zod, driver, { ...extra, encrypted: true });
|
|
201
|
+
},
|
|
202
|
+
meta(): Field<T, K> {
|
|
203
|
+
// Plaintext-metadata opt-out (owned aggregates) — threaded through merge/optional like `encrypted`.
|
|
204
|
+
// Driver/schema unchanged; the fact rides the aggregate's audience surface, not the wire kind.
|
|
205
|
+
return makeField<T, K>(kind, zod, driver, { ...extra, meta: true });
|
|
206
|
+
},
|
|
167
207
|
};
|
|
168
208
|
return f;
|
|
169
209
|
}
|
|
@@ -178,6 +218,12 @@ export const t = {
|
|
|
178
218
|
int(): Field<number, "int"> {
|
|
179
219
|
return makeField("int", z.number().int(), Lww);
|
|
180
220
|
},
|
|
221
|
+
/** A boolean flag (Lww). Stored as the kernel scalar `"true"`/`"false"` and decoded back to a real
|
|
222
|
+
* JSON boolean on read — so a flag is `t.bool()`, not a 2-value `t.enum`. Counts/filters key on the
|
|
223
|
+
* same string form. (Not encryptable — only Lww-class scalars are, and bool already is.) */
|
|
224
|
+
bool(): Field<boolean, "bool"> {
|
|
225
|
+
return makeField("bool", z.boolean(), Lww);
|
|
226
|
+
},
|
|
181
227
|
/** Enum via `as const` literal union — value typos squeal at compile time. */
|
|
182
228
|
enum<const E extends readonly [string, ...string[]]>(values: E): Field<E[number], "enum"> {
|
|
183
229
|
return makeField<E[number], "enum">(
|
|
@@ -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);
|