@githolon/dsl 0.6.0 → 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.6.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
@@ -51,6 +51,26 @@ export type ReadVisibility = { readonly requires: string };
51
51
  /** Capability/role names: a non-empty token, no whitespace or quotes (it rides a USD string + a JSON leaf). */
52
52
  const READ_CAP_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
53
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
+
54
74
  /**
55
75
  * Declare an aggregate PRIVATE behind a capability — the value for
56
76
  * `aggregate(id, fields, { visibility: requires("readEntry") })`. The only
@@ -136,6 +156,13 @@ export interface AggregateHandle<
136
156
  * `nomosReadVisibility` era key is likewise omitted). See {@link ReadVisibility}.
137
157
  */
138
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;
139
166
  /**
140
167
  * E2E ENCRYPTION (e2e_encryption.md slice 2) — the STORED field names whose values
141
168
  * are `.encrypted()`, SORTED. PRESENT only when ≥1 field is encrypted; OMITTED
@@ -144,13 +171,31 @@ export interface AggregateHandle<
144
171
  * aggregate is `visibility: requires(cap)` (the scope) and its driver is Lww-class.
145
172
  */
146
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>;
147
192
  }
148
193
 
149
194
  export function aggregate<const Id extends string, F extends Record<string, Field>>(
150
195
  id: Id,
151
196
  fields: F,
152
197
  opts?: { invariant?: AggregateInvariantFn; cap?: number; visibility?: ReadVisibility },
153
- ): AggregateHandle<Id, F> {
198
+ ): AggregateHandle<Id, F> & AudienceMethods<Id, F> {
154
199
  const { stored, hasMany } = partitionFields(fields);
155
200
  if (opts?.cap !== undefined && (!Number.isInteger(opts.cap) || opts.cap < 1)) {
156
201
  throw new Error(`aggregate '${id}': cap must be a positive integer (got ${opts.cap})`);
@@ -180,18 +225,48 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
180
225
  }
181
226
  }
182
227
  }
183
- return {
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 = {
184
237
  id,
185
238
  fields: stored as StoredFields<F>,
186
239
  hasMany: hasMany as HasManyFields<F>,
187
- __isAggregateHandle: true,
240
+ __isAggregateHandle: true as const,
188
241
  ...(opts?.invariant !== undefined
189
242
  ? { hasInvariant: true as const, invariant: opts.invariant }
190
243
  : {}),
191
244
  ...(opts?.cap !== undefined ? { cap: opts.cap } : {}),
192
- ...(opts?.visibility !== undefined ? { visibility: opts.visibility } : {}),
193
245
  ...(encryptedFields.length > 0 ? { encryptedFields } : {}),
246
+ ...(metaFields.length > 0 ? { metaFields } : {}),
194
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);
195
270
  }
196
271
 
197
272
  /** Lww-class driver: a wholesale-replace merge (`Lww`, or `MapOf` wrapping an Lww-class inner). */
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"
@@ -127,10 +128,20 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
127
128
  * the `encrypted()` METHOD sets it, mirroring `optional()`/`isOptional`.)
128
129
  */
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;
130
139
  /** Returns a copy with the chosen driver (kind literal preserved). */
131
140
  merge(driver: Driver): Field<T, K>;
132
141
  /** Mark this field's value ENCRYPTED (sets {@link Field.isEncrypted}). Chainable + non-destructive. */
