@githolon/dsl 0.67.0 → 0.69.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.
@@ -283,6 +283,27 @@ export const CloudWorkspace = aggregate("CloudWorkspace", {
283
283
  * (JSON), never `t.json`: it rides verbatim as a scalar (the kernel `Value` has no JSON variant — the same
284
284
  * reasoning as the auth keys). Absent on root (the base of the world tree, born under nothing). */
285
285
  bornUnder: t.string().merge(Lww).optional(),
286
+ /**
287
+ * SUPERSESSION (recovery verb 3 — {@link rebirthWorkspace}, architecture/rebirth_and_custody_lifecycle.md).
288
+ * "Supersession, never forgiveness": a chain scarred by a historical LAW violation cannot be retro-blessed;
289
+ * the lawful remedy is a REBIRTH — a new chain re-genesised from the current law + state that self-verifies
290
+ * from intent 0. These three ADDITIVE, append-only lineage facts annotate THAT act on the workspace's record
291
+ * (the workspace NAME survives; the chain identity changes). Absent = the workspace has never been reborn.
292
+ * The presence of {@link CloudWorkspace.supersededAt} is the committed LAW FACT the bailiff's `/rebirth-custody`
293
+ * verb gates on — the law authorizes, the bailiff executes the compaction+swap. Era-safe (optional Lww, additive sids).
294
+ *
295
+ * `supersededBy` — the new incarnation's genesis oid once custody has built + self-verified it (the receipt,
296
+ * authored by the same custodian via a follow-up rebirthWorkspace carrying `incarnation`); `""` until then.
297
+ */
298
+ supersededBy: t.string().merge(Lww).optional(),
299
+ /** WHY the chain was superseded — the custodian's mandatory justification (audit, on the ledger forever). */
300
+ supersessionReason: t.string().merge(Lww).optional(),
301
+ /** Epoch-ms the supersession was authored (caller-stamped) — its PRESENCE is the law fact custody gates on. */
302
+ supersededAt: t.int().merge(Lww).optional(),
303
+ /** LINEAGE CONTINUITY (ratified 2026-07-09): on a REBORN holon's OWN birth record — the superseded
304
+ * incarnation's genesis oid. The reborn holon itself testifies to its ancestry (alongside the UNCHANGED
305
+ * `parent` — a rebirth may never claim new parents; see {@link rebirthRetainsLineage}). Additive/era-safe. */
306
+ rebornFrom: t.string().merge(Lww).optional(),
286
307
  });
287
308
 
288
309
  // AdminGrant / CreationGrant aggregates: DELETED (full-native Zanzibar). Admin/creation authority is
@@ -489,6 +510,10 @@ export function buildGenesisChain(p: {
489
510
  * package compiled under the metered-birth DSL) — see the poolSteps note below.
490
511
  */
491
512
  descendantPool?: number;
513
+ /** LINEAGE CONTINUITY (rebirth genesis only): the superseded incarnation's genesis oid. Threads onto the
514
+ * child's birthCloud record (`rebornFrom`) + the bornUnder commitment — the reborn holon testifies to its
515
+ * ancestry while `parent` stays the SUPERSEDED record's parent (a rebirth may never reparent). */
516
+ rebornFrom?: string;
492
517
  }): string {
493
518
  const founder = "user:" + p.principal;
494
519
  // THE ORDER-DECLARED LAW INSTALL (G3): the child installs the EXACT law the order names — bytes are NOT in
@@ -514,6 +539,7 @@ export function buildGenesisChain(p: {
514
539
  lawHash: "",
515
540
  authorizationHash: "",
516
541
  parentHead: "",
542
+ ...(p.rebornFrom ? { rebornFrom: p.rebornFrom } : {}),
517
543
  });
518
544
  // METERED BIRTH (slice A3 — THE POOL CHAINS TO ROOT): when the order carries a `descendantPool`, the
519
545
  // child is BORN BOUNDED — a `seedBirthPolicy` genesis step writes the child's own BirthPolicy singleton
@@ -546,7 +572,7 @@ export function buildGenesisChain(p: {
546
572
  { domain: "workspaces", directiveId: "seedGenesis", actor: founder, payload: { principal: founder, workspaceId: p.requiresWarrant !== false ? p.name : "", bornAt: p.bornAt } },
547
573
  { domain: "workspaces", directiveId: "grantAdmin", actor: founder, payload: { keyHash: p.foundingAdminHash, label: `founding admin (born for ${p.principal})`, grantedBy: p.principal, grantedAt: p.requestedAt } },
548
574
  // No `role` — it was a vestigial node-class label (collapsed with `kind`); birthCloud ignores it now.
549
- { domain: "workspaces", directiveId: "birthCloud", actor: "", payload: { principal: p.principal, name: p.name, tier: p.tier, bornAt: p.requestedAt, bornUnder } },
575
+ { domain: "workspaces", directiveId: "birthCloud", actor: "", payload: { principal: p.principal, name: p.name, tier: p.tier, bornAt: p.requestedAt, bornUnder, ...(p.rebornFrom ? { rebornFrom: p.rebornFrom } : {}) } },
550
576
  ]);
551
577
  }
