@githolon/dsl 0.7.1 → 0.8.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,37 @@ 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
+ };
191
224
  /**
192
225
  * STABLE IDENTIFIERS (#58 — names are labels, identity is minted). The layer's
193
226
  * domain/aggregate/field sids + label lineages, carried verbatim from the canonical
@@ -214,15 +247,17 @@ export interface UsdLayer {
214
247
  readonly directives: Record<string, string[]>;
215
248
  };
216
249
  /**
217
- * THE READ-VISIBILITY GATE DECLARATION (slice 5.1, `host_routes_only.md` §5.1).
250
+ * THE READ-VISIBILITY GATE DECLARATION (the native-relations read gate).
218
251
  * Present IFF ≥1 aggregate of this layer is declared PRIVATE (`visibility:
219
252
  * requires(cap)`): the law's EXPLICIT opt-in to the githolon gating its own
220
253
  * 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.
254
+ * a principal that holds the read RELATION `cap` on the workspace-scope object —
255
+ * decided by the native `RewriteEngine.check` over this workspace's own folded
256
+ * relation tuples (the compiler ALSO synthesizes a `workspace#<cap>` relation into
257
+ * `nomosRelations`). OMITTED for all-public law (byte-identical hash — the EXACT
258
+ * `nomosReadGate`/`nomosInvariantGate` era discipline; every pre-feature chain
259
+ * serves reads exactly as before, and a public type is never gated). Keyed by
260
+ * aggregate TYPE name → required capability (= read relation).
226
261
  */