133
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>;
134
145
  /**
135
146
  * Mark this (scalar) field OPTIONAL: a folded aggregate may lack it, so the
136
147
  * generated Dart type is nullable + tolerant. Chainable + non-destructive (kind
@@ -154,6 +165,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
154
165
  mapValueKind?: FieldKind;
155
166
  jsonShape?: "object";
156
167
  encrypted?: true;
168
+ meta?: true;
157
169
  } = {},
158
170
  ): Field<T, K> {
159
171
  const isOptional = extra.isOptional ?? false;
@@ -168,6 +180,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
168
180
  ...(extra.mapValueKind !== undefined ? { mapValueKind: extra.mapValueKind } : {}),
169
181
  ...(extra.jsonShape !== undefined ? { jsonShape: extra.jsonShape } : {}),
170
182
  ...(extra.encrypted ? { isEncrypted: true as const } : {}),
183
+ ...(extra.meta ? { isMeta: true as const } : {}),
171
184
  merge(d: Driver) {
172
185
  return makeField<T, K>(kind, zod, d, extra);
173
186
  },
@@ -186,6 +199,11 @@ function makeField<T, K extends FieldKind = FieldKind>(
186
199
  // `nomosEncryption` era key, built by aggregate()/manifest, is what carries the fact.
187
200
  return makeField<T, K>(kind, zod, driver, { ...extra, encrypted: true });
188
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
+ },
189
207
  };
190
208
  return f;
191
209
  }
@@ -200,6 +218,12 @@ export const t = {
200
218
  int(): Field<number, "int"> {
201
219
  return makeField("int", z.number().int(), Lww);
202
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
+ },
203
227
  /** Enum via `as const` literal union — value typos squeal at compile time. */
