@githolon/dsl 0.105.13 → 0.105.15

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.
@@ -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
+ }
@@ -7,9 +7,9 @@
7
7
  * THE EVOLVE PREVIEW — `githolon check --against <prior>` (architecture/author_time_gate.md,
8
8
  * the `--against` row of check slice 3). The evolve gate is the kernel's #58 diff-by-stable-id
9
9
  * (executor/src/law58.rs::evolve_violation): a RENAME (same sid, new label) admits silently; a
10
- * true REMOVAL / merge-driver change refuses TYPED unless the installation offer carries a
11
- * `dispositions` acknowledgement. A changed BUSINESS VALUE CONTRACT is stricter: it needs
12
- * deterministic `.evolvesFrom(...)` coverage and cannot be waived by an acknowledgement.
10
+ * true REMOVAL / merge-driver change refuses TYPED unless the candidate law itself carries the
11
+ * corresponding retirement or deterministic evolution. Application/deployment code never authors
12
+ * stable-id waivers.
13
13
  *
14
14
  * This module is the AUTHOR-TIME TWIN of that gate: given the PRIOR law and the CURRENT build,
15
15
  * it diffs them BY STABLE ID and pre-fires the gate's verdicts — with the EXACT generated
@@ -18,10 +18,10 @@
18
18
  * RENAME (note) — same sid, new label; admits silently, old rows read the new label.
19
19
  * ADDITIVE (note) — a new aggregate/field/directive (incl. an additive optional VO field);
20
20
  * the evolve gate admits it silently (vo_first_class.md slice 6).
21
- * REMOVAL (refuse) — a sid disappeared; the gate REFUSES without dispositions.retired:[sid].
21
+ * REMOVAL (refuse) — a sid disappeared; declare a typed retirement in the evolution layer.
22
22
  * CONTRACT (refuse) — same sid, changed value language without a deterministic projection;
23
23
  * needs `.evolvesFrom(...)`, never a disposition.
24
- * DRIVER (refuse) — same sid, changed merge driver; needs dispositions.retyped.
24
+ * DRIVER (refuse) — same sid, changed merge driver; needs a typed evolution or a new field.
25
25
  * PAYLOAD (warn) — a directive's payload shape changed; clients compiled against the old
26
26
  * law get typed 422s until regenerated (directives are not sid-tracked).
27
27
  *
@@ -66,19 +66,17 @@ export interface EvolveDomain {
66
66
  readonly aggregates: Record<string, EvolveAggregate>;
67
67
  /** keyed by DIRECTIVE ID (directives are not sid-tracked — the dispatched name IS identity). */
68
68
  readonly directives: Record<string, DirectivePayloadShape>;
69
+ /** Authored candidate tombstones, kept label-shaped until the compiler resolves them to sids. */
70
+ readonly retirements?: readonly {
71
+ kind: "aggregate" | "field";
72
+ aggregate: string;
73
+ field?: string;
74
+ }[];
69
75
  }
70
76
 
71
77
  /** A whole package's evolve surface, keyed by DOMAIN KEY. */
72
78
  export type EvolveLaw = Record<string, EvolveDomain>;
73
79
 
74
- /** The dispositions fragment the installDomain offer carries to admit destructive evolves (the exact
75
- * shape executor/src/law58.rs::dispositions_from_payload parses). */
76
- export interface EvolveDispositionsJson {
77
- retired?: string[];
78
- retyped?: Record<string, string>;
79
- rebinds?: Record<string, string>;
80
- }
81
-
82
80
  // ── normalizers: build an EvolveLaw from the artifacts a dev already has ────────────
83
81
 
