@githolon/dsl 0.106.0 → 0.107.1

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.
@@ -1664,8 +1664,8 @@ const NOMOS_TYPES_DEP = "^0.8.8";
1664
1664
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
1665
1665
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
1666
1666
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
1667
- const NOMOS_CLIENT_DEP = "^0.64.40";
1668
- const NOMOS_FLUTTER_DEP = "^0.63.68";
1667
+ const NOMOS_CLIENT_DEP = "^0.64.46";
1668
+ const NOMOS_FLUTTER_DEP = "^0.63.75";
1669
1669
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
1670
1670
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
1671
1671
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
@@ -53,7 +53,7 @@
53
53
  * (`architecture/capability_credentials.md`).
54
54
  */
55
55
  import { z } from "zod";
56
- import { instance } from "../aggregate.js";
56
+ import { instance, type AggregateHandle } from "../aggregate.js";
57
57
  import type { Field } from "../fields.js";
58
58
  import { set, type PlannedOp } from "../ops.js";
59
59
  import { query, type QueryDecl } from "../query.js";
@@ -218,6 +218,17 @@ export function capability<
218
218
  /** How many times an overdue-but-still-`requested` obligation may be RE-OPENED in place by
219
219
  * the bounded `reschedule` verb before the next reschedule dead-letters it. Defaults to 5. */
220
220
  maxRescheduleAttempts?: number;