552
578
 
@@ -708,6 +734,9 @@ export const birthCloud = directive("birthCloud")
708
734
  * birth, recorded onto the child's self-record so its OWN chain self-attests its parentage. Absent on
709
735
  * root (born under nothing). Authored by {@link buildGenesisChain} for a child birth. */
710
736
  bornUnder: z.string().optional(),
737
+ /** LINEAGE CONTINUITY: present ONLY on a REBIRTH genesis — the superseded incarnation's genesis oid.
738
+ * The reborn holon's own chain testifies to its ancestry; `parent` (in bornUnder) stays UNCHANGED. */
739
+ rebornFrom: z.string().optional(),
711
740
  }),
712
741
  )
713
742
  .plan((p) => {
@@ -723,6 +752,9 @@ export const birthCloud = directive("birthCloud")
723
752
  .set("provisionedAt", p.bornAt);
724
753
  // The child's chain self-attests its lineage authorization (the proof-friendly binding); root omits it.
725
754
  if (p.bornUnder) rec.set("bornUnder", p.bornUnder);
755
+ // LINEAGE CONTINUITY: a rebirth genesis records the superseded incarnation it re-genesises (ancestry
756
+ // testimony in the reborn holon's OWN chain, beside the unchanged parent).
757
+ if (p.rebornFrom) rec.set("rebornFrom", p.rebornFrom);
726
758
  // The self-identity singleton at the CONSTANT id (ungated — birthCloud is not admin-mutating), so the
727
759
  // gate's self-record exists before any admin write. ensure() = deterministic-id sibling birth. `name` is
728
760
  // the born marker; `role` is no longer written (vestigial node-class — see WorkspaceSelf).
@@ -896,6 +928,106 @@ export const reclaimWorkspace = directive("reclaimWorkspace")
896
928
  })
897
929
  .requires("admin");
898
930
 