84
82
  /** Build ONE domain's aggregate surface from a #58 `StableIds` block (+ optional driver
@@ -217,29 +215,39 @@ export function currentEvolveLaw(
217
215
  const stableAggregate = Object.values(stableIds.aggregates).find(
218
216
  (candidate) => candidate.sid === agg.sid,
219
217
  );
220
- const schema = locked?.schemas?.[agg.label];
218
+ const stableFields = new Map(
219
+ Object.values(stableAggregate?.fields ?? {})
220
+ .map((field) => [field.sid, field] as const),
221
+ );
222
+ const schema = locked?.schemas?.[agg.label]
223
+ ?? Object.entries(locked?.schemas ?? {}).find(([type]) => type.endsWith(`.${agg.label}`))?.[1];
221
224
  for (const f of Object.values(agg.fields)) {
222
- const stableField = Object.values(stableAggregate?.fields ?? {}).find(
223
- (candidate) => candidate.sid === f.sid,
224
- );
225
+ const stableField = stableFields.get(f.sid);
225
226
  const driver = stableField?.driver ?? schema?.[f.label];
226
227
  if (driver !== undefined) (f as { driver?: string }).driver = JSON.stringify(driver);
228
+ if (stableField?.contract !== undefined) f.contract = stableField.contract;
229
+ if (stableField?.schema !== undefined) f.schema = stableField.schema;
230
+ if (stableField?.evolutions !== undefined) {
231
+ f.evolutions = stableField.evolutions.map((rule) => ({ from: rule.from, to: rule.to }));
232
+ }
227
233
  }
228
234
  }
229
235
  }
230
- law[key] = dom;
236
+ law[key] = {
237
+ ...dom,
238
+ ...(mod.retirements !== undefined && mod.retirements.length > 0
239
+ ? { retirements: mod.retirements }
240
+ : {}),
241
+ };
231
242
  }
232
243
  return law;
233
244
  }
234
245
 
235
246
  // ── the diff ────────────────────────────────────────────────────────────────────────
236
247
 
237
- /** The full preview result: the findings AND the merged dispositions the destructive findings
238
- * demand, keyed by DOMAIN KEY (the object carried by an installDomain offer). */
248
+ /** The full author-time preview result. Mechanical transition authority stays in candidate law. */
239
249
  export interface EvolveDiffResult {
240
250
  readonly findings: Finding[];
241
- /** domain key → the dispositions fragment that makes THIS domain's destructive evolves admit. */
242
- readonly dispositions: Record<string, EvolveDispositionsJson>;
243
251
  }
244
252
 
245
253
  /** A short, bounded render of a directive's payload field shape (for a warn's old-vs-new naming). */
@@ -250,10 +258,6 @@ function payloadShapeText(shape: DirectivePayloadShape): string {
250
258
  return `{ ${parts.join(", ")} }`;
251
259
  }
252
260
 
253
- function ensureDisp(map: Record<string, EvolveDispositionsJson>, domain: string): EvolveDispositionsJson {
254
- return (map[domain] ??= {});
255
- }
256
-
257
261
  /** Mirror executor::law58::evolution_reaches: one deterministic route per source,
258
262
  * accumulated across supported releases, must terminate at the current schema. */