227
262
  readonly nomosReadVisibility?: {
228
263
  readonly v: 1;
@@ -285,7 +320,10 @@ function encodeModuleToPrims(layerPath: string, mod: DomainModule): UsdPrim[] {
285
320
  path: `${layerPath}/${d.id}`,
286
321
  target: d.target,
287
322
  marker: d.marker,
288
- requires: [d.requiresCapability],
323
+ // The legacy RBAC capability set is GONE (full-native teardown) — native write-authz is the
324
+ // `nomosRelationGate` era key (a directive's `.requires(relation)`), read by the one gate, not
325
+ // this prim field. Kept empty so the #137 layer-composition shape is unchanged.
326
+ requires: [],
289
327
  // Carry the declared law boundary straight from the canonical manifest, which
290
328
  // already applies omit-when-empty: `reads`/`emits` are present here ONLY when the
291
329
  // directive declared them, so a boundary-free prim is byte-identical to before.
@@ -300,6 +338,75 @@ function encodeModuleToPrims(layerPath: string, mod: DomainModule): UsdPrim[] {
300
338
  return sortPrims(prims);
301
339
  }
302
340
 
341
+ /**
342
+ * Lower a layer's `relationType` decls into the canonical `nomosRelations` wire object (P1) —
343
+ * keys SORTED for byte-determinism, omit-when-empty (the era rule). Multiple decls for one type
344
+ * MERGE their relations (a relation-name collision is an author error); a caps conflict on one
345
+ * type is an error. Rewrite-child ORDER is authored (semantic); `allowed` is sorted (it is a set).
346
+ */
347
+ function serializeRelationSchema(
348
+ decls: readonly import("./relation_schema.js").RelationTypeDecl[],
349
+ ): UsdLayer["nomosRelations"] {
350
+ if (decls.length === 0) return undefined;
351
+ type AllowedW = { kind: string; type?: string; ns?: string; relation?: string };
352
+ const allowedKey = (a: AllowedW): string =>
353
+ a.kind === "direct" ? `direct:${a.type}` : a.kind === "wildcard" ? "wildcard" : `userset:${a.type}#${a.relation}`;
354
+ const byType = new Map<string, { relations: Map<string, { rewrite: unknown; allowed: unknown[] }>; caps?: { maxDepth: number; maxFanout: number } }>();
355
+ for (const d of decls) {
356
+ let entry = byType.get(d.type);
357
+ if (entry === undefined) {
358
+ entry = { relations: new Map(), ...(d.caps ? { caps: d.caps } : {}) };
359
+ byType.set(d.type, entry);
360
+ } else if (d.caps !== undefined) {
361
+ if (entry.caps !== undefined && (entry.caps.maxDepth !== d.caps.maxDepth || entry.caps.maxFanout !== d.caps.maxFanout)) {
362
+ throw new Error(`relationType("${d.type}"): conflicting caps across declarations`);
363
+ }
364
+ entry.caps = d.caps;
365
+ }
366
+ for (const [rel, def] of d.relations) {
367
+ if (entry.relations.has(rel)) throw new Error(`relationType("${d.type}"): relation "${rel}" declared in two relationType() blocks`);
368
+ const allowed = [...def.allowed].sort((x, y) => (allowedKey(x as AllowedW) < allowedKey(y as AllowedW) ? -1 : 1));
369
+ entry.relations.set(rel, { rewrite: def.rewrite, allowed });
370
+ }
371
+ }
372
+ const types: Record<string, { relations: Record<string, { rewrite: unknown; allowed: unknown[] }>; caps?: { maxDepth: number; maxFanout: number } }> = {};
373
+ for (const type of [...byType.keys()].sort()) {
374
+ const entry = byType.get(type)!;
375
+ const relations: Record<string, { rewrite: unknown; allowed: unknown[] }> = {};
376
+ for (const rel of [...entry.relations.keys()].sort()) relations[rel] = entry.relations.get(rel)!;
377
+ types[type] = { relations, ...(entry.caps ? { caps: entry.caps } : {}) };
378
+ }
379
+ return { v: 1 as const, types };
380
+ }
381
+
382
+ /**
383
+ * Synthesize the NATIVE read relations a layer's private aggregates imply. Each distinct
384
+ * `requires(cap)` becomes ONE relation `cap` on the workspace-scope object, allowing the leaf
385
+ * subject classes a read principal can be (`user:<uid>`, the `key:<hash>` control-plane subject,
386
+ * and the `user:*` public wildcard). The read gate's `RewriteEngine.check(workspace:self, cap,
387
+ * Subject::parse(principal))` resolves THIS relation against the tuples a `relateSubject` grant
388
+ * folds — so a granted reader is admitted and a non-grantee fail-closes (no tuple). Pure-direct
389
+ * `this` rewrite (a flat membership relation — the read-cap is just "who was granted it").
390
+ */
391
+ function synthesizeReadRelationTypes(
392
+ manifest: CanonicalManifest,
393
+ ): import("./relation_schema.js").RelationTypeDecl[] {
394
+ const caps = new Set<string>();
395
+ for (const a of manifest.aggregates) {
396
+ if (a.requiresRead !== undefined) caps.add(a.requiresRead);
397
+ }
398
+ if (caps.size === 0) return [];
399
+ const builder = relationType("workspace");
400
+ // Sorted for byte-determinism (the serializer also sorts, but keep the source stable). PURE
401
+ // DIRECT (`rewrite` omitted ⇒ `null`, not `self()`): a flat membership relation — the read-cap is
402
+ // exactly "who was granted it". (`self()`/`Rewrite::This` inside a rewrite expands to an EMPTY
403
+ // leaf in v1, which would zero the encryption fan-out roster — the direct form is what Expand reads.)
404
+ for (const cap of [...caps].sort()) {
405
+ builder.relation(cap, { allow: [user(), key(), anyUser()] });
406
+ }
407
+ return [builder];
408
+ }
409
+
303
410
  /**
304
411
  * Encode each `{ path, module }` into a `UsdLayer` of prims under that path, returning
305
412
  * the canonical `UsdDocument`: layers SORTED by path, prims SORTED by path.
@@ -323,6 +430,32 @@ export function emitUsd(
323
430
  const wsInvariants = [...(l.module.workspaceInvariants ?? [])]
324
431
  .map((w) => ({ id: w.id, on: w.on }))
325
432
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
433
+ // THE READ-VISIBILITY → NATIVE RELATION lowering (the one-model teardown): a private
434
+ // aggregate's `requires(cap)` IS a native read relation on the workspace-scope object. Each
435
+ // distinct read cap synthesizes a `relationType("workspace").relation(cap, { allow:[user, key,
436
+ // anyUser] })` so the ONE evaluator (`RewriteEngine.check`) resolves it at the read gate — the
437
+ // SAME relation a `relateSubject({ object:"workspace:self", relation:cap, ... })` grant folds
438
+ // into. The `nomosReadVisibility` key (type → cap) is RETAINED as the type-to-relation map; the
439
+ // synthesized schema is what makes that cap a checkable relation. SORTED + omit-when-empty.
440
+ const readRelationTypes = synthesizeReadRelationTypes(manifest);
441
+ // THE RELATION SCHEMA (P1): per-type rewrites lowered to the nomosRelations AST, UNIONED with the
442
+ // synthesized read relations. SORTED keys (byte-determinism — the layered flatten reproduces these
443
+ // bytes); omit-when-empty (era rule). A read-cap name colliding with an authored `workspace`
444
+ // relation is an author error (caught in the merge).
445
+ const nomosRelations = serializeRelationSchema([
446
+ ...(l.module.relationTypes ?? []),
447
+ ...readRelationTypes,
448
+ ]);
449
+ // THE RELATION GATE (P4): directives declaring `.requiresRelation` → directiveId → {relation,
450
+ // object}. SORTED keys; omit-when-empty (era rule — a relation-gate-free layer is byte-identical).
451
+ const relationGateReq: Record<string, { relation: string; object: string }> = {};
452
+ for (const d of [...l.module.directives].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
453
+ if (d.requiresRelationGate !== undefined) {
454
+ relationGateReq[d.id] = { relation: d.requiresRelationGate.relation, object: d.requiresRelationGate.object };
455
+ }
456
+ }
457
+ const nomosRelationGate =
458
+ Object.keys(relationGateReq).length > 0 ? { v: 1 as const, requires: relationGateReq } : undefined;
326
459
  // THE CAPTURED-READ GATE DECLARATION (#58): collected from the canonical
327
460
  // manifest's per-directive `capturedReads` (omit-when-empty — read-free law's
328
461
  // USD is byte-identical apart from the always-on stableIds key).
@@ -380,6 +513,8 @@ export function emitUsd(
380
513
  },
381
514
  }
382
515
  : {}),
516
+ ...(nomosRelations !== undefined ? { nomosRelations } : {}),
517
+ ...(nomosRelationGate !== undefined ? { nomosRelationGate } : {}),
383
518
  ...(Object.keys(visAggregates).length > 0
384
519
  ? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
385
520
  : {}),
package/src/usd_layers.ts CHANGED
@@ -365,6 +365,12 @@ 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
+ : []),
368
374
  ``,
369
375
  ...body,
370
376
  ` }`,
@@ -549,6 +555,35 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
549
555
  encryption = { v: 1, aggregates: aggregatesRec };
550
556
  }
551
557
  }
558
+ // THE RELATION SCHEMA (P1): per-type relation defs merged later-wins across the stack,
559
+ // SORTED keys (matching the composed emission). Omit-when-empty (the nomosRelations era rule).
560
+ let relations: UsdLayer["nomosRelations"];
561
+ {
562
+ const t = new Map<string, NonNullable<UsdLayer["nomosRelations"]>["types"][string]>();
563
+ for (const l of stack) {
564
+ if (l.nomosRelations === undefined) continue;
565
+ for (const [type, spec] of Object.entries(l.nomosRelations.types)) t.set(type, spec);
566
+ }
567
+ if (t.size > 0) {
568
+ const typesRec: NonNullable<UsdLayer["nomosRelations"]>["types"] = {};
569
+ for (const k of [...t.keys()].sort()) typesRec[k] = t.get(k)!;
570
+ relations = { v: 1, types: typesRec };
571
+ }
572
+ }
573
+ // THE RELATION GATE (P4): directiveId → {relation, object} merged later-wins, SORTED. Omit-when-empty.
574
+ let relationGate: UsdLayer["nomosRelationGate"];
575
+ {
576
+ const r = new Map<string, { relation: string; object: string }>();
577
+ for (const l of stack) {
578
+ if (l.nomosRelationGate === undefined) continue;
579
+ for (const [dir, spec] of Object.entries(l.nomosRelationGate.requires)) r.set(dir, spec);
580
+ }
581
+ if (r.size > 0) {
582
+ const requiresRec: Record<string, { relation: string; object: string }> = {};
583
+ for (const k of [...r.keys()].sort()) requiresRec[k] = r.get(k)!;
584
+ relationGate = { v: 1, requires: requiresRec };
585
+ }
586
+ }
552
587
  return {
553
588
  path,
554
589
  prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
@@ -559,6 +594,8 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
559
594
  ...(readGate !== undefined ? { nomosReadGate: readGate } : {}),
560
595
  ...(readVisibility !== undefined ? { nomosReadVisibility: readVisibility } : {}),
561
596
  ...(encryption !== undefined ? { nomosEncryption: encryption } : {}),
597
+ ...(relations !== undefined ? { nomosRelations: relations } : {}),
598
+ ...(relationGate !== undefined ? { nomosRelationGate: relationGate } : {}),
562
599
  };
563
600
  });
564
601
 
@@ -680,6 +717,8 @@ export function parseUsdaDocument(text: string): UsdDocument {
680
717
  const readGateByDomain = new Map<string, UsdLayer["nomosReadGate"]>();
681
718
  const readVisibilityByDomain = new Map<string, UsdLayer["nomosReadVisibility"]>();
682
719
  const encryptionByDomain = new Map<string, UsdLayer["nomosEncryption"]>();
720
+ const relationsByDomain = new Map<string, UsdLayer["nomosRelations"]>();
721
+ const relationGateByDomain = new Map<string, UsdLayer["nomosRelationGate"]>();
683
722
  for (const p of prims) {
684
723
  if (p.attrs.get("nomos:kind") === "domain") {
685
724
  const domain = attrString(p, "nomos:domain");
@@ -703,6 +742,16 @@ export function parseUsdaDocument(text: string): UsdDocument {
703
742
  if (typeof encHex === "string") {
704
743
  encryptionByDomain.set(domain, JSON.parse(unhexUtf8(encHex)) as UsdLayer["nomosEncryption"]);
705
744
  }
745
+ // THE RELATION SCHEMA (P1): the hex-carried per-type relation rewrite map.
746
+ const relHex = p.attrs.get("nomos:relationsHex");
747
+ if (typeof relHex === "string") {
748
+ relationsByDomain.set(domain, JSON.parse(unhexUtf8(relHex)) as UsdLayer["nomosRelations"]);
749
+ }
750
+ // THE RELATION GATE (P4): the hex-carried directiveId → {relation, object} map.
751
+ const relGateHex = p.attrs.get("nomos:relationGateHex");
752
+ if (typeof relGateHex === "string") {
753
+ relationGateByDomain.set(domain, JSON.parse(unhexUtf8(relGateHex)) as UsdLayer["nomosRelationGate"]);
754
+ }
706
755
  }
707
756
  }
708
757
 
@@ -794,6 +843,8 @@ export function parseUsdaDocument(text: string): UsdDocument {
794
843
  const rg = readGateByDomain.get(domain);
795
844
  const rv = readVisibilityByDomain.get(domain);
796
845
  const enc = encryptionByDomain.get(domain);
846
+ const rel = relationsByDomain.get(domain);
847
+ const relGate = relationGateByDomain.get(domain);
797
848
  return {
798
849
  path: `/Nomos/${domain}`,
799
850
  prims: [...b.prims].sort(byPath),
@@ -804,6 +855,8 @@ export function parseUsdaDocument(text: string): UsdDocument {
804
855
  ...(rg !== undefined ? { nomosReadGate: rg } : {}),
805
856
  ...(rv !== undefined ? { nomosReadVisibility: rv } : {}),
806
857
  ...(enc !== undefined ? { nomosEncryption: enc } : {}),
858
+ ...(rel !== undefined ? { nomosRelations: rel } : {}),
859
+ ...(relGate !== undefined ? { nomosRelationGate: relGate } : {}),
807
860
  };
808
861
  })
809
862
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));