204
228
  enum<const E extends readonly [string, ...string[]]>(values: E): Field<E[number], "enum"> {
205
229
  return makeField<E[number], "enum">(
@@ -0,0 +1,205 @@
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/relations.ts — NATIVE ZANZIBAR / ReBAC TUPLE EVENTS (the ONE authz spine).
8
+ *
9
+ * A relation tuple `object#relation@subject` is a Zanzibar authorization FACT, and on
10
+ * Nomos it is a LEDGER EVENT folded into the projected `relations` table. This module is
11
+ * the DSL surface that AUTHORS those events — the object-keyed, clean-slate-native shape
12
+ * the F4 reversal ratified (the per-USER `UserPermission` map was the pre-Zanzibar relic;
13
+ * object-keying is what gives per-RECORD sharing + `Expand` for free).
14
+ *
15
+ * ── The aggregate (object-keyed) ─────────────────────────────────────────────
16
+ * `RelationTuple` is keyed `reltuple-<object>-<relation>` — ONE aggregate per
17
+ * (object, relation) pair — carrying a `subjects: MapOf(Lww)` map of `subject → leaf`,
18
+ * where each leaf is a JSON `{ status }` ("active" | "revoked"). The projection folds an
19
+ * ACTIVE subject into a `relations(object, relation, subject)` row; a revoked subject
20
+ * yields no row (Check fails closed).
21
+ *
22
+ * ── Why a status leaf, not a remove-wins set ─────────────────────────────────
23
+ * Kernel `RemoveWins` is palette-only (no kernel driver → excluded from the identity
24
+ * manifest), so a native tuple cannot use a remove-wins member set and stay deployable.
25
+ * We replicate `UserPermission`'s sound trick: revoke flips the SAME map entry to
26
+ * `status:"revoked"` (an Lww write over the per-subject key). The gate supplies
27
+ * REMOVE-WINS by refusing to re-grant a subject that was ever revoked in the prefix
28
+ * (slice 4); the projection already drops a revoked leaf. Only Lww/MapOf drivers → the
29
+ * aggregate is identity-manifest-eligible.
30
+ *
31
+ * ── Determinism ──────────────────────────────────────────────────────────────
32
+ * The aggregate id is a pure function of (object, relation); the subject is the map KEY.
33
+ * A re-offered grant is therefore idempotent (upsert-by-deterministic-key — the
34
+ * sanctioned `.ensures` category). Plans carry no clock/random — provenance timestamps
35
+ * ride in the payload as ISO strings.
36
+ *
37
+ * ── Reserved tokens ──────────────────────────────────────────────────────────
38
+ * `object` is `"<type>:<id>"`; `subject` is `user:<uid>`, the public wildcard `user:*`,
39
+ * or a userset `<object>#<relation>`. A raw uid carrying the `:` delimiter or being the
40
+ * bare `*` is refused at the payload boundary so it can never forge a wildcard/userset.
41
+ */
42
+ import { z } from "zod";
43
+ import { aggregate, instance } from "../aggregate.js";
44
+ import { t } from "../fields.js";
45
+ import { Lww, MapOf } from "../drivers.js";
46
+ import { directive } from "../directive.js";
47
+ import { set, setEntry, withMarker, type PlannedOp } from "../ops.js";
48
+ import { query } from "../query.js";
49
+
50
+ /** Tuple subject lifecycle — the same vocabulary the kernel read drops a non-active leaf by. */
51
+ export const RELATION_STATUSES = ["active", "revoked"] as const;
52
+ export type RelationStatus = (typeof RELATION_STATUSES)[number];
53
+
54
+ /**
55
+ * An OBJECT address: `"<type>:<id>"` (per-record grain) or a workspace-scope object such
56
+ * as `"workspace:<ws>"`. The `:` delimiter is structural — a single colon separating the
57
+ * type from the id — so the projection can split it back into the `relations` row's
58
+ * (object) column verbatim. We allow at most the one delimiter the address shape needs.
59
+ */
60
+ const OBJECT_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*:[A-Za-z0-9][A-Za-z0-9_.:/-]*$/;
61
+ /** A relation name (the edge label) — a bare identifier, like a directive/cap name. */
62
+ const RELATION_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
63
+ /**
64
+ * A SUBJECT: `user:<uid>`, the public wildcard `user:*`, or a userset `<object>#<relation>`.
65
+ * v1 admits user-subjects + the wildcard; usersets are reserved for slice 3 (the rewrite
66
+ * layer) and refused here so a raw uid can never smuggle one in.
67
+ */
68
+ const USER_SUBJECT_RE = /^user:([A-Za-z0-9][A-Za-z0-9_.-]*|\*)$/;
69
+
70
+ /** Deterministic per-(object, relation) aggregate instance id — replay-stable. */
71
+ export function relationTupleInstanceId(object: string, relation: string): string {
72
+ return `reltuple-${object}-${relation}`;
73
+ }
74
+
75
+ /**
76
+ * The native relation-tuple aggregate, OBJECT-KEYED. One instance per (object, relation);
77
+ * `subjects` is the per-subject status map. A read of the projected `relations` table is
78
+ * the Check leaf — this aggregate is the WRITE shape that fold produces those rows.
79
+ *
80
+ * - object Lww (the address, materialised on first relate so the aggregate exists)
81
+ * - relation Lww (the edge label)
82
+ * - subjects MapOf(Lww) — subject → JSON `{status}` leaf (active drops to a tuple row)
83
+ */
84
+ export const RelationTuple = aggregate("RelationTuple", {
85
+ object: t.string().merge(Lww),
86
+ relation: t.string().merge(Lww),
87
+ subjects: t.map(t.json()).merge(MapOf(Lww)),
88
+ });
89
+ export type RelationTupleHandle = typeof RelationTuple;
90
+
91
+ /** The per-subject leaf the `subjects` map stores (JSON-encoded). Frozen wire shape. */
92
+ export interface RelationSubjectLeaf {
93
+ status: RelationStatus;
94
+ grantedBy: string;
95
+ grantedAt: string;
96
+ revokedBy?: string;
97
+ revokedAt?: string;
98
+ }
99
+
100
+ /** Strip undefined keys so the JSON leaf is byte-stable (no dropped-slot `"k":undefined`). */
101
+ function compact(obj: Record<string, unknown>): RelationSubjectLeaf {
102
+ const out: Record<string, unknown> = {};
103
+ for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v;
104
+ return out as unknown as RelationSubjectLeaf;
105
+ }
106
+
107
+ interface WriteTupleInput {
108
+ object: string;
109
+ relation: string;
110
+ subject: string;
111
+ grantedBy: string;
112
+ grantedAt: string;
113
+ }
114
+
115
+ /** Lower a tuple write to plan ops. The first op MATERIALIZES the (object, relation)
116
+ * aggregate (`ensures` — a re-offer is idempotent); the subject entry rides the map. */
117
+ function lowerTuple(
118
+ p: WriteTupleInput & {
119
+ status: RelationStatus;
120
+ revokedBy?: string | undefined;
121
+ revokedAt?: string | undefined;
122
+ },
123
+ ): PlannedOp[] {
124
+ const rt = instance(RelationTuple, relationTupleInstanceId(p.object, p.relation));
125
+ const leaf = compact({
126
+ status: p.status,
127
+ grantedBy: p.grantedBy,
128
+ grantedAt: p.grantedAt,
129
+ revokedBy: p.revokedBy,
130
+ revokedAt: p.revokedAt,
131
+ });
132
+ return [
133
+ // upsert the aggregate head (first relate creates it); without this the referential
134
+ // gate would reject a setEntry on an absent id.
135
+ withMarker(set(rt, "object", p.object), "ensures"),
136
+ set(rt, "relation", p.relation),
137
+ setEntry(rt, "subjects", p.subject, JSON.stringify(leaf)),
138
+ ];
139
+ }
140
+
141
+ /**
142
+ * writeTuple — the framework PRIMITIVE: write one ACTIVE tuple `object#relation@subject`.
143
+ * The collapse target of a relate-family directive. Ensures the (object, relation) head.
144
+ */
145
+ export function writeTuple(p: WriteTupleInput): PlannedOp[] {
146
+ return lowerTuple({ ...p, status: "active" });
147
+ }
148
+
149
+ /**
150
+ * removeTuple — the framework PRIMITIVE: flip the subject's leaf to `status:revoked`
151
+ * (an Lww write over the SAME map entry — the projection then drops the tuple row). The
152
+ * gate's remove-wins check (slice 4) refuses a later re-grant of an ever-revoked subject.
153
+ */
154
+ export function removeTuple(
155
+ p: WriteTupleInput & { revokedBy: string; revokedAt: string },
156
+ ): PlannedOp[] {
157
+ return lowerTuple({
158
+ ...p,
159
+ status: "revoked",
160
+ revokedBy: p.revokedBy,
161
+ revokedAt: p.revokedAt,
162
+ });
163
+ }
164
+
165
+ /** The shared payload boundary — validates the address + subject reserved-token rules. */
166
+ const relatePayload = z.object({
167
+ object: z.string().regex(OBJECT_RE, "object must be <type>:<id>"),
168
+ relation: z.string().regex(RELATION_RE),
169
+ subject: z.string().regex(USER_SUBJECT_RE, "subject must be user:<uid> or user:*"),
170
+ grantedBy: z.string().min(1),
171
+ grantedAt: z.string().min(1), // ISO-8601, caller-stamped (determinism)
172
+ });
173
+
174
+ /**
175
+ * `relateSubject` — author one ACTIVE tuple. The `.ensures(RelationTuple)` DECLARATION
176
+ * makes `RelationTuple` an ensured type so the deterministic-keyed (object, relation)
177
+ * aggregate is admitted by the id-mint gate (upsert-by-deterministic-key). The relation
178
+ * itself is the cap the gate will require to author it (slice 4 wires `.requires`).
179
+ */
180
+ export const relateSubject = directive("relateSubject")
181
+ .ensures(RelationTuple)
182
+ .payload(relatePayload)
183
+ .plan((p) => writeTuple(p));
184
+
185
+ /**
186
+ * `unrelateSubject` — revoke a tuple (flip the subject leaf to revoked). Same ensured
187
+ * type; the aggregate already exists, but `ensures` keeps a revoke-before-grant ordering
188
+ * race idempotent (it materialises the head if somehow absent).
189
+ */
190
+ export const unrelateSubject = directive("unrelateSubject")
191
+ .ensures(RelationTuple)
192
+ .payload(
193
+ relatePayload.extend({
194
+ revokedBy: z.string().min(1),
195
+ revokedAt: z.string().min(1),
196
+ }),
197
+ )
198
+ .plan((p) => removeTuple(p));
199
+
200
+ /** Enumerate the tuples for one object (the subjects map per relation). The Check/Expand
201
+ * read serves off the projected `relations` table; this declared query exposes the raw
202
+ * tuple aggregate for tooling/audit. */
203
+ export const relationTuplesByObject = query("relationTuplesByObject")
204
+ .key("object")
205
+ .returns(RelationTuple);
@@ -81,9 +81,18 @@ export const WORKSPACE_TIERS = ["edge", "heavy"] as const;
81
81
  /**
82
82
  * The workspace lifecycle: `requested` (law fact only — nothing physical exists)
83
83
  * → `provisioned` (the worker built the machinery and recorded the owner hash)
84
- * → `retired` (the law has withdrawn it; the worker refuses its bearers).
84
+ * → `retired` (the law has withdrawn it; the worker refuses its bearers)
85
+ * → `reclaimed` (the LAW has authorized the host to DISCARD the custody — the git
86
+ * ledger and the DO state are dropped; the custody-reclaimed FACT remains here
87
+ * forever. The ONE status from which there is no return: retirement is a status,
88
+ * reclamation is the lawful discard of the bytes. See {@link reclaimWorkspace}).
85
89
  */
86
- export const WORKSPACE_STATUSES = ["requested", "provisioned", "retired"] as const;
90
+ export const WORKSPACE_STATUSES = [
91
+ "requested",
92
+ "provisioned",
93
+ "retired",
94
+ "reclaimed",
95
+ ] as const;
87
96
 
88
97
  /** A grant is `active` until the law revokes it — revocation is permanent. */
89
98
  export const GRANT_STATUSES = ["active", "revoked"] as const;
@@ -454,6 +463,36 @@ export const retireWorkspace = directive("retireWorkspace")
454
463
  return [set(ws, "status", "retired" as const)];
455
464
  });
