@cleocode/contracts 2026.5.111 → 2026.5.112

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.
Files changed (31) hide show
  1. package/dist/__tests__/evidence-atom-schema.test.d.ts +16 -0
  2. package/dist/__tests__/evidence-atom-schema.test.d.ts.map +1 -0
  3. package/dist/__tests__/evidence-atom-schema.test.js +394 -0
  4. package/dist/__tests__/evidence-atom-schema.test.js.map +1 -0
  5. package/dist/evidence-atom-schema.d.ts +407 -0
  6. package/dist/evidence-atom-schema.d.ts.map +1 -0
  7. package/dist/evidence-atom-schema.js +505 -0
  8. package/dist/evidence-atom-schema.js.map +1 -0
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/invariants/__tests__/registry.test.d.ts +14 -0
  14. package/dist/invariants/__tests__/registry.test.d.ts.map +1 -0
  15. package/dist/invariants/__tests__/registry.test.js +113 -0
  16. package/dist/invariants/__tests__/registry.test.js.map +1 -0
  17. package/dist/invariants/adr-073-saga.d.ts +32 -0
  18. package/dist/invariants/adr-073-saga.d.ts.map +1 -0
  19. package/dist/invariants/adr-073-saga.js +155 -0
  20. package/dist/invariants/adr-073-saga.js.map +1 -0
  21. package/dist/invariants/index.d.ts +159 -0
  22. package/dist/invariants/index.d.ts.map +1 -0
  23. package/dist/invariants/index.js +109 -0
  24. package/dist/invariants/index.js.map +1 -0
  25. package/package.json +2 -2
  26. package/src/__tests__/evidence-atom-schema.test.ts +499 -0
  27. package/src/evidence-atom-schema.ts +628 -0
  28. package/src/index.ts +39 -0
  29. package/src/invariants/__tests__/registry.test.ts +136 -0
  30. package/src/invariants/adr-073-saga.ts +167 -0
  31. package/src/invariants/index.ts +213 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Unit tests for the central invariants registry (T10335).
