@githolon/dsl 0.28.0 → 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.28.0",
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
@@ -41,6 +41,7 @@ import type { Directive } from "./directive.js";
41
41
  import type { DomainModule } from "./codegen_dart.js";
42
42
  import type { QueryDecl } from "./query.js";
43
43
  import type { FieldOp } from "./ops.js";
44
+ import type { RelationTypeDecl, Rewrite } from "./relation_schema.js";
44
45
  import { finishCount } from "./count.js";
45
46
  import { mintedCreateField } from "./workspace_routing.js";
46
47
  import { deterministicPorts } from "./ctx.js";
@@ -348,15 +349,26 @@ function relationSeedLegLines(
348
349
  leg: RelationBootstrapLeg | undefined,
349
350
  factory: string,
350
351
  n: () => number,
352
+ diagnostic?: string,
351
353
  ): string[] {
352
- if (leg === undefined) return [];
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
+ }
353
364
  const ACTOR = `"user:proof-conformance"`;
354
- // Grant payload: overwrite the routed subject/grantedBy fields (and any name-shaped fallback) with the actor.
365
+ const speculative = leg.seedSpeculative === true;
366
+ // Grant/seed payload: overwrite the routed subject/grantedBy fields (and any name-shaped fallback) with the actor.
355
367
  const grantPayloadLit = (() => {
356
368
  const overwrite = new Set<string>([...leg.grantSubjectFields, ...leg.grantGrantedByFields]);
357
369
  const entries = Object.entries(leg.grantPayload).map(([k, v]) => {
358
370
  // 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);
371
+ const looksSubject = /^(subject|principal|grantedBy|grantee|member|user|owner)$/i.test(k);
360
372
  return overwrite.has(k) || looksSubject
361
373
  ? `${JSON.stringify(k)}: ${ACTOR}`
362
374
  : `${JSON.stringify(k)}: ${JSON.stringify(v)}`;
@@ -366,9 +378,54 @@ function relationSeedLegLines(
366
378
  const gatedPayloadLit = leg.gatedMintField !== undefined && leg.gatedAggregateId !== undefined
367
379
  ? `{ ...${JSON.stringify(leg.gatedPayload)}, ${JSON.stringify(leg.gatedMintField)}: await holon.mint(${JSON.stringify(leg.gatedAggregateId)}) }`
368
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
+
369
426
  return [
370
427
  `// ${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}).`,
428
+ `// (\`.requires("${leg.relation}")\`). \`${leg.domain}/${leg.grantDirectiveId}\` GRANTS "${leg.grantedRelation}" (${leg.via})${computedNote}.`,
372
429
  `// Grant it to the proof actor, THEN prove the gated write SUCCEEDS under that actor (what the negative`,
373
430
  `// conformance leg above can't cover — a seeded holder authoring a gated write).`,
374
431
  `{`,
@@ -376,10 +433,10 @@ function relationSeedLegLines(
376
433
  ` globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: relation-bootstrap touched the network"); }) as typeof fetch;`,
377
434
  ` try {`,
378
435
  ` const ACTOR = ${ACTOR};`,
379
- ` // (1) grant "${leg.relation}" to ACTOR (the grant folds a writeTuple; subject seated on ACTOR).`,
436
+ ` // (1) grant "${leg.grantedRelation}" to ACTOR (the grant folds a writeTuple; subject seated on ACTOR).`,
380
437
  ` 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}").`,
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}"` : ``}).`,
383
440
  ` await ${factory}(holon, { actor: ACTOR }).${leg.gatedDirectiveId}(${gatedPayloadLit});`,
384
441
  ` ok(\`relation bootstrap (prove) — ${leg.domain}/${leg.gatedDirectiveId} authored under the seeded holder of "${leg.relation}" (gated write SUCCEEDS once granted)\`);`,
385
442
  ` } finally {`,
@@ -406,8 +463,13 @@ export interface RelationBootstrapLeg {
406
463
  readonly domain: string;
407
464
  /** The gated directive id (`.requires(relation)`). */
408
465
  readonly gatedDirectiveId: string;
409
- /** The relation the gate requires (and the grant establishes). */
466
+ /** The relation the GATE requires. */
410
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;
411
473
  /** The synthesized gated-directive payload (auto-stamped `…At` omitted; mintField removed). */
412
474
  readonly gatedPayload: Record<string, unknown>;
413
475
  readonly gatedAutoStamped: readonly string[];
@@ -426,8 +488,21 @@ export interface RelationBootstrapLeg {
426
488
  readonly grantSubjectFields: readonly string[];
427
489
  /** Grant-payload field(s) routed into the tuple `grantedBy` — set to the proof actor (free attribution). */
428
490
  readonly grantGrantedByFields: readonly string[];
429
- /** How the granter was resolved — `"marker"` (`.grants(R)`) or `"inferred"` (plan-probe). Cosmetic. */
430
- readonly via: "marker" | "inferred";
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[];
431
506
  }
432
507
 
433
508
  /** Run a directive's plan with a synthesized+probe payload under birth-probe globals and return the drained
@@ -472,6 +547,19 @@ function probeGrantsRelation(dir: Directive<unknown>, relation: string, where: s
472
547
  return probed.ops.some((o) => o.kind === "set" && o.field === "relation" && o.value === relation);
473
548
  }
474
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
+
475
563
  /** Resolve WHICH grant-payload fields route into the tuple subject / grantedBy — by matching the op values
476
564
  * the plan emitted (the subject `setEntry`'s entryKey, the `grantedBy` leaf) back to synthesized payload
477
565
  * values. Best-effort: an unmatched routing just leaves the field list empty (runtime falls back). */
@@ -503,9 +591,108 @@ function resolveGrantSubjectFields(
503
591
  return { subjectFields: [...subjectFields], grantedByFields: [...grantedByFields] };
504
592
  }
505
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
+
506
689
  /** 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). */
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). */
509
696
  export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstrapLeg | undefined {
510
697
  const domain = mod.domain ?? mod.name;
511
698
  const directives = mod.directives as Directive<unknown>[];
@@ -531,16 +718,57 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
531
718
  const gatedPayload: Record<string, unknown> = { ...gatedSynth.value };
532
719
  if (gatedMintField !== undefined) delete gatedPayload[gatedMintField];
533
720
 
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.
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).
536
726
  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}`));
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
+ }
542
770
  }
543
- if (granter === undefined) return undefined;
771
+ const granter = found.granter;
544
772
 
545
773
  // Synthesize the grant payload + resolve which fields route into subject / grantedBy (so runtime seats the
546
774
  // grant on the proof actor). Best-effort: an un-probable grant plan still emits the leg (runtime overwrites
@@ -559,6 +787,7 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
559
787
  domain,
560
788
  gatedDirectiveId: gated.id,
561
789
  relation,
790
+ grantedRelation,
562
791
  gatedPayload,
563
792
  gatedAutoStamped: gatedSynth.autoStamped,
564
793
  ...(gatedMintField !== undefined ? { gatedMintField, gatedAggregateId: gated.aggregateId } : {}),
@@ -568,9 +797,61 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
568
797
  grantSubjectFields: routed.subjectFields,
569
798
  grantGrantedByFields: routed.grantedByFields,
570
799
  via,
800
+ ...(seedSpeculative ? { seedSpeculative: true } : {}),
801
+ ...(seedWritesRelations !== undefined ? { seedWritesRelations } : {}),
571
802
  };
572
803
  }
573
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
+
574
855
  // ── child-birth discovery (the G3 `.births()` lane — prove a parent birth end-to-end into the child) ──────
575
856
 
576
857
  /** ONE step of a discovered birth recipe's genesis chain (the subset codegen reads). */
@@ -1537,10 +1818,11 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
1537
1818
  // offer is impossible to GENERATE (throws MissingOfferAuthority before any submit). No-op when un-gated.
1538
1819
  out.push(...authorityConformanceLegLines(conformance, factory, n));
1539
1820
 
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));
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));
1544
1826
 
1545
1827
  // THE CHILD-BIRTH LEG (G3, gap 2): when the primary domain has a `.births()` directive, prove the birth
1546
1828
  // END-TO-END against the cloud — drive the parent birth offer, then read the BORN child workspace it spawns.
@@ -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
@@ -201,6 +233,24 @@ export interface Directive<P = unknown> {
201
233
  * manifest is byte-identical to before this existed (the pinned #136 hashes stay UNCHANGED).
202
234
  */
203
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;
204
254
  }
205
255
 
206
256
  /**
@@ -325,50 +375,149 @@ export interface RequirableDirective<P> extends Directive<P> {
325
375
  /** Stage 1: id + a referential marker (which fixes the target aggregate). */
326
376
  class DirectiveMarkerStep<Id extends string> {
327
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
+ */
328
383
  creates(agg: AggregateHandle): DirectivePayloadStep<Id> {
329
- return new DirectivePayloadStep(this.id, "creates", agg.id);
384
+ return new DirectivePayloadStep(this.id, "creates", agg);
330
385
  }
331
386
  mutates(agg: AggregateHandle): DirectivePayloadStep<Id> {
332
- return new DirectivePayloadStep(this.id, "mutates", agg.id);
387
+ return new DirectivePayloadStep(this.id, "mutates", agg);
333
388
  }
334
389
  ensures(agg: AggregateHandle): DirectivePayloadStep<Id> {
335
- return new DirectivePayloadStep(this.id, "ensures", agg.id);
390
+ return new DirectivePayloadStep(this.id, "ensures", agg);
336
391
  }
337
392
  archives(agg: AggregateHandle): DirectivePayloadStep<Id> {
338
- return new DirectivePayloadStep(this.id, "archives", agg.id);
393
+ return new DirectivePayloadStep(this.id, "archives", agg);
339
394
  }
340
395
  }
341
396
 
342
- /** 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. */
343
399
  class DirectivePayloadStep<Id extends string> {
344
400
  constructor(
345
401
  private readonly id: Id,
346
402
  private readonly marker: ReferentialMarker,
347
- private readonly aggregateId: string,
403
+ private readonly aggregate: AggregateHandle,
348
404
  ) {}
349
405
  payload<S extends z.ZodTypeAny>(schema: S): DirectivePlanStep<Id, z.infer<S>> {
350
406
  return new DirectivePlanStep(
351
407
  this.id,
352
408
  this.marker,
353
- this.aggregateId,
409
+ this.aggregate,
354
410
  schema as z.ZodType<z.infer<S>>,
355
411
  );
356
412
  }
357
413
  }
358
414
 
359
- /** 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
+ */
360
423
  class DirectivePlanStep<Id extends string, P> {
361
424
  constructor(
362
425
  private readonly id: Id,
363
426
  private readonly marker: ReferentialMarker,
364
- private readonly aggregateId: string,
427
+ private readonly aggregate: AggregateHandle,
365
428
  private readonly payloadSchema: z.ZodType<P>,
429
+ private readonly createOnceIdField?: (payload: unknown) => string,
430
+ private readonly preconditions: PreconditionDescriptor[] = [],
366
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
+
367
516
  plan(fn: (payload: P, ctx: Ports) => PlannedOp[]): RequirableDirective<P> {
368
517
  return makeRequirable({
369
518
  id: this.id,
370
519
  marker: this.marker,
371
- aggregateId: this.aggregateId,
520
+ aggregateId: this.aggregate.id,
372
521
  payloadSchema: this.payloadSchema,
373
522
  plan: fn,
374
523
  declaredReads: [],
@@ -377,10 +526,27 @@ class DirectivePlanStep<Id extends string, P> {
377
526
  declaredCertifiedReads: {},
378
527
  declaredRelations: [],
379
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 } : {}),
380
534
  });
381
535
  }
382
536
  }
383
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
+
384
550
  /**
385
551
  * Build a PER-RECORD authz object template (home_holons §6) for `.requires(rel, { objectFrom })`. The
386
552
  * gate then checks the relation on `<Agg>:<validated_payload[idField]>` — the specific record this
@@ -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
+ }