456
465
 
466
+ /**
467
+ * reclaimWorkspace — THE LAWFUL CUSTODY DISCARD. Where {@link retireWorkspace}
468
+ * withdraws the law (a status, the bytes untouched), reclaim is the law AUTHORIZING
469
+ * the host to drop the custody: the worker bailiff, on seeing THIS fact folded,
470
+ * `ARTIFACTS.delete`s the workspace's git ledger and `deleteAll`s its DO storage.
471
+ *
472
+ * THE TWO-PHASE RULE (enforced worker-side, since a plan cannot read prior state):
473
+ * only a `retired` workspace may be reclaimed — retire withdraws, reclaim discards.
474
+ *
475
+ * THE FIXED POINT: this is authored for CHILDREN only. The worker refuses to reclaim
476
+ * ROOT — the genesis workspace is the seat of the law that authorizes reclaim, so the
477
+ * law can reap everything it governs EXCEPT itself. Dropping root is the one act Nomos
478
+ * cannot perform in-law; the custodian does it through Cloudflare directly. The
479
+ * custody-reclaimed fact (this status flip) is append-only and outlives the bytes:
480
+ * Nomos never erases the FACT that a workspace existed and was reclaimed.
481
+ */
482
+ export const reclaimWorkspace = directive("reclaimWorkspace")
483
+ .mutates(CloudWorkspace)
484
+ .payload(
485
+ z.object({
486
+ workspaceId: z.string().min(1),
487
+ reclaimedBy: z.string().min(1),
488
+ reclaimedAt: epochMs,
489
+ }),
490
+ )
491
+ .plan((p) => {
492
+ const ws = instance(CloudWorkspace, p.workspaceId);
493
+ return [set(ws, "status", "reclaimed" as const)];
494
+ });
495
+
457
496
  /**
458
497
  * birthPlatform — A PLATFORM IS BORN: the cloud, one level down. Records the
459
498
  * platform-kind workspace (parented to root, owned by the invited principal,
package/src/index.ts CHANGED
@@ -14,7 +14,10 @@ export {
14
14
  instance,
15
15
  forwardRef,
16
16
  requires,
17
+ OWNER_FIELD,
17
18
  type AggregateHandle,
19
+ type AudienceMethods,
20
+ type Audience,
18
21
  type BoundAggregate,
19
22
  type StoredFields,
20
23
  type HasManyFields,
@@ -32,6 +35,8 @@ export {
32
35
  } from "./fields.js";
33
36
  export {
34
37
  Lww,
38
+ LwwCausal,
39
+ LwwBy,
35
40
  AddWins,
36
41
  Conflict,
37
42
  MapOf,
@@ -64,7 +69,7 @@ export { directive, type Directive, type ReferentialMarker } from "./directive.j
64
69
  // a DDD-fluent `AggregateRef`; the dev `.set`/`.add`(collection)/`.setEntry`(map)/`.relate`(reference)
65
70
  // — naming no id. A directive's `.plan` calls these and returns `[]` (the recorded graph is the write
66
71
  // surface). The dev never sees a "prim" — that is the GitHolon's internal OpenUSD IR.
67
- export { create, existing, type AggregateRef } from "./authoring.js";
72
+ export { create, existing, ensure, type AggregateRef } from "./authoring.js";
68
73
  // THE CAPTURED READ — a plan reads O(1) DSL queries via `read(query, args)`; the result is committed as
69
74
  // the intent's read footprint (deterministic replay + stale-premise detection). The dev calls typed DSL
70
75
  // queries; the library routes them here. The host capability is `nomos.read` (twin of `nomos.rng`).
@@ -204,6 +209,21 @@ export {
204
209
  type Scope,
205
210
  type RoleCatalogue,
206
211
  } from "./framework/rbac.js";
212
+ // NATIVE ZANZIBAR / ReBAC (architecture/zanzibar_native_plan.md, slice 1) — object-keyed
213
+ // relation-tuple events `object#relation@subject`, the clean-slate-native authz spine.
214
+ export {
215
+ RELATION_STATUSES,
216
+ RelationTuple,
217
+ relationTupleInstanceId,
218
+ writeTuple,
219
+ removeTuple,
220
+ relateSubject,
221
+ unrelateSubject,
222
+ relationTuplesByObject,
223
+ type RelationStatus,
224
+ type RelationSubjectLeaf,
225
+ type RelationTupleHandle,
226
+ } from "./framework/relations.js";
207
227
  // E2E ENCRYPTION (e2e_encryption.md slice 1) — the published per-principal X25519
208
228
  // encryption-key registry; the wrap-target lookup the key-handoff fan-out resolves.
209
229
  export {
package/src/manifest.ts CHANGED
@@ -1010,7 +1010,19 @@ function stableIdsForModule(
1010
1010
  */
