@githolon/dsl 0.27.1 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -737,22 +737,30 @@ function typedIdClassForAggregateType(aggregateType: string): string {
737
737
  function collectEnums(
738
738
  aggregates: AggregateHandle[],
739
739
  directives: AnyDirective[],
740
+ domainKey: string,
740
741
  ): { dartTypes: Map<string, string>; decls: string[] } {
741
742
  const dartTypes = new Map<string, string>(); // "a|b|c" -> DartEnumName
742
743
  const usedNames = new Set<string>(); // every emitted enum name (collision guard)
743
744
  const decls: string[] = [];
745
+ // Generated enum types are DOMAIN-QUALIFIED (`EstateStatusEnum`,
746
+ // `Co2PlatformStatusEnum`) so two domains re-exported through one Dart barrel
747
+ // never collide on a bare `StatusEnum` — the collision that otherwise forces a
748
+ // hand-maintained `hide` list on the package barrel. The prefix is the field's
749
+ // semantic home, not an accident of import order.
750
+ const domainPrefix = dartTypeNameFromField(domainKey);
744
751
  const register = (preferredName: string, values: readonly string[]) => {
745
752
  const key = values.join("|");
746
753
  if (dartTypes.has(key)) return;
747
- // Two DIFFERENT value-sets can prefer the SAME name (e.g. several `status`
748
- // fields with distinct literal lists all want `StatusEnum`). Suffix on collision
749
- // so each distinct value-set gets a UNIQUE Dart enum (`StatusEnum`, `Status2Enum`).
750
- const base = `${dartTypeNameFromField(preferredName)}Enum`;
754
+ // Two DIFFERENT value-sets WITHIN one domain can prefer the SAME name (e.g.
755
+ // several `status` fields with distinct literal lists all want `StatusEnum`).
756
+ // Suffix on collision so each distinct value-set gets a UNIQUE Dart enum
757
+ // (`EstateStatusEnum`, `EstateStatus2Enum`).
758
+ const base = `${domainPrefix}${dartTypeNameFromField(preferredName)}Enum`;
751
759
  let name = base;
752
760
  let n = 1;
753
761
  while (usedNames.has(name)) {
754
762
  n += 1;
755
- name = `${dartTypeNameFromField(preferredName)}${n}Enum`;
763
+ name = `${domainPrefix}${dartTypeNameFromField(preferredName)}${n}Enum`;
756
764
  }
757
765
  usedNames.add(name);
758
766
  dartTypes.set(key, name);
@@ -2560,7 +2568,11 @@ export interface DomainModule {
2560
2568
  /** Generate the full Dart source for one domain module. */
2561
2569
  export function generateDartDomain(mod: DomainModule): string {
2562
2570
  const domainKey = mod.domain ?? mod.name;
2563
- const { dartTypes, decls } = collectEnums(mod.aggregates, mod.directives);
2571
+ const { dartTypes, decls } = collectEnums(
2572
+ mod.aggregates,
2573
+ mod.directives,
2574
+ domainKey,
2575
+ );
2564
2576
  const refIdClassForAggregate = refIdClassResolver(mod);
2565
2577
  const aggregatesById = new Map<string, AggregateHandle>();
2566
2578
  for (const a of mod.aggregates) aggregatesById.set(a.id, a);
@@ -40,6 +40,7 @@ import type { Field } from "./fields.js";
40
40
  import type { Directive } from "./directive.js";
41
41
  import type { DomainModule } from "./codegen_dart.js";
42
42
  import type { QueryDecl } from "./query.js";
43
+ import type { FieldOp } from "./ops.js";
43
44
  import { finishCount } from "./count.js";
44
45
  import { mintedCreateField } from "./workspace_routing.js";
45
46
  import { deterministicPorts } from "./ctx.js";
@@ -336,6 +337,240 @@ function authorityConformanceLegLines(
336
337
  ];
337
338
  }
338
339
 
340
+ /**
341
+ * THE RELATION-BOOTSTRAP LEG (the seed-then-prove POSITIVE — complements the negative conformance leg). On the
342
+ * LOCAL holon with fetch trapped: (1) dispatch the GRANT directive under the proof actor (its subject fields
343
+ * overwritten to the actor so the relation seats ON it), then (2) dispatch the GATED directive under the SAME
344
+ * actor and assert it SUCCEEDS — proving that once relation R is granted, an actor holding R authors the gated
345
+ * write (the exact loop the negative leg can't cover). Emits nothing when no granter is discoverable.
346
+ */
347
+ function relationSeedLegLines(
348
+ leg: RelationBootstrapLeg | undefined,
349
+ factory: string,
350
+ n: () => number,
351
+ ): string[] {
352
+ if (leg === undefined) return [];
353
+ const ACTOR = `"user:proof-conformance"`;
354
+ // Grant payload: overwrite the routed subject/grantedBy fields (and any name-shaped fallback) with the actor.
355
+ const grantPayloadLit = (() => {
356
+ const overwrite = new Set<string>([...leg.grantSubjectFields, ...leg.grantGrantedByFields]);
357
+ const entries = Object.entries(leg.grantPayload).map(([k, v]) => {
358
+ // Also catch conventional subject/grantedBy field names the plan-probe couldn't value-match.
359
+ const looksSubject = /^(subject|principal|grantedBy|grantee|member|user)$/i.test(k);
360
+ return overwrite.has(k) || looksSubject
361
+ ? `${JSON.stringify(k)}: ${ACTOR}`
362
+ : `${JSON.stringify(k)}: ${JSON.stringify(v)}`;
363
+ });
364
+ return `{ ${entries.join(", ")} }`;
365
+ })();
366
+ const gatedPayloadLit = leg.gatedMintField !== undefined && leg.gatedAggregateId !== undefined
367
+ ? `{ ...${JSON.stringify(leg.gatedPayload)}, ${JSON.stringify(leg.gatedMintField)}: await holon.mint(${JSON.stringify(leg.gatedAggregateId)}) }`
368
+ : JSON.stringify(leg.gatedPayload);
369
+ return [
370
+ `// ${n()}. RELATION BOOTSTRAP (seed-then-prove the POSITIVE): \`${leg.domain}/${leg.gatedDirectiveId}\` is GATED`,
371
+ `// (\`.requires("${leg.relation}")\`). \`${leg.domain}/${leg.grantDirectiveId}\` GRANTS "${leg.relation}" (${leg.via}).`,
372
+ `// Grant it to the proof actor, THEN prove the gated write SUCCEEDS under that actor (what the negative`,
373
+ `// conformance leg above can't cover — a seeded holder authoring a gated write).`,
374
+ `{`,
375
+ ` const realFetch3 = globalThis.fetch;`,
376
+ ` globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: relation-bootstrap touched the network"); }) as typeof fetch;`,
377
+ ` try {`,
378
+ ` const ACTOR = ${ACTOR};`,
379
+ ` // (1) grant "${leg.relation}" to ACTOR (the grant folds a writeTuple; subject seated on ACTOR).`,
380
+ ` await ${factory}(holon, { actor: ACTOR }).${leg.grantDirectiveId}(${grantPayloadLit});`,
381
+ ` ok(\`relation bootstrap (grant) — ${leg.domain}/${leg.grantDirectiveId} granted "${leg.relation}" to \${ACTOR}\`);`,
382
+ ` // (2) the GATED write now SUCCEEDS under the seeded actor (holds "${leg.relation}").`,
383
+ ` await ${factory}(holon, { actor: ACTOR }).${leg.gatedDirectiveId}(${gatedPayloadLit});`,
384
+ ` ok(\`relation bootstrap (prove) — ${leg.domain}/${leg.gatedDirectiveId} authored under the seeded holder of "${leg.relation}" (gated write SUCCEEDS once granted)\`);`,
385
+ ` } finally {`,
386
+ ` globalThis.fetch = realFetch3;`,
387
+ ` }`,
388
+ `}`,
389
+ ``,
390
+ ];
391
+ }
392
+
393
+ // ── relation-bootstrap discovery (seed the gate: grant relation R, then prove a .requires(R) write SUCCEEDS) ──
394
+ //
395
+ // The NEGATIVE conformance leg above proves an ACTORLESS gated offer can't be generated. This POSITIVE leg
396
+ // proves the other half: with the relation GRANTED, an actor that holds it authors the gated write SUCCESS-
397
+ // fully. It needs a sibling directive that ESTABLISHES the relation (folds a granting `writeTuple`). We find
398
+ // it two ways: FIRST a `.grants(R)` producer marker (the static, unambiguous lane — symmetric to `.requires`);
399
+ // ELSE plan-probe each sibling for a `writeTuple`/relation-tuple op whose `relation === R` (inference).
400
+
401
+ /** A discovered RELATION-BOOTSTRAP leg: a gated (`.requires(R)`) directive plus the sibling directive that
402
+ * GRANTS R. The proof dispatches the grant FIRST (its subject/grantedBy set to the seeded proof actor), then
403
+ * dispatches the gated directive under that actor and asserts it SUCCEEDS — the seed-then-prove positive. */
404
+ export interface RelationBootstrapLeg {
405
+ /** The gated directive's domain. */
406
+ readonly domain: string;
407
+ /** The gated directive id (`.requires(relation)`). */
408
+ readonly gatedDirectiveId: string;
409
+ /** The relation the gate requires (and the grant establishes). */
410
+ readonly relation: string;
411
+ /** The synthesized gated-directive payload (auto-stamped `…At` omitted; mintField removed). */
412
+ readonly gatedPayload: Record<string, unknown>;
413
+ readonly gatedAutoStamped: readonly string[];
414
+ /** When the gated directive's own id is kernel-minted, the field to mint at runtime. */
415
+ readonly gatedMintField?: string;
416
+ /** The gated create's aggregate id (the mint's type tag) — present when `gatedMintField` is. */
417
+ readonly gatedAggregateId?: string;
418
+ /** The granting directive id (the sibling that folds the granting `writeTuple`). */
419
+ readonly grantDirectiveId: string;
420
+ /** The synthesized grant payload (auto-stamped `…At` omitted). The SUBJECT fields (below) are overwritten
421
+ * at runtime with the seeded proof actor so the grant seats authority ON that actor. */
422
+ readonly grantPayload: Record<string, unknown>;
423
+ readonly grantAutoStamped: readonly string[];
424
+ /** The grant-payload field(s) whose value the plan routed into the tuple SUBJECT — set to the proof actor
425
+ * at dispatch so the granted relation lands on the actor the gated write runs as. */
426
+ readonly grantSubjectFields: readonly string[];
427
+ /** Grant-payload field(s) routed into the tuple `grantedBy` — set to the proof actor (free attribution). */
428
+ readonly grantGrantedByFields: readonly string[];
429
+ /** How the granter was resolved — `"marker"` (`.grants(R)`) or `"inferred"` (plan-probe). Cosmetic. */
430
+ readonly via: "marker" | "inferred";
431
+ }
432
+
433
+ /** Run a directive's plan with a synthesized+probe payload under birth-probe globals and return the drained
434
+ * field ops (authoring sink + returned ops). `undefined` ⇒ the plan couldn't be probed. */
435
+ function probePlanOps(
436
+ dir: Directive<unknown>,
437
+ where: string,
438
+ ): { ops: FieldOp[]; synth: SynthesizedPayload; probePayload: Record<string, unknown> } | undefined {
439
+ let synth: SynthesizedPayload;
440
+ try {
441
+ synth = synthesizePayload(dir.payloadSchema as z.ZodTypeAny, where);
442
+ } catch {
443
+ return undefined;
444
+ }
445
+ const probePayload: Record<string, unknown> = { ...synth.value };
446
+ for (const f of synth.autoStamped) probePayload[f] = "2026-01-01T00:00:00Z";
447
+ let ops: FieldOp[] = [];
448
+ try {
449
+ withBirthProbeGlobals(() => {
450
+ __resetAuthoring();
451
+ __resetReads();
452
+ __resetBirths();
453
+ try {
454
+ const returned = dir.plan(probePayload as never, deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }));
455
+ const drained = [...__drainAuthoring(), ...(Array.isArray(returned) ? returned : [])];
456
+ ops = drained.filter((o): o is FieldOp => (o as { kind?: string }).kind !== "strike");
457
+ } finally {
458
+ try { __drainReads(); __drainBirths(); } catch { /* drained */ }
459
+ }
460
+ });
461
+ } catch {
462
+ return undefined;
463
+ }
464
+ return { ops, synth, probePayload };
465
+ }
466
+
467
+ /** Does this directive's plan GRANT `relation` — i.e. fold a relation-tuple write whose `relation` field is
468
+ * set to R (the `writeTuple`/`relateSubject` shape: a `set(rt, "relation", R)` op on a RelationTuple)? */
469
+ function probeGrantsRelation(dir: Directive<unknown>, relation: string, where: string): boolean {
470
+ const probed = probePlanOps(dir, where);
471
+ if (probed === undefined) return false;
472
+ return probed.ops.some((o) => o.kind === "set" && o.field === "relation" && o.value === relation);
473
+ }
474
+
475
+ /** Resolve WHICH grant-payload fields route into the tuple subject / grantedBy — by matching the op values
476
+ * the plan emitted (the subject `setEntry`'s entryKey, the `grantedBy` leaf) back to synthesized payload
477
+ * values. Best-effort: an unmatched routing just leaves the field list empty (runtime falls back). */
478
+ function resolveGrantSubjectFields(
479
+ probed: { ops: FieldOp[]; synth: SynthesizedPayload },
480
+ ): { subjectFields: string[]; grantedByFields: string[] } {
481
+ const { ops, synth } = probed;
482
+ const fieldForValue = (v: unknown): string | undefined =>
483
+ typeof v === "string" && v.length > 0
484
+ ? Object.entries(synth.value).find(([, sv]) => sv === v)?.[0]
485
+ : undefined;
486
+ const subjectFields = new Set<string>();
487
+ const grantedByFields = new Set<string>();
488
+ for (const o of ops) {
489
+ // The subject rides the tuple map entry KEY (`setEntry(rt, "subjects", <subjectRaw>, leaf)`).
490
+ if (o.kind === "setEntry" && o.field === "subjects" && typeof o.entryKey === "string") {
491
+ const f = fieldForValue(o.entryKey);
492
+ if (f !== undefined) subjectFields.add(f);
493
+ // grantedBy rides the JSON leaf value — scan the serialized leaf for any synthesized payload value.
494
+ if (typeof o.value === "string") {
495
+ for (const [name, sv] of Object.entries(synth.value)) {
496
+ if (typeof sv === "string" && sv.length > 0 && name !== fieldForValue(o.entryKey) && o.value.includes(sv)) {
497
+ grantedByFields.add(name);
498
+ }
499
+ }
500
+ }
501
+ }
502
+ }
503
+ return { subjectFields: [...subjectFields], grantedByFields: [...grantedByFields] };
504
+ }
505
+
506
+ /** Discover the RELATION-BOOTSTRAP leg for a module: the first gated (`.requires(R)`) directive plus the
507
+ * sibling that ESTABLISHES R (prefer a `.grants(R)` marker; else plan-probe for a granting `writeTuple`).
508
+ * `undefined` ⇒ no gated directive, or no discoverable granter (the negative conformance leg still runs). */
509
+ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstrapLeg | undefined {
510
+ const domain = mod.domain ?? mod.name;
511
+ const directives = mod.directives as Directive<unknown>[];
512
+ const gated = directives.find(
513
+ (d) =>
514
+ d?.requiresRelationGate !== undefined &&
515
+ typeof d.payloadSchema === "object" &&
516
+ d.birthsChild !== true &&
517
+ d.attestationAuthorizedGate === undefined,
518
+ );
519
+ if (gated === undefined) return undefined;
520
+ const relation = gated.requiresRelationGate!.relation;
521
+
522
+ // Synthesize the gated payload (mirror of the conformance leg — mintField removed, auto-stamped omitted).
523
+ let gatedSynth: SynthesizedPayload;
524
+ try {
525
+ gatedSynth = synthesizePayload(gated.payloadSchema as z.ZodTypeAny, `${domain}/${gated.id}`);
526
+ } catch {
527
+ return undefined;
528
+ }
529
+ const gatedAgg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === gated.aggregateId);
530
+ const gatedMintField = gatedAgg !== undefined ? mintedCreateField(gated, gatedAgg) : undefined;
531
+ const gatedPayload: Record<string, unknown> = { ...gatedSynth.value };
532
+ if (gatedMintField !== undefined) delete gatedPayload[gatedMintField];
533
+
534
+ // Find the granter. FIRST prefer a `.grants(R)` marker (static, unambiguous). ELSE plan-probe siblings for
535
+ // a granting `writeTuple` whose relation === R. Never the gated directive itself.
536
+ const candidates = directives.filter((d) => d !== gated && typeof d?.payloadSchema === "object" && d.birthsChild !== true);
537
+ let via: "marker" | "inferred" = "marker";
538
+ let granter = candidates.find((d) => (d.grantsRelation ?? []).includes(relation));
539
+ if (granter === undefined) {
540
+ via = "inferred";
541
+ granter = candidates.find((d) => probeGrantsRelation(d, relation, `${domain}/${d.id}`));
542
+ }
543
+ if (granter === undefined) return undefined;
544
+
545
+ // Synthesize the grant payload + resolve which fields route into subject / grantedBy (so runtime seats the
546
+ // grant on the proof actor). Best-effort: an un-probable grant plan still emits the leg (runtime overwrites
547
+ // any field whose name looks like a subject/grantedBy anyway).
548
+ let grantSynth: SynthesizedPayload;
549
+ try {
550
+ grantSynth = synthesizePayload(granter.payloadSchema as z.ZodTypeAny, `${domain}/${granter.id}`);
551
+ } catch {
552
+ return undefined;
553
+ }
554
+ const grantPayload: Record<string, unknown> = { ...grantSynth.value };
555
+ const probed = probePlanOps(granter, `${domain}/${granter.id}`);
556
+ const routed = probed !== undefined ? resolveGrantSubjectFields(probed) : { subjectFields: [], grantedByFields: [] };
557
+
558
+ return {
559
+ domain,
560
+ gatedDirectiveId: gated.id,
561
+ relation,
562
+ gatedPayload,
563
+ gatedAutoStamped: gatedSynth.autoStamped,
564
+ ...(gatedMintField !== undefined ? { gatedMintField, gatedAggregateId: gated.aggregateId } : {}),
565
+ grantDirectiveId: granter.id,
566
+ grantPayload,
567
+ grantAutoStamped: grantSynth.autoStamped,
568
+ grantSubjectFields: routed.subjectFields,
569
+ grantGrantedByFields: routed.grantedByFields,
570
+ via,
571
+ };
572
+ }
573
+
339
574
  // ── child-birth discovery (the G3 `.births()` lane — prove a parent birth end-to-end into the child) ──────
340
575
 
341
576
  /** ONE step of a discovered birth recipe's genesis chain (the subset codegen reads). */
@@ -634,8 +869,9 @@ export interface TsProofOptions {
634
869
  * · `compile-valid` — the law compiled (always — every compiled domain is at least this).
635
870
  * · `standalone-provable` — a `.creates` write proven offline (dispatch → declared query/count locally).
636
871
  * · `child-birth-provable` — a `.births()` directive proven END-TO-END into the child it births.
872
+ * · `relation-bootstrap-provable` — a `.requires(R)`-gated write proven to SUCCEED after granting R (seed-then-prove).
637
873
  * · `live-cloud-provable` — the full cloud loop (the `.mts` `--live` lane) completes (the primary domain). */
638
- export type ProofKind = "compile-valid" | "standalone-provable" | "child-birth-provable" | "live-cloud-provable";
874
+ export type ProofKind = "compile-valid" | "standalone-provable" | "child-birth-provable" | "relation-bootstrap-provable" | "live-cloud-provable";
639
875
 
640
876
  /** The machine-readable OFFLINE legs for ONE domain (the `@nomos-proof-legs`
641
877
  * marker shape; the `proof-legs.json` index is a map of these by domain key). */
@@ -657,6 +893,10 @@ export interface ProofLegs {
657
893
  * When this domain is itself the BORN child's law (no standalone create), the index also registers the
658
894
  * child domain key pointing at the SAME leg, so `proof --domain <child>` resolves through the parent birth. */
659
895
  readonly childBirth?: ChildBirthLeg;
896
+ /** THE RELATION-BOOTSTRAP LEG — present iff this domain has a `.requires(R)`-gated directive AND a
897
+ * discoverable granter of R (a `.grants(R)` marker, or a plan-probed granting `writeTuple`). Proves the
898
+ * gated write SUCCEEDS once R is granted (the seed-then-prove positive). Additive: absent ⇒ older/ungated. */
899
+ readonly relationBootstrap?: RelationBootstrapLeg;
660
900
  }
661
901
 
662
902
  /** Synthesize the standalone-create offline legs for one domain module, or `null` when it has no
@@ -727,9 +967,19 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
727
967
  const childBirth = birthDir !== undefined ? allBirths.find((b) => b.parentDomain === domain && b.birthDirectiveId === birthDir.id) : undefined;
728
968
  const childBirthWithSetup = childBirth !== undefined ? attachParentSetup(childBirth, allBirths) : undefined;
729
969
 
970
+ // The relation-bootstrap leg (when the domain has a `.requires(R)`-gated directive AND a discoverable
971
+ // granter of R). FAIL SOFT: no granter → no leg (the negative conformance leg still covers the gate).
972
+ let relationBootstrap: RelationBootstrapLeg | undefined;
973
+ try {
974
+ relationBootstrap = discoverRelationBootstrapLeg(mod);
975
+ } catch {
976
+ relationBootstrap = undefined;
977
+ }
978
+
730
979
  const legs: ProofKind[] = ["compile-valid"];
731
980
  if (standalone !== null) legs.push("standalone-provable");
732
981
  if (childBirthWithSetup !== undefined) legs.push("child-birth-provable");
982
+ if (relationBootstrap !== undefined) legs.push("relation-bootstrap-provable");
733
983
 
734
984
  return {
735
985
  domain,
@@ -746,6 +996,7 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
746
996
  }
747
997
  : {}),
748
998
  ...(childBirthWithSetup !== undefined ? { childBirth: childBirthWithSetup } : {}),
999
+ ...(relationBootstrap !== undefined ? { relationBootstrap } : {}),
749
1000
  };
750
1001
  }
751
1002
 
@@ -1090,6 +1341,11 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
1090
1341
  // throws MissingOfferAuthority. `undefined` ⇒ a documented no-op (the domain authors openly).
1091
1342
  const conformance = discoverAuthorityConformanceLeg(mod);
1092
1343
 
1344
+ // THE RELATION-BOOTSTRAP LEG (the seed-then-prove positive): grant relation R to the proof actor, then prove
1345
+ // a `.requires(R)`-gated write SUCCEEDS under that actor. `undefined` ⇒ no gated directive or no discoverable
1346
+ // granter (the negative conformance leg still runs). Comes from the SAME `legsForDomain` synthesis (marker).
1347
+ const relationBootstrap = primaryLegs.relationBootstrap;
1348
+
1093
1349
  // Workspace-name slug: any package name → a safe, recognizable ws prefix.
1094
1350
  const slug =
1095
1351
  opts.packageName
@@ -1281,6 +1537,11 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
1281
1537
  // offer is impossible to GENERATE (throws MissingOfferAuthority before any submit). No-op when un-gated.
1282
1538
  out.push(...authorityConformanceLegLines(conformance, factory, n));
1283
1539
 
1540
+ // THE RELATION-BOOTSTRAP LEG (seed-then-prove the POSITIVE): grant relation R to the proof actor, then prove
1541
+ // the gated write SUCCEEDS under that actor. Runs on the LOCAL holon (fetch re-trapped). Emits nothing when
1542
+ // no granter is discoverable (the negative conformance leg still covers the gate).
1543
+ out.push(...relationSeedLegLines(relationBootstrap, factory, n));
1544
+
1284
1545
  // THE CHILD-BIRTH LEG (G3, gap 2): when the primary domain has a `.births()` directive, prove the birth
1285
1546
  // END-TO-END against the cloud — drive the parent birth offer, then read the BORN child workspace it spawns.
1286
1547
  if (childBirth !== undefined) {
package/src/directive.ts CHANGED
@@ -175,6 +175,17 @@ export interface Directive<P = unknown> {
175
175
  * `attestationAuthorizedGate`/`.attestationAuthorized()` naming convention; the declaration is the noun.)
176
176
  */
177
177
  readonly birthsChild?: boolean;
178
+ /**
179
+ * PROOF-TOOLING PRODUCER MARKER (relation-seed leg). Declares that this directive ESTABLISHES the named
180
+ * relation(s) — i.e. its `plan` folds a `writeTuple` that grants the relation. Purely a static hint for
181
+ * the proof generator's relation-bootstrap leg (`discoverRelationBootstrapLeg`): it disambiguates which
182
+ * sibling directive to run FIRST to seed the actor before proving a `.requires(R)`-gated write succeeds,
183
+ * and resolves the "who grants the granter" / multiple-candidate ambiguity that pure plan-probe inference
184
+ * cannot. NO kernel/manifest effect (it is NOT lowered into any era key — the gate is the `writeTuple` the
185
+ * plan already folds); a directive that never calls `.grants()` is byte-identical to before. Set via the
186
+ * chainable `.grants(relation)` (symmetric to `.requires`).
187
+ */
188
+ readonly grantsRelation?: string[];
178
189
  /**
179
190
  * The directive's DECLARED required HOST CAPABILITIES — the capability PORTS the HOST must
180
191
  * PROVIDE for this directive to run (`OFFER_KERNEL_ARCHITECTURE.dot` cluster_ports / `OFFER_FLOW.dot`
@@ -301,6 +312,14 @@ export interface RequirableDirective<P> extends Directive<P> {
301
312
  * the canonical manifest to before this existed (the era rule). Returns a NEW requirable directive.
302
313
  */
303
314
  births(): RequirableDirective<P>;
315
+ /**
316
+ * PROOF-TOOLING PRODUCER MARKER (symmetric to `.requires`): declare that this directive ESTABLISHES the
317
+ * named relation (its `plan` folds a `writeTuple` granting it). A pure proof-generator hint — NO kernel /
318
+ * manifest / era-key effect. It tells the relation-bootstrap proof leg which sibling to run FIRST to seed
319
+ * the actor before proving a `.requires(relation)`-gated write succeeds, and resolves inference ambiguity.
320
+ * Accumulates + dedups; additive + non-destructive. A directive that never calls this is byte-identical.
321
+ */
322
+ grants(relation: string): RequirableDirective<P>;
304
323
  }
305
324
 
306
325
  /** Stage 1: id + a referential marker (which fixes the target aggregate). */
@@ -492,6 +511,13 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
492
511
  // the birth after admit. Era-ruled: a non-birthing directive omits the flag and is byte-identical.
493
512
  return makeRequirable({ ...base, birthsChild: true });
494
513
  },
514
+ grants(relation: string): RequirableDirective<P> {
515
+ // PROOF-TOOLING PRODUCER MARKER (symmetric to `.requires`): record that this directive establishes
516
+ // `relation` (its plan folds a granting `writeTuple`). NO kernel/manifest effect — a pure static hint
517
+ // the proof generator's relation-bootstrap leg reads. Accumulate + dedup; additive + non-destructive.
518
+ const merged = [...new Set([...(base.grantsRelation ?? []), relation])].sort();
519
+ return makeRequirable({ ...base, grantsRelation: merged });
520
+ },
495
521
  };
496
522
  }
497
523