@githolon/dsl 0.27.1 → 0.29.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.29.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",
package/src/aggregate.ts CHANGED
@@ -114,6 +114,21 @@ export type HasManyFields<F extends Record<string, Field>> = {
114
114
  [K in keyof F as IsHasMany<F[K]> extends true ? K : never]: F[K];
115
115
  };
116
116
 
117
+ /** The value TYPE a stored field decodes to (the `T` in `Field<T, K>`) — `unknown` for an untyped field. */
118
+ export type FieldValue<Fld> = Fld extends Field<infer T> ? T : unknown;
119
+
120
+ /**
121
+ * The READ-MODEL ROW shape a projected aggregate decodes to — one property per STORED field, at its
122
+ * decoded value type (`Field<T>` → `T`), EVERY field optional (folds are partial — a projection may lack
123
+ * a field). The compile-time twin of the codegen `<Agg>Data` interface, derived from the SAME field kinds.
124
+ * Used to type the `.where(Agg, idFn, predicate)` row so `row.status !== "deleted"` type-checks and a
125
+ * typo'd field name is a compile error. (The ABSENT case — the whole instance missing — is modelled as
126
+ * `ReadModelRow<F> | undefined` at the predicate boundary.)
127
+ */
128
+ export type ReadModelRow<F extends Record<string, Field>> = {
129
+ [K in keyof StoredFields<F>]?: FieldValue<StoredFields<F>[K]>;
130
+ };
131
+
117
132
  /** Runtime partition of a declared field record into stored fields + virtual `hasMany` inverses. */
118
133
  function partitionFields(fields: Record<string, Field>): {
119
134
  stored: Record<string, Field>;
@@ -53,6 +53,7 @@ import type { CombinedDecl } from "./combined.js";
53
53
  import type { ImpureCapabilityDecl } from "./codegen_provider_dart.js";
54
54
  import type { WorkspaceTypeDecl } from "./workspace_type.js";
55
55
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
56
+ import { synthesizePreconditionInvariants } from "./precondition.js";
56
57
  import type { RelationTypeDecl } from "./relation_schema.js";
57
58
  import { domainHash, emitManifestBytes } from "./manifest.js";
58
59
  import { emitUsd } from "./usd.js";
@@ -181,6 +182,11 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
181
182
  const maxes = [...(spec.maxes ?? [])];
182
183
  const workspaceTypes = [...(spec.workspaceTypes ?? [])];
183
184
  const workspaceInvariants = [...(spec.workspaceInvariants ?? [])];
185
+ // DESUGAR the write-path precondition primitives (`.requiresExisting`/`.creates(Agg,idFn)`/`.where`):
186
+ // each directive's descriptors synthesize workspace invariants (LANE B) appended to the SAME list a
187
+ // hand-authored `workspaceInvariant(...)` occupies — so downstream treats them identically. Era-safe:
188
+ // a directive with no precondition synthesizes nothing (byte-identical manifest).
189
+ for (const d of directives) workspaceInvariants.push(...synthesizePreconditionInvariants(d));
184
190
  const relationTypes = [...(spec.relationTypes ?? [])];
185
191
  const dartImports = [...(spec.dartImports ?? [])];
186
192
  // THE PEER-SOVEREIGN ROOT-OF-TRUST: a home/child law that exports `requireParentAttestedBirth` declares
@@ -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,8 @@ 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";
44
+ import type { RelationTypeDecl, Rewrite } from "./relation_schema.js";
43
45
  import { finishCount } from "./count.js";
44
46
  import { mintedCreateField } from "./workspace_routing.js";
45
47
  import { deterministicPorts } from "./ctx.js";
@@ -336,6 +338,520 @@ function authorityConformanceLegLines(
336
338
  ];
337
339
  }
338
340
 