259
263
  function evolutionReaches(
@@ -276,13 +280,12 @@ function evolutionReaches(
276
280
  return true;
277
281
  }
278
282
 
279
- /** Diff one domain's aggregates/fields/directives BY STABLE ID and append findings + dispositions. */
283
+ /** Diff one domain's aggregates/fields/directives BY STABLE ID and append findings. */
280
284
  function diffDomain(
281
285
  domain: string,
282
286
  prior: EvolveDomain,
283
287
  current: EvolveDomain,
284
288
  findings: Finding[],
285
- disp: Record<string, EvolveDispositionsJson>,
286
289
  ): void {
287
290
  const curAggBySid = current.aggregates;
288
291
  const priorAggBySid = prior.aggregates;
@@ -290,14 +293,18 @@ function diffDomain(
290
293
  for (const [sid, oldAgg] of Object.entries(priorAggBySid)) {
291
294
  const newAgg = curAggBySid[sid];
292
295
  if (newAgg === undefined) {
293
- // REMOVAL of a whole aggregate — retiring the agg sid satisfies the kernel's diff_by_sid.
294
- const d = ensureDisp(disp, domain);
295
- (d.retired ??= []).push(sid);
296
+ const declared = current.retirements?.some(
297
+ (retirement) => retirement.kind === "aggregate" && retirement.aggregate === oldAgg.label,
298
+ ) ?? false;
296
299
  findings.push({
297
300
  domain,
298
- level: "refuse",
299
- what: `aggregate '${oldAgg.label}' (stable id ${sid}) is REMOVED — the evolve gate will refuse without a disposition`,
300
- fix: `include in the installation offer: "dispositions": { "retired": ["${sid}"] }`,
301
+ level: declared ? "note" : "refuse",
302
+ what: declared
303
+ ? `aggregate '${oldAgg.label}' (stable id ${sid}) is RETIRED by candidate law`
304
+ : `aggregate '${oldAgg.label}' (stable id ${sid}) is REMOVED without a candidate-law retirement`,
305
+ fix: declared
306
+ ? "the compiler will lower this declaration to a typed USDA tombstone"
307
+ : `create evolutions/${domain}/<concern>.evolution.ts and export retirement(formerAggregate("${oldAgg.label}"))`,
301
308
  });
302
309
  continue;
303
310
  }
@@ -313,13 +320,20 @@ function diffDomain(
313
320
  for (const [fSid, oldF] of Object.entries(oldAgg.fields)) {
314
321
  const newF = newAgg.fields[fSid];
315
322
  if (newF === undefined) {
316
- const d = ensureDisp(disp, domain);
317
- (d.retired ??= []).push(fSid);
323
+ const declared = current.retirements?.some(
324
+ (retirement) => retirement.kind === "field"
325
+ && retirement.aggregate === oldAgg.label
326
+ && retirement.field === oldF.label,
327
+ ) ?? false;
318
328
  findings.push({
319
329
  domain,
320
- level: "refuse",
321
- what: `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) is REMOVED — the evolve gate will refuse without a disposition`,
322
- fix: `include in the installation offer: "dispositions": { "retired": ["${fSid}"] }`,
330
+ level: declared ? "note" : "refuse",
331
+ what: declared
332
+ ? `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) is RETIRED by candidate law`
333
+ : `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) is REMOVED without a candidate-law retirement`,
334
+ fix: declared
335
+ ? "the compiler will lower this declaration to a typed USDA tombstone"
336
+ : `create evolutions/${domain}/<concern>.evolution.ts, name formerAggregate("${oldAgg.label}", ["${oldF.label}"]), and export its retirement`,
323
337
  });
324
338
  continue;
325
339
  }
@@ -336,27 +350,26 @@ function diffDomain(
336
350
  oldF.driver !== undefined && newF.driver !== undefined && oldF.driver !== newF.driver;
337
351
  const contractChanged =
338
352
  oldF.contract !== undefined && newF.contract !== undefined && oldF.contract !== newF.contract;
339
- const contractCovered = contractChanged && evolutionReaches(newF, oldF.schema, newF.schema);
353
+ const evolutionCovered = evolutionReaches(newF, oldF.schema, newF.schema);
354
+ const contractCovered = contractChanged && evolutionCovered;
340
355
  if (contractChanged && !contractCovered) {
341
356
  findings.push({
342
357
  domain,
343
358
  level: "refuse",
344
359
  what: `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) changes its business value contract without deterministic evolution coverage`,
345
- fix: `declare .evolvesFrom(oldField) for a compatible widening or .evolvesFrom(oldField, value => nextValue) for an exhaustive finite business mapping; dispositions.retyped cannot waive this`,
360
+ fix: `in evolutions/${domain}/<concern>.evolution.ts export evolution(CurrentAggregate, "${newF.label}").from(oldField), optionally with an exhaustive finite business mapping`,
346
361
  });
347
362
  continue;
348
363
  }
349
- if ((kindChanged && !contractCovered) || driverChanged) {
350
- const d = ensureDisp(disp, domain);
351
- (d.retyped ??= {})[fSid] = "<why goes here>";
364
+ if ((kindChanged || driverChanged) && !evolutionCovered) {
352
365
  const how = kindChanged && !contractCovered
353
366
  ? `read kind ${oldF.kind} → ${newF.kind}`
354
367
  : `merge driver ${oldF.driver} → ${newF.driver}`;
355
368
  findings.push({
356
369
  domain,
357
370
  level: "refuse",
358
- what: `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) is RETYPED in place (${how}) — the evolve gate will refuse without a disposition`,
359
- fix: `include in the installation offer: "dispositions": { "retyped": { "${fSid}": "<why goes here>" } } — or add a NEW field instead`,
371
+ what: `field '${oldAgg.label}.${oldF.label}' (stable id ${fSid}) changes in place (${how}) without a candidate-law evolution`,
372
+ fix: `declare the old field contract and export evolution(CurrentAggregate, "${newF.label}").from(oldField) from evolutions/${domain}/<concern>.evolution.ts — or add a new field`,
360
373
  });
361
374
  }
362
375
  }
@@ -433,31 +446,27 @@ function samePayloadShape(a: DirectivePayloadShape, b: DirectivePayloadShape): b
433
446
  }
434
447
 
435
448
  /**
436
- * The full evolve preview: diff PRIOR vs CURRENT law by stable id, returning the findings AND
437
- * the merged dispositions the destructive ones demand (per domain key). Domains present in only
438
- * one side are ADDITIVE (new domain — note) or a whole-domain REMOVAL (refuse per aggregate).
449
+ * The full evolve preview: diff PRIOR vs CURRENT law by stable id. Domains present in only one
450
+ * side are ADDITIVE (new domain — note) or a whole-domain REMOVAL (refuse per aggregate).
439
451
  */
440
452
  export function evolveDiff(prior: EvolveLaw, current: EvolveLaw): EvolveDiffResult {
441
453
  const findings: Finding[] = [];
442
- const dispositions: Record<string, EvolveDispositionsJson> = {};
443
454
 
444
455
  for (const [domain, priorDom] of Object.entries(prior)) {
445
456
  const currentDom = current[domain];
446
457
  if (currentDom === undefined) {
447
458
  // The whole domain uninstalled — every aggregate's stable identity disappears.
448
- const d = ensureDisp(dispositions, domain);
449
459
  for (const [sid, agg] of Object.entries(priorDom.aggregates)) {
450
- (d.retired ??= []).push(sid);
451
460
  findings.push({
452
461
  domain,
453
462
  level: "refuse",
454
463
  what: `domain '${domain}' aggregate '${agg.label}' (stable id ${sid}) disappears — the current build no longer carries domain '${domain}'`,
455
- fix: `restore the domain, or include "dispositions": { "retired": ["${sid}"] } in the replacement installation offer`,
464
+ fix: `restore the domain and retire its aggregates in a typed evolution layer before removing executable law`,
456
465
  });
457
466
  }
458
467
  continue;
459
468
  }
460
- diffDomain(domain, priorDom, currentDom, findings, dispositions);
469
+ diffDomain(domain, priorDom, currentDom, findings);
461
470
  }
462
471
  for (const [domain, currentDom] of Object.entries(current)) {
463
472
  if (prior[domain] !== undefined) continue;
@@ -471,7 +480,7 @@ export function evolveDiff(prior: EvolveLaw, current: EvolveLaw): EvolveDiffResu
471
480
 
472
481
  const rank = { refuse: 0, warn: 1, note: 2 } as const;
473
482
  findings.sort((a, b) => rank[a.level] - rank[b.level]);
474
- return { findings, dispositions };
483
+ return { findings };
475
484
  }
476
485
 
477
486
  /** The findings-only surface (the `evolvePreview(prior, current): Finding[]` contract). */
package/src/index.ts CHANGED
@@ -160,6 +160,16 @@ export {
160
160
  type ConflictPolicy,
161
161
  type ConflictRule,
162
162
  } from "./conflicts.js";
163
+ export {
164
+ evolution,
165
+ formerAggregate,
166
+ retirement,
167
+ type FieldEvolution,
168
+ type FieldEvolutionBuilder,
169
+ type FormerAggregate,
170
+ type FormerField,
171
+ type Retirement,
172
+ } from "./evolution.js";
163
173
  export {
164
174
  intent,
165
175
  language,