@githolon/dsl 0.69.3 → 0.70.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.69.3",
3
+ "version": "0.70.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",
@@ -61,6 +61,7 @@ import {
61
61
  } from "./birth_meter.js";
62
62
  import { withBirthMeterExports } from "./birth_meter_ensure.js";
63
63
  import { withClearDirectives } from "./clear_directive.js";
64
+ import { expandRoleExports, rolesOf } from "./role.js";
64
65
  import {
65
66
  withAttestedReadExports,
66
67
  isTrustSourceDecl,
@@ -184,6 +185,12 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
184
185
  // `clear<Agg><Field>` directive (mutates + one clear op; `.requires` when declared) at the same
185
186
  // seam — manifest-visible, kernel-gated, proof-covered. Clearable-free domains are byte-identical.
186
187
  merged = withClearDirectives(merged);
188
+ // ROLE SUGAR: lift every `role(...).emit()` into discoverable grant/revoke/seed directives (so
189
+ // `directivesOf` folds them into the manifest) — the SAME "discoverable without re-exports" seam.
190
+ // The role-synthesized `relationType("workspace")` rides `spec.relationTypes` (the compile plane
191
+ // discovered it off the same expansion). Role-free domains are byte-identical (era rule).
192
+ merged = expandRoleExports(merged);
193
+ const roles = rolesOf(merged);
187
194
 
188
195
  const byId = new Map<string, AggregateHandle>();
189
196
  for (const a of [...aggregatesOf(merged), ...(spec.extraAggregates ?? [])]) byId.set(a.id, a);
@@ -388,6 +395,9 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
388
395
  directives,
389
396
  // Diagnostic metadata for the compile summary — NOT law (not hashed). Omit-when-empty.
390
397
  ...(autoDeclaredEnsures.length > 0 ? { autoDeclaredEnsures } : {}),
398
+ // ROLE SUGAR codegen metadata — NOT law (not hashed): drives the `canEdit<Agg>`/`canView<Agg>`
399
+ // UI-hint helpers. Omit-when-empty (a role-free domain is byte-identical).
400
+ ...(roles.length > 0 ? { roles } : {}),
391
401
  ...(queries.length > 0 ? { queries } : {}),
392
402
  ...(counts.length > 0 ? { counts } : {}),
393
403
  ...(spatials.length > 0 ? { spatials } : {}),
@@ -3702,6 +3702,12 @@ export interface DomainModule {
3702
3702
  * when the domain has no unsanctioned secondary ensure (the common case).
3703
3703
  */
3704
3704
  autoDeclaredEnsures?: string[];
3705
+ /**
3706
+ * ROLE SUGAR codegen metadata (role.ts) — the emitted roles' names, lowered edit/view relation
3707
+ * names, and the aggregates each covers. Drives the `canEdit<Agg>`/`canView<Agg>` UI-hint helpers
3708
+ * (TS + Dart). NOT law, NOT hashed (like {@link autoDeclaredEnsures}). Absent when no role is declared.
3709
+ */
3710
+ roles?: import("./role.js").RoleMeta[];
3705
3711
  /**
3706
3712
  * STABLE-ID CONTINUITY (#58 — names are labels, identity is minted). When present
3707
3713
  * (attached by `nomos-compile` from the PRIOR build's identity manifests), the
@@ -4269,6 +4275,62 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
4269
4275
  ]
4270
4276
  : [];
4271
4277
 
4278
+ // ROLE SUGAR — per-(role × aggregate) `canEdit<Agg>`/`canView<Agg>` UI-HINT helpers, pure functions
4279
+ // over the folded RelationTuple projection (the client-side MIRROR of engine.check). INFORMATIONAL
4280
+ // only — THE KERNEL GATE REMAINS THE AUTHORITY. Emitted only when the domain declares roles (era-safe).
4281
+ const roleBlock = (() => {
4282
+ const editRels = new Map<string, Set<string>>();
4283
+ const viewRels = new Map<string, Set<string>>();
4284
+ for (const r of mod.roles ?? []) {
4285
+ if (r.editRelation) for (const agg of r.editAggregates) (editRels.get(agg) ?? editRels.set(agg, new Set()).get(agg)!).add(r.editRelation);
4286
+ if (r.viewRelation) for (const agg of r.viewAggregates) {
4287
+ const s = viewRels.get(agg) ?? viewRels.set(agg, new Set()).get(agg)!;
4288
+ s.add(r.viewRelation);
4289
+ if (r.editRelation && r.editAggregates.includes(agg)) s.add(r.editRelation);
4290
+ }
4291
+ }
4292
+ if (editRels.size === 0 && viewRels.size === 0) return "";
4293
+ const lines: string[] = [
4294
+ `/// True iff [principal] holds ANY of [relations] as an ACTIVE tuple in [rows] (or a \`user:*\` wildcard).`,
4295
+ `bool _holdsAnyRelation(List<Map<String, dynamic>>? rows, Set<String> relations, String principal) {`,
4296
+ ` for (final r in rows ?? const []) {`,
4297
+ ` final data = r['data'];`,
4298
+ ` if (data is! Map) continue;`,
4299
+ ` final rel = data['relation'];`,
4300
+ ` if (rel is! String || !relations.contains(rel)) continue;`,
4301
+ ` final subjects = data['subjects'];`,
4302
+ ` if (subjects is! Map) continue;`,
4303
+ ` for (final entry in subjects.entries) {`,
4304
+ ` final subj = entry.key.toString();`,
4305
+ ` dynamic leaf = entry.value;`,
4306
+ ` if (leaf is String) { try { leaf = jsonDecode(leaf); } catch (_) { leaf = const {}; } }`,
4307
+ ` final status = leaf is Map ? leaf['status'] : null;`,
4308
+ ` if (status != null && status != 'active') continue;`,
4309
+ ` if (subj == 'user:\$principal' || subj == 'user:*') return true;`,
4310
+ ` }`,
4311
+ ` }`,
4312
+ ` return false;`,
4313
+ `}`,
4314
+ ];
4315
+ const dartSet = (s: Set<string>): string => `{${[...s].sort().map((x) => `'${x}'`).join(", ")}}`;
4316
+ for (const [agg, rels] of [...editRels].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
4317
+ lines.push(
4318
+ `/// UI HINT — may [principal] EDIT a ${agg}? Mirrors the kernel's ReBAC check; INFORMATIONAL only`,
4319
+ `/// (the one wasm gate is the authority and refuses regardless).`,
4320
+ `bool canEdit${cap(agg)}(List<Map<String, dynamic>>? relations, String principal) =>`,
4321
+ ` _holdsAnyRelation(relations, ${dartSet(rels)}, principal);`,
4322
+ );
4323
+ }
4324
+ for (const [agg, rels] of [...viewRels].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
4325
+ lines.push(
4326
+ `/// UI HINT — may [principal] VIEW a ${agg}? Mirrors the kernel's ReBAC read gate; INFORMATIONAL only.`,
4327
+ `bool canView${cap(agg)}(List<Map<String, dynamic>>? relations, String principal) =>`,
4328
+ ` _holdsAnyRelation(relations, ${dartSet(rels)}, principal);`,
4329
+ );
4330
+ }
4331
+ return lines.join("\n");
4332
+ })();
4333
+
4272
4334
  return [
4273
4335
  `// GENERATED by nomos2/dsl/src/codegen_dart.ts — DO NOT EDIT BY HAND.`,
4274
4336
  `//`,
@@ -4280,7 +4342,7 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
4280
4342
  `// ignore_for_file: unused_import`,
4281
4343
  ``,
4282
4344
  // A nullable plain-`t.json()` read-model slot decode re-stringifies via `jsonEncode`.
4283
- ...(needsJsonConvert ? [`import 'dart:convert';`] : []),
4345
+ ...(needsJsonConvert || roleBlock !== "" ? [`import 'dart:convert';`] : []),
4284
4346
  // The generated tenant domains live in the `co2_nomos_domains` package and depend
4285
4347
  // on the framework `nomos_client` package by its PUBLIC package: URI — NOT a relative
4286
4348
  // `../wire.dart` path into the framework (which broke when the generated dir moved
@@ -4354,5 +4416,12 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
4354
4416
  ]
4355
4417
  : []),
4356
4418
  ...helperBlock,
4419
+ ...(roleBlock !== ""
4420
+ ? [
4421
+ ``,
4422
+ `// ---- role policy (UI hints — the KERNEL GATE is the authority) ----`,
4423
+ roleBlock,
4424
+ ]
4425
+ : []),
4357
4426
  ].join("\n");
4358
4427
  }
package/src/codegen_ts.ts CHANGED
@@ -1908,6 +1908,66 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1908
1908
  );
1909
1909
  }
1910
1910
 
1911
+ // ROLE SUGAR — the per-(role × aggregate) `canEdit<Agg>` / `canView<Agg>` UI-HINT helpers. They
1912
+ // evaluate LOCALLY against the folded RelationTuple projection (the client-side MIRROR of
1913
+ // `engine.check`) — INFORMATIONAL only; THE KERNEL GATE REMAINS THE AUTHORITY and refuses regardless.
1914
+ {
1915
+ const editRels = new Map<string, Set<string>>(); // aggregate id → the edit relations that cover it
1916
+ const viewRels = new Map<string, Set<string>>(); // aggregate id → the view relations that cover it
1917
+ for (const m of modules) {
1918
+ for (const r of m.roles ?? []) {
1919
+ if (r.editRelation) for (const agg of r.editAggregates) (editRels.get(agg) ?? editRels.set(agg, new Set()).get(agg)!).add(r.editRelation);
1920
+ // A role-holder granted `.canView` holds BOTH its view relation AND (when it can edit) its edit
1921
+ // relation — an editor can always read what it edits, so canView accepts either.
1922
+ if (r.viewRelation) for (const agg of r.viewAggregates) {
1923
+ const s = viewRels.get(agg) ?? viewRels.set(agg, new Set()).get(agg)!;
1924
+ s.add(r.viewRelation);
1925
+ if (r.editRelation && r.editAggregates.includes(agg)) s.add(r.editRelation);
1926
+ }
1927
+ }
1928
+ }
1929
+ if (editRels.size > 0 || viewRels.size > 0) {
1930
+ out.push(
1931
+ `// ── ROLE POLICY (UI hints — the KERNEL GATE is the authority) ─────────────────────────────`,
1932
+ `/** One folded RelationTuple row (as \`relationTuplesByObject\`/\`queryById("reltuple-…")\` yields). */`,
1933
+ `export interface NomosRoleRelationRow { data?: { relation?: string; subjects?: Record<string, unknown> } }`,
1934
+ `/** True iff \`principal\` holds ANY of \`relations\` as an ACTIVE tuple in \`rows\` (or a \`user:*\` wildcard). */`,
1935
+ `function _holdsAnyRelation(rows: readonly NomosRoleRelationRow[] | undefined, relations: readonly string[], principal: string): boolean {`,
1936
+ ` const want = new Set(relations);`,
1937
+ ` for (const r of rows ?? []) {`,
1938
+ ` if (r?.data?.relation === undefined || !want.has(r.data.relation)) continue;`,
1939
+ ` for (const [subj, leafRaw] of Object.entries(r.data.subjects ?? {})) {`,
1940
+ ` let leaf: { status?: string };`,
1941
+ ` try { leaf = typeof leafRaw === "string" ? JSON.parse(leafRaw) : (leafRaw as { status?: string }); } catch { leaf = {}; }`,
1942
+ ` if (leaf?.status !== undefined && leaf.status !== "active") continue;`,
1943
+ ` if (subj === \`user:\${principal}\` || subj === "user:*") return true;`,
1944
+ ` }`,
1945
+ ` }`,
1946
+ ` return false;`,
1947
+ `}`,
1948
+ );
1949
+ for (const [agg, rels] of [...editRels].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
1950
+ out.push(
1951
+ `/** UI HINT — may \`principal\` EDIT a ${agg}? Mirrors the kernel's ReBAC check over the folded`,
1952
+ ` * tuples; INFORMATIONAL only (the one wasm gate is the authority and refuses regardless). */`,
1953
+ `export function canEdit${cap(agg)}(relations: readonly NomosRoleRelationRow[] | undefined, principal: string): boolean {`,
1954
+ ` return _holdsAnyRelation(relations, ${JSON.stringify([...rels].sort())}, principal);`,
1955
+ `}`,
1956
+ );
1957
+ }
1958
+ for (const [agg, rels] of [...viewRels].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
1959
+ out.push(
1960
+ `/** UI HINT — may \`principal\` VIEW a ${agg}? Mirrors the kernel's ReBAC read gate;`,
1961
+ ` * INFORMATIONAL only (the one wasm read boundary is the authority). */`,
1962
+ `export function canView${cap(agg)}(relations: readonly NomosRoleRelationRow[] | undefined, principal: string): boolean {`,
1963
+ ` return _holdsAnyRelation(relations, ${JSON.stringify([...rels].sort())}, principal);`,
1964
+ `}`,
1965
+ );
1966
+ }
1967
+ out.push(``);
1968
+ }
1969
+ }
1970
+
1911
1971
  // RECURSIVE PAYLOAD TYPES (`z.lazy`): emit each minted named alias once. Sorted by name for
1912
1972
  // byte-determinism; TS hoists type aliases so referencing them above is fine.
1913
1973
  if (lazyReg.decls.size > 0) {
@@ -90,6 +90,7 @@ import {
90
90
  import { synthesizePreconditionInvariants } from "./precondition.js";
91
91
  import { BIRTH_METER_QUERIES, synthesizeBirthQuotaInvariants } from "./birth_meter.js";
92
92
  import { withBirthMeterExports } from "./birth_meter_ensure.js";
93
+ import { expandRoleExports, rolesOf } from "./role.js";
93
94
  import { collectEngineRouting } from "./engine_entry.js";
94
95
  import {
95
96
  discover,
@@ -1286,6 +1287,10 @@ async function main(): Promise<void> {
1286
1287
  merged = expandCapabilityExports(merged);
1287
1288
  // METERED BIRTH: the same injection composeDomainModule/mergeModules apply — one seam, no drift.
1288
1289
  merged = withBirthMeterExports(merged);
1290
+ // ROLE SUGAR: lift every `role(...).emit()` into discoverable directives + the synthesized
1291
+ // `relationType("workspace")` (the same seam composeDomainModule expands) so this plane's openapi /
1292
+ // relation-schema discovery sees the grant/revoke/seed directives + the teeth-bearing schema.
1293
+ merged = expandRoleExports(merged);
1289
1294
  let shardingSums: AnySum[] = [];
1290
1295
  let shardingMins: AnyExtremum[] = [];
1291
1296
  let shardingMaxes: AnyExtremum[] = [];
@@ -59,6 +59,7 @@ import { isRelationTypeDecl } from "./relation_schema.js";
59
59
  import { synthesizePreconditionInvariants } from "./precondition.js";
60
60
  import { synthesizeBirthQuotaInvariants } from "./birth_meter.js";
61
61
  import { withBirthMeterExports } from "./birth_meter_ensure.js";
62
+ import { expandRoleExports } from "./role.js";
62
63
  import { withAttestedReadExports } from "./attested.js";
63
64
  import { generateOpenApiFragment, mergeOpenApiFragments, type OpenApiDoc } from "./codegen_openapi.js";
64
65
  import type { QueryDecl } from "./query.js";
@@ -250,7 +251,9 @@ function mergeModules(mods: readonly DomainModuleExports[]): DomainModuleExports
250
251
  // METERED BIRTH: a `.births()`-declaring domain gains the birth-meter structural exports
251
252
  // (the sanctioning `nomosBirthRecordEnsure` + the meter aggregates) at this same seam, so
252
253
  // the registry + `describe()` see the exact surface the composed manifest carries.
253
- return withAttestedReadExports(withBirthMeterExports(expandCapabilityExports(merged)));
254
+ // ROLE SUGAR: lift every `role(...).emit()` into its grant/revoke/seed directives at the SAME
255
+ // engine merge point so the registry dispatches them (the compiler saw them via the same expander).
256
+ return expandRoleExports(withAttestedReadExports(withBirthMeterExports(expandCapabilityExports(merged)))) as DomainModuleExports;
254
257
  }
255
258
 
256
259
  /**
@@ -172,6 +172,19 @@ export const workspaceRelations = relationType("workspace")
172
172
  /** Where a workspace's holon runs: an edge DO or the dedicated 12GiB container. */
173
173
  export const WORKSPACE_TIERS = ["edge", "heavy"] as const;
174
174
 
175
+ /**
176
+ * GOVERNANCE POSTURE (architecture/governance_postures.md) — every platform DECLARES how the operator may
177
+ * act in its subtree. `managed` (the default when absent) folds an OperatorGrant: the operator may run
178
+ * recovery verbs (rebirth, certifyEra, repairs) FOR the tenant, each a signed, attributed, replayable law
179
+ * fact. `sovereign` withholds it: root's recovery verbs REFUSE for a Sovereign workspace UNLESS the offer
180
+ * carries the tenant's own {@link RebirthConsent} fact (folded in the tenant's OWN chain — the two-key
181
+ * ceremony). v1 is bailiff-verified (the custody act reads both facts); v2 rides the attested-read lane so
182
+ * the ROOT GATE re-derives it. Hosted sovereignty = law-encoded restraint + total detectability + credible
183
+ * exit — never prevention (the operator controls root's law, but cannot change it SECRETLY, and the tenant
184
+ * can always clone + leave).
185
+ */
186
+ export const GOVERNANCE_POSTURES = ["managed", "sovereign"] as const;
187
+
175
188
  /**
176
189
  * The workspace lifecycle: `requested` (law fact only — nothing physical exists)
177
190
  * → `provisioned` (the worker built the machinery and recorded the owner hash)
@@ -304,6 +317,13 @@ export const CloudWorkspace = aggregate("CloudWorkspace", {
304
317
  * incarnation's genesis oid. The reborn holon itself testifies to its ancestry (alongside the UNCHANGED
305
318
  * `parent` — a rebirth may never claim new parents; see {@link rebirthRetainsLineage}). Additive/era-safe. */
306
319
  rebornFrom: t.string().merge(Lww).optional(),
320
+ /** GOVERNANCE POSTURE MIRROR (architecture/governance_postures.md) — the child's declared posture as a
321
+ * root lineage annotation, so root's bailiff can consult it WITHOUT reading N tenant chains on the hot
322
+ * path. AUTHORITATIVE is the tenant chain's own {@link GovernancePosture} singleton — this is the fast
323
+ * path only; a mirror↔tenant MISMATCH fails CLOSED to sovereign at the custody act. Absent ⇒ `managed`
324
+ * (the default). Set at birth ({@link birthChild}/{@link createWorkspace} `posture` payload). Additive/
325
+ * era-safe (optional Lww). */
326
+ governancePosture: t.enum(GOVERNANCE_POSTURES).merge(Lww).optional(),
307
327
  });
308
328
 
309
329
  // AdminGrant / CreationGrant aggregates: DELETED (full-native Zanzibar). Admin/creation authority is
@@ -440,6 +460,10 @@ function planBirthRecord(
440
460
  tier: (typeof WORKSPACE_TIERS)[number];
441
461
  requestedBy: string;
442
462
  requestedAt: number;
463
+ /** GOVERNANCE POSTURE MIRROR (governance_postures.md) — the child's declared posture, mirrored onto its
464
+ * root lineage record. Absent ⇒ managed (the default; never written when managed to keep the write
465
+ * additive/minimal). */
466
+ posture?: (typeof GOVERNANCE_POSTURES)[number] | undefined;
443
467
  },
444
468
  genesis?: { foundingAdminHash: string; pool: number },
445
469
  ) {
@@ -452,6 +476,8 @@ function planBirthRecord(
452
476
  .set("ownerGrantHash", "") // no owner credential until provisioned
453
477
  .set("createdBy", p.requestedBy)
454
478
  .set("createdAt", p.requestedAt);
479
+ // POSTURE MIRROR: record it only when a non-default posture is declared (managed = absent — additive/era-safe).
480
+ if (p.posture && p.posture !== "managed") rec.set("governancePosture", p.posture);
455
481
  if (genesis) {
456
482
  rec
457
483
  .set("kind", "platform" as const) // DERIVED LABEL ONLY — the order's foundingAdminHash is the real signal
@@ -692,6 +718,9 @@ export const createWorkspace = directive("createWorkspace")
692
718
  principal: z.string().min(1),
693
719
  requestedBy: z.string().min(1),
694
720
  requestedAt: epochMs,
721
+ /** GOVERNANCE POSTURE (governance_postures.md) — the born workspace's declared posture; default
722
+ * `managed`. Mirrored onto the lineage record (the tenant's own chain is authoritative). */
723
+ posture: z.enum(GOVERNANCE_POSTURES).optional(),
695
724
  }),
696
725
  )
697
726
  .plan((p) => {
@@ -1028,6 +1057,142 @@ export const rebirthRetainsLineage = workspaceInvariant("RebirthRetainsLineage")
1028
1057
  * `/rebirth-custody` drain's queue). Keyed on `supersededBy` is unhelpful; the flat record read via
1029
1058
  * {@link workspaceByName} suffices for the custody gate, so this stays a by-name lookup upstream. */
1030
1059
 
1060
+ // ─────────────────────────── GOVERNANCE POSTURES (managed / sovereign) ────────────────────────────────
1061
+ // architecture/governance_postures.md — v1, the BAILIFF-GRADE consent seam. A platform DECLARES its
1062
+ // governance posture as a fact in its OWN chain (the tenant owns the fact); it is mirrored onto root's
1063
+ // lineage record ({@link CloudWorkspace.governancePosture}) as a hot-path fast-path (a mismatch fails
1064
+ // CLOSED to sovereign at the custody act). Under `sovereign`, root's `rebirthWorkspace` custody act
1065
+ // REFUSES unless a live {@link RebirthConsent} fact — folded by the tenant's OWN admin, in the tenant's
1066
+ // OWN chain — is present (the two-key ceremony). All ADDITIVE / era-safe (a chain that never sets a
1067
+ // posture is `managed` and replays byte-identical). The v2 kernel-grade lane (attested-read consent
1068
+ // re-derived at the ROOT GATE on every lane) is the named follow-up — NOT built here.
1069
+
1070
+ /** THE POSTURE SINGLETON INSTANCE ID — the platform's own declared posture lives at this constant id in
1071
+ * its OWN governance chain (the RelationTuple/AuthProvider discipline — a literal, never minted). */
1072
+ const GOVERNANCE_POSTURE_ID = "governance-posture:self";
1073
+
1074
+ /**
1075
+ * GovernancePosture — the platform's OWN declared governance posture (a law-state singleton, id
1076
+ * `governance-posture:self`). `managed` (default when this record is absent) ⇒ the operator may run
1077
+ * recovery verbs for the tenant; `sovereign` ⇒ recovery verbs require the tenant's {@link RebirthConsent}.
1078
+ * The tenant owns this fact — it is set through the platform's own SIGNED governance offers
1079
+ * ({@link setGovernancePosture} / {@link revokeOperator} / {@link grantOperator}).
1080
+ */
1081
+ export const GovernancePosture = aggregate("GovernancePosture", {
1082
+ /** The singleton key — always `"self"`. */
1083
+ scope: t.string().merge(Lww),
1084
+ /** `managed` | `sovereign`. */
1085
+ posture: t.enum(GOVERNANCE_POSTURES).merge(Lww),
1086
+ /** WHY the posture was last set (audit, on the ledger forever). */
1087
+ reason: t.string().merge(Lww),
1088
+ /** Who last set it (attribution — the signing principal). */
1089
+ setBy: t.string().merge(Lww),
1090
+ /** Epoch-ms it was last set (caller-stamped). */
1091
+ setAt: t.int().merge(Lww),
1092
+ });
1093
+
1094
+ /**
1095
+ * RebirthConsent — the tenant's OWN consent to a rebirth, folded in the tenant's OWN chain (the two-key
1096
+ * ceremony: the custody act cannot move without BOTH root's supersession fact AND this consent fact). A
1097
+ * Sovereign workspace's operator restraint becomes law that replays, not host policy. `expiresAt` bounds
1098
+ * the consent window (absent ⇒ until revoked-by-supersession). Keyed by the workspace NAME so the bailiff
1099
+ * can resolve it while reading the tenant chain.
1100
+ */
1101
+ export const RebirthConsent = aggregate("RebirthConsent", {
1102
+ /** The workspace NAME this consent authorizes a rebirth of. */
1103
+ workspaceId: t.string().merge(Lww),
1104
+ /** WHY the tenant consents (audit). */
1105
+ reason: t.string().merge(Lww),
1106
+ /** The tenant admin that consented (attribution). */
1107
+ consentedBy: t.string().merge(Lww),
1108
+ /** Epoch-ms the consent was authored (caller-stamped). */
1109
+ consentedAt: t.int().merge(Lww),
1110
+ /** OPTIONAL epoch-ms expiry; absent = until superseded. Expired ⇒ the custody act treats it as absent. */
1111
+ expiresAt: t.int().merge(Lww).optional(),
1112
+ });
1113
+
1114
+ /** Shared plan body for the three posture setters — ensures the singleton to `posture`. */
1115
+ function setPosturePlan(posture: (typeof GOVERNANCE_POSTURES)[number], reason: string, setBy: string, at: number) {
1116
+ const gp = instance(GovernancePosture, GOVERNANCE_POSTURE_ID);
1117
+ return [
1118
+ withMarker(set(gp, "scope", "self"), "ensures"),
1119
+ set(gp, "posture", posture),
1120
+ set(gp, "reason", reason),
1121
+ set(gp, "setBy", setBy),
1122
+ set(gp, "setAt", at),
1123
+ ];
1124
+ }
1125
+
1126
+ /**
1127
+ * setGovernancePosture — the platform DECLARES (or changes) its posture. A SIGNED, admin-gated offer to the
1128
+ * platform's OWN gateway — the tenant owns the fact. `managed → sovereign` withdraws the operator grant
1129
+ * (forward-only; help already given stays in history); `sovereign → managed` re-invites help.
1130
+ */
1131
+ export const setGovernancePosture = directive("setGovernancePosture")
1132
+ .ensures(GovernancePosture)
1133
+ .payload(
1134
+ z.object({
1135
+ /** The workspace this posture governs (the platform's own name — informational; the singleton is self-scoped). */
1136
+ workspaceId: z.string().min(1),
1137
+ posture: z.enum(GOVERNANCE_POSTURES),
1138
+ reason: z.string().min(1),
1139
+ /** The signing principal (attribution). */
1140
+ setBy: z.string().min(1),
1141
+ at: epochMs,
1142
+ }),
1143
+ )
1144
+ .plan((p) => setPosturePlan(p.posture, p.reason, p.setBy, p.at))
1145
+ .requires("admin");
1146
+
1147
+ /** revokeOperator — posture sugar: `managed → sovereign` (withdraw the operator grant). */
1148
+ export const revokeOperator = directive("revokeOperator")
1149
+ .ensures(GovernancePosture)
1150
+ .payload(z.object({ workspaceId: z.string().min(1), reason: z.string().min(1), setBy: z.string().min(1), at: epochMs }))
1151
+ .plan((p) => setPosturePlan("sovereign", p.reason, p.setBy, p.at))
1152
+ .requires("admin");
1153
+
1154
+ /** grantOperator — posture sugar: `sovereign → managed` (re-invite the operator's help). */
1155
+ export const grantOperator = directive("grantOperator")
1156
+ .ensures(GovernancePosture)
1157
+ .payload(z.object({ workspaceId: z.string().min(1), reason: z.string().min(1), setBy: z.string().min(1), at: epochMs }))
1158
+ .plan((p) => setPosturePlan("managed", p.reason, p.setBy, p.at))
1159
+ .requires("admin");
1160
+
1161
+ /**
1162
+ * consentToRebirth — the tenant's OWN consent to a rebirth (the Sovereign lane). A SIGNED, founding-admin-
1163
+ * gated offer folding a {@link RebirthConsent} fact in the TENANT'S chain. The custody act reads it (v1) /
1164
+ * the root gate re-derives it (v2) before superseding a Sovereign workspace.
1165
+ */
1166
+ export const consentToRebirth = directive("consentToRebirth")
1167
+ .creates(RebirthConsent)
1168
+ .payload(
1169
+ z.object({
1170
+ workspaceId: z.string().min(1),
1171
+ reason: z.string().min(1),
1172
+ consentedBy: z.string().min(1),
1173
+ consentedAt: epochMs,
1174
+ expiresAt: epochMs.optional(),
1175
+ }),
1176
+ )
1177
+ .plan((p) => {
1178
+ const c = create(RebirthConsent)
1179
+ .set("workspaceId", p.workspaceId)
1180
+ .set("reason", p.reason)
1181
+ .set("consentedBy", p.consentedBy)
1182
+ .set("consentedAt", p.consentedAt);
1183
+ if (p.expiresAt !== undefined) c.set("expiresAt", p.expiresAt);
1184
+ return [];
1185
+ })
1186
+ .requires("admin");
1187
+
1188
+ /** The platform's own declared posture (`scope: "self"` singleton) — the AUTHORITATIVE posture read. */
1189
+ export const governancePosture = query("governancePosture").key("scope").returns(GovernancePosture);
1190
+
1191
+ /** The tenant's rebirth-consent facts for a workspace name — the custody act's Sovereign-lane consent probe. */
1192
+ export const rebirthConsentByWorkspace = query("rebirthConsentByWorkspace")
1193
+ .key("workspaceId")
1194
+ .returns(RebirthConsent);
1195
+
1031
1196
  // ─────────────────────────── BIRTH AS A NOMOS PRIMITIVE ──────────────────────────────────────────────
1032
1197
  // (birthPlatform is GONE — the tier collapsed onto #creator; birthChild is the one birth directive.)
1033
1198
  // THE RECORD IS THE OBLIGATION (architecture/githolon_host_contract.md "Birth is a capability" +
@@ -1096,6 +1261,10 @@ export const birthChild = directive("birthChild")
1096
1261
  * only pass it when the `lawHash` the order names carries `seedBirthPolicy` (a governance
1097
1262
  * package compiled under the metered-birth DSL). Only meaningful with a genesis. */
1098
1263
  descendantPool: z.number().int().positive().optional(),
1264
+ /** GOVERNANCE POSTURE (governance_postures.md) — the born child's declared posture; default
1265
+ * `managed`. Mirrored onto its lineage record so root's bailiff can consult it on the hot path
1266
+ * (the tenant's own {@link GovernancePosture} singleton is authoritative). Additive/era-safe. */
1267
+ posture: z.enum(GOVERNANCE_POSTURES).optional(),
1099
1268
  })
1100
1269
  // A genesis birth (a platform) must carry a principal AND a bornAt — it owns a subtree, a lineage
1101
1270
  // anchor, and a law-authored genesis chain whose warrant is stamped at bornAt.
package/src/index.ts CHANGED
@@ -597,6 +597,19 @@ export {
597
597
  type RelationOpts,
598
598
  type SugaredRelationTypeBuilder,
599
599
  } from "./relation_sugar.js";
600
+ // ROLE SUGAR — the highest-level authz surface: `role("editor").canEdit(Note).canView(Note)
601
+ // .grantableBy("admin")` LOWERS onto the ONE Zanzibar spine (relation tuples + the kernel gate) —
602
+ // emitting the relation schema (teeth, no fail-open), grant/revoke/seed directives, typed policy
603
+ // refs (role.edit / role.view), and codegen canEdit/canView UI helpers. NO role-array auth anywhere.
604
+ export {
605
+ role,
606
+ isRoleEmission,
607
+ expandRoleExports,
608
+ rolesOf,
609
+ type RoleBuilder,
610
+ type RoleEmission,
611
+ type RoleMeta,
612
+ } from "./role.js";
600
613
  export { csv, csvRows } from "./report/csv.js";
601
614
  // CertifiedRead — the DECLARED write-path read surface (survival Constraint 5;
602
615
  // `certified_read.md`). A directive declares profiled reads via `.readsCertified({ name:
@@ -109,6 +109,9 @@ export const GLOBAL_READ_PROJECTION_AGGREGATE_IDS: ReadonlySet<string> = new Set
109
109
  "CapabilityBinding",
110
110
  "AuthProvider",
111
111
  "HostnameClaim",
112
+ // governance postures (managed / sovereign) — the tenant-owned posture singleton + consent facts
113
+ "GovernancePosture",
114
+ "RebirthConsent",
112
115
  ]);
113
116
 
114
117
  /** The minimal handle shape the classification reads (compiler AND engine — one function). */
package/src/role.ts ADDED
@@ -0,0 +1,292 @@
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
+ * role — ROLE SUGAR over the ONE Zanzibar/ReBAC spine.
8
+ *
9
+ * A `role("editor").canEdit(Note).canView(Note).grantableBy("admin")` declaration is PURE SUGAR
10
+ * that LOWERS onto the native relation spine — there is NO role-array membership check anywhere,
11
+ * and NOTHING kernel-judged stops being kernel-judged. A role EMITS:
12
+ *
13
+ * 1. RELATION TYPE entries + schema — the `<role>Edit` write relation is authored into a
14
+ * `relationType("workspace")` block (the fail-open footgun is CLOSED — a `.requires(role.edit)`
15
+ * always finds a declared relation), and the `<role>View` relation is a READ-VISIBILITY cap
16
+ * (used as an aggregate `requires(role.view)` — usd.ts synthesizes the global workspace read
17
+ * relation, exactly as any private aggregate does). The grant-authority relation (`grantableBy`,
18
+ * default `admin`) is likewise authored into the schema so the gate on the grant directive has teeth.
19
+ *
20
+ * 2. GRANT / SEED DIRECTIVES per role — `grant<Role>` / `revoke<Role>` fold the relation TUPLE via
21
+ * the existing `writeTuple`/`removeTuple` primitives (a grant seats `<role>Edit` + `<role>View`
22
+ * on `user:<principal>`), gated `.requires(grantableBy)` — the KERNEL judges the granter. A
23
+ * `seed<Role>` UNGATED directive seats the `grantableBy` relation on a founding principal at
24
+ * genesis/birth (the birthHome seed pattern) so the grant-authority exists from intent 0.
25
+ *
26
+ * 3. POLICY REFS — the role handle is usable DIRECTLY, typed (never strings): `.requires(editor.edit)`
27
+ * on a mutating directive, `requires(editor.view)` as an aggregate visibility.
28
+ *
29
+ * 4. CODEGEN `canEdit<Agg>` / `canView<Agg>` — per (role × aggregate) TS + Dart helpers that evaluate
30
+ * LOCALLY against the folded RelationTuple projection (the client-side MIRROR of `engine.check`).
31
+ * They are INFORMATIONAL / UI-gating ONLY — THE KERNEL GATE REMAINS THE AUTHORITY and refuses
32
+ * regardless of what the helper returns (documented in the emitted doc-comment).
33
+ *
34
+ * NOTHING here adds a role ARRAY as auth: every capability lowers to ONE relation tuple + the ONE
35
+ * kernel gate. Roles are workspace-scoped (object `workspace:self`) — the read-visibility gate is
36
+ * workspace-scoped in the kernel, so a per-record VIEW cap could not have teeth; per-record grants are
37
+ * a documented follow-on (the `.requires(rel, { objectFrom })` axis is unchanged and still available).
38
+ */
39
+ import { z } from "zod";
40
+ import type { AggregateHandle } from "./aggregate.js";
41
+ import type { Directive, RequirableDirective } from "./directive.js";
42
+ import { directive } from "./directive.js";
43
+ import { RelationTuple, writeTuple, removeTuple } from "./framework/relations.js";
44
+ import { relationType, self, user, key, type RelationTypeDecl } from "./relation_schema.js";
45
+
46
+ /** A role name: kebab-case (`editor`, `site-admin`) — lowered to relation/directive labels. */
47
+ const ROLE_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
48
+
49
+ const camel = (s: string): string => s.replace(/-+(\w)/g, (_m, c: string) => c.toUpperCase());
50
+ const pascal = (s: string): string => {
51
+ const c = camel(s);
52
+ return c.length ? c[0]!.toUpperCase() + c.slice(1) : c;
53
+ };
54
+
55
+ /**
56
+ * The codegen + expander view of one emitted role — pure metadata (NOT law, NOT hashed): the role
57
+ * name, its lowered edit/view relation names, the grant-authority relation, and the aggregates it
58
+ * covers. The compiler reads THIS to synthesize the schema block and the `canEdit`/`canView` helpers.
59
+ */
60
+ export interface RoleMeta {
61
+ readonly role: string;
62
+ /** The `<role>Edit` write relation (present iff the role `.canEdit`s ≥1 aggregate). */
63
+ readonly editRelation?: string;
64
+ /** The `<role>View` read-visibility relation (present iff the role `.canView`s ≥1 aggregate). */
65
+ readonly viewRelation?: string;
66
+ /** The relation the granter must HOLD to grant/revoke this role (default `admin`). */
67
+ readonly grantableBy: string;
68
+ /** The aggregate TYPE ids this role may edit (drives `canEdit<Agg>` codegen). Sorted. */
69
+ readonly editAggregates: readonly string[];
70
+ /** The aggregate TYPE ids this role may view (drives `canView<Agg>` codegen). Sorted. */
71
+ readonly viewAggregates: readonly string[];
72
+ }
73
+
74
+ /**
75
+ * The value `role(...).emit()` returns — a discoverable container the compiler expands (like a
76
+ * `capability()` bundle): its `directives` (grant/revoke/seed) are lifted into the module's directive
77
+ * set, and its `meta` drives the synthesized relation schema + codegen. Branded so `expandRoleExports`
78
+ * / `rolesOf` find it by shape (`__isRoleEmission`).
79
+ */
80
+ export interface RoleEmission {
81
+ readonly __isRoleEmission: true;
82
+ readonly meta: RoleMeta;
83
+ readonly directives: readonly Directive<unknown>[];
84
+ }
85
+
86
+ export function isRoleEmission(v: unknown): v is RoleEmission {
87
+ return typeof v === "object" && v !== null && (v as { __isRoleEmission?: unknown }).__isRoleEmission === true;
88
+ }
89
+
90
+ /** The fluent role builder. `role.edit` / `role.view` are the typed POLICY REFS (relation names). */
91
+ export interface RoleBuilder {
92
+ /** The role name (kebab-case). */
93
+ readonly name: string;
94
+ /** POLICY REF — the `<role>Edit` write relation, for `.requires(role.edit)` on a mutating directive. */
95
+ readonly edit: string;
96
+ /** POLICY REF — the `<role>View` read relation, for an aggregate `requires(role.view)` visibility. */
97
+ readonly view: string;
98
+ /** Declare that this role may EDIT `agg` (its `<role>Edit` relation gates `agg`'s mutating directives). */
99
+ canEdit(agg: AggregateHandle): RoleBuilder;
100
+ /** Declare that this role may VIEW `agg` (wire `agg`'s visibility to `role.view` for teeth). */
101
+ canView(agg: AggregateHandle): RoleBuilder;
102
+ /** Who may grant/revoke this role — a relation ref (default `admin`). Seeded via `seed<Role>` at genesis. */
103
+ grantableBy(relation: string): RoleBuilder;
104
+ /** Finalize — emit the grant/revoke/seed directives + the metadata the compiler lowers. */
105
+ emit(): RoleEmission;
106
+ }
107
+
108
+ interface RoleState {
109
+ readonly name: string;
110
+ readonly editRelation: string;
111
+ readonly viewRelation: string;
112
+ readonly grantableBy: string;
113
+ readonly editAggregates: string[];
114
+ readonly viewAggregates: string[];
115
+ readonly canEdit: boolean;
116
+ readonly canView: boolean;
117
+ }
118
+
119
+ function aggId(agg: AggregateHandle, verb: string, role: string): string {
120
+ if (agg === null || typeof agg !== "object" || typeof (agg as { id?: unknown }).id !== "string" || (agg as { id: string }).id.length === 0) {
121
+ throw new Error(`role("${role}").${verb}(...): expected an aggregate handle with a string id (pass the aggregate(...) handle, not its name)`);
122
+ }
123
+ return (agg as { id: string }).id;
124
+ }
125
+
126
+ function build(s: RoleState): RoleBuilder {
127
+ const builder: RoleBuilder = {
128
+ name: s.name,
129
+ edit: s.editRelation,
130
+ view: s.viewRelation,
131
+ canEdit(agg: AggregateHandle): RoleBuilder {
132
+ const id = aggId(agg, "canEdit", s.name);
133
+ if (s.editAggregates.includes(id)) return builder;
134
+ return build({ ...s, canEdit: true, editAggregates: [...s.editAggregates, id] });
135
+ },
136
+ canView(agg: AggregateHandle): RoleBuilder {
137
+ const id = aggId(agg, "canView", s.name);
138
+ if (s.viewAggregates.includes(id)) return builder;
139
+ return build({ ...s, canView: true, viewAggregates: [...s.viewAggregates, id] });
140
+ },
141
+ grantableBy(relation: string): RoleBuilder {
142
+ if (typeof relation !== "string" || relation.length === 0) {
143
+ throw new Error(`role("${s.name}").grantableBy(...): expected a non-empty relation name`);
144
+ }
145
+ return build({ ...s, grantableBy: relation });
146
+ },
147
+ emit(): RoleEmission {
148
+ if (!s.canEdit && !s.canView) {
149
+ throw new Error(`role("${s.name}").emit(): a role must declare at least one .canEdit(...) or .canView(...)`);
150
+ }
151
+ const editRelation = s.canEdit ? s.editRelation : undefined;
152
+ const viewRelation = s.canView ? s.viewRelation : undefined;
153
+ const P = pascal(s.name);
154
+ // The subject of a grant is the `user:<uid>` leaf — the SAME wire form the kernel's Subject::parse
155
+ // reads (a bare uid never forges a wildcard/userset; the reserved-token rules live in relatePayload).
156
+ const seatTuples = (principal: string, grantedBy: string, grantedAt: string) => [
157
+ ...(editRelation ? writeTuple({ object: "workspace:self", relation: editRelation, subject: `user:${principal}`, grantedBy, grantedAt }) : []),
158
+ ...(viewRelation ? writeTuple({ object: "workspace:self", relation: viewRelation, subject: `user:${principal}`, grantedBy, grantedAt }) : []),
159
+ ];
160
+
161
+ const grantPayload = z.object({
162
+ principal: z.string().min(1),
163
+ grantedBy: z.string().min(1),
164
+ grantedAt: z.string().min(1),
165
+ });
166
+ let grantDir: RequirableDirective<z.infer<typeof grantPayload>> = directive(`grant${P}`)
167
+ .ensures(RelationTuple)
168
+ .payload(grantPayload)
169
+ .plan((p) => seatTuples(p.principal, p.grantedBy, p.grantedAt))
170
+ .doc(`Grant the "${s.name}" role to a principal (seats the relation tuple; the kernel judges the granter via .requires("${s.grantableBy}")).`)
171
+ .requires(s.grantableBy);
172
+ // Proof-tooling hint: this directive ESTABLISHES the role's relations (so the relation-bootstrap
173
+ // proof leg seats a role-holder before proving a `.requires(role.edit)`-gated write).
174
+ if (editRelation) grantDir = grantDir.grants(editRelation);
175
+ if (viewRelation) grantDir = grantDir.grants(viewRelation);
176
+
177
+ const revokePayload = grantPayload.extend({
178
+ revokedBy: z.string().min(1),
179
+ revokedAt: z.string().min(1),
180
+ });
181
+ const revokeDir: RequirableDirective<z.infer<typeof revokePayload>> = directive(`revoke${P}`)
182
+ .ensures(RelationTuple)
183
+ .payload(revokePayload)
184
+ .plan((p) => [
185
+ ...(editRelation ? removeTuple({ object: "workspace:self", relation: editRelation, subject: `user:${p.principal}`, grantedBy: p.grantedBy, grantedAt: p.grantedAt, revokedBy: p.revokedBy, revokedAt: p.revokedAt }) : []),
186
+ ...(viewRelation ? removeTuple({ object: "workspace:self", relation: viewRelation, subject: `user:${p.principal}`, grantedBy: p.grantedBy, grantedAt: p.grantedAt, revokedBy: p.revokedBy, revokedAt: p.revokedAt }) : []),
187
+ ])
188
+ .doc(`Revoke the "${s.name}" role from a principal (remove-wins: the projection drops the tuple; a replayed grant cannot resurrect it).`)
189
+ .requires(s.grantableBy);
190
+
191
+ // THE SEED LANE (genesis/birth) — seat the grant-authority relation on a founding principal, UNGATED,
192
+ // so the granter exists from intent 0 (the seedHomeOwner shape: a genesis-chain step, refused on the
193
+ // open session lane structurally like any control write). Grants nothing but the authority to grant.
194
+ const seedPayload = z.object({
195
+ principal: z.string().min(1),
196
+ grantedAt: z.string().min(1),
197
+ });
198
+ const seedDir: RequirableDirective<z.infer<typeof seedPayload>> = directive(`seed${P}`)
199
+ .ensures(RelationTuple)
200
+ .payload(seedPayload)
201
+ .plan((p) => writeTuple({ object: "workspace:self", relation: s.grantableBy, subject: `user:${p.principal}`, grantedBy: p.principal, grantedAt: p.grantedAt }))
202
+ .doc(`Seed the "${s.grantableBy}" grant-authority on a founding principal (genesis/birth only).`)
203
+ .grants(s.grantableBy);
204
+
205
+ const meta: RoleMeta = {
206
+ role: s.name,
207
+ ...(editRelation ? { editRelation } : {}),
208
+ ...(viewRelation ? { viewRelation } : {}),
209
+ grantableBy: s.grantableBy,
210
+ editAggregates: [...s.editAggregates].sort(),
211
+ viewAggregates: [...s.viewAggregates].sort(),
212
+ };
213
+ return {
214
+ __isRoleEmission: true,
215
+ meta,
216
+ directives: [grantDir, revokeDir, seedDir] as unknown as Directive<unknown>[],
217
+ };
218
+ },
219
+ };
220
+ return builder;
221
+ }
222
+
223
+ /**
224
+ * `role("editor")` — begin a role declaration. `role.edit` / `role.view` (the typed policy refs) are
225
+ * available IMMEDIATELY (derived from the name, no aggregate needed) so an aggregate can wire
226
+ * `requires(editor.view)` before the role is `.emit()`ed (no declaration-order cycle).
227
+ */
228
+ export function role(name: string): RoleBuilder {
229
+ if (typeof name !== "string" || !ROLE_NAME_RE.test(name)) {
230
+ throw new Error(`role(${JSON.stringify(name)}): a role name must be kebab-case (${ROLE_NAME_RE})`);
231
+ }
232
+ const base = camel(name);
233
+ return build({
234
+ name,
235
+ editRelation: `${base}Edit`,
236
+ viewRelation: `${base}View`,
237
+ grantableBy: "admin",
238
+ editAggregates: [],
239
+ viewAggregates: [],
240
+ canEdit: false,
241
+ canView: false,
242
+ });
243
+ }
244
+
245
+ /** Collect every emitted role's metadata from a module's exports bag (by shape) — sorted by role name. */
246
+ export function rolesOf(mod: Record<string, unknown>): RoleMeta[] {
247
+ const out: RoleMeta[] = [];
248
+ for (const v of Object.values(mod)) if (isRoleEmission(v)) out.push(v.meta);
249
+ return out.sort((a, b) => (a.role < b.role ? -1 : a.role > b.role ? 1 : 0));
250
+ }
251
+
252
+ /** The injected export key for the role-synthesized `relationType("workspace")` block. */
253
+ const ROLE_RELATIONS_KEY = "__nomosRoleRelations";
254
+
255
+ /**
256
+ * EXPANDER (the `capability()`/birth-meter seam): given a module's exports bag, LIFT every `RoleEmission`
257
+ * into discoverable entries — the grant/revoke/seed directives become top-level exports (so `directivesOf`
258
+ * finds them), and ONE synthesized `relationType("workspace")` (the `<role>Edit` write relations + the
259
+ * distinct `grantableBy` grant-authority relations, deduped) becomes a discoverable relation type (so
260
+ * `.requires(role.edit)` / `.requires(grantableBy)` have TEETH — the fail-open footgun is closed). The
261
+ * `<role>View` read relations are NOT authored here: they are read-visibility caps, synthesized by usd.ts
262
+ * from the aggregates that declare `requires(role.view)`. IDEMPOTENT + additive: a bag with no role is
263
+ * returned unchanged (byte-identical), and re-running on an already-expanded bag is a no-op.
264
+ */
265
+ export function expandRoleExports(mod: Record<string, unknown>): Record<string, unknown> {
266
+ const emissions: RoleEmission[] = [];
267
+ for (const v of Object.values(mod)) if (isRoleEmission(v)) emissions.push(v);
268
+ if (emissions.length === 0) return mod;
269
+ if (ROLE_RELATIONS_KEY in mod) return mod; // already expanded (idempotent)
270
+
271
+ const out: Record<string, unknown> = { ...mod };
272
+ // ONE workspace-scoped relation block covering every role's authored (edit + grant-authority) relations.
273
+ const builder = relationType("workspace");
274
+ const declared = new Set<string>();
275
+ const declare = (rel: string): void => {
276
+ if (declared.has(rel)) return;
277
+ declared.add(rel);
278
+ // A flat direct-membership relation, leaf subjects only (user + the control-plane key) — the same
279
+ // shape the read-visibility synthesis + governance control relations use (never a userset/wildcard).
280
+ builder.relation(rel, { rewrite: self(), allow: [user(), key()] });
281
+ };
282
+ for (const e of emissions) {
283
+ if (e.meta.editRelation) declare(e.meta.editRelation);
284
+ declare(e.meta.grantableBy);
285
+ for (const dir of e.directives) {
286
+ const anyDir = dir as { id: string };
287
+ out[`__role_${anyDir.id}`] = dir;
288
+ }
289
+ }
290
+ out[ROLE_RELATIONS_KEY] = builder as unknown as RelationTypeDecl;
291
+ return out;
292
+ }