@githolon/dsl 0.105.13 → 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.
@@ -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,
package/src/law_graph.ts CHANGED
@@ -745,10 +745,20 @@ export type DeclaredLawNode =
745
745
  export interface LawGraph {
746
746
  readonly v: 1;
747
747
  readonly domain: LawDomainNode;
748
+ /** Explicit tombstones authored in evolution files. The compiler registry remembers
749
+ * every disappearance, but only these candidate-carried facts authorize retirement. */
750
+ readonly retirements: readonly LawRetirement[];
748
751
  /** Canonically SID-ordered, so module/export discovery order cannot affect traversal. */
749
752
  readonly nodes: readonly DeclaredLawNode[];
750
753
  }
751
754
 
755
+ export interface LawRetirement {
756
+ readonly kind: "aggregate" | "field";
757
+ readonly sid: string;
758
+ readonly label: string;
759
+ readonly parentSid?: string;
760
+ }
761
+
752
762
  export interface DiscoverLawGraphOptions {
753
763
  readonly intentCarries?: readonly LawIdentityCarry[];
754
764
  readonly readCarries?: readonly LawIdentityCarry[];
@@ -2273,7 +2283,54 @@ export function discoverLawGraph(
2273
2283
  ...predicateNodes.values(),
2274
2284
  ].sort((a, b) => compareText(a.entity.id, b.entity.id));
2275
2285
  registry = identities.finish();
2276
- return { registry, graph: { v: 1, domain: domainNode, nodes } };
2286
+ const identityEntity = (
2287
+ kind: "aggregate" | "field",
2288
+ parent: string,
2289
+ label: string,
2290
+ status?: "active" | "retired",
2291
+ ) => {
2292
+ const matches = Object.entries(registry.entities).filter(([, candidate]) =>
2293
+ candidate.kind === kind &&
2294
+ candidate.parent === parent &&
2295
+ (status === undefined || candidate.status === status) &&
2296
+ (candidate.label === label || candidate.priorLabels?.includes(label) === true)
2297
+ );
2298
+ if (matches.length !== 1) {
2299
+ fail(
2300
+ `${kind} retirement '${label}' resolves to ${matches.length} prior identities under '${parent}'; ` +
2301
+ "a retirement must name exactly one declaration from the committed identity layer",
2302
+ );
2303
+ }
2304
+ return matches[0]!;
2305
+ };
2306
+ const retirements = (module.retirements ?? []).map((declaration): LawRetirement => {
2307
+ const [aggregateKey, aggregate] = identityEntity(
2308
+ "aggregate",
2309
+ domainKey,
2310
+ declaration.aggregate,
2311
+ declaration.kind === "aggregate" ? "retired" : undefined,
2312
+ );
2313
+ if (declaration.kind === "aggregate") {
2314
+ return {
2315
+ kind: "aggregate",
2316
+ sid: aggregate.sid,
2317
+ label: aggregate.label,
2318
+ };
2319
+ }
2320
+ const [, field] = identityEntity(
2321
+ "field",
2322
+ aggregateKey,
2323
+ declaration.field ?? "",
2324
+ "retired",
2325
+ );
2326
+ return {
2327
+ kind: "field",
2328
+ sid: field.sid,
2329
+ label: field.label,
2330
+ parentSid: aggregate.sid,
2331
+ };
2332
+ }).sort((left, right) => compareText(`${left.kind}:${left.sid}`, `${right.kind}:${right.sid}`));
2333
+ return { registry, graph: { v: 1, domain: domainNode, retirements, nodes } };
2277
2334
  }
2278
2335
 
2279
2336
  type ReadInputBase = { id: string; public: boolean };
@@ -17,6 +17,7 @@ import type {
17
17
  LawPredicateNode,
18
18
  LawReadContractNode,
19
19
  LawReadNode,
20
+ LawRetirement,
20
21
  LawRelationNode,
21
22
  LawRelationObjectTypeNode,
22
23
  LawRelationPredicateNode,
@@ -586,6 +587,19 @@ export function emitSparseLawGraphUsda(
586
587
  ` custom uniform string nomos:parentCertField = ${quote(domain.parentAttestedBirth.certField)}`,
587
588
  );
588
589
  }
590
+ if (graph.retirements.length > 0) {
591
+ lines.push("", ' def Scope "Retirements"', " {");
592
+ for (const retirement of graph.retirements) {
593
+ const name = `${retirement.kind === "aggregate" ? "a" : "f"}_${retirement.sid}`;
594
+ lines.push(
595
+ ` def NomosRetirement ${quote(name)}`,
596
+ " {",
597
+ ...emitRetirement(retirement, 20),
598
+ " }",
599
+ );
600
+ }
601
+ lines.push(" }");
602
+ }
589
603
  lines.push("");
590
604
  emitContainer(lines, "Aggregates", aggregates, 12, (aggregate, indent) =>
591
605
  emitAggregate(aggregate, fields, pathById, resolveDependency, indent));
@@ -664,6 +678,18 @@ export function emitSparseLawGraphUsda(
664
678
  return lines.join("\n");
665
679
  }
666
680
 
681
+ function emitRetirement(retirement: LawRetirement, indent: number): string[] {
682
+ const pad = " ".repeat(indent);
683
+ return [
684
+ `${pad}custom uniform token nomos:entityKind = ${quote(retirement.kind)}`,
685
+ `${pad}custom uniform string nomos:label = ${quote(retirement.label)}`,
686
+ `${pad}custom uniform string nomos:sid = ${quote(retirement.sid)}`,
687
+ ...(retirement.parentSid === undefined
688
+ ? []
689
+ : [`${pad}custom uniform string nomos:parentSid = ${quote(retirement.parentSid)}`]),
690
+ ];
691
+ }
692
+
667
693
  function emitAggregate(
668
694
  aggregate: LawAggregateNode,
669
695
  fields: readonly LawFieldNode[],
@@ -170,7 +170,7 @@ function fieldEvolutionRules(
170
170
  fieldSid,
171
171
  declaration.source.kind,
172
172
  source.contract,
173
- driver,
173
+ encodeDriver(declaration.source.driver),
174
174
  );
175
175
  if (declaration.mode === "identity" && !safeIdentityProjection(source.shape, target.shape)) {
176
176
  throw new Error(