@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.5.1",
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
- return {
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
- private readonly birth: boolean,
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, marked `creates` iff this ref is a birth. */
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.birth ? { ...op, marker: "creates" } : op);
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), true);
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, false);
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
  }
@@ -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]) {
@@ -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, AddWins, MapOf, Conflict, RemoveWins, Counter, ` +
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}'`);
@@ -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);