3
+ *
4
+ * The registry is the SSoT for system-wide invariants surfaced via the
5
+ * SG-SUBSTRATE-RECONCILIATION saga. Downstream R-tasks (R2 ORC codes,
6
+ * R4 CI gate, R5 release refactor, R6 doctor audit, R8 docs renderer)
7
+ * all key off the metadata shape asserted below.
8
+ *
9
+ * @epic T10327 — E-INVARIANT-REGISTRY-SSOT
10
+ * @saga T10326 — SG-SUBSTRATE-RECONCILIATION
11
+ * @task T10335 — R1: registry + ADR-073 I1-I8
12
+ */
13
+
14
+ import { describe, expect, it } from 'vitest';
15
+ import {
16
+ E_SAGA_INVARIANT_VIOLATION_I3,
17
+ E_SAGA_INVARIANT_VIOLATION_I5,
18
+ E_SAGA_INVARIANT_VIOLATION_I7,
19
+ } from '../../errors.js';
20
+ import {
21
+ ADR_073_INVARIANTS,
22
+ getInvariant,
23
+ getInvariantsByAdr,
24
+ INVARIANTS_REGISTRY,
25
+ type RegisteredInvariant,
26
+ } from '../index.js';
27
+
28
+ /**
29
+ * Map from ADR-073 invariant code → expected LAFS error code in
30
+ * `packages/contracts/src/errors.ts`. R2/R4 will extend this mapping with
31
+ * ADR-070 ORC codes once they land — for now the registry only knows
32
+ * about the three saga-runtime guards.
33
+ *
34
+ * NOTE: I4 / I6 / I8 deliberately have no error code today — they are
35
+ * UNENFORCED warnings. The R4 CI gate (T10338) is the right home for the
36
+ * strict "every error has an error code" assertion; this test only
37
+ * validates the subset we already have wired.
38
+ */
39
+ const ADR_073_ERROR_CODE_MAP: Record<string, string> = {
40
+ I3: E_SAGA_INVARIANT_VIOLATION_I3,
41
+ I5: E_SAGA_INVARIANT_VIOLATION_I5,
42
+ I7: E_SAGA_INVARIANT_VIOLATION_I7,
43
+ };
44
+
45
+ describe('INVARIANTS_REGISTRY — central registry shape', () => {
46
+ it('exposes at least 8 entries (ADR-073 I1-I8 baseline)', () => {
47
+ const entries = Object.values(INVARIANTS_REGISTRY);
48
+ expect(entries.length).toBeGreaterThanOrEqual(8);
49
+ });
50
+
51
+ it('registers all eight ADR-073 invariants under predictable keys', () => {
52
+ for (let i = 1; i <= 8; i++) {
53
+ const key = `ADR-073.I${i}`;
54
+ expect(INVARIANTS_REGISTRY[key]).toBeDefined();
55
+ expect(INVARIANTS_REGISTRY[key]?.adr).toBe('ADR-073');
56
+ expect(INVARIANTS_REGISTRY[key]?.code).toBe(`I${i}`);
57
+ }
58
+ });
59
+
60
+ it('every entry carries a non-empty name and description', () => {
61
+ for (const entry of Object.values(INVARIANTS_REGISTRY)) {
62
+ expect(entry.name.length).toBeGreaterThan(0);
63
+ expect(entry.description.length).toBeGreaterThan(0);
64
+ }
65
+ });
66
+
67
+ it('every severity:"error" invariant has a non-null runtimeGate', () => {
68
+ const errorTier: RegisteredInvariant[] = Object.values(INVARIANTS_REGISTRY).filter(
69
+ (e) => e.severity === 'error',
70
+ );
71
+ expect(errorTier.length).toBeGreaterThan(0);
72
+ for (const entry of errorTier) {
73
+ expect(entry.runtimeGate).not.toBeNull();
74
+ // Triple is fully populated when present.
75
+ if (entry.runtimeGate !== null) {
76
+ expect(entry.runtimeGate.module.length).toBeGreaterThan(0);
77
+ expect(entry.runtimeGate.functionName.length).toBeGreaterThan(0);
78
+ }
79
+ }
80
+ });
81
+ });
82
+
83
+ describe('getInvariant — lookup helper', () => {
84
+ it('returns the ADR-073.I3 entry with the expected runtime guard', () => {
85
+ const i3 = getInvariant('ADR-073.I3');
86
+ expect(i3).toBeDefined();
87
+ expect(i3?.adr).toBe('ADR-073');
88
+ expect(i3?.code).toBe('I3');
89
+ expect(i3?.severity).toBe('error');
90
+ expect(i3?.runtimeGate?.functionName).toBe('assertSagaInvariantI3');
91
+ expect(i3?.runtimeGate?.module).toBe('packages/core/src/sagas/enforcement.ts');
92
+ });
93
+
94
+ it('returns undefined for an unregistered key', () => {
95
+ expect(getInvariant('ADR-999.X1')).toBeUndefined();
96
+ });
97
+ });
98
+
99
+ describe('getInvariantsByAdr — per-ADR enumeration', () => {
100
+ it('returns all eight ADR-073 invariants in declaration order', () => {
101
+ const adr073 = getInvariantsByAdr('ADR-073');
102
+ expect(adr073).toHaveLength(8);
103
+ expect(adr073.map((e) => e.code)).toEqual(['I1', 'I2', 'I3', 'I4', 'I5', 'I6', 'I7', 'I8']);
104
+ });
105
+
106
+ it('returns an empty array for an unknown ADR', () => {
107
+ expect(getInvariantsByAdr('ADR-DOES-NOT-EXIST')).toEqual([]);
108
+ });
109
+ });
110
+
111
+ describe('ADR_073_INVARIANTS — exported convenience binding', () => {
112
+ it('matches the entries surfaced via getInvariantsByAdr', () => {
113
+ expect(ADR_073_INVARIANTS).toHaveLength(8);
114
+ const enumerated = getInvariantsByAdr('ADR-073');
115
+ expect(enumerated).toHaveLength(ADR_073_INVARIANTS.length);
116
+ for (let i = 0; i < ADR_073_INVARIANTS.length; i++) {
117
+ expect(enumerated[i]?.code).toBe(ADR_073_INVARIANTS[i]?.code);
118
+ }
119
+ });
120
+ });
121
+
122
+ describe('error-code cross-reference (subset — R4 T10338 owns full strict gate)', () => {
123
+ it('every ADR-073 invariant with a runtimeGate has a known LAFS error code', () => {
124
+ // TODO(T10338 / R4): replace this targeted subset with a strict registry-wide
125
+ // assertion that walks every severity:"error" entry and confirms a matching
126
+ // LAFS error code (E_<ADR>_INVARIANT_VIOLATION_<CODE>) exists in errors.ts.
127
+ const adr073 = getInvariantsByAdr('ADR-073');
128
+ for (const entry of adr073) {
129
+ if (entry.runtimeGate !== null) {
130
+ const expected = ADR_073_ERROR_CODE_MAP[entry.code];
131
+ expect(expected).toBeDefined();
132
+ expect(typeof expected).toBe('string');
133
+ }
134
+ }
135
+ });
136
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * ADR-073 §1.2 invariants I1-I8 registered against the central invariants
3
+ * registry (`./index.ts`).
4
+ *
5
+ * The eight invariants govern the 4-tier task hierarchy (Saga → Epic → Task
6
+ * → Subtask) and the cross-tier rules that keep it healthy. I3, I5, I7 carry
7
+ * runtime guards (the `assertSagaInvariant{I3,I5,I7}` functions in
8
+ * `@cleocode/core` saga module) and surface as `E_SAGA_INVARIANT_VIOLATION_*`
9
+ * LAFS error codes. I1 + I2 are display/storage concerns enforced by DB
10
+ * CHECK constraints (added in W1.B T10329 migration) and ID generation
11
+ * conventions rather than runtime functions. I4, I6, I8 are
12
+ * orchestration/process invariants that lack a unified runtime guard today
13
+ * — they are marked `warning` and explicitly tagged as UNENFORCED
14
+ * (load-bearing) so downstream R6 doctor audits surface them visibly.
15
+ *
16
+ * @epic T10327 — E-INVARIANT-REGISTRY-SSOT
17
+ * @saga T10326 — SG-SUBSTRATE-RECONCILIATION
18
+ * @task T10335 — R1 (registry + ADR-073 I1-I8)
19
+ * @see ADR-073-above-epic-naming.md §1.2
20
+ */
21
+
22
+ import type { RegisteredInvariant } from './index.js';
23
+
24
+ /**
25
+ * Module path that hosts the saga runtime-guard exports
26
+ * (`assertSagaInvariantI3`, `assertSagaInvariantI5`, `assertSagaInvariantI7`).
27
+ *
28
+ * Held as a constant so all four entries share the same source-of-truth
29
+ * pointer — touching the module path once propagates to every guard ref.
30
+ */
31
+ const SAGA_ENFORCEMENT_MODULE = 'packages/core/src/sagas/enforcement.ts';
32
+
33
+ /**
34
+ * Path to the canonical enforcement-tests file. Used as the `tests` ref
35
+ * for every I3 / I5 / I7 entry below.
36
+ */
37
+ const SAGA_ENFORCEMENT_TESTS = 'packages/core/src/sagas/__tests__/enforcement.test.ts';
38
+
39
+ /**
40
+ * ADR-073 invariants I1-I8 in declaration order.
41
+ *
42
+ * Severity mapping (see `RegisteredInvariant` JSDoc for tier semantics):
43
+ * - I1, I2 → `info` (display/storage; enforced by DB CHECK + ID convention).
44
+ * - I3, I5, I7 → `error` (LAFS error code + runtime guard mandatory).
45
+ * - I4, I6, I8 → `warning` (orchestration/process; UNENFORCED at runtime
46
+ * today, doctor audit only).
47
+ */
48
+ export const ADR_073_INVARIANTS: readonly RegisteredInvariant[] = Object.freeze([
49
+ {
50
+ adr: 'ADR-073',
51
+ code: 'I1',
52
+ name: 'Storage uniformity',
53
+ description:
54
+ 'All task IDs are stored as T#### and the type column is the canonical tier discriminator. There is no separate ID space for Sagas, Epics, Tasks, or Subtasks; label="saga" elevates a type="epic" row to Saga semantics.',
55
+ severity: 'info',
56
+ // I1 is enforced by the DB CHECK constraints introduced in W1.B (T10329)
57
+ // and by TASK_ID_PATTERN in packages/core/src/tasks/id-generator.ts —
58
+ // not by a single runtime function.
59
+ runtimeGate: null,
60
+ lintRule: null,
61
+ doctorAudit: null,
62
+ tests: ['packages/core/src/tasks/__tests__/id-generator.test.ts'],
63
+ },
64
+ {
65
+ adr: 'ADR-073',
66
+ code: 'I2',
67
+ name: 'Conceptual prefixes are display + import only',
68
+ description:
69
+ "SG-, E-, T- (and Subtask's implicit absence) are documentation, CLI display, and import-mapping conventions only. They MUST NOT be used as DB primary keys — display-only with no runtime enforcement.",
70
+ severity: 'info',
71
+ // I2 is a display convention; the SG- prefix preservation snapshot
72
+ // (T10333) protects the display contract but there is no runtime guard.
73
+ runtimeGate: null,
74
+ lintRule: null,
75
+ doctorAudit: null,
76
+ tests: [],
77
+ },
78
+ {
79
+ adr: 'ADR-073',
80
+ code: 'I3',
81
+ name: 'Tier promotion mandatory when scope outgrows the tier',
82
+ description:
83
+ 'A Subtask whose change exceeds 2 files or crosses a module boundary MUST be split or promoted to a sibling Task. A Task that requires more than one PR or wave MUST be split. An Epic that spans more than one release MUST be regrouped under a Saga.',
84
+ severity: 'error',
85
+ runtimeGate: {
86
+ module: SAGA_ENFORCEMENT_MODULE,
87
+ functionName: 'assertSagaInvariantI3',
88
+ },
89
+ lintRule: null,
90
+ doctorAudit: null,
91
+ tests: [SAGA_ENFORCEMENT_TESTS],
92
+ },
93
+ {
94
+ adr: 'ADR-073',
95
+ code: 'I4',
96
+ name: 'Ownership non-overlapping',
97
+ description:
98
+ 'A single tier maps to a single orchestration role (per ADR-070). Workers MUST NOT spawn other Workers. Phase Leads MUST NOT own multiple Epics simultaneously. The Orchestrator MUST NOT spawn Workers directly when fan-out exceeds the ADR-070 migration threshold.',
99
+ severity: 'warning',
100
+ // I4 is partially covered by ADR-070 spawn guards but the unification
101
+ // with the registry happens in R2 (T10336 — ORC codes). For now this
102
+ // entry stays warning + runtimeGate:null to mark the gap explicitly.
103
+ runtimeGate: null,
104
+ lintRule: null,
105
+ doctorAudit: null,
106
+ tests: [],
107
+ },
108
+ {
109
+ adr: 'ADR-073',
110
+ code: 'I5',
111
+ name: 'Sagas link via groups, not parent',
112
+ description:
113
+ 'task_relations.type="groups" is the ONLY relation type that links a Saga to its member Epics. The Saga row\'s parent_id MUST be NULL. Enforced at runtime by assertSagaInvariantI5 AND by the DB CHECK constraint from W1.B (T10329) on label="saga" rows.',
114
+ severity: 'error',
115
+ runtimeGate: {
116
+ module: SAGA_ENFORCEMENT_MODULE,
117
+ functionName: 'assertSagaInvariantI5',
118
+ },
119
+ lintRule: null,
120
+ doctorAudit: null,
121
+ tests: [SAGA_ENFORCEMENT_TESTS],
122
+ },
123
+ {
124
+ adr: 'ADR-073',
125
+ code: 'I6',
126
+ name: 'Acceptance criteria required at every tier',
127
+ description:
128
+ 'Per ADR-066 §"Ownership Matrix" invariant #5, all tasks regardless of type or kind MUST have --acceptance set at creation time. No tier exemption exists. Delegated to the ADR-066 --acceptance requirement on cleo add/add-batch.',
129
+ severity: 'warning',
130
+ // I6 is enforced by ADR-066's CLI requirement, not by a saga runtime
131
+ // function. UNENFORCED in the saga module — left as a warning so R6
132
+ // doctor audit reminds operators of the upstream guard's location.
133
+ runtimeGate: null,
134
+ lintRule: null,
135
+ doctorAudit: null,
136
+ tests: [],
137
+ },
138
+ {
139
+ adr: 'ADR-073',
140
+ code: 'I7',
141
+ name: 'Maximum parent depth is 3',
142
+ description:
143
+ 'The parent ladder Subtask → Task → Epic is fixed at depth 3 (hierarchy.maxDepth=3). Sagas do NOT consume depth — they attach via groups relations, not parent edges. Enforced at runtime by assertSagaInvariantI7.',
144
+ severity: 'error',
145
+ runtimeGate: {
146
+ module: SAGA_ENFORCEMENT_MODULE,
147
+ functionName: 'assertSagaInvariantI7',
148
+ },
149
+ lintRule: null,
150
+ doctorAudit: null,
151
+ tests: [SAGA_ENFORCEMENT_TESTS],
152
+ },
153
+ {
154
+ adr: 'ADR-073',
155
+ code: 'I8',
156
+ name: 'Subtask-to-PR aggregation rule',
157
+ description:
158
+ "A Task ships as exactly one PR. The PR's commit history is the union of the Task's Subtask commits. A Subtask never produces its own PR; if a unit of work warrants its own PR, it is a Task, not a Subtask. UNENFORCED at runtime today — load-bearing convention enforced via code review and the lifecycle decision table.",
159
+ severity: 'warning',
160
+ // I8 is explicitly "UNENFORCED, load-bearing" — there is no automated
161
+ // gate. R6 doctor audit surfaces this entry so it stays visible.
162
+ runtimeGate: null,
163
+ lintRule: null,
164
+ doctorAudit: null,
165
+ tests: [],
166
+ },
167
+ ]);
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Central invariants registry — SSoT for system-wide invariants surfaced via
3
+ * the SaGa T10326 SG-SUBSTRATE-RECONCILIATION programme (Epic T10327
4
+ * E-INVARIANT-REGISTRY-SSOT, Task T10335 R1).
5
+ *
6
+ * Every registered invariant carries the metadata needed to:
7
+ * 1. Reference its source ADR (e.g. `ADR-073` §1.2 for I1-I8).
8
+ * 2. Locate the runtime guard that enforces it (when one exists).
9
+ * 3. Bind the CI lint rule that protects it (when one exists).
10
+ * 4. Cross-reference test files that exercise it.
11
+ * 5. Render documentation via the downstream R8 auto-render pipeline.
12
+ *
13
+ * Downstream consumers:
14
+ * - R2 (T10336): register ADR-070 ORC codes into this same registry.
15
+ * - R4 (T10338): CI gate validates that every `severity:'error'` entry has
16
+ * a non-null `runtimeGate` AND a matching error-code in `errors.ts`.
17
+ * - R5 (T10339): refactor `packages/core/src/release/invariants/registry.ts`
18
+ * to consume the central registry instead of carrying its own list.
19
+ * - R6 (T10340): `cleo doctor --audit-invariants` walks the registry to
20
+ * produce a per-invariant audit report.
21
+ * - R8 (T10342): auto-render docs page from the registry.
22
+ *
23
+ * @epic T10327 — E-INVARIANT-REGISTRY-SSOT
24
+ * @saga T10326 — SG-SUBSTRATE-RECONCILIATION
25
+ * @task T10335 — R1: registry + ADR-073 I1-I8
26
+ * @see ADR-073-above-epic-naming.md §1.2
27
+ */
28
+
29
+ import { ADR_073_INVARIANTS } from './adr-073-saga.js';
30
+
31
+ // Re-exported so consumers can import either the per-ADR list or the merged
32
+ // registry from one place.
33
+ export { ADR_073_INVARIANTS } from './adr-073-saga.js';
34
+
35
+ /**
36
+ * Severity tier for a registered invariant.
37
+ *
38
+ * - `'error'` — runtime gate MUST exist; violation surfaces as LAFS error
39
+ * code (cross-checked by R4 CI gate).
40
+ * - `'warning'` — orchestration/process concern; surfaces via doctor audit
41
+ * but does not throw at runtime.
42
+ * - `'info'` — display or storage concern; enforced (if at all) by DB
43
+ * constraints or convention rather than a runtime function.
44
+ */
45
+ export type InvariantSeverity = 'info' | 'warning' | 'error';
46
+
47
+ /**
48
+ * Pointer to a runtime guard function that enforces an invariant.
49
+ *
50
+ * The `module` field is the package-relative import specifier
51
+ * (e.g. `packages/core/src/sagas/enforcement.ts`). The `functionName` is the
52
+ * exported identifier (e.g. `assertSagaInvariantI3`).
53
+ *
54
+ * R4 CI gate uses this triple to assert the guard actually exists and is
55
+ * exported with the expected name. R6 doctor audit uses it to render
56
+ * per-invariant enforcement provenance.
57
+ */
58
+ export interface InvariantRuntimeGate {
59
+ readonly module: string;
60
+ readonly functionName: string;
61
+ }
62
+
63
+ /**
64
+ * Pointer to a CI lint script that protects an invariant.
65
+ *
66
+ * The path is repo-rooted (e.g. `scripts/lint-saga-label-anti-pattern.mjs`).
67
+ */
68
+ export interface InvariantLintRule {
69
+ readonly lintScript: string;
70
+ }
71
+
72
+ /**
73
+ * Pointer to a `cleo doctor` audit that surfaces invariant violations.
74
+ *
75
+ * The lintScript field mirrors the CI gate pointer — many invariants share
76
+ * the same script between CI and doctor (e.g. saga-label anti-pattern).
77
+ */
78
+ export interface InvariantDoctorAudit {
79
+ readonly lintScript: string;
80
+ }
81
+
82
+ /**
83
+ * A registered invariant — one entry per `(adr, code)` pair.
84
+ *
85
+ * The `adr` + `code` combination forms the registry key
86
+ * (`${adr}.${code}`, e.g. `'ADR-073.I3'`). Lookups via `getInvariant`
87
+ * and per-ADR enumeration via `getInvariantsByAdr` use this convention.
88
+ *
89
+ * @see ADR-073-above-epic-naming.md §1.2 — original I1-I8 source.
90
+ */
91
+ export interface RegisteredInvariant {
92
+ /** Source ADR identifier, e.g. `'ADR-073'`. */
93
+ readonly adr: string;
94
+ /** Invariant code within the ADR, e.g. `'I3'` or `'ORC-001'`. */
95
+ readonly code: string;
96
+ /** Short human-readable name, e.g. `'Tier promotion mandatory'`. */
97
+ readonly name: string;
98
+ /** Full description (one or two sentences, no markdown). */
99
+ readonly description: string;
100
+ /** Severity tier — drives R4 CI gate strictness. */
101
+ readonly severity: InvariantSeverity;
102
+ /**
103
+ * Runtime guard function, or `null` when the invariant has no
104
+ * runtime-checkable form (display/storage/process concerns).
105
+ *
106
+ * Every `severity:'error'` invariant MUST set a non-null
107
+ * `runtimeGate` — enforced by the registry test suite (T10335 AC5)
108
+ * and by the R4 CI gate (T10338).
109
+ */
110
+ readonly runtimeGate: InvariantRuntimeGate | null;
111
+ /**
112
+ * Optional CI lint rule reference. Distinct from `runtimeGate` —
113
+ * `runtimeGate` runs in dispatch code, `lintRule` runs in CI.
114
+ */
115
+ readonly lintRule?: InvariantLintRule | null;
116
+ /**
117
+ * Optional `cleo doctor` audit reference. R6 consumes this field to
118
+ * decide which invariants surface in `cleo doctor --audit-invariants`.
119
+ */
120
+ readonly doctorAudit?: InvariantDoctorAudit | null;
121
+ /**
122
+ * Paths of test files that exercise the invariant. Used by R8 to render
123
+ * documentation pages with live test cross-references.
124
+ */
125
+ readonly tests: readonly string[];
126
+ /**
127
+ * Optional flag — set to `true` when the invariant is superseded but
128
+ * preserved for historical/import-mapping reasons. Defaults to `false`.
129
+ */
130
+ readonly deprecated?: boolean;
131
+ }
132
+
133
+ /**
134
+ * Compute the canonical registry key for an invariant.
135
+ *
136
+ * @internal
137
+ */
138
+ function buildKey(invariant: RegisteredInvariant): string {
139
+ return `${invariant.adr}.${invariant.code}`;
140
+ }
141
+
142
+ /**
143
+ * Build the merged registry record from all per-ADR invariant modules.
144
+ *
145
+ * Future ADR modules (ADR-070 ORC codes via R2 T10336, etc.) append to
146
+ * the spread list below. Each module exports a `readonly RegisteredInvariant[]`
147
+ * — this index assembles them into a keyed record at module load time.
148
+ *
149
+ * @internal
150
+ */
151
+ function buildRegistry(): Readonly<Record<string, RegisteredInvariant>> {
152
+ const entries: RegisteredInvariant[] = [...ADR_073_INVARIANTS];
153
+ const record: Record<string, RegisteredInvariant> = {};
154
+ for (const entry of entries) {
155
+ const key = buildKey(entry);
156
+ if (record[key] !== undefined) {
157
+ throw new Error(`Duplicate invariant key registered: ${key}`);
158
+ }
159
+ record[key] = entry;
160
+ }
161
+ return Object.freeze(record);
162
+ }
163
+
164
+ /**
165
+ * Central invariants registry — keyed by `${adr}.${code}`.
166
+ *
167
+ * Treat as read-only at runtime. Mutating this record is a contract
168
+ * violation; downstream consumers (R5 release-registry refactor, R6
169
+ * doctor audit, R8 docs renderer) assume immutability.
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * import { INVARIANTS_REGISTRY } from '@cleocode/contracts';
174
+ * const i3 = INVARIANTS_REGISTRY['ADR-073.I3'];
175
+ * if (i3?.runtimeGate) {
176
+ * console.log(`Guard: ${i3.runtimeGate.functionName}`);
177
+ * }
178
+ * ```
179
+ */
180
+ export const INVARIANTS_REGISTRY: Readonly<Record<string, RegisteredInvariant>> = buildRegistry();
181
+
182
+ /**
183
+ * Look up a single invariant by its `${adr}.${code}` key.
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * const i3 = getInvariant('ADR-073.I3');
188
+ * // → RegisteredInvariant for ADR-073 invariant I3
189
+ * ```
190
+ */
191
+ export function getInvariant(adrCode: string): RegisteredInvariant | undefined {
192
+ return INVARIANTS_REGISTRY[adrCode];
193
+ }
194
+
195
+ /**
196
+ * Return every invariant registered against a given ADR, in declaration order.
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * const adr073 = getInvariantsByAdr('ADR-073');
201
+ * // → array of I1..I8 entries
202
+ * ```
203
+ */
204
+ export function getInvariantsByAdr(adr: string): RegisteredInvariant[] {
205
+ const result: RegisteredInvariant[] = [];
206
+ for (const key of Object.keys(INVARIANTS_REGISTRY)) {
207
+ const entry = INVARIANTS_REGISTRY[key];
208
+ if (entry !== undefined && entry.adr === adr) {
209
+ result.push(entry);
210
+ }
211
+ }
212
+ return result;
213
+ }