@githolon/dsl 0.30.1 → 0.31.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.30.1",
3
+ "version": "0.31.1",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/aggregate.ts CHANGED
@@ -155,6 +155,17 @@ export interface AggregateHandle<
155
155
  readonly hasMany: HasManyFields<F>;
156
156
  /** Brand so a plain string is never accepted where a handle is required. */
157
157
  readonly __isAggregateHandle: true;
158
+ /**
159
+ * FRAMEWORK-SHARED brand (Dart-codegen hygiene). Set (by `framework/framework_aggregates.ts`)
160
+ * ONLY on the framework-owned aggregate singletons declared in `dsl/src/framework/` — most
161
+ * importantly `RelationTuple`, auto-added to EVERY relation-using domain. A branded aggregate's
162
+ * Dart surface (read-model class, typed-id class, by-id accessors) is emitted ONCE per package into
163
+ * a shared `nomos_framework.dart` file, NOT re-emitted in each domain's generated `.dart` — so a
164
+ * package barrel re-exporting several domains never gets a duplicate framework symbol (no hand-
165
+ * maintained `hide` clause). NOT hashed / NOT law: a pure frontend-codegen marker (like
166
+ * `readModelAggregates`). Identified by OBJECT IDENTITY (the branded singleton), so a tenant
167
+ * aggregate that merely SHARES a name is never misclassified. OMITTED on tenant aggregates. */
168
+ readonly framework?: true;
158
169
  /** Presence-only flag (hashed); OMITTED ENTIRELY when no invariant is declared. */
159
170
  readonly hasInvariant?: true;
160
171
  /** The executable body — ships in the engine bundle, NOT the manifest. */
