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