931
+ /**
932
+ * rebirthWorkspace — LAWFUL SUPERSESSION (recovery verb 3, architecture/rebirth_and_custody_lifecycle.md +
933
+ * lawful_recovery.md). "Supersession, never forgiveness": where {@link certifyEra} vouches IDENTITY (heals a
934
+ * ForgedPrincipal/BadSignature scar) and cannot paper over a LAW violation, rebirth is the remedy for a chain
935
+ * scarred UNRECOVERABLY (e.g. an evolve-gate retype with no dispositions) — a NEW chain re-genesised from the
936
+ * current law + state that self-verifies from intent 0, with the OLD chain SUPERSEDED in lineage (annotated,
937
+ * kept in custody, never deleted). Mirrors reclaim's law-act/custody-act split:
938
+ * • LAW ACT (this directive): the custodian records the supersession on the workspace's lineage record —
939
+ * {@link CloudWorkspace.supersededAt} (its PRESENCE is the committed fact the bailiff gates on),
940
+ * `supersessionReason` (mandatory), and `supersededBy` (the new incarnation oid — `""` on the first
941
+ * offer, filled by a follow-up offer once custody has built + verified the incarnation).
942
+ * • CUSTODY ACT: the internal `/rebirth-custody` worker verb builds the compacted incarnation, self-verifies
943
+ * it through the upload-birth `verify_chain` lane (GREEN from intent 0 REQUIRED before any repoint),
944
+ * births it into FRESH custody (ONE LEDGER PER REPO — a repo IS a holon; lineage forms the holarchy),
945
+ * REPOINTS the workspace NAME to it, and FREEZES the old repo untouched at its own custody address —
946
+ * retained whole, never rewritten (GC-later = deleting that repo wholesale).
947
+ *
948
+ * THE FIXED POINT (inherited from reclaim): the law REFUSES to rebirth ROOT — root is the seat of the very law
949
+ * that authorizes rebirth, so the law can re-genesise everything it governs EXCEPT itself. Enforced BY LAW here
950
+ * (the {@link rebirthNotRoot} invariant reads the target record's identity at the one gate on every lane), not
951
+ * merely by the bailiff. Gated `.requires("admin")` — the strongest control-plane authority (a custodian act).
952
+ */
953
+ export const rebirthWorkspace = directive("rebirthWorkspace")
954
+ .mutates(CloudWorkspace)
955
+ .payload(
956
+ z.object({
957
+ /** The target CloudWorkspace AGGREGATE id (the minted lineage-record id; root-custody registry). */
958
+ workspaceId: z.string().min(1),
959
+ /** MANDATORY — the custodian's justification for superseding this chain (audit, on the ledger forever). */
960
+ reason: z.string().min(1),
961
+ /** Epoch-ms the supersession was authored (caller-stamped — the timestamp doctrine). Its presence on the
962
+ * record is the LAW FACT the `/rebirth-custody` verb gates on. */
963
+ rebornAt: epochMs,
964
+ /** The new incarnation's genesis oid — OPTIONAL: absent/`""` on the first (decision) offer; the custodian
965
+ * re-offers carrying it once custody has built + self-verified the incarnation (the receipt). */
966
+ incarnation: z.string().optional(),
967
+ /** LINEAGE CONTINUITY: the parent the new incarnation CLAIMS (carried on the receipt leg for the
968
+ * audit record). When present it MUST equal the superseded record's parent — a reparenting rebirth
969
+ * is a typed refusal ({@link rebirthRetainsLineage}). Absent ⇒ the parent is unchanged by definition. */
970
+ parent: z.string().optional(),
971
+ }),
972
+ )
973
+ .plan((p) => {
974
+ const ws = instance(CloudWorkspace, p.workspaceId);
975
+ return [
976
+ set(ws, "supersessionReason", p.reason),
977
+ set(ws, "supersededAt", p.rebornAt),
978
+ set(ws, "supersededBy", p.incarnation ?? ""),
979
+ ];
980
+ })
981
+ .requires("admin");
982
+
983
+ /**
984
+ * rebirthNotRoot — THE FIXED POINT AS LAW. The one gate reads the target record's identity (its `name`) at the
985
+ * pre-apply position and REFUSES a rebirth of the genesis workspace `root` — the seat of the very law that
986
+ * authorizes rebirth (mirroring the bailiff's `ws === ROOT_WS` fixed point in `/rebirth-custody`, and reclaim's
987
+ * root carve-out). NOTE `parent` is NOT the test: every workspace's OWN birthCloud self-record carries
988
+ * `parent: ""` (lineage lives in the PARENT's registry), so `parent === ""` would reject every workspace. A
989
+ * record read of the target (no NEW declared query) ⇒ the read manifest is unchanged (era-/evolve-safe).
990
+ * Enforced on every lane (author / edge admission / verify_chain).
991
+ */
992
+ export const rebirthNotRoot = workspaceInvariant("RebirthNotRoot")
993
+ .on("rebirthWorkspace")
994
+ .reads((ctx) => [
995
+ refAs("target", "CloudWorkspace", "pre:" + String((ctx?.intent ?? {}).workspaceId ?? "")),
996
+ ])
997
+ .assert((refs) => {
998
+ const target = (refs as { target?: { name?: string } }).target;
999
+ return target?.name === "root"
1000
+ ? { reject: "rebirth-refuses-root: root is the seat of the authorizing law and cannot supersede itself" }
1001
+ : { accept: true };
1002
+ });
1003
+
1004
+ /**
1005
+ * rebirthRetainsLineage — LINEAGE CONTINUITY AS LAW (ratified 2026-07-09). A rebirth/incarnation of an
1006
+ * existing workspace MUST retain its lineage: the parent the offer claims for the new incarnation must equal
1007
+ * the SUPERSEDED record's `parent` — a reparenting rebirth is a TYPED refusal at the one gate on every lane.
1008
+ * WHY: the holarchy must stay TOTALLY REPLAYABLE from root genesis — every birth/supersession/rebirth an
1009
+ * ordered law fact; a reborn holon that claimed new parents would fork the lineage graph out-of-history.
1010
+ * (The reborn holon's OWN chain additionally testifies via `rebornFrom` + the unchanged parent in bornUnder.)
1011
+ */
1012
+ export const rebirthRetainsLineage = workspaceInvariant("RebirthRetainsLineage")
1013
+ .on("rebirthWorkspace")
1014
+ .reads((ctx) => [
1015
+ refAs("target", "CloudWorkspace", "pre:" + String((ctx?.intent ?? {}).workspaceId ?? "")),
1016
+ ])
1017
+ .assert((refs, ctx) => {
1018
+ const target = (refs as { target?: { parent?: string } }).target;
1019
+ const claimed = (ctx?.intent as { parent?: string } | undefined)?.parent;
1020
+ // No claim ⇒ the parent is untouched (the plan never writes parent) — lineage retained by construction.
1021
+ if (claimed === undefined || claimed === "") return { accept: true };
1022
+ return claimed === (target?.parent ?? "")
1023
+ ? { accept: true }
1024
+ : { reject: "rebirth-must-retain-lineage: a rebirth cannot claim new parents — the incarnation keeps the superseded record's parent" };
1025
+ });
1026
+
1027
+ /** supersededWorkspaces — the bailiff/CLI reads workspaces carrying a supersession fact (audit + the
1028
+ * `/rebirth-custody` drain's queue). Keyed on `supersededBy` is unhelpful; the flat record read via
1029
+ * {@link workspaceByName} suffices for the custody gate, so this stays a by-name lookup upstream. */
1030
+
899
1031
  // ─────────────────────────── BIRTH AS A NOMOS PRIMITIVE ──────────────────────────────────────────────
