@githolon/dsl 0.105.12 → 0.105.14

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/src/codegen_ts.ts CHANGED
@@ -751,6 +751,9 @@ export interface TsClientOptions {
751
751
  readonly packageName: string;
752
752
  /** Content identity of the built typed USDA closure (`.law.usdz`). */
753
753
  readonly domainHash: string;
754
+ /** Sibling lossless USDZ transport carried by the generated client when its signed-in workspace
755
+ * still serves the predecessor model. The archive is never canonical state and is not embedded. */
756
+ readonly lawAssetName?: string;
754
757
  /** The package's STRUCTURAL INTERFACE (#72) — directives (payload shapes) + aggregates
755
758
  * (read shapes, sid-keyed), keyed by domain. Baked in so `connect()` can negotiate
756
759
  * shape-compatibility against the workspace's installed offer — NOT by hash. */
@@ -819,6 +822,7 @@ function emitTsActivityBlock(entries: readonly PackageActivityEntry[], describeM
819
822
  `}`,
820
823
  `type NomosModelAdoption =`,
821
824
  ` | { status: "adopted"; summary: string; review: NomosModelReview }`,
825
+ ` | { status: "needs-staging"; summary: string; review: NomosModelReview; stage(): Promise<NomosModelAdoption> }`,
822
826
  ` | { status: "needs-decision"; summary: string; review: NomosModelReview; accept(because: string): Promise<NomosModelAdoption> }`,
823
827
  ` | { status: "blocked"; summary: string; error: string; review: NomosModelReview };`,
824
828
  `type NomosModelRestoration =`,
@@ -868,7 +872,7 @@ function emitTsActivityBlock(entries: readonly PackageActivityEntry[], describeM
868
872
  ` };`,
869
873
  `}`,
870
874
  `async function _nomosAdoptModel(`,
871
- ` holon: NomosHolon, domain: string, candidateHash: string, because?: string,`,
875
+ ` holon: NomosHolon, domain: string, candidateHash: string, lawAsset?: URL, because?: string,`,
872
876
  `): Promise<NomosModelAdoption> {`,
873
877
  ` if (!holon.__nomosAdoptModel) {`,
874
878
  ` const error = "This device needs the current Nomos client before it can adopt a model.";`,
@@ -879,6 +883,26 @@ function emitTsActivityBlock(entries: readonly PackageActivityEntry[], describeM
879
883
  ` if (raw.stage === "promoted" && raw.ok) {`,
880
884
  ` return { status: "adopted", summary: "Nomos proved this model against current history and made it active.", review };`,
881
885
  ` }`,
886
+ ` if (raw.stage === "staging-required" && lawAsset !== undefined && holon.__nomosStageGeneratedModel !== undefined) {`,
887
+ ` return {`,
888
+ ` status: "needs-staging",`,
889
+ ` summary: "This workspace is running the previous model. Nomos can stage this generated USDA candidate through its signed author gate.",`,
890
+ ` review,`,
891
+ ` stage: async () => {`,
892
+ ` // Keep Node's file loader behind a runtime-only boundary: browser-generated clients`,
893
+ ` // must typecheck without @types/node, while the local CLI can still stage its USDA asset.`,
894
+ ` const dynamicImport = Function("specifier", "return import(specifier)") as (specifier: string) => Promise<{ readFile(source: URL): Promise<Uint8Array> }>;`,
895
+ ` const { readFile } = await dynamicImport("node:fs/promises");`,
896
+ ` const lawUsdz = new Uint8Array(await readFile(lawAsset));`,
897
+ ` const staged = await holon.__nomosStageGeneratedModel!(domain, candidateHash, lawUsdz);`,
898
+ ` if (staged.ok !== true) {`,
899
+ ` const error = staged.error ?? "The workspace refused the generated USDA candidate.";`,
900
+ ` return { status: "blocked", summary: error, error, review };`,
901
+ ` }`,
902
+ ` return _nomosAdoptModel(holon, domain, candidateHash, lawAsset);`,
903
+ ` },`,
904
+ ` };`,
905
+ ` }`,
882
906
  ` if (raw.stage === "acceptance") {`,
883
907
  ` return {`,
884
908
  ` status: "needs-decision",`,
@@ -887,7 +911,7 @@ function emitTsActivityBlock(entries: readonly PackageActivityEntry[], describeM
887
911
  ` accept: async (reason: string) => {`,
888
912
  ` const stated = reason.trim();`,
889
913
  ` if (!stated) throw new Error("accept(reason) needs the business reason for changing prior outcomes");`,
890
- ` return _nomosAdoptModel(holon, domain, candidateHash, stated);`,
914
+ ` return _nomosAdoptModel(holon, domain, candidateHash, lawAsset, stated);`,
891
915
  ` },`,
892
916
  ` };`,
893
917
  ` }`,
@@ -1807,6 +1831,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1807
1831
  ` /** FRAMEWORK-INTERNAL exact-history adoption seam. Generated clients expose only`,
1808
1832
  ` * \`model.adopt()\` and a chained \`decision.accept(reason)\`. */`,
1809
1833
  ` __nomosAdoptModel?(domain: string, candidateHash: string, opts?: { because?: string }): Promise<__NomosModelAdoptionWire>;`,
1834
+ ` /** FRAMEWORK-INTERNAL signed lifecycle offer used by generated model staging. */`,
1835
+ ` __nomosStageGeneratedModel?(domain: string, candidateHash: string, lawUsdz: Uint8Array): Promise<{ ok?: boolean; error?: string }>;`,
1810
1836
  ` /** FRAMEWORK-INTERNAL exact-history restoration seam. Generated clients expose only`,
1811
1837
  ` * \`model.restorePrevious()\` and a chained \`decision.accept(reason)\`. */`,
1812
1838
  ` __nomosRestorePreviousModel?(domain: string, currentHash: string, opts?: { because?: string }): Promise<__NomosModelRestorationWire>;`,
@@ -3844,7 +3870,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
3844
3870
  ` /** Adopt THIS generated model. Nomos proves it against current history; a deliberate`,
3845
3871
  ` * difference returns a decision whose accept(reason) re-proves and records the reason. */`,
3846
3872
  ` model: {`,
3847
- ` adopt: (): Promise<NomosModelAdoption> => _nomosAdoptModel(holon, ${JSON.stringify(domain)}, domainHash),`,
3873
+ ` adopt: (): Promise<NomosModelAdoption> => _nomosAdoptModel(holon, ${JSON.stringify(domain)}, domainHash${opts.lawAssetName === undefined ? "" : `, new URL(${JSON.stringify(`./${opts.lawAssetName}`)}, import.meta.url)`}),`,
3848
3874
  ` restorePrevious: (): Promise<NomosModelRestoration> => _nomosRestorePreviousModel(holon, ${JSON.stringify(domain)}, domainHash),`,
3849
3875
  ` },`,
3850
3876
  ...(clientInterface !== undefined
@@ -3971,6 +3997,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
3971
3997
  ` readonly signIn: (request?: { readonly deviceChallenge?: string }) => Promise<string>;`,
3972
3998
  ` /** Typed timings for developer tooling; Nomos routing, credentials and Git custody remain private. */`,
3973
3999
  ` readonly onPerformance?: (sample: ${applicationName}PerformanceSample) => void;`,
4000
+ ` /** Supply the accountable business decision when this client intentionally changes the deployed model. */`,
4001
+ ` readonly approveModel?: (decision: { readonly summary: string; readonly review: NomosModelReview }) => Promise<{ readonly because: string }>;`,
3974
4002
  `}`,
3975
4003
  `function _nomosPublicDomain<T extends Record<string, unknown>>(domain: T): _NomosPublicDomain<T> {`,
3976
4004
  ` const application: Record<string, unknown> = {};`,
@@ -4106,7 +4134,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
4106
4134
  ` * or parallel cache around it. Nomos owns offline custody and synchronization. */`,
4107
4135
  `export function ${applicationOpener}(options: ${applicationName}OpenOptions): Promise<${applicationName}> {`,
4108
4136
  ` return nomosInternalOpenGeneratedApplication(`,
4109
- ` { cloud: options.cloud, application: options.application, signIn: options.signIn, onPerformance: options.onPerformance, lawHash: ${hashConst}, readyCommands: [${rootBirths.map((birth) => `{ domain: ${JSON.stringify(birth.sourceDomain)}, packageHash: ${hashConst}, directiveId: ${JSON.stringify(birth.directiveId)} }`).join(", ")}] },`,
4137
+ ` { cloud: options.cloud, application: options.application, signIn: options.signIn, onPerformance: options.onPerformance, approveModel: options.approveModel, lawHash: ${hashConst}, readyCommands: [${rootBirths.map((birth) => `{ domain: ${JSON.stringify(birth.sourceDomain)}, packageHash: ${hashConst}, directiveId: ${JSON.stringify(birth.directiveId)} }`).join(", ")}] },`,
4110
4138
  ` (holon, user) => _nomosBind${applicationName}(holon as NomosHolon, user),`,
4111
4139
  ` );`,
4112
4140
  `}`,
@@ -64,6 +64,8 @@ import {
64
64
  } from "./codegen_dart.js";
65
65
  import type { ContractHandle } from "./contract.js";
66
66
  import { bindEnumTypeExports } from "./enum_type.js";
67
+ import { discoverEvolutionModulePaths, isEvolutionModulePath } from "./evolution_files.js";
68
+ import { assertEvolutionModuleExports } from "./evolution.js";
67
69
  import { generateDartBridgeClient } from "./codegen_dart_client.js";
68
70
  import {
69
71
  dartApplicationClassName,
@@ -154,6 +156,7 @@ import {
154
156
  serializeIdentityUsda,
155
157
  } from "./identity_usda.js";
156
158
  import { reconcileStableIdsWithIdentity } from "./stable_id_identity_bridge.js";
159
+ import { LAW_FRAMEWORK_PARENT } from "./law_identity.js";
157
160
  import { discoverLawGraph, type LawGraph } from "./law_graph.js";
158
161
  import { emitFrameworkLawUsda, emitSparseLawGraphUsda } from "./law_graph_usda.js";
159
162
  import { assertNoDartBarrelCollisions, type BarrelExportedFile } from "./dart_barrel_guard.js";
@@ -163,6 +166,7 @@ import { domainInterface, type DomainInterface } from "./shape_compat.js";
163
166
  import { generateTsProof, proofLegsIndex } from "./codegen_proof.js";
164
167
  import { usdzPack } from "./usdz.js";
165
168
  import { checkDomainModules, formatFindings, hasRefusal } from "./law_check.js";
169
+ import { currentEvolveLaw, evolveDiff, evolveLawFromLockfile } from "./evolve_preview.js";
166
170
  import type { NomosCompat } from "./compat_manifest.js";
167
171
  import { requiredKernelFeatures, type FeatureProbeModule } from "./kernel_capabilities.js";
168
172
  import { APPLICATION_HOME_DOMAIN } from "./framework/application_home_domain.js";
@@ -1660,8 +1664,8 @@ const NOMOS_TYPES_DEP = "^0.8.7";
1660
1664
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
1661
1665
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
1662
1666
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
1663
- const NOMOS_CLIENT_DEP = "^0.64.26";
1664
- const NOMOS_FLUTTER_DEP = "^0.63.50";
1667
+ const NOMOS_CLIENT_DEP = "^0.64.29";
1668
+ const NOMOS_FLUTTER_DEP = "^0.63.52";
1665
1669
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
1666
1670
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
1667
1671
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
@@ -3137,6 +3141,7 @@ async function main(): Promise<void> {
3137
3141
  fail(`each domain needs { key, modules: [path, ...] }; got ${JSON.stringify(d)}`);
3138
3142
  }
3139
3143
  const mods: Mod[] = [];
3144
+ const evolutionMods: Mod[] = [];
3140
3145
  const sourceExprs: string[] = [];
3141
3146
  const directOfferExports = new Map<string, { modulePath: string; exportName: string; aggregateId: string }>();
3142
3147
  const directWorkspaceInvariantExports = new Map<string, {
@@ -3145,6 +3150,12 @@ async function main(): Promise<void> {
3145
3150
  }>();
3146
3151
  for (const rel of d.modules) {
3147
3152
  const abs = path.resolve(cfgDir, rel);
3153
+ if (isEvolutionModulePath(abs)) {
3154
+ fail(
3155
+ `domain '${d.key}': ${rel} is an evolution concern; move it under ` +
3156
+ `evolutions/${d.key}/ and remove it from feature modules so it cannot enter QuickJS`,
3157
+ );
3158
+ }
3148
3159
  if (!existsSync(abs)) fail(`domain '${d.key}': module not found: ${abs}`);
3149
3160
  const m = (await import(pathToFileURL(abs).href)) as Mod;
3150
3161
  bindEnumTypeExports(m, abs);
@@ -3182,6 +3193,16 @@ async function main(): Promise<void> {
3182
3193
  }
3183
3194
  }
3184
3195
  }
3196
+ const configuredPaths = new Set(d.modules.map((rel) => path.resolve(cfgDir, rel)));
3197
+ for (const abs of discoverEvolutionModulePaths(cfgDir, d.key)) {
3198
+ // Explicitly listed files remain supported during migration, but the same
3199
+ // module must not be composed twice when it is already in the convention.
3200
+ if (configuredPaths.has(abs)) continue;
3201
+ const evolutionModule = (await import(pathToFileURL(abs).href)) as Mod;
3202
+ assertEvolutionModuleExports(evolutionModule, abs);
3203
+ bindEnumTypeExports(evolutionModule, abs);
3204
+ evolutionMods.push(evolutionModule);
3205
+ }
3185
3206
  if (mods.some((mod) => Object.prototype.hasOwnProperty.call(mod, BUSINESS_BIRTH_POLICY_DOMAINS_EXPORT))) {
3186
3207
  fail(
3187
3208
  `domain '${d.key}' exports reserved compiler binding '${BUSINESS_BIRTH_POLICY_DOMAINS_EXPORT}'; ` +
@@ -3502,7 +3523,7 @@ async function main(): Promise<void> {
3502
3523
  composeDomainModule({
3503
3524
  name: d.name ?? d.key,
3504
3525
  domain: d.key,
3505
- modules: mods,
3526
+ modules: [...mods, ...evolutionMods],
3506
3527
  ...(extraAggregates.length > 0 ? { extraAggregates } : {}),
3507
3528
  ...(queries.length > 0 ? { queries } : {}),
3508
3529
  ...(counts.length > 0 ? { counts } : {}),
@@ -3748,7 +3769,8 @@ async function main(): Promise<void> {
3748
3769
  // exactly ONE disappeared + ONE appeared name of the same kind/shape is an inferred
3749
3770
  // RENAME (sid carried, lineage recorded, printed below); anything else mints fresh
3750
3771
  // at first appearance (a leftover disappearance is THE EVOLVE GATE's typed question
3751
- // at deploy — answered in the upgrade intent's disposition). The resolved continuity
3772
+ // at compile — answered by a typed declaration in evolutions/<domain>/*.evolution.ts).
3773
+ // The resolved continuity
3752
3774
  // is attached to the module so the canonical manifest + USD-IR carry it.
3753
3775
  const lockPath = path.join(cfgDir, NOMOS_IDENTITY_FILENAME);
3754
3776
  const priorLock: CompilerIdentityModel | undefined = existsSync(lockPath)
@@ -3891,6 +3913,63 @@ async function main(): Promise<void> {
3891
3913
  schemas: Object.fromEntries(currentAggs.map((a) => [a.id, a.schema])),
3892
3914
  };
3893
3915
  }
3916
+
3917
+ // ── ORDINARY-COMPILE EVOLUTION GATE ──────────────────────────────────────────────
3918
+ // Agents reliably run `compile`; they do not reliably discover an optional migration command.
3919
+ // Compare the candidate against the committed identity frontier here, after stable-id
3920
+ // reconciliation (so inferred renames have their true identity) but before writing either the
3921
+ // lock or the custody package. The kernel independently repeats the same admission decision at
3922
+ // deployment; this is its fast, local static twin, not a replacement for runtime authority.
3923
+ //
3924
+ // The identity file is project-wide while a package may own only some of its domains. Scope the
3925
+ // prior side to the modules in this compile so compiling one sibling never reads another sibling's
3926
+ // absence as a domain retirement.
3927
+ if (priorLock !== undefined) {
3928
+ const priorDomainsForPackage: CompilerIdentityModel["domains"] = {};
3929
+ const priorIdentity = identityRegistryFromModel(priorLock);
3930
+ const frameworkAggregateSids = new Set(
3931
+ Object.values(priorIdentity.entities)
3932
+ .filter((entity) => entity.kind === "aggregate" && entity.parent === LAW_FRAMEWORK_PARENT)
3933
+ .map((entity) => entity.sid),
3934
+ );
3935
+ for (const mod of domainModules) {
3936
+ const domainKey = mod.domain ?? mod.name;
3937
+ const locked = priorLock.domains[mod.name] ?? priorLock.domains[domainKey];
3938
+ if (locked !== undefined) {
3939
+ // Framework aggregates are shared implementation law, reconciled under @framework rather
3940
+ // than owned by this tenant domain. A package may compose a different framework subset as
3941
+ // its topology changes; never instruct a tenant to author a retirement for Nomos internals.
3942
+ const localAggregates = Object.fromEntries(
3943
+ Object.entries(locked.stableIds.aggregates)
3944
+ .filter(([, aggregate]) => !frameworkAggregateSids.has(aggregate.sid)),
3945
+ );
3946
+ priorDomainsForPackage[domainKey] = {
3947
+ stableIds: { ...locked.stableIds, aggregates: localAggregates },
3948
+ ...(locked.schemas === undefined
3949
+ ? {}
3950
+ : {
3951
+ schemas: Object.fromEntries(
3952
+ Object.entries(locked.schemas).filter(([label]) => label in localAggregates),
3953
+ ),
3954
+ }),
3955
+ };
3956
+ }
3957
+ }
3958
+ const priorLaw = evolveLawFromLockfile({
3959
+ v: 1,
3960
+ domains: priorDomainsForPackage,
3961
+ ...(priorLock.identity === undefined ? {} : { identity: priorLock.identity }),
3962
+ });
3963
+ const candidateLaw = currentEvolveLaw(domainModules, nextLock);
3964
+ const evolutionFindings = evolveDiff(priorLaw, candidateLaw).findings;
3965
+ const evolutionRefusals = evolutionFindings.filter((finding) => finding.level === "refuse");
3966
+ if (evolutionRefusals.length > 0) {
3967
+ console.error("nomos-compile: candidate law evolution refused before custody emission:");
3968
+ for (const line of formatFindings(evolutionRefusals)) console.error(` ✗ ${line}`);
3969
+ process.exitCode = 1;
3970
+ return;
3971
+ }
3972
+ }
3894
3973
  // Persist the typed identity layer (commit it with the law: it is how a rename keeps its
3895
3974
  // identity across clean clones). Existing project-domain order is retained, so once sibling
3896
3975
  // packages are registered, recompiling them in any order rewrites the same bytes without a
@@ -5214,8 +5293,11 @@ async function main(): Promise<void> {
5214
5293
  : `package asset: NOT EMITTED — declare rootPart: { name, kind: "component" | "assembly" }`,
5215
5294
  `typed application: ${cfg.name}.client.mjs — open${cfg.name.split(/[^A-Za-z0-9]+/).filter(Boolean).map((part) => part[0]!.toUpperCase() + part.slice(1)).join("")}Application({ cloud, signIn })`,
5216
5295
  proofPath !== undefined
5217
- ? `generated proof: ${cfg.name}.proof.mts — run: githolon proof (live; ends ALL GREEN or names the jam)`
5296
+ ? `generated proof: ${cfg.name}.proof.mts — fresh: githolon proof; existing custody: githolon proof --upgrade`
5218
5297
  : `generated proof: SKIPPED — ${proofSkip}`,
5298
+ ...(priorLock === undefined
5299
+ ? []
5300
+ : [`upgrade proof: githolon proof --upgrade — exact existing history, disposable promotion, cold restart`]),
5219
5301
  ``,
5220
5302
  ].join("\n");
5221
5303
  writeFileSync(path.join(outDir, `${cfg.name}.summary.txt`), summaryTxt, "utf8");
@@ -5258,6 +5340,7 @@ async function main(): Promise<void> {
5258
5340
  const clientOptions = {
5259
5341
  packageName: cfg.name,
5260
5342
  domainHash,
5343
+ lawAssetName: `${cfg.name}.law.usdz`,
5261
5344
  interface: packageInterface,
5262
5345
  compat: nomosCompat,
5263
5346
  homeDomain: resolveApplicationTopology(presentationDomainModules).homeDomain,
@@ -5271,6 +5354,11 @@ async function main(): Promise<void> {
5271
5354
  if (!existsSync(runtimePath)) {
5272
5355
  fail("the compiler-private generated application runtime is missing — reinstall @githolon/dsl");
5273
5356
  }
5357
+ // Validate the generated ESM clients as ESM even when the tenant project has no package.json yet.
5358
+ // NodeNext classifies a plain `.ts` file from the nearest package boundary, so a freshly scaffolded
5359
+ // or fixture-only project would otherwise be treated as CommonJS and reject the intentional
5360
+ // `import.meta.url` used to locate its adjacent law asset. The compiler options below describe the
5361
+ // generated `.mjs` runtime contract directly instead of inheriting unrelated tenant metadata.
5274
5362
  const temporaryClientPath = path.join(outDir, `.${cfg.name}.client.source.ts`);
5275
5363
  const temporaryHeadlessClientPath = path.join(outDir, `.${cfg.name}.headless.source.ts`);
5276
5364
  const temporaryRuntimeTypesPath = path.join(outDir, `.${cfg.name}.application-runtime.d.ts`);
@@ -5278,7 +5366,7 @@ async function main(): Promise<void> {
5278
5366
  const temporaryHeadlessDeclarationPath = temporaryHeadlessClientPath.replace(/\.ts$/, ".d.ts");
5279
5367
  const runtimeTypesSource = [
5280
5368
  `export declare function nomosInternalOpenGeneratedApplication<T extends object>(`,
5281
- ` options: { cloud: string; application: string; signIn: (request?: { readonly deviceChallenge?: string }) => Promise<string>; onPerformance?: (sample: { readonly operation: "open" | "offer" | "birth" | "read" | "sync" | "maintenance"; readonly name: string; readonly durationMs: number; readonly domain?: string; readonly directive?: string }) => void; lawHash: string; readyCommands?: readonly { readonly domain: string; readonly packageHash: string; readonly directiveId: string }[] },`,
5369
+ ` options: { cloud: string; application: string; signIn: (request?: { readonly deviceChallenge?: string }) => Promise<string>; onPerformance?: (sample: { readonly operation: "open" | "offer" | "birth" | "read" | "sync" | "maintenance"; readonly name: string; readonly durationMs: number; readonly domain?: string; readonly directive?: string }) => void; approveModel?: (decision: { readonly summary: string; readonly review: { checkedActions: number; candidateActions: number; sameOutcome: boolean | null; differences: { kind: "rules" | "outcome" | "evidence"; area?: string; action?: string; summary: string }[] } }) => Promise<{ readonly because: string }>; lawHash: string; readyCommands?: readonly { readonly domain: string; readonly packageHash: string; readonly directiveId: string }[] },`,
5282
5370
  ` bind: (holon: unknown, user: { readonly identityId: string; readonly identityAddress: string }) => T,`,
5283
5371
  `): Promise<T & {`,
5284
5372
  ` currentStatus(): Promise<any>;`,
@@ -5357,8 +5445,8 @@ async function main(): Promise<void> {
5357
5445
  [temporaryClientPath, temporaryHeadlessClientPath, temporaryRuntimeTypesPath],
5358
5446
  {
5359
5447
  target: ts.ScriptTarget.ES2022,
5360
- module: ts.ModuleKind.NodeNext,
5361
- moduleResolution: ts.ModuleResolutionKind.NodeNext,
5448
+ module: ts.ModuleKind.ESNext,
5449
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
5362
5450
  declaration: true,
5363
5451
  emitDeclarationOnly: true,
5364
5452
  noEmitOnError: true,
@@ -6042,12 +6130,15 @@ async function main(): Promise<void> {
6042
6130
  console.log(` deploy githolon deploy <workspace> --file ${rel(lawUsdzPath)}`);
6043
6131
  console.log(``);
6044
6132
  console.log(`your business path:`);
6133
+ if (priorLock !== undefined) {
6134
+ console.log(` npx githolon proof --upgrade # candidate -> exact existing history -> disposable promotion -> cold restart`);
6135
+ }
6045
6136
  if (proofPath !== undefined) {
6046
6137
  console.log(` npx githolon proof # generated birth -> verify -> durable restore -> business read-back`);
6047
6138
  console.log(` npx githolon dev # rerun that proof on every save`);
6048
6139
  console.log(` npx githolon proof --live # the same business proof against Nomos Cloud`);
6049
6140
  if (proofDomains.length > 1) {
6050
- console.log(` this package proves ${proofDomains.length} domains (${proofDomains.join(", ")}) — default '${proofDomains[0]}'; pick another: npx githolon proof --domain <key>`);
6141
+ console.log(` this package proves all ${proofDomains.length} business areas together (${proofDomains.join(", ")})`);
6051
6142
  }
6052
6143
  } else {
6053
6144
  console.log(` add a business birth declaration to generate an end-to-end proof`);
@@ -0,0 +1,313 @@
1
+ // NOMOS — business model evolution as a separate, composable concern.
2
+ //
3
+ // Current aggregate files describe what the business means now. An evolution file
4
+ // relates an earlier lawful field contract to that current meaning. The compiler
5
+ // discovers these typed declarations under evolutions/<domain>/**/*.evolution.ts,
6
+ // proves them, and lowers them into the same canonical USDA law. They are never host
7
+ // configuration and never deployment-time stable-id strings.
8
+
9
+ import type { AggregateHandle, StoredFields } from "./aggregate.js";
10
+ import type {
11
+ Field,
12
+ FieldEvolutionDeclaration,
13
+ FieldValue,
14
+ } from "./fields.js";
15
+
16
+ const EVOLUTION_BRAND = "__nomos_field_evolution_v1" as const;
17
+ const FORMER_AGGREGATE_BRAND = "__nomos_former_aggregate_v1" as const;
18
+ const FORMER_FIELD_BRAND = "__nomos_former_field_v1" as const;
19
+ const RETIREMENT_BRAND = "__nomos_retirement_v1" as const;
20
+
21
+ export interface FieldEvolution<
22
+ A extends AggregateHandle = AggregateHandle,
23
+ > {
24
+ readonly [EVOLUTION_BRAND]: true;
25
+ readonly aggregate: A;
26
+ readonly field: string;
27
+ readonly declaration: FieldEvolutionDeclaration;
28
+ }
29
+
30
+ export interface FieldEvolutionBuilder<T> {
31
+ /**
32
+ * Relate one earlier lawful field contract to the current field. With no
33
+ * projection, Nomos proves identity widening. A projection is admitted only
34
+ * for a finite source language and is exhaustively compiled to a case table.
35
+ */
36
+ from<From>(
37
+ source: Field<From>,
38
+ project?: (value: From) => T,
39
+ ): FieldEvolution;
40
+ }
41
+
42
+ export interface FormerField<AggregateId extends string = string, Name extends string = string> {
43
+ readonly [FORMER_FIELD_BRAND]: true;
44
+ readonly aggregate: AggregateId;
45
+ readonly name: Name;
46
+ }
47
+
48
+ export interface FormerAggregate<
49
+ Id extends string = string,
50
+ FieldName extends string = string,
51
+ > {
52
+ readonly [FORMER_AGGREGATE_BRAND]: true;
53
+ readonly id: Id;
54
+ readonly fields: Readonly<Record<FieldName, FormerField<Id, FieldName>>>;
55
+ }
56
+
57
+ export interface Retirement {
58
+ readonly [RETIREMENT_BRAND]: true;
59
+ readonly kind: "aggregate" | "field";
60
+ readonly aggregate: string;
61
+ readonly field?: string;
62
+ }
63
+
64
+ /**
65
+ * Name a declaration from the immediately preceding model without putting it
66
+ * back into the current business model. The names occur once at this historical
67
+ * declaration site; every retirement elsewhere refers to the returned handles.
68
+ */
69
+ export function formerAggregate<
70
+ const Id extends string,
71
+ const FieldName extends string,
72
+ >(
73
+ id: Id,
74
+ fieldNames: readonly FieldName[] = [],
75
+ ): FormerAggregate<Id, FieldName> {
76
+ if (!id.trim()) throw new Error("formerAggregate(id) needs the prior aggregate's non-empty name");
77
+ const fields: Record<string, FormerField<Id, FieldName>> = {};
78
+ for (const name of fieldNames) {
79
+ if (!name.trim()) throw new Error(`formerAggregate(${id}) has an empty prior field name`);
80
+ if (fields[name] !== undefined) throw new Error(`formerAggregate(${id}) repeats prior field '${name}'`);
81
+ fields[name] = Object.freeze({
82
+ [FORMER_FIELD_BRAND]: true as const,
83
+ aggregate: id,
84
+ name,
85
+ });
86
+ }
87
+ return Object.freeze({
88
+ [FORMER_AGGREGATE_BRAND]: true as const,
89
+ id,
90
+ fields: Object.freeze(fields) as Readonly<Record<FieldName, FormerField<Id, FieldName>>>,
91
+ });
92
+ }
93
+
94
+ /** Declare one intentional tombstone in a separate evolution file. */
95
+ export function retirement(
96
+ entity: FormerAggregate | FormerField,
97
+ ): Retirement {
98
+ if (entity === null || typeof entity !== "object") {
99
+ throw new Error("retirement(entity) needs a handle from formerAggregate(...)");
100
+ }
101
+ if ((entity as unknown as Record<string, unknown>)[FORMER_AGGREGATE_BRAND] === true) {
102
+ const aggregate = entity as FormerAggregate;
103
+ return Object.freeze({
104
+ [RETIREMENT_BRAND]: true as const,
105
+ kind: "aggregate" as const,
106
+ aggregate: aggregate.id,
107
+ });
108
+ }
109
+ if ((entity as unknown as Record<string, unknown>)[FORMER_FIELD_BRAND] === true) {
110
+ const field = entity as FormerField;
111
+ return Object.freeze({
112
+ [RETIREMENT_BRAND]: true as const,
113
+ kind: "field" as const,
114
+ aggregate: field.aggregate,
115
+ field: field.name,
116
+ });
117
+ }
118
+ throw new Error("retirement(entity) needs a handle from formerAggregate(...)");
119
+ }
120
+
121
+ /**
122
+ * Begin a separately authored evolution for one current business field.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * // evolutions/publication-reference.evolution.ts
127
+ * export const publicationReference = evolution(Snapshot, "publicationId")
128
+ * .from(legacyPublicationId);
129
+ * ```
130
+ */
131
+ export function evolution<
132
+ const Id extends string,
133
+ F extends Record<string, Field>,
134
+ K extends keyof StoredFields<F> & string,
135
+ >(
136
+ aggregate: AggregateHandle<Id, F>,
137
+ field: K,
138
+ ): FieldEvolutionBuilder<FieldValue<StoredFields<F>[K]>> {
139
+ const target = (aggregate.fields as Record<string, Field>)[field];
140
+ if (target === undefined) {
141
+ throw new Error(
142
+ `evolution(${aggregate.id}, ${JSON.stringify(field)}): '${field}' is not a stored field of ${aggregate.id}`,
143
+ );
144
+ }
145
+ return {
146
+ from<From>(source: Field<From>, project?: (value: From) => FieldValue<StoredFields<F>[K]>): FieldEvolution {
147
+ // Reuse the field declaration's single proof implementation. The returned
148
+ // field is only a temporary immutable value from which we take the one new
149
+ // edge; the current aggregate declaration is never mutated.
150
+ const evolved = target.evolvesFrom(
151
+ source,
152
+ project as ((value: From) => unknown) | undefined,
153
+ );
154
+ const declarations = evolved.evolutions ?? [];
155
+ const declaration = declarations[declarations.length - 1];
156
+ if (declaration === undefined) {
157
+ throw new Error(
158
+ `evolution(${aggregate.id}, ${JSON.stringify(field)}): failed to construct an evolution edge`,
159
+ );
160
+ }
161
+ return Object.freeze({
162
+ [EVOLUTION_BRAND]: true as const,
163
+ aggregate,
164
+ field,
165
+ declaration,
166
+ });
167
+ },
168
+ };
169
+ }
170
+
171
+ export function isFieldEvolution(value: unknown): value is FieldEvolution {
172
+ return (
173
+ value !== null &&
174
+ typeof value === "object" &&
175
+ (value as Record<string, unknown>)[EVOLUTION_BRAND] === true
176
+ );
177
+ }
178
+
179
+ export function isRetirement(value: unknown): value is Retirement {
180
+ return (
181
+ value !== null &&
182
+ typeof value === "object" &&
183
+ (value as Record<string, unknown>)[RETIREMENT_BRAND] === true
184
+ );
185
+ }
186
+
187
+ /** Evolution files are compiler-only by construction; executable/business exports would be law
188
+ * that is absent from QuickJS. Refuse that split-brain shape at the file boundary. */
189
+ export function assertEvolutionModuleExports(
190
+ module: Readonly<Record<string, unknown>>,
191
+ file: string,
192
+ ): void {
193
+ for (const [name, value] of Object.entries(module)) {
194
+ if (isFieldEvolution(value) || isRetirement(value)) continue;
195
+ throw new Error(
196
+ `${file}: evolution file export '${name}' is not evolution(...) or retirement(...). ` +
197
+ "Keep current aggregates, directives and helpers in business modules; evolution files " +
198
+ "are compiler-only historical law.",
199
+ );
200
+ }
201
+ }
202
+
203
+ /** Collect evolution edges without allowing re-exports to duplicate one object. */
204
+ export function fieldEvolutionsOf(
205
+ modules: readonly Record<string, unknown>[],
206
+ ): FieldEvolution[] {
207
+ const seen = new Set<FieldEvolution>();
208
+ const out: FieldEvolution[] = [];
209
+ for (const module of modules) {
210
+ for (const value of Object.values(module)) {
211
+ if (!isFieldEvolution(value) || seen.has(value)) continue;
212
+ seen.add(value);
213
+ out.push(value);
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /** Collect explicit tombstones; two files may not retire the same identity twice. */
220
+ export function retirementsOf(
221
+ modules: readonly Record<string, unknown>[],
222
+ domain: string,
223
+ ): Retirement[] {
224
+ const out: Retirement[] = [];
225
+ const keys = new Set<string>();
226
+ for (const module of modules) {
227
+ for (const value of Object.values(module)) {
228
+ if (!isRetirement(value)) continue;
229
+ const key = value.kind === "aggregate"
230
+ ? `aggregate:${value.aggregate}`
231
+ : `field:${value.aggregate}.${value.field ?? ""}`;
232
+ if (!keys.add(key)) {
233
+ throw new Error(`domain '${domain}' retires '${key}' more than once`);
234
+ }
235
+ out.push(value);
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+
241
+ function cloneFieldWithEvolution(
242
+ field: Field,
243
+ declaration: FieldEvolutionDeclaration,
244
+ ): Field {
245
+ const clone = Object.defineProperties(
246
+ {},
247
+ Object.getOwnPropertyDescriptors(field),
248
+ ) as Field;
249
+ Object.defineProperty(clone, "evolutions", {
250
+ value: Object.freeze([...(field.evolutions ?? []), declaration]),
251
+ enumerable: true,
252
+ configurable: true,
253
+ writable: false,
254
+ });
255
+ return clone;
256
+ }
257
+
258
+ /** Apply separately authored edges to the aggregate handles consumed by law emission. */
259
+ export function applyFieldEvolutionsToAggregates(
260
+ aggregates: readonly AggregateHandle[],
261
+ evolutions: readonly FieldEvolution[],
262
+ domain: string,
263
+ ): AggregateHandle[] {
264
+ const byAggregate = new Map(aggregates.map((aggregate) => [aggregate.id, aggregate]));
265
+ const byTarget = new Map<string, FieldEvolution[]>();
266
+ for (const declaration of evolutions) {
267
+ const aggregate = byAggregate.get(declaration.aggregate.id);
268
+ if (aggregate === undefined) {
269
+ throw new Error(
270
+ `domain '${domain}' exports an evolution for '${declaration.aggregate.id}.${declaration.field}' ` +
271
+ "but does not export the current aggregate itself",
272
+ );
273
+ }
274
+ if ((aggregate.fields as Record<string, Field>)[declaration.field] === undefined) {
275
+ throw new Error(
276
+ `domain '${domain}' exports an evolution for missing current field ` +
277
+ `'${declaration.aggregate.id}.${declaration.field}'`,
278
+ );
279
+ }
280
+ const key = `${declaration.aggregate.id}\u0000${declaration.field}`;
281
+ const current = byTarget.get(key) ?? [];
282
+ current.push(declaration);
283
+ byTarget.set(key, current);
284
+ }
285
+
286
+ return aggregates.map((aggregate) => {
287
+ const targets = [...byTarget]
288
+ .filter(([key]) => key.startsWith(`${aggregate.id}\u0000`))
289
+ .flatMap(([, declarations]) => declarations);
290
+ if (targets.length === 0) return aggregate;
291
+ const fields = Object.defineProperties(
292
+ {},
293
+ Object.getOwnPropertyDescriptors(aggregate.fields),
294
+ ) as Record<string, Field>;
295
+ for (const target of targets) {
296
+ fields[target.field] = cloneFieldWithEvolution(
297
+ fields[target.field]!,
298
+ target.declaration,
299
+ );
300
+ }
301
+ const clone = Object.defineProperties(
302
+ {},
303
+ Object.getOwnPropertyDescriptors(aggregate),
304
+ ) as AggregateHandle;
305
+ Object.defineProperty(clone, "fields", {
306
+ value: fields,
307
+ enumerable: true,
308
+ configurable: true,
309
+ writable: false,
310
+ });
311
+ return clone;
312
+ });
313
+ }
@@ -0,0 +1,31 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const EVOLUTION_MODULE_SUFFIX = /\.evolution\.(?:ts|tsx|mts|mjs|js)$/;
5
+
6
+ export function isEvolutionModulePath(file: string): boolean {
7
+ return EVOLUTION_MODULE_SUFFIX.test(file);
8
+ }
9
+
10
+ /**
11
+ * Discover compiler-only evolution concerns by convention. Both `compile` and
12
+ * `check` use this function so local assurance cannot inspect a different law
13
+ * closure from the one that will be deployed.
14
+ */
15
+ export function discoverEvolutionModulePaths(
16
+ configDirectory: string,
17
+ domainKey: string,
18
+ ): string[] {
19
+ const root = path.join(configDirectory, "evolutions", domainKey);
20
+ if (!existsSync(root)) return [];
21
+ const out: string[] = [];
22
+ const visit = (directory: string): void => {
23
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
24
+ const absolute = path.join(directory, entry.name);
25
+ if (entry.isDirectory()) visit(absolute);
26
+ else if (entry.isFile() && isEvolutionModulePath(entry.name)) out.push(absolute);
27
+ }
28
+ };
29
+ visit(root);
30
+ return out.sort((left, right) => left.localeCompare(right));
31
+ }