@@ -1168,8 +1168,8 @@ function emitEvidenceRefClass(): string {
1168
1168
 
1169
1169
  /** True when ANY aggregate field in the module is `t.evidence()` — gates emitting the
1170
1170
  * shared [emitEvidenceRefClass] once. */
1171
- function moduleUsesEvidence(mod: DomainModule): boolean {
1172
- return mod.aggregates.some((a) =>
1171
+ function moduleUsesEvidence(aggregates: readonly AggregateHandle[]): boolean {
1172
+ return aggregates.some((a) =>
1173
1173
  Object.values(a.fields as Record<string, Field>).some((f) => f.kind === "evidence"),
1174
1174
  );
1175
1175
  }
@@ -2300,14 +2300,21 @@ function emitMintedCreates(
2300
2300
  directives: AnyDirective[],
2301
2301
  domainName: string,
2302
2302
  enumDartTypes: Map<string, string>,
2303
+ /** Aggregate ids whose typed-id CLASS is emitted in the shared `nomos_framework.dart` (imported
2304
+ * here), so it must NOT be re-declared. Minting FACTORIES still emit and reference the imported
2305
+ * class. Empty = emit every id class locally (the single-domain / shared-file path). */
2306
+ skipIdClassFor: ReadonlySet<string> = new Set(),
2303
2307
  ): string {
2304
2308
  const blocks: string[] = [];
2305
2309
 
2306
2310
  // (1) A typed id class per aggregate. Dedup by the typed-id class NAME: two wire
2307
2311
  // types never collide on a name (the name is a pure function of the type), and a
2308
2312
  // domain that re-exports a framework aggregate must not emit its id class twice.
2313
+ // Framework-shared aggregates are emitted ONCE in the package's `nomos_framework.dart`,
2314
+ // so their id class is SKIPPED here (referenced via import).
2309
2315
  const emittedIdClasses = new Set<string>();
2310
2316
  for (const agg of aggregates) {
2317
+ if (skipIdClassFor.has(agg.id)) continue;
2311
2318
  const cls = typedIdClassForAggregateType(agg.id);
2312
2319
  if (emittedIdClasses.has(cls)) continue;
2313
2320
  emittedIdClasses.add(cls);
@@ -2565,12 +2572,49 @@ export interface DomainModule {
2565
2572
  dartRefImports?: DartRefImport[];
2566
2573
  }
2567
2574
 
2575
+ /** Options controlling FRAMEWORK-SHARED aggregate deduplication (Dart-codegen hygiene). */
2576
+ export interface GenerateDartDomainOptions {
2577
+ /**
2578
+ * Aggregate ids whose Dart surface (projection class, read model, typed-id class, by-id accessors)
2579
+ * is emitted ONCE per package in the shared `nomos_framework.dart` and MUST NOT be re-emitted here —
2580
+ * this domain REFERENCES them via {@link frameworkImportUri}. Empty/absent ⇒ emit everything locally
2581
+ * (the legacy single-file behaviour; byte-identical to before this option existed).
2582
+ */
2583
+ readonly skipAggregateIds?: ReadonlySet<string>;
2584
+ /**
2585
+ * Framework-shared DIRECTIVE ids whose generated payload class (+ minted-create factory) is emitted
2586
+ * ONCE per package in the shared file and MUST NOT be re-emitted here. The domain still OFFERS them
2587
+ * (the client offer methods stay per-domain); only the payload TYPE moves to the shared file.
2588
+ */
2589
+ readonly skipDirectiveIds?: ReadonlySet<string>;
2590
+ /**
2591
+ * Framework-shared QUERY ids whose top-level `watch<Q>`/`read<Q>` accessors are emitted ONCE per
2592
+ * package in the shared file and MUST NOT be re-emitted here. The client's per-domain query methods
2593
+ * stay (they reference the shared read models via the framework import).
2594
+ */
2595
+ readonly skipQueryIds?: ReadonlySet<string>;
2596
+ /**
2597
+ * Relative import of the shared framework file (e.g. `nomos_framework.dart`). Added as an ordinary
2598
+ * `import` so skipped framework symbols resolve. Omit when nothing is skipped.
2599
+ */
2600
+ readonly frameworkImportUri?: string;
2601
+ }
2602
+
2568
2603
  /** Generate the full Dart source for one domain module. */
2569
- export function generateDartDomain(mod: DomainModule): string {
2604
+ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOptions = {}): string {
2570
2605
  const domainKey = mod.domain ?? mod.name;
2606
+ const skipAggregateIds = opts.skipAggregateIds ?? new Set<string>();
2607
+ const skipDirectiveIds = opts.skipDirectiveIds ?? new Set<string>();
2608
+ const skipQueryIds = opts.skipQueryIds ?? new Set<string>();
2609
+ // The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
2610
+ const localAggregates = mod.aggregates.filter((a) => !skipAggregateIds.has(a.id));
2611
+ const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
2612
+ const localQueries = (mod.queries ?? []).filter((q) => !skipQueryIds.has(q.id));
2613
+ // Enums are collected from the LOCALLY-emitted symbols only — a framework directive/aggregate's
2614
+ // enums ride the shared `nomos_framework.dart`, so a domain that skips them emits no dangling enum.
2571
2615
  const { dartTypes, decls } = collectEnums(
2572
- mod.aggregates,
2573
- mod.directives,
2616
+ localAggregates,
2617
+ localDirectives,
2574
2618
  domainKey,
2575
2619
  );
2576
2620
  const refIdClassForAggregate = refIdClassResolver(mod);
@@ -2583,7 +2627,7 @@ export function generateDartDomain(mod: DomainModule): string {
2583
2627
  );
2584
2628
  const enumBlock = decls.join("\n\n");
2585
2629
  const permissionVocabularyBlock = emitPermissionVocabulary(mod.permissionVocabulary);
2586
- const aggBlock = mod.aggregates
2630
+ const aggBlock = localAggregates
2587
2631
  .map((a) =>
2588
2632
  emitAggregate(
2589
2633
  a,
@@ -2593,7 +2637,7 @@ export function generateDartDomain(mod: DomainModule): string {
2593
2637
  ),
2594
2638
  )
2595
2639
  .join("\n\n");
2596
- const dirBlock = mod.directives
2640
+ const dirBlock = localDirectives
2597
2641
  .map((d) => emitDirective(d, domainKey, dartTypes))
2598
2642
  .join("\n\n");
2599
2643
 
@@ -2602,7 +2646,9 @@ export function generateDartDomain(mod: DomainModule): string {
2602
2646
  // ref fields name their target's typed id), plus a top-level `create…` factory that
2603
2647
  // mints THROUGH the kernel (no raw id) for every `.creates` directive carrying a
2604
2648
  // `mintSeed` + an own-id field.
2605
- const mintedBlock = emitMintedCreates(mod.aggregates, mod.directives, domainKey, dartTypes);
2649
+ // Minted-create factories for framework directives (if any) live in the shared file — pass only the
2650
+ // LOCAL directives here; framework typed-id classes are likewise skipped (referenced via import).
2651
+ const mintedBlock = emitMintedCreates(mod.aggregates, localDirectives, domainKey, dartTypes, skipAggregateIds);
2606
2652
 
2607
2653
  // PROVIDER SDK (the symmetric twin of the peer payload classes): for each declared
2608
2654
  // `impureCapability` capability, a typed Obligation/Result/Failure + handler typedef + dispatcher.
@@ -2635,7 +2681,7 @@ export function generateDartDomain(mod: DomainModule): string {
2635
2681
  list.push(c);
2636
2682
  combinedsByType.set(c.of, list);
2637
2683
  }
2638
- const readModelBlock = mod.aggregates
2684
+ const readModelBlock = localAggregates
2639
2685
  .filter((a) => readModelTypes.has(a.id))
2640
2686
  .map((a) =>
2641
2687
  emitReadModel(
@@ -2657,7 +2703,7 @@ export function generateDartDomain(mod: DomainModule): string {
2657
2703
  // §5 claim that "codegen already emits count reads" was wrong; this is the actual
2658
2704
  // first emission. The predicate is baked into the maintained counter and is
2659
2705
  // INVISIBLE to the generated accessor (worldview law).
2660
- const queryReads = (mod.queries ?? [])
2706
+ const queryReads = localQueries
2661
2707
  .map((q) =>
2662
2708
  emitQueryReads(
2663
2709
  q,
@@ -2731,7 +2777,9 @@ export function generateDartDomain(mod: DomainModule): string {
2731
2777
  )
2732
2778
  .join("\n\n");
2733
2779
  const orderedReadBlock = (mod.orderedReads ?? []).map(emitOrderedReads).join("\n\n");
2734
- const byIdReads = mod.aggregates.map(emitAggregateByIdReads).join("\n\n");
2780
+ // By-id accessors for framework-shared aggregates live in `nomos_framework.dart` (emitted once);
2781
+ // this domain references them via the framework import.
2782
+ const byIdReads = localAggregates.map(emitAggregateByIdReads).join("\n\n");
2735
2783
  const readBlock = [queryReads, countReads, sumReads, minReads, maxReads, existsReads, orderedReadBlock, byIdReads]
2736
2784
  .filter((s) => s !== "")
2737
2785
  .join("\n\n");
@@ -2739,7 +2787,7 @@ export function generateDartDomain(mod: DomainModule): string {
2739
2787
  // `_mapEq` is only referenced by aggregate `==`/`hashCode` for MAP fields. Emit it
2740
2788
  // ONLY when some aggregate has a map field, else a map-less domain trips an
2741
2789
  // unused-element lint.
2742
- const hasMapField = mod.aggregates.some((a) =>
2790
+ const hasMapField = localAggregates.some((a) =>
2743
2791
  Object.values(a.fields as Record<string, Field>).some((f) => f.kind === "map"),
2744
2792
  );
2745
2793
  const helperBlock = hasMapField
@@ -2771,6 +2819,9 @@ export function generateDartDomain(mod: DomainModule): string {
2771
2819
  // out of nomos_client into the co-located tenant package). The framework barrel
2772
2820
  // re-exports wire/dispatch/subscriptions/provider, so one import covers them all.
2773
2821
  `import 'package:nomos_client/nomos_client.dart';`,
2822
+ // FRAMEWORK-SHARED aggregates (RelationTuple, …) are emitted ONCE per package in the shared
2823
+ // file; import it so this domain's read accessors / ref-id fields resolve those symbols.
2824
+ ...(opts.frameworkImportUri !== undefined ? [`import '${opts.frameworkImportUri}';`] : []),
2774
2825
  ...(mod.dartImports ?? []).map(dartImportLine),
2775
2826
  ``,
2776
2827
  `// ---- enums ----`,
@@ -2783,7 +2834,7 @@ export function generateDartDomain(mod: DomainModule): string {
2783
2834
  ``,
2784
2835
  ]
2785
2836
  : []),
2786
- ...(moduleUsesEvidence(mod)
2837
+ ...(moduleUsesEvidence(localAggregates)
2787
2838
  ? [
2788
2839
  `// ---- evidence (content-addressed EvidenceRef; evidence.md §9.2) ----`,
2789
2840
  emitEvidenceRefClass(),
@@ -99,6 +99,14 @@ export interface DartBridgeClientOptions {
99
99
  readonly interface?: unknown;
100
100
  /** Where the bridge/runtime support types are imported from. */
101
101
  readonly supportImport?: "vendored" | "package";
102
+ /**
103
+ * Relative import of the shared `nomos_framework.dart` (framework-shared aggregate types, emitted
104
+ * ONCE per package). When present the client IMPORTS it (its by-id accessors reference framework
105
+ * read models) and RE-EXPORTS it, so a package barrel re-exporting several domain clients surfaces
106
+ * the framework types EXACTLY ONCE (Dart de-dups re-exports of the SAME library) — no `hide` needed.
107
+ * Omit for a package with no framework-shared aggregates.
108
+ */
109
+ readonly frameworkImportUri?: string;
102
110
  }
103
111
 
104
112
  /** Generate the `<name>.client.dart` async bridge client for one domain module. */
@@ -245,9 +253,13 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
245
253
  ...(opts.interface !== undefined ? [`import 'dart:convert';`, ``] : []),
246
254
  `import '${supportImport}';`,
247
255
  `import '${mod.name}.dart';`,
256
+ // Framework-shared aggregate types (RelationTupleReadModel, …) live in the ONE shared file — import
257
+ // them (the by-id accessors reference them) and re-export them (once per package, Dart de-dups).
258
+ ...(opts.frameworkImportUri !== undefined ? [`import '${opts.frameworkImportUri}';`] : []),
248
259
  `// Re-export the sibling types (read models + payloads + the bridge runtime) so ONE import of this`,
249
260
  `// client file gives an app the whole typed surface.`,
250
261
  `export '${mod.name}.dart';`,
262
+ ...(opts.frameworkImportUri !== undefined ? [`export '${opts.frameworkImportUri}';`] : []),
251
263
  `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion;`,
252
264
  ``,
253
265
  `/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
@@ -42,6 +42,7 @@ import type { DomainModule } from "./codegen_dart.js";
42
42
  import type { QueryDecl } from "./query.js";
43
43
  import type { FieldOp } from "./ops.js";
44
44
  import type { RelationTypeDecl, Rewrite } from "./relation_schema.js";
45
+ import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
45
46
  import { finishCount } from "./count.js";
46
47
  import { mintedCreateField } from "./workspace_routing.js";
47
48
  import { deterministicPorts } from "./ctx.js";
@@ -364,18 +365,24 @@ function relationSeedLegLines(
364
365
  }
365
366
  const ACTOR = `"user:proof-conformance"`;
366
367
  const speculative = leg.seedSpeculative === true;
367
- // Grant/seed payload: overwrite the routed subject/grantedBy fields (and any name-shaped fallback) with the actor.
368
- const grantPayloadLit = (() => {
369
- const overwrite = new Set<string>([...leg.grantSubjectFields, ...leg.grantGrantedByFields]);
370
- const entries = Object.entries(leg.grantPayload).map(([k, v]) => {
371
- // Also catch conventional subject/grantedBy field names the plan-probe couldn't value-match.
372
- const looksSubject = /^(subject|principal|grantedBy|grantee|member|user|owner)$/i.test(k);
373
- return overwrite.has(k) || looksSubject
368
+ // A payload literal with subject/founder-conventional fields (+ the routed set) overwritten to the actor —
369
+ // so a seat lands ON the actor AND every write-once/author-binding/founder-binding invariant holds.
370
+ const actorSeatLit = (payload: Record<string, unknown>, routed: readonly string[]): string => {
371
+ const overwrite = new Set<string>(routed);
372
+ const entries = Object.entries(payload).map(([k, v]) =>
373
+ overwrite.has(k) || /^(subject|principal|grantedBy|grantee|member|user|owner|founder)$/i.test(k)
374
374
  ? `${JSON.stringify(k)}: ${ACTOR}`
375
- : `${JSON.stringify(k)}: ${JSON.stringify(v)}`;
376
- });
375
+ : `${JSON.stringify(k)}: ${JSON.stringify(v)}`,
376
+ );
377
377
  return `{ ${entries.join(", ")} }`;
378
- })();
378
+ };
379
+ const grantPayloadLit = actorSeatLit(leg.grantPayload, [...leg.grantSubjectFields, ...leg.grantGrantedByFields]);
380
+ // The PREREQUISITE-SEED CHAIN dispatches (each as the actor = founder) BEFORE the grant.
381
+ const prereqLines = (leg.prerequisiteSeeds ?? []).flatMap((seed) => [
382
+ ` // prerequisite: ${leg.domain}/${seed.directiveId} establishes the state the grant's invariant reads (as founder = ACTOR).`,
383
+ ` await ${factory}(holon, { actor: ACTOR }).${seed.directiveId}(${actorSeatLit(seed.payload, [])});`,
384
+ ` ok(\`relation bootstrap (prerequisite) — ${leg.domain}/${seed.directiveId} established (as founder \${ACTOR}) before the grant\`);`,
385
+ ]);
379
386
  const gatedPayloadLit = leg.gatedMintField !== undefined && leg.gatedAggregateId !== undefined
380
387
  ? `{ ...${JSON.stringify(leg.gatedPayload)}, ${JSON.stringify(leg.gatedMintField)}: await holon.mint(${JSON.stringify(leg.gatedAggregateId)}) }`
381
388
  : JSON.stringify(leg.gatedPayload);
@@ -445,9 +452,12 @@ function relationSeedLegLines(
445
452
  ];
446
453
  }
447
454
 
455
+ const prereqNote = (leg.prerequisiteSeeds ?? []).length > 0
456
+ ? ` The grant needs prerequisite seed(s) [${(leg.prerequisiteSeeds ?? []).map((s) => s.directiveId).join(", ")}] run first (as the actor = founder).`
457
+ : ``;
448
458
  return [
449
459
  `// ${n()}. RELATION BOOTSTRAP (seed-then-prove the POSITIVE): \`${leg.domain}/${leg.gatedDirectiveId}\` is GATED`,
450
- `// (\`.requires("${leg.relation}")\`). \`${leg.domain}/${leg.grantDirectiveId}\` GRANTS "${leg.grantedRelation}" (${leg.via})${computedNote}.`,
460
+ `// (\`.requires("${leg.relation}")\`). \`${leg.domain}/${leg.grantDirectiveId}\` GRANTS "${leg.grantedRelation}" (${leg.via})${computedNote}.${prereqNote}`,
451
461
  `// Grant it to the proof actor, THEN prove the gated write SUCCEEDS under that actor (what the negative`,
452
462
  `// conformance leg above can't cover — a seeded holder authoring a gated write).`,
453
463
  `{`,
@@ -455,6 +465,7 @@ function relationSeedLegLines(
455
465
  ` globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: relation-bootstrap touched the network"); }) as typeof fetch;`,
456
466
  ` try {`,
457
467
  ` const ACTOR = ${ACTOR};`,
468
+ ...prereqLines,
458
469
  ` // (1) grant "${leg.grantedRelation}" to ACTOR (the grant folds a writeTuple; subject seated on ACTOR).`,
459
470
  ` await ${factory}(holon, { actor: ACTOR }).${leg.grantDirectiveId}(${grantPayloadLit});`,
460
471
  ` ok(\`relation bootstrap (grant) — ${leg.domain}/${leg.grantDirectiveId} granted "${leg.grantedRelation}" to \${ACTOR}${leg.grantedRelation !== leg.relation ? ` (⇒ satisfies "${leg.relation}" via rewrite)` : ``}\`);`,
@@ -536,6 +547,24 @@ export interface RelationBootstrapLeg {
536
547
  * would trip the seed's owner-binding prerequisite (HomeOwner unset) and FALSE-fail. When present, the
537
548
  * runner IGNORES the standalone grant fields and proves via {@link BornContextAuthority}. */
538
549
  readonly bornContext?: BornContextAuthority;
550
+ /** THE PREREQUISITE-SEED CHAIN (the composed co2_platform / `seedPlatformAdmin` case). Present iff the
551
+ * granter (`grantDirectiveId`) carries a PREREQUISITE invariant whose state is established NOT by a birth
552
+ * genesis but by a SIBLING SEED in the SAME domain (e.g. `seedPlatformAdmin`'s `PlatformAdminBindsFounder`
553
+ * reads the founder singleton stamped by `seedPlatformFounder`). The runner dispatches these seeds IN ORDER
554
+ * (each as the proof actor = founder, so their own write-once/author-binding invariants hold) BEFORE the
555
+ * grant — then the grant + the gated write succeed. A non-birth analog of {@link BornContextAuthority}. */
556
+ readonly prerequisiteSeeds?: readonly PrerequisiteSeed[];
557
+ }
558
+
559
+ /** One prerequisite seed the runner dispatches (as the proof actor = founder) BEFORE the grant — it
560
+ * establishes the state a granter's prerequisite invariant reads (e.g. the founder singleton). */
561
+ export interface PrerequisiteSeed {
562
+ /** The seed directive id (e.g. `seedPlatformFounder`). */
563
+ readonly directiveId: string;
564
+ /** The synthesized payload; founder/subject-conventional fields are set to the proof actor at dispatch so
565
+ * the seed's own write-once/author-binding invariants pass (founder == the seeding actor). */
566
+ readonly payload: Record<string, unknown>;
567
+ readonly autoStamped: readonly string[];
539
568
  }
540
569
 
541
570
  /** THE BORN-DOMAIN GENESIS AUTHORITY (layer 4): the self-birth directive whose genesis seats the gate relation
@@ -594,27 +623,61 @@ function probePlanOps(
594
623
  return { ops, synth, probePayload };
595
624
  }
596
625
 
597
- /** Does this directive's plan GRANT `relation` — i.e. fold a relation-tuple write whose `relation` field is
598
- * set to R (the `writeTuple`/`relateSubject` shape: a `set(rt, "relation", R)` op on a RelationTuple)? */
599
- function probeGrantsRelation(dir: Directive<unknown>, relation: string, where: string): boolean {
600
- const probed = probePlanOps(dir, where);
601
- if (probed === undefined) return false;
602
- return probed.ops.some((o) => o.kind === "set" && o.field === "relation" && o.value === relation);
626
+ /** The `status` of a `subjects` setEntry leaf (`writeTuple`/`removeTuple` lowers a JSON `{status, …}` leaf).
627
+ * `"active"` = a POSITIVE grant; `"revoked"` = a REVOKE. `undefined` ⇒ not a parseable subject leaf. */
628
+ function subjectLeafStatus(op: FieldOp): string | undefined {
629
+ if (op.kind !== "setEntry" || op.field !== "subjects" || typeof op.value !== "string") return undefined;
630
+ try {
631
+ return (JSON.parse(op.value) as { status?: unknown }).status as string | undefined;
632
+ } catch {
633
+ return undefined;
634
+ }
603
635
  }
604
636
 
605
- /** ALL relation names a directive's plan writes tuples for (the `set(rt, "relation", <name>)` ops) — the
606
- * layer-3 seed discovery reads this to find a directive that ESTABLISHES relation authority, without needing
607
- * the written relation to be a rewrite-resolvable base of the gate. `[]` ⇒ writes no relation tuples. */
608
- function probeRelationsWritten(dir: Directive<unknown>, where: string): string[] {
637
+ /**
638
+ * The relations a directive's plan POSITIVELY GRANTS — judged PER RELATION-TUPLE (per `aggregateId`), never by
639
+ * relation-name alone. Both a grant (`writeTuple`, `status:"active"`) AND a revoke (`removeTuple`,
640
+ * `status:"revoked"`) emit `set(rt, "relation", R)` on the SAME reltuple aggregate; the ONLY discriminator is
641
+ * the `status` inside that tuple's `subjects` leaf. So a relation counts as GRANTED iff its tuple carries an
642
+ * ACTIVE subject leaf — a revoke of R is INELIGIBLE (bug: `revokeCo2PlatformRole` was inferred as a granter).
643
+ * A directive that writes AND removes DIFFERENT relations is judged per-tuple (each aggregateId independently).
644
+ */
645
+ function probeGrantedRelations(dir: Directive<unknown>, where: string): string[] {
609
646
  const probed = probePlanOps(dir, where);
610
647
  if (probed === undefined) return [];
611
- const out = new Set<string>();
648
+ // Group ops by the reltuple instance (aggregateId): each (object, relation) tuple is one bucket.
649
+ const byTuple = new Map<string, { relations: Set<string>; hasActive: boolean; hasRevoked: boolean }>();
612
650
  for (const o of probed.ops) {
613
- if (o.kind === "set" && o.field === "relation" && typeof o.value === "string" && o.value.length > 0) out.add(o.value);
651
+ const key = o.aggregateId;
652
+ let bucket = byTuple.get(key);
653
+ if (bucket === undefined) { bucket = { relations: new Set(), hasActive: false, hasRevoked: false }; byTuple.set(key, bucket); }
654
+ if (o.kind === "set" && o.field === "relation" && typeof o.value === "string" && o.value.length > 0) bucket.relations.add(o.value);
655
+ const status = subjectLeafStatus(o);
656
+ if (status === "active") bucket.hasActive = true;
657
+ else if (status === "revoked") bucket.hasRevoked = true;
658
+ }
659
+ const out = new Set<string>();
660
+ for (const bucket of byTuple.values()) {
661
+ // A tuple GRANTS its relation(s) only when it seats an ACTIVE subject leaf. A tuple with only a revoked
662
+ // leaf (or no subject leaf at all — a materialize with no active seat) does NOT grant.
663
+ if (bucket.hasActive) for (const r of bucket.relations) out.add(r);
614
664
  }
615
665
  return [...out];
616
666
  }
617
667
 
668
+ /** Does this directive's plan POSITIVELY GRANT `relation` (an ACTIVE `writeTuple`/`relateSubject` for R)?
669
+ * A revoke (`removeTuple`, `status:"revoked"`) for R is NOT a grant (per-tuple status discriminator). */
670
+ function probeGrantsRelation(dir: Directive<unknown>, relation: string, where: string): boolean {
671
+ return probeGrantedRelations(dir, where).includes(relation);
672
+ }
673
+
674
+ /** ALL relations a directive's plan POSITIVELY GRANTS (active writeTuples) — the layer-3 seed discovery reads
675
+ * this to find a directive that ESTABLISHES relation authority. A revoke-only directive yields `[]` (it seats
676
+ * no authority). `[]` ⇒ writes no ACTIVE relation tuples. */
677
+ function probeRelationsWritten(dir: Directive<unknown>, where: string): string[] {
678
+ return probeGrantedRelations(dir, where);
679
+ }
680
+
618
681
  /** Resolve WHICH grant-payload fields route into the tuple subject / grantedBy — by matching the op values
619
682
  * the plan emitted (the subject `setEntry`'s entryKey, the `grantedBy` leaf) back to synthesized payload
620
683
  * values. Best-effort: an unmatched routing just leaves the field list empty (runtime falls back). */
@@ -862,6 +925,95 @@ function directiveHasPrerequisiteInvariant(mod: DomainModule, directiveId: strin
862
925
  return (mod.workspaceInvariants ?? []).some((inv) => inv.on === directiveId);
863
926
  }
864
927
 
928
+ /** The AGGREGATE TYPES a directive's plan writes (set/ensure/create ops) — used to find the sibling seed that
929
+ * establishes the state a prerequisite invariant reads. `[]` ⇒ not probeable / writes nothing. */
930
+ function probeWritesAggregateTypes(dir: Directive<unknown>, where: string): Set<string> {
931
+ const probed = probePlanOps(dir, where);
932
+ const out = new Set<string>();
933
+ if (probed === undefined) return out;
934
+ for (const o of probed.ops) if (typeof o.aggregateType === "string" && o.aggregateType.length > 0) out.add(o.aggregateType);
935
+ return out;
936
+ }
937
+
938
+ /** The aggregate TYPES a workspaceInvariant's declared reads depend on — the state its predicate needs to be
939
+ * established first. A `pre:`-prefixed read id is a PRE-apply self-check (write-once), NOT a cross-step
940
+ * dependency, so it's excluded. Best-effort: an un-callable reads fn yields none. */
941
+ function invariantReadAggregateTypes(inv: { reads: (ctx: { intent: Record<string, unknown>; principal?: string }) => Array<{ aggregate: string; id: string }> }): Set<string> {
942
+ const out = new Set<string>();
943
+ let refs: Array<{ aggregate: string; id: string }>;
944
+ try {
945
+ refs = inv.reads({ intent: {}, principal: "" });
946
+ } catch {
947
+ return out;
948
+ }
949
+ for (const r of refs) {
950
+ if (r === null || typeof r !== "object") continue;
951
+ if (typeof r.id === "string" && r.id.startsWith("pre:")) continue; // write-once self-check, not a dependency
952
+ if (typeof r.aggregate === "string" && r.aggregate.length > 0) out.add(r.aggregate);
953
+ }
954
+ return out;
955
+ }
956
+
957
+ /**
958
+ * Discover the PREREQUISITE-SEED CHAIN for a granter whose prerequisite invariant(s) read state established by
959
+ * SIBLING SEEDS in the same domain (the composed co2_platform / `seedPlatformAdmin` case — `seedPlatformAdmin`
960
+ * needs the founder singleton `seedPlatformFounder` stamps). Returns the ordered seeds to dispatch (as the
961
+ * proof actor = founder) BEFORE the grant, or `undefined` when the prerequisite can't be satisfied by a
962
+ * sibling seed (fail-closed — the caller then falls through to born-context / the diagnostic).
963
+ *
964
+ * Principled: for each invariant on `dir`, resolve the aggregate types its reads depend on; for each type NOT
965
+ * written by `dir` itself (a genuine cross-step dependency), find a sibling seed that writes it, then RECURSE
966
+ * into that seed's own prerequisites (bounded by a visited set + a small depth cap — no cycles, no fabrication).
967
+ */
968
+ export function discoverPrerequisiteChain(mod: DomainModule, granter: Directive<unknown>): PrerequisiteSeed[] | undefined {
969
+ const domain = mod.domain ?? mod.name;
970
+ const directives = mod.directives as Directive<unknown>[];
971
+ const invariants = (mod.workspaceInvariants ?? []) as Array<WorkspaceInvariantDecl>;
972
+ const chain: PrerequisiteSeed[] = [];
973
+ const inChain = new Set<string>();
974
+ const synthSeed = (dir: Directive<unknown>): PrerequisiteSeed | undefined => {
975
+ let synth: SynthesizedPayload;
976
+ try {
977
+ synth = synthesizePayload(dir.payloadSchema as z.ZodTypeAny, `${domain}/${dir.id}`);
978
+ } catch {
979
+ return undefined;
980
+ }
981
+ const agg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === dir.aggregateId);
982
+ const mintField = agg !== undefined ? mintedCreateField(dir, agg) : undefined;
983
+ const payload: Record<string, unknown> = { ...synth.value };
984
+ if (mintField !== undefined) delete payload[mintField];
985
+ return { directiveId: dir.id, payload, autoStamped: synth.autoStamped };
986
+ };
987
+ // Resolve `dir`'s prerequisites (a sibling seed per cross-step aggregate dependency), recursively. Returns
988
+ // false when ANY prerequisite has no establishing sibling seed (the whole chain is then unsatisfiable).
989
+ const resolve = (dir: Directive<unknown>, depth: number): boolean => {
990
+ if (depth > 8) return false; // guard runaway recursion
991
+ for (const inv of invariants) {
992
+ if (inv.on !== dir.id) continue;
993
+ const dirWrites = probeWritesAggregateTypes(dir, `${domain}/${dir.id}`);
994
+ for (const aggType of invariantReadAggregateTypes(inv)) {
995
+ if (dirWrites.has(aggType)) continue; // self-dependency (dir establishes it in its own apply)
996
+ // Find a sibling seed that WRITES aggType (not `dir`, not the gated directive, not itself gated).
997
+ const seedDir = directives.find(
998
+ (d) => d !== dir && d.requiresRelationGate === undefined && typeof d?.payloadSchema === "object"
999
+ && d.birthsChild !== true && probeWritesAggregateTypes(d, `${domain}/${d.id}`).has(aggType),
1000
+ );
1001
+ if (seedDir === undefined) return false; // unmet prerequisite → chain unsatisfiable
1002
+ if (!inChain.has(seedDir.id)) {
1003
+ if (!resolve(seedDir, depth + 1)) return false; // establish the seed's OWN prerequisites first
1004
+ const s = synthSeed(seedDir);
1005
+ if (s === undefined) return false;
1006
+ inChain.add(seedDir.id);
1007
+ chain.push(s); // dependency-first order: the seed runs before `dir`
1008
+ }
1009
+ }
1010
+ }
1011
+ return true;
1012
+ };
1013
+ if (!resolve(granter, 0)) return undefined;
1014
+ return chain.length > 0 ? chain : undefined;
1015
+ }
1016
+
865
1017
  /** Discover the RELATION-BOOTSTRAP leg for a module: the first gated (`.requires(R)`) directive plus the
866
1018
  * sibling that ESTABLISHES the actor's authority for R. In priority order: (1) a DIRECT granter of R (a
867
1019
  * `.grants(R)` marker or a plan-probed `writeTuple(R)`) — the original lane; (2) REWRITE-AWARE — when R is
@@ -916,6 +1068,7 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
916
1068
  let via: "marker" | "inferred" | "computed-base" | "seed-path" = "marker";
917
1069
  let seedSpeculative = false;
918
1070
  let seedWritesRelations: string[] | undefined;
1071
+ let prerequisiteSeeds: PrerequisiteSeed[] | undefined;
919
1072
  let found = findGranterFor(relation);
920
1073
  if (found !== undefined) {
921
1074
  via = found.via;
@@ -980,11 +1133,16 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
980
1133
  bornContext,
981
1134
  };
982
1135
  }
983
- // No born-domain path. If a standalone granter exists but carries a prerequisite it can't satisfy alone
984
- // (and isn't the speculative lane), do NOT emit a doomed passing leg — return undefined so the caller's
985
- // loud diagnostic names the unmet prerequisite. A speculative seed-path stays (it degrades safely).
1136
+ // No born-domain path. A standalone granter with a prerequisite it can't satisfy alone (and not the
1137
+ // speculative lane): try the PREREQUISITE-SEED CHAIN — a sibling seed establishes the prerequisite state
1138
+ // (the composed co2_platform case: `seedPlatformFounder` stamps the founder `seedPlatformAdmin` binds to).
1139
+ // The runner dispatches the chain (as the proof actor = founder) BEFORE the grant. If no chain satisfies
1140
+ // the prerequisite, do NOT emit a doomed passing leg — return undefined so the diagnostic names the gap.
1141
+ // A speculative seed-path stays (it degrades safely at runtime).
986
1142
  if (found !== undefined && !seedSpeculative && directiveHasPrerequisiteInvariant(mod, found.granter.id)) {
987
- return undefined;
1143
+ const chain = discoverPrerequisiteChain(mod, found.granter);
1144
+ if (chain === undefined) return undefined;
1145
+ prerequisiteSeeds = chain;
988
1146
  }
989
1147
  }
990
1148
  if (found === undefined) return undefined; // no granter at all → the caller emits the diagnostic
@@ -1019,6 +1177,7 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
1019
1177
  via,
1020
1178
  ...(seedSpeculative ? { seedSpeculative: true } : {}),
1021
1179
  ...(seedWritesRelations !== undefined ? { seedWritesRelations } : {}),
1180
+ ...(prerequisiteSeeds !== undefined ? { prerequisiteSeeds } : {}),
1022
1181
  };
1023
1182
  }
1024
1183
 
@@ -61,6 +61,11 @@ import {
61
61
  type PermissionVocabulary,
62
62
  } from "./codegen_dart.js";
63
63
  import { generateDartBridgeClient } from "./codegen_dart_client.js";
64
+ import {
65
+ isFrameworkAggregate,
66
+ isFrameworkDirective,
67
+ isFrameworkQuery,
68
+ } from "./framework/framework_aggregates.js";
64
69
  import { finishCount, type AnyCount } from "./count.js";
65
70
  import type { QueryDecl } from "./query.js";
66
71
  import type { SpatialDecl } from "./spatial.js";
@@ -387,35 +392,108 @@ function emitDart(
387
392
  removeVendoredDartSupport(dartOut);
388
393
  }
389
394
 
390
- // 2. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
391
- // CLIENT (`<name>.client.dart`, the Flutter sibling of `<name>.client.ts`).
392
- const written: string[] = [];
395
+ // 2. FRAMEWORK-SHARED symbol dedup (Dart-codegen hygiene). Framework-owned symbols fold into MANY
396
+ // domains, so their generated Dart would be DEFINED once per domain — a package barrel re-exporting
397
+ // ≥2 domains then COLLIDES (the historical `hide RelationTuple, EnrollDelegationPayload,
398
+ // readSignerDelegationByUser, …` band-aid). THREE kinds are deduped, each identified by the
399
+ // framework BRAND (object identity, so a tenant symbol that merely shares a name is never
400
+ // misclassified) and emitted EXACTLY ONCE into `nomos_framework.dart`:
401
+ // • AGGREGATES — read-model class, typed-id class, by-id accessors (`RelationTuple` auto-added
402
+ // to every relation-using domain, `SignerDelegation`/`KeyScope`/… re-exported verbatim);
403
+ // • DIRECTIVES — the generated payload class (relate/signer/enroll family). The domain still
404
+ // OFFERS them (client offer methods stay per-domain); only the payload TYPE moves to the shared file;
405
+ // • QUERIES — the top-level `watch<Q>`/`read<Q>` accessors.
406
+ // Per-domain files SKIP each and IMPORT the shared file; clients RE-EXPORT it (Dart de-dups the
407
+ // same-library re-export). So a barrel over all domains needs NO `hide` clause.
408
+ type ModDirective = DomainModule["directives"][number];
409
+ type ModQuery = NonNullable<DomainModule["queries"]>[number];
410
+ const frameworkAggById = new Map<string, AggregateHandle>();
411
+ const frameworkDirById = new Map<string, ModDirective>();
412
+ const frameworkQueryById = new Map<string, ModQuery>();
393
413
  for (const mod of modules) {
394
- // Default the read-model allowlist to EVERY aggregate when the domain didn't pin one, so
395
- // `<name>.dart` carries a read model per aggregate — the bridge client imports them. A domain
396
- // that pins `readModelAggregates` keeps its choice (byte-identical to before this default).
397
- const modForDart: DomainModule =
398
- mod.readModelAggregates === undefined
399
- ? { ...mod, readModelAggregates: mod.aggregates.map((a) => a.id) }
400
- : mod;
401
- const src = generateDartDomain(modForDart);
414
+ for (const a of mod.aggregates) if (isFrameworkAggregate(a)) frameworkAggById.set(a.id, a);
415
+ for (const d of mod.directives) if (isFrameworkDirective(d)) frameworkDirById.set(d.id, d);
416
+ for (const q of mod.queries ?? []) if (isFrameworkQuery(q)) frameworkQueryById.set(q.id, q);
417
+ }
418
+ const frameworkAggs = [...frameworkAggById.values()];
419
+ const frameworkDirs = [...frameworkDirById.values()];
420
+ const frameworkQueries = [...frameworkQueryById.values()];
421
+ const frameworkAggIds: ReadonlySet<string> = new Set(frameworkAggById.keys());
422
+ const frameworkDirIds: ReadonlySet<string> = new Set(frameworkDirById.keys());
423
+ const frameworkQueryIds: ReadonlySet<string> = new Set(frameworkQueryById.keys());
424
+ const hasFrameworkSymbols =
425
+ frameworkAggs.length > 0 || frameworkDirs.length > 0 || frameworkQueries.length > 0;
426
+ const FRAMEWORK_FILE = "nomos_framework";
427
+ const frameworkImportUri = hasFrameworkSymbols ? `${FRAMEWORK_FILE}.dart` : undefined;
428
+
429
+ const written: string[] = [];
430
+
431
+ const writeGenerated = (fileBase: string, src: string): string => {
402
432
  if (!src.includes(DART_PACKAGE_IMPORT)) {
403
433
  fail(
404
- `generated Dart for domain '${mod.name}' does not carry the expected support import (${DART_PACKAGE_IMPORT}) — codegen_dart header drifted; fix the rewrite in compile_package_main.ts`,
434
+ `generated Dart for '${fileBase}' does not carry the expected support import (${DART_PACKAGE_IMPORT}) — codegen_dart header drifted; fix the rewrite in compile_package_main.ts`,
405
435
  );
406
436
  }
407
- const p = path.join(dartOut, `${mod.name}.dart`);
437
+ const p = path.join(dartOut, `${fileBase}.dart`);
408
438
  writeFileSync(
409
439
  p,
410
440
  supportImport === "vendored" ? src.replace(DART_PACKAGE_IMPORT, DART_RELATIVE_IMPORT) : src,
411
441
  "utf8",
412
442
  );
413
443
  written.push(p);
444
+ return p;
445
+ };
446
+
447
+ // 2a. the shared framework file — the framework aggregates + directives + queries, ONCE. Its own
448
+ // surface is emitted in full (no skip). Imported by every domain that folds any framework symbol.
449
+ if (hasFrameworkSymbols) {
450
+ const frameworkMod: DomainModule = {
451
+ name: FRAMEWORK_FILE,
452
+ domain: FRAMEWORK_FILE,
453
+ aggregates: frameworkAggs,
454
+ directives: frameworkDirs,
455
+ readModelAggregates: frameworkAggs.map((a) => a.id),
456
+ ...(frameworkQueries.length > 0 ? { queries: frameworkQueries } : {}),
457
+ };
458
+ writeGenerated(FRAMEWORK_FILE, generateDartDomain(frameworkMod));
459
+ }
460
+
461
+ // 2b. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
462
+ // CLIENT (`<name>.client.dart`, the Flutter sibling of `<name>.client.ts`).
463
+ for (const mod of modules) {
464
+ // Default the read-model allowlist to EVERY aggregate when the domain didn't pin one, so
465
+ // `<name>.dart` carries a read model per aggregate — the bridge client imports them. A domain
466
+ // that pins `readModelAggregates` keeps its choice (byte-identical to before this default).
467
+ const modForDart: DomainModule =
468
+ mod.readModelAggregates === undefined
469
+ ? { ...mod, readModelAggregates: mod.aggregates.map((a) => a.id) }
470
+ : mod;
471
+ // Skip framework symbols this domain folds (they live in the shared file); reference them via the
472
+ // framework import. Only pass the import when this domain actually folds a framework symbol.
473
+ const domainFwAggIds = new Set(
474
+ modForDart.aggregates.filter((a) => frameworkAggIds.has(a.id)).map((a) => a.id),
475
+ );
476
+ const domainFwDirIds = new Set(
477
+ modForDart.directives.filter((d) => frameworkDirIds.has(d.id)).map((d) => d.id),
478
+ );
479
+ const domainFwQueryIds = new Set(
480
+ (modForDart.queries ?? []).filter((q) => frameworkQueryIds.has(q.id)).map((q) => q.id),
481
+ );
482
+ const foldsFramework = domainFwAggIds.size > 0 || domainFwDirIds.size > 0 || domainFwQueryIds.size > 0;
483
+ const domainFrameworkImport = foldsFramework ? frameworkImportUri : undefined;
484
+ const src = generateDartDomain(modForDart, {
485
+ skipAggregateIds: domainFwAggIds,
486
+ skipDirectiveIds: domainFwDirIds,
487
+ skipQueryIds: domainFwQueryIds,
488
+ ...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
489
+ });
490
+ writeGenerated(mod.name, src);
414
491
 
415
492
  const clientSrc = generateDartBridgeClient(modForDart, {
416
493
  domainHash,
417
494
  interface: packageInterface[modForDart.domain ?? modForDart.name],
418
495
  supportImport,
496
+ ...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
419
497
  });
420
498
  const cp = path.join(dartOut, `${mod.name}.client.dart`);
421
499
  writeFileSync(cp, clientSrc, "utf8");
package/src/directive.ts CHANGED
@@ -71,6 +71,16 @@ export interface Directive<P = unknown> {
71
71
  readonly aggregateId: string;
72
72
  readonly payloadSchema: z.ZodType<P>;
73
73
  readonly plan: (payload: P, ctx: Ports) => PlannedOp[];
74
+ /**
75
+ * FRAMEWORK-SHARED brand (Dart-codegen hygiene). Set (by `framework/framework_aggregates.ts`) ONLY on
76
+ * the framework-owned directive singletons declared in `dsl/src/framework/` (the relate/signer/enroll
77
+ * family — `relateSubject`, `enrollDelegation`, `advanceSignerEpoch`, …) which fold into MANY domains.
78
+ * A branded directive's generated Dart PAYLOAD TYPE (`EnrollDelegationPayload`, …) + any minted-create
79
+ * factory is emitted ONCE per package into the shared `nomos_framework.dart`, NOT re-emitted in each
80
+ * domain's `.dart` — so a package barrel re-exporting several domains never gets a duplicate payload
81
+ * class (no hand-maintained `hide` clause). NOT hashed / NOT law: a pure frontend-codegen marker.
82
+ * Identified by OBJECT IDENTITY, so a tenant directive that merely shares a name is never misclassified. */
83
+ readonly framework?: true;
74
84
  /**
75
85
  * Human-readable description of what this directive does — the SINGLE source of
76
86
  * the hover-doc text codegen emits onto each generated payload class + ctor (so
@@ -0,0 +1,138 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon wasm.
3
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
4
+
5
+ /**
6
+ * THE FRAMEWORK-SYMBOL REGISTRY (Dart-codegen hygiene).
7
+ *
8
+ * Some DSL symbols are FRAMEWORK-owned + framework-SHARED: they are declared once in
9
+ * `dsl/src/framework/` and folded into MANY tenant domains. Three kinds collide in generated Dart:
10
+ *
11
+ * • AGGREGATES — `RelationTuple` (auto-added to EVERY relation-using domain), plus `SignerDelegation`,
12
+ * `KeyScope`, `WrappedKey`, `HomeOwner`, `WorkspaceSelf`, … re-exported verbatim so their events fold.
13
+ * Each emits a read-model class + typed-id class + by-id accessors → `RelationTupleReadModel`,
14
+ * `SignerDelegationId`, `watchSignerDelegationById`, … DEFINED per domain.
15
+ * • DIRECTIVES — the relate/signer/enroll family (`relateSubject`, `enrollDelegation`,
16
+ * `advanceSignerEpoch`, …) fold into many domains; each emits a payload class → `EnrollDelegationPayload`
17
+ * DEFINED per domain.
18
+ * • QUERIES — `signerDelegationByUser`, `relationTuplesByObject`, … emit top-level `watch<Q>`/`read<Q>`
19
+ * accessors → `readSignerDelegationByUser`, `watchSignerDelegationByUser` DEFINED per domain.
20
+ *
21
+ * A package barrel re-exporting ≥2 domains COLLIDES on every such symbol — the historical band-aid was a
22
+ * hand-maintained `hide RelationTuple, EnrollDelegationPayload, readSignerDelegationByUser, …` clause on
23
+ * the tenant barrel. The fix: BRAND the framework SINGLETONS here (once, at module load) so the codegen
24
+ * emits their Dart surface EXACTLY ONCE per package (into the shared `nomos_framework.dart`) and SKIPS
25
+ * re-emitting them in each domain. Identification is by OBJECT IDENTITY (the brand rides the branded
26
+ * singleton), so a tenant symbol that merely SHARES a name is NEVER misclassified.
27
+ *
28
+ * FRONTEND CODEGEN ONLY — the brand is never hashed and never enters the USD IR / read manifest / law
29
+ * (identical guarantee to `readModelAggregates`). Deterministic: the registry is derived from the actual
30
+ * framework exports, not a hand-kept string list.
31
+ */
32
+ import type { AggregateHandle } from "../aggregate.js";
33
+ import type { Directive } from "../directive.js";
34
+ import type { QueryDecl } from "../query.js";
35
+ import * as disclosure from "./disclosure.js";
36
+ import * as domainLifecycle from "./domain_lifecycle.js";
37
+ import * as encryptionIdentity from "./encryption_identity.js";
38
+ import * as encryptionScope from "./encryption_scope.js";
39
+ import * as genesis from "./genesis.js";
40
+ import * as home from "./home.js";
41
+ import * as identity from "./identity.js";
42
+ import * as relations from "./relations.js";
43
+ import * as repair from "./repair.js";
44
+ import * as signingIdentity from "./signing_identity.js";
45
+ import * as workspaces from "./workspaces.js";
46
+
47
+ // Every framework module that DECLARES aggregate / directive / query singletons. Namespace imports so
48
+ // the registry is derived from the real exports (no hand-kept id list to drift).
49
+ const FRAMEWORK_NAMESPACES: ReadonlyArray<Record<string, unknown>> = [
50
+ disclosure,
51
+ domainLifecycle,
52
+ encryptionIdentity,
53
+ encryptionScope,
54
+ genesis,
55
+ home,
56
+ identity,
57
+ relations,
58
+ repair,
59
+ signingIdentity,
60
+ workspaces,
61
+ ];
62
+
63
+ function isAggregateHandle(v: unknown): v is AggregateHandle {
64
+ return (
65
+ typeof v === "object" &&
66
+ v !== null &&
67
+ (v as { __isAggregateHandle?: unknown }).__isAggregateHandle === true
68
+ );
69
+ }
70
+
71
+ // A finished Directive duck-type ({id, plan, aggregateId}) — the SAME shape `directivesOf` discovers.
72
+ function isDirective(v: unknown): v is Directive {
73
+ return (
74
+ typeof v === "object" &&
75
+ v !== null &&
76
+ typeof (v as { id?: unknown }).id === "string" &&
77
+ typeof (v as { plan?: unknown }).plan === "function" &&
78
+ typeof (v as { aggregateId?: unknown }).aggregateId === "string"
79
+ );
80
+ }
81
+
82
+ // A finished QueryDecl duck-type ({id, key:[], returns:""}). Distinct from the un-keyed builder
83
+ // (`KeylessQuery.key` is a FUNCTION); here `key` is an array and `returns` a string.
84
+ function isQueryDecl(v: unknown): v is QueryDecl {
85
+ return (
86
+ typeof v === "object" &&
87
+ v !== null &&
88
+ typeof (v as { id?: unknown }).id === "string" &&
89
+ Array.isArray((v as { key?: unknown }).key) &&
90
+ typeof (v as { returns?: unknown }).returns === "string"
91
+ );
92
+ }
93
+
94
+ const aggregateIds = new Set<string>();
95
+ const directiveIds = new Set<string>();
96
+ const queryIds = new Set<string>();
97
+
98
+ // Brand every framework singleton at module load. Mutating the readonly `framework` slot on the
99
+ // singleton is intentional: the branded object IS the one that flows through composition
100
+ // (`composeDomainModule` keeps the discovered aggregate/directive/query object references; a tenant
101
+ // re-exports the framework singleton verbatim), so the brand rides object identity everywhere.
102
+ for (const ns of FRAMEWORK_NAMESPACES) {
103
+ for (const v of Object.values(ns)) {
104
+ if (isAggregateHandle(v)) {
105
+ (v as { framework?: true }).framework = true;
106
+ aggregateIds.add(v.id);
107
+ } else if (isDirective(v)) {
108
+ (v as { framework?: true }).framework = true;
109
+ directiveIds.add(v.id);
110
+ } else if (isQueryDecl(v)) {
111
+ (v as { framework?: true }).framework = true;
112
+ queryIds.add(v.id);
113
+ }
114
+ }
115
+ }
116
+
117
+ export const FRAMEWORK_AGGREGATE_IDS: ReadonlySet<string> = aggregateIds;
118
+ export const FRAMEWORK_DIRECTIVE_IDS: ReadonlySet<string> = directiveIds;
119
+ export const FRAMEWORK_QUERY_IDS: ReadonlySet<string> = queryIds;
120
+
121
+ /**
122
+ * TRUE iff this aggregate is a framework-shared singleton whose Dart surface is emitted ONCE per
123
+ * package (in `nomos_framework.dart`). By the BRAND (object identity) — a tenant aggregate that
124
+ * happens to share an id is NOT a framework aggregate.
125
+ */
126
+ export function isFrameworkAggregate(agg: Pick<AggregateHandle, "framework">): boolean {
127
+ return agg.framework === true;
128
+ }
129
+
130
+ /** TRUE iff this directive is a framework-shared singleton (its payload class lives in the shared file). */
131
+ export function isFrameworkDirective(d: Pick<Directive, "framework">): boolean {
132
+ return d.framework === true;
133
+ }
134
+
135
+ /** TRUE iff this query is a framework-shared singleton (its accessors live in the shared file). */
136
+ export function isFrameworkQuery(q: Pick<QueryDecl, "framework">): boolean {
137
+ return q.framework === true;
138
+ }
@@ -0,0 +1,285 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/platform_admin.ts — `seedPlatformAdmin`, THE PLATFORM ANALOG OF `seedHomeCreator`.
8
+ *
9
+ * WHY THIS EXISTS. A tenant platform (an org’s self-governing workspace) declares CUSTOM governance roles
10
+ * (`platformAdmin`, `platformMember`, …) as `relationType("workspace").relation("platformAdmin", …)` and gates its
11
+ * directives on them (`grantRole.requires("platformAdmin")`, `revokeRole.requires(
12
+ * "platformAdmin")`). But there is NO in-law bootstrap for the FOUNDING admin: `grantRole` is
13
+ * itself `platformAdmin`-gated (chicken/egg) and `revokeRole` REMOVES authority. So a platform's
14
+ * founding authority was only ever seated IMPLICITLY at platform birth (the `grantAdmin` step inside
15
+ * `buildGenesisChain`, which seats the FRAMEWORK `admin` relation — OUTSIDE the tenant's own domain), a
16
+ * seat a single-domain `githolon proof` can neither see nor establish.
17
+ *
18
+ * `seedPlatformAdmin` front-loads this into the framework, EXACTLY as `seedHomeCreator`
19
+ * (`framework/home.ts` shape) seats a home's `creator`: a first-class, PROVABLE founding-admin bootstrap
20
+ * that seats a TENANT-NAMED governance relation for a founder principal, at genesis, write-once, bound to
21
+ * the verified genesis author. Every platform then gets a consistent, proof-discoverable founding-admin
22
+ * seat tied to a real AuthProvider identity ("bring your own auth").
23
+ *
24
+ * ── BLAST RADIUS: IMPORTABLE, npm-only ──────────────────────────────────────────────────────────
25
+ * This is a TENANT-IMPORTABLE primitive — a directive/aggregate/invariant factory a tenant imports into
26
+ * THEIR OWN platform-governance domain and deploys via THEIR normal `evolve`. It seats a relation on
27
+ * `workspace:self` in the TENANT'S domain; it does NOT touch the running root/platform governance bundle
28
+ * (`framework/workspaces.ts`). This mirrors `home.ts`: `seedHomeOwner`/`birthHome`/`homeNameBindsOwner`
29
+ * are exported framework primitives a tenant composes into a SEPARATE, per-home deployable package — they
30
+ * are NOT baked into root's live governance. Adopting `seedPlatformAdmin` is a tenant recompile + evolve
31
+ * of their platform domain; no governance re-deploy, no re-birth.
32
+ *
33
+ * ── WHAT IT SEATS (design choice (b) — a tenant-NAMED relation, directly) ────────────────────────
34
+ * We seat the tenant's OWN relation (`platformAdmin`) directly, not the framework `admin` relation. Rationale:
35
+ * • Tenant-owned governance — the platform's roles are the platform's law; the founding seat should be a
36
+ * fact IN the tenant's domain, provable from that domain alone (the `githolon proof` subject).
37
+ * • Self-contained per role, no rewrite plumbing required. (For a platform whose roles should ALL derive
38
+ * from one admin, the tenant may instead seat ONE relation here and link the rest by rewrite —
39
+ * `relation("platformMember", { rewrite: union(self(), computed("platformAdmin")) })` — design choice (a). We
40
+ * support that cleanly too: seat once, rewrite the rest. Nothing here forbids it.)
41
+ * • The relation is CLOSED OVER by the factory (LAW), never payload-carried — a payload-carried relation
42
+ * would let a caller seat ANY relation (a forge). The founder subject is payload-carried but bound.
43
+ *
44
+ * ── HOW A TENANT ADOPTS (a platform-governance domain — before → after) ───────────────────────────────────────────
45
+ * BEFORE (the gap): a `platform-governance` domain with `platformAdmin`/`platformMember` relations, `grantRole
46
+ * .requires("platformAdmin")`, `revokeRole.requires("platformAdmin")` — but no in-law way to seat the
47
+ * first `platformAdmin`, so `githolon proof` can't establish authority for the gated directives.
48
+ * AFTER (one import + include, then evolve):
49
+ *
50
+ * import { seedPlatformAdmin } from "@githolon/dsl";
51
+ * export const founding = seedPlatformAdmin({ relation: "platformAdmin" });
52
+ * export const PlatformFounder = founding.PlatformFounder; // the founder singleton
53
+ * export const seedPlatformFounder = founding.seedPlatformFounder; // stamps the founder (genesis)
54
+ * export const seatFoundingAdmin = founding.seedPlatformAdmin; // seats platformAdmin@founder (genesis)
55
+ * export const platformFounderWriteOnce = founding.platformFounderWriteOnce;
56
+ * export const platformAdminBindsFounder = founding.platformAdminBindsFounder;
57
+ * export const platformFounder = founding.platformFounder; // audit query
58
+ * // the platform's genesis chain runs, after the law installs:
59
+ * // [ … installDomain×2, seedGenesis, seedPlatformFounder(founder), seatFoundingAdmin(founder) ]
60
+ *
61
+ * The tenant DEPLOYS this via `evolve` (npm-only recompile + their normal deploy); no root/platform
62
+ * governance re-deploy, no re-birth. A NEW platform seeds it in its birth genesis; an EXISTING platform
63
+ * that was already born with an implicit `admin` seat evolves to ADD the tenant-relation seat (a
64
+ * one-time `seedPlatformFounder`+`seatFoundingAdmin` authored by the existing admin — see the note on the
65
+ * write-once posture below for the existing-platform lane).
66
+ *
67
+ * ── SECURITY POSTURE (mirror `seedHomeCreator`: genesis-only, ungated-but-safe) ──────────────────
68
+ * The seeds carry NO `.requires` (gating the founding seat on the relation it establishes is the very
69
+ * chicken/egg this primitive dissolves). They are safe because they are CONTROL-PLANE SEEDS:
70
+ * 1. WRITE-ONCE — `platformFounderWriteOnce` refuses `seedPlatformFounder` if a founder is ALREADY
71
+ * seated (a `pre:`-apply read of the singleton). So the founder can be stamped exactly once; a
72
+ * replayed/injected re-seat on the open session lane is refused.
73
+ * 2. FOUNDER == VERIFIED GENESIS AUTHOR — `platformFounderWriteOnce` also binds the recorded founder to
74
+ * `ctx.principal` (the captured, peer-identical author). The upstream VERIFICATION is the birth
75
+ * offer's principal (the parent's gate verified an AuthProvider identity before the offer-effect ran
76
+ * the genesis actor as that founder) — the SAME "bring your own auth" boundary `birthHome` relies on.
77
+ * (We do NOT REQUIRE `ctx.principalVerified` inside the child gate: genesis runs on a warrant-free
78
+ * prefix — the warrant lands LAST, `home.ts` "BREAK 1" — so `principalVerified` is not yet true at
79
+ * the seed step; requiring it would make every platform un-birthable. The verified-founder guarantee
80
+ * is therefore the offer principal, asserted upstream, not re-asserted here. See the report.)
81
+ * 3. THE SEAT CAN ONLY GRANT THE FOUNDER — `platformAdminBindsFounder` refuses `seedPlatformAdmin`
82
+ * unless its tuple subject == the recorded founder. So even an ungated seed can seat NO ONE but the
83
+ * write-once founder. The tuple is a positive `writeTuple` grant (never a revoke).
84
+ * Together: the ONLY principal that can ever hold the seeded relation via this primitive is the write-once
85
+ * founder bound to the verified genesis author. This is `seedHomeCreator`'s posture, at platform grain.
86
+ *
87
+ * ── PROVABLE ────────────────────────────────────────────────────────────────────────────────────
88
+ * `seedPlatformAdmin` (the tuple-seating directive) is marked `.grants(relation)` — the producer marker
89
+ * (`codegen_proof.ts`) so the proof's bootstrap discovery finds the granter UNAMBIGUOUSLY (no inference,
90
+ * no guessing) and proves the platform's `.requires(relation)`-gated directives (`grantRole`/`revokeRole`)
91
+ * from it — the born-context / relation-bootstrap proof leg the layer-4 discovery understands.
92
+ *
93
+ * ── ERA-SAFE ────────────────────────────────────────────────────────────────────────────────────
94
+ * A factory: a package that never CALLS `seedPlatformAdmin({…})` emits none of these directives /
95
+ * aggregates / invariants and is byte-identical to before this file existed.
96
+ */
97
+ import { z } from "zod";
98
+ import { aggregate, instance, type AggregateHandle } from "../aggregate.js";
99
+ import { t } from "../fields.js";
100
+ import { Lww } from "../drivers.js";
101
+ import { directive, type RequirableDirective } from "../directive.js";
102
+ import { set } from "../ops.js";
103
+ import { query, type QueryDecl } from "../query.js";
104
+ import { writeTuple, RelationTuple } from "./relations.js";
105
+ import { workspaceInvariant, refAs, type WorkspaceInvariantDecl } from "./workspace_invariant.js";
106
+
107
+ /** The founder singleton's stable instance id — one founder per platform workspace (`workspace:self`). */
108
+ export const PLATFORM_FOUNDER_ID = "platform-founder:self";
109
+
110
+ /** The object every seat is written against — the self-relative workspace scope (`workspace:self#R`),
111
+ * the SAME self-relative posture the framework `admin`/`creator` relations use (custody guarantees a
112
+ * workspace only ever folds its OWN relation tuples, so the seat is name-plumbing-free). */
113
+ const SELF_WORKSPACE_OBJECT = "workspace:self";
114
+
115
+ /** A tuple SUBJECT: `user:<uid>` (an AuthProvider identity) or `key:<keyHash>` (a control-plane key —
116
+ * e.g. a platform's `sha256(platformAdminKey)`). Mirrors the relation-tuple subject vocabulary; a raw
117
+ * uid carrying `:` / a bare `*` is refused so it can never forge a wildcard/userset. */
118
+ const FOUNDER_SUBJECT_RE = /^(user|key):[A-Za-z0-9][A-Za-z0-9_.:/-]*$/;
119
+ const founderSubject = z.string().regex(FOUNDER_SUBJECT_RE, "founder must be user:<uid> or key:<keyHash>");
120
+
121
+ /** A relation name (the seated edge label) — a bare identifier, like a directive/cap name. */
122
+ const RELATION_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
123
+
124
+ /** The bundle a `seedPlatformAdmin({ relation })` call yields — the aggregate, the two genesis seeds, the
125
+ * two binding invariants, and the audit query. Spread the members into a platform-governance domain
126
+ * module (see the adoption sketch at the top of this file). */
127
+ export interface PlatformAdminSeed {
128
+ /** The founder singleton aggregate — records the platform's founding principal (write-once at genesis). */
129
+ readonly PlatformFounder: AggregateHandle;
130
+ /** GENESIS SEED 1 — stamps the founder singleton (write-once, bound to the verified genesis author). */
131
+ readonly seedPlatformFounder: RequirableDirective<{ founder: string; seededAt: string }>;
132
+ /** GENESIS SEED 2 — seats `workspace:self#<relation>@<founder>` (the founding-admin tuple). `.grants(R)`. */
133
+ readonly seedPlatformAdmin: RequirableDirective<{ founder: string; seededAt: string }>;
134
+ /** The write-once + author-binding invariant on {@link seedPlatformFounder}. */
135
+ readonly platformFounderWriteOnce: WorkspaceInvariantDecl;
136
+ /** The subject-binding invariant on {@link seedPlatformAdmin} (the seat can grant NO ONE but the founder). */
137
+ readonly platformAdminBindsFounder: WorkspaceInvariantDecl;
138
+ /** Audit read: "who is this platform's founder?" — the recorded founder + the relation seated. */
139
+ readonly platformFounder: QueryDecl;
140
+ /** The relation this bundle seats (echoed back for the tenant's convenience). */
141
+ readonly relation: string;
142
+ }
143
+
144
+ /** Optional id overrides — a platform seating MORE THAN ONE founding relation from the same founder passes
145
+ * distinct ids per call so the directives/invariants/aggregate don't collide. Defaults suit the common
146
+ * single-founding-relation case. */
147
+ export interface SeedPlatformAdminOptions {
148
+ /** The tenant governance relation to seat for the founder (`"platformAdmin"`). Required. */
149
+ readonly relation: string;
150
+ /** The founder singleton aggregate id (default `"PlatformFounder"`). Share it across multi-relation
151
+ * seats so one founder identity is recorded once and seated into several relations. */
152
+ readonly founderAggregateId?: string;
153
+ /** The founder-stamp directive id (default `"seedPlatformFounder"`). */
154
+ readonly founderDirectiveId?: string;
155
+ /** The tuple-seat directive id (default `"seedPlatformAdmin"`). */
156
+ readonly seatDirectiveId?: string;
157
+ /** The write-once invariant id (default `"PlatformFounderWriteOnce"`). */
158
+ readonly writeOnceInvariantId?: string;
159
+ /** The subject-binding invariant id (default `"PlatformAdminBindsFounder"`). */
160
+ readonly bindsInvariantId?: string;
161
+ /** The audit-query id (default `"platformFounder"`). */
162
+ readonly queryId?: string;
163
+ }
164
+
165
+ /**
166
+ * `seedPlatformAdmin({ relation })` — build the founding-admin bootstrap bundle for a tenant platform.
167
+ * See the file header for the full doctrine, security posture, and adoption sketch.
168
+ */
169
+ export function seedPlatformAdmin(opts: SeedPlatformAdminOptions): PlatformAdminSeed {
170
+ const relation = opts.relation;
171
+ if (typeof relation !== "string" || !RELATION_RE.test(relation)) {
172
+ throw new Error(
173
+ `seedPlatformAdmin({ relation }): relation must be a bare relation name (matched ${RELATION_RE}); got ${JSON.stringify(relation)}`,
174
+ );
175
+ }
176
+ const founderAggregateId = opts.founderAggregateId ?? "PlatformFounder";
177
+ const founderDirectiveId = opts.founderDirectiveId ?? "seedPlatformFounder";
178
+ const seatDirectiveId = opts.seatDirectiveId ?? "seedPlatformAdmin";
179
+ const writeOnceInvariantId = opts.writeOnceInvariantId ?? "PlatformFounderWriteOnce";
180
+ const bindsInvariantId = opts.bindsInvariantId ?? "PlatformAdminBindsFounder";
181
+ const queryId = opts.queryId ?? "platformFounder";
182
+
183
+ /** The founder singleton — a platform's founding principal + the relation it was seated into (audit). */
184
+ const PlatformFounder = aggregate(founderAggregateId, {
185
+ /** The founding principal — a `user:<uid>` / `key:<keyHash>` subject; write-once at genesis. */
186
+ principal: t.string().merge(Lww),
187
+ /** The governance relation seated for the founder (echo of the factory's `relation`). */
188
+ relation: t.string().merge(Lww),
189
+ /** ISO-8601, caller-stamped (determinism — no clock in the plan). */
190
+ seededAt: t.string().merge(Lww),
191
+ });
192
+
193
+ /**
194
+ * GENESIS SEED 1 — stamp the founder singleton at the fixed id. Authored by the platform's birth/genesis
195
+ * chain; on the open session lane it is refused by {@link platformFounderWriteOnce} (a genesis founder is
196
+ * already seated by then) + the author binding. `.ensures` (a deterministic-keyed singleton).
197
+ */
198
+ const seedPlatformFounder = directive(founderDirectiveId)
199
+ .ensures(PlatformFounder)
200
+ .payload(z.object({ founder: founderSubject, seededAt: z.string().min(1) }))
201
+ .plan((p) => {
202
+ const h = instance(PlatformFounder, PLATFORM_FOUNDER_ID);
203
+ return [set(h, "principal", p.founder), set(h, "relation", relation), set(h, "seededAt", p.seededAt)];
204
+ });
205
+
206
+ /**
207
+ * WRITE-ONCE + AUTHOR-BINDING invariant on {@link seedPlatformFounder}. Reads the PRE-apply singleton
208
+ * (the `pre:` id lane — see `workspaces.ts::retireOwnOnly`): a founder may be stamped exactly ONCE, and
209
+ * only for the VERIFIED genesis author (`ctx.principal` — the captured, peer-identical author identity;
210
+ * the upstream verification is the birth offer's principal, see the file header). Runs at the ONE gate
211
+ * on every lane (author / edge admission / verify_chain).
212
+ */
213
+ const platformFounderWriteOnce = workspaceInvariant(writeOnceInvariantId)
214
+ .on(founderDirectiveId)
215
+ .reads(() => [refAs("prior", founderAggregateId, "pre:" + PLATFORM_FOUNDER_ID)])
216
+ .assert(({ prior }, ctx) => {
217
+ const seated = (prior ?? {}) as { principal?: string };
218
+ // WRITE-ONCE: a founder already recorded ⇒ refuse a re-seat (the seat is immutable after genesis).
219
+ if (seated.principal) {
220
+ return { reject: "platform-founder-write-once: a founding admin is already seated for this platform" };
221
+ }
222
+ // AUTHOR BINDING: the recorded founder MUST be the captured, verified author of this seed. So a seed
223
+ // can only stamp ITS OWN author as the founder — never a third party.
224
+ const author = ctx?.principal ?? "";
225
+ const intent = (ctx?.intent ?? {}) as { founder?: string };
226
+ return author && intent.founder === author
227
+ ? { accept: true }
228
+ : { reject: "platform-founder-must-bind-verified-author: the seeded founder must equal the seeding principal" };
229
+ });
230
+
231
+ /**
232
+ * GENESIS SEED 2 — seat the founding tuple `workspace:self#<relation>@<founder>` (a POSITIVE grant, never
233
+ * a revoke). Marked `.grants(relation)` so the proof's bootstrap discovery finds it unambiguously. Gated
234
+ * by {@link platformAdminBindsFounder} — the tuple subject can be NO ONE but the recorded founder.
235
+ */
236
+ const seedPlatformAdminDirective = directive(seatDirectiveId)
237
+ // `.ensures(RelationTuple)` — the deterministic-keyed (object, relation) tuple aggregate is admitted by
238
+ // the id-mint gate (upsert-by-deterministic-key); the tenant includes `RelationTuple` in its aggregates.
239
+ .ensures(RelationTuple)
240
+ .payload(z.object({ founder: founderSubject, seededAt: z.string().min(1) }))
241
+ .plan((p) =>
242
+ writeTuple({
243
+ object: SELF_WORKSPACE_OBJECT,
244
+ relation,
245
+ subject: p.founder,
246
+ grantedBy: p.founder,
247
+ grantedAt: p.seededAt,
248
+ }),
249
+ )
250
+ .grants(relation);
251
+
252
+ /**
253
+ * SUBJECT-BINDING invariant on {@link seedPlatformAdminDirective}. Reads the founder singleton (stamped
254
+ * by seed 1 earlier in the genesis chain) and refuses unless the tuple's subject == the recorded founder.
255
+ * So the founding relation can be seated for the write-once founder and NO ONE ELSE — even though the
256
+ * seed itself carries no `.requires`.
257
+ */
258
+ const platformAdminBindsFounder = workspaceInvariant(bindsInvariantId)
259
+ .on(seatDirectiveId)
260
+ .reads(() => [refAs("founder", founderAggregateId, PLATFORM_FOUNDER_ID)])
261
+ .assert(({ founder }, ctx) => {
262
+ const rec = (founder ?? {}) as { principal?: string };
263
+ const intent = (ctx?.intent ?? {}) as { founder?: string };
264
+ // The seat's subject MUST be the recorded (write-once, author-bound) founder.
265
+ if (!rec.principal) {
266
+ return { reject: "platform-admin-seat-needs-founder: seed the platform founder before seating its relation" };
267
+ }
268
+ return rec.principal === intent.founder
269
+ ? { accept: true }
270
+ : { reject: "platform-admin-binds-founder: the seated relation may be granted only to the recorded founder" };
271
+ });
272
+
273
+ /** Audit read — "who is this platform's founder + which relation was seated". */
274
+ const platformFounder = query(queryId).key("principal").returns(PlatformFounder);
275
+
276
+ return {
277
+ PlatformFounder,
278
+ seedPlatformFounder,
279
+ seedPlatformAdmin: seedPlatformAdminDirective,
280
+ platformFounderWriteOnce,
281
+ platformAdminBindsFounder,
282
+ platformFounder,
283
+ relation,
284
+ };
285
+ }
package/src/index.ts CHANGED
@@ -312,6 +312,14 @@ export {
312
312
  birthAttestation,
313
313
  parentAttestedBirth,
314
314
  } from "./framework/home.js";
315
+ // THE PLATFORM FOUNDING-ADMIN BOOTSTRAP (the platform analog of seedHomeCreator) — a tenant-importable
316
+ // factory that seats a tenant-named governance relation for a write-once, author-bound founder at genesis.
317
+ export {
318
+ seedPlatformAdmin,
319
+ PLATFORM_FOUNDER_ID,
320
+ type PlatformAdminSeed,
321
+ type SeedPlatformAdminOptions,
322
+ } from "./framework/platform_admin.js";
315
323
  // THE GENESIS WARRANT (identity_keys.md step 0) — the in-history birth fact that lets
316
324
  // the gate's genesis clause admit a founding control-plane grant on a virgin chain.
317
325
  export {
package/src/query.ts CHANGED
@@ -67,6 +67,16 @@ export interface QueryDecl {
67
67
  readonly key: string[];
68
68
  /** The aggregate TYPE id the query returns, e.g. `SampleThingAggregate`. */
69
69
  readonly returns: string;
70
+ /**
71
+ * FRAMEWORK-SHARED brand (Dart-codegen hygiene). Set (by `framework/framework_aggregates.ts`) ONLY on
72
+ * the framework-owned query singletons declared in `dsl/src/framework/` (`signerDelegationByUser`,
73
+ * `relationTuplesByObject`, …) which fold into MANY domains. A branded query's generated top-level
74
+ * `watch<Q>`/`read<Q>` accessors are emitted ONCE per package into the shared `nomos_framework.dart`,
75
+ * NOT re-emitted in each domain's `.dart` — so a package barrel re-exporting several domains never gets
76
+ * duplicate accessors (no hand-maintained `hide` clause). NOT hashed / NOT law: a pure frontend-codegen
77
+ * marker (never enters the read manifest / USD IR). Identified by OBJECT IDENTITY, so a tenant query
78
+ * that merely shares a name is never misclassified. */
79
+ readonly framework?: true;
70
80
  }
71
81
 
72
82
  /**