1011
1011
  export function domainManifest(mod: DomainModule): CanonicalManifest {
1012
1012
  const aggregates: CanonicalAggregate[] = mod.aggregates
1013
- .map((agg) => ({
1013
+ .map((agg) => {
1014
+ // SECURE-BY-DEFAULT — `.owned()` is declared in the audience API, but its owner-SCOPED reads
1015
+ // (the projection row-filter) + per-record E2E are not yet enforced. Refuse to compile owned law
1016
+ // FAIL-CLOSED rather than ship a type whose reads aren't gated (architecture/secure_by_default_dsl.md
1017
+ // build order). Use `.public()` or `.readableBy(cap)` until the `.owned()` slice lands.
1018
+ if (agg.audience?.kind === "owned") {
1019
+ throw new Error(
1020
+ `aggregate '${agg.id}': .owned() is declared but its read-scoping + E2E are not yet enforced — ` +
1021
+ `refusing to compile owned law (fail-closed). Use .public() or .readableBy("<cap>") for now ` +
1022
+ `(the .owned() slice is next — see architecture/secure_by_default_dsl.md).`,
1023
+ );
1024
+ }
1025
+ return {
1014
1026
  id: agg.id,
1015
1027
  schema: sortedSchema(encodeKernelSchema(agg)),
1016
1028
  // OMIT-WHEN-ABSENT: only emit `hasInvariant` when the aggregate declares one,
@@ -1025,7 +1037,8 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1025
1037
  // E2E ENCRYPTION (slice 2) — the encrypted field names. Hash-bearing law;
1026
1038
  // omit-when-none so plaintext aggregates are byte-stable.
1027
1039
  ...(agg.encryptedFields !== undefined ? { encryptedFields: [...agg.encryptedFields] } : {}),
1028
- }))
1040
+ };
1041
+ })
1029
1042
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1030
1043
 
1031
1044
  const directives: CanonicalDirective[] = mod.directives
package/src/usd_layers.ts CHANGED
@@ -99,12 +99,14 @@ export function usdaStringArray(items: readonly string[]): string {
99
99
 
100
100
  /**
101
101
  * A `WireDriver` as a READABLE usda token: unit drivers are their bare serde
102
- * string (`"Lww"`, `"AddWins"`, …); `MapOf` nests as `MapOf(<inner>)`. Readable on
103
- * purpose — the usdcat/usdview tree is the visual proof, so the merge law of every
104
- * field must be legible in it (hex would bury the point).
102
+ * string (`"Lww"`, `"AddWins"`, `"LwwCausal"`, …); `MapOf` nests as `MapOf(<inner>)`;
103
+ * the EXPLICIT TIME-AUTHORITY `LwwBy` carries its principal as `LwwBy(<principal>)`.
104
+ * Readable on purpose — the usdcat/usdview tree is the visual proof, so the merge law
105
+ * (and the time-authority) of every field must be legible in it (hex would bury the point).
105
106
  */
106
107
  export function driverToken(driver: WireDriver): string {
107
108
  if (typeof driver === "string") return driver;
109
+ if ("LwwBy" in driver) return `LwwBy(${driver.LwwBy})`;
108
110
  return `MapOf(${driverToken(driver.MapOf)})`;
109
111
  }
110
112
 
@@ -113,6 +115,15 @@ export function parseDriverToken(token: string): WireDriver {
113
115
  if (token.startsWith("MapOf(") && token.endsWith(")")) {
114
116
  return { MapOf: parseDriverToken(token.slice("MapOf(".length, -1)) };
115
117
  }
118
+ // `LwwBy(<principal>)` — the principal is an opaque non-empty string (uids carry
119
+ // hyphens, so it is NOT the bare-identifier shape the unit-driver branch validates).
120
+ if (token.startsWith("LwwBy(") && token.endsWith(")")) {
121
+ const principal = token.slice("LwwBy(".length, -1);
122
+ if (principal.length === 0) {
123
+ throw new Error(`usd-layers: LwwBy driver token names no time-authority principal`);
124
+ }
125
+ return { LwwBy: principal };
126
+ }
116
127
  if (!/^[A-Za-z][A-Za-z0-9]*$/.test(token)) {
117
128
  throw new Error(`usd-layers: malformed driver token '${token}'`);
118
129
  }
package/src/wire.ts CHANGED
@@ -130,19 +130,23 @@ export interface WireRead {
130
130
 
131
131
  /**
132
132
  * `Driver` — the kernel's externally-tagged serde encoding (`nomos2/kernel/src/lib.rs`).
133
- * Unit variants → bare strings; `MapOf` → `{ MapOf: <inner> }`. This mirrors the kernel
134
- * variants the DSL `encodeDriver` emits: the four originals plus the structural CRDT unit
135
- * drivers `RemoveWins` / `Counter` / `LastPosition` (each a real `merge_field` arm).
133
+ * Unit variants → bare strings; `MapOf` → `{ MapOf: <inner> }`; the newtype `LwwBy(String)`
134
+ * → `{ LwwBy: <principal> }`. This mirrors the kernel variants the DSL `encodeDriver` emits:
135
+ * the four originals, the structural CRDT unit drivers `RemoveWins` / `Counter` /
136
+ * `LastPosition`, and the EXPLICIT TIME-AUTHORITY drivers `LwwCausal` (causal-only LWW) /
137
+ * `LwwBy` (principal-as-time-authority LWW) — each a real `merge_field` arm.
136
138
  * (`NumericMax`/`NumericMin`/`ImmutableAfterCreate`/`OrderedList` are kernel variants no
137
139
  * DSL driver encodes to yet — added here only as the DSL gains a constant for them.)
138
140
  */
139
141
  export type WireDriver =
140
142
  | "Lww"
143
+ | "LwwCausal"
141
144
  | "AddWins"
142
145
  | "Conflict"
143
146
  | "RemoveWins"
144
147
  | "Counter"
145
148
  | "LastPosition"
149
+ | { LwwBy: string }
146
150
  | { MapOf: WireDriver };
147
151
 
148
152
  /** `Schema = BTreeMap<FieldId, Driver>` — a plain object, field -> Driver. */