@githolon/dsl 0.7.1 → 0.9.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/src/usd.ts CHANGED
@@ -30,6 +30,7 @@ import { createHash } from "node:crypto";
30
30
  import {
31
31
  canonicalJson,
32
32
  domainManifest,
33
+ type CanonicalManifest,
33
34
  type CanonicalProjectionReturn,
34
35
  } from "./manifest.js";
35
36
  import {
@@ -39,6 +40,7 @@ import {
39
40
  } from "./compose.js";
40
41
  import type { WireSchema, WireRefMode } from "./wire.js";
41
42
  import type { DomainModule } from "./codegen_dart.js";
43
+ import { relationType, user, key, anyUser } from "./relation_schema.js";
42
44
 
43
45
  /** The referential marker of a directive prim (kernel `RefMode` wire string). */
44
46
  export type RefMode = WireRefMode;
@@ -188,6 +190,62 @@ export interface UsdLayer {
188
190
  /** Declared workspace invariants `{id, on}` the gate executes, sorted by id. */
189
191
  readonly workspaceInvariants?: { id: string; on: string }[];
190
192
  };
193
+ /**
194
+ * THE NATIVE ZANZIBAR RELATION SCHEMA (P1 — the userset-rewrite law as PURE DATA). Present IFF the
195
+ * layer declares ≥1 `relationType`: the law's EXPLICIT opt-in to the recursive Check/Expand gate.
196
+ * The Rust `RewriteEngine` READS this AST (per-type relation rewrites + fail-closed type restrictions
197
+ * + the schema-declared depth/fanout caps); the JS lump never runs it. OMITTED for relation-free law
198
+ * (byte-identical hash — the exact `nomosInvariantGate` era discipline; pre-feature chains replay
199
+ * green, the relation gate inert). Keys SORTED for byte-determinism (the layered flatten reproduces
200
+ * these bytes; see usd_layers.ts). Relation refs are NAMES in P1; the #58 stable-id swap is the
201
+ * localized follow-up.
202
+ */
203
+ readonly nomosRelations?: {
204
+ readonly v: 1;
205
+ /** typeName → { relations: {relName → {rewrite, allowed}}, caps? } — keys sorted. */
206
+ readonly types: Record<
207
+ string,
208
+ {
209
+ readonly relations: Record<string, { readonly rewrite: unknown; readonly allowed: unknown[] }>;
210
+ readonly caps?: { readonly maxDepth: number; readonly maxFanout: number };
211
+ }
212
+ >;
213
+ };
214
+ /**
215
+ * THE NATIVE ZANZIBAR WRITE-AUTHZ GATE (P4). Present IFF ≥1 directive declares `.requiresRelation`:
216
+ * the one gate (`executor::admit`) refuses an author who does not HOLD the relation the directive
217
+ * requires (`engine.check` over the folded tuple store). `requires` maps directiveId → {relation,
218
+ * object}. OMITTED for relation-gate-free law (the era rule — byte-identical hash). Keys sorted.
219
+ */
220
+ readonly nomosRelationGate?: {
221
+ readonly v: 1;
222
+ readonly requires: Record<string, { readonly relation: string; readonly object: string }>;
223
+ };
224
+ /**
225
+ * THE CROSS-CUSTODY ATTESTATION GATE (home_holons §3/§5). Present IFF ≥1 directive declares
226
+ * `.attestationAuthorized()`: the SORTED set of their ids. A directive named here is authorized by a
227
+ * carried §3 signed quote rather than an author block — the one gate verifies the quote (bound to the
228
+ * home's warrant owner) + pins the captured principal to the proven attestation key, and the signed-author
229
+ * doors exempt it. OMITTED for law with no such directive (the era rule — byte-identical hash).
230
+ */
231
+ readonly nomosAttestationGate?: {
232
+ readonly v: 1;
233
+ readonly directives: Record<
234
+ string,
235
+ {
236
+ readonly ownerObject: string;
237
+ readonly ownerField: string;
238
+ readonly fields: {
239
+ readonly asserterWorkspace: string;
240
+ readonly object: string;
241
+ readonly relation: string;
242
+ readonly attestedAt: string;
243
+ readonly publicKey: string;
244
+ readonly signature: string;
245
+ };
246
+ }
247
+ >;
248
+ };
191
249
  /**
192
250
  * STABLE IDENTIFIERS (#58 — names are labels, identity is minted). The layer's
193
251
  * domain/aggregate/field sids + label lineages, carried verbatim from the canonical
@@ -214,15 +272,17 @@ export interface UsdLayer {
214
272
  readonly directives: Record<string, string[]>;
215
273
  };
216
274
  /**
217
- * THE READ-VISIBILITY GATE DECLARATION (slice 5.1, `host_routes_only.md` §5.1).
275
+ * THE READ-VISIBILITY GATE DECLARATION (the native-relations read gate).
218
276
  * Present IFF ≥1 aggregate of this layer is declared PRIVATE (`visibility:
219
277
  * requires(cap)`): the law's EXPLICIT opt-in to the githolon gating its own
220
278
  * reads. The one wasm read boundary serves a read touching a private type ONLY to
221
- * a principal that effectively holds the required `cap` in this workspace's own
222
- * folded role-bindings (the kernel `identity::authz` path). OMITTED for all-public
223
- * law (byte-identical hash — the EXACT `nomosReadGate`/`nomosInvariantGate` era
224
- * discipline; every pre-slice-5.1 chain serves reads exactly as before, and a
225
- * public type is never gated). Keyed by aggregate TYPE name → required capability.
279
+ * a principal that holds the read RELATION `cap` on the workspace-scope object —
280
+ * decided by the native `RewriteEngine.check` over this workspace's own folded
281
+ * relation tuples (the compiler ALSO synthesizes a `workspace#<cap>` relation into
282
+ * `nomosRelations`). OMITTED for all-public law (byte-identical hash — the EXACT
283
+ * `nomosReadGate`/`nomosInvariantGate` era discipline; every pre-feature chain
284
+ * serves reads exactly as before, and a public type is never gated). Keyed by
285
+ * aggregate TYPE name → required capability (= read relation).
226
286
  */
227
287
  readonly nomosReadVisibility?: {
228
288
  readonly v: 1;
@@ -285,7 +345,10 @@ function encodeModuleToPrims(layerPath: string, mod: DomainModule): UsdPrim[] {
285
345
  path: `${layerPath}/${d.id}`,
286
346
  target: d.target,
287
347
  marker: d.marker,
288
- requires: [d.requiresCapability],
348
+ // The legacy RBAC capability set is GONE (full-native teardown) — native write-authz is the
349
+ // `nomosRelationGate` era key (a directive's `.requires(relation)`), read by the one gate, not
350
+ // this prim field. Kept empty so the #137 layer-composition shape is unchanged.
351
+ requires: [],
289
352
  // Carry the declared law boundary straight from the canonical manifest, which
290
353
  // already applies omit-when-empty: `reads`/`emits` are present here ONLY when the
291
354
  // directive declared them, so a boundary-free prim is byte-identical to before.
@@ -300,6 +363,75 @@ function encodeModuleToPrims(layerPath: string, mod: DomainModule): UsdPrim[] {
300
363
  return sortPrims(prims);
301
364
  }
302
365
 
366
+ /**
367
+ * Lower a layer's `relationType` decls into the canonical `nomosRelations` wire object (P1) —
368
+ * keys SORTED for byte-determinism, omit-when-empty (the era rule). Multiple decls for one type
369
+ * MERGE their relations (a relation-name collision is an author error); a caps conflict on one
370
+ * type is an error. Rewrite-child ORDER is authored (semantic); `allowed` is sorted (it is a set).
371
+ */
372
+ function serializeRelationSchema(
373
+ decls: readonly import("./relation_schema.js").RelationTypeDecl[],
374
+ ): UsdLayer["nomosRelations"] {
375
+ if (decls.length === 0) return undefined;
376
+ type AllowedW = { kind: string; type?: string; ns?: string; relation?: string };
377
+ const allowedKey = (a: AllowedW): string =>
378
+ a.kind === "direct" ? `direct:${a.type}` : a.kind === "wildcard" ? "wildcard" : `userset:${a.type}#${a.relation}`;
379
+ const byType = new Map<string, { relations: Map<string, { rewrite: unknown; allowed: unknown[] }>; caps?: { maxDepth: number; maxFanout: number } }>();
380
+ for (const d of decls) {
381
+ let entry = byType.get(d.type);
382
+ if (entry === undefined) {
383
+ entry = { relations: new Map(), ...(d.caps ? { caps: d.caps } : {}) };
384
+ byType.set(d.type, entry);
385
+ } else if (d.caps !== undefined) {
386
+ if (entry.caps !== undefined && (entry.caps.maxDepth !== d.caps.maxDepth || entry.caps.maxFanout !== d.caps.maxFanout)) {
387
+ throw new Error(`relationType("${d.type}"): conflicting caps across declarations`);
388
+ }
389
+ entry.caps = d.caps;
390
+ }
391
+ for (const [rel, def] of d.relations) {
392
+ if (entry.relations.has(rel)) throw new Error(`relationType("${d.type}"): relation "${rel}" declared in two relationType() blocks`);
393
+ const allowed = [...def.allowed].sort((x, y) => (allowedKey(x as AllowedW) < allowedKey(y as AllowedW) ? -1 : 1));
394
+ entry.relations.set(rel, { rewrite: def.rewrite, allowed });
395
+ }
396
+ }
397
+ const types: Record<string, { relations: Record<string, { rewrite: unknown; allowed: unknown[] }>; caps?: { maxDepth: number; maxFanout: number } }> = {};
398
+ for (const type of [...byType.keys()].sort()) {
399
+ const entry = byType.get(type)!;
400
+ const relations: Record<string, { rewrite: unknown; allowed: unknown[] }> = {};
401
+ for (const rel of [...entry.relations.keys()].sort()) relations[rel] = entry.relations.get(rel)!;
402
+ types[type] = { relations, ...(entry.caps ? { caps: entry.caps } : {}) };
403
+ }
404
+ return { v: 1 as const, types };
405
+ }
406
+
407
+ /**
408
+ * Synthesize the NATIVE read relations a layer's private aggregates imply. Each distinct
409
+ * `requires(cap)` becomes ONE relation `cap` on the workspace-scope object, allowing the leaf
410
+ * subject classes a read principal can be (`user:<uid>`, the `key:<hash>` control-plane subject,
411
+ * and the `user:*` public wildcard). The read gate's `RewriteEngine.check(workspace:self, cap,
412
+ * Subject::parse(principal))` resolves THIS relation against the tuples a `relateSubject` grant
413
+ * folds — so a granted reader is admitted and a non-grantee fail-closes (no tuple). Pure-direct
414
+ * `this` rewrite (a flat membership relation — the read-cap is just "who was granted it").
415
+ */
416
+ function synthesizeReadRelationTypes(
417
+ manifest: CanonicalManifest,
418
+ ): import("./relation_schema.js").RelationTypeDecl[] {
419
+ const caps = new Set<string>();
420
+ for (const a of manifest.aggregates) {
421
+ if (a.requiresRead !== undefined) caps.add(a.requiresRead);
422
+ }
423
+ if (caps.size === 0) return [];
424
+ const builder = relationType("workspace");
425
+ // Sorted for byte-determinism (the serializer also sorts, but keep the source stable). PURE
426
+ // DIRECT (`rewrite` omitted ⇒ `null`, not `self()`): a flat membership relation — the read-cap is
427
+ // exactly "who was granted it". (`self()`/`Rewrite::This` inside a rewrite expands to an EMPTY
428
+ // leaf in v1, which would zero the encryption fan-out roster — the direct form is what Expand reads.)
429
+ for (const cap of [...caps].sort()) {
430
+ builder.relation(cap, { allow: [user(), key(), anyUser()] });
431
+ }
432
+ return [builder];
433
+ }
434
+
303
435
  /**
304
436
  * Encode each `{ path, module }` into a `UsdLayer` of prims under that path, returning
305
437
  * the canonical `UsdDocument`: layers SORTED by path, prims SORTED by path.
@@ -323,6 +455,66 @@ export function emitUsd(
323
455
  const wsInvariants = [...(l.module.workspaceInvariants ?? [])]
324
456
  .map((w) => ({ id: w.id, on: w.on }))
325
457
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
458
+ // THE READ-VISIBILITY → NATIVE RELATION lowering (the one-model teardown): a private
459
+ // aggregate's `requires(cap)` IS a native read relation on the workspace-scope object. Each
460
+ // distinct read cap synthesizes a `relationType("workspace").relation(cap, { allow:[user, key,
461
+ // anyUser] })` so the ONE evaluator (`RewriteEngine.check`) resolves it at the read gate — the
462
+ // SAME relation a `relateSubject({ object:"workspace:self", relation:cap, ... })` grant folds
463
+ // into. The `nomosReadVisibility` key (type → cap) is RETAINED as the type-to-relation map; the
464
+ // synthesized schema is what makes that cap a checkable relation. SORTED + omit-when-empty.
465
+ const readRelationTypes = synthesizeReadRelationTypes(manifest);
466
+ // THE RELATION SCHEMA (P1): per-type rewrites lowered to the nomosRelations AST, UNIONED with the
467
+ // synthesized read relations. SORTED keys (byte-determinism — the layered flatten reproduces these
468
+ // bytes); omit-when-empty (era rule). A read-cap name colliding with an authored `workspace`
469
+ // relation is an author error (caught in the merge).
470
+ const nomosRelations = serializeRelationSchema([
471
+ ...(l.module.relationTypes ?? []),
472
+ ...readRelationTypes,
473
+ ]);
474
+ // THE RELATION GATE (P4): directives declaring `.requiresRelation` → directiveId → {relation,
475
+ // object}. SORTED keys; omit-when-empty (era rule — a relation-gate-free layer is byte-identical).
476
+ const relationGateReq: Record<
477
+ string,
478
+ { relation: string; object: string; objectFrom?: { recordType: string; idField: string } }
479
+ > = {};
480
+ for (const d of [...l.module.directives].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
481
+ if (d.requiresRelationGate !== undefined) {
482
+ relationGateReq[d.id] = {
483
+ relation: d.requiresRelationGate.relation,
484
+ object: d.requiresRelationGate.object,
485
+ // PER-RECORD object (home_holons §6): carried through to the wasm gate; omitted when absent
486
+ // so a self-relative directive's USD is byte-identical (the era rule).
487
+ ...(d.requiresRelationGate.objectFrom !== undefined
488
+ ? { objectFrom: d.requiresRelationGate.objectFrom }
489
+ : {}),
490
+ };
491
+ }
492
+ }
493
+ const nomosRelationGate =
494
+ Object.keys(relationGateReq).length > 0 ? { v: 1 as const, requires: relationGateReq } : undefined;
495
+ // THE CROSS-CUSTODY ATTESTATION GATE (home_holons §3/§5): directives declaring `.attestationAuthorized()`
496
+ // → directiveId → its LAW-DECLARED owner-source {ownerObject, ownerField}. SORTED keys (byte-
497
+ // determinism). Omit-when-empty (era rule). The one gate verifies the carried §3 quote (binding its
498
+ // subject to the named owner) + pins the principal to the attestation key — the kernel hardcodes no id.
499
+ const attestationReq: Record<
500
+ string,
501
+ {
502
+ ownerObject: string;
503
+ ownerField: string;
504
+ fields: { asserterWorkspace: string; object: string; relation: string; attestedAt: string; publicKey: string; signature: string };
505
+ }
506
+ > = {};
507
+ for (const d of [...l.module.directives].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
508
+ if (d.attestationAuthorizedGate !== undefined) {
509
+ attestationReq[d.id] = {
510
+ ownerObject: d.attestationAuthorizedGate.ownerObject,
511
+ ownerField: d.attestationAuthorizedGate.ownerField,
512
+ fields: { ...d.attestationAuthorizedGate.fields },
513
+ };
514
+ }
515
+ }
516
+ const nomosAttestationGate =
517
+ Object.keys(attestationReq).length > 0 ? { v: 1 as const, directives: attestationReq } : undefined;
326
518
  // THE CAPTURED-READ GATE DECLARATION (#58): collected from the canonical
327
519
  // manifest's per-directive `capturedReads` (omit-when-empty — read-free law's
328
520
  // USD is byte-identical apart from the always-on stableIds key).
@@ -380,6 +572,9 @@ export function emitUsd(
380
572
  },
381
573
  }
382
574
  : {}),
575
+ ...(nomosRelations !== undefined ? { nomosRelations } : {}),
576
+ ...(nomosRelationGate !== undefined ? { nomosRelationGate } : {}),
577
+ ...(nomosAttestationGate !== undefined ? { nomosAttestationGate } : {}),
383
578
  ...(Object.keys(visAggregates).length > 0
384
579
  ? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
385
580
  : {}),
package/src/usd_layers.ts CHANGED
@@ -365,6 +365,15 @@ export function usdaLayerText(
365
365
  ...(layer.nomosEncryption !== undefined
366
366
  ? [` custom string nomos:encryptionHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosEncryption)))}`]
367
367
  : []),
368
+ ...(layer.nomosRelations !== undefined
369
+ ? [` custom string nomos:relationsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosRelations)))}`]
370
+ : []),
371
+ ...(layer.nomosRelationGate !== undefined
372
+ ? [` custom string nomos:relationGateHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosRelationGate)))}`]
373
+ : []),
374
+ ...(layer.nomosAttestationGate !== undefined
375
+ ? [` custom string nomos:attestationGateHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosAttestationGate)))}`]
376
+ : []),
368
377
  ``,
369
378
  ...body,
370
379
  ` }`,
@@ -549,6 +558,51 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
549
558
  encryption = { v: 1, aggregates: aggregatesRec };
550
559
  }
551
560
  }
561
+ // THE RELATION SCHEMA (P1): per-type relation defs merged later-wins across the stack,
562
+ // SORTED keys (matching the composed emission). Omit-when-empty (the nomosRelations era rule).
563
+ let relations: UsdLayer["nomosRelations"];
564
+ {
565
+ const t = new Map<string, NonNullable<UsdLayer["nomosRelations"]>["types"][string]>();
566
+ for (const l of stack) {
567
+ if (l.nomosRelations === undefined) continue;
568
+ for (const [type, spec] of Object.entries(l.nomosRelations.types)) t.set(type, spec);
569
+ }
570
+ if (t.size > 0) {
571
+ const typesRec: NonNullable<UsdLayer["nomosRelations"]>["types"] = {};
572
+ for (const k of [...t.keys()].sort()) typesRec[k] = t.get(k)!;
573
+ relations = { v: 1, types: typesRec };
574
+ }
575
+ }
576
+ // THE RELATION GATE (P4): directiveId → {relation, object} merged later-wins, SORTED. Omit-when-empty.
577
+ let relationGate: UsdLayer["nomosRelationGate"];
578
+ {
579
+ const r = new Map<string, { relation: string; object: string }>();
580
+ for (const l of stack) {
581
+ if (l.nomosRelationGate === undefined) continue;
582
+ for (const [dir, spec] of Object.entries(l.nomosRelationGate.requires)) r.set(dir, spec);
583
+ }
584
+ if (r.size > 0) {
585
+ const requiresRec: Record<string, { relation: string; object: string }> = {};
586
+ for (const k of [...r.keys()].sort()) requiresRec[k] = r.get(k)!;
587
+ relationGate = { v: 1, requires: requiresRec };
588
+ }
589
+ }
590
+ // THE ATTESTATION GATE (home_holons §3/§5): directiveId → owner-source merged later-wins across layers,
591
+ // SORTED keys. Omit-when-empty (era rule).
592
+ let attestationGate: UsdLayer["nomosAttestationGate"];
593
+ {
594
+ type AttSrc = NonNullable<UsdLayer["nomosAttestationGate"]>["directives"][string];
595
+ const a = new Map<string, AttSrc>();
596
+ for (const l of stack) {
597
+ if (l.nomosAttestationGate === undefined) continue;
598
+ for (const [id, src] of Object.entries(l.nomosAttestationGate.directives)) a.set(id, src);
599
+ }
600
+ if (a.size > 0) {
601
+ const rec: Record<string, AttSrc> = {};
602
+ for (const k of [...a.keys()].sort()) rec[k] = a.get(k)!;
603
+ attestationGate = { v: 1, directives: rec };
604
+ }
605
+ }
552
606
  return {
553
607
  path,
554
608
  prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
@@ -559,6 +613,9 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
559
613
  ...(readGate !== undefined ? { nomosReadGate: readGate } : {}),
560
614
  ...(readVisibility !== undefined ? { nomosReadVisibility: readVisibility } : {}),
561
615
  ...(encryption !== undefined ? { nomosEncryption: encryption } : {}),
616
+ ...(relations !== undefined ? { nomosRelations: relations } : {}),
617
+ ...(relationGate !== undefined ? { nomosRelationGate: relationGate } : {}),
618
+ ...(attestationGate !== undefined ? { nomosAttestationGate: attestationGate } : {}),
562
619
  };
563
620
  });
564
621
 
@@ -680,6 +737,9 @@ export function parseUsdaDocument(text: string): UsdDocument {
680
737
  const readGateByDomain = new Map<string, UsdLayer["nomosReadGate"]>();
681
738
  const readVisibilityByDomain = new Map<string, UsdLayer["nomosReadVisibility"]>();
682
739
  const encryptionByDomain = new Map<string, UsdLayer["nomosEncryption"]>();
740
+ const relationsByDomain = new Map<string, UsdLayer["nomosRelations"]>();
741
+ const relationGateByDomain = new Map<string, UsdLayer["nomosRelationGate"]>();
742
+ const attestationGateByDomain = new Map<string, UsdLayer["nomosAttestationGate"]>();
683
743
  for (const p of prims) {
684
744
  if (p.attrs.get("nomos:kind") === "domain") {
685
745
  const domain = attrString(p, "nomos:domain");
@@ -703,6 +763,21 @@ export function parseUsdaDocument(text: string): UsdDocument {
703
763
  if (typeof encHex === "string") {
704
764
  encryptionByDomain.set(domain, JSON.parse(unhexUtf8(encHex)) as UsdLayer["nomosEncryption"]);
705
765
  }
766
+ // THE RELATION SCHEMA (P1): the hex-carried per-type relation rewrite map.
767
+ const relHex = p.attrs.get("nomos:relationsHex");
768
+ if (typeof relHex === "string") {
769
+ relationsByDomain.set(domain, JSON.parse(unhexUtf8(relHex)) as UsdLayer["nomosRelations"]);
770
+ }
771
+ // THE RELATION GATE (P4): the hex-carried directiveId → {relation, object} map.
772
+ const relGateHex = p.attrs.get("nomos:relationGateHex");
773
+ if (typeof relGateHex === "string") {
774
+ relationGateByDomain.set(domain, JSON.parse(unhexUtf8(relGateHex)) as UsdLayer["nomosRelationGate"]);
775
+ }
776
+ // THE ATTESTATION GATE (home_holons §3/§5): the hex-carried attestation-authorized directive set.
777
+ const attGateHex = p.attrs.get("nomos:attestationGateHex");
778
+ if (typeof attGateHex === "string") {
779
+ attestationGateByDomain.set(domain, JSON.parse(unhexUtf8(attGateHex)) as UsdLayer["nomosAttestationGate"]);
780
+ }
706
781
  }
707
782
  }
708
783
 
@@ -794,6 +869,9 @@ export function parseUsdaDocument(text: string): UsdDocument {
794
869
  const rg = readGateByDomain.get(domain);
795
870
  const rv = readVisibilityByDomain.get(domain);
796
871
  const enc = encryptionByDomain.get(domain);
872
+ const rel = relationsByDomain.get(domain);
873
+ const relGate = relationGateByDomain.get(domain);
874
+ const attGate = attestationGateByDomain.get(domain);
797
875
  return {
798
876
  path: `/Nomos/${domain}`,
799
877
  prims: [...b.prims].sort(byPath),
@@ -804,6 +882,9 @@ export function parseUsdaDocument(text: string): UsdDocument {
804
882
  ...(rg !== undefined ? { nomosReadGate: rg } : {}),
805
883
  ...(rv !== undefined ? { nomosReadVisibility: rv } : {}),
806
884
  ...(enc !== undefined ? { nomosEncryption: enc } : {}),
885
+ ...(rel !== undefined ? { nomosRelations: rel } : {}),
886
+ ...(relGate !== undefined ? { nomosRelationGate: relGate } : {}),
887
+ ...(attGate !== undefined ? { nomosAttestationGate: attGate } : {}),
807
888
  };
808
889
  })
809
890
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
package/src/wire.ts CHANGED
@@ -39,7 +39,12 @@ export type WireValue =
39
39
  | { Int: number }
40
40
  | { Set: string[] }
41
41
  | { Map: Record<string, WireField> }
42
- | { Evidence: WireEvidenceRef };
42
+ | { Evidence: WireEvidenceRef }
43
+ // ── STRUCTURAL CRDT state values (only seen when reading RAW folded state back; the read MODEL
44
+ // collapses them via the kernel `observed_value` to Int/Str/Set, so typed reads stay scalar) ──
45
+ | { Counter: Record<string, { pos: number; neg: number }> }
46
+ | { Position: string }
47
+ | { TwoPhaseSet: { adds: string[]; removes: string[] } };
43
48
 
44
49
  /** `EvidenceRef { hash: ContentHash, media_type, byte_length, storage_class }` — the
45
50
  * kernel serde shape. `ContentHash(String)` serializes transparently as a bare string
@@ -57,11 +62,21 @@ export interface WireField {
57
62
  stamp: WireHlc;
58
63
  }
59
64
 
60
- /** `Op = Set(Value) | AddToSet(BTreeSet<String>) | SetEntry { key, value }`. */
65
+ /**
66
+ * `Op` — the kernel's externally-tagged serde shape (`nomos2/kernel/src/lib.rs::Op`). The originals
67
+ * (Set / AddToSet / SetEntry) plus the STRUCTURAL CRDT ops: `AddToTwoPhase`/`RemoveFromTwoPhase`
68
+ * (the `removeWins` 2-phase set), `IncrementCounter`/`DecrementCounter` (the `counter` PN-counter,
69
+ * carrying a bare `u64` amount), and `SetPosition` (the `lastPosition` LWW order-key, a bare string).
70
+ */
61
71
  export type WireOp =
62
72
  | { Set: WireValue }
63
73
  | { AddToSet: string[] }
64
- | { SetEntry: { key: string; value: WireValue } };
74
+ | { SetEntry: { key: string; value: WireValue } }
75
+ | { AddToTwoPhase: string[] }
76
+ | { RemoveFromTwoPhase: string[] }
77
+ | { IncrementCounter: number }
78
+ | { DecrementCounter: number }
79
+ | { SetPosition: string };
65
80
 
66
81
  /** `FieldOp { field: FieldId, op: Op }`. */
67
82
  export interface WireFieldOp {
@@ -100,13 +100,39 @@ function encodeKernelValue(field: Field | undefined, value: unknown): WireValue
100
100
  }
101
101
 
102
102
  function encodeKernelFieldOp(op: FieldOp, field: Field | undefined): WireFieldOp {
103
+ // The field's DRIVER selects the kernel op for the structural CRDTs — a `set`/`addToSet` on a
104
+ // position / remove-wins field lowers to `SetPosition` / `AddToTwoPhase`, never the scalar/AddWins op.
105
+ const driver = field?.driver?.kind;
103
106
  let wireOp: WireOp;
104
107
  switch (op.kind) {
105
108
  case "set":
106
- wireOp = { Set: encodeKernelValue(field, op.value) };
109
+ // A `lastPosition` field's `set` is the kernel `SetPosition` (LWW order-key, a bare string).
110
+ wireOp =
111
+ driver === "LastPosition"
112
+ ? { SetPosition: String(op.value) }
113
+ : { Set: encodeKernelValue(field, op.value) };
107
114
  break;
108
115
  case "addToSet":
109
- wireOp = { AddToSet: [...(op.items ?? [])] };
116
+ // On a remove-wins set the add grows the 2-phase set's `adds`; on AddWins it is the plain union.
117
+ wireOp =
118
+ driver === "RemoveWins"
119
+ ? { AddToTwoPhase: [...(op.items ?? [])] }
120
+ : { AddToSet: [...(op.items ?? [])] };
121
+ break;
122
+ case "removeFromSet":
123
+ if (driver !== "RemoveWins") {
124
+ throw new Error(
125
+ `removeFromSet('${op.field}'): only a RemoveWins set has a remove (an AddWins set is ` +
126
+ `add-only by design). Declare the field \`t.set(...).merge(RemoveWins)\`.`,
127
+ );
128
+ }
129
+ wireOp = { RemoveFromTwoPhase: [...(op.items ?? [])] };
130
+ break;
131
+ case "increment":
132
+ wireOp = { IncrementCounter: Number(op.value) };
133
+ break;
134
+ case "decrement":
135
+ wireOp = { DecrementCounter: Number(op.value) };
110
136
  break;
111
137
  case "setEntry":
112
138
  wireOp = {