221
+ /**
222
+ * EXISTENCE PRECONDITIONS on the oblige verb: each entry names a request field carrying a
223
+ * referenced aggregate id; the oblige REFUSES (typed, kernel-evaluated at every gate) when
224
+ * that row does not exist, so a typo'd address can never seal an unfulfillable obligation.
225
+ * `rejectCode` defaults to `"<field>-not-found"`. Era-additive.
226
+ */
227
+ requestRefsExist?: ReadonlyArray<{
228
+ field: keyof Req & string;
229
+ aggregate: AggregateHandle;
230
+ rejectCode?: string;
231
+ }>;
221
232
  /**
222
233
  * THE ACKNOWLEDGED WAIVER — open this capability's Receipts to any authorised author.
223
234
  *
@@ -289,9 +300,15 @@ export function capability<
289
300
  // The loop is dynamic (declared field names), so the per-field `set()` typing
290
301
  // is asserted — the PUBLIC payload types stay fully inferred from the zod shape.
291
302
  writeParams: (taskId, p) =>
292
- Object.keys(spec.request).map((k) =>
293
- set(instance(taskAggregate, taskId), k as never, (p as Record<string, unknown>)[k] as never),
294
- ),
303
+ Object.keys(spec.request)
304
+ // ABSENT OPTIONALS ARE SKIPPED, symmetrically with the receipt half below: an optional
305
+ // request field the caller omitted must author NO op — `undefined` can never be ledger
306
+ // state, and writing it deterministically halts the author plan (caught live 2026-08-19:
307
+ // the first tempo-less oblige after the tempo law refused on `estimatedMs`).
308
+ .filter((k) => (p as Record<string, unknown>)[k] !== undefined)
309
+ .map((k) =>
310
+ set(instance(taskAggregate, taskId), k as never, (p as Record<string, unknown>)[k] as never),
311
+ ),
295
312
  // The receipt half, symmetrical with the request half above: declared fields are merged
296
313
  // onto the complete envelope as TYPED values and written straight through. The payload
297
314
  // boundary has already validated them against the same zod the author declared, so the
@@ -315,6 +332,15 @@ export function capability<
315
332
  ...(spec.maxRescheduleAttempts !== undefined
316
333
  ? { maxRescheduleAttempts: spec.maxRescheduleAttempts }
317
334
  : {}),
335
+ ...(spec.requestRefsExist !== undefined
336
+ ? {
337
+ obligationRefsExist: spec.requestRefsExist.map((pre) => ({
338
+ aggregate: pre.aggregate,
339
+ idField: pre.field,
340
+ rejectCode: pre.rejectCode ?? `${pre.field}-not-found`,
341
+ })),
342
+ }
343
+ : {}),
318
344
  });
319
345
 
320
346
  const camel = pascal[0]!.toLowerCase() + pascal.slice(1);
@@ -137,7 +137,7 @@ export const DomainInstallation = aggregate("DomainInstallation", {
137
137
  }).public();
138
138
 
139
139
  /**
140
- * The accepted, exact-history evidence for one activation. This is deliberately an
140
+ * The accepted current-frontier evidence for one activation. This is deliberately an
141
141
  * obscure framework concept: tenant models never author or name it. The generated
142
142
  * lifecycle client runs pre-flight and folds this receipt in the SAME intent that
143
143
  * activates the candidate, so recording evidence cannot make its own head anchor stale.
@@ -151,18 +151,7 @@ export const ReleaseReceipt = aggregate(RELEASE_RECEIPT_AGGREGATE, {
151
151
  domain: t.string().merge(Lww),
152
152
  atHash: t.gitObjectId().merge(Lww),
153
153
  atStateRoot: t.gitObjectId().merge(Lww),
154
- historyHash: t.sha256Digest().merge(Lww),
155
- reportHash: t.sha256Digest().merge(Lww),
156
- reportJson: t.string().merge(Lww),
157
- complete: t.bool(),
158
154
  evolveOk: t.bool(),
159
- rawPromotable: t.bool(),
160
- projectionEqual: t.bool(),
161
- replayedIntents: t.int().merge(Lww),
162
- candidateIntents: t.int().merge(Lww),
163
- divergenceCount: t.int().merge(Lww),
164
- baselineRoot: t.sha256Digest().merge(Lww),
165
- candidateRoot: t.sha256Digest().merge(Lww),
166
155
  acceptanceReason: t.string().merge(Lww).optional(),
167
156
  acceptedBy: attributionField(t.string().merge(Lww)),
168
157
  acceptedAt: t.int().merge(Lww),
@@ -176,12 +165,12 @@ function domainInstallationInstance(domainHash: Sha256Digest) {
176
165
  return instance(DomainInstallation, domainInstallationAggregateId(domainHash));
177
166
  }
178
167
 
179
- export function releaseReceiptAggregateId(reportHash: Sha256Digest): string {
180
- return `${RELEASE_RECEIPT_PREFIX}${reportHash}`;
168
+ export function releaseReceiptAggregateId(candidateHash: Sha256Digest, atStateRoot: string): string {
169
+ return `${RELEASE_RECEIPT_PREFIX}${candidateHash}:${atStateRoot}`;
181
170
  }
182
171
 
183
- function releaseReceiptInstance(reportHash: Sha256Digest) {
184
- return instance(ReleaseReceipt, releaseReceiptAggregateId(reportHash));
172
+ function releaseReceiptInstance(candidateHash: Sha256Digest, atStateRoot: string) {
173
+ return instance(ReleaseReceipt, releaseReceiptAggregateId(candidateHash, atStateRoot));
185
174
  }
186
175
 
187
176
  const dispositions = z.object({
@@ -198,28 +187,9 @@ const preflightReceipt = z.object({
198
187
  domain: z.string().min(1),
199
188
  atHash: gitOid,
200
189
  atStateRoot: gitOid,
201
- historyHash: lowerHex64,
202
- reportHash: lowerHex64,
203
- reportJson: z.string().min(2),
204
- complete: z.literal(true),
205
190
  evolveOk: z.literal(true),
206
- rawPromotable: z.boolean(),
207
- projectionEqual: z.boolean(),
208
- replayedIntents: z.number().int().nonnegative(),
209
- candidateIntents: z.number().int().nonnegative(),
210
- divergenceCount: z.number().int().nonnegative(),
211
- baselineRoot: lowerHex64,
212
- candidateRoot: lowerHex64,
213
191
  acceptanceReason: z.string().trim().min(1).optional(),
214
- }).superRefine((receipt, ctx) => {
215
- if (!receipt.rawPromotable && receipt.acceptanceReason === undefined) {
216
- ctx.addIssue({
217
- code: z.ZodIssueCode.custom,
218
- path: ["acceptanceReason"],
219
- message: "a divergent but complete report needs an explicit business acceptance reason",
220
- });
221
- }
222
- });
192
+ }).strict();
223
193
 
224
194
  const installPackagePayload = {
225
195
  domainHash: lowerHex64,
@@ -317,7 +287,7 @@ export const promoteDomain = directive(DOMAIN_LIFECYCLE_DIRECTIVES.Promote)
317
287
  }))
318
288
  .plan((p, ctx): PlannedOp[] => {
319
289
  const promoted = domainInstallationInstance(p.domainHash);
320
- const receipt = releaseReceiptInstance(p.preflight.reportHash);
290
+ const receipt = releaseReceiptInstance(p.domainHash, p.preflight.atStateRoot);
321
291
  const ops: PlannedOp[] = [
322
292
  set(promoted, "status.phase", DomainInstallationPhase.Active),
323
293
  set(promoted, "status.observedGeneration", 1),
@@ -326,18 +296,7 @@ export const promoteDomain = directive(DOMAIN_LIFECYCLE_DIRECTIVES.Promote)
326
296
  set(receipt, "domain", p.preflight.domain),
327
297
  set(receipt, "atHash", p.preflight.atHash),
328
298
  set(receipt, "atStateRoot", p.preflight.atStateRoot),
329
- set(receipt, "historyHash", p.preflight.historyHash),
330
- set(receipt, "reportHash", p.preflight.reportHash),
331
- set(receipt, "reportJson", p.preflight.reportJson),
332
- set(receipt, "complete", true),
333
299
  set(receipt, "evolveOk", true),
334
- set(receipt, "rawPromotable", p.preflight.rawPromotable),
335
- set(receipt, "projectionEqual", p.preflight.projectionEqual),
336
- set(receipt, "replayedIntents", p.preflight.replayedIntents),
337
- set(receipt, "candidateIntents", p.preflight.candidateIntents),
338
- set(receipt, "divergenceCount", p.preflight.divergenceCount),
339
- set(receipt, "baselineRoot", p.preflight.baselineRoot),
340
- set(receipt, "candidateRoot", p.preflight.candidateRoot),
341
300
  ...(p.preflight.acceptanceReason !== undefined
342
301
  ? [set(receipt, "acceptanceReason", p.preflight.acceptanceReason)]
343
302
  : []),
@@ -380,7 +339,7 @@ export const rollbackDomain = directive(DOMAIN_LIFECYCLE_DIRECTIVES.Rollback)
380
339
  .plan((p, ctx): PlannedOp[] => {
381
340
  const candidate = domainInstallationInstance(p.candidateHash);
382
341
  const prior = domainInstallationInstance(p.priorHash);
383
- const receipt = releaseReceiptInstance(p.preflight.reportHash);
342
+ const receipt = releaseReceiptInstance(p.priorHash, p.preflight.atStateRoot);
384
343
  return [
385
344
  set(candidate, "status.phase", DomainInstallationPhase.Disabled),
386
345
  set(candidate, "status.supersededBy", p.priorHash),
@@ -391,18 +350,7 @@ export const rollbackDomain = directive(DOMAIN_LIFECYCLE_DIRECTIVES.Rollback)
391
350
  set(receipt, "domain", p.preflight.domain),
392
351
  set(receipt, "atHash", p.preflight.atHash),
393
352
  set(receipt, "atStateRoot", p.preflight.atStateRoot),
394
- set(receipt, "historyHash", p.preflight.historyHash),
395
- set(receipt, "reportHash", p.preflight.reportHash),
396
- set(receipt, "reportJson", p.preflight.reportJson),
397
- set(receipt, "complete", true),
398
353
  set(receipt, "evolveOk", true),
399
- set(receipt, "rawPromotable", p.preflight.rawPromotable),
400
- set(receipt, "projectionEqual", p.preflight.projectionEqual),
401
- set(receipt, "replayedIntents", p.preflight.replayedIntents),
402
- set(receipt, "candidateIntents", p.preflight.candidateIntents),
403
- set(receipt, "divergenceCount", p.preflight.divergenceCount),
404
- set(receipt, "baselineRoot", p.preflight.baselineRoot),
405
- set(receipt, "candidateRoot", p.preflight.candidateRoot),
406
354
  ...(p.preflight.acceptanceReason !== undefined
407
355
  ? [set(receipt, "acceptanceReason", p.preflight.acceptanceReason)]
408
356
  : []),
@@ -0,0 +1,10 @@
1
+ // NOMOS — canonical framework governance relation identities.
2
+ //
3
+ // These names are global, kernel-coupled vocabulary. Coin them once so DSL
4
+ // sugar cannot accidentally redeclare them as tenant-local relations and cause
5
+ // the compiler to namespace gates away from the framework's founding tuples.
6
+ import { relationRef } from "../relation_schema.js";
7
+
8
+ /** The global workspace authority seated at `workspace:self#admin`. */
9
+ export const WORKSPACE_ADMIN_RELATION = relationRef("admin");
10
+
@@ -160,6 +160,13 @@ export const IMPURE_CAPABILITY_ENVELOPE = {
160
160
  // (read as 0); making it required would break decode of every pre-existing task AND the era
161
161
  // rule (old chains fold no `attempts`). Judged against `maxRescheduleAttempts` to bound retries.
162
162
  attempts: t.int().merge(Lww).optional(),
163
+ // TEMPO LIMIT-BREACH facts (OPTIONAL — era-safe): stamped by the bounded `reschedule` verb
164
+ // when the drum judges a still-`requested` task past its DECLARED `limitMs` (a tenant request
165
+ // field, when the capability declares one). Lww-last is enough for the projection — every
166
+ // stamp is an ordinary Intent in the chain, so the full breach history stays auditable.
167
+ // Limits are law; ESTIMATE breaches are read-side telemetry and never fold here.
168
+ limitBreachedAt: t.int().merge(Lww).optional(),
169
+ limitBreachElapsedMs: t.int().merge(Lww).optional(),
163
170
  // The FRAMEWORK Obligation↔Receipt CLOSURE link (OPTIONAL): the id of the Obligation this
164
171
  // RECEIPT closes (== the task's own requestId — the reactor mutates the task instance).
165
172
  // GENERIC, written by every impureCapability Receipt (complete OR fail), so the read
@@ -411,7 +418,14 @@ export function deadLetterOps(
411
418
  */
412
419
  export function rescheduleOps(
413
420
  aggregateId: string,
414
- p: { requestId: string; scheduledAt: number; deadlineAt: number; attempts: number },
421
+ p: {
422
+ requestId: string;
423
+ scheduledAt: number;
424
+ deadlineAt: number;
425
+ attempts: number;
426
+ /** Present iff this re-open was forced by a DECLARED-LIMIT breach (not the deadline). */
427
+ limitBreach?: { at: number; elapsedMs: number };
428
+ },
415
429
  ): PlannedOp[] {
416
430
  const taskInstance = envelopeInstance(aggregateId, p.requestId);
417
431
  return [
@@ -420,6 +434,12 @@ export function rescheduleOps(
420
434
  set(taskInstance, "attempts", p.attempts),
421
435
  // KEEP status:'requested' — re-opening a non-terminal task, never a terminal transition.
422
436
  set(taskInstance, "status", "requested"),
437
+ ...(p.limitBreach !== undefined
438
+ ? [
439
+ set(taskInstance, "limitBreachedAt", p.limitBreach.at),
440
+ set(taskInstance, "limitBreachElapsedMs", p.limitBreach.elapsedMs),
441
+ ]
442
+ : []),
423
443
  ];
424
444
  }
425
445
 
@@ -591,6 +611,19 @@ export function impureCapability<
591
611
  /** How many times an overdue-but-still-`requested` obligation may be RE-OPENED in place by
592
612
  * `reschedule` before the next reschedule dead-letters it instead. Defaults to 5. */
593
613
  maxRescheduleAttempts?: number;
614
+ /**
615
+ * OPTIONAL EXISTENCE PRECONDITIONS on the Obligation verb (kernel-lowered point guards):
616
+ * each entry refuses the oblige when the row the named request field references is ABSENT,
617
+ * so a typo'd address can never seal an unfulfillable obligation that only surfaces as
618
+ * tempo-breach noise later. Era-additive — declaring nothing changes nothing on the wire.
619
+ */
620
+ obligationRefsExist?: ReadonlyArray<{
621
+ aggregate: AggregateHandle;
622
+ /** The obligation request field carrying the referenced aggregate's id. */
623
+ idField: string;
624
+ /** The typed refusal code (e.g. `"delivery-product-not-found"`). */
625
+ rejectCode: string;
626
+ }>;
594
627
  }): ImpureCapability<Id, ParamsShape, F, ReceiptShape> {
595
628
  const taskAggregate = impureCapabilityAggregate(spec.aggregateId, spec.params);
596
629
  const obligationPayload = z.object(spec.paramsSchema);
@@ -626,12 +659,25 @@ export function impureCapability<
626
659
  .returns(taskAggregate);
627
660
  const maxRescheduleAttempts = spec.maxRescheduleAttempts ?? 5;
628
661
 
629
- const obligation: RequirableDirective<ObligationPayload<ParamsShape>> = directive(
662
+ let obligationBuilder = directive(
630
663
  spec.obligationDirectiveId,
631
664
  )
632
665
  .creates(taskAggregate)
633
- .payload(obligationPayload)
634
- .plan((p, ctx) => {
666
+ .payload(obligationPayload);
667
+ // THE EXISTENCE FENCE (CO2-1624): refuse an oblige whose request references a row that does
668
+ // not exist. Chained BEFORE `.plan` so each guard lowers into the manifest as a typed point
669
+ // precondition the kernel evaluates at every gate — author, edge admission, verify_chain.
670
+ for (const pre of spec.obligationRefsExist ?? []) {
671
+ obligationBuilder = obligationBuilder.precondition(
672
+ pre.aggregate,
673
+ ({ row, intent }) => ({
674
+ id: intent.field(pre.idField as never) as never,
675
+ holds: row.exists(),
676
+ }),
677
+ pre.rejectCode,
678
+ );
679
+ }
680
+ const obligationDirective = obligationBuilder.plan((p, ctx) => {
635
681
  // The caller supplies BUSINESS PARAMS only. Nomos owns the task identity,
636
682
  // provenance and lifecycle clock so generated clients cannot accidentally
637
683
  // spoof or hand-roll the obligation envelope.
@@ -654,6 +700,7 @@ export function impureCapability<
654
700
  .set("status", "requested");
655
701
  return spec.writeParams(impureCapabilityInstanceId(requestId), params);
656
702
  });
703
+ const obligation = obligationDirective as RequirableDirective<ObligationPayload<ParamsShape>>;
657
704
 
658
705
  // The two branches above are distinct object schemas; the declared payload TYPE is the
659
706
  // `CompletePayload<ReceiptShape>` conditional, so name it once here rather than leaking a
@@ -696,7 +743,7 @@ export function impureCapability<
696
743
  // outcomes, mirroring the consequent-outbox cap discipline (never touch a terminal task; only
697
744
  // re-open genuinely-overdue work; dead-letter once the retry budget is exhausted):
698
745
  // 1. status !== "requested" → NO-OP (never mutate a terminal task).
699
- // 2. now <= deadlineAt → NO-OP (not overdue yet).
746
+ // 2. now <= deadlineAt AND no declared-limit breach → NO-OP (not overdue yet).
700
747
  // 3. overdue & attempts < cap → RE-OPEN in place (rescheduleOps): fresh window +
701
748
  // attempts+1, KEEP status:'requested' (kernel-legal —
702
749
  // a still-`requested` task is non-terminal).
@@ -723,23 +770,36 @@ export function impureCapability<
723
770
  // (1) NEVER touch a terminal task — only `requested` is non-terminal.
724
771
  if (status !== "requested") return [];
725
772
  const deadlineAt = Number(data?.deadlineAt ?? 0);
726
- // (2) Not overdue yet — the deterministic clock has not passed the committed deadline.
727
- if (now <= deadlineAt) return [];
773
+ // THE DECLARED-LIMIT JUDGMENT (tempo): a capability whose request declares `limitMs`
774
+ // folds it onto this same task row, and elapsed time past that limit is judged exactly
775
+ // like overdue-ness — in-law, from the deterministic captured clock, against the task's
776
+ // own committed `scheduledAt`. Absent `limitMs` (legacy work) ≡ no limit — era-safe.
777
+ const limitMsRaw = data?.limitMs;
778
+ const limitMs = typeof limitMsRaw === "number" && limitMsRaw >= 0 ? limitMsRaw : undefined;
779
+ const scheduledAt = Number(data?.scheduledAt ?? 0);
780
+ const elapsedMs = now - scheduledAt;
781
+ const pastLimit = limitMs !== undefined && scheduledAt > 0 && elapsedMs > limitMs;
782
+ // (2) Neither past the committed deadline NOR past the declared limit — no-op.
783
+ if (now <= deadlineAt && !pastLimit) return [];
728
784
  const attempts = Number(data?.attempts ?? 0);
729
785
  // (4) Overdue AND the retry budget is exhausted → terminal dead-letter (reuse deadLetterOps).
730
786
  if (attempts >= maxRescheduleAttempts) {
731
787
  return deadLetterOps(spec.aggregateId, {
732
788
  requestId: p.requestId,
733
- deadLetterReason: `overdue: exhausted ${maxRescheduleAttempts} reschedule attempts`,
789
+ deadLetterReason: pastLimit
790
+ ? `overdue: exhausted ${maxRescheduleAttempts} reschedule attempts (declared limit ${limitMs}ms breached; elapsed ${elapsedMs}ms)`
791
+ : `overdue: exhausted ${maxRescheduleAttempts} reschedule attempts`,
734
792
  deadLetteredAt: now,
735
793
  });
736
794
  }
737
- // (3) Overdue but under the cap → RE-OPEN in place with a fresh window + bumped attempts.
795
+ // (3) Overdue (or limit-breached) but under the cap → RE-OPEN in place with a fresh
796
+ // window + bumped attempts; a limit breach additionally stamps the breach fact.
738
797
  return rescheduleOps(spec.aggregateId, {
739
798
  requestId: p.requestId,
740
799
  scheduledAt: now + (spec.scheduleAfterMs ?? 0),
741
800
  deadlineAt: now + (spec.deadlineAfterMs ?? 24 * 60 * 60 * 1000),
742
801
  attempts: attempts + 1,
802
+ ...(pastLimit ? { limitBreach: { at: now, elapsedMs } } : {}),
743
803
  });
744
804
  })
745
805
  .reads(taskById);
@@ -77,6 +77,7 @@ import { read } from "../read.js";
77
77
  import { readRef, readSet, workspaceInvariant, refAs, type WorkspaceInvariantVerdict } from "./workspace_invariant.js";
78
78
  import { writeTuple, removeTuple, RelationTuple, relationTupleInstanceId } from "./relations.js";
79
79
  import { relationType, self, user, key } from "../relation_schema.js";
80
+ import { WORKSPACE_ADMIN_RELATION } from "./governance_relations.js";
80
81
  import { GENESIS_WARRANT_ID } from "./genesis.js";
81
82
  import { idOf } from "../value_object.js";
82
83
  import { enrollSigner, signerObject, SIGNER_RELATION } from "./signing_identity.js";
@@ -191,7 +192,7 @@ export const workspaceRelations = relationType("workspace")
191
192
  // migration. (A TENANT that wants a workspace-scoped role uses a namespaced `relationType("workspace")`
192
193
  // + a `selfWorkspace()` seed; only THIS framework governance type is global.)
193
194
  .asGlobal()
194
- .relation("admin", { rewrite: self(), allow: [user(), key()] })
195
+ .relation(WORKSPACE_ADMIN_RELATION, { rewrite: self(), allow: [user(), key()] })
195
196
  .relation("creator", { rewrite: self(), allow: [user(), key()] })
196
197
  .relation("platformCreator", { rewrite: self(), allow: [user(), key()] });
197
198
 
package/src/role.ts CHANGED
@@ -14,8 +14,9 @@
14
14
  * `relationType("workspace")` block (the fail-open footgun is CLOSED — a `.requires(role.edit)`
15
15
  * always finds a declared relation), and the `<role>View` relation is a READ-VISIBILITY cap
16
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.
17
+ * relation, exactly as any private aggregate does). A custom grant-authority relation is likewise
18
+ * authored locally. The default `admin` instead refers to the framework's existing GLOBAL
19
+ * `workspace:self#admin`; redeclaring it locally would namespace the gate away from its founding tuple.
19
20
  *
20
21
  * 2. GRANT / SEED DIRECTIVES per role — `grant<Role>` / `revoke<Role>` fold the relation TUPLE via
21
22
  * the existing `writeTuple`/`removeTuple` primitives (a grant seats `<role>Edit` + `<role>View`
@@ -41,6 +42,7 @@ import type { AggregateHandle } from "./aggregate.js";
41
42
  import type { Directive, RequirableDirective } from "./directive.js";
42
43
  import { directive } from "./directive.js";
43
44
  import { RelationTuple, writeTuple, removeTuple } from "./framework/relations.js";
45
+ import { WORKSPACE_ADMIN_RELATION } from "./framework/governance_relations.js";
44
46
  import { relationType, self, user, key, type RelationTypeDecl } from "./relation_schema.js";
45
47
 
46
48
  /** A role name: kebab-case (`editor`, `site-admin`) — lowered to relation/directive labels. */
@@ -234,7 +236,7 @@ export function role(name: string): RoleBuilder {
234
236
  name,
235
237
  editRelation: `${base}Edit`,
236
238
  viewRelation: `${base}View`,
237
- grantableBy: "admin",
239
+ grantableBy: WORKSPACE_ADMIN_RELATION,
238
240
  editAggregates: [],
239
241
  viewAggregates: [],
240
242
  canEdit: false,
@@ -256,8 +258,10 @@ const ROLE_RELATIONS_KEY = "__nomosRoleRelations";
256
258
  * EXPANDER (the `capability()`/birth-meter seam): given a module's exports bag, LIFT every `RoleEmission`
257
259
  * into discoverable entries — the grant/revoke/seed directives become top-level exports (so `directivesOf`
258
260
  * 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
+ * distinct LOCAL `grantableBy` grant-authority relations, deduped) becomes a discoverable relation type
262
+ * (so `.requires(role.edit)` / `.requires(customGrantAuthority)` have TEETH — the fail-open footgun is
263
+ * closed). The default framework `admin` authority is already declared by the global governance schema
264
+ * and MUST NOT be redeclared here as tenant-local. The
261
265
  * `<role>View` read relations are NOT authored here: they are read-visibility caps, synthesized by usd.ts
262
266
  * from the aggregates that declare `requires(role.view)`. IDEMPOTENT + additive: a bag with no role is
263
267
  * returned unchanged (byte-identical), and re-running on an already-expanded bag is a no-op.
@@ -281,12 +285,14 @@ export function expandRoleExports(mod: Record<string, unknown>): Record<string,
281
285
  };
282
286
  for (const e of emissions) {
283
287
  if (e.meta.editRelation) declare(e.meta.editRelation);
284
- declare(e.meta.grantableBy);
288
+ if (e.meta.grantableBy !== WORKSPACE_ADMIN_RELATION) declare(e.meta.grantableBy);
285
289
  for (const dir of e.directives) {
286
290
  const anyDir = dir as { id: string };
287
291
  out[`__role_${anyDir.id}`] = dir;
288
292
  }
289
293
  }
290
- out[ROLE_RELATIONS_KEY] = builder as unknown as RelationTypeDecl;
294
+ // Keep the marker even for a view-only role using global admin so expansion stays idempotent without
295
+ // publishing an empty relation schema declaration.
296
+ out[ROLE_RELATIONS_KEY] = declared.size > 0 ? builder as unknown as RelationTypeDecl : true;
291
297
  return out;
292
298
  }