900
1032
  // (birthPlatform is GONE — the tier collapsed onto #creator; birthChild is the one birth directive.)
901
1033
  // THE RECORD IS THE OBLIGATION (architecture/githolon_host_contract.md "Birth is a capability" +
package/src/index.ts CHANGED
@@ -137,6 +137,9 @@ export {
137
137
  export { create, existing, ensure, type AggregateRef } from "./authoring.js";
138
138
  export { contract, isContractHandle, satisfiesContract, type ContractHandle, type ContractField, type ContractSatisfaction } from "./contract.js";
139
139
  export { normalizeActivity, renderActivityTemplate, activityKey, type ActivityInput, type ActivityDescriptor, type ActivityRefInput, type ActivitySeverity } from "./activity.js";
140
+ // The REBIRTH-boundary repair DSL (architecture/repair_dsl.md) — distinct from framework/repair.js (the
141
+ // IN-CHAIN compensating-intent lane): repair() binds a typed replay refusal to a declared re-fold remedy.
142
+ export { repair, repairsOf, isRepairDecl, type RepairDecl, type RepairMatcher, type RepairVerb, type RepairRefusalKind, type RetypeMapping } from "./repair.js";
140
143
  // THE CAPTURED READ — a plan reads O(1) DSL queries via `read(query, args)`; the result is committed as
141
144
  // the intent's read footprint (deterministic replay + stale-premise detection). The dev calls typed DSL
142
145
  // queries; the library routes them here. The host capability is `nomos.read` (twin of `nomos.rng`).
package/src/repair.ts ADDED
@@ -0,0 +1,151 @@
1
+ // NOMOS — THE REPAIR DSL (architecture/repair_dsl.md). A REPAIR binds a KNOWN replay failure (a TYPED
2
+ // refusal matcher — kind + stable ids, never error prose) to a DECLARED remedy (a verb from the small
3
+ // deterministic palette), authored as CODE in the tenant repo's `repairs/` folder. Repairs IMPORT the
4
+ // domain; the domain never imports repairs. The repair manifest is NOT law and never hashed — the LAW facts
5
+ // are the supersession + certificate the execution emits (a custodian-gated rebirth-with-transform at the
6
+ // producer's reserved `transformState` seam).
7
+ //
8
+ // import { repair } from "@githolon/dsl";
9
+ // import { EstateAsset } from "../src/domains/estate";
10
+ //
11
+ // export const healAssetTypeRetype = repair("heal-assettype-retype")
12
+ // .matches({ refusal: "evolve-gate", stableId: "2c6d2218ad784422" })
13
+ // .reason("assetType retyped string→json in 0.54; history re-expressed through the mapping")
14
+ // .retypes(EstateAsset, "assetType", { through: "wrapAsJson" });
15
+ //
16
+ // DX (the product): `githolon replay` names the matching repair on refusal; `--repair` executes it
17
+ // (plan → confirm → rebirth → certificate); replay again is green. A repair costs one file + two commands.
18
+ import type { AggregateHandle } from "./aggregate.js";
19
+
20
+ /** The refusal kinds a matcher may bind (v1 — extend deliberately; each addition is a design conversation). */
21
+ export type RepairRefusalKind = "evolve-gate";
22
+
23
+ /** A typed refusal matcher — binds to the refusal's KIND + the stable id it names, never to prose. */
24
+ export interface RepairMatcher {
25
+ readonly refusal: RepairRefusalKind;
26
+ /** The stable id the refusal names (a field/aggregate sid from the lockfile lineage). */
27
+ readonly stableId: string;
28
+ /** Optionally pin the domain (when the same sid could appear in more than one — defensive). */
29
+ readonly domain?: string;
30
+ }
31
+
32
+ /** The NAMED deterministic mapping palette for `.retypes` — like merge drivers, a closed set the engine
33
+ * implements identically on every peer (determinism or death applies to repairs too). v1:
34
+ * - `wrapAsJson`: a scalar value v re-expresses as the json value v (string "x" → json "x") — the
35
+ * string→json retype lane (co2's assetType wound). */
36
+ export type RetypeMapping = "wrapAsJson";
37
+
38
+ /** A declared repair verb (v1 palette — designed against the three real cases). */
39
+ export type RepairVerb =
40
+ | { readonly kind: "retypes"; readonly aggregateId: string; readonly field: string; readonly through: RetypeMapping }
41
+ | { readonly kind: "strikes"; readonly aggregateId: string; readonly whereField: string; readonly equals: string }
42
+ | { readonly kind: "shreds"; readonly aggregateId: string; readonly field: string };
43
+
44
+ /** The finished, serializable repair declaration (what the compiler emits into the repairs manifest). */
45
+ export interface RepairDecl {
46
+ readonly id: string;
47
+ readonly matcher: RepairMatcher;
48
+ readonly reason: string;
49
+ readonly verb: RepairVerb;
50
+ readonly __isRepair: true;
51
+ }
52
+
53
+ /** True iff `x` is a finished repair declaration (the discovery duck-type). */
54
+ export function isRepairDecl(x: unknown): x is RepairDecl {
55
+ return typeof x === "object" && x !== null && (x as { __isRepair?: unknown }).__isRepair === true;
56
+ }
57
+
58
+ const RETYPE_MAPPINGS: ReadonlySet<string> = new Set(["wrapAsJson"]);
59
+ const REFUSAL_KINDS: ReadonlySet<string> = new Set(["evolve-gate"]);
60
+ const SID_RE = /^[0-9a-f]{16}$/i;
61
+
62
+ interface RepairNeedsMatcher {
63
+ /** Bind the KNOWN failure: the typed refusal this repair remedies. Validated: known kind, sid-shaped id. */
64
+ matches(m: RepairMatcher): RepairNeedsReason;
65
+ }
66
+ interface RepairNeedsReason {
67
+ /** MANDATORY human reason — rides the plan, the certificate, and the lineage supersession fact. */
68
+ reason(text: string): RepairNeedsVerb;
69
+ }
70
+ interface RepairNeedsVerb {
71
+ /** Historical RETYPE through a NAMED deterministic mapping (the co2-assetType lane). */
72
+ retypes(agg: AggregateHandle, field: string, opts: { through: RetypeMapping }): RepairDecl;
73
+ /** Drop matching rows' history at the re-fold (the data-purge lane). v1 predicate: field equality. */
74
+ strikes(agg: AggregateHandle, where: { field: string; equals: string }): RepairDecl;
75
+ /** Carry the field forward ABSENT — crypto-shred / PII erasure (the GDPR verb). */
76
+ shreds(agg: AggregateHandle, field: string): RepairDecl;
77
+ }
78
+
79
+ /**
80
+ * Declare a REPAIR — a known replay failure bound to a declared remedy. Staged builder so a half-finished
81
+ * repair is unrepresentable: `repair(id).matches(...).reason(...).<verb>(...)` yields the {@link RepairDecl}.
82
+ */
83
+ export function repair(id: string): RepairNeedsMatcher {
84
+ if (typeof id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(id)) {
85
+ throw new Error(`repair(id): the id must be a kebab-case slug (got '${id}') — it names the remedy in plans, certificates, and lineage.`);
86
+ }
87
+ const requireAgg = (agg: AggregateHandle, verb: string): string => {
88
+ if (typeof agg !== "object" || agg === null || (agg as { __isAggregateHandle?: unknown }).__isAggregateHandle !== true) {
89
+ throw new Error(`repair('${id}').${verb}: the first argument must be an aggregate handle (import it from your domain).`);
90
+ }
91
+ return agg.id;
92
+ };
93
+ const requireField = (agg: AggregateHandle, field: string, verb: string): void => {
94
+ if (!(field in agg.fields)) {
95
+ throw new Error(
96
+ `repair('${id}').${verb}: aggregate '${agg.id}' has no field '${field}' (known: ${Object.keys(agg.fields).sort().join(", ")}).`,
97
+ );
98
+ }
99
+ };
100
+ return {
101
+ matches(m: RepairMatcher): RepairNeedsReason {
102
+ if (!REFUSAL_KINDS.has(m?.refusal)) {
103
+ throw new Error(`repair('${id}').matches: unknown refusal kind '${m?.refusal}' (known: ${[...REFUSAL_KINDS].join(", ")}).`);
104
+ }
105
+ if (typeof m.stableId !== "string" || !SID_RE.test(m.stableId)) {
106
+ throw new Error(`repair('${id}').matches: 'stableId' must be the 16-hex stable id the refusal names (got '${m.stableId}').`);
107
+ }
108
+ return {
109
+ reason(text: string): RepairNeedsVerb {
110
+ if (typeof text !== "string" || text.trim().length === 0) {
111
+ throw new Error(`repair('${id}').reason: a non-empty reason is mandatory — it rides the plan, certificate, and lineage.`);
112
+ }
113
+ const finish = (verb: RepairVerb): RepairDecl => ({ id, matcher: { refusal: m.refusal, stableId: m.stableId, ...(m.domain !== undefined ? { domain: m.domain } : {}) }, reason: text, verb, __isRepair: true });
114
+ return {
115
+ retypes(agg, field, opts) {
116
+ const aggregateId = requireAgg(agg, "retypes");
117
+ requireField(agg, field, "retypes");
118
+ if (!RETYPE_MAPPINGS.has(opts?.through)) {
119
+ throw new Error(`repair('${id}').retypes: unknown mapping '${opts?.through}' (known: ${[...RETYPE_MAPPINGS].join(", ")}) — mappings are a NAMED deterministic palette, never closures.`);
120
+ }
121
+ return finish({ kind: "retypes", aggregateId, field, through: opts.through });
122
+ },
123
+ strikes(agg, where) {
124
+ const aggregateId = requireAgg(agg, "strikes");
125
+ requireField(agg, where?.field ?? "", "strikes");
126
+ if (typeof where?.equals !== "string") throw new Error(`repair('${id}').strikes: 'where.equals' must be a string.`);
127
+ return finish({ kind: "strikes", aggregateId, whereField: where.field, equals: where.equals });
128
+ },
129
+ shreds(agg, field) {
130
+ const aggregateId = requireAgg(agg, "shreds");
131
+ requireField(agg, field, "shreds");
132
+ return finish({ kind: "shreds", aggregateId, field });
133
+ },
134
+ };
135
+ },
136
+ };
137
+ },
138
+ };
139
+ }
140
+
141
+ /** Collect every exported repair from a module's exports bag (the discovery duck-type, like aggregatesOf). */
142
+ export function repairsOf(mod: Record<string, unknown>): RepairDecl[] {
143
+ const out: RepairDecl[] = [];
144
+ for (const v of Object.values(mod)) if (isRepairDecl(v)) out.push(v);
145
+ // deterministic order + duplicate-id refusal (two repairs with one id would make --repair ambiguous)
146
+ out.sort((a, b) => a.id.localeCompare(b.id));
147
+ for (let i = 1; i < out.length; i++) {
148
+ if (out[i]!.id === out[i - 1]!.id) throw new Error(`repairs: duplicate repair id '${out[i]!.id}' — repair ids must be unique.`);
149
+ }
150
+ return out;
151
+ }