@githolon/dsl 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/aggregate.ts +15 -0
- package/src/build_package.ts +6 -0
- package/src/codegen_proof.ts +422 -51
- package/src/compile_package_main.ts +10 -1
- package/src/directive.ts +177 -11
- package/src/engine_entry.ts +9 -0
- package/src/index.ts +7 -1
- package/src/precondition.ts +92 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/aggregate.ts
CHANGED
|
@@ -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>;
|
package/src/build_package.ts
CHANGED
|
@@ -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
|
package/src/codegen_proof.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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)`)
|
|
430
|
-
|
|
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,19 +591,122 @@ 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
|
|
508
|
-
* `
|
|
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>[];
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
)
|
|
699
|
+
const isGatedProvable = (d: Directive<unknown> | undefined): boolean =>
|
|
700
|
+
d?.requiresRelationGate !== undefined &&
|
|
701
|
+
typeof d.payloadSchema === "object" &&
|
|
702
|
+
d.birthsChild !== true &&
|
|
703
|
+
d.attestationAuthorizedGate === undefined;
|
|
704
|
+
// PREFER a gated `.creates` as the bootstrap target: a gated create is exactly the shape excluded from the
|
|
705
|
+
// naive standalone-create leg (isStandaloneCreate), so the bootstrap leg is its ONLY positive proof — seed
|
|
706
|
+
// authority, then prove the CREATE is admitted. When no gated create exists, fall back to any gated write
|
|
707
|
+
// (a gated `.mutates`/`.ensures`), so an un-created gated law is still proven.
|
|
708
|
+
const gated = directives.find((d) => isGatedProvable(d) && d.marker === "creates")
|
|
709
|
+
?? directives.find((d) => isGatedProvable(d));
|
|
519
710
|
if (gated === undefined) return undefined;
|
|
520
711
|
const relation = gated.requiresRelationGate!.relation;
|
|
521
712
|
|
|
@@ -531,16 +722,57 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
|
|
|
531
722
|
const gatedPayload: Record<string, unknown> = { ...gatedSynth.value };
|
|
532
723
|
if (gatedMintField !== undefined) delete gatedPayload[gatedMintField];
|
|
533
724
|
|
|
534
|
-
// Find
|
|
535
|
-
// a
|
|
725
|
+
// Find a granter that establishes the actor's authority for the gate relation. Never the gated directive.
|
|
726
|
+
// (1) DIRECT: a `.grants(R)` marker (unambiguous), ELSE a plan-probed `writeTuple(R)`.
|
|
727
|
+
// (2) COMPUTED-BASE: when R is computed, walk the relation-schema rewrite for the directly-grantable BASE
|
|
728
|
+
// relations B (e.g. gate `estateMember` → `estateOwner`/`admin`), and find a granter for a base —
|
|
729
|
+
// granting B makes the actor satisfy R through the computed chain (what estate's seed does).
|
|
536
730
|
const candidates = directives.filter((d) => d !== gated && typeof d?.payloadSchema === "object" && d.birthsChild !== true);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
731
|
+
const findGranterFor = (rel: string): { granter: Directive<unknown>; via: "marker" | "inferred" } | undefined => {
|
|
732
|
+
const marked = candidates.find((d) => (d.grantsRelation ?? []).includes(rel));
|
|
733
|
+
if (marked !== undefined) return { granter: marked, via: "marker" };
|
|
734
|
+
const inferred = candidates.find((d) => probeGrantsRelation(d, rel, `${domain}/${d.id}`));
|
|
735
|
+
return inferred !== undefined ? { granter: inferred, via: "inferred" } : undefined;
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// (1) direct grant of R.
|
|
739
|
+
let grantedRelation = relation;
|
|
740
|
+
let via: "marker" | "inferred" | "computed-base" | "seed-path" = "marker";
|
|
741
|
+
let seedSpeculative = false;
|
|
742
|
+
let seedWritesRelations: string[] | undefined;
|
|
743
|
+
let found = findGranterFor(relation);
|
|
744
|
+
if (found !== undefined) {
|
|
745
|
+
via = found.via;
|
|
746
|
+
} else {
|
|
747
|
+
// (2) rewrite-aware: R is computed — resolve grantable base relations and find a granter for one.
|
|
748
|
+
const bases = resolveGrantableBaseRelations(mod.relationTypes, gated.requiresRelationGate!.object, relation)
|
|
749
|
+
.filter((b) => b !== relation); // R itself already tried in (1)
|
|
750
|
+
for (const base of bases) {
|
|
751
|
+
const f = findGranterFor(base);
|
|
752
|
+
if (f !== undefined) {
|
|
753
|
+
found = f;
|
|
754
|
+
grantedRelation = base;
|
|
755
|
+
via = "computed-base";
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (found === undefined) {
|
|
760
|
+
// (3) SEED-PATH REPLAY (the general fallback): R is reachable only through ttu/intersection/exclusion —
|
|
761
|
+
// no directly-grantable base for layer 2. Discover a DECLARED seed directive that ESTABLISHES relation
|
|
762
|
+
// authority (its plan writes ≥1 relation tuple) and route its subject to the proof actor. We do NOT
|
|
763
|
+
// claim this authorizes the gated write — the REAL KERNEL GATE decides at runtime (the anti-false-green
|
|
764
|
+
// guard: `seedSpeculative`). Prefer a seed that seats the MOST relations (a genesis/owner seed like
|
|
765
|
+
// estate's, which lands admin/creator/estateOwner together — the broadest root authority).
|
|
766
|
+
const seed = discoverSeedDirective(candidates, domain);
|
|
767
|
+
if (seed === undefined) return undefined; // no seed path → the caller emits the diagnostic
|
|
768
|
+
found = { granter: seed.dir, via: "inferred" };
|
|
769
|
+
grantedRelation = seed.relationsWritten[0] ?? relation; // cosmetic — the seed's primary relation
|
|
770
|
+
via = "seed-path";
|
|
771
|
+
seedSpeculative = true;
|
|
772
|
+
seedWritesRelations = seed.relationsWritten;
|
|
773
|
+
}
|
|
542
774
|
}
|
|
543
|
-
|
|
775
|
+
const granter = found.granter;
|
|
544
776
|
|
|
545
777
|
// Synthesize the grant payload + resolve which fields route into subject / grantedBy (so runtime seats the
|
|
546
778
|
// grant on the proof actor). Best-effort: an un-probable grant plan still emits the leg (runtime overwrites
|
|
@@ -559,6 +791,7 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
|
|
|
559
791
|
domain,
|
|
560
792
|
gatedDirectiveId: gated.id,
|
|
561
793
|
relation,
|
|
794
|
+
grantedRelation,
|
|
562
795
|
gatedPayload,
|
|
563
796
|
gatedAutoStamped: gatedSynth.autoStamped,
|
|
564
797
|
...(gatedMintField !== undefined ? { gatedMintField, gatedAggregateId: gated.aggregateId } : {}),
|
|
@@ -568,9 +801,61 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
|
|
|
568
801
|
grantSubjectFields: routed.subjectFields,
|
|
569
802
|
grantGrantedByFields: routed.grantedByFields,
|
|
570
803
|
via,
|
|
804
|
+
...(seedSpeculative ? { seedSpeculative: true } : {}),
|
|
805
|
+
...(seedWritesRelations !== undefined ? { seedWritesRelations } : {}),
|
|
571
806
|
};
|
|
572
807
|
}
|
|
573
808
|
|
|
809
|
+
/** A discovered layer-3 SEED directive: a sibling (non-gated, non-birth) whose plan writes relation tuples,
|
|
810
|
+
* so replaying it SEATS relation authority the gate's rewrite may consume. Ranked by how many relations it
|
|
811
|
+
* writes (a broad genesis/owner seed outranks a single-relation grant). `undefined` ⇒ no such directive. */
|
|
812
|
+
function discoverSeedDirective(
|
|
813
|
+
candidates: readonly Directive<unknown>[],
|
|
814
|
+
domain: string,
|
|
815
|
+
): { dir: Directive<unknown>; relationsWritten: string[] } | undefined {
|
|
816
|
+
let best: { dir: Directive<unknown>; relationsWritten: string[] } | undefined;
|
|
817
|
+
for (const d of candidates) {
|
|
818
|
+
// Skip a directive that is itself authz-gated — replaying it would need its OWN authority first (a seed
|
|
819
|
+
// must be an ungated genesis/bootstrap the proof can drive under a bare actor).
|
|
820
|
+
if (d.requiresRelationGate !== undefined) continue;
|
|
821
|
+
const written = probeRelationsWritten(d, `${domain}/${d.id}`);
|
|
822
|
+
if (written.length === 0) continue;
|
|
823
|
+
if (best === undefined || written.length > best.relationsWritten.length) {
|
|
824
|
+
best = { dir: d, relationsWritten: written.sort() };
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return best;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* When a module HAS a gated (`.requires(R)`) directive but `discoverRelationBootstrapLeg` finds no granter
|
|
832
|
+
* (neither a direct granter of R nor one for any rewrite BASE of R), explain EXACTLY what is missing so the
|
|
833
|
+
* proof fails usefully rather than silently skipping the positive leg. `undefined` ⇒ nothing to diagnose
|
|
834
|
+
* (no gated directive, or the leg WAS discovered). Never throws.
|
|
835
|
+
*/
|
|
836
|
+
export function diagnoseRelationBootstrapGap(mod: DomainModule): string | undefined {
|
|
837
|
+
const domain = mod.domain ?? mod.name;
|
|
838
|
+
const directives = mod.directives as Directive<unknown>[];
|
|
839
|
+
const gated = directives.find(
|
|
840
|
+
(d) => d?.requiresRelationGate !== undefined && typeof d.payloadSchema === "object"
|
|
841
|
+
&& d.birthsChild !== true && d.attestationAuthorizedGate === undefined,
|
|
842
|
+
);
|
|
843
|
+
if (gated === undefined) return undefined;
|
|
844
|
+
if (discoverRelationBootstrapLeg(mod) !== undefined) return undefined; // resolved — no gap
|
|
845
|
+
const relation = gated.requiresRelationGate!.relation;
|
|
846
|
+
const object = gated.requiresRelationGate!.object;
|
|
847
|
+
const bases = resolveGrantableBaseRelations(mod.relationTypes, object, relation).filter((b) => b !== relation);
|
|
848
|
+
const tried = bases.length > 0
|
|
849
|
+
? `no directive grants "${relation}" directly, nor any of its directly-grantable rewrite bases [${bases.join(", ")}]`
|
|
850
|
+
: (mod.relationTypes ?? []).some((t) => t.type === (object.includes(":") ? object.slice(0, object.indexOf(":")) : object))
|
|
851
|
+
? `"${relation}" on "${object}" is not directly grantable and its rewrite (intersection/exclusion/tuple_to_userset) has no locally-seedable base relation`
|
|
852
|
+
: `no relation schema declares object type "${object}", so "${relation}" cannot be resolved to a grantable base`;
|
|
853
|
+
return `${domain}/${gated.id} is gated by .requires("${relation}") but the proof cannot seed authority: ${tried}, `
|
|
854
|
+
+ `AND no ungated seed/genesis directive writes any relation tuple to replay (layer-3 seed-path found nothing). `
|
|
855
|
+
+ `Add a directive that folds a granting writeTuple for "${relation}" (or a grantable base of it) and mark it .grants(...), `
|
|
856
|
+
+ `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.)`;
|
|
857
|
+
}
|
|
858
|
+
|
|
574
859
|
// ── child-birth discovery (the G3 `.births()` lane — prove a parent birth end-to-end into the child) ──────
|
|
575
860
|
|
|
576
861
|
/** ONE step of a discovered birth recipe's genesis chain (the subset codegen reads). */
|
|
@@ -904,13 +1189,17 @@ export interface ProofLegs {
|
|
|
904
1189
|
* The SINGLE source of the create-leg selection — `generateTsProof` (the .mts marker) and
|
|
905
1190
|
* `proofLegsIndex` (the per-domain index) both call this, so the offline lane proves any domain the
|
|
906
1191
|
* same way the generated proof proves its primary one. */
|
|
907
|
-
/** A `.creates` directive the proof can DRIVE from a synthesized sample offline
|
|
908
|
-
* is the child-birth lane), not ATTESTATION-AUTHORIZED (needs a signed §3 quote no
|
|
909
|
-
* e.g. the home law's `recordShare`)
|
|
910
|
-
*
|
|
1192
|
+
/** A `.creates` directive the proof can DRIVE from a synthesized sample offline WITH NO SEEDED AUTHORITY: not
|
|
1193
|
+
* a `.births()` (a birth is the child-birth lane), not ATTESTATION-AUTHORIZED (needs a signed §3 quote no
|
|
1194
|
+
* synthesizer can produce — e.g. the home law's `recordShare`), and NOT AUTHZ-GATED (`.requires(R)` — a
|
|
1195
|
+
* gated create dispatched with no seeded authority is REFUSED at the gate; its metadata already says it
|
|
1196
|
+
* needs authority, so the naive standalone leg must not touch it — it is proven POSITIVELY by the
|
|
1197
|
+
* relation-bootstrap leg / the born-child genesis instead). Those are NOT standalone-provable offline; the
|
|
1198
|
+
* domain proves via its bootstrap / child-birth / compile-valid legs instead. */
|
|
911
1199
|
function isStandaloneCreate(d: Directive<unknown> | undefined): boolean {
|
|
912
1200
|
return d?.marker === "creates" && typeof d.payloadSchema === "object"
|
|
913
|
-
&& d.birthsChild !== true && d.attestationAuthorizedGate === undefined
|
|
1201
|
+
&& d.birthsChild !== true && d.attestationAuthorizedGate === undefined
|
|
1202
|
+
&& d.requiresRelationGate === undefined;
|
|
914
1203
|
}
|
|
915
1204
|
|
|
916
1205
|
export function proofLegsForModule(mod: DomainModule): ProofLegs | null {
|
|
@@ -1264,19 +1553,94 @@ function generateBirthOnlyProof(
|
|
|
1264
1553
|
return out.join("\n");
|
|
1265
1554
|
}
|
|
1266
1555
|
|
|
1556
|
+
/**
|
|
1557
|
+
* THE BOOTSTRAP-ONLY PROOF (gated-create isolation): a package whose ONLY creating write is AUTHZ-GATED
|
|
1558
|
+
* (`.creates(...).requires(R)` — co2's estate `createSite`). It has no ungated standalone create to dispatch
|
|
1559
|
+
* naively (that would be REFUSED at the gate with no seeded authority) and no `.births()` genesis. So the
|
|
1560
|
+
* proof deploys the law, connects, and drives the RELATION-BOOTSTRAP leg as its primary write: seed authority
|
|
1561
|
+
* (layers 1-3), THEN prove the gated create is ADMITTED — the ordering (seed BEFORE the gated dispatch) is
|
|
1562
|
+
* correct by construction. If authority genuinely can't be seeded, the leg emits the loud diagnostic (never a
|
|
1563
|
+
* false green). The offline runner proves the same leg locally (`githolon proof`); this `.mts` is `--live`.
|
|
1564
|
+
*/
|
|
1565
|
+
function generateBootstrapOnlyProof(
|
|
1566
|
+
mod: DomainModule,
|
|
1567
|
+
opts: TsProofOptions,
|
|
1568
|
+
primaryLegs: ProofLegs,
|
|
1569
|
+
): string {
|
|
1570
|
+
const domain = mod.domain ?? mod.name;
|
|
1571
|
+
const factory = tsClientFactoryName(domain);
|
|
1572
|
+
const hashConst = tsHashConstName(opts.packageName);
|
|
1573
|
+
const rb = primaryLegs.relationBootstrap;
|
|
1574
|
+
if (rb === undefined) {
|
|
1575
|
+
// Only reached defensively — the caller gates on relationBootstrap !== undefined.
|
|
1576
|
+
throw new Error(`domain '${domain}' has a gated create but no relation-bootstrap leg to prove it (no seedable authority)`);
|
|
1577
|
+
}
|
|
1578
|
+
const slug =
|
|
1579
|
+
opts.packageName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24) || "domain";
|
|
1580
|
+
const legsMarker = JSON.stringify(primaryLegs);
|
|
1581
|
+
let step = 0;
|
|
1582
|
+
const n = () => ++step;
|
|
1583
|
+
const out: string[] = [];
|
|
1584
|
+
out.push(
|
|
1585
|
+
`// AUTO-GENERATED by nomos-compile — the RUNNABLE PROOF for package "${opts.packageName}",`,
|
|
1586
|
+
`// synthesized from the domain's OWN law. This package's ONLY creating write is AUTHZ-GATED`,
|
|
1587
|
+
`// (\`${domain}/${rb.gatedDirectiveId}\` .requires("${rb.relation}")), so the proof SEEDS authority first,`,
|
|
1588
|
+
`// then proves the gated create is admitted — never a naive dispatch the gate would refuse. Reshape the`,
|
|
1589
|
+
`// law, recompile, and this proof reshapes itself — no test rewriting. Run it:`,
|
|
1590
|
+
`//`,
|
|
1591
|
+
`// npx githolon proof (offline legs on a local holon)`,
|
|
1592
|
+
`// npx githolon proof --live (or: npx tsx build/${opts.packageName}.proof.mts — the full cloud loop)`,
|
|
1593
|
+
`//`,
|
|
1594
|
+
`// Regenerated on every compile; do not edit.`,
|
|
1595
|
+
`// @nomos-proof-legs ${legsMarker}`,
|
|
1596
|
+
`import { readFileSync } from "node:fs";`,
|
|
1597
|
+
`import { connect, encodeIntentOfferBytes } from "@githolon/client";`,
|
|
1598
|
+
`import { ${factory}, ${hashConst} } from "./${opts.packageName}.client.ts";`,
|
|
1599
|
+
`void connect; void encodeIntentOfferBytes; void ${factory};`,
|
|
1600
|
+
``,
|
|
1601
|
+
`const CLOUD = (process.env.NOMOS_CLOUD || "https://nomos.captainapp.co.uk").replace(/\\/+$/, "");`,
|
|
1602
|
+
`const WS = process.env.NOMOS_WS || ${JSON.stringify(slug + "-proof-")} + Math.random().toString(36).slice(2, 8);`,
|
|
1603
|
+
``,
|
|
1604
|
+
...v2IdentityAndCleanupPreamble(opts, hashConst, n),
|
|
1605
|
+
``,
|
|
1606
|
+
`// ${n()}. connect + bind the GENERATED typed client (the relation-bootstrap leg drives it with an actor)`,
|
|
1607
|
+
`const holon = await connect({ cloud: CLOUD, workspace: WS, clientId: "proof-1" });`,
|
|
1608
|
+
`ok(\`web client connected — ${factory} bound (replica \${holon.replica.slice(0, 6)}…)\`);`,
|
|
1609
|
+
``,
|
|
1610
|
+
// The SAME relation-bootstrap emitter the standalone proof uses — seed authority, then prove the GATED
|
|
1611
|
+
// CREATE is admitted (layers 1-3; a speculative seed-path degrades to a diagnostic, never a false green).
|
|
1612
|
+
...relationSeedLegLines(rb, factory, n),
|
|
1613
|
+
`console.log(\`\\nALL GREEN — generated proof for "${opts.packageName}": deploy → seed authority → gated create ${rb.gatedDirectiveId} admitted (\${WS})\`);`,
|
|
1614
|
+
``,
|
|
1615
|
+
...v2CatchFinally(),
|
|
1616
|
+
``,
|
|
1617
|
+
);
|
|
1618
|
+
return out.join("\n");
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1267
1621
|
export function generateTsProof(modules: readonly DomainModule[], opts: TsProofOptions): string {
|
|
1268
|
-
// PRIMARY-DOMAIN SELECTION: the live `.mts` drives a STANDALONE create when the package has one
|
|
1269
|
-
// deploy → write → query → sync → cloud loop).
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1622
|
+
// PRIMARY-DOMAIN SELECTION: the live `.mts` drives a STANDALONE (ungated) create when the package has one
|
|
1623
|
+
// (the full deploy → write → query → sync → cloud loop). Else a BIRTH package (`.births()`) drives the
|
|
1624
|
+
// birth. Else a GATED-CREATE package — whose only creating write `.requires(R)` (co2's estate `createSite`)
|
|
1625
|
+
// — drives the RELATION-BOOTSTRAP leg as its primary write: seed authority, THEN the gated create is
|
|
1626
|
+
// admitted (never a naive first dispatch that the gate would refuse). Either way, the marker = the primary
|
|
1627
|
+
// domain's FULL legs (so the offline runner + the index never describe different directives).
|
|
1273
1628
|
const hasStandaloneCreate = (m: DomainModule): boolean =>
|
|
1274
1629
|
(m.directives as Directive<unknown>[]).some((d) => isStandaloneCreate(d));
|
|
1275
1630
|
const hasBirth = (m: DomainModule): boolean => birthDirectiveOf(m) !== undefined;
|
|
1276
|
-
const
|
|
1631
|
+
const isBootstrapProvable = (m: DomainModule): boolean => {
|
|
1632
|
+
try { return discoverRelationBootstrapLeg(m) !== undefined; } catch { return false; }
|
|
1633
|
+
};
|
|
1634
|
+
const mod = modules.find(hasStandaloneCreate) ?? modules.find(hasBirth) ?? modules.find(isBootstrapProvable);
|
|
1277
1635
|
if (mod === undefined) {
|
|
1636
|
+
// No provable creating write anywhere. If a GATED create exists but its authority can't be seeded, surface
|
|
1637
|
+
// the loud bootstrap diagnostic (never a false green); else the generic "add a .creates" remedy.
|
|
1638
|
+
const gapMod = modules.find((m) => diagnoseRelationBootstrapGap(m) !== undefined);
|
|
1639
|
+
const gap = gapMod !== undefined ? diagnoseRelationBootstrapGap(gapMod) : undefined;
|
|
1278
1640
|
throw new Error(
|
|
1279
|
-
|
|
1641
|
+
gap !== undefined
|
|
1642
|
+
? `no proof could be synthesized: ${gap}`
|
|
1643
|
+
: `no .creates directive in any domain — a generated proof needs one creating write (or a .births() birth directive); add a directive(...).creates(...) (test/e2e.mts remains the hand-authored lane)`,
|
|
1280
1644
|
);
|
|
1281
1645
|
}
|
|
1282
1646
|
const domain = mod.domain ?? mod.name;
|
|
@@ -1284,18 +1648,24 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
|
|
|
1284
1648
|
const hashConst = tsHashConstName(opts.packageName);
|
|
1285
1649
|
// THE MARKER (since 0.5.0) + the per-domain index are ONE synthesis (`legsForDomain`) — the proof body, its
|
|
1286
1650
|
// marker, and the index can never describe different directives. The primary domain's legs carry whichever
|
|
1287
|
-
// of standalone / child-birth it has.
|
|
1651
|
+
// of standalone / child-birth / relation-bootstrap it has.
|
|
1288
1652
|
const primaryLegs = legsForDomain(mod, modules);
|
|
1289
1653
|
const childBirth = primaryLegs.childBirth;
|
|
1290
1654
|
|
|
1291
|
-
// The standalone-create lane
|
|
1292
|
-
//
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
);
|
|
1655
|
+
// The standalone-create lane needs an UNGATED create (isStandaloneCreate — never a gated/birth/attestation
|
|
1656
|
+
// create, which the gate would refuse dispatched with no seeded authority). Absent one, the primary write
|
|
1657
|
+
// is the birth leg (a `.births()` domain) or the relation-bootstrap leg (a gated-create domain).
|
|
1658
|
+
const createDir = (mod.directives as Directive<unknown>[]).find((d) => isStandaloneCreate(d));
|
|
1296
1659
|
if (createDir === undefined) {
|
|
1297
|
-
// BIRTH-ONLY PRIMARY: emit a birth-shaped proof (
|
|
1298
|
-
|
|
1660
|
+
// BIRTH-ONLY PRIMARY: emit a birth-shaped proof (the birth replays genesis, seating authority, then the
|
|
1661
|
+
// born child self-validates — a gated child-domain create is proven INSIDE the born child context).
|
|
1662
|
+
if (primaryLegs.childBirth !== undefined) return generateBirthOnlyProof(mod, modules, opts, primaryLegs);
|
|
1663
|
+
// BOOTSTRAP-ONLY PRIMARY: the only creating write is GATED — deploy, then seed authority + prove the
|
|
1664
|
+
// gated create is admitted (the relation-bootstrap leg is its ONLY positive proof).
|
|
1665
|
+
if (primaryLegs.relationBootstrap !== undefined) return generateBootstrapOnlyProof(mod, opts, primaryLegs);
|
|
1666
|
+
// A gated create with no seedable authority: loud diagnostic (compile skips the proof, law still ships).
|
|
1667
|
+
const gap = diagnoseRelationBootstrapGap(mod);
|
|
1668
|
+
throw new Error(gap !== undefined ? `no proof could be synthesized for '${domain}': ${gap}` : `domain '${domain}' has no provable creating write`);
|
|
1299
1669
|
}
|
|
1300
1670
|
const agg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === createDir.aggregateId);
|
|
1301
1671
|
if (agg === undefined) {
|
|
@@ -1537,10 +1907,11 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
|
|
|
1537
1907
|
// offer is impossible to GENERATE (throws MissingOfferAuthority before any submit). No-op when un-gated.
|
|
1538
1908
|
out.push(...authorityConformanceLegLines(conformance, factory, n));
|
|
1539
1909
|
|
|
1540
|
-
// THE RELATION-BOOTSTRAP LEG (seed-then-prove the POSITIVE): grant relation R
|
|
1541
|
-
// the gated write SUCCEEDS under that actor. Runs on the LOCAL holon (fetch
|
|
1542
|
-
// no granter is discoverable
|
|
1543
|
-
|
|
1910
|
+
// THE RELATION-BOOTSTRAP LEG (seed-then-prove the POSITIVE): grant relation R (or a rewrite base of it) to
|
|
1911
|
+
// the proof actor, then prove the gated write SUCCEEDS under that actor. Runs on the LOCAL holon (fetch
|
|
1912
|
+
// re-trapped). When no granter is discoverable but a gated directive exists, emits a CLEAR diagnostic naming
|
|
1913
|
+
// exactly what's missing (the negative conformance leg still covers the gate).
|
|
1914
|
+
out.push(...relationSeedLegLines(relationBootstrap, factory, n, relationBootstrap === undefined ? diagnoseRelationBootstrapGap(mod) : undefined));
|
|
1544
1915
|
|
|
1545
1916
|
// THE CHILD-BIRTH LEG (G3, gap 2): when the primary domain has a `.births()` directive, prove the birth
|
|
1546
1917
|
// 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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
409
|
+
this.aggregate,
|
|
354
410
|
schema as z.ZodType<z.infer<S>>,
|
|
355
411
|
);
|
|
356
412
|
}
|
|
357
413
|
}
|
|
358
414
|
|
|
359
|
-
/**
|
|
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
|
|
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.
|
|
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
|
package/src/engine_entry.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|