341
+ /**
342
+ * THE RELATION-BOOTSTRAP LEG (the seed-then-prove POSITIVE — complements the negative conformance leg). On the
343
+ * LOCAL holon with fetch trapped: (1) dispatch the GRANT directive under the proof actor (its subject fields
344
+ * overwritten to the actor so the relation seats ON it), then (2) dispatch the GATED directive under the SAME
345
+ * actor and assert it SUCCEEDS — proving that once relation R is granted, an actor holding R authors the gated
346
+ * write (the exact loop the negative leg can't cover). Emits nothing when no granter is discoverable.
347
+ */
348
+ function relationSeedLegLines(
349
+ leg: RelationBootstrapLeg | undefined,
350
+ factory: string,
351
+ n: () => number,
352
+ diagnostic?: string,
353
+ ): string[] {
354
+ if (leg === undefined) {
355
+ if (diagnostic === undefined) return [];
356
+ // A gated directive exists but no authority could be seeded — say EXACTLY why (a useful failure, not a
357
+ // silent skip). The gate is still proven negatively by the conformance leg above.
358
+ return [
359
+ `// ${n()}. relation-bootstrap leg: this law has a gated directive the proof COULD NOT seed authority for.`,
360
+ `console.log(${JSON.stringify(`– relation-bootstrap leg SKIPPED — ${diagnostic}`)});`,
361
+ ``,
362
+ ];
363
+ }
364
+ const ACTOR = `"user:proof-conformance"`;
365
+ const speculative = leg.seedSpeculative === true;
366
+ // Grant/seed payload: overwrite the routed subject/grantedBy fields (and any name-shaped fallback) with the actor.
367
+ const grantPayloadLit = (() => {
368
+ const overwrite = new Set<string>([...leg.grantSubjectFields, ...leg.grantGrantedByFields]);
369
+ const entries = Object.entries(leg.grantPayload).map(([k, v]) => {
370
+ // Also catch conventional subject/grantedBy field names the plan-probe couldn't value-match.
371
+ const looksSubject = /^(subject|principal|grantedBy|grantee|member|user|owner)$/i.test(k);
372
+ return overwrite.has(k) || looksSubject
373
+ ? `${JSON.stringify(k)}: ${ACTOR}`
374
+ : `${JSON.stringify(k)}: ${JSON.stringify(v)}`;
375
+ });
376
+ return `{ ${entries.join(", ")} }`;
377
+ })();
378
+ const gatedPayloadLit = leg.gatedMintField !== undefined && leg.gatedAggregateId !== undefined
379
+ ? `{ ...${JSON.stringify(leg.gatedPayload)}, ${JSON.stringify(leg.gatedMintField)}: await holon.mint(${JSON.stringify(leg.gatedAggregateId)}) }`
380
+ : JSON.stringify(leg.gatedPayload);
381
+ const computedNote = leg.grantedRelation !== leg.relation && !speculative
382
+ ? ` — a COMPUTED gate: granting "${leg.grantedRelation}" makes the actor satisfy "${leg.relation}" via the rewrite`
383
+ : ``;
384
+
385
+ if (speculative) {
386
+ // LAYER 3 (seed-path replay). The gate relation is reachable only through ttu/intersection/exclusion — no
387
+ // directly-grantable base. We REPLAY the domain's declared seed and let the REAL GATE arbitrate: if the
388
+ // gated write is ADMITTED, it is a green positive leg; if REFUSED, it is a DIAGNOSTIC (never a false
389
+ // green) — the discovered seed was not the authority path. Step (2) is wrapped so a refusal degrades.
390
+ const seats = (leg.seedWritesRelations ?? [leg.grantedRelation]).join(", ");
391
+ return [
392
+ `// ${n()}. RELATION BOOTSTRAP — LAYER 3 (seed-path replay): \`${leg.domain}/${leg.gatedDirectiveId}\` is GATED`,
393
+ `// (\`.requires("${leg.relation}")\`), reachable only via ttu/intersection (no directly-grantable base).`,
394
+ `// REPLAY the declared seed \`${leg.domain}/${leg.grantDirectiveId}\` (seats [${seats}]) on the actor, then let`,
395
+ `// the REAL gate decide: admitted ⇒ proven; refused ⇒ diagnostic (a speculative seed never false-greens).`,
396
+ `{`,
397
+ ` const realFetch3 = globalThis.fetch;`,
398
+ ` globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: relation-bootstrap touched the network"); }) as typeof fetch;`,
399
+ ` try {`,
400
+ ` const ACTOR = ${ACTOR};`,
401
+ ` let seedReplayed = false;`,
402
+ ` try {`,
403
+ ` await ${factory}(holon, { actor: ACTOR }).${leg.grantDirectiveId}(${grantPayloadLit});`,
404
+ ` seedReplayed = true;`,
405
+ ` } catch (e) {`,
406
+ ` console.log(\`– relation-bootstrap leg SKIPPED (layer-3 seed-path) — the declared seed ${leg.domain}/${leg.grantDirectiveId} could not be replayed (\${(e as Error)?.message ?? e}); gate ${leg.gatedDirectiveId} stays proven negatively\`);`,
407
+ ` }`,
408
+ ` if (seedReplayed) {`,
409
+ ` try {`,
410
+ ` await ${factory}(holon, { actor: ACTOR }).${leg.gatedDirectiveId}(${gatedPayloadLit});`,
411
+ ` ok(\`relation bootstrap (seed-path) — ${leg.domain}/${leg.gatedDirectiveId} authored after replaying the declared seed ${leg.grantDirectiveId} (the REAL gate confirmed it establishes authority for "${leg.relation}")\`);`,
412
+ ` } catch (e) {`,
413
+ ` // ANTI-FALSE-GREEN: the real gate REFUSED — the discovered seed was not the authority path. Do NOT`,
414
+ ` // assert a passing leg; emit a diagnostic (the conformance leg still proves the gate has teeth).`,
415
+ ` console.log(\`– relation-bootstrap leg SKIPPED (layer-3 seed-path) — replaying ${leg.grantDirectiveId} did NOT authorize ${leg.gatedDirectiveId} .requires("${leg.relation}") on the real gate (\${(e as Error)?.message ?? e}); no seed-path is provable\`);`,
416
+ ` }`,
417
+ ` }`,
418
+ ` } finally {`,
419
+ ` globalThis.fetch = realFetch3;`,
420
+ ` }`,
421
+ `}`,
422
+ ``,
423
+ ];
424
+ }
425
+
426
+ return [
427
+ `// ${n()}. RELATION BOOTSTRAP (seed-then-prove the POSITIVE): \`${leg.domain}/${leg.gatedDirectiveId}\` is GATED`,
428
+ `// (\`.requires("${leg.relation}")\`). \`${leg.domain}/${leg.grantDirectiveId}\` GRANTS "${leg.grantedRelation}" (${leg.via})${computedNote}.`,
429
+ `// Grant it to the proof actor, THEN prove the gated write SUCCEEDS under that actor (what the negative`,
430
+ `// conformance leg above can't cover — a seeded holder authoring a gated write).`,
431
+ `{`,
432
+ ` const realFetch3 = globalThis.fetch;`,
433
+ ` globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: relation-bootstrap touched the network"); }) as typeof fetch;`,
434
+ ` try {`,
435
+ ` const ACTOR = ${ACTOR};`,
436
+ ` // (1) grant "${leg.grantedRelation}" to ACTOR (the grant folds a writeTuple; subject seated on ACTOR).`,
437
+ ` await ${factory}(holon, { actor: ACTOR }).${leg.grantDirectiveId}(${grantPayloadLit});`,
438
+ ` ok(\`relation bootstrap (grant) — ${leg.domain}/${leg.grantDirectiveId} granted "${leg.grantedRelation}" to \${ACTOR}${leg.grantedRelation !== leg.relation ? ` (⇒ satisfies "${leg.relation}" via rewrite)` : ``}\`);`,
439
+ ` // (2) the GATED write now SUCCEEDS under the seeded actor (holds "${leg.relation}"${leg.grantedRelation !== leg.relation ? ` via "${leg.grantedRelation}"` : ``}).`,
440
+ ` await ${factory}(holon, { actor: ACTOR }).${leg.gatedDirectiveId}(${gatedPayloadLit});`,
441
+ ` ok(\`relation bootstrap (prove) — ${leg.domain}/${leg.gatedDirectiveId} authored under the seeded holder of "${leg.relation}" (gated write SUCCEEDS once granted)\`);`,
442
+ ` } finally {`,
443
+ ` globalThis.fetch = realFetch3;`,
444
+ ` }`,
445
+ `}`,
446
+ ``,
447
+ ];
448
+ }
449
+
450
+ // ── relation-bootstrap discovery (seed the gate: grant relation R, then prove a .requires(R) write SUCCEEDS) ──
451
+ //
452
+ // The NEGATIVE conformance leg above proves an ACTORLESS gated offer can't be generated. This POSITIVE leg
453
+ // proves the other half: with the relation GRANTED, an actor that holds it authors the gated write SUCCESS-
454
+ // fully. It needs a sibling directive that ESTABLISHES the relation (folds a granting `writeTuple`). We find
455
+ // it two ways: FIRST a `.grants(R)` producer marker (the static, unambiguous lane — symmetric to `.requires`);
456
+ // ELSE plan-probe each sibling for a `writeTuple`/relation-tuple op whose `relation === R` (inference).
457
+
458
+ /** A discovered RELATION-BOOTSTRAP leg: a gated (`.requires(R)`) directive plus the sibling directive that
459
+ * GRANTS R. The proof dispatches the grant FIRST (its subject/grantedBy set to the seeded proof actor), then
460
+ * dispatches the gated directive under that actor and asserts it SUCCEEDS — the seed-then-prove positive. */
461
+ export interface RelationBootstrapLeg {
462
+ /** The gated directive's domain. */
463
+ readonly domain: string;
464
+ /** The gated directive id (`.requires(relation)`). */
465
+ readonly gatedDirectiveId: string;
466
+ /** The relation the GATE requires. */
467
+ readonly relation: string;
468
+ /** The relation the GRANT actually establishes on the actor. For a DIRECT grant this === `relation`; for a
469
+ * COMPUTED gate relation it is the directly-grantable BASE relation (e.g. gate `estateMember` → grant
470
+ * `estateOwner`), resolved by walking the relation-schema rewrite. Holding it makes the actor satisfy
471
+ * `relation` through the computed chain. */
472
+ readonly grantedRelation: string;
473
+ /** The synthesized gated-directive payload (auto-stamped `…At` omitted; mintField removed). */
474
+ readonly gatedPayload: Record<string, unknown>;
475
+ readonly gatedAutoStamped: readonly string[];
476
+ /** When the gated directive's own id is kernel-minted, the field to mint at runtime. */
477
+ readonly gatedMintField?: string;
478
+ /** The gated create's aggregate id (the mint's type tag) — present when `gatedMintField` is. */
479
+ readonly gatedAggregateId?: string;
480
+ /** The granting directive id (the sibling that folds the granting `writeTuple`). */
481
+ readonly grantDirectiveId: string;
482
+ /** The synthesized grant payload (auto-stamped `…At` omitted). The SUBJECT fields (below) are overwritten
483
+ * at runtime with the seeded proof actor so the grant seats authority ON that actor. */
484
+ readonly grantPayload: Record<string, unknown>;
485
+ readonly grantAutoStamped: readonly string[];
486
+ /** The grant-payload field(s) whose value the plan routed into the tuple SUBJECT — set to the proof actor
487
+ * at dispatch so the granted relation lands on the actor the gated write runs as. */
488
+ readonly grantSubjectFields: readonly string[];
489
+ /** Grant-payload field(s) routed into the tuple `grantedBy` — set to the proof actor (free attribution). */
490
+ readonly grantGrantedByFields: readonly string[];
491
+ /** How the granter was resolved — `"marker"` (`.grants(R)` on R directly), `"inferred"` (a plan-probed
492
+ * `writeTuple(R)`), `"computed-base"` (R is computed; the grant seats a rewrite BASE relation the granter
493
+ * was found for), or `"seed-path"` (LAYER 3: R is reachable only through ttu/intersection/exclusion — no
494
+ * directly-grantable base — so a DECLARED SEED directive that establishes root authority is replayed and
495
+ * the REAL KERNEL GATE is the arbiter of whether it authorizes the write). Audit + runner semantics. */
496
+ readonly via: "marker" | "inferred" | "computed-base" | "seed-path";
497
+ /** LAYER-3 ANTI-FALSE-GREEN FLAG. `true` ⇒ this is a SPECULATIVE seed-path leg: codegen could NOT prove
498
+ * (via rewrite resolution) that replaying `grantDirectiveId` authorizes the gated write — it discovered a
499
+ * seed that writes relation tuples and is DEFERRING to the real kernel gate. The runner replays the seed,
500
+ * attempts the gated write, and: ADMITTED ⇒ green positive leg (the law truly authorizes it); REFUSED ⇒
501
+ * NOT a proof failure but an honest DIAGNOSTIC (the discovered seed was not the authority path) — a
502
+ * speculative seed can NEVER emit a false green. Absent/false ⇒ layers 1/2 (a refusal IS a loud regression). */
503
+ readonly seedSpeculative?: boolean;
504
+ /** The relations the discovered seed directive writes (layer-3 audit/diagnostic — what authority it seats). */
505
+ readonly seedWritesRelations?: readonly string[];
506
+ }
507
+
508
+ /** Run a directive's plan with a synthesized+probe payload under birth-probe globals and return the drained
509
+ * field ops (authoring sink + returned ops). `undefined` ⇒ the plan couldn't be probed. */
510
+ function probePlanOps(
511
+ dir: Directive<unknown>,
512
+ where: string,
513
+ ): { ops: FieldOp[]; synth: SynthesizedPayload; probePayload: Record<string, unknown> } | undefined {
514
+ let synth: SynthesizedPayload;
515
+ try {
516
+ synth = synthesizePayload(dir.payloadSchema as z.ZodTypeAny, where);
517
+ } catch {
518
+ return undefined;
519
+ }
520
+ const probePayload: Record<string, unknown> = { ...synth.value };
521
+ for (const f of synth.autoStamped) probePayload[f] = "2026-01-01T00:00:00Z";
522
+ let ops: FieldOp[] = [];
523
+ try {
524
+ withBirthProbeGlobals(() => {
525
+ __resetAuthoring();
526
+ __resetReads();
527
+ __resetBirths();
528
+ try {
529
+ const returned = dir.plan(probePayload as never, deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }));
530
+ const drained = [...__drainAuthoring(), ...(Array.isArray(returned) ? returned : [])];
531
+ ops = drained.filter((o): o is FieldOp => (o as { kind?: string }).kind !== "strike");
532
+ } finally {
533
+ try { __drainReads(); __drainBirths(); } catch { /* drained */ }
534
+ }
535
+ });
536
+ } catch {
537
+ return undefined;
538
+ }
539
+ return { ops, synth, probePayload };
540
+ }
541
+
542
+ /** Does this directive's plan GRANT `relation` — i.e. fold a relation-tuple write whose `relation` field is
543
+ * set to R (the `writeTuple`/`relateSubject` shape: a `set(rt, "relation", R)` op on a RelationTuple)? */
544
+ function probeGrantsRelation(dir: Directive<unknown>, relation: string, where: string): boolean {
545
+ const probed = probePlanOps(dir, where);
546
+ if (probed === undefined) return false;
547
+ return probed.ops.some((o) => o.kind === "set" && o.field === "relation" && o.value === relation);
548
+ }
549
+
550
+ /** ALL relation names a directive's plan writes tuples for (the `set(rt, "relation", <name>)` ops) — the
551
+ * layer-3 seed discovery reads this to find a directive that ESTABLISHES relation authority, without needing
552
+ * the written relation to be a rewrite-resolvable base of the gate. `[]` ⇒ writes no relation tuples. */
553
+ function probeRelationsWritten(dir: Directive<unknown>, where: string): string[] {
554
+ const probed = probePlanOps(dir, where);
555
+ if (probed === undefined) return [];
556
+ const out = new Set<string>();
557
+ for (const o of probed.ops) {
558
+ if (o.kind === "set" && o.field === "relation" && typeof o.value === "string" && o.value.length > 0) out.add(o.value);
559
+ }
560
+ return [...out];
561
+ }
562
+
563
+ /** Resolve WHICH grant-payload fields route into the tuple subject / grantedBy — by matching the op values
564
+ * the plan emitted (the subject `setEntry`'s entryKey, the `grantedBy` leaf) back to synthesized payload
565
+ * values. Best-effort: an unmatched routing just leaves the field list empty (runtime falls back). */
566
+ function resolveGrantSubjectFields(
567
+ probed: { ops: FieldOp[]; synth: SynthesizedPayload },
568
+ ): { subjectFields: string[]; grantedByFields: string[] } {
569
+ const { ops, synth } = probed;
570
+ const fieldForValue = (v: unknown): string | undefined =>
571
+ typeof v === "string" && v.length > 0
572
+ ? Object.entries(synth.value).find(([, sv]) => sv === v)?.[0]
573
+ : undefined;
574
+ const subjectFields = new Set<string>();
575
+ const grantedByFields = new Set<string>();
576
+ for (const o of ops) {
577
+ // The subject rides the tuple map entry KEY (`setEntry(rt, "subjects", <subjectRaw>, leaf)`).
578
+ if (o.kind === "setEntry" && o.field === "subjects" && typeof o.entryKey === "string") {
579
+ const f = fieldForValue(o.entryKey);
580
+ if (f !== undefined) subjectFields.add(f);
581
+ // grantedBy rides the JSON leaf value — scan the serialized leaf for any synthesized payload value.
582
+ if (typeof o.value === "string") {
583
+ for (const [name, sv] of Object.entries(synth.value)) {
584
+ if (typeof sv === "string" && sv.length > 0 && name !== fieldForValue(o.entryKey) && o.value.includes(sv)) {
585
+ grantedByFields.add(name);
586
+ }
587
+ }
588
+ }
589
+ }
590
+ }
591
+ return { subjectFields: [...subjectFields], grantedByFields: [...grantedByFields] };
592
+ }
593
+
594
+ // ── rewrite-aware base-relation resolution (the COMPUTED-authority lane) ──────────────────────────────
595
+ //
596
+ // A gate `.requires(R)` may name a COMPUTED relation — one nobody grants a direct tuple for. The owner
597
+ // satisfies R through a Zanzibar REWRITE (e.g. `estateMember = self ∪ computed("estateOwner")` ⇒ an
598
+ // `estateOwner` IS an `estateMember`). The direct-granter search below can't find a granter of R (nobody
599
+ // writes an `estateMember` tuple), so the positive leg was impossible without the domain hand-seeding a
600
+ // tuple. This resolver walks R's rewrite in the module's relation schema and returns the ordered set of
601
+ // directly-grantable BASE relations B such that granting B on the gate object makes the actor satisfy R:
602
+ // seed B (via its `.grants(B)` directive / plan-probed writeTuple), then the gated write admits.
603
+ //
604
+ // PRINCIPLED, never a guess: it descends `this`/`self` (R itself is directly grantable), `computed(B)`
605
+ // (B satisfies R — recurse into B), and `union` (ANY branch that yields a base suffices — OR semantics).
606
+ // `intersection`/`exclusion`/`ttu` (tuple_to_userset — a cross-object two-hop) are NOT locally decidable
607
+ // from a same-object grant, so they yield nothing here and the caller falls through to the seed-path lane.
608
+
609
+ /** Is relation `rel` on `decl` DIRECTLY GRANTABLE — i.e. its rewrite admits `this` (stores direct tuples)
610
+ * and it `allow`s ≥1 subject class (a `writeTuple`/grant can seat a principal on it)? */
611
+ function isDirectlyGrantable(decl: RelationTypeDecl, rel: string): boolean {
612
+ const def = decl.relations.get(rel);
613
+ if (def === undefined) return false;
614
+ if (def.allowed.length === 0) return false; // fail-closed: stores no direct tuples
615
+ return rewriteAdmitsThis(def.rewrite);
616
+ }
617
+
618
+ /** Does a rewrite include `_this` (directly-stored tuples) somewhere a same-object grant lands — i.e. at the
619
+ * top or inside a `union`? `null` ⇒ pure direct `this`. (A `this` buried under intersection/exclusion is not
620
+ * a clean grant target — treat conservatively as non-admitting for the seed lane.) */
621
+ function rewriteAdmitsThis(r: Rewrite | null): boolean {
622
+ if (r === null) return true;
623
+ switch (r.kind) {
624
+ case "this":
625
+ return true;
626
+ case "union":
627
+ return r.children.some((c) => rewriteAdmitsThis(c));
628
+ default:
629
+ return false;
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Resolve the ordered directly-grantable BASE relations whose grant makes an actor satisfy `relation` on
635
+ * `objectType`, by walking the rewrite in the module's relation schema. Returns [] when the relation is not
636
+ * computed in a locally-decidable way (intersection/exclusion/ttu, an undeclared type, or nothing grantable)
637
+ * — the caller then tries the direct-granter or seed-path lanes. R itself, when directly grantable, is the
638
+ * FIRST candidate (the existing direct-grant lane covers it, but listing it keeps this the single resolver).
639
+ */
640
+ export function resolveGrantableBaseRelations(
641
+ relationTypes: readonly RelationTypeDecl[] | undefined,
642
+ objectType: string,
643
+ relation: string,
644
+ ): string[] {
645
+ // The gate object is a `<type>:<id>` reference (e.g. `workspace:self`); a relation schema is keyed by TYPE.
646
+ const type = objectType.includes(":") ? objectType.slice(0, objectType.indexOf(":")) : objectType;
647
+ const decl = (relationTypes ?? []).find((t) => t.type === type);
648
+ if (decl === undefined) return [];
649
+ const out: string[] = [];
650
+ const seen = new Set<string>();
651
+ const pushBase = (rel: string): void => {
652
+ if (!seen.has(rel) && isDirectlyGrantable(decl, rel)) {
653
+ seen.add(rel);
654
+ out.push(rel);
655
+ }
656
+ };
657
+ // Walk from `relation` collecting grantable bases reachable via this/computed/union.
658
+ const visited = new Set<string>();
659
+ const walk = (rel: string): void => {
660
+ if (visited.has(rel)) return;
661
+ visited.add(rel);
662
+ const def = decl.relations.get(rel);
663
+ if (def === undefined) return;
664
+ // If THIS relation is directly grantable, granting it satisfies itself (and thus `relation` through the
665
+ // computed chain that led here).
666
+ pushBase(rel);
667
+ walkRewrite(def.rewrite);
668
+ };
669
+ const walkRewrite = (r: Rewrite | null): void => {
670
+ if (r === null) return;
671
+ switch (r.kind) {
672
+ case "this":
673
+ return; // handled by pushBase at the relation level
674
+ case "computed":
675
+ walk(r.relation); // an actor holding the computed sibling satisfies this relation
676
+ return;
677
+ case "union":
678
+ for (const c of r.children) walkRewrite(c);
679
+ return;
680
+ // intersection/exclusion/ttu: not locally decidable from a single same-object grant → yield nothing.
681
+ default:
682
+ return;
683
+ }
684
+ };
685
+ walk(relation);
686
+ return out;
687
+ }
688
+
689
+ /** Discover the RELATION-BOOTSTRAP leg for a module: the first gated (`.requires(R)`) directive plus the
690
+ * sibling that ESTABLISHES the actor's authority for R. In priority order: (1) a DIRECT granter of R (a
691
+ * `.grants(R)` marker or a plan-probed `writeTuple(R)`) — the original lane; (2) REWRITE-AWARE — when R is
692
+ * COMPUTED, resolve a directly-grantable BASE relation B (via the relation schema's rewrite) and find its
693
+ * granter, so granting B makes the actor satisfy R. `undefined` ⇒ no gated directive, or no granter for R
694
+ * or any of its bases (the caller may then fall through to the seed-path lane; the negative conformance
695
+ * leg always still runs). */
696
+ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstrapLeg | undefined {
697
+ const domain = mod.domain ?? mod.name;
698
+ const directives = mod.directives as Directive<unknown>[];
699
+ const gated = directives.find(
700
+ (d) =>
701
+ d?.requiresRelationGate !== undefined &&
702
+ typeof d.payloadSchema === "object" &&
703
+ d.birthsChild !== true &&
704
+ d.attestationAuthorizedGate === undefined,
705
+ );
706
+ if (gated === undefined) return undefined;
707
+ const relation = gated.requiresRelationGate!.relation;
708
+
709
+ // Synthesize the gated payload (mirror of the conformance leg — mintField removed, auto-stamped omitted).
710
+ let gatedSynth: SynthesizedPayload;
711
+ try {
712
+ gatedSynth = synthesizePayload(gated.payloadSchema as z.ZodTypeAny, `${domain}/${gated.id}`);
713
+ } catch {
714
+ return undefined;
715
+ }
716
+ const gatedAgg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === gated.aggregateId);
717
+ const gatedMintField = gatedAgg !== undefined ? mintedCreateField(gated, gatedAgg) : undefined;
718
+ const gatedPayload: Record<string, unknown> = { ...gatedSynth.value };
719
+ if (gatedMintField !== undefined) delete gatedPayload[gatedMintField];
720
+
721
+ // Find a granter that establishes the actor's authority for the gate relation. Never the gated directive.
722
+ // (1) DIRECT: a `.grants(R)` marker (unambiguous), ELSE a plan-probed `writeTuple(R)`.
723
+ // (2) COMPUTED-BASE: when R is computed, walk the relation-schema rewrite for the directly-grantable BASE
724
+ // relations B (e.g. gate `estateMember` → `estateOwner`/`admin`), and find a granter for a base —
725
+ // granting B makes the actor satisfy R through the computed chain (what estate's seed does).
726
+ const candidates = directives.filter((d) => d !== gated && typeof d?.payloadSchema === "object" && d.birthsChild !== true);
727
+ const findGranterFor = (rel: string): { granter: Directive<unknown>; via: "marker" | "inferred" } | undefined => {
728
+ const marked = candidates.find((d) => (d.grantsRelation ?? []).includes(rel));
729
+ if (marked !== undefined) return { granter: marked, via: "marker" };
730
+ const inferred = candidates.find((d) => probeGrantsRelation(d, rel, `${domain}/${d.id}`));
731
+ return inferred !== undefined ? { granter: inferred, via: "inferred" } : undefined;
732
+ };
733
+
734
+ // (1) direct grant of R.
735
+ let grantedRelation = relation;
736
+ let via: "marker" | "inferred" | "computed-base" | "seed-path" = "marker";
737
+ let seedSpeculative = false;
738
+ let seedWritesRelations: string[] | undefined;
739
+ let found = findGranterFor(relation);
740
+ if (found !== undefined) {
741
+ via = found.via;
742
+ } else {
743
+ // (2) rewrite-aware: R is computed — resolve grantable base relations and find a granter for one.
744
+ const bases = resolveGrantableBaseRelations(mod.relationTypes, gated.requiresRelationGate!.object, relation)
745
+ .filter((b) => b !== relation); // R itself already tried in (1)
746
+ for (const base of bases) {
747
+ const f = findGranterFor(base);
748
+ if (f !== undefined) {
749
+ found = f;
750
+ grantedRelation = base;
751
+ via = "computed-base";
752
+ break;
753
+ }
754
+ }
755
+ if (found === undefined) {
756
+ // (3) SEED-PATH REPLAY (the general fallback): R is reachable only through ttu/intersection/exclusion —
757
+ // no directly-grantable base for layer 2. Discover a DECLARED seed directive that ESTABLISHES relation
758
+ // authority (its plan writes ≥1 relation tuple) and route its subject to the proof actor. We do NOT
759
+ // claim this authorizes the gated write — the REAL KERNEL GATE decides at runtime (the anti-false-green
760
+ // guard: `seedSpeculative`). Prefer a seed that seats the MOST relations (a genesis/owner seed like
761
+ // estate's, which lands admin/creator/estateOwner together — the broadest root authority).
762
+ const seed = discoverSeedDirective(candidates, domain);
763
+ if (seed === undefined) return undefined; // no seed path → the caller emits the diagnostic
764
+ found = { granter: seed.dir, via: "inferred" };
765
+ grantedRelation = seed.relationsWritten[0] ?? relation; // cosmetic — the seed's primary relation
766
+ via = "seed-path";
767
+ seedSpeculative = true;
768
+ seedWritesRelations = seed.relationsWritten;
769
+ }
770
+ }
771
+ const granter = found.granter;
772
+
773
+ // Synthesize the grant payload + resolve which fields route into subject / grantedBy (so runtime seats the
774
+ // grant on the proof actor). Best-effort: an un-probable grant plan still emits the leg (runtime overwrites
775
+ // any field whose name looks like a subject/grantedBy anyway).
776
+ let grantSynth: SynthesizedPayload;
777
+ try {
778
+ grantSynth = synthesizePayload(granter.payloadSchema as z.ZodTypeAny, `${domain}/${granter.id}`);
779
+ } catch {
780
+ return undefined;
781
+ }
782
+ const grantPayload: Record<string, unknown> = { ...grantSynth.value };
783
+ const probed = probePlanOps(granter, `${domain}/${granter.id}`);
784
+ const routed = probed !== undefined ? resolveGrantSubjectFields(probed) : { subjectFields: [], grantedByFields: [] };
785
+
786
+ return {
787
+ domain,
788
+ gatedDirectiveId: gated.id,
789
+ relation,
790
+ grantedRelation,
791
+ gatedPayload,
792
+ gatedAutoStamped: gatedSynth.autoStamped,
793
+ ...(gatedMintField !== undefined ? { gatedMintField, gatedAggregateId: gated.aggregateId } : {}),
794
+ grantDirectiveId: granter.id,
795
+ grantPayload,
796
+ grantAutoStamped: grantSynth.autoStamped,
797
+ grantSubjectFields: routed.subjectFields,
798
+ grantGrantedByFields: routed.grantedByFields,
799
+ via,
800
+ ...(seedSpeculative ? { seedSpeculative: true } : {}),
801
+ ...(seedWritesRelations !== undefined ? { seedWritesRelations } : {}),
802
+ };
803
+ }
804
+
805
+ /** A discovered layer-3 SEED directive: a sibling (non-gated, non-birth) whose plan writes relation tuples,
806
+ * so replaying it SEATS relation authority the gate's rewrite may consume. Ranked by how many relations it
807
+ * writes (a broad genesis/owner seed outranks a single-relation grant). `undefined` ⇒ no such directive. */
808
+ function discoverSeedDirective(
809
+ candidates: readonly Directive<unknown>[],
810
+ domain: string,
811
+ ): { dir: Directive<unknown>; relationsWritten: string[] } | undefined {
812
+ let best: { dir: Directive<unknown>; relationsWritten: string[] } | undefined;
813
+ for (const d of candidates) {
814
+ // Skip a directive that is itself authz-gated — replaying it would need its OWN authority first (a seed
815
+ // must be an ungated genesis/bootstrap the proof can drive under a bare actor).
816
+ if (d.requiresRelationGate !== undefined) continue;
817
+ const written = probeRelationsWritten(d, `${domain}/${d.id}`);
818
+ if (written.length === 0) continue;
819
+ if (best === undefined || written.length > best.relationsWritten.length) {
820
+ best = { dir: d, relationsWritten: written.sort() };
821
+ }
822
+ }
823
+ return best;
824
+ }
825
+
826
+ /**
827
+ * When a module HAS a gated (`.requires(R)`) directive but `discoverRelationBootstrapLeg` finds no granter
828
+ * (neither a direct granter of R nor one for any rewrite BASE of R), explain EXACTLY what is missing so the
829
+ * proof fails usefully rather than silently skipping the positive leg. `undefined` ⇒ nothing to diagnose
830
+ * (no gated directive, or the leg WAS discovered). Never throws.
831
+ */
832
+ export function diagnoseRelationBootstrapGap(mod: DomainModule): string | undefined {
833
+ const domain = mod.domain ?? mod.name;
834
+ const directives = mod.directives as Directive<unknown>[];
835
+ const gated = directives.find(
836
+ (d) => d?.requiresRelationGate !== undefined && typeof d.payloadSchema === "object"
837
+ && d.birthsChild !== true && d.attestationAuthorizedGate === undefined,
838
+ );
839
+ if (gated === undefined) return undefined;
840
+ if (discoverRelationBootstrapLeg(mod) !== undefined) return undefined; // resolved — no gap
841
+ const relation = gated.requiresRelationGate!.relation;
842
+ const object = gated.requiresRelationGate!.object;
843
+ const bases = resolveGrantableBaseRelations(mod.relationTypes, object, relation).filter((b) => b !== relation);
844
+ const tried = bases.length > 0
845
+ ? `no directive grants "${relation}" directly, nor any of its directly-grantable rewrite bases [${bases.join(", ")}]`
846
+ : (mod.relationTypes ?? []).some((t) => t.type === (object.includes(":") ? object.slice(0, object.indexOf(":")) : object))
847
+ ? `"${relation}" on "${object}" is not directly grantable and its rewrite (intersection/exclusion/tuple_to_userset) has no locally-seedable base relation`
848
+ : `no relation schema declares object type "${object}", so "${relation}" cannot be resolved to a grantable base`;
849
+ return `${domain}/${gated.id} is gated by .requires("${relation}") but the proof cannot seed authority: ${tried}, `
850
+ + `AND no ungated seed/genesis directive writes any relation tuple to replay (layer-3 seed-path found nothing). `
851
+ + `Add a directive that folds a granting writeTuple for "${relation}" (or a grantable base of it) and mark it .grants(...), `
852
+ + `or expose a genesis/seed directive that establishes the owner's root authority. (The negative conformance leg still proves the gate refuses an actorless offer.)`;
853
+ }
854
+
339
855
  // ── child-birth discovery (the G3 `.births()` lane — prove a parent birth end-to-end into the child) ──────
340
856
 
341
857
  /** ONE step of a discovered birth recipe's genesis chain (the subset codegen reads). */
@@ -634,8 +1150,9 @@ export interface TsProofOptions {
634
1150
  * · `compile-valid` — the law compiled (always — every compiled domain is at least this).
635
1151
  * · `standalone-provable` — a `.creates` write proven offline (dispatch → declared query/count locally).
636
1152
  * · `child-birth-provable` — a `.births()` directive proven END-TO-END into the child it births.
1153
+ * · `relation-bootstrap-provable` — a `.requires(R)`-gated write proven to SUCCEED after granting R (seed-then-prove).
637
1154
  * · `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";
1155
+ export type ProofKind = "compile-valid" | "standalone-provable" | "child-birth-provable" | "relation-bootstrap-provable" | "live-cloud-provable";
639
1156
 
640
1157
  /** The machine-readable OFFLINE legs for ONE domain (the `@nomos-proof-legs`
641
1158
  * marker shape; the `proof-legs.json` index is a map of these by domain key). */
@@ -657,6 +1174,10 @@ export interface ProofLegs {
657
1174
  * When this domain is itself the BORN child's law (no standalone create), the index also registers the
658
1175
  * child domain key pointing at the SAME leg, so `proof --domain <child>` resolves through the parent birth. */
659
1176
  readonly childBirth?: ChildBirthLeg;
1177
+ /** THE RELATION-BOOTSTRAP LEG — present iff this domain has a `.requires(R)`-gated directive AND a
1178
+ * discoverable granter of R (a `.grants(R)` marker, or a plan-probed granting `writeTuple`). Proves the
1179
+ * gated write SUCCEEDS once R is granted (the seed-then-prove positive). Additive: absent ⇒ older/ungated. */
1180
+ readonly relationBootstrap?: RelationBootstrapLeg;
660
1181
  }
661
1182
 
662
1183
  /** Synthesize the standalone-create offline legs for one domain module, or `null` when it has no
@@ -727,9 +1248,19 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
727
1248
  const childBirth = birthDir !== undefined ? allBirths.find((b) => b.parentDomain === domain && b.birthDirectiveId === birthDir.id) : undefined;
728
1249
  const childBirthWithSetup = childBirth !== undefined ? attachParentSetup(childBirth, allBirths) : undefined;
729
1250
 
1251
+ // The relation-bootstrap leg (when the domain has a `.requires(R)`-gated directive AND a discoverable
1252
+ // granter of R). FAIL SOFT: no granter → no leg (the negative conformance leg still covers the gate).
1253
+ let relationBootstrap: RelationBootstrapLeg | undefined;
1254
+ try {
1255
+ relationBootstrap = discoverRelationBootstrapLeg(mod);
1256
+ } catch {
1257
+ relationBootstrap = undefined;
1258
+ }
1259
+
730
1260
  const legs: ProofKind[] = ["compile-valid"];
731
1261
  if (standalone !== null) legs.push("standalone-provable");
732
1262
  if (childBirthWithSetup !== undefined) legs.push("child-birth-provable");
1263
+ if (relationBootstrap !== undefined) legs.push("relation-bootstrap-provable");
733
1264
 
734
1265
  return {
735
1266
  domain,
@@ -746,6 +1277,7 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
746
1277
  }
747
1278
  : {}),
748
1279
  ...(childBirthWithSetup !== undefined ? { childBirth: childBirthWithSetup } : {}),
1280
+ ...(relationBootstrap !== undefined ? { relationBootstrap } : {}),
749
1281
  };
750
1282
  }
751
1283
 
@@ -1090,6 +1622,11 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
1090
1622
  // throws MissingOfferAuthority. `undefined` ⇒ a documented no-op (the domain authors openly).
1091
1623
  const conformance = discoverAuthorityConformanceLeg(mod);
1092
1624
 
1625
+ // THE RELATION-BOOTSTRAP LEG (the seed-then-prove positive): grant relation R to the proof actor, then prove
1626
+ // a `.requires(R)`-gated write SUCCEEDS under that actor. `undefined` ⇒ no gated directive or no discoverable
1627
+ // granter (the negative conformance leg still runs). Comes from the SAME `legsForDomain` synthesis (marker).
1628
+ const relationBootstrap = primaryLegs.relationBootstrap;
1629
+
1093
1630
  // Workspace-name slug: any package name → a safe, recognizable ws prefix.
1094
1631
  const slug =
1095
1632
  opts.packageName
@@ -1281,6 +1818,12 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
1281
1818
  // offer is impossible to GENERATE (throws MissingOfferAuthority before any submit). No-op when un-gated.
1282
1819
  out.push(...authorityConformanceLegLines(conformance, factory, n));
1283
1820
 
1821
+ // THE RELATION-BOOTSTRAP LEG (seed-then-prove the POSITIVE): grant relation R (or a rewrite base of it) to
1822
+ // the proof actor, then prove the gated write SUCCEEDS under that actor. Runs on the LOCAL holon (fetch
1823
+ // re-trapped). When no granter is discoverable but a gated directive exists, emits a CLEAR diagnostic naming
1824
+ // exactly what's missing (the negative conformance leg still covers the gate).
1825
+ out.push(...relationSeedLegLines(relationBootstrap, factory, n, relationBootstrap === undefined ? diagnoseRelationBootstrapGap(mod) : undefined));
1826
+
1284
1827
  // THE CHILD-BIRTH LEG (G3, gap 2): when the primary domain has a `.births()` directive, prove the birth
1285
1828
  // END-TO-END against the cloud — drive the parent birth offer, then read the BORN child workspace it spawns.
1286
1829
  if (childBirth !== undefined) {
@@ -78,6 +78,7 @@ import {
78
78
  sha256HexUtf8,
79
79
  type Mod,
80
80
  } from "./build_package.js";
81
+ import { synthesizePreconditionInvariants } from "./precondition.js";
81
82
  import { collectEngineRouting } from "./engine_entry.js";
82
83
  import {
83
84
  discover,
@@ -672,10 +673,18 @@ async function main(): Promise<void> {
672
673
  ...discover<CombinedDecl>(merged, isCombinedDecl),
673
674
  ...resolveNamed<CombinedDecl>(merged, d.combineds, "combineds"),
674
675
  ];
676
+ // Hand-authored invariants ONLY — the DESUGARED write-path preconditions are synthesized INSIDE
677
+ // `composeDomainModule` (the one owner of directive discovery), so passing them here too would
678
+ // double-emit. They are recomposed below purely for the openapi self-describe decls.
675
679
  const workspaceInvariants = [
676
680
  ...discover<WorkspaceInvariantDecl>(merged, isWorkspaceInvariantDecl),
677
681
  ...resolveNamed<WorkspaceInvariantDecl>(merged, d.workspaceInvariants, "workspaceInvariants"),
678
682
  ];
683
+ // The FULL invariant set (hand-authored ∪ synthesized preconditions) — for the openapi decls only.
684
+ const workspaceInvariantsForDecls = [
685
+ ...workspaceInvariants,
686
+ ...directivesOf(merged).flatMap((dir) => synthesizePreconditionInvariants(dir)),
687
+ ];
679
688
  // Native Zanzibar relation schema (P1) — auto-discovered by brand, like workspaceInvariants.
680
689
  const relationTypes = [
681
690
  ...discover<RelationTypeDecl>(merged, isRelationTypeDecl),
@@ -782,7 +791,7 @@ async function main(): Promise<void> {
782
791
  if (mins.length > 0) declsForDomain.mins = mins.map((e) => finishExtremum(e));
783
792
  if (maxes.length > 0) declsForDomain.maxes = maxes.map((e) => finishExtremum(e));
784
793
  if (spatials.length > 0) declsForDomain.spatials = spatials.map((s) => ({ id: s.id, of: s.of, on: s.on }));
785
- if (workspaceInvariants.length > 0) declsForDomain.workspaceInvariants = workspaceInvariants.map((w) => ({ id: w.id, on: w.on }));
794
+ if (workspaceInvariantsForDecls.length > 0) declsForDomain.workspaceInvariants = workspaceInvariantsForDecls.map((w) => ({ id: w.id, on: w.on }));
786
795
  if (Object.keys(declsForDomain).length > 0) openapiDeclsByDomain[d.key] = declsForDomain;
787
796
  }
788
797
 
package/src/directive.ts CHANGED
@@ -16,7 +16,8 @@
16
16
  * contract §1 (creates/mutates/ensures/archives).
17
17
  */
18
18
  import type { z } from "zod";
19
- import type { AggregateHandle } from "./aggregate.js";
19
+ import type { AggregateHandle, ReadModelRow } from "./aggregate.js";
20
+ import type { Field } from "./fields.js";
20
21
  import type { PlannedOp } from "./ops.js";
21
22
  import type { Ports } from "./ctx.js";
22
23
  import type { CertifiedReadDecl } from "./certified_read.js";
@@ -25,6 +26,37 @@ import type { QueryDecl } from "./query.js";
25
26
 
26
27
  export type ReferentialMarker = "creates" | "mutates" | "ensures" | "archives";
27
28
 
29
+ /**
30
+ * A DECLARATIVE WRITE-PATH PRECONDITION (the precondition-primitives arc). One descriptor per
31
+ * `.requiresExisting` / `.creates(Agg).payload(...).withId(idFn)` create-once / `.where` marker on a directive. Each
32
+ * desugars — at `composeDomainModule` — to the SAME mechanism: a synthesized `workspaceInvariant`
33
+ * whose `.reads` resolves the ONE by-id ref (`ref(<Agg>, idFn(payload))`) and whose `.assert`
34
+ * evaluates `predicate(row)` over the resolved (possibly-absent → `undefined`) snapshot, yielding a
35
+ * typed refusal (`rejectCode`) at the ONE gate on EVERY lane (author / edge admission / verify_chain).
36
+ * NO kernel/wasm change — it rides the live #266 workspace-invariant + ref-resolution lane (an absent
37
+ * ref resolves to an empty/undefined snapshot, NEVER a refusal — proven by the framework's own
38
+ * `homeNameBindsOwner`, which reads `owner ?? {}`).
39
+ *
40
+ * `kind` fixes the predicate for the two specializations so ONE desugaring path serves all three:
41
+ * - `exists` — refuse when the row is ABSENT (`requiresExisting`).
42
+ * - `absent` — refuse when the row is PRESENT (`.creates(Agg).payload(...).withId(idFn)` create-once).
43
+ * - `predicate` — refuse when the user `predicate(row)` is false (`.where`).
44
+ * The `predicate` is stored for ALL kinds (the specializations synthesize a fixed presence predicate),
45
+ * so the desugaring reads one field. `idField` is the pure payload→instance-id extractor.
46
+ */
47
+ export interface PreconditionDescriptor {
48
+ /** The guarded aggregate's TYPE id (the ref type the synthesized invariant resolves). */
49
+ readonly aggregateId: string;
50
+ /** Pure payload → target instance id (no clock, no IO — re-derived identically on every peer). */
51
+ readonly idField: (payload: unknown) => string;
52
+ /** The fixed-predicate specialization, or `predicate` for the general `.where` form. */
53
+ readonly kind: "exists" | "absent" | "predicate";
54
+ /** The row predicate — `row` is the resolved snapshot or `undefined` when the instance is absent. */
55
+ readonly predicate: (row: Record<string, unknown> | undefined) => boolean;
56
+ /** The typed refusal code emitted when the predicate is false. */
57
+ readonly rejectCode: string;
58
+ }
59
+
28
60
  /**
29
61
  * A pure, replay-stable derivation of the authz SCOPE an authored intent targets,
30
62
  * from its payload — e.g. `(p) => ["site", p.siteId]`. It MUST be a pure function
@@ -175,6 +207,17 @@ export interface Directive<P = unknown> {
175
207
  * `attestationAuthorizedGate`/`.attestationAuthorized()` naming convention; the declaration is the noun.)
176
208
  */
177
209
  readonly birthsChild?: boolean;
210
+ /**
211
+ * PROOF-TOOLING PRODUCER MARKER (relation-seed leg). Declares that this directive ESTABLISHES the named
212
+ * relation(s) — i.e. its `plan` folds a `writeTuple` that grants the relation. Purely a static hint for
213
+ * the proof generator's relation-bootstrap leg (`discoverRelationBootstrapLeg`): it disambiguates which
214
+ * sibling directive to run FIRST to seed the actor before proving a `.requires(R)`-gated write succeeds,
215
+ * and resolves the "who grants the granter" / multiple-candidate ambiguity that pure plan-probe inference
216
+ * cannot. NO kernel/manifest effect (it is NOT lowered into any era key — the gate is the `writeTuple` the
217
+ * plan already folds); a directive that never calls `.grants()` is byte-identical to before. Set via the
218
+ * chainable `.grants(relation)` (symmetric to `.requires`).
219
+ */
220
+ readonly grantsRelation?: string[];
178
221
  /**
179
222
  * The directive's DECLARED required HOST CAPABILITIES — the capability PORTS the HOST must
180
223
  * PROVIDE for this directive to run (`OFFER_KERNEL_ARCHITECTURE.dot` cluster_ports / `OFFER_FLOW.dot`
@@ -190,6 +233,24 @@ export interface Directive<P = unknown> {
190
233
  * manifest is byte-identical to before this existed (the pinned #136 hashes stay UNCHANGED).
191
234
  */
192
235
  readonly declaredHostCapabilities: string[];
236
+ /**
237
+ * DECLARATIVE WRITE-PATH PRECONDITIONS (the precondition-primitives arc) — the `.requiresExisting`
238
+ * / `.creates(Agg).payload(...).withId(idFn)` create-once / `.where` markers, each a {@link PreconditionDescriptor}.
239
+ * DESUGARED at `composeDomainModule` into synthesized `workspaceInvariant`s (one per descriptor) that
240
+ * ride the existing #266 lane — so downstream (manifest, USD IR, `nomosInvariantGate` era key, engine
241
+ * bundle) treats them IDENTICALLY to a hand-authored `workspaceInvariant`. Purely additive +
242
+ * OMITTED ENTIRELY when empty, so a directive using none is byte-identical in the canonical manifest
243
+ * to before this existed (the markers only emit the already-existing invariant era key, when used).
244
+ */
245
+ readonly preconditions?: PreconditionDescriptor[];
246
+ /**
247
+ * CALLER-SUPPLIED-ID CREATE-ONCE (`.creates(Agg)….withId(idFn)`). When set, the directive `creates` the
248
+ * aggregate at an APP-SUPPLIED stable id (`idFn(payload)`, like `.ensures`) rather than a minted id,
249
+ * guarded by an `absent` precondition (a duplicate id is a typed `"<agg>-exists"` refusal). Carries the
250
+ * extractor so codegen/authoring knows the id is caller-supplied. OMITTED for the minted `.creates(Agg)`
251
+ * path (byte-identical). The marker stays `"creates"`.
252
+ */
253
+ readonly createOnceIdField?: (payload: unknown) => string;
193
254
  }
194
255
 
195
256
  /**
@@ -301,55 +362,162 @@ export interface RequirableDirective<P> extends Directive<P> {
301
362
  * the canonical manifest to before this existed (the era rule). Returns a NEW requirable directive.
302
363
  */
303
364
  births(): RequirableDirective<P>;
365
+ /**
366
+ * PROOF-TOOLING PRODUCER MARKER (symmetric to `.requires`): declare that this directive ESTABLISHES the
367
+ * named relation (its `plan` folds a `writeTuple` granting it). A pure proof-generator hint — NO kernel /
368
+ * manifest / era-key effect. It tells the relation-bootstrap proof leg which sibling to run FIRST to seed
369
+ * the actor before proving a `.requires(relation)`-gated write succeeds, and resolves inference ambiguity.
370
+ * Accumulates + dedups; additive + non-destructive. A directive that never calls this is byte-identical.
371
+ */
372
+ grants(relation: string): RequirableDirective<P>;
304
373
  }
305
374
 
306
375
  /** Stage 1: id + a referential marker (which fixes the target aggregate). */
307
376
  class DirectiveMarkerStep<Id extends string> {
308
377
  constructor(private readonly id: Id) {}
378
+ /**
379
+ * `creates(Agg)` — Nomos MINTS the id (byte-identical to before this arg existed). For CREATE-ONCE at an
380
+ * APP-SUPPLIED stable id, chain `.payload(...).withId(p => p.id)` on the plan step (typed against the
381
+ * inferred payload) — the id form lives POST-`.payload` so the extractor is fully payload-typed.
382
+ */
309
383
  creates(agg: AggregateHandle): DirectivePayloadStep<Id> {
310
- return new DirectivePayloadStep(this.id, "creates", agg.id);
384
+ return new DirectivePayloadStep(this.id, "creates", agg);
311
385
  }
312
386
  mutates(agg: AggregateHandle): DirectivePayloadStep<Id> {
313
- return new DirectivePayloadStep(this.id, "mutates", agg.id);
387
+ return new DirectivePayloadStep(this.id, "mutates", agg);
314
388
  }
315
389
  ensures(agg: AggregateHandle): DirectivePayloadStep<Id> {
316
- return new DirectivePayloadStep(this.id, "ensures", agg.id);
390
+ return new DirectivePayloadStep(this.id, "ensures", agg);
317
391
  }
318
392
  archives(agg: AggregateHandle): DirectivePayloadStep<Id> {
319
- return new DirectivePayloadStep(this.id, "archives", agg.id);
393
+ return new DirectivePayloadStep(this.id, "archives", agg);
320
394
  }
321
395
  }
322
396
 
323
- /** Stage 2: attach a Zod payload schema (drives `plan`'s payload type). */
397
+ /** Stage 2: attach a Zod payload schema (drives `plan`'s payload type). Carries the marker's aggregate
398
+ * HANDLE forward so the plan step's create-once `.withId` knows its target. */
324
399
  class DirectivePayloadStep<Id extends string> {
325
400
  constructor(
326
401
  private readonly id: Id,
327
402
  private readonly marker: ReferentialMarker,
328
- private readonly aggregateId: string,
403
+ private readonly aggregate: AggregateHandle,
329
404
  ) {}
330
405
  payload<S extends z.ZodTypeAny>(schema: S): DirectivePlanStep<Id, z.infer<S>> {
331
406
  return new DirectivePlanStep(
332
407
  this.id,
333
408
  this.marker,
334
- this.aggregateId,
409
+ this.aggregate,
335
410
  schema as z.ZodType<z.infer<S>>,
336
411
  );
337
412
  }
338
413
  }
339
414
 
340
- /** Stage 3: define `plan`, producing the finished Directive. */
415
+ /**
416
+ * Stage 3: declare write-path PRECONDITIONS (post-`.payload`, so the payload type `P` is BOUND and every
417
+ * extractor/predicate is fully typed) and define `.plan`, producing the finished Directive. The markers
418
+ * live HERE — `directive(id).mutates(Agg).payload(schema).requiresExisting(Agg, p => p.siteId).plan(...)`
419
+ * — so `p` is the inferred payload (a typo like `p.siteid` is a COMPILE error) and `.where`'s row is the
420
+ * aggregate's typed read model (`row.status` is known, a wrong field a compile error). Each accumulates a
421
+ * {@link PreconditionDescriptor} that threads into the finished directive; `.plan` is the terminal.
422
+ */
341
423
  class DirectivePlanStep<Id extends string, P> {
342
424
  constructor(
343
425
  private readonly id: Id,
344
426
  private readonly marker: ReferentialMarker,
345
- private readonly aggregateId: string,
427
+ private readonly aggregate: AggregateHandle,
346
428
  private readonly payloadSchema: z.ZodType<P>,
429
+ private readonly createOnceIdField?: (payload: unknown) => string,
430
+ private readonly preconditions: PreconditionDescriptor[] = [],
347
431
  ) {}
432
+
433
+ /** Non-destructive copy carrying one more precondition (and/or the create-once id field). */
434
+ private with(
435
+ desc?: PreconditionDescriptor,
436
+ createOnceIdField?: (payload: unknown) => string,
437
+ ): DirectivePlanStep<Id, P> {
438
+ return new DirectivePlanStep(
439
+ this.id,
440
+ this.marker,
441
+ this.aggregate,
442
+ this.payloadSchema,
443
+ createOnceIdField ?? this.createOnceIdField,
444
+ desc !== undefined ? [...this.preconditions, desc] : this.preconditions,
445
+ );
446
+ }
447
+
448
+ /**
449
+ * CREATE-ONCE at an APP-SUPPLIED stable id (`.creates(Agg).payload(...).withId(p => p.siteId)`). The id
450
+ * comes from `idFn(payload)` (like `.ensures`) instead of a minted id, and a duplicate id is a typed
451
+ * `"<agg>-exists"` refusal (an auto-desugared `absent` precondition). `idFn` is a PURE, fully-typed
452
+ * function of the inferred payload. ONLY valid on a `.creates` directive (it defines the created id).
453
+ * Additive; `.creates(Agg)` with no `.withId` is the minted path (byte-identical).
454
+ */
455
+ withId(idFn: (payload: P) => string): DirectivePlanStep<Id, P> {
456
+ if (this.marker !== "creates") {
457
+ throw new Error(
458
+ `directive('${this.id}').withId(...) is only valid on a .creates(...) directive — it fixes the ` +
459
+ `CREATED aggregate's caller-supplied id (a .${this.marker} directive targets an existing id).`,
460
+ );
461
+ }
462
+ const idField = idFn as (payload: unknown) => string;
463
+ return this.with(
464
+ {
465
+ aggregateId: this.aggregate.id,
466
+ idField,
467
+ kind: "absent",
468
+ predicate: (row) => row === undefined,
469
+ rejectCode: `${shortName(this.aggregate.id)}-exists`,
470
+ },
471
+ idField,
472
+ );
473
+ }
474
+
475
+ /**
476
+ * DECLARATIVE WRITE-PATH PRECONDITION — refuse if the target aggregate instance is ABSENT. Sugar for
477
+ * `.where(Agg, idFn, row => row !== undefined, "<agg>-not-found")` (the fixed `exists` specialization of
478
+ * the ONE desugaring path). `idFn` is a PURE, fully-typed payload → instance id. Additive.
479
+ */
480
+ requiresExisting<F extends Record<string, Field>>(
481
+ aggregate: AggregateHandle<string, F>,
482
+ idFn: (payload: P) => string,
483
+ rejectCode?: string,
484
+ ): DirectivePlanStep<Id, P> {
485
+ return this.with({
486
+ aggregateId: aggregate.id,
487
+ idField: idFn as (payload: unknown) => string,
488
+ kind: "exists",
489
+ predicate: (row) => row !== undefined,
490
+ rejectCode: rejectCode ?? `${shortName(aggregate.id)}-not-found`,
491
+ });
492
+ }
493
+
494
+ /**
495
+ * DECLARATIVE WRITE-PATH PRECONDITION — the GENERAL form: refuse if `predicate(row)` over the target's
496
+ * CURRENT (pre-apply) row is false. `row` is the aggregate's TYPED read model ({@link ReadModelRow} —
497
+ * one property per stored field at its decoded type, all optional) or `undefined` when the instance is
498
+ * ABSENT (so the predicate decides absence semantics too). `rejectCode` is the typed refusal. Callable
499
+ * multiple times to accumulate distinct guards.
500
+ */
501
+ where<F extends Record<string, Field>>(
502
+ aggregate: AggregateHandle<string, F>,
503
+ idFn: (payload: P) => string,
504
+ predicate: (row: ReadModelRow<F> | undefined) => boolean,
505
+ rejectCode: string,
506
+ ): DirectivePlanStep<Id, P> {
507
+ return this.with({
508
+ aggregateId: aggregate.id,
509
+ idField: idFn as (payload: unknown) => string,
510
+ kind: "predicate",
511
+ predicate: predicate as (row: Record<string, unknown> | undefined) => boolean,
512
+ rejectCode,
513
+ });
514
+ }
515
+
348
516
  plan(fn: (payload: P, ctx: Ports) => PlannedOp[]): RequirableDirective<P> {
349
517
  return makeRequirable({
350
518
  id: this.id,
351
519
  marker: this.marker,
352
- aggregateId: this.aggregateId,
520
+ aggregateId: this.aggregate.id,
353
521
  payloadSchema: this.payloadSchema,
354
522
  plan: fn,
355
523
  declaredReads: [],
@@ -358,10 +526,27 @@ class DirectivePlanStep<Id extends string, P> {
358
526
  declaredCertifiedReads: {},
359
527
  declaredRelations: [],
360
528
  declaredHostCapabilities: [],
529
+ // `.withId(...)` — record the caller-supplied-id extractor (codegen/authoring hint). The `absent`
530
+ // create-once guard is already in `preconditions` (seeded by `.withId`). Omit-when-absent.
531
+ ...(this.createOnceIdField !== undefined ? { createOnceIdField: this.createOnceIdField } : {}),
532
+ // Omit-when-empty so a directive with no precondition is byte-identical (the era rule).
533
+ ...(this.preconditions.length > 0 ? { preconditions: this.preconditions } : {}),
361
534
  });
362
535
  }
363
536
  }
