@githolon/dsl 0.31.0 → 0.32.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 +11 -0
- package/src/codegen_dart.ts +64 -13
- package/src/codegen_dart_client.ts +12 -0
- package/src/compile_package_main.ts +118 -14
- package/src/directive.ts +10 -0
- package/src/framework/domain_lifecycle.ts +21 -2
- package/src/framework/framework_aggregates.ts +138 -0
- package/src/query.ts +10 -0
- package/src/relation_composition.ts +286 -0
- package/src/usd.ts +6 -1
- package/src/usd_layers.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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
|
@@ -155,6 +155,17 @@ export interface AggregateHandle<
|
|
|
155
155
|
readonly hasMany: HasManyFields<F>;
|
|
156
156
|
/** Brand so a plain string is never accepted where a handle is required. */
|
|
157
157
|
readonly __isAggregateHandle: true;
|
|
158
|
+
/**
|
|
159
|
+
* FRAMEWORK-SHARED brand (Dart-codegen hygiene). Set (by `framework/framework_aggregates.ts`)
|
|
160
|
+
* ONLY on the framework-owned aggregate singletons declared in `dsl/src/framework/` — most
|
|
161
|
+
* importantly `RelationTuple`, auto-added to EVERY relation-using domain. A branded aggregate's
|
|
162
|
+
* Dart surface (read-model class, typed-id class, by-id accessors) is emitted ONCE per package into
|
|
163
|
+
* a shared `nomos_framework.dart` file, NOT re-emitted in each domain's generated `.dart` — so a
|
|
164
|
+
* package barrel re-exporting several domains never gets a duplicate framework symbol (no hand-
|
|
165
|
+
* maintained `hide` clause). NOT hashed / NOT law: a pure frontend-codegen marker (like
|
|
166
|
+
* `readModelAggregates`). Identified by OBJECT IDENTITY (the branded singleton), so a tenant
|
|
167
|
+
* aggregate that merely SHARES a name is never misclassified. OMITTED on tenant aggregates. */
|
|
168
|
+
readonly framework?: true;
|
|
158
169
|
/** Presence-only flag (hashed); OMITTED ENTIRELY when no invariant is declared. */
|
|
159
170
|
readonly hasInvariant?: true;
|
|
160
171
|
/** The executable body — ships in the engine bundle, NOT the manifest. */
|
package/src/codegen_dart.ts
CHANGED
|
@@ -1168,8 +1168,8 @@ function emitEvidenceRefClass(): string {
|
|
|
1168
1168
|
|
|
1169
1169
|
/** True when ANY aggregate field in the module is `t.evidence()` — gates emitting the
|
|
1170
1170
|
* shared [emitEvidenceRefClass] once. */
|
|
1171
|
-
function moduleUsesEvidence(
|
|
1172
|
-
return
|
|
1171
|
+
function moduleUsesEvidence(aggregates: readonly AggregateHandle[]): boolean {
|
|
1172
|
+
return aggregates.some((a) =>
|
|
1173
1173
|
Object.values(a.fields as Record<string, Field>).some((f) => f.kind === "evidence"),
|
|
1174
1174
|
);
|
|
1175
1175
|
}
|
|
@@ -2300,14 +2300,21 @@ function emitMintedCreates(
|
|
|
2300
2300
|
directives: AnyDirective[],
|
|
2301
2301
|
domainName: string,
|
|
2302
2302
|
enumDartTypes: Map<string, string>,
|
|
2303
|
+
/** Aggregate ids whose typed-id CLASS is emitted in the shared `nomos_framework.dart` (imported
|
|
2304
|
+
* here), so it must NOT be re-declared. Minting FACTORIES still emit and reference the imported
|
|
2305
|
+
* class. Empty = emit every id class locally (the single-domain / shared-file path). */
|
|
2306
|
+
skipIdClassFor: ReadonlySet<string> = new Set(),
|
|
2303
2307
|
): string {
|
|
2304
2308
|
const blocks: string[] = [];
|
|
2305
2309
|
|
|
2306
2310
|
// (1) A typed id class per aggregate. Dedup by the typed-id class NAME: two wire
|
|
2307
2311
|
// types never collide on a name (the name is a pure function of the type), and a
|
|
2308
2312
|
// domain that re-exports a framework aggregate must not emit its id class twice.
|
|
2313
|
+
// Framework-shared aggregates are emitted ONCE in the package's `nomos_framework.dart`,
|
|
2314
|
+
// so their id class is SKIPPED here (referenced via import).
|
|
2309
2315
|
const emittedIdClasses = new Set<string>();
|
|
2310
2316
|
for (const agg of aggregates) {
|
|
2317
|
+
if (skipIdClassFor.has(agg.id)) continue;
|
|
2311
2318
|
const cls = typedIdClassForAggregateType(agg.id);
|
|
2312
2319
|
if (emittedIdClasses.has(cls)) continue;
|
|
2313
2320
|
emittedIdClasses.add(cls);
|
|
@@ -2565,12 +2572,49 @@ export interface DomainModule {
|
|
|
2565
2572
|
dartRefImports?: DartRefImport[];
|
|
2566
2573
|
}
|
|
2567
2574
|
|
|
2575
|
+
/** Options controlling FRAMEWORK-SHARED aggregate deduplication (Dart-codegen hygiene). */
|
|
2576
|
+
export interface GenerateDartDomainOptions {
|
|
2577
|
+
/**
|
|
2578
|
+
* Aggregate ids whose Dart surface (projection class, read model, typed-id class, by-id accessors)
|
|
2579
|
+
* is emitted ONCE per package in the shared `nomos_framework.dart` and MUST NOT be re-emitted here —
|
|
2580
|
+
* this domain REFERENCES them via {@link frameworkImportUri}. Empty/absent ⇒ emit everything locally
|
|
2581
|
+
* (the legacy single-file behaviour; byte-identical to before this option existed).
|
|
2582
|
+
*/
|
|
2583
|
+
readonly skipAggregateIds?: ReadonlySet<string>;
|
|
2584
|
+
/**
|
|
2585
|
+
* Framework-shared DIRECTIVE ids whose generated payload class (+ minted-create factory) is emitted
|
|
2586
|
+
* ONCE per package in the shared file and MUST NOT be re-emitted here. The domain still OFFERS them
|
|
2587
|
+
* (the client offer methods stay per-domain); only the payload TYPE moves to the shared file.
|
|
2588
|
+
*/
|
|
2589
|
+
readonly skipDirectiveIds?: ReadonlySet<string>;
|
|
2590
|
+
/**
|
|
2591
|
+
* Framework-shared QUERY ids whose top-level `watch<Q>`/`read<Q>` accessors are emitted ONCE per
|
|
2592
|
+
* package in the shared file and MUST NOT be re-emitted here. The client's per-domain query methods
|
|
2593
|
+
* stay (they reference the shared read models via the framework import).
|
|
2594
|
+
*/
|
|
2595
|
+
readonly skipQueryIds?: ReadonlySet<string>;
|
|
2596
|
+
/**
|
|
2597
|
+
* Relative import of the shared framework file (e.g. `nomos_framework.dart`). Added as an ordinary
|
|
2598
|
+
* `import` so skipped framework symbols resolve. Omit when nothing is skipped.
|
|
2599
|
+
*/
|
|
2600
|
+
readonly frameworkImportUri?: string;
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2568
2603
|
/** Generate the full Dart source for one domain module. */
|
|
2569
|
-
export function generateDartDomain(mod: DomainModule): string {
|
|
2604
|
+
export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOptions = {}): string {
|
|
2570
2605
|
const domainKey = mod.domain ?? mod.name;
|
|
2606
|
+
const skipAggregateIds = opts.skipAggregateIds ?? new Set<string>();
|
|
2607
|
+
const skipDirectiveIds = opts.skipDirectiveIds ?? new Set<string>();
|
|
2608
|
+
const skipQueryIds = opts.skipQueryIds ?? new Set<string>();
|
|
2609
|
+
// The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
|
|
2610
|
+
const localAggregates = mod.aggregates.filter((a) => !skipAggregateIds.has(a.id));
|
|
2611
|
+
const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
|
|
2612
|
+
const localQueries = (mod.queries ?? []).filter((q) => !skipQueryIds.has(q.id));
|
|
2613
|
+
// Enums are collected from the LOCALLY-emitted symbols only — a framework directive/aggregate's
|
|
2614
|
+
// enums ride the shared `nomos_framework.dart`, so a domain that skips them emits no dangling enum.
|
|
2571
2615
|
const { dartTypes, decls } = collectEnums(
|
|
2572
|
-
|
|
2573
|
-
|
|
2616
|
+
localAggregates,
|
|
2617
|
+
localDirectives,
|
|
2574
2618
|
domainKey,
|
|
2575
2619
|
);
|
|
2576
2620
|
const refIdClassForAggregate = refIdClassResolver(mod);
|
|
@@ -2583,7 +2627,7 @@ export function generateDartDomain(mod: DomainModule): string {
|
|
|
2583
2627
|
);
|
|
2584
2628
|
const enumBlock = decls.join("\n\n");
|
|
2585
2629
|
const permissionVocabularyBlock = emitPermissionVocabulary(mod.permissionVocabulary);
|
|
2586
|
-
const aggBlock =
|
|
2630
|
+
const aggBlock = localAggregates
|
|
2587
2631
|
.map((a) =>
|
|
2588
2632
|
emitAggregate(
|
|
2589
2633
|
a,
|
|
@@ -2593,7 +2637,7 @@ export function generateDartDomain(mod: DomainModule): string {
|
|
|
2593
2637
|
),
|
|
2594
2638
|
)
|
|
2595
2639
|
.join("\n\n");
|
|
2596
|
-
const dirBlock =
|
|
2640
|
+
const dirBlock = localDirectives
|
|
2597
2641
|
.map((d) => emitDirective(d, domainKey, dartTypes))
|
|
2598
2642
|
.join("\n\n");
|
|
2599
2643
|
|
|
@@ -2602,7 +2646,9 @@ export function generateDartDomain(mod: DomainModule): string {
|
|
|
2602
2646
|
// ref fields name their target's typed id), plus a top-level `create…` factory that
|
|
2603
2647
|
// mints THROUGH the kernel (no raw id) for every `.creates` directive carrying a
|
|
2604
2648
|
// `mintSeed` + an own-id field.
|
|
2605
|
-
|
|
2649
|
+
// Minted-create factories for framework directives (if any) live in the shared file — pass only the
|
|
2650
|
+
// LOCAL directives here; framework typed-id classes are likewise skipped (referenced via import).
|
|
2651
|
+
const mintedBlock = emitMintedCreates(mod.aggregates, localDirectives, domainKey, dartTypes, skipAggregateIds);
|
|
2606
2652
|
|
|
2607
2653
|
// PROVIDER SDK (the symmetric twin of the peer payload classes): for each declared
|
|
2608
2654
|
// `impureCapability` capability, a typed Obligation/Result/Failure + handler typedef + dispatcher.
|
|
@@ -2635,7 +2681,7 @@ export function generateDartDomain(mod: DomainModule): string {
|
|
|
2635
2681
|
list.push(c);
|
|
2636
2682
|
combinedsByType.set(c.of, list);
|
|
2637
2683
|
}
|
|
2638
|
-
const readModelBlock =
|
|
2684
|
+
const readModelBlock = localAggregates
|
|
2639
2685
|
.filter((a) => readModelTypes.has(a.id))
|
|
2640
2686
|
.map((a) =>
|
|
2641
2687
|
emitReadModel(
|
|
@@ -2657,7 +2703,7 @@ export function generateDartDomain(mod: DomainModule): string {
|
|
|
2657
2703
|
// §5 claim that "codegen already emits count reads" was wrong; this is the actual
|
|
2658
2704
|
// first emission. The predicate is baked into the maintained counter and is
|
|
2659
2705
|
// INVISIBLE to the generated accessor (worldview law).
|
|
2660
|
-
const queryReads =
|
|
2706
|
+
const queryReads = localQueries
|
|
2661
2707
|
.map((q) =>
|
|
2662
2708
|
emitQueryReads(
|
|
2663
2709
|
q,
|
|
@@ -2731,7 +2777,9 @@ export function generateDartDomain(mod: DomainModule): string {
|
|
|
2731
2777
|
)
|
|
2732
2778
|
.join("\n\n");
|
|
2733
2779
|
const orderedReadBlock = (mod.orderedReads ?? []).map(emitOrderedReads).join("\n\n");
|
|
2734
|
-
|
|
2780
|
+
// By-id accessors for framework-shared aggregates live in `nomos_framework.dart` (emitted once);
|
|
2781
|
+
// this domain references them via the framework import.
|
|
2782
|
+
const byIdReads = localAggregates.map(emitAggregateByIdReads).join("\n\n");
|
|
2735
2783
|
const readBlock = [queryReads, countReads, sumReads, minReads, maxReads, existsReads, orderedReadBlock, byIdReads]
|
|
2736
2784
|
.filter((s) => s !== "")
|
|
2737
2785
|
.join("\n\n");
|
|
@@ -2739,7 +2787,7 @@ export function generateDartDomain(mod: DomainModule): string {
|
|
|
2739
2787
|
// `_mapEq` is only referenced by aggregate `==`/`hashCode` for MAP fields. Emit it
|
|
2740
2788
|
// ONLY when some aggregate has a map field, else a map-less domain trips an
|
|
2741
2789
|
// unused-element lint.
|
|
2742
|
-
const hasMapField =
|
|
2790
|
+
const hasMapField = localAggregates.some((a) =>
|
|
2743
2791
|
Object.values(a.fields as Record<string, Field>).some((f) => f.kind === "map"),
|
|
2744
2792
|
);
|
|
2745
2793
|
const helperBlock = hasMapField
|
|
@@ -2771,6 +2819,9 @@ export function generateDartDomain(mod: DomainModule): string {
|
|
|
2771
2819
|
// out of nomos_client into the co-located tenant package). The framework barrel
|
|
2772
2820
|
// re-exports wire/dispatch/subscriptions/provider, so one import covers them all.
|
|
2773
2821
|
`import 'package:nomos_client/nomos_client.dart';`,
|
|
2822
|
+
// FRAMEWORK-SHARED aggregates (RelationTuple, …) are emitted ONCE per package in the shared
|
|
2823
|
+
// file; import it so this domain's read accessors / ref-id fields resolve those symbols.
|
|
2824
|
+
...(opts.frameworkImportUri !== undefined ? [`import '${opts.frameworkImportUri}';`] : []),
|
|
2774
2825
|
...(mod.dartImports ?? []).map(dartImportLine),
|
|
2775
2826
|
``,
|
|
2776
2827
|
`// ---- enums ----`,
|
|
@@ -2783,7 +2834,7 @@ export function generateDartDomain(mod: DomainModule): string {
|
|
|
2783
2834
|
``,
|
|
2784
2835
|
]
|
|
2785
2836
|
: []),
|
|
2786
|
-
...(moduleUsesEvidence(
|
|
2837
|
+
...(moduleUsesEvidence(localAggregates)
|
|
2787
2838
|
? [
|
|
2788
2839
|
`// ---- evidence (content-addressed EvidenceRef; evidence.md §9.2) ----`,
|
|
2789
2840
|
emitEvidenceRefClass(),
|
|
@@ -99,6 +99,14 @@ export interface DartBridgeClientOptions {
|
|
|
99
99
|
readonly interface?: unknown;
|
|
100
100
|
/** Where the bridge/runtime support types are imported from. */
|
|
101
101
|
readonly supportImport?: "vendored" | "package";
|
|
102
|
+
/**
|
|
103
|
+
* Relative import of the shared `nomos_framework.dart` (framework-shared aggregate types, emitted
|
|
104
|
+
* ONCE per package). When present the client IMPORTS it (its by-id accessors reference framework
|
|
105
|
+
* read models) and RE-EXPORTS it, so a package barrel re-exporting several domain clients surfaces
|
|
106
|
+
* the framework types EXACTLY ONCE (Dart de-dups re-exports of the SAME library) — no `hide` needed.
|
|
107
|
+
* Omit for a package with no framework-shared aggregates.
|
|
108
|
+
*/
|
|
109
|
+
readonly frameworkImportUri?: string;
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
/** Generate the `<name>.client.dart` async bridge client for one domain module. */
|
|
@@ -245,9 +253,13 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
245
253
|
...(opts.interface !== undefined ? [`import 'dart:convert';`, ``] : []),
|
|
246
254
|
`import '${supportImport}';`,
|
|
247
255
|
`import '${mod.name}.dart';`,
|
|
256
|
+
// Framework-shared aggregate types (RelationTupleReadModel, …) live in the ONE shared file — import
|
|
257
|
+
// them (the by-id accessors reference them) and re-export them (once per package, Dart de-dups).
|
|
258
|
+
...(opts.frameworkImportUri !== undefined ? [`import '${opts.frameworkImportUri}';`] : []),
|
|
248
259
|
`// Re-export the sibling types (read models + payloads + the bridge runtime) so ONE import of this`,
|
|
249
260
|
`// client file gives an app the whole typed surface.`,
|
|
250
261
|
`export '${mod.name}.dart';`,
|
|
262
|
+
...(opts.frameworkImportUri !== undefined ? [`export '${opts.frameworkImportUri}';`] : []),
|
|
251
263
|
`export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion;`,
|
|
252
264
|
``,
|
|
253
265
|
`/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
|
|
@@ -61,6 +61,11 @@ import {
|
|
|
61
61
|
type PermissionVocabulary,
|
|
62
62
|
} from "./codegen_dart.js";
|
|
63
63
|
import { generateDartBridgeClient } from "./codegen_dart_client.js";
|
|
64
|
+
import {
|
|
65
|
+
isFrameworkAggregate,
|
|
66
|
+
isFrameworkDirective,
|
|
67
|
+
isFrameworkQuery,
|
|
68
|
+
} from "./framework/framework_aggregates.js";
|
|
64
69
|
import { finishCount, type AnyCount } from "./count.js";
|
|
65
70
|
import type { QueryDecl } from "./query.js";
|
|
66
71
|
import type { SpatialDecl } from "./spatial.js";
|
|
@@ -108,7 +113,8 @@ import {
|
|
|
108
113
|
type InferredRename,
|
|
109
114
|
type StableIds,
|
|
110
115
|
} from "./stable_ids.js";
|
|
111
|
-
import type
|
|
116
|
+
import { emitUsd, type UsdLayer } from "./usd.js";
|
|
117
|
+
import { emitLayeredRelationUsda, type RelationLayerInput } from "./relation_composition.js";
|
|
112
118
|
import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
|
|
113
119
|
import { generateOpenApiFragment, mergeOpenApiFragments } from "./codegen_openapi.js";
|
|
114
120
|
import { domainInterface, type DomainInterface } from "./shape_compat.js";
|
|
@@ -387,35 +393,108 @@ function emitDart(
|
|
|
387
393
|
removeVendoredDartSupport(dartOut);
|
|
388
394
|
}
|
|
389
395
|
|
|
390
|
-
// 2.
|
|
391
|
-
//
|
|
392
|
-
|
|
396
|
+
// 2. FRAMEWORK-SHARED symbol dedup (Dart-codegen hygiene). Framework-owned symbols fold into MANY
|
|
397
|
+
// domains, so their generated Dart would be DEFINED once per domain — a package barrel re-exporting
|
|
398
|
+
// ≥2 domains then COLLIDES (the historical `hide RelationTuple, EnrollDelegationPayload,
|
|
399
|
+
// readSignerDelegationByUser, …` band-aid). THREE kinds are deduped, each identified by the
|
|
400
|
+
// framework BRAND (object identity, so a tenant symbol that merely shares a name is never
|
|
401
|
+
// misclassified) and emitted EXACTLY ONCE into `nomos_framework.dart`:
|
|
402
|
+
// • AGGREGATES — read-model class, typed-id class, by-id accessors (`RelationTuple` auto-added
|
|
403
|
+
// to every relation-using domain, `SignerDelegation`/`KeyScope`/… re-exported verbatim);
|
|
404
|
+
// • DIRECTIVES — the generated payload class (relate/signer/enroll family). The domain still
|
|
405
|
+
// OFFERS them (client offer methods stay per-domain); only the payload TYPE moves to the shared file;
|
|
406
|
+
// • QUERIES — the top-level `watch<Q>`/`read<Q>` accessors.
|
|
407
|
+
// Per-domain files SKIP each and IMPORT the shared file; clients RE-EXPORT it (Dart de-dups the
|
|
408
|
+
// same-library re-export). So a barrel over all domains needs NO `hide` clause.
|
|
409
|
+
type ModDirective = DomainModule["directives"][number];
|
|
410
|
+
type ModQuery = NonNullable<DomainModule["queries"]>[number];
|
|
411
|
+
const frameworkAggById = new Map<string, AggregateHandle>();
|
|
412
|
+
const frameworkDirById = new Map<string, ModDirective>();
|
|
413
|
+
const frameworkQueryById = new Map<string, ModQuery>();
|
|
393
414
|
for (const mod of modules) {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
415
|
+
for (const a of mod.aggregates) if (isFrameworkAggregate(a)) frameworkAggById.set(a.id, a);
|
|
416
|
+
for (const d of mod.directives) if (isFrameworkDirective(d)) frameworkDirById.set(d.id, d);
|
|
417
|
+
for (const q of mod.queries ?? []) if (isFrameworkQuery(q)) frameworkQueryById.set(q.id, q);
|
|
418
|
+
}
|
|
419
|
+
const frameworkAggs = [...frameworkAggById.values()];
|
|
420
|
+
const frameworkDirs = [...frameworkDirById.values()];
|
|
421
|
+
const frameworkQueries = [...frameworkQueryById.values()];
|
|
422
|
+
const frameworkAggIds: ReadonlySet<string> = new Set(frameworkAggById.keys());
|
|
423
|
+
const frameworkDirIds: ReadonlySet<string> = new Set(frameworkDirById.keys());
|
|
424
|
+
const frameworkQueryIds: ReadonlySet<string> = new Set(frameworkQueryById.keys());
|
|
425
|
+
const hasFrameworkSymbols =
|
|
426
|
+
frameworkAggs.length > 0 || frameworkDirs.length > 0 || frameworkQueries.length > 0;
|
|
427
|
+
const FRAMEWORK_FILE = "nomos_framework";
|
|
428
|
+
const frameworkImportUri = hasFrameworkSymbols ? `${FRAMEWORK_FILE}.dart` : undefined;
|
|
429
|
+
|
|
430
|
+
const written: string[] = [];
|
|
431
|
+
|
|
432
|
+
const writeGenerated = (fileBase: string, src: string): string => {
|
|
402
433
|
if (!src.includes(DART_PACKAGE_IMPORT)) {
|
|
403
434
|
fail(
|
|
404
|
-
`generated Dart for
|
|
435
|
+
`generated Dart for '${fileBase}' does not carry the expected support import (${DART_PACKAGE_IMPORT}) — codegen_dart header drifted; fix the rewrite in compile_package_main.ts`,
|
|
405
436
|
);
|
|
406
437
|
}
|
|
407
|
-
const p = path.join(dartOut, `${
|
|
438
|
+
const p = path.join(dartOut, `${fileBase}.dart`);
|
|
408
439
|
writeFileSync(
|
|
409
440
|
p,
|
|
410
441
|
supportImport === "vendored" ? src.replace(DART_PACKAGE_IMPORT, DART_RELATIVE_IMPORT) : src,
|
|
411
442
|
"utf8",
|
|
412
443
|
);
|
|
413
444
|
written.push(p);
|
|
445
|
+
return p;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// 2a. the shared framework file — the framework aggregates + directives + queries, ONCE. Its own
|
|
449
|
+
// surface is emitted in full (no skip). Imported by every domain that folds any framework symbol.
|
|
450
|
+
if (hasFrameworkSymbols) {
|
|
451
|
+
const frameworkMod: DomainModule = {
|
|
452
|
+
name: FRAMEWORK_FILE,
|
|
453
|
+
domain: FRAMEWORK_FILE,
|
|
454
|
+
aggregates: frameworkAggs,
|
|
455
|
+
directives: frameworkDirs,
|
|
456
|
+
readModelAggregates: frameworkAggs.map((a) => a.id),
|
|
457
|
+
...(frameworkQueries.length > 0 ? { queries: frameworkQueries } : {}),
|
|
458
|
+
};
|
|
459
|
+
writeGenerated(FRAMEWORK_FILE, generateDartDomain(frameworkMod));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 2b. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
|
|
463
|
+
// CLIENT (`<name>.client.dart`, the Flutter sibling of `<name>.client.ts`).
|
|
464
|
+
for (const mod of modules) {
|
|
465
|
+
// Default the read-model allowlist to EVERY aggregate when the domain didn't pin one, so
|
|
466
|
+
// `<name>.dart` carries a read model per aggregate — the bridge client imports them. A domain
|
|
467
|
+
// that pins `readModelAggregates` keeps its choice (byte-identical to before this default).
|
|
468
|
+
const modForDart: DomainModule =
|
|
469
|
+
mod.readModelAggregates === undefined
|
|
470
|
+
? { ...mod, readModelAggregates: mod.aggregates.map((a) => a.id) }
|
|
471
|
+
: mod;
|
|
472
|
+
// Skip framework symbols this domain folds (they live in the shared file); reference them via the
|
|
473
|
+
// framework import. Only pass the import when this domain actually folds a framework symbol.
|
|
474
|
+
const domainFwAggIds = new Set(
|
|
475
|
+
modForDart.aggregates.filter((a) => frameworkAggIds.has(a.id)).map((a) => a.id),
|
|
476
|
+
);
|
|
477
|
+
const domainFwDirIds = new Set(
|
|
478
|
+
modForDart.directives.filter((d) => frameworkDirIds.has(d.id)).map((d) => d.id),
|
|
479
|
+
);
|
|
480
|
+
const domainFwQueryIds = new Set(
|
|
481
|
+
(modForDart.queries ?? []).filter((q) => frameworkQueryIds.has(q.id)).map((q) => q.id),
|
|
482
|
+
);
|
|
483
|
+
const foldsFramework = domainFwAggIds.size > 0 || domainFwDirIds.size > 0 || domainFwQueryIds.size > 0;
|
|
484
|
+
const domainFrameworkImport = foldsFramework ? frameworkImportUri : undefined;
|
|
485
|
+
const src = generateDartDomain(modForDart, {
|
|
486
|
+
skipAggregateIds: domainFwAggIds,
|
|
487
|
+
skipDirectiveIds: domainFwDirIds,
|
|
488
|
+
skipQueryIds: domainFwQueryIds,
|
|
489
|
+
...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
|
|
490
|
+
});
|
|
491
|
+
writeGenerated(mod.name, src);
|
|
414
492
|
|
|
415
493
|
const clientSrc = generateDartBridgeClient(modForDart, {
|
|
416
494
|
domainHash,
|
|
417
495
|
interface: packageInterface[modForDart.domain ?? modForDart.name],
|
|
418
496
|
supportImport,
|
|
497
|
+
...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
|
|
419
498
|
});
|
|
420
499
|
const cp = path.join(dartOut, `${mod.name}.client.dart`);
|
|
421
500
|
writeFileSync(cp, clientSrc, "utf8");
|
|
@@ -1237,6 +1316,31 @@ async function main(): Promise<void> {
|
|
|
1237
1316
|
writeFileSync(pkgPath, text, "utf8");
|
|
1238
1317
|
const domainHash = sha256HexUtf8(text);
|
|
1239
1318
|
|
|
1319
|
+
// ── 2a. the REAL layered RELATION `.usda` set — an additive, deterministic visualization +
|
|
1320
|
+
// cross-check artifact (usdview / `usdcat --flatten`). One sublayer per domain carrying its
|
|
1321
|
+
// relation opinions as PRIMS + a root with `subLayers` STRONGEST-FIRST; usdcat composes the
|
|
1322
|
+
// shared relation types, the compiler's `composeSharedRelationTypes` is the fail-closed guard
|
|
1323
|
+
// on top. Emitted iff ANY domain declares a relation type. Never feeds the package hash.
|
|
1324
|
+
{
|
|
1325
|
+
const relInputs: RelationLayerInput[] = [];
|
|
1326
|
+
for (const mod of domainModules) {
|
|
1327
|
+
// Per-domain UNCOMPOSED relation types: emit the single module alone (single-domain ⇒ no shared
|
|
1328
|
+
// type ⇒ its own decls, un-unioned — the layers ARE the sublayers usdcat will compose).
|
|
1329
|
+
const one = emitUsd([{ path: `/Nomos/${mod.domain ?? mod.name}`, module: mod }]);
|
|
1330
|
+
const types = one.layers[0]?.nomosRelations?.types;
|
|
1331
|
+
if (types !== undefined && Object.keys(types).length > 0) {
|
|
1332
|
+
relInputs.push({ domain: mod.domain ?? mod.name, types });
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (relInputs.length > 0) {
|
|
1336
|
+
const emit = emitLayeredRelationUsda(relInputs, cfg.name);
|
|
1337
|
+
const relDir = path.join(outDir, `${cfg.name}.relation-layers`);
|
|
1338
|
+
mkdirSync(relDir, { recursive: true });
|
|
1339
|
+
for (const f of emit.files) writeFileSync(path.join(relDir, f.name), f.text, "utf8");
|
|
1340
|
+
writeFileSync(path.join(relDir, "root.usda"), emit.root, "utf8");
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1240
1344
|
// ── 2b. opt-in LAYERED emission — additive artifacts ONLY (the flattened package
|
|
1241
1345
|
// above and its domainHash are already written and do not move). The
|
|
1242
1346
|
// homomorphism is ASSERTED fail-closed on every layered compile: the
|
package/src/directive.ts
CHANGED
|
@@ -71,6 +71,16 @@ export interface Directive<P = unknown> {
|
|
|
71
71
|
readonly aggregateId: string;
|
|
72
72
|
readonly payloadSchema: z.ZodType<P>;
|
|
73
73
|
readonly plan: (payload: P, ctx: Ports) => PlannedOp[];
|
|
74
|
+
/**
|
|
75
|
+
* FRAMEWORK-SHARED brand (Dart-codegen hygiene). Set (by `framework/framework_aggregates.ts`) ONLY on
|
|
76
|
+
* the framework-owned directive singletons declared in `dsl/src/framework/` (the relate/signer/enroll
|
|
77
|
+
* family — `relateSubject`, `enrollDelegation`, `advanceSignerEpoch`, …) which fold into MANY domains.
|
|
78
|
+
* A branded directive's generated Dart PAYLOAD TYPE (`EnrollDelegationPayload`, …) + any minted-create
|
|
79
|
+
* factory is emitted ONCE per package into the shared `nomos_framework.dart`, NOT re-emitted in each
|
|
80
|
+
* domain's `.dart` — so a package barrel re-exporting several domains never gets a duplicate payload
|
|
81
|
+
* class (no hand-maintained `hide` clause). NOT hashed / NOT law: a pure frontend-codegen marker.
|
|
82
|
+
* Identified by OBJECT IDENTITY, so a tenant directive that merely shares a name is never misclassified. */
|
|
83
|
+
readonly framework?: true;
|
|
74
84
|
/**
|
|
75
85
|
* Human-readable description of what this directive does — the SINGLE source of
|
|
76
86
|
* the hover-doc text codegen emits onto each generated payload class + ctor (so
|
|
@@ -28,6 +28,7 @@ export const DOMAIN_INSTALLATION_PHASES = [
|
|
|
28
28
|
"Disabled",
|
|
29
29
|
"Superseded",
|
|
30
30
|
"Retiring",
|
|
31
|
+
"Retired",
|
|
31
32
|
"Failed",
|
|
32
33
|
] as const;
|
|
33
34
|
export type DomainInstallationPhase = (typeof DOMAIN_INSTALLATION_PHASES)[number];
|
|
@@ -48,6 +49,7 @@ export const DomainInstallation = aggregate("DomainInstallation", {
|
|
|
48
49
|
"metadata.generation": t.int().merge(Lww),
|
|
49
50
|
"status.phase": t.enum(DOMAIN_INSTALLATION_PHASES).merge(Lww),
|
|
50
51
|
"status.observedGeneration": t.int().merge(Lww),
|
|
52
|
+
"status.supersededBy": t.string().merge(Lww).optional(),
|
|
51
53
|
"status.conditions": t.map(t.json()).merge(MapOf(Lww)),
|
|
52
54
|
});
|
|
53
55
|
|
|
@@ -77,6 +79,13 @@ export const installDomain = directive("installDomain")
|
|
|
77
79
|
authorityScope: z.string().default("workspace/root"),
|
|
78
80
|
dependencies: z.array(z.string()).default([]),
|
|
79
81
|
supersedes: z.string().optional(),
|
|
82
|
+
// THE FRONTIER TRANSITION (install-lifecycle): `replaces` names a PRIOR install this
|
|
83
|
+
// deploy makes current UNEQUIVOCALLY — the prior install is flipped out of the served
|
|
84
|
+
// frontier (`Superseded`, or `Retired` when `retireReplaced`) and gets the reverse
|
|
85
|
+
// `status.supersededBy` edge to this install. Absent ⇒ install-BESIDE (backward-compat:
|
|
86
|
+
// byte-identical ops to the pre-lifecycle plan). `replaces` also sets `spec.supersedes`.
|
|
87
|
+
replaces: z.string().optional(),
|
|
88
|
+
retireReplaced: z.boolean().default(false),
|
|
80
89
|
finalizers: z.array(z.string()).default([]),
|
|
81
90
|
}),
|
|
82
91
|
)
|
|
@@ -97,8 +106,18 @@ export const installDomain = directive("installDomain")
|
|
|
97
106
|
if (p.dependencies.length > 0) {
|
|
98
107
|
ops.push(addToSet(install, "spec.dependencies", p.dependencies));
|
|
99
108
|
}
|
|
100
|
-
|
|
101
|
-
|
|
109
|
+
// The predecessor back-link: `supersedes` (explicit) or `replaces` (the actionable form).
|
|
110
|
+
const supersedes = p.supersedes ?? p.replaces;
|
|
111
|
+
if (supersedes !== undefined) {
|
|
112
|
+
ops.push(set(install, "spec.supersedes", supersedes));
|
|
113
|
+
}
|
|
114
|
+
// The transition on the PRIOR install: drop it from the served frontier + point it here.
|
|
115
|
+
if (p.replaces !== undefined) {
|
|
116
|
+
const replaced = domainInstallationInstance(p.replaces);
|
|
117
|
+
ops.push(
|
|
118
|
+
set(replaced, "status.phase", p.retireReplaced ? "Retired" : "Superseded"),
|
|
119
|
+
);
|
|
120
|
+
ops.push(set(replaced, "status.supersededBy", p.domainHash));
|
|
102
121
|
}
|
|
103
122
|
if (p.finalizers.length > 0) {
|
|
104
123
|
ops.push(addToSet(install, "spec.finalizers", p.finalizers));
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon wasm.
|
|
3
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* THE FRAMEWORK-SYMBOL REGISTRY (Dart-codegen hygiene).
|
|
7
|
+
*
|
|
8
|
+
* Some DSL symbols are FRAMEWORK-owned + framework-SHARED: they are declared once in
|
|
9
|
+
* `dsl/src/framework/` and folded into MANY tenant domains. Three kinds collide in generated Dart:
|
|
10
|
+
*
|
|
11
|
+
* • AGGREGATES — `RelationTuple` (auto-added to EVERY relation-using domain), plus `SignerDelegation`,
|
|
12
|
+
* `KeyScope`, `WrappedKey`, `HomeOwner`, `WorkspaceSelf`, … re-exported verbatim so their events fold.
|
|
13
|
+
* Each emits a read-model class + typed-id class + by-id accessors → `RelationTupleReadModel`,
|
|
14
|
+
* `SignerDelegationId`, `watchSignerDelegationById`, … DEFINED per domain.
|
|
15
|
+
* • DIRECTIVES — the relate/signer/enroll family (`relateSubject`, `enrollDelegation`,
|
|
16
|
+
* `advanceSignerEpoch`, …) fold into many domains; each emits a payload class → `EnrollDelegationPayload`
|
|
17
|
+
* DEFINED per domain.
|
|
18
|
+
* • QUERIES — `signerDelegationByUser`, `relationTuplesByObject`, … emit top-level `watch<Q>`/`read<Q>`
|
|
19
|
+
* accessors → `readSignerDelegationByUser`, `watchSignerDelegationByUser` DEFINED per domain.
|
|
20
|
+
*
|
|
21
|
+
* A package barrel re-exporting ≥2 domains COLLIDES on every such symbol — the historical band-aid was a
|
|
22
|
+
* hand-maintained `hide RelationTuple, EnrollDelegationPayload, readSignerDelegationByUser, …` clause on
|
|
23
|
+
* the tenant barrel. The fix: BRAND the framework SINGLETONS here (once, at module load) so the codegen
|
|
24
|
+
* emits their Dart surface EXACTLY ONCE per package (into the shared `nomos_framework.dart`) and SKIPS
|
|
25
|
+
* re-emitting them in each domain. Identification is by OBJECT IDENTITY (the brand rides the branded
|
|
26
|
+
* singleton), so a tenant symbol that merely SHARES a name is NEVER misclassified.
|
|
27
|
+
*
|
|
28
|
+
* FRONTEND CODEGEN ONLY — the brand is never hashed and never enters the USD IR / read manifest / law
|
|
29
|
+
* (identical guarantee to `readModelAggregates`). Deterministic: the registry is derived from the actual
|
|
30
|
+
* framework exports, not a hand-kept string list.
|
|
31
|
+
*/
|
|
32
|
+
import type { AggregateHandle } from "../aggregate.js";
|
|
33
|
+
import type { Directive } from "../directive.js";
|
|
34
|
+
import type { QueryDecl } from "../query.js";
|
|
35
|
+
import * as disclosure from "./disclosure.js";
|
|
36
|
+
import * as domainLifecycle from "./domain_lifecycle.js";
|
|
37
|
+
import * as encryptionIdentity from "./encryption_identity.js";
|
|
38
|
+
import * as encryptionScope from "./encryption_scope.js";
|
|
39
|
+
import * as genesis from "./genesis.js";
|
|
40
|
+
import * as home from "./home.js";
|
|
41
|
+
import * as identity from "./identity.js";
|
|
42
|
+
import * as relations from "./relations.js";
|
|
43
|
+
import * as repair from "./repair.js";
|
|
44
|
+
import * as signingIdentity from "./signing_identity.js";
|
|
45
|
+
import * as workspaces from "./workspaces.js";
|
|
46
|
+
|
|
47
|
+
// Every framework module that DECLARES aggregate / directive / query singletons. Namespace imports so
|
|
48
|
+
// the registry is derived from the real exports (no hand-kept id list to drift).
|
|
49
|
+
const FRAMEWORK_NAMESPACES: ReadonlyArray<Record<string, unknown>> = [
|
|
50
|
+
disclosure,
|
|
51
|
+
domainLifecycle,
|
|
52
|
+
encryptionIdentity,
|
|
53
|
+
encryptionScope,
|
|
54
|
+
genesis,
|
|
55
|
+
home,
|
|
56
|
+
identity,
|
|
57
|
+
relations,
|
|
58
|
+
repair,
|
|
59
|
+
signingIdentity,
|
|
60
|
+
workspaces,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function isAggregateHandle(v: unknown): v is AggregateHandle {
|
|
64
|
+
return (
|
|
65
|
+
typeof v === "object" &&
|
|
66
|
+
v !== null &&
|
|
67
|
+
(v as { __isAggregateHandle?: unknown }).__isAggregateHandle === true
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A finished Directive duck-type ({id, plan, aggregateId}) — the SAME shape `directivesOf` discovers.
|
|
72
|
+
function isDirective(v: unknown): v is Directive {
|
|
73
|
+
return (
|
|
74
|
+
typeof v === "object" &&
|
|
75
|
+
v !== null &&
|
|
76
|
+
typeof (v as { id?: unknown }).id === "string" &&
|
|
77
|
+
typeof (v as { plan?: unknown }).plan === "function" &&
|
|
78
|
+
typeof (v as { aggregateId?: unknown }).aggregateId === "string"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A finished QueryDecl duck-type ({id, key:[], returns:""}). Distinct from the un-keyed builder
|
|
83
|
+
// (`KeylessQuery.key` is a FUNCTION); here `key` is an array and `returns` a string.
|
|
84
|
+
function isQueryDecl(v: unknown): v is QueryDecl {
|
|
85
|
+
return (
|
|
86
|
+
typeof v === "object" &&
|
|
87
|
+
v !== null &&
|
|
88
|
+
typeof (v as { id?: unknown }).id === "string" &&
|
|
89
|
+
Array.isArray((v as { key?: unknown }).key) &&
|
|
90
|
+
typeof (v as { returns?: unknown }).returns === "string"
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const aggregateIds = new Set<string>();
|
|
95
|
+
const directiveIds = new Set<string>();
|
|
96
|
+
const queryIds = new Set<string>();
|
|
97
|
+
|
|
98
|
+
// Brand every framework singleton at module load. Mutating the readonly `framework` slot on the
|
|
99
|
+
// singleton is intentional: the branded object IS the one that flows through composition
|
|
100
|
+
// (`composeDomainModule` keeps the discovered aggregate/directive/query object references; a tenant
|
|
101
|
+
// re-exports the framework singleton verbatim), so the brand rides object identity everywhere.
|
|
102
|
+
for (const ns of FRAMEWORK_NAMESPACES) {
|
|
103
|
+
for (const v of Object.values(ns)) {
|
|
104
|
+
if (isAggregateHandle(v)) {
|
|
105
|
+
(v as { framework?: true }).framework = true;
|
|
106
|
+
aggregateIds.add(v.id);
|
|
107
|
+
} else if (isDirective(v)) {
|
|
108
|
+
(v as { framework?: true }).framework = true;
|
|
109
|
+
directiveIds.add(v.id);
|
|
110
|
+
} else if (isQueryDecl(v)) {
|
|
111
|
+
(v as { framework?: true }).framework = true;
|
|
112
|
+
queryIds.add(v.id);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const FRAMEWORK_AGGREGATE_IDS: ReadonlySet<string> = aggregateIds;
|
|
118
|
+
export const FRAMEWORK_DIRECTIVE_IDS: ReadonlySet<string> = directiveIds;
|
|
119
|
+
export const FRAMEWORK_QUERY_IDS: ReadonlySet<string> = queryIds;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* TRUE iff this aggregate is a framework-shared singleton whose Dart surface is emitted ONCE per
|
|
123
|
+
* package (in `nomos_framework.dart`). By the BRAND (object identity) — a tenant aggregate that
|
|
124
|
+
* happens to share an id is NOT a framework aggregate.
|
|
125
|
+
*/
|
|
126
|
+
export function isFrameworkAggregate(agg: Pick<AggregateHandle, "framework">): boolean {
|
|
127
|
+
return agg.framework === true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** TRUE iff this directive is a framework-shared singleton (its payload class lives in the shared file). */
|
|
131
|
+
export function isFrameworkDirective(d: Pick<Directive, "framework">): boolean {
|
|
132
|
+
return d.framework === true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** TRUE iff this query is a framework-shared singleton (its accessors live in the shared file). */
|
|
136
|
+
export function isFrameworkQuery(q: Pick<QueryDecl, "framework">): boolean {
|
|
137
|
+
return q.framework === true;
|
|
138
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -67,6 +67,16 @@ export interface QueryDecl {
|
|
|
67
67
|
readonly key: string[];
|
|
68
68
|
/** The aggregate TYPE id the query returns, e.g. `SampleThingAggregate`. */
|
|
69
69
|
readonly returns: string;
|
|
70
|
+
/**
|
|
71
|
+
* FRAMEWORK-SHARED brand (Dart-codegen hygiene). Set (by `framework/framework_aggregates.ts`) ONLY on
|
|
72
|
+
* the framework-owned query singletons declared in `dsl/src/framework/` (`signerDelegationByUser`,
|
|
73
|
+
* `relationTuplesByObject`, …) which fold into MANY domains. A branded query's generated top-level
|
|
74
|
+
* `watch<Q>`/`read<Q>` accessors are emitted ONCE per package into the shared `nomos_framework.dart`,
|
|
75
|
+
* NOT re-emitted in each domain's `.dart` — so a package barrel re-exporting several domains never gets
|
|
76
|
+
* duplicate accessors (no hand-maintained `hide` clause). NOT hashed / NOT law: a pure frontend-codegen
|
|
77
|
+
* marker (never enters the read manifest / USD IR). Identified by OBJECT IDENTITY, so a tenant query
|
|
78
|
+
* that merely shares a name is never misclassified. */
|
|
79
|
+
readonly framework?: true;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
/**
|
|
@@ -0,0 +1,286 @@
|
|
|
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 QuickJS engine}.
|
|
4
|
+
//
|
|
5
|
+
// COMPILE-TIME USD-LAYER COMPOSITION for SHARED (global) relation types + the
|
|
6
|
+
// fail-closed DIVERGENCE GUARD.
|
|
7
|
+
//
|
|
8
|
+
// THE MODEL. When a package bundles multiple domains, each domain lowers its own
|
|
9
|
+
// `relationType` decls into its own `UsdLayer.nomosRelations`. A NON-global type is
|
|
10
|
+
// auto-qualified by its owning domain (`estate` → `estate__workspace`), so two
|
|
11
|
+
// domains that each declare the same bare type NEVER collide — their qualified ids
|
|
12
|
+
// differ, and they stay separate layers. A `.asGlobal()` type (a kernel-coupled
|
|
13
|
+
// framework type, e.g. governance/read-visibility `workspace`) stays a BARE shared
|
|
14
|
+
// name, so MULTIPLE domains that each contribute to one global type land on the SAME
|
|
15
|
+
// canonical id. That cross-domain merge USED to happen only at the KERNEL (runtime
|
|
16
|
+
// `merge_layers`, a blunt per-type last-wins that SILENTLY DROPS the losing layer's
|
|
17
|
+
// relations — the bug that broke co2's `createSite`). We move it to COMPILE time, in
|
|
18
|
+
// TS, using USD's composition MODEL (LIVRPS sublayer strength):
|
|
19
|
+
//
|
|
20
|
+
// * COMPOSE (deliverable #1): for each SHARED global type, UNION the relations
|
|
21
|
+
// across the contributing domains — a prim-set union, so EVERY domain's relations
|
|
22
|
+
// survive (nothing is dropped). The composed union is written back into every
|
|
23
|
+
// contributing layer so the downstream kernel merge (union OR last-wins) yields
|
|
24
|
+
// the full union regardless of its policy. This is idempotent and byte-stable.
|
|
25
|
+
//
|
|
26
|
+
// * GUARD (deliverable #2): when two or more domains declare the SAME relation on a
|
|
27
|
+
// SHARED global type with DIVERGENT rewrites (co2's `workspace.admin`: `self()` in
|
|
28
|
+
// estate/platform vs `union(self, computed(creator))` in home), REFUSE AT COMPILE
|
|
29
|
+
// with a scalpel message naming the type, the relation, and each domain's rewrite.
|
|
30
|
+
// Single-owner relations and byte-identical agreeing opinions PASS (union). This
|
|
31
|
+
// turns the kernel's blunt `None` / "no-relation-schema" into a precise compile
|
|
32
|
+
// refusal — the era rule ("the law is the era") is preserved because a package with
|
|
33
|
+
// no shared global type composes to itself, byte-identical.
|
|
34
|
+
//
|
|
35
|
+
// SUBLAYER STRENGTH ORDER (documented, deliverable #1): the package's domain order is
|
|
36
|
+
// WEAK → STRONG (Nomos module-fold notation — later wins), which maps onto USD
|
|
37
|
+
// `subLayers` STRONGEST-FIRST by reversal (the same left-biased monoid, opposite list
|
|
38
|
+
// notation — the convention `usd_layers.ts::layeredRootUsda` already records). For a
|
|
39
|
+
// NON-divergent shared type the union is order-INDEPENDENT (every domain's opinion on a
|
|
40
|
+
// given relation is identical, so which layer "wins" is immaterial); order matters ONLY
|
|
41
|
+
// to the visualization flatten, never to the composed IR.
|
|
42
|
+
|
|
43
|
+
import type { UsdLayer } from "./usd.js";
|
|
44
|
+
|
|
45
|
+
/** A single relation's definition as it rides in `nomosRelations` (`{rewrite, allowed}`). */
|
|
46
|
+
type RelDef = NonNullable<UsdLayer["nomosRelations"]>["types"][string]["relations"][string];
|
|
47
|
+
/** One relation TYPE's spec (`{relations, caps?}`). */
|
|
48
|
+
type RelSpec = NonNullable<UsdLayer["nomosRelations"]>["types"][string];
|
|
49
|
+
|
|
50
|
+
/** Strip the `/Nomos/` prim-scope prefix to recover the owning domain key from a layer path. */
|
|
51
|
+
export function domainOfLayerPath(path: string): string {
|
|
52
|
+
return path.startsWith("/Nomos/") ? path.slice("/Nomos/".length) : path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render a rewrite AST to the author-facing surface syntax (`self()` / `computed(x)` /
|
|
57
|
+
* `union(...)` / `via(t, c)` / …) — the readable form the scalpel message + the layered
|
|
58
|
+
* `.usda` provenance attr carry. A `null`/absent rewrite is a pure-direct relation (`this`).
|
|
59
|
+
*/
|
|
60
|
+
export function renderRewrite(rw: unknown): string {
|
|
61
|
+
if (rw === null || rw === undefined) return "this";
|
|
62
|
+
const r = rw as { kind?: string; [k: string]: unknown };
|
|
63
|
+
switch (r.kind) {
|
|
64
|
+
case "this":
|
|
65
|
+
return "self()";
|
|
66
|
+
case "computed":
|
|
67
|
+
return `computed(${String(r.relation)})`;
|
|
68
|
+
case "ttu":
|
|
69
|
+
return `${r.all ? "viaAll" : "via"}(${String(r.tupleset)}, ${String(r.computed)})`;
|
|
70
|
+
case "union":
|
|
71
|
+
return `union(${(r.children as unknown[]).map(renderRewrite).join(", ")})`;
|
|
72
|
+
case "intersection":
|
|
73
|
+
return `intersection(${(r.children as unknown[]).map(renderRewrite).join(", ")})`;
|
|
74
|
+
case "exclusion":
|
|
75
|
+
return `exclusion(${renderRewrite(r.base)}, ${renderRewrite(r.subtract)})`;
|
|
76
|
+
default:
|
|
77
|
+
return JSON.stringify(rw);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A per-relation divergence, gathered for the scalpel message. */
|
|
82
|
+
interface RelationConflict {
|
|
83
|
+
readonly type: string;
|
|
84
|
+
readonly relation: string;
|
|
85
|
+
/** Each contributing domain's opinion, in package (weak→strong) order. */
|
|
86
|
+
readonly opinions: { readonly domain: string; readonly rewrite: string }[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Format the accumulated divergences into the fail-closed refusal (mirrors `failclosed_check.py`). */
|
|
90
|
+
function scalpelMessage(conflicts: readonly RelationConflict[]): string {
|
|
91
|
+
const lines: string[] = [
|
|
92
|
+
`Refusing to compose the package: shared relation type(s) carry DIVERGENT rewrites across ` +
|
|
93
|
+
`domains — the composed flatten would SILENTLY take the strongest layer's opinion (LIVRPS ` +
|
|
94
|
+
`last-wins), so this is a fail-closed compile refusal, not a silent pick.`,
|
|
95
|
+
``,
|
|
96
|
+
];
|
|
97
|
+
for (const c of conflicts) {
|
|
98
|
+
lines.push(` relation type "${c.type}" relation "${c.relation}" — ${c.opinions.length} divergent opinions:`);
|
|
99
|
+
const w = Math.max(...c.opinions.map((o) => o.domain.length));
|
|
100
|
+
for (const o of c.opinions) lines.push(` · ${o.domain.padEnd(w)} ${o.rewrite}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push(
|
|
103
|
+
``,
|
|
104
|
+
`Fix: give the type ONE canonical definition of each divergent relation (the domains must ` +
|
|
105
|
+
`agree, byte-for-byte), or use DISTINCT / domain-qualified relation types (drop \`.asGlobal()\` ` +
|
|
106
|
+
`so each domain's type auto-qualifies and never shares an id).`,
|
|
107
|
+
);
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Compose the SHARED (global, bare-id) relation types across a package's layers — UNION the
|
|
113
|
+
* relations per shared type, GUARD divergent rewrites fail-closed, and write the union back into
|
|
114
|
+
* every contributing layer. Non-shared (single-owner / auto-qualified) types are untouched, so a
|
|
115
|
+
* package with no shared global type returns byte-identical layers (the era rule). Deterministic:
|
|
116
|
+
* sorted keys, sorted diagnostics.
|
|
117
|
+
*
|
|
118
|
+
* @throws a scalpel `Error` naming the type, relation, and each domain's rewrite on any divergence.
|
|
119
|
+
*/
|
|
120
|
+
export function composeSharedRelationTypes(layers: readonly UsdLayer[]): UsdLayer[] {
|
|
121
|
+
// type id → the layers (domains) that contribute it, in package (weak→strong) order.
|
|
122
|
+
const contributors = new Map<string, { domain: string; spec: RelSpec }[]>();
|
|
123
|
+
for (const layer of layers) {
|
|
124
|
+
const rels = layer.nomosRelations;
|
|
125
|
+
if (rels === undefined) continue;
|
|
126
|
+
for (const [type, spec] of Object.entries(rels.types)) {
|
|
127
|
+
const arr = contributors.get(type) ?? [];
|
|
128
|
+
arr.push({ domain: domainOfLayerPath(layer.path), spec });
|
|
129
|
+
contributors.set(type, arr);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const composed = new Map<string, RelSpec>();
|
|
134
|
+
const conflicts: RelationConflict[] = [];
|
|
135
|
+
for (const type of [...contributors.keys()].sort()) {
|
|
136
|
+
const contribs = contributors.get(type)!;
|
|
137
|
+
// A type contributed by ONE domain is single-owner — nothing to compose (untouched).
|
|
138
|
+
if (contribs.length < 2) continue;
|
|
139
|
+
|
|
140
|
+
// caps must agree across the contributing domains (a divergent bound is a hard error).
|
|
141
|
+
let caps: RelSpec["caps"];
|
|
142
|
+
for (const c of contribs) {
|
|
143
|
+
if (c.spec.caps === undefined) continue;
|
|
144
|
+
if (caps === undefined) caps = c.spec.caps;
|
|
145
|
+
else if (caps.maxDepth !== c.spec.caps.maxDepth || caps.maxFanout !== c.spec.caps.maxFanout) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Refusing to compose the package: shared relation type "${type}" is declared with ` +
|
|
148
|
+
`CONFLICTING caps across domains (${contribs.map((x) => x.domain).join(", ")}). Give the ` +
|
|
149
|
+
`type one canonical \`caps\`.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Gather every domain's opinion on each relation (in package order).
|
|
155
|
+
const opinions = new Map<string, { domain: string; def: RelDef }[]>();
|
|
156
|
+
for (const c of contribs) {
|
|
157
|
+
for (const [rel, def] of Object.entries(c.spec.relations)) {
|
|
158
|
+
const arr = opinions.get(rel) ?? [];
|
|
159
|
+
arr.push({ domain: c.domain, def });
|
|
160
|
+
opinions.set(rel, arr);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const unioned: Record<string, RelDef> = {};
|
|
165
|
+
for (const rel of [...opinions.keys()].sort()) {
|
|
166
|
+
const ops = opinions.get(rel)!;
|
|
167
|
+
const distinct = new Set(ops.map((o) => JSON.stringify(o.def)));
|
|
168
|
+
if (distinct.size > 1) {
|
|
169
|
+
conflicts.push({
|
|
170
|
+
type,
|
|
171
|
+
relation: rel,
|
|
172
|
+
opinions: ops.map((o) => ({ domain: o.domain, rewrite: renderRewrite((o.def as { rewrite: unknown }).rewrite) })),
|
|
173
|
+
});
|
|
174
|
+
} else {
|
|
175
|
+
unioned[rel] = ops[0]!.def; // agreeing opinions / single-owner relation — keep it
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Sorted relation keys (byte-determinism against the per-layer emission).
|
|
179
|
+
const relations: Record<string, RelDef> = {};
|
|
180
|
+
for (const k of Object.keys(unioned).sort()) relations[k] = unioned[k]!;
|
|
181
|
+
composed.set(type, { relations, ...(caps !== undefined ? { caps } : {}) });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (conflicts.length > 0) throw new Error(scalpelMessage(conflicts));
|
|
185
|
+
if (composed.size === 0) return [...layers]; // nothing shared — byte-identical (era rule)
|
|
186
|
+
|
|
187
|
+
// Write the composed union back into every contributing layer, keeping type keys sorted.
|
|
188
|
+
return layers.map((layer) => {
|
|
189
|
+
const rels = layer.nomosRelations;
|
|
190
|
+
if (rels === undefined) return layer;
|
|
191
|
+
let touched = false;
|
|
192
|
+
const merged: Record<string, RelSpec> = {};
|
|
193
|
+
for (const [type, spec] of Object.entries(rels.types)) {
|
|
194
|
+
const c = composed.get(type);
|
|
195
|
+
if (c !== undefined) {
|
|
196
|
+
merged[type] = c;
|
|
197
|
+
touched = true;
|
|
198
|
+
} else {
|
|
199
|
+
merged[type] = spec;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!touched) return layer;
|
|
203
|
+
const sorted: Record<string, RelSpec> = {};
|
|
204
|
+
for (const k of Object.keys(merged).sort()) sorted[k] = merged[k]!;
|
|
205
|
+
return { ...layer, nomosRelations: { v: 1 as const, types: sorted } };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── the REAL layered `.usda` emission (deliverable #3 — for usdview / usdcat --flatten) ──────────
|
|
210
|
+
|
|
211
|
+
/** One domain's contribution to the layered relation emission: its bare-id relation types. */
|
|
212
|
+
export interface RelationLayerInput {
|
|
213
|
+
readonly domain: string;
|
|
214
|
+
/** The domain's `nomosRelations.types` (UNCOMPOSED — the layers ARE the sublayers usdcat composes). */
|
|
215
|
+
readonly types: Record<string, RelSpec>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** `"a"` → escaped USDA string literal (minimal — relation/rewrite tokens are `[A-Za-z0-9_.,()]`). */
|
|
219
|
+
function usda(s: string): string {
|
|
220
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Emit ONE domain's relation opinions as a real `.usda` sublayer: `def "<Type>" { def "<relation>"
|
|
225
|
+
* { custom string nomos:rewrite = "…"; custom string nomos:from = "<domain>" } }`. Structured so
|
|
226
|
+
* `usdcat --flatten` composes the relation prims across sublayers (a stronger layer's `nomos:rewrite`
|
|
227
|
+
* wins; the relation SET is the union). Deterministic: types + relations sorted.
|
|
228
|
+
*/
|
|
229
|
+
export function emitRelationSublayerUsda(input: RelationLayerInput): string {
|
|
230
|
+
const lines: string[] = [`#usda 1.0`, `(`, ` doc = "Nomos relation layer — domain ${input.domain}"`, `)`, ``];
|
|
231
|
+
for (const type of Object.keys(input.types).sort()) {
|
|
232
|
+
lines.push(`def ${usda(type)}`, `{`);
|
|
233
|
+
const relations = input.types[type]!.relations;
|
|
234
|
+
for (const rel of Object.keys(relations).sort()) {
|
|
235
|
+
const def = relations[rel]! as { rewrite: unknown };
|
|
236
|
+
lines.push(
|
|
237
|
+
` def ${usda(rel)}`,
|
|
238
|
+
` {`,
|
|
239
|
+
` custom string nomos:rewrite = ${usda(renderRewrite(def.rewrite))}`,
|
|
240
|
+
` custom string nomos:from = ${usda(input.domain)}`,
|
|
241
|
+
` }`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
lines.push(`}`, ``);
|
|
245
|
+
}
|
|
246
|
+
return lines.join("\n");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The result of {@link emitLayeredRelationUsda}: a root document + one sublayer file per domain. */
|
|
250
|
+
export interface LayeredRelationEmit {
|
|
251
|
+
/** The root `.usda` (`subLayers = [...]`, STRONGEST-FIRST) that usdcat --flatten composes. */
|
|
252
|
+
readonly root: string;
|
|
253
|
+
/** Per-domain sublayer files (`name` is a bare filename; write beside the root). */
|
|
254
|
+
readonly files: { readonly name: string; readonly text: string }[];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Emit a REAL layered relation `.usda` set: one sublayer per domain (its relation opinions as prims)
|
|
259
|
+
* + a root with `subLayers` STRONGEST-FIRST, so `usdcat --flatten <root>` composes the package's
|
|
260
|
+
* shared relation types. `inputs` are in package order (WEAK → STRONG); the root reverses them to
|
|
261
|
+
* strongest-first (the documented convention). Deterministic (sorted within each layer, stable file
|
|
262
|
+
* names). Additive build artifact — never feeds the package hash.
|
|
263
|
+
*/
|
|
264
|
+
export function emitLayeredRelationUsda(inputs: readonly RelationLayerInput[], packageName: string): LayeredRelationEmit {
|
|
265
|
+
const safe = (s: string) => s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
266
|
+
const files = inputs.map((input) => ({
|
|
267
|
+
name: `${safe(input.domain)}.usda`,
|
|
268
|
+
text: emitRelationSublayerUsda(input),
|
|
269
|
+
}));
|
|
270
|
+
// subLayers is STRONGEST-FIRST → reverse the weak→strong package order.
|
|
271
|
+
const strongestFirst = [...files].reverse();
|
|
272
|
+
const root = [
|
|
273
|
+
`#usda 1.0`,
|
|
274
|
+
`(`,
|
|
275
|
+
` doc = """Nomos relation-composition layers for package ${packageName}. subLayers are`,
|
|
276
|
+
`STRONGEST-FIRST (USD layer-stack order); the Nomos domain list composes weak→strong (later`,
|
|
277
|
+
`wins). \`usdcat --flatten\` composes the shared relation types; the compiler's TS composition`,
|
|
278
|
+
`(composeSharedRelationTypes) is the fail-closed guard layered on top."""`,
|
|
279
|
+
` subLayers = [`,
|
|
280
|
+
...strongestFirst.map((f, i) => ` @./${f.name}@${i < strongestFirst.length - 1 ? "," : ""}`),
|
|
281
|
+
` ]`,
|
|
282
|
+
`)`,
|
|
283
|
+
``,
|
|
284
|
+
].join("\n");
|
|
285
|
+
return { root, files };
|
|
286
|
+
}
|
package/src/usd.ts
CHANGED
|
@@ -44,6 +44,7 @@ import type { DomainModule } from "./codegen_dart.js";
|
|
|
44
44
|
import { domainInterface } from "./shape_compat.js";
|
|
45
45
|
import { relationType, user, key, anyUser, qualifiedTypeId, qualifyRelationType } from "./relation_schema.js";
|
|
46
46
|
import { harvestPrincipalFlows } from "./wire_encode.js";
|
|
47
|
+
import { composeSharedRelationTypes } from "./relation_composition.js";
|
|
47
48
|
|
|
48
49
|
/** The referential marker of a directive prim (kernel `RefMode` wire string). */
|
|
49
50
|
export type RefMode = WireRefMode;
|
|
@@ -1060,7 +1061,11 @@ export function emitUsd(
|
|
|
1060
1061
|
};
|
|
1061
1062
|
})
|
|
1062
1063
|
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
1063
|
-
|
|
1064
|
+
// COMPILE-TIME COMPOSITION + fail-closed GUARD for SHARED (global) relation types across the
|
|
1065
|
+
// package's per-domain layers: UNION every domain's relations on a shared bare-id type (so nothing
|
|
1066
|
+
// is silently dropped) and REFUSE on a divergent shared rewrite (the scalpel). Non-shared /
|
|
1067
|
+
// auto-qualified types are untouched → a package with no shared global type is byte-identical.
|
|
1068
|
+
return { layers: composeSharedRelationTypes(out) };
|
|
1064
1069
|
}
|
|
1065
1070
|
|
|
1066
1071
|
/** Two aggregate prims at the same path are identical iff same type + same schema. */
|
package/src/usd_layers.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
type UsdPrim,
|
|
71
71
|
type UsdQuery,
|
|
72
72
|
} from "./usd.js";
|
|
73
|
+
import { composeSharedRelationTypes } from "./relation_composition.js";
|
|
73
74
|
|
|
74
75
|
/** The layer-file format marker (customLayerData `nomos:format`). */
|
|
75
76
|
export const NOMOS_USD_LAYER_FORMAT = "nomos.usd-layer.v1";
|
|
@@ -865,7 +866,10 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
|
|
|
865
866
|
};
|
|
866
867
|
});
|
|
867
868
|
|
|
868
|
-
|
|
869
|
+
// Apply the SAME cross-layer shared-relation composition + guard the composed emission
|
|
870
|
+
// (`usd.ts::emitUsd`) applies, so the layered fold stays byte-homomorphic with it (a divergent
|
|
871
|
+
// shared global type refuses identically on both paths).
|
|
872
|
+
return { layers: composeSharedRelationTypes(out) };
|
|
869
873
|
}
|
|
870
874
|
|
|
871
875
|
// ── usda → IR (the round-trip parser for the pxr/usdcat conformance lane) ──────────
|