364
537
 
538
+ /**
539
+ * Short, lower-camel name for an aggregate id — the reject-code stem. `EstateSite` → `estateSite`;
540
+ * `inventory.Item` → `item` (the SAME derivation the workspace-invariant ref-name uses). Used to build
541
+ * the default reject codes `"<agg>-not-found"` / `"<agg>-exists"`.
542
+ */
543
+ function shortName(aggregateId: string): string {
544
+ const short = aggregateId.includes(".")
545
+ ? aggregateId.slice(aggregateId.lastIndexOf(".") + 1)
546
+ : aggregateId;
547
+ return short.length > 0 ? short[0]!.toLowerCase() + short.slice(1) : short;
548
+ }
549
+
365
550
  /**
366
551
  * Build a PER-RECORD authz object template (home_holons §6) for `.requires(rel, { objectFrom })`. The
367
552
  * gate then checks the relation on `<Agg>:<validated_payload[idField]>` — the specific record this
@@ -492,6 +677,13 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
492
677
  // the birth after admit. Era-ruled: a non-birthing directive omits the flag and is byte-identical.
493
678
  return makeRequirable({ ...base, birthsChild: true });
494
679
  },
680
+ grants(relation: string): RequirableDirective<P> {
681
+ // PROOF-TOOLING PRODUCER MARKER (symmetric to `.requires`): record that this directive establishes
682
+ // `relation` (its plan folds a granting `writeTuple`). NO kernel/manifest effect — a pure static hint
683
+ // the proof generator's relation-bootstrap leg reads. Accumulate + dedup; additive + non-destructive.
684
+ const merged = [...new Set([...(base.grantsRelation ?? []), relation])].sort();
685
+ return makeRequirable({ ...base, grantsRelation: merged });
686
+ },
495
687
  };
496
688
  }
497
689
 
@@ -54,6 +54,7 @@
54
54
  import { executeDirectiveToIntent } from "./wire_encode.js";
55
55
  import { expandCapabilityExports } from "./capability_exports.js";
56
56
  import { RelationTuple } from "./framework/relations.js";
57
+ import { synthesizePreconditionInvariants } from "./precondition.js";
57
58
  import { generateOpenApiFragment, mergeOpenApiFragments, type OpenApiDoc } from "./codegen_openapi.js";
58
59
  import type { QueryDecl } from "./query.js";
59
60
  import type { AnyCount } from "./count.js";
@@ -360,6 +361,14 @@ function buildDomainSlice(mod: DomainModuleExports): DomainSlice {
360
361
  wsInvariants.set(w.id, w);
361
362
  }
362
363
  }
364
+ // ── DESUGARED write-path preconditions (`.requiresExisting`/`.creates(Agg,idFn)`/`.where`) ──
365
+ // The synthesized workspace invariants are NOT module exports (they are generated from the directive
366
+ // markers), so the export-scan above never sees them. Synthesize them here — from the SAME discovered
367
+ // directives — so the SEALED ENGINE registers their `reads`/`assert` bodies and the gate actually
368
+ // enforces them on every lane. Byte-identical for a directive with no precondition (synthesizes none).
369
+ for (const directive of dirs.values()) {
370
+ for (const w of synthesizePreconditionInvariants(directive)) wsInvariants.set(w.id, w);
371
+ }
363
372
 
364
373
  return { registry, deriveds, combineds, invariants, aggInvariants, wsInvariants };
365
374
  }
package/src/index.ts CHANGED
@@ -75,7 +75,13 @@ export {
75
75
  type PlannedEventMarker,
76
76
  type StrikeOp,
77
77
  } from "./ops.js";
78
- export { directive, recordObject, type Directive, type ReferentialMarker } from "./directive.js";
78
+ export {
79
+ directive,
80
+ recordObject,
81
+ type Directive,
82
+ type ReferentialMarker,
83
+ type PreconditionDescriptor,
84
+ } from "./directive.js";
79
85
  // FIRST-CLASS WORKSPACE TYPES (sharding §10 RATIFIED, slice 1) live on the SUBPATH
80
86
  // `@githolon/dsl/workspace-type` (`workspaceType("estate").root(Estate).hasMany(...)`),
81
87
  // NOT this runtime barrel — the barrel is bundled into EVERY tenant's engine lump, and
@@ -0,0 +1,92 @@
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
4
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /**
9
+ * precondition — the DESUGARING of the write-path precondition primitives.
10
+ *
11
+ * The three directive markers (`.requiresExisting` / `.creates(Agg).payload(...).withId(idFn)` create-once / `.where`)
12
+ * each record a {@link PreconditionDescriptor}. Here — at `composeDomainModule`, the ONE place a
13
+ * directive's descriptors and the domain's `workspaceInvariants` list co-exist — every descriptor is
14
+ * SYNTHESIZED into a {@link WorkspaceInvariantDecl} (LANE B — the ref-by-id lane) and appended to the
15
+ * same array a hand-authored `workspaceInvariant(...)` occupies. Downstream (manifest, USD IR, the
16
+ * `nomosInvariantGate` era key, the engine bundle, openapi) then treats them IDENTICALLY — the marker
17
+ * is pure sugar over the live #266 workspace-invariant lane.
18
+ *
19
+ * WHY LANE B (ref-by-id), not the captured-read lane (A):
20
+ * - The invariant resolves ONE instance by its INSTANCE id via `ref(Agg, idFn(payload))`. An absent
21
+ * ref resolves to an empty/undefined snapshot with NO refusal on ANY lane — PROVEN by the
22
+ * framework's own `homeNameBindsOwner` (`.reads([refAs("owner", "HomeOwner", id)])` + `owner ?? {}`),
23
+ * which runs BEFORE the singleton exists, including at `verify_chain`. Every invariant body in the
24
+ * codebase guards `snaps[name] ?? {}`, so absent-resolves-empty is the established contract.
25
+ * - Lane A (an auto-minted `query("<Agg>ById").key("id")`) is NOT expressible: a query index key MUST
26
+ * be a DECLARED aggregate FIELD (see `query.ts`), and an aggregate's INSTANCE id is not a field — so
27
+ * there is no field to index a by-instance-id read on. Ref-by-id resolves by instance id directly.
28
+ *
29
+ * The synthesized invariant is a plain branded `WorkspaceInvariantDecl` (bodies ship in the engine
30
+ * bundle, PRESENCE-only is hashed — id + the directive it is `on`). Its id is STABLE + deterministic
31
+ * (`<directiveId>__precondition__<Agg>__<kind>[#n]`), so recompiles are byte-stable.
32
+ */
33
+ import type { Directive, PreconditionDescriptor } from "./directive.js";
34
+ import {
35
+ refAs,
36
+ type InvariantRef,
37
+ type WorkspaceInvariantDecl,
38
+ } from "./framework/workspace_invariant.js";
39
+
40
+ /** The stable ref binding name the synthesized invariant resolves the target under. */
41
+ const REF_NAME = "target";
42
+
43
+ /**
44
+ * Deterministic id for a synthesized precondition invariant. Stable across compiles (a pure function of
45
+ * the directive id, aggregate id, kind, and — only when a directive declares MORE THAN ONE guard of the
46
+ * same (agg, kind) — a disambiguating index). The `#n` suffix is OMITTED for the first/only such guard so
47
+ * the common single-guard case stays terse and stable.
48
+ */
49
+ export function preconditionInvariantId(
50
+ directiveId: string,
51
+ desc: PreconditionDescriptor,
52
+ ordinal: number,
53
+ ): string {
54
+ const base = `${directiveId}__precondition__${desc.aggregateId}__${desc.kind}`;
55
+ return ordinal === 0 ? base : `${base}#${ordinal}`;
56
+ }
57
+
58
+ /**
59
+ * Synthesize the `WorkspaceInvariantDecl`s for ONE directive's precondition descriptors (LANE B). Returns
60
+ * `[]` for a directive with none (the era rule — no invariant emitted, byte-identical). The invariant:
61
+ * - `.on(directiveId)` — fires for this directive.
62
+ * - `.reads` — resolves the ONE by-id ref `refAs(REF_NAME, <Agg>, idFn(payload))`.
63
+ * - `.assert` — evaluates the descriptor's `predicate` over the resolved snapshot; an ABSENT
64
+ * instance passes `undefined` (the row is `snapshots[REF_NAME]`, which the kernel
65
+ * omits when the ref does not resolve). True ⇒ `{accept}`; false ⇒ the reject code.
66
+ */
67
+ export function synthesizePreconditionInvariants<P>(directive: Directive<P>): WorkspaceInvariantDecl[] {
68
+ const descs = directive.preconditions ?? [];
69
+ if (descs.length === 0) return [];
70
+ // Disambiguate repeated (agg, kind) guards by a per-(agg,kind) ordinal so ids stay stable + unique.
71
+ const seen = new Map<string, number>();
72
+ return descs.map((desc) => {
73
+ const key = `${desc.aggregateId}__${desc.kind}`;
74
+ const ordinal = seen.get(key) ?? 0;
75
+ seen.set(key, ordinal + 1);
76
+ const id = preconditionInvariantId(directive.id, desc, ordinal);
77
+ return {
78
+ id,
79
+ on: directive.id,
80
+ reads: (ctx: { intent: Record<string, unknown> }): InvariantRef[] => [
81
+ refAs(REF_NAME, desc.aggregateId, desc.idField(ctx.intent)),
82
+ ],
83
+ assert: (snapshots: Record<string, Record<string, unknown>>) => {
84
+ // The kernel resolves the ref into `snapshots[REF_NAME]`; an ABSENT instance leaves the key
85
+ // undefined (proven safe — `homeNameBindsOwner`), so `undefined` is the "row does not exist" signal.
86
+ const row = snapshots[REF_NAME] as Record<string, unknown> | undefined;
87
+ return desc.predicate(row) ? { accept: true } : { reject: desc.rejectCode };
88
+ },
89
+ __isWorkspaceInvariant: true as const,
90
+ };
91
+ });
92
+ }