@dzhechkov/harness-core 0.3.150 → 0.4.2

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 (111) hide show
  1. package/.dz-manifest.json +410 -62
  2. package/README.md +81 -3
  3. package/dist/agentdb-index.d.ts.map +1 -1
  4. package/dist/agentdb-index.js +10 -2
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/backlog-embed.d.ts +94 -0
  7. package/dist/backlog-embed.d.ts.map +1 -0
  8. package/dist/backlog-embed.js +138 -0
  9. package/dist/backlog-embed.js.map +1 -0
  10. package/dist/backlog.d.ts +180 -7
  11. package/dist/backlog.d.ts.map +1 -1
  12. package/dist/backlog.js +429 -26
  13. package/dist/backlog.js.map +1 -1
  14. package/dist/challenge-panel.d.ts +3 -0
  15. package/dist/challenge-panel.d.ts.map +1 -1
  16. package/dist/challenge-panel.js +3 -0
  17. package/dist/challenge-panel.js.map +1 -1
  18. package/dist/export-holdout.d.ts +149 -0
  19. package/dist/export-holdout.d.ts.map +1 -0
  20. package/dist/export-holdout.js +198 -0
  21. package/dist/export-holdout.js.map +1 -0
  22. package/dist/feature-adr-checkpoints.d.ts +127 -0
  23. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  24. package/dist/feature-adr-checkpoints.js +199 -1
  25. package/dist/feature-adr-checkpoints.js.map +1 -1
  26. package/dist/feature-adr-routing.d.ts +3 -0
  27. package/dist/feature-adr-routing.d.ts.map +1 -1
  28. package/dist/feature-adr-routing.js +3 -0
  29. package/dist/feature-adr-routing.js.map +1 -1
  30. package/dist/guard.d.ts +42 -0
  31. package/dist/guard.d.ts.map +1 -1
  32. package/dist/guard.js +73 -1
  33. package/dist/guard.js.map +1 -1
  34. package/dist/index.d.ts +16 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +28 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/loop-blobs.generated.d.ts +33 -0
  39. package/dist/loop-blobs.generated.d.ts.map +1 -0
  40. package/dist/loop-blobs.generated.js +101 -0
  41. package/dist/loop-blobs.generated.js.map +1 -0
  42. package/dist/loop-lint.d.ts +63 -0
  43. package/dist/loop-lint.d.ts.map +1 -0
  44. package/dist/loop-lint.js +606 -0
  45. package/dist/loop-lint.js.map +1 -0
  46. package/dist/loop-plan.d.ts +416 -0
  47. package/dist/loop-plan.d.ts.map +1 -0
  48. package/dist/loop-plan.js +1151 -0
  49. package/dist/loop-plan.js.map +1 -0
  50. package/dist/loop-render.d.ts +104 -0
  51. package/dist/loop-render.d.ts.map +1 -0
  52. package/dist/loop-render.js +1068 -0
  53. package/dist/loop-render.js.map +1 -0
  54. package/dist/loop-trace.d.ts +229 -0
  55. package/dist/loop-trace.d.ts.map +1 -0
  56. package/dist/loop-trace.js +614 -0
  57. package/dist/loop-trace.js.map +1 -0
  58. package/dist/mutation-gate.d.ts +247 -0
  59. package/dist/mutation-gate.d.ts.map +1 -0
  60. package/dist/mutation-gate.js +535 -0
  61. package/dist/mutation-gate.js.map +1 -0
  62. package/dist/no-stubs.d.ts +53 -0
  63. package/dist/no-stubs.d.ts.map +1 -0
  64. package/dist/no-stubs.js +190 -0
  65. package/dist/no-stubs.js.map +1 -0
  66. package/dist/package-skill-layouts.d.ts +67 -0
  67. package/dist/package-skill-layouts.d.ts.map +1 -0
  68. package/dist/package-skill-layouts.js +81 -0
  69. package/dist/package-skill-layouts.js.map +1 -0
  70. package/dist/patterns.d.ts.map +1 -1
  71. package/dist/patterns.js +156 -75
  72. package/dist/patterns.js.map +1 -1
  73. package/dist/recall-domain-boost.d.ts.map +1 -1
  74. package/dist/recall-domain-boost.js +6 -0
  75. package/dist/recall-domain-boost.js.map +1 -1
  76. package/dist/statusline.d.ts +10 -2
  77. package/dist/statusline.d.ts.map +1 -1
  78. package/dist/statusline.js +122 -36
  79. package/dist/statusline.js.map +1 -1
  80. package/dist/store-lock.d.ts +108 -0
  81. package/dist/store-lock.d.ts.map +1 -0
  82. package/dist/store-lock.js +231 -0
  83. package/dist/store-lock.js.map +1 -0
  84. package/dist/workflows.d.ts +16 -22
  85. package/dist/workflows.d.ts.map +1 -1
  86. package/dist/workflows.js +17 -98
  87. package/dist/workflows.js.map +1 -1
  88. package/package.json +6 -4
  89. package/sbom.json +1073 -203
  90. package/src/agentdb-index.ts +10 -1
  91. package/src/backlog-embed.ts +156 -0
  92. package/src/backlog.ts +536 -28
  93. package/src/challenge-panel.ts +4 -0
  94. package/src/export-holdout.ts +235 -0
  95. package/src/feature-adr-checkpoints.ts +291 -1
  96. package/src/feature-adr-routing.ts +4 -0
  97. package/src/guard.ts +106 -1
  98. package/src/index.ts +62 -2
  99. package/src/loop-blobs.generated.ts +114 -0
  100. package/src/loop-lint.ts +643 -0
  101. package/src/loop-plan.ts +1419 -0
  102. package/src/loop-render.ts +1126 -0
  103. package/src/loop-trace.ts +727 -0
  104. package/src/mutation-gate.ts +701 -0
  105. package/src/no-stubs.ts +204 -0
  106. package/src/package-skill-layouts.ts +107 -0
  107. package/src/patterns.ts +135 -60
  108. package/src/recall-domain-boost.ts +6 -0
  109. package/src/statusline.ts +117 -30
  110. package/src/store-lock.ts +258 -0
  111. package/src/workflows.ts +18 -117
@@ -0,0 +1,1419 @@
1
+ /**
2
+ * `loop-plan/1` — the versioned, typed workflow-plan schema of the loop-designer meta-factory
3
+ * (feature loop-designer, ADR-001).
4
+ *
5
+ * ONE semantic source of truth (the plan) with THREE derived analytical projections (AM-3 /
6
+ * Codex 04/Q1's "one plan, three projections" correction):
7
+ * - `toOracleProjection` → graph-diff against a hand-authored, requirement-derived oracle;
8
+ * - `toLintProjection` → CFG with synthetic entry/exit + explicit fork/join pairs for
9
+ * `loop-lint.ts` (barrier post-dominance, budget, reachability);
10
+ * - `toTraceProjection` → expected runtime invariants for `loop-trace.ts::runInvariants`.
11
+ * No consumer reads `plan.steps`/`plan.deps`/… directly — enforced by the layer-1 source grep
12
+ * `loop-projection-consumers.test.ts` (empty allowlist).
13
+ *
14
+ * This schema deliberately carries NO LangGraph branding and adopts none of LangGraph's execution
15
+ * semantics (AM-7): it is an internally-versioned typed plan that names its own semantics —
16
+ * `RetryProfile.maxAttempts` INCLUDES the initial attempt (AM-4), retryable failure classes are a
17
+ * CLOSED serializable enum (never a callable), `cache` (keyed on normalized input) is a SEPARATE
18
+ * identity from `checkpointing` (keyed on execution position — INV-6).
19
+ *
20
+ * Extension point: any top-level or per-step key matching /^x-/ is accepted by `parsePlan`,
21
+ * preserved by `normalizePlan` in canonical order (so it participates in `planDigest`), and
22
+ * ignored by `validatePlan` and all three projections (narrowing N-2 — vendor/consumer data such
23
+ * as a cfr case-set rides as `x-caseSet` without schema surgery).
24
+ *
25
+ * Everything here is pure (node:crypto for the digest only — no fs, no clock, no randomness).
26
+ */
27
+
28
+ import { createHash } from 'node:crypto';
29
+ import { TRACE_KEY_RE } from './loop-trace.js';
30
+
31
+ export const LOOP_PLAN_SCHEMA = 'loop-plan/1';
32
+
33
+ /** Blob/schema version stamp read by scripts/gen-loop-blobs.mjs (this module is NOT a blob source
34
+ * today; the stamp keeps the convention uniform for every loop-designer module). */
35
+ export const LOOP_PLAN_MODULE_VERSION = '1.0.0';
36
+
37
+ export type StepKind = 'agent' | 'fanout' | 'join' | 'gate' | 'pause';
38
+ export type Deliverable = 'return-value' | 'file';
39
+ export type ConcurrencyShape = 'barrier' | 'pipeline';
40
+ export type DispatchRoute = 'inline' | 'codex-wrapper' | 'codex-exec';
41
+
42
+ /** CLOSED serializable failure-class enum (AM-4 — never a callable). */
43
+ export const RETRYABLE_FAILURE_CLASSES = ['timeout', 'transport', 'malformed-output', 'policy-refusal'] as const;
44
+ export type FailureClass = (typeof RETRYABLE_FAILURE_CLASSES)[number];
45
+
46
+ /** The closed join-policy set (INV-3). `quorum:<n>` is validated by pattern. */
47
+ export const JOIN_POLICIES = ['all-declared', 'all-activated', 'any'] as const;
48
+ export const QUORUM_RE = /^quorum:[1-9][0-9]*$/;
49
+
50
+ /**
51
+ * Retry semantics owned by THIS schema (AM-4 — no verbatim LangGraph RetryPolicy copy):
52
+ * - `maxAttempts` INCLUDES the initial attempt: `1` = run once, never retry (the default for
53
+ * `kind:'agent'` steps — agent stages are expensive and rarely idempotent).
54
+ * - `retryableFailureClasses` is a CLOSED serializable enum list — a function value is a parse error.
55
+ *
56
+ * v1 NARROWING (QE round 6): the retry-TIMING family — `initialDelayMs` / `backoffMultiplier` /
57
+ * `maxDelayMs` / `jitter` — is NOT enacted by loop-plan/1 v1 and is VALIDATED-AWAY
58
+ * (`ENACT-RETRY-TIMING`). v1 retries are IMMEDIATE. Rationale: this field family reopened a
59
+ * reviewer blocker in every round it was "enacted" (round-4: the member projection dropped
60
+ * `initialDelayMs`; round-5: `backoffMultiplier`/`maxDelayMs` without `initialDelayMs` validated
61
+ * and vanished symmetrically; negative delays and a zero multiplier validated then were silently
62
+ * skipped). A plan must not validate while promising unperformed timing — see roadmap.
63
+ */
64
+ export interface RetryProfile {
65
+ /** Total attempt budget, INCLUDING the initial attempt (1 = no retry). */
66
+ maxAttempts: number;
67
+ /** NOT ENACTED in v1 — rejected by ENACT-RETRY-TIMING (retries are immediate); see roadmap. Milliseconds. */
68
+ initialDelayMs?: number;
69
+ /** NOT ENACTED in v1 — rejected by ENACT-RETRY-TIMING; see roadmap. */
70
+ backoffMultiplier?: number;
71
+ /** NOT ENACTED in v1 — rejected by ENACT-RETRY-TIMING; see roadmap. Milliseconds. */
72
+ maxDelayMs?: number;
73
+ /** NOT ENACTED in v1 — rejected by ENACT-RETRY-TIMING (the sandbox has no randomness); see roadmap. */
74
+ jitter?: 'none' | 'full' | 'deterministic';
75
+ /** CLOSED enum of retryable failure classes — never a callable (AM-4). */
76
+ retryableFailureClasses?: FailureClass[];
77
+ }
78
+
79
+ /**
80
+ * Cache policy — a SEPARATE identity from checkpointing (INV-6): cache is keyed on the step's
81
+ * NORMALIZED INPUT; a checkpoint is keyed on EXECUTION POSITION. A step may be cacheable only
82
+ * when `idempotent: true` and its deliverable has no external side effect.
83
+ */
84
+ export interface CachePolicy {
85
+ enabled: boolean;
86
+ /** The canonical cache-key composition (documented, fixed for schema/1). */
87
+ keyedOn: 'workflowVersion+stageVersion+normalizedInput+model+promptHash+toolSchemaHash+parameters+artifactHashes';
88
+ ttl?: string;
89
+ invalidateOn?: string[];
90
+ }
91
+
92
+ export interface LoopStep {
93
+ stepId: string;
94
+ title?: string;
95
+ kind: StepKind;
96
+ /** The phase this step reports under; `meta.phases` order must match first-reference order (INV-7). */
97
+ phase: string;
98
+ deps?: string[];
99
+ /** Prompt seed rendered into the GENERATED region (part of the exec fingerprint's prompt axis). */
100
+ prompt?: string;
101
+ artifacts?: { reads?: string[]; writes?: string[] };
102
+ /** For fanout members: barrier (all A, then all B) vs pipeline (per-item chains). */
103
+ concurrency?: ConcurrencyShape;
104
+ /** Model spec for this step (resolved by the model-resolver blob when set). */
105
+ model?: string | null;
106
+ /** What the step delivers: its return value, or a file written out-of-band. Default 'return-value'. */
107
+ deliverable?: Deliverable;
108
+ idempotent?: boolean;
109
+ retry?: RetryProfile;
110
+ /** Cacheable only when idempotent and side-effect-free (INV-6). */
111
+ cacheable?: boolean;
112
+ cache?: CachePolicy;
113
+ /** NOT ENACTED in v1 — rejected by ENACT-CKPT-OPT (per-step checkpoint granularity is deferred;
114
+ * `checkpointing.enabled` checkpoints EVERY top-level agent step). The field false-flipped the
115
+ * exec fingerprint in two review rounds; see roadmap. */
116
+ checkpoint?: boolean;
117
+ budget?: { maxAgents: number };
118
+ /** Dispatch route. v1 NARROWING (QE round 6): only 'inline' is enacted — 'codex-wrapper' and
119
+ * 'codex-exec' are VALIDATED-AWAY (ENACT-DISPATCH). The fire-and-forget wrapper returns a stub
120
+ * that reads as a clean result, and codex-exec had no live-proven enactment here; see roadmap. */
121
+ dispatch?: DispatchRoute;
122
+ /** For kind:'pause' — which declared pause state this step returns. */
123
+ pauseState?: string;
124
+ /** Extension keys (x-…) are preserved and digested, never validated. */
125
+ [xKey: `x-${string}`]: unknown;
126
+ }
127
+
128
+ export interface LoopFanout {
129
+ /** The stepId of the kind:'fanout' step this config belongs to. */
130
+ stage: string;
131
+ /** The member registry the fanout draws from — REQUIRED (INV-2). */
132
+ registry: string[];
133
+ /** Hard concurrency bound — REQUIRED, >= 1 (INV-2; unbounded fanout is unrepresentable-invalid). */
134
+ maxFanout: number;
135
+ dedup?: boolean;
136
+ reasonRequired?: boolean;
137
+ /** Per-item step chain (pipeline shape) — stepIds run in sequence per member. */
138
+ chain?: string[];
139
+ }
140
+
141
+ export interface LoopJoin {
142
+ /** The stepId of the kind:'join' step. */
143
+ stage: string;
144
+ /** The fanout stage this join closes (INV-3: every parallel region names its join). */
145
+ forStage: string;
146
+ branchSchema?: { caveats?: string[] };
147
+ /** From the closed set: all-declared | all-activated | any | quorum:<n>. */
148
+ joinPolicy: string;
149
+ onInvalid?: string;
150
+ }
151
+
152
+ export interface LoopGate {
153
+ stepId: string;
154
+ kind: string;
155
+ failRoute?: string;
156
+ maxRedos?: number;
157
+ }
158
+
159
+ export interface LoopPause {
160
+ /** Typed pause state name (checkpoint-return/re-invoke — never a generic interrupt, C-4). */
161
+ state: string;
162
+ payloadSchema?: Record<string, unknown>;
163
+ /** The args key a re-invoke supplies to resume past this pause. */
164
+ resumeArg: string;
165
+ }
166
+
167
+ /** The 5 explicit opt-in subsystem booleans — default ALL false. `model-resolver` is NOT an
168
+ * opt-in: it auto-includes whenever any `step.model` is set (DDD resolution, 04 §3.1). */
169
+ export interface LoopSubsystems {
170
+ checkpoints?: boolean;
171
+ /** Default OFF — the health-advisor PHI lesson (AM-9): capture is never on by default. */
172
+ trainingPairs?: boolean;
173
+ usageAdaptive?: boolean;
174
+ challengePanel?: boolean;
175
+ /** NOT ENACTED in v1 — enabling it is rejected (ENACT-SUBSYS): codex dispatch routes are
176
+ * validated-away (ENACT-DISPATCH), so the subsystem blob would inject with no call sites. */
177
+ codexDispatch?: boolean;
178
+ }
179
+
180
+ export interface LoopPlan {
181
+ schema: typeof LOOP_PLAN_SCHEMA;
182
+ name: string;
183
+ description: string;
184
+ whenToUse: string;
185
+ steps: LoopStep[];
186
+ gates?: LoopGate[];
187
+ fanouts?: LoopFanout[];
188
+ joins?: LoopJoin[];
189
+ pauses?: LoopPause[];
190
+ /** `schemaVersion` is NOT ENACTED in v1 — rejected by ENACT-CKPT-OPT (v1 pins `loop-ckpt-1`);
191
+ * the declared-vs-default distinction false-flipped the fingerprint in round 3; see roadmap. */
192
+ checkpointing?: { enabled: boolean; schemaVersion?: string };
193
+ subsystems?: LoopSubsystems;
194
+ trace?: { emit: boolean };
195
+ [xKey: `x-${string}`]: unknown;
196
+ }
197
+
198
+ export interface ParseError {
199
+ path: string;
200
+ message: string;
201
+ }
202
+
203
+ export interface Diagnostic {
204
+ invariant: string;
205
+ path: string;
206
+ message: string;
207
+ }
208
+
209
+ const STEP_ID_RE = /^[a-z0-9_.:-]{1,64}$/;
210
+
211
+ /**
212
+ * THE ItemKey domain — ONE regex object, shared by the PLAN layer (`fanouts[].registry` items) and
213
+ * the TRACE layer (`loop-trace.ts`'s dispatch-event `itemKey`/`stepId` VO check). It is the SAME
214
+ * object, not a copy: `ITEM_KEY_RE === TRACE_KEY_RE` is asserted by test, so the two layers can
215
+ * never drift apart.
216
+ *
217
+ * QE round-7 (Codex round-6 R2/R3, MEASURED): `registry: ["hello world"]` parsed and validated
218
+ * with ZERO findings — INV-2 checked only that the registry was a non-empty string array — and the
219
+ * run then behaved DIFFERENTLY depending on a field that is supposed to be observational only:
220
+ * with `trace.emit:false` it COMPLETED with one member call; with `trace.emit:true` it died BEFORE
221
+ * the member call with `loop-trace: refusing non-conforming event (itemKey fails its VO regex)`.
222
+ * A declared, retained field whose plan-domain and runtime-domain disagree is exactly the "a valid
223
+ * plan promises what the runtime will not perform" class; the domain is now ONE, checked at parse.
224
+ */
225
+ export const ITEM_KEY_RE = TRACE_KEY_RE;
226
+
227
+ /** The ItemKey validator both layers use (the plan half; the trace half calls the same regex). */
228
+ export function isItemKey(v: unknown): v is string {
229
+ return typeof v === 'string' && ITEM_KEY_RE.test(v);
230
+ }
231
+
232
+ /**
233
+ * COLLISION-RESISTANT stepId → JS-identifier lowering, with parse-time collision rejection by
234
+ * IDENT-1 (QE round-6; the round-5 reviewer's named third class: `a-b` and `a.b` both lowered to
235
+ * `a_b`, so two LEGAL distinct stepIds rendered `SyntaxError: Identifier 'USER_PROMPT_a_b' has
236
+ * already been declared`). A lossless id (already a pure `[A-Za-z0-9_]` word) lowers to itself; a
237
+ * LOSSY sanitization always appends an 8-hex sha256 suffix of the ORIGINAL id, so two distinct ids
238
+ * no longer collide by ACCIDENT. The IDENT-1 parse check is the belt — both for crafted names that
239
+ * imitate the suffix form and for the (astronomically unlikely by accident, cheap for an attacker)
240
+ * truncated-hash collision. This function is NOT injective; see the honesty note below.
241
+ *
242
+ * HONESTY NOTE (QE round-7 — the round-6 reviewer's caveat, CONCEDED verbatim: "an 8-hex truncated
243
+ * hash is not itself mathematically injective; the parse belt supplies the actual safety"). A
244
+ * 32-bit truncation of sha256 is COLLISION-RESISTANT, not INJECTIVE: over a 32-bit codomain,
245
+ * distinct originals with the same sanitized stem CAN in principle share a suffix (finding such a
246
+ * pair costs ~2^16 tries by the birthday bound — cheap for an ATTACKER, unreachable by accident).
247
+ * So the guarantee this function carries alone is "no ACCIDENTAL collision"; the guarantee that a
248
+ * plan with two ids lowering to one identifier is REJECTED is IDENT-1's (parsePlan), which
249
+ * compares the ACTUAL lowered strings and needs no injectivity assumption. IDENT-1 is the safety
250
+ * belt; widening the suffix would only move the bound, never remove the need for the belt.
251
+ */
252
+ export function stepIdent(stepId: string): string {
253
+ const clean = stepId.replace(/[^A-Za-z0-9_]/g, '_');
254
+ if (clean === stepId) return stepId;
255
+ return clean + '_' + createHash('sha256').update(stepId, 'utf8').digest('hex').slice(0, 8);
256
+ }
257
+
258
+ function isRecord(v: unknown): v is Record<string, unknown> {
259
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
260
+ }
261
+
262
+ function isStringArray(v: unknown): v is string[] {
263
+ return Array.isArray(v) && v.every((s) => typeof s === 'string');
264
+ }
265
+
266
+ // ─────────────────────────────────────────────────────────────────────────────
267
+ // FIELD DOMAINS (QE round-4 B1): every declared schema field has a DECLARED raw-value domain, and
268
+ // parsePlan validates each present value against it BEFORE the cast to LoopPlan — `deliverable:
269
+ // 'telepathy'`, `dispatch: 'carrier-pigeon'` and `checkpointing.enabled: 'yes'` are PARSE errors,
270
+ // never silently-accepted junk that downstream `=== true` / `.find()` logic quietly ignores.
271
+ // `'bespoke'` marks fields whose dedicated parse/validate logic already owns the check (schema,
272
+ // stepId regex, kind enum, retry inner shape, …) — the table stays TOTAL so the honesty test can
273
+ // assert a new field cannot ship without a domain entry (same discipline as the disposition
274
+ // enumeration).
275
+ // ─────────────────────────────────────────────────────────────────────────────
276
+
277
+ export type FieldDomain =
278
+ | { t: 'bespoke' }
279
+ | { t: 'string' }
280
+ | { t: 'boolean' }
281
+ | { t: 'number' }
282
+ | { t: 'posInt' } // integer >= 1 (QE round-5 B1: retry.maxAttempts 0 was a silent runtime clamp)
283
+ | { t: 'enum'; values: readonly string[] }
284
+ | { t: 'string[]' }
285
+ | { t: 'record' }
286
+ | { t: 'record[]' }
287
+ | { t: 'string|null' };
288
+
289
+ /** Keys are `<Interface>.<field>` plus `<inlineField>.<sub>` — EXACTLY the keys the honesty test
290
+ * extracts from this file's interface source (both directions asserted there). */
291
+ export const FIELD_DOMAINS: Record<string, FieldDomain> = {
292
+ // ── LoopPlan ──
293
+ 'LoopPlan.schema': { t: 'bespoke' },
294
+ 'LoopPlan.name': { t: 'bespoke' },
295
+ 'LoopPlan.description': { t: 'bespoke' },
296
+ 'LoopPlan.whenToUse': { t: 'bespoke' },
297
+ 'LoopPlan.steps': { t: 'bespoke' },
298
+ 'LoopPlan.gates': { t: 'record[]' },
299
+ 'LoopPlan.fanouts': { t: 'record[]' },
300
+ 'LoopPlan.joins': { t: 'record[]' },
301
+ 'LoopPlan.pauses': { t: 'record[]' },
302
+ 'LoopPlan.checkpointing': { t: 'record' },
303
+ 'LoopPlan.subsystems': { t: 'record' },
304
+ 'LoopPlan.trace': { t: 'record' },
305
+ 'checkpointing.enabled': { t: 'boolean' },
306
+ 'checkpointing.schemaVersion': { t: 'string' },
307
+ 'trace.emit': { t: 'boolean' },
308
+ // ── LoopSubsystems ──
309
+ 'LoopSubsystems.checkpoints': { t: 'boolean' },
310
+ 'LoopSubsystems.trainingPairs': { t: 'boolean' },
311
+ 'LoopSubsystems.usageAdaptive': { t: 'boolean' },
312
+ 'LoopSubsystems.challengePanel': { t: 'boolean' },
313
+ 'LoopSubsystems.codexDispatch': { t: 'boolean' },
314
+ // ── LoopStep ──
315
+ 'LoopStep.stepId': { t: 'bespoke' },
316
+ 'LoopStep.title': { t: 'string' },
317
+ 'LoopStep.kind': { t: 'bespoke' },
318
+ 'LoopStep.phase': { t: 'bespoke' },
319
+ 'LoopStep.deps': { t: 'bespoke' },
320
+ 'LoopStep.prompt': { t: 'string' },
321
+ 'LoopStep.artifacts': { t: 'record' },
322
+ 'LoopStep.concurrency': { t: 'enum', values: ['barrier', 'pipeline'] },
323
+ 'LoopStep.model': { t: 'string|null' },
324
+ 'LoopStep.deliverable': { t: 'enum', values: ['return-value', 'file'] },
325
+ 'LoopStep.idempotent': { t: 'boolean' },
326
+ 'LoopStep.retry': { t: 'bespoke' },
327
+ 'LoopStep.cacheable': { t: 'boolean' },
328
+ 'LoopStep.cache': { t: 'record' },
329
+ 'LoopStep.checkpoint': { t: 'boolean' },
330
+ 'LoopStep.budget': { t: 'record' },
331
+ 'LoopStep.dispatch': { t: 'enum', values: ['inline', 'codex-wrapper', 'codex-exec'] },
332
+ 'LoopStep.pauseState': { t: 'string' },
333
+ 'artifacts.reads': { t: 'string[]' },
334
+ 'artifacts.writes': { t: 'string[]' },
335
+ 'budget.maxAgents': { t: 'number' },
336
+ // ── RetryProfile ──
337
+ // QE round-5 B1 (Codex R4: "retry.maxAttempts: 0 parses and validates … the runtime silently
338
+ // clamps it to one attempt"): the DOMAIN owns the minimum — 0/negative/fractional is a PARSE
339
+ // error, never a silent clamp. The emitted runtime keeps its >=1 guard as defense-in-depth only.
340
+ 'RetryProfile.maxAttempts': { t: 'posInt' },
341
+ 'RetryProfile.initialDelayMs': { t: 'number' },
342
+ 'RetryProfile.backoffMultiplier': { t: 'number' },
343
+ 'RetryProfile.maxDelayMs': { t: 'number' },
344
+ 'RetryProfile.jitter': { t: 'enum', values: ['none', 'full', 'deterministic'] },
345
+ 'RetryProfile.retryableFailureClasses': { t: 'bespoke' },
346
+ // ── CachePolicy ──
347
+ 'CachePolicy.enabled': { t: 'boolean' },
348
+ 'CachePolicy.keyedOn': { t: 'enum', values: ['workflowVersion+stageVersion+normalizedInput+model+promptHash+toolSchemaHash+parameters+artifactHashes'] },
349
+ 'CachePolicy.ttl': { t: 'string' },
350
+ 'CachePolicy.invalidateOn': { t: 'string[]' },
351
+ // ── LoopFanout ──
352
+ 'LoopFanout.stage': { t: 'string' },
353
+ 'LoopFanout.registry': { t: 'string[]' },
354
+ 'LoopFanout.maxFanout': { t: 'number' },
355
+ 'LoopFanout.dedup': { t: 'boolean' },
356
+ 'LoopFanout.reasonRequired': { t: 'boolean' },
357
+ 'LoopFanout.chain': { t: 'string[]' },
358
+ // ── LoopJoin ──
359
+ 'LoopJoin.stage': { t: 'string' },
360
+ 'LoopJoin.forStage': { t: 'string' },
361
+ 'LoopJoin.branchSchema': { t: 'record' },
362
+ 'LoopJoin.joinPolicy': { t: 'string' }, // the closed SET is INV-3's (semantic); the raw TYPE is a string
363
+ 'LoopJoin.onInvalid': { t: 'string' }, // the enacted set is ENACT-JOIN's
364
+ 'branchSchema.caveats': { t: 'string[]' },
365
+ // ── LoopGate ──
366
+ 'LoopGate.stepId': { t: 'string' },
367
+ 'LoopGate.kind': { t: 'string' },
368
+ 'LoopGate.failRoute': { t: 'string' },
369
+ 'LoopGate.maxRedos': { t: 'number' }, // integer/range semantics are GATE-1's
370
+ // ── LoopPause ──
371
+ 'LoopPause.state': { t: 'string' },
372
+ 'LoopPause.payloadSchema': { t: 'record' },
373
+ 'LoopPause.resumeArg': { t: 'string' }, // non-emptiness is INV-5's
374
+ };
375
+
376
+ // ─────────────────────────────────────────────────────────────────────────────
377
+ // CLOSED-WORLD KEY SETS (QE round-7 B1 class-kill; Codex round-6 R1/R3 BLOCKER, MEASURED: five
378
+ // unknown non-`x-` keys — `steps[].retry.delayMs`, `steps[].dispatchRoute:"codex-exec"`,
379
+ // `subsystems.codexExec:true`, `checkpointing.version`, top-level `retryTiming` — parsed with ZERO
380
+ // diagnostics, were preserved into the plan digest, and did NOTHING at runtime; typos `trcae` /
381
+ // `promtp` did the same. `checkDomains` accepted any key whose FIELD_DOMAINS lookup was absent
382
+ // (`dom === undefined || domainAccepts(...)`), so the published contract "everything else is
383
+ // REJECTED with a named diagnostic" was false for the whole second-spelling/sibling-field space.
384
+ //
385
+ // THE MECHANISM (deliberately NOT a second hand-list): the known-key set per record kind is
386
+ // DERIVED from FIELD_DOMAINS — the same table the honesty enumeration pins, in BOTH directions,
387
+ // against the interface SOURCE of this file ("EVERY field declared in loop-plan.ts has a
388
+ // FIELD_DOMAINS entry, and no entry is stale"). Therefore: adding an interface field WITHOUT a
389
+ // domain entry fails the honesty test; adding it WITH one makes it known here automatically. There
390
+ // is exactly one roster, and it is the source's.
391
+ //
392
+ // WHAT THIS DOES AND DOES NOT PROVE (QE round-7, the cross-family reviewer's ONE not-met bar item,
393
+ // CONCEDED). PROVEN, and tested: every record path CURRENTLY WIRED here is closed — no key of any
394
+ // present-day spelling reaches the plan without a diagnostic, and the accepted roster is the
395
+ // source's, not a second hand-list. NOT PROVEN, and it would be an overclaim to say otherwise: that
396
+ // a record kind added in the FUTURE is closed AUTOMATICALLY. Three things remain hand-bounded —
397
+ // (a) the honesty test's `SCANNED` names the interfaces it scans, (b) the fuzz's `INJECT` names the
398
+ // injection sites, and (c) `checkKeys`' parser DESCENT names which nested records it walks. The
399
+ // reviewer's constructive counterexample: declare `LoopStep.extra?: ExtraPolicy` with only a
400
+ // `LoopStep.extra: {t:'record'}` domain entry and an `ExtraPolicy { enabled?: boolean }` interface —
401
+ // unless someone ALSO hand-adds ExtraPolicy's own domains, the descent, `SCANNED` and `INJECT`, then
402
+ // `extra: { enabeld: true }` escapes closed-world checking WHILE the equality assertions stay green.
403
+ // That is a future-extension / proof-maintenance hole, not an input bypass that works today.
404
+ //
405
+ // EXTENSION DISCIPLINE (the manual step the derivation does not perform for you). When you add a
406
+ // NEW nested record-typed field, do ALL FOUR in the same change, or the key space it opens is not
407
+ // closed: 1) add `<NewIface>.<field>` entries to FIELD_DOMAINS for every field of the new interface
408
+ // (not just the `{t:'record'}` entry on its PARENT); 2) add the new interface to the honesty test's
409
+ // `SCANNED`; 3) add an `INJECT` site for it in the closed-world fuzz; 4) descend into it in
410
+ // `checkKeys`. A structural fix that derives 2–4 from the interface graph — so a new record kind is
411
+ // either wired automatically or fails LOUDLY as unknown — is filed as backlog idea
412
+ // `d25a3c8aef603921` (`dz backlog show d25a3c8aef603921`); until it lands, this list is the
413
+ // contract, and it lives one ladder-layer weaker than the property it protects.
414
+ // ─────────────────────────────────────────────────────────────────────────────
415
+
416
+ /** `<iface>` → its accepted field names, derived from FIELD_DOMAINS (never hand-written). */
417
+ export const KNOWN_KEYS: Record<string, ReadonlySet<string>> = (() => {
418
+ const m: Record<string, Set<string>> = {};
419
+ for (const k of Object.keys(FIELD_DOMAINS)) {
420
+ const dot = k.indexOf('.');
421
+ const iface = k.slice(0, dot);
422
+ const field = k.slice(dot + 1);
423
+ const set = m[iface] ?? new Set<string>();
424
+ set.add(field);
425
+ m[iface] = set;
426
+ }
427
+ return m;
428
+ })();
429
+
430
+ /** The record kinds that carry the documented `[xKey: \`x-${string}\`]` vendor escape — i.e. the
431
+ * ONLY scopes where an `x-` key is accepted (Codex round-6: "Restrict `x-` acceptance to its
432
+ * documented scopes"). The honesty test derives this set from the interface SOURCE (which
433
+ * interfaces declare the index signature) in both directions, so it cannot drift either. */
434
+ export const X_KEY_IFACES: ReadonlySet<string> = new Set(['LoopPlan', 'LoopStep']);
435
+
436
+ /**
437
+ * REQUIRED fields per record kind (QE round-6; Codex round-5 R1 blocker 1: a `fanouts[]` record
438
+ * with the REQUIRED `stage` field ABSENT parsed, validated, and rendered a zero-agent COMPLETED
439
+ * run — `parsePlan` checked only PRESENT fields and XREF-1 skipped `undefined` targets).
440
+ * Same discipline as FIELD_DOMAINS: the table is TOTAL over the schema's record kinds, and the
441
+ * honesty test derives the expected sets from the interface SOURCE (fields declared without `?`),
442
+ * so a new required field cannot ship unenforced and a stale entry cannot linger.
443
+ *
444
+ * Keys marked in OWNED_ABSENCE have dedicated absence diagnostics in parsePlan (kept verbatim for
445
+ * message stability); the REQUIRED pass enforces everything else.
446
+ */
447
+ export const REQUIRED_FIELDS: Record<string, readonly string[]> = {
448
+ LoopPlan: ['schema', 'name', 'description', 'whenToUse', 'steps'],
449
+ LoopStep: ['stepId', 'kind', 'phase'],
450
+ RetryProfile: ['maxAttempts'],
451
+ CachePolicy: ['enabled', 'keyedOn'],
452
+ LoopFanout: ['stage', 'registry', 'maxFanout'],
453
+ LoopJoin: ['stage', 'forStage', 'joinPolicy'],
454
+ LoopGate: ['stepId', 'kind'],
455
+ LoopPause: ['state', 'resumeArg'],
456
+ LoopSubsystems: [],
457
+ checkpointing: ['enabled'],
458
+ trace: ['emit'],
459
+ artifacts: [],
460
+ budget: ['maxAgents'],
461
+ branchSchema: [],
462
+ };
463
+
464
+ /** `<iface>.<field>` keys whose ABSENCE is reported by a dedicated (pre-round-6) parse check —
465
+ * the REQUIRED pass skips them so one absence yields one diagnostic. */
466
+ const OWNED_ABSENCE = new Set([
467
+ 'LoopPlan.schema',
468
+ 'LoopPlan.name',
469
+ 'LoopPlan.description',
470
+ 'LoopPlan.whenToUse',
471
+ 'LoopPlan.steps',
472
+ 'LoopStep.stepId',
473
+ 'LoopStep.kind',
474
+ 'LoopStep.phase',
475
+ 'RetryProfile.maxAttempts',
476
+ ]);
477
+
478
+ function domainAccepts(dom: FieldDomain, v: unknown): boolean {
479
+ switch (dom.t) {
480
+ case 'bespoke':
481
+ return true;
482
+ case 'string':
483
+ return typeof v === 'string';
484
+ case 'boolean':
485
+ return typeof v === 'boolean';
486
+ case 'number':
487
+ return typeof v === 'number' && Number.isFinite(v);
488
+ case 'posInt':
489
+ return typeof v === 'number' && Number.isInteger(v) && v >= 1;
490
+ case 'enum':
491
+ return typeof v === 'string' && dom.values.includes(v);
492
+ case 'string[]':
493
+ return isStringArray(v);
494
+ case 'record':
495
+ return isRecord(v);
496
+ case 'record[]':
497
+ return Array.isArray(v) && v.every(isRecord);
498
+ case 'string|null':
499
+ return v === null || typeof v === 'string';
500
+ }
501
+ }
502
+
503
+ function describeDomain(dom: FieldDomain): string {
504
+ switch (dom.t) {
505
+ case 'bespoke':
506
+ return 'checked by dedicated logic';
507
+ case 'posInt':
508
+ return 'an integer >= 1';
509
+ case 'enum':
510
+ return `one of ${dom.values.join('|')}`;
511
+ case 'string[]':
512
+ return 'a string array';
513
+ case 'record':
514
+ return 'an object';
515
+ case 'record[]':
516
+ return 'an array of objects';
517
+ case 'string|null':
518
+ return 'a string or null';
519
+ default:
520
+ return `a ${dom.t}`;
521
+ }
522
+ }
523
+
524
+ /** KIND-APPLICABILITY matrix (QE round-4 B1, Codex R4: "a pause with deliverable:'file' and
525
+ * artifacts.writes validated … and performed zero landed polls"): which LoopStep fields loop-render/1
526
+ * ENACTS per step kind, declared as DATA and validated from it (KIND-1). A field present on a kind
527
+ * that never enacts it is an unperformed promise and is REJECTED. Fanout chain members are kind
528
+ * 'agent', so member fields ride the agent column (MEMBER-1 further restricts them). */
529
+ const ALL_KINDS: readonly StepKind[] = ['agent', 'fanout', 'join', 'gate', 'pause'];
530
+ const DISPATCHING_KINDS: readonly StepKind[] = ['agent', 'gate'];
531
+ export const STEP_FIELD_KINDS: Record<string, readonly StepKind[]> = {
532
+ stepId: ALL_KINDS,
533
+ title: ALL_KINDS,
534
+ kind: ALL_KINDS,
535
+ phase: ALL_KINDS,
536
+ deps: ALL_KINDS,
537
+ budget: ALL_KINDS, // budget is an ACCOUNTING weight — summed into the rendered budget guard for every kind
538
+ prompt: DISPATCHING_KINDS,
539
+ artifacts: DISPATCHING_KINDS,
540
+ model: DISPATCHING_KINDS,
541
+ deliverable: DISPATCHING_KINDS,
542
+ idempotent: DISPATCHING_KINDS,
543
+ retry: DISPATCHING_KINDS,
544
+ cacheable: DISPATCHING_KINDS,
545
+ cache: DISPATCHING_KINDS,
546
+ dispatch: DISPATCHING_KINDS,
547
+ checkpoint: ['agent'], // historical granularity row — the FIELD itself is validated-away in v1 (ENACT-CKPT-OPT); only top-level agent steps checkpoint, as a renderer fact
548
+ concurrency: ['fanout'],
549
+ pauseState: ['pause'],
550
+ };
551
+
552
+ // ─────────────────────────────────────────────────────────────────────────────
553
+ // CROSS-REF TABLE (QE round-5 B1 class-kill): every field that references a stepId is DECLARED
554
+ // here — which collection/field it is, which step KINDS are legal targets, and what renderer
555
+ // consumption makes an accepted record non-decorative. Round-4's lesson: existence-only refCheck
556
+ // (INV-1) let a joins[] record name an ordinary agent — the renderer added it to its join-skip set
557
+ // and the run COMPLETED with zero agent calls (Codex R3). Validation is DRIVEN by this table
558
+ // (XREF-1), so a new referencing field cannot ship without declaring its legal target kinds.
559
+ // NOT stepId references (owned elsewhere): pauses[].state / steps[].pauseState are pause-STATE
560
+ // names (INV-5); gates[].failRoute 'terminal:*' names are typed terminal phases (allowTerminal).
561
+ // ─────────────────────────────────────────────────────────────────────────────
562
+
563
+ export interface CrossRef {
564
+ /** '<collection>[].<field>' — the referencing field, as documented in diagnostics. */
565
+ ref: string;
566
+ /** Legal target step kinds. A reference to an existing step of another kind is XREF-1. */
567
+ targetKinds: readonly StepKind[];
568
+ /** 'terminal:<name>' allowed in place of a stepId (gate terminal routes). */
569
+ allowTerminal?: true;
570
+ /** How the renderer CONSUMES an accepted record (doc — the UNCONSUMED-1 checks assert the
571
+ * reverse direction where a skip/no-op is otherwise possible). */
572
+ consumes: string;
573
+ /** Enumerate every reference instance this field carries in a plan (the table is self-contained:
574
+ * a new entry cannot ship without its accessor, so it cannot silently validate nothing). */
575
+ get: (plan: LoopPlan) => { target: string | undefined; path: string }[];
576
+ }
577
+
578
+ export const CROSS_REFS: readonly CrossRef[] = [
579
+ {
580
+ ref: 'steps[].deps[]',
581
+ targetKinds: ['agent', 'fanout', 'join', 'gate', 'pause'],
582
+ consumes: 'dep ordering + checkpoint input hash + causedBy wiring',
583
+ get: (p) => p.steps.flatMap((s, i) => (s.deps ?? []).map((d) => ({ target: d, path: `$.steps[${i}].deps` }))),
584
+ },
585
+ {
586
+ ref: 'gates[].stepId',
587
+ targetKinds: ['gate'],
588
+ consumes: 'the gate step render .find()s its config (verdict routing)',
589
+ get: (p) => (p.gates ?? []).map((g, i) => ({ target: g.stepId, path: `$.gates[${i}].stepId` })),
590
+ },
591
+ {
592
+ ref: 'gates[].failRoute',
593
+ targetKinds: ['agent'],
594
+ allowTerminal: true,
595
+ consumes: 'the redo loop re-dispatches __dispatch_<failRoute>',
596
+ get: (p) => (p.gates ?? []).map((g, i) => ({ target: g.failRoute, path: `$.gates[${i}].failRoute` })),
597
+ },
598
+ {
599
+ ref: 'fanouts[].stage',
600
+ targetKinds: ['fanout'],
601
+ consumes: 'the fanout step render .find()s its config (registry/cap/chain)',
602
+ get: (p) => (p.fanouts ?? []).map((f, i) => ({ target: f.stage, path: `$.fanouts[${i}].stage` })),
603
+ },
604
+ {
605
+ ref: 'fanouts[].chain[]',
606
+ targetKinds: ['agent'],
607
+ consumes: 'dispatched per item inside the region (a pause/join/gate in a chain would be mis-dispatched as an agent)',
608
+ get: (p) => (p.fanouts ?? []).flatMap((f, i) => (f.chain ?? []).map((c) => ({ target: c, path: `$.fanouts[${i}].chain` }))),
609
+ },
610
+ {
611
+ ref: 'joins[].stage',
612
+ targetKinds: ['join'],
613
+ consumes: 'skipped as a top-level step; its joinRegion call closes the region (naming an agent here would silently skip that agent)',
614
+ get: (p) => (p.joins ?? []).map((j, i) => ({ target: j.stage, path: `$.joins[${i}].stage` })),
615
+ },
616
+ {
617
+ ref: 'joins[].forStage',
618
+ targetKinds: ['fanout'],
619
+ consumes: 'the fanout region render .find()s its join policy',
620
+ get: (p) => (p.joins ?? []).map((j, i) => ({ target: j.forStage, path: `$.joins[${i}].forStage` })),
621
+ },
622
+ ];
623
+
624
+ /**
625
+ * Parse a raw JSON value into a LoopPlan, or return the ParseError list (union return per the
626
+ * behavioral contract). Structural only — semantic invariants live in `validatePlan`.
627
+ * `x-` keys at the top level and per step are accepted and carried through verbatim.
628
+ * A callable/`function` value anywhere in a retry field is a PARSE error (AM-4).
629
+ */
630
+ export function parsePlan(json: unknown): LoopPlan | ParseError[] {
631
+ const errors: ParseError[] = [];
632
+ if (!isRecord(json)) return [{ path: '$', message: 'plan must be a JSON object' }];
633
+ // ── raw-domain validation (QE round-4 B1): every PRESENT declared field is checked against its
634
+ // FIELD_DOMAINS entry BEFORE the cast — an out-of-domain value is a named PARSE error, never a
635
+ // silently-ignored value the runtime's `=== true` / `?? default` logic quietly normalizes away.
636
+ const checkDomains = (obj: Record<string, unknown>, iface: string, basePath: string): void => {
637
+ const known = KNOWN_KEYS[iface];
638
+ const xScoped = X_KEY_IFACES.has(iface);
639
+ for (const [k, v] of Object.entries(obj)) {
640
+ // CLOSED-WORLD key check (QE round-7 B1): only the source-derived documented fields plus the
641
+ // `x-` vendor escape AT ITS DOCUMENTED SCOPES are accepted. An unknown non-`x-` key is a
642
+ // second spelling or a sibling field that would parse, ride the digest, and do NOTHING.
643
+ if (k.startsWith('x-')) {
644
+ if (!xScoped) {
645
+ errors.push({ path: `${basePath}.${k}`, message: `x- vendor keys are accepted only at their documented scopes (${[...X_KEY_IFACES].sort().join(', ')}) — "${k}" on ${iface} is outside them, so it would be digested but never carried anywhere a consumer reads it` });
646
+ }
647
+ continue;
648
+ }
649
+ if (known === undefined || !known.has(k)) {
650
+ errors.push({ path: `${basePath}.${k}`, message: `unknown key "${k}" on ${iface} — loop-plan/1 is CLOSED-WORLD: only the documented fields (derived from this file's interface source via FIELD_DOMAINS) and x- vendor keys are accepted; an unknown non-x- key parses, digests, and enacts NOTHING, which is exactly the "a valid plan promises unperformed behavior" defect this schema forbids` });
651
+ continue;
652
+ }
653
+ if (v === undefined) continue;
654
+ const dom = FIELD_DOMAINS[`${iface}.${k}`];
655
+ if (dom === undefined || domainAccepts(dom, v)) continue;
656
+ errors.push({ path: `${basePath}.${k}`, message: `${k} must be ${describeDomain(dom)} (raw-domain check) — got ${typeof v === 'string' ? JSON.stringify(v) : typeof v === 'object' ? (Array.isArray(v) ? 'an array' : 'an object') : String(v)}` });
657
+ }
658
+ // REQUIRED-fields presence (QE round-6): an ABSENT required field is a PARSE error — round-5's
659
+ // zero-agent completion rode a fanouts[] record whose required `stage` was simply missing.
660
+ for (const req of REQUIRED_FIELDS[iface] ?? []) {
661
+ if (OWNED_ABSENCE.has(`${iface}.${req}`)) continue;
662
+ if (obj[req] === undefined) {
663
+ errors.push({ path: `${basePath}.${req}`, message: `${req} is REQUIRED on ${iface} (REQUIRED_FIELDS) — an absent required field must fail parse, never render a silently-skipped record` });
664
+ }
665
+ }
666
+ };
667
+ checkDomains(json, 'LoopPlan', '$');
668
+ if (isRecord(json['checkpointing'])) checkDomains(json['checkpointing'], 'checkpointing', '$.checkpointing');
669
+ if (isRecord(json['subsystems'])) checkDomains(json['subsystems'], 'LoopSubsystems', '$.subsystems');
670
+ if (isRecord(json['trace'])) checkDomains(json['trace'], 'trace', '$.trace');
671
+ for (const [arrKey, iface] of [['gates', 'LoopGate'], ['fanouts', 'LoopFanout'], ['joins', 'LoopJoin'], ['pauses', 'LoopPause']] as const) {
672
+ const arr = json[arrKey];
673
+ if (!Array.isArray(arr)) continue;
674
+ arr.forEach((entry, i) => {
675
+ if (!isRecord(entry)) return; // the record[] domain check on the array itself already errored
676
+ checkDomains(entry, iface, `$.${arrKey}[${i}]`);
677
+ if (iface === 'LoopJoin' && isRecord(entry['branchSchema'])) checkDomains(entry['branchSchema'], 'branchSchema', `$.${arrKey}[${i}].branchSchema`);
678
+ // ITEM-KEY DOMAIN (QE round-7 B1): registry items become the runtime's `itemKey` — they must
679
+ // live in the ONE shared ItemKey domain, checked HERE at parse rather than discovered by the
680
+ // trace plane at dispatch time (Codex round-6: `"hello world"` validated clean, then trace-on
681
+ // refused the event BEFORE the member call while trace-off COMPLETED).
682
+ if (iface === 'LoopFanout' && Array.isArray(entry['registry'])) {
683
+ (entry['registry'] as unknown[]).forEach((item, ri) => {
684
+ if (typeof item !== 'string') return; // the string[] raw-domain check owns the type
685
+ if (!isItemKey(item)) {
686
+ errors.push({ path: `$.${arrKey}[${i}].registry[${ri}]`, message: `registry item ${JSON.stringify(item)} is not an ItemKey (${String(ITEM_KEY_RE)}) — registry items become the runtime itemKey of every member dispatch, and the trace plane REFUSES a non-conforming event, so an out-of-domain item makes trace.emit decide whether the plan can run at all` });
687
+ }
688
+ });
689
+ }
690
+ });
691
+ }
692
+ if (json['schema'] !== LOOP_PLAN_SCHEMA) {
693
+ errors.push({ path: '$.schema', message: `schema must be "${LOOP_PLAN_SCHEMA}"` });
694
+ }
695
+ for (const key of ['name', 'description', 'whenToUse'] as const) {
696
+ if (typeof json[key] !== 'string' || json[key] === '') {
697
+ errors.push({ path: `$.${key}`, message: `${key} must be a non-empty string` });
698
+ }
699
+ }
700
+ if (!Array.isArray(json['steps']) || json['steps'].length === 0) {
701
+ errors.push({ path: '$.steps', message: 'steps must be a non-empty array' });
702
+ return errors;
703
+ }
704
+ const steps = json['steps'] as unknown[];
705
+ steps.forEach((raw, i) => {
706
+ if (!isRecord(raw)) {
707
+ errors.push({ path: `$.steps[${i}]`, message: 'step must be an object' });
708
+ return;
709
+ }
710
+ checkDomains(raw, 'LoopStep', `$.steps[${i}]`);
711
+ if (isRecord(raw['artifacts'])) checkDomains(raw['artifacts'], 'artifacts', `$.steps[${i}].artifacts`);
712
+ if (isRecord(raw['budget'])) checkDomains(raw['budget'], 'budget', `$.steps[${i}].budget`);
713
+ if (isRecord(raw['retry'])) checkDomains(raw['retry'], 'RetryProfile', `$.steps[${i}].retry`);
714
+ if (isRecord(raw['cache'])) checkDomains(raw['cache'], 'CachePolicy', `$.steps[${i}].cache`);
715
+ if (typeof raw['stepId'] !== 'string' || !STEP_ID_RE.test(raw['stepId'])) {
716
+ errors.push({ path: `$.steps[${i}].stepId`, message: `stepId must match ${String(STEP_ID_RE)}` });
717
+ }
718
+ if (!['agent', 'fanout', 'join', 'gate', 'pause'].includes(String(raw['kind']))) {
719
+ errors.push({ path: `$.steps[${i}].kind`, message: 'kind must be one of agent|fanout|join|gate|pause' });
720
+ }
721
+ if (typeof raw['phase'] !== 'string' || raw['phase'] === '') {
722
+ errors.push({ path: `$.steps[${i}].phase`, message: 'phase must be a non-empty string' });
723
+ }
724
+ if (raw['deps'] !== undefined && !isStringArray(raw['deps'])) {
725
+ errors.push({ path: `$.steps[${i}].deps`, message: 'deps must be a string array' });
726
+ }
727
+ const retry = raw['retry'];
728
+ if (retry !== undefined) {
729
+ if (!isRecord(retry) || typeof retry['maxAttempts'] !== 'number') {
730
+ errors.push({ path: `$.steps[${i}].retry`, message: 'retry.maxAttempts (number, INCLUDES the initial attempt) is required' });
731
+ } else {
732
+ for (const [k, v] of Object.entries(retry)) {
733
+ if (typeof v === 'function') {
734
+ errors.push({ path: `$.steps[${i}].retry.${k}`, message: 'retry fields are data-only — a callable is not a valid failure predicate (closed failure-class enum only)' });
735
+ }
736
+ }
737
+ const rfc = retry['retryableFailureClasses'];
738
+ if (rfc !== undefined) {
739
+ if (!Array.isArray(rfc) || rfc.some((c) => !RETRYABLE_FAILURE_CLASSES.includes(c as FailureClass))) {
740
+ errors.push({
741
+ path: `$.steps[${i}].retry.retryableFailureClasses`,
742
+ message: `retryableFailureClasses must be a subset of the closed enum [${RETRYABLE_FAILURE_CLASSES.join(', ')}]`,
743
+ });
744
+ }
745
+ }
746
+ }
747
+ }
748
+ });
749
+ // Duplicate stepIds are a PARSE error (QE round-6; Codex round-5: two steps named 'a' validated
750
+ // cleanly, then rendering died with "Identifier 'USER_PROMPT_a' has already been declared").
751
+ const seenIds = new Map<string, number>();
752
+ steps.forEach((raw, i) => {
753
+ if (!isRecord(raw) || typeof raw['stepId'] !== 'string') return;
754
+ const id = raw['stepId'];
755
+ const first = seenIds.get(id);
756
+ if (first !== undefined) {
757
+ errors.push({ path: `$.steps[${i}].stepId`, message: `duplicate stepId "${id}" (first declared at $.steps[${first}]) — stepIds must be unique; a duplicate renders colliding const declarations (an unparseable script)` });
758
+ } else {
759
+ seenIds.set(id, i);
760
+ }
761
+ });
762
+ // IDENT-1 (QE round-6, the collision belt): distinct stepIds must lower to distinct generated
763
+ // identifiers. stepIdent() is COLLISION-RESISTANT, not injective (8-hex = 32-bit truncated
764
+ // sha256) — so this check, which compares the ACTUAL lowered strings, is what supplies the
765
+ // safety: it catches a crafted clean name that imitates another id's suffixed form AND any
766
+ // truncated-hash collision, without assuming injectivity anywhere.
767
+ const identSeen = new Map<string, string>();
768
+ for (const [id] of seenIds) {
769
+ const low = stepIdent(id);
770
+ const prior = identSeen.get(low);
771
+ if (prior !== undefined && prior !== id) {
772
+ errors.push({ path: '$.steps', message: `stepIds "${prior}" and "${id}" lower to the same generated identifier "${low}" (IDENT-1) — distinct stepIds must lower to distinct generated identifiers; rename one` });
773
+ } else {
774
+ identSeen.set(low, id);
775
+ }
776
+ }
777
+ if (errors.length > 0) return errors;
778
+ return json as unknown as LoopPlan;
779
+ }
780
+
781
+ /** True when a parsePlan return is the error branch of the union. */
782
+ export function isParseErrors(v: LoopPlan | ParseError[]): v is ParseError[] {
783
+ return Array.isArray(v);
784
+ }
785
+
786
+ function stepDefault(step: LoopStep): Required<Pick<LoopStep, 'deliverable' | 'idempotent'>> & { maxAttempts: number } {
787
+ return {
788
+ deliverable: step.deliverable ?? 'return-value',
789
+ idempotent: step.idempotent ?? false,
790
+ maxAttempts: step.retry?.maxAttempts ?? 1,
791
+ };
792
+ }
793
+
794
+ /**
795
+ * Semantic validation — INV-1…8 (one diagnostic per violated invariant instance, each naming the
796
+ * invariant it defends). Pure; never throws.
797
+ */
798
+ export function validatePlan(plan: LoopPlan): Diagnostic[] {
799
+ const out: Diagnostic[] = [];
800
+ const ids = new Set(plan.steps.map((s) => s.stepId));
801
+ const byId = new Map(plan.steps.map((s) => [s.stepId, s]));
802
+
803
+ // INV-1: reference closure + acyclicity (excluding gate redo/fail routes).
804
+ const refCheck = (path: string, target: string | undefined): void => {
805
+ if (target !== undefined && !ids.has(target) && !target.startsWith('terminal:')) {
806
+ out.push({ invariant: 'INV-1', path, message: `reference "${target}" resolves to no stepId` });
807
+ }
808
+ };
809
+ plan.steps.forEach((s, i) => {
810
+ for (const d of s.deps ?? []) refCheck(`$.steps[${i}].deps`, d);
811
+ });
812
+ (plan.gates ?? []).forEach((g, i) => {
813
+ refCheck(`$.gates[${i}].stepId`, g.stepId);
814
+ refCheck(`$.gates[${i}].failRoute`, g.failRoute);
815
+ });
816
+ (plan.fanouts ?? []).forEach((f, i) => {
817
+ refCheck(`$.fanouts[${i}].stage`, f.stage);
818
+ for (const c of f.chain ?? []) refCheck(`$.fanouts[${i}].chain`, c);
819
+ });
820
+ (plan.joins ?? []).forEach((j, i) => {
821
+ refCheck(`$.joins[${i}].stage`, j.stage);
822
+ refCheck(`$.joins[${i}].forStage`, j.forStage);
823
+ });
824
+ // acyclicity over deps (redo/failRoute edges are deliberately excluded — they are loops by design)
825
+ const state = new Map<string, 0 | 1 | 2>();
826
+ const dfs = (id: string, trail: string[]): void => {
827
+ const st = state.get(id) ?? 0;
828
+ if (st === 1) {
829
+ out.push({ invariant: 'INV-1', path: '$.steps', message: `dependency cycle: ${[...trail, id].join(' → ')}` });
830
+ return;
831
+ }
832
+ if (st === 2) return;
833
+ state.set(id, 1);
834
+ for (const d of byId.get(id)?.deps ?? []) {
835
+ if (ids.has(d)) dfs(d, [...trail, id]);
836
+ }
837
+ state.set(id, 2);
838
+ };
839
+ for (const id of ids) if ((state.get(id) ?? 0) === 0) dfs(id, []);
840
+
841
+ // ORDER-1 (QE round-4 B1; REBUILT round 6 on EFFECTIVE EXECUTION POSITIONS — Codex round-5:
842
+ // "ORDER-1 compares raw steps[] indexes, but members and joins are skipped at those positions
843
+ // and execute at their fanout's position"; measured dispatch order was `consumer` then `m:i1`).
844
+ // A fanout chain member and a join step EXECUTE at their region's (fanout's) plan position, so
845
+ // the comparison anchors every step there. A dep on a step whose EFFECTIVE position is later is
846
+ // REJECTED, never silently misordered. Additionally: a dep TARGETING a chain member from outside
847
+ // its region is rejected — members settle PER-ITEM, so the dependency's causedBy wiring is
848
+ // unrepresentable (depend on the region's join step instead).
849
+ const planIndex = new Map(plan.steps.map((s, i) => [s.stepId, i]));
850
+ const memberRegion = new Map<string, string>(); // chain member -> its fanout stage
851
+ for (const f of plan.fanouts ?? []) for (const c of f.chain ?? []) if (!memberRegion.has(c)) memberRegion.set(c, f.stage);
852
+ const joinRegion = new Map<string, string>(); // join step -> its fanout stage
853
+ for (const j of plan.joins ?? []) if (!joinRegion.has(j.stage)) joinRegion.set(j.stage, j.forStage);
854
+ const effIdx = (id: string): number | undefined => {
855
+ const anchor = memberRegion.get(id) ?? joinRegion.get(id) ?? id;
856
+ return planIndex.get(anchor) ?? planIndex.get(id);
857
+ };
858
+ plan.steps.forEach((s, i) => {
859
+ for (const d of s.deps ?? []) {
860
+ const di = effIdx(d);
861
+ const si = effIdx(s.stepId);
862
+ if (di !== undefined && si !== undefined && di > si) {
863
+ out.push({ invariant: 'ORDER-1', path: `$.steps[${i}].deps`, message: `"${s.stepId}" (effective execution position ${si}) depends on "${d}", whose EFFECTIVE execution position is ${di} (members/joins execute at their fanout's position, not their declaration index) — loop-render/1 executes in effective plan order, so this dependency cannot be honored; reorder or re-anchor it` });
864
+ }
865
+ if (memberRegion.has(d) && memberRegion.get(s.stepId) !== memberRegion.get(d)) {
866
+ out.push({ invariant: 'ORDER-1', path: `$.steps[${i}].deps`, message: `"${s.stepId}" depends on "${d}", a fanout chain member — members settle PER-ITEM at their region's position, so this dependency's causedBy wiring is unrepresentable; depend on the region's join step instead` });
867
+ }
868
+ }
869
+ });
870
+
871
+ // XREF-1 (QE round-5 B1 class-kill, driven by the CROSS_REFS table): a stepId reference must
872
+ // target a step of a LEGAL KIND, not merely an existing one. Codex R3's decisive counterexample:
873
+ // joins[{stage:'a', forStage:'a'}] on an ordinary agent validated, the renderer skipped 'a' as a
874
+ // join step, and the run COMPLETED with ZERO agent calls. Kind-checking by table means a new
875
+ // referencing field cannot ship with existence-only validation.
876
+ for (const x of CROSS_REFS) {
877
+ for (const { target, path } of x.get(plan)) {
878
+ if (target === undefined) continue;
879
+ if (x.allowTerminal === true && target.startsWith('terminal:')) continue;
880
+ const t = byId.get(target);
881
+ if (t === undefined) continue; // nonexistence is INV-1's
882
+ if (!x.targetKinds.includes(t.kind)) {
883
+ out.push({ invariant: 'XREF-1', path, message: `${x.ref} reference "${target}" targets a kind:'${t.kind}' step — legal target kinds: ${x.targetKinds.join('|')} (consumption: ${x.consumes}); a mis-kinded reference is skipped or mis-dispatched, never enacted` });
884
+ }
885
+ }
886
+ }
887
+
888
+ // UNCONSUMED-1 (QE round-5 B1): every record/step a renderer would SKIP without consuming is
889
+ // rejected. A kind:'join' step named by no joins[].stage renders as dead code (its region-closing
890
+ // joinRegion call is emitted only by the fanout region that finds it via joins[]).
891
+ const joinStages = new Set((plan.joins ?? []).map((j) => j.stage));
892
+ plan.steps.forEach((s, i) => {
893
+ if (s.kind === 'join' && !joinStages.has(s.stepId)) {
894
+ out.push({ invariant: 'UNCONSUMED-1', path: `$.steps[${i}]`, message: `kind:'join' step "${s.stepId}" is consumed by no joins[] record — the renderer would emit it as dead code (no joinRegion call closes anything through it)` });
895
+ }
896
+ });
897
+
898
+ // KIND-1 (QE round-4 B1, Codex R4 kind-applicability): a field declared on a step kind that never
899
+ // enacts it (per the STEP_FIELD_KINDS matrix — data, not case law) is an unperformed promise.
900
+ // The observed counterexample: a pause with deliverable:'file' + artifacts.writes validated and
901
+ // completed with ZERO landed polls — the barrier exists only in the agent/gate branch.
902
+ plan.steps.forEach((s, i) => {
903
+ if (!(ALL_KINDS as readonly string[]).includes(s.kind)) return; // parse layer owns the kind enum
904
+ for (const k of Object.keys(s)) {
905
+ if (k.startsWith('x-')) continue;
906
+ const kinds = STEP_FIELD_KINDS[k];
907
+ if (kinds === undefined || kinds.includes(s.kind)) continue;
908
+ out.push({ invariant: 'KIND-1', path: `$.steps[${i}].${k}`, message: `"${s.stepId}" declares "${k}" on a kind:'${s.kind}' step — loop-render/1 enacts this field only for kind ${kinds.join('|')} (a declared field the renderer ignores would be an unperformed promise)` });
909
+ }
910
+ });
911
+
912
+ // DUP-1 (QE round-4 B1, Codex R4 duplicate-gate falsifier): the renderer binds configs with
913
+ // `.find()`, so only the FIRST config per key is ever enacted — a duplicate would be silently
914
+ // ignored. Duplicates are rejected for every .find()-bound config surface, not only gates.
915
+ const dupCheck = (label: string, arr: readonly Record<string, unknown>[], keyField: string): void => {
916
+ const seen = new Set<string>();
917
+ arr.forEach((x, i) => {
918
+ const k = x[keyField];
919
+ if (typeof k !== 'string') return;
920
+ if (seen.has(k)) {
921
+ out.push({ invariant: 'DUP-1', path: `$.${label}[${i}]`, message: `duplicate ${label} config for ${keyField} "${k}" — loop-render/1 enacts only the FIRST (.find()); a second config would be silently ignored, so it is rejected` });
922
+ }
923
+ seen.add(k);
924
+ });
925
+ };
926
+ dupCheck('gates', (plan.gates ?? []) as unknown as Record<string, unknown>[], 'stepId');
927
+ dupCheck('fanouts', (plan.fanouts ?? []) as unknown as Record<string, unknown>[], 'stage');
928
+ dupCheck('joins', (plan.joins ?? []) as unknown as Record<string, unknown>[], 'forStage');
929
+ // QE round-5: joins are ALSO keyed by stage (two records sharing one join step would render one
930
+ // region-closing call and silently orphan the other record).
931
+ dupCheck('joins', (plan.joins ?? []) as unknown as Record<string, unknown>[], 'stage');
932
+ dupCheck('pauses', (plan.pauses ?? []) as unknown as Record<string, unknown>[], 'state');
933
+
934
+ // MEMBER-2 (QE round-5 B1): fanout chains have a renderable SHAPE — at least one member, exactly
935
+ // one under the barrier shape (loop-render/1 dispatches exactly one member step per item there;
936
+ // extra barrier chain entries were silently never dispatched), and no step in two chain
937
+ // positions (each member renders its USER/prompt consts exactly once — a repeat would emit
938
+ // colliding const declarations, an unparseable script).
939
+ const chainSeen = new Set<string>();
940
+ plan.steps.forEach((s, i) => {
941
+ if (s.kind !== 'fanout') return;
942
+ const f = (plan.fanouts ?? []).find((x) => x.stage === s.stepId);
943
+ if (!f) return; // INV-2 owns the missing-config case
944
+ const chain = f.chain ?? [];
945
+ if (chain.length === 0) {
946
+ out.push({ invariant: 'MEMBER-2', path: `$.steps[${i}]`, message: `fanout "${s.stepId}" declares no chain member — a region needs at least one kind:'agent' member step (loop-render/1 dispatches chain members per item; an empty chain has nothing to dispatch)` });
947
+ return;
948
+ }
949
+ if ((s.concurrency ?? 'barrier') === 'barrier' && chain.length > 1) {
950
+ out.push({ invariant: 'MEMBER-2', path: `$.steps[${i}]`, message: `barrier-shape fanout "${s.stepId}" declares ${chain.length} chain members — loop-render/1 dispatches exactly ONE member step per item under the barrier shape (the extra members would silently never dispatch); use concurrency:'pipeline' for per-item chains` });
951
+ }
952
+ for (const c of chain) {
953
+ if (chainSeen.has(c)) {
954
+ out.push({ invariant: 'MEMBER-2', path: `$.steps[${i}]`, message: `step "${c}" appears in more than one fanout chain position — each member renders its USER/prompt consts exactly once, so a repeated member is unrenderable` });
955
+ }
956
+ chainSeen.add(c);
957
+ }
958
+ });
959
+
960
+ // INV-2: every fanout step declares maxFanout >= 1 AND a non-empty registry.
961
+ const fanoutByStage = new Map((plan.fanouts ?? []).map((f) => [f.stage, f]));
962
+ plan.steps.forEach((s, i) => {
963
+ if (s.kind !== 'fanout') return;
964
+ const f = fanoutByStage.get(s.stepId);
965
+ if (!f) {
966
+ out.push({ invariant: 'INV-2', path: `$.steps[${i}]`, message: `fanout step "${s.stepId}" has no fanouts[] config — unbounded fanout is invalid` });
967
+ return;
968
+ }
969
+ if (typeof f.maxFanout !== 'number' || !Number.isFinite(f.maxFanout) || f.maxFanout < 1) {
970
+ out.push({ invariant: 'INV-2', path: `$.fanouts[${f.stage}].maxFanout`, message: 'maxFanout must be a finite number >= 1' });
971
+ }
972
+ if (!Array.isArray(f.registry) || f.registry.length === 0) {
973
+ out.push({ invariant: 'INV-2', path: `$.fanouts[${f.stage}].registry`, message: 'a fanout requires a non-empty member registry' });
974
+ }
975
+ });
976
+
977
+ // INV-3: every fanout (parallel region) names an explicit join with a closed-set joinPolicy.
978
+ const joinsFor = new Map((plan.joins ?? []).map((j) => [j.forStage, j]));
979
+ plan.steps.forEach((s, i) => {
980
+ if (s.kind !== 'fanout') return;
981
+ const j = joinsFor.get(s.stepId);
982
+ if (!j) {
983
+ out.push({ invariant: 'INV-3', path: `$.steps[${i}]`, message: `parallel region "${s.stepId}" names no join — concurrent work without a join is unrepresentable` });
984
+ return;
985
+ }
986
+ const ok = (JOIN_POLICIES as readonly string[]).includes(j.joinPolicy) || QUORUM_RE.test(j.joinPolicy);
987
+ if (!ok) {
988
+ out.push({ invariant: 'INV-3', path: `$.joins[${j.stage}].joinPolicy`, message: `joinPolicy "${j.joinPolicy}" is not in the closed set (all-declared|all-activated|any|quorum:<n>)` });
989
+ }
990
+ });
991
+
992
+ // INV-4: retry.maxAttempts > 1 only on idempotent steps.
993
+ plan.steps.forEach((s, i) => {
994
+ const d = stepDefault(s);
995
+ if (d.maxAttempts > 1 && d.idempotent !== true) {
996
+ out.push({ invariant: 'INV-4', path: `$.steps[${i}].retry`, message: `maxAttempts ${d.maxAttempts} > 1 requires idempotent: true (maxAttempts INCLUDES the initial attempt)` });
997
+ }
998
+ });
999
+
1000
+ // INV-5 (plan half): every declared pause state is carried by a reachable pause step, with a resumeArg.
1001
+ const pauseStates = new Set(plan.steps.filter((s) => s.kind === 'pause').map((s) => s.pauseState ?? s.stepId));
1002
+ (plan.pauses ?? []).forEach((p, i) => {
1003
+ if (typeof p.resumeArg !== 'string' || p.resumeArg === '') {
1004
+ out.push({ invariant: 'INV-5', path: `$.pauses[${i}].resumeArg`, message: `pause "${p.state}" declares no resumeArg — a pause a re-invoke cannot resume is a lie` });
1005
+ }
1006
+ if (!pauseStates.has(p.state)) {
1007
+ out.push({ invariant: 'INV-5', path: `$.pauses[${i}].state`, message: `declared pause state "${p.state}" is returned by no pause step` });
1008
+ }
1009
+ });
1010
+
1011
+ // INV-6: cacheable only when idempotent AND side-effect-free (deliverable return-value, no writes).
1012
+ plan.steps.forEach((s, i) => {
1013
+ const d = stepDefault(s);
1014
+ const writes = s.artifacts?.writes ?? [];
1015
+ if (s.cacheable === true && (d.idempotent !== true || d.deliverable === 'file' || writes.length > 0)) {
1016
+ out.push({ invariant: 'INV-6', path: `$.steps[${i}].cacheable`, message: `"${s.stepId}" is cacheable but not side-effect-free (cache is keyed on normalized input and is NOT a checkpoint)` });
1017
+ }
1018
+ });
1019
+
1020
+ // INV-8 (retired in round 6, SUBSUMED by ENACT-DISPATCH): "a return-value deliverable may never
1021
+ // ride the fire-and-forget wrapper" is now unrepresentable — the wrapper route itself is
1022
+ // validated-away for every step, so the property holds vacuously and needs no separate check.
1023
+
1024
+ // ── Schema/runtime contract closure (QE round-3 B1): a plan must not VALIDATE while promising
1025
+ // behavior the generated workflow does not perform. Every declared field is either ENACTED by
1026
+ // loop-render/1 or REJECTED here with a named "not yet enacted" diagnostic — never a silent no-op.
1027
+ // The enumeration is machine-checked by loop-plan-honesty.test.ts.
1028
+
1029
+ // ENACT-CACHE: keyed cache lookup/store is NOT enacted by loop-render/1 (a persistent keyed store
1030
+ // + normalized-input capture is not available in the fs-less sandbox this round). `cacheable`
1031
+ // stays a validated CAPABILITY declaration (INV-6); an ENABLED cache policy is a behavior promise.
1032
+ plan.steps.forEach((s, i) => {
1033
+ if (s.cache?.enabled === true) {
1034
+ out.push({ invariant: 'ENACT-CACHE', path: `$.steps[${i}].cache.enabled`, message: `"${s.stepId}" declares cache.enabled — cache execution is not yet enacted by loop-render/1, and a plan must not validate while promising unperformed caching; set enabled:false (cacheable remains the capability declaration) or wait for a renderer that performs the keyed lookup` });
1035
+ }
1036
+ });
1037
+
1038
+ // ENACT-RETRY-TIMING (QE round-6 NARROWING): the retry-TIMING family is not enacted in
1039
+ // loop-plan/1 v1 — retries are IMMEDIATE. This field family reopened a blocker in every round it
1040
+ // was "enacted" (round-4: the member projection dropped initialDelayMs; round-5: backoff/cap
1041
+ // without initialDelayMs validated and vanished SYMMETRICALLY — invisible to a parity proof by
1042
+ // construction; negative delays and a zero multiplier validated then were silently skipped).
1043
+ // v1 retry is {maxAttempts, retryableFailureClasses} ONLY; the timing family is deferred to the
1044
+ // roadmap, per the round-3 cache precedent: a plan must not validate while promising unperformed
1045
+ // behavior.
1046
+ plan.steps.forEach((s, i) => {
1047
+ for (const k of ['initialDelayMs', 'backoffMultiplier', 'maxDelayMs', 'jitter'] as const) {
1048
+ if (s.retry?.[k] !== undefined) {
1049
+ out.push({ invariant: 'ENACT-RETRY-TIMING', path: `$.steps[${i}].retry.${k}`, message: `retry.${k} is not enacted in loop-plan/1 v1 (retries are IMMEDIATE; the timing family — delay/backoff/cap/jitter — is deferred, see roadmap) — v1 retry is {maxAttempts, retryableFailureClasses} only` });
1050
+ }
1051
+ }
1052
+ });
1053
+
1054
+ // ENACT-SUBSYS: usageAdaptive and challengePanel select their blobs but loop-render/1 emits no
1055
+ // call site for them yet — enabling either would promise unperformed behavior.
1056
+ if (plan.subsystems?.usageAdaptive === true) {
1057
+ out.push({ invariant: 'ENACT-SUBSYS', path: '$.subsystems.usageAdaptive', message: 'usage-adaptive routing is not yet enacted by loop-render/1 (the blob would be injected with no probe call sites) — remove the opt-in until a renderer version enacts it' });
1058
+ }
1059
+ if (plan.subsystems?.challengePanel === true) {
1060
+ out.push({ invariant: 'ENACT-SUBSYS', path: '$.subsystems.challengePanel', message: 'the challenge panel is not yet enacted by loop-render/1 (the blob would be injected with no call sites) — remove the opt-in until a renderer version enacts it' });
1061
+ }
1062
+ if (plan.subsystems?.codexDispatch === true) {
1063
+ out.push({ invariant: 'ENACT-SUBSYS', path: '$.subsystems.codexDispatch', message: 'codex dispatch is not enacted in loop-plan/1 v1 (dispatch routes other than inline are validated-away by ENACT-DISPATCH, so the blob would be injected with no call sites) — see roadmap' });
1064
+ }
1065
+
1066
+ // ENACT-DISPATCH (QE round-6 NARROWING): only 'inline' dispatch is enacted in v1. The
1067
+ // fire-and-forget codex wrapper returns a STUB that reads as a clean result (the codex-rescue
1068
+ // lesson), and the codex-exec thunk had no live-proven enactment in this pipeline — both routes
1069
+ // are rejected loudly instead of carried as risk. See roadmap.
1070
+ plan.steps.forEach((s, i) => {
1071
+ if (s.dispatch !== undefined && s.dispatch !== 'inline') {
1072
+ out.push({ invariant: 'ENACT-DISPATCH', path: `$.steps[${i}].dispatch`, message: `dispatch "${s.dispatch}" is not enacted in loop-plan/1 v1 — every step dispatches inline (codex routes are deferred; see roadmap)` });
1073
+ }
1074
+ });
1075
+
1076
+ // ENACT-CKPT-OPT (QE round-6 NARROWING): checkpoint GRANULARITY/SCHEMA options are not enacted
1077
+ // in v1 — checkpointing.enabled checkpoints EVERY top-level agent step, stamped 'loop-ckpt-1'.
1078
+ // Both option fields false-flipped the exec fingerprint across review rounds (schemaVersion in
1079
+ // round 3, per-step checkpoint in round 4); v1 removes the option surface instead of normalizing
1080
+ // it a third time. See roadmap.
1081
+ plan.steps.forEach((s, i) => {
1082
+ if (s.checkpoint !== undefined) {
1083
+ out.push({ invariant: 'ENACT-CKPT-OPT', path: `$.steps[${i}].checkpoint`, message: `per-step checkpoint granularity is not enacted in loop-plan/1 v1 — checkpointing.enabled checkpoints every top-level agent step (see roadmap); remove the field` });
1084
+ }
1085
+ });
1086
+ if (plan.checkpointing?.schemaVersion !== undefined) {
1087
+ out.push({ invariant: 'ENACT-CKPT-OPT', path: '$.checkpointing.schemaVersion', message: `checkpointing.schemaVersion is not enacted in loop-plan/1 v1 — the schema stamp is pinned to 'loop-ckpt-1' (see roadmap); remove the field` });
1088
+ }
1089
+
1090
+ // ENACT-FANOUT / ENACT-JOIN: reasonRequired and branchSchema have no runtime enactment.
1091
+ (plan.fanouts ?? []).forEach((f, i) => {
1092
+ if (f.reasonRequired === true) {
1093
+ out.push({ invariant: 'ENACT-FANOUT', path: `$.fanouts[${i}].reasonRequired`, message: 'reasonRequired is not enacted by loop-render/1 — omit it (dedup IS enacted)' });
1094
+ }
1095
+ });
1096
+ (plan.joins ?? []).forEach((j, i) => {
1097
+ if (j.branchSchema !== undefined) {
1098
+ out.push({ invariant: 'ENACT-JOIN', path: `$.joins[${i}].branchSchema`, message: 'branchSchema is not enacted by loop-render/1 — omit it' });
1099
+ }
1100
+ if (j.onInvalid !== undefined && j.onInvalid !== 'named-failure') {
1101
+ out.push({ invariant: 'ENACT-JOIN', path: `$.joins[${i}].onInvalid`, message: `onInvalid "${j.onInvalid}" is not in the enacted set — loop-render/1 enacts exactly 'named-failure' (the join throws a named error)` });
1102
+ }
1103
+ });
1104
+
1105
+ // GATE-1: gate redo/fail routing must be STRUCTURALLY enactable — the generated redo loop
1106
+ // re-dispatches an EARLIER agent step; anything else cannot be honestly rendered.
1107
+ const stepOrder = new Map(plan.steps.map((s, i) => [s.stepId, i]));
1108
+ const memberIds = new Set<string>();
1109
+ for (const f of plan.fanouts ?? []) for (const c of f.chain ?? []) memberIds.add(c);
1110
+ (plan.gates ?? []).forEach((g, i) => {
1111
+ // (gate/failRoute target KINDS are XREF-1's, from the CROSS_REFS table — round 5)
1112
+ if (g.maxRedos !== undefined && (typeof g.maxRedos !== 'number' || !Number.isFinite(g.maxRedos) || g.maxRedos < 0 || Math.floor(g.maxRedos) !== g.maxRedos)) {
1113
+ out.push({ invariant: 'GATE-1', path: `$.gates[${i}].maxRedos`, message: 'maxRedos must be a finite integer >= 0' });
1114
+ }
1115
+ const redos = typeof g.maxRedos === 'number' && Number.isFinite(g.maxRedos) ? g.maxRedos : 0;
1116
+ const routeIsTerminal = typeof g.failRoute === 'string' && g.failRoute.startsWith('terminal:');
1117
+ if (typeof g.failRoute === 'string' && !routeIsTerminal && ids.has(g.failRoute)) {
1118
+ const gi = stepOrder.get(g.stepId);
1119
+ const fi = stepOrder.get(g.failRoute);
1120
+ if (gi !== undefined && fi !== undefined && fi >= gi) {
1121
+ out.push({ invariant: 'GATE-1', path: `$.gates[${i}].failRoute`, message: `failRoute "${g.failRoute}" does not PRECEDE gate "${g.stepId}" in plan order — a redo can only re-dispatch an earlier step` });
1122
+ }
1123
+ const routeStep = byId.get(g.failRoute);
1124
+ if (routeStep && routeStep.kind === 'agent' && memberIds.has(g.failRoute)) {
1125
+ // (a non-agent failRoute is XREF-1's; the MEMBER restriction stays GATE-1's)
1126
+ out.push({ invariant: 'GATE-1', path: `$.gates[${i}].failRoute`, message: `failRoute "${g.failRoute}" must be a top-level kind:'agent' step (fanout members cannot be re-dispatched individually)` });
1127
+ }
1128
+ }
1129
+ if (redos > 0 && (g.failRoute === undefined || routeIsTerminal)) {
1130
+ out.push({ invariant: 'GATE-1', path: `$.gates[${i}].maxRedos`, message: 'maxRedos > 0 requires a step failRoute (a terminal or absent route has nothing to re-dispatch)' });
1131
+ }
1132
+ });
1133
+
1134
+ // CKPT-1 (retired in round 6, SUBSUMED by ENACT-CKPT-OPT): the per-step checkpoint opt-in field
1135
+ // is validated-away whole, so "only top-level agent steps checkpoint" is now a renderer fact
1136
+ // (checkpointing.enabled checkpoints exactly the top-level agent steps), not a per-field check.
1137
+
1138
+ // DELIV-1: a file deliverable must declare the writes its landed barrier verifies.
1139
+ plan.steps.forEach((s, i) => {
1140
+ const d = stepDefault(s);
1141
+ if (d.deliverable === 'file' && (s.artifacts?.writes ?? []).length === 0) {
1142
+ out.push({ invariant: 'DELIV-1', path: `$.steps[${i}].deliverable`, message: `"${s.stepId}" declares a file deliverable but no artifacts.writes — the landed barrier would have nothing to verify (a stub return would read as a delivered file)` });
1143
+ }
1144
+ });
1145
+
1146
+ // DISPATCH-1 (retired in round 6, SUBSUMED by ENACT-DISPATCH): codex routes are validated-away
1147
+ // for every step, so the "codex route requires the codex-dispatch subsystem" pairing rule has
1148
+ // no representable trigger left.
1149
+
1150
+ // MEMBER-1: fanout chain members render INSIDE their region — the per-member call path enacts
1151
+ // model + retry (G5) and OUTSIDE-region deps (their settles ride the member's causedBy — round
1152
+ // 6), but not file deliverables, write barriers, or deps BETWEEN members (chain order IS the
1153
+ // region's ordering; a member-to-member dep would be a decorative duplicate of the chain).
1154
+ // Declaring those on a member would be an unperformed promise. (The per-member dispatch leg was
1155
+ // retired in round 6 — non-inline dispatch is validated-away globally by ENACT-DISPATCH.)
1156
+ plan.steps.forEach((s, i) => {
1157
+ if (!memberIds.has(s.stepId)) return;
1158
+ const d = stepDefault(s);
1159
+ if (d.deliverable === 'file' || (s.artifacts?.writes ?? []).length > 0) {
1160
+ out.push({ invariant: 'MEMBER-1', path: `$.steps[${i}]`, message: `fanout member "${s.stepId}" declares file writes/deliverable — the landed barrier is not enacted per member (declare it on a post-join step instead)` });
1161
+ }
1162
+ for (const dep of s.deps ?? []) {
1163
+ if (memberIds.has(dep)) {
1164
+ out.push({ invariant: 'MEMBER-1', path: `$.steps[${i}].deps`, message: `fanout member "${s.stepId}" depends on member "${dep}" — deps between chain members are not enacted (chain order is the region's ordering); remove the dep or express it via the chain` });
1165
+ }
1166
+ }
1167
+ });
1168
+
1169
+ return out;
1170
+ }
1171
+
1172
+ /** Cosmetic fields stripped by normalization (excluded from the digest). */
1173
+ const COSMETIC_STEP_FIELDS = new Set(['title']);
1174
+
1175
+ function canonicalize(value: unknown): unknown {
1176
+ if (Array.isArray(value)) return value.map(canonicalize);
1177
+ if (isRecord(value)) {
1178
+ const out: Record<string, unknown> = {};
1179
+ for (const key of Object.keys(value).sort()) {
1180
+ const v = value[key];
1181
+ if (v === undefined) continue;
1182
+ out[key] = canonicalize(v);
1183
+ }
1184
+ return out;
1185
+ }
1186
+ return value;
1187
+ }
1188
+
1189
+ /**
1190
+ * Canonical form: object keys sorted (x- keys included, so they participate in the digest),
1191
+ * cosmetic fields stripped, undefined dropped, step/array ORDER preserved (order is semantic —
1192
+ * INV-7 phase order derives from it).
1193
+ */
1194
+ export function normalizePlan(plan: LoopPlan): LoopPlan {
1195
+ const steps = plan.steps.map((s) => {
1196
+ const copy: Record<string, unknown> = {};
1197
+ for (const [k, v] of Object.entries(s)) {
1198
+ if (COSMETIC_STEP_FIELDS.has(k)) continue;
1199
+ copy[k] = v;
1200
+ }
1201
+ if (copy['retry'] === undefined && s.kind === 'agent') {
1202
+ // schema default made explicit so the digest is stable against later default-filling
1203
+ copy['retry'] = { maxAttempts: 1 };
1204
+ }
1205
+ return copy;
1206
+ });
1207
+ const top: Record<string, unknown> = { ...plan, steps };
1208
+ return canonicalize(top) as unknown as LoopPlan;
1209
+ }
1210
+
1211
+ /** sha256 over the canonical JSON of the normalized plan. */
1212
+ export function planDigest(plan: LoopPlan): string {
1213
+ const canon = JSON.stringify(normalizePlan(plan));
1214
+ return createHash('sha256').update(canon, 'utf8').digest('hex');
1215
+ }
1216
+
1217
+ // ─────────────────────────────────────────────────────────────────────────────
1218
+ // The three projections (AM-3). Three SEPARATE exported functions with three
1219
+ // distinct output shapes — consumers import exactly one each and never read the
1220
+ // raw plan (loop-projection-consumers.test.ts, empty allowlist).
1221
+ // ─────────────────────────────────────────────────────────────────────────────
1222
+
1223
+ export interface OracleProjection {
1224
+ kind: 'oracle-projection/1';
1225
+ steps: { id: string; kind: StepKind; phase: string }[];
1226
+ transitions: { from: string; to: string; kind: 'dep' }[];
1227
+ regions: { fanout: string; join: string; joinPolicy: string; maxFanout: number; registry: string[]; chain: string[] }[];
1228
+ }
1229
+
1230
+ /** Normalized logical graph for the oracle diff: steps + typed transitions + region membership,
1231
+ * canonical (sorted) ordering, cosmetic fields gone. */
1232
+ export function toOracleProjection(plan: LoopPlan): OracleProjection {
1233
+ const norm = normalizePlan(plan);
1234
+ const steps = norm.steps
1235
+ .map((s) => ({ id: s.stepId, kind: s.kind, phase: s.phase }))
1236
+ .sort((a, b) => a.id.localeCompare(b.id));
1237
+ const transitions: OracleProjection['transitions'] = [];
1238
+ for (const s of norm.steps) {
1239
+ for (const d of s.deps ?? []) transitions.push({ from: d, to: s.stepId, kind: 'dep' });
1240
+ }
1241
+ transitions.sort((a, b) => (a.from + '→' + a.to).localeCompare(b.from + '→' + b.to));
1242
+ const joins = new Map((norm.joins ?? []).map((j) => [j.forStage, j]));
1243
+ const regions = (norm.fanouts ?? [])
1244
+ .map((f) => {
1245
+ const j = joins.get(f.stage);
1246
+ return {
1247
+ fanout: f.stage,
1248
+ join: j?.stage ?? '',
1249
+ joinPolicy: j?.joinPolicy ?? '',
1250
+ maxFanout: f.maxFanout,
1251
+ registry: [...f.registry].sort(),
1252
+ chain: [...(f.chain ?? [])],
1253
+ };
1254
+ })
1255
+ .sort((a, b) => a.fanout.localeCompare(b.fanout));
1256
+ return { kind: 'oracle-projection/1', steps, transitions, regions };
1257
+ }
1258
+
1259
+ export interface LintProjection {
1260
+ kind: 'lint-projection/1';
1261
+ /** CFG nodes: synthetic 'entry'/'exit' + every stepId. */
1262
+ nodes: string[];
1263
+ /** Directed edges entry→roots, dep edges, leaves→exit. */
1264
+ edges: { from: string; to: string }[];
1265
+ /** Explicit fork/join pairs with the branches activated between them. */
1266
+ forkJoinPairs: { fork: string; join: string; policy: string; branches: string[]; maxFanout: number; registrySize: number }[];
1267
+ /** Phase first-reference order (INV-7 cross-check). */
1268
+ phaseOrder: string[];
1269
+ /** Per-step lint facts (budget, retry, dispatch, deliverable, cache) — the rule inputs. */
1270
+ facts: {
1271
+ id: string;
1272
+ kind: StepKind;
1273
+ budgetMaxAgents: number | null;
1274
+ maxAttempts: number;
1275
+ idempotent: boolean;
1276
+ deliverable: Deliverable;
1277
+ dispatch: DispatchRoute;
1278
+ cacheable: boolean;
1279
+ writes: string[];
1280
+ }[];
1281
+ pauses: { state: string; resumeArg: string }[];
1282
+ checkpointingEnabled: boolean;
1283
+ traceEmit: boolean;
1284
+ }
1285
+
1286
+ /** Control-flow/dependency graph with synthetic entry/exit and explicit fork/join pairs —
1287
+ * the ONLY plan view `loop-lint.ts` consumes. */
1288
+ export function toLintProjection(plan: LoopPlan): LintProjection {
1289
+ const norm = normalizePlan(plan);
1290
+ const ids = norm.steps.map((s) => s.stepId);
1291
+ const idSet = new Set(ids);
1292
+ const edges: { from: string; to: string }[] = [];
1293
+ const hasIncoming = new Set<string>();
1294
+ const hasOutgoing = new Set<string>();
1295
+ const addEdge = (from: string, to: string): void => {
1296
+ edges.push({ from, to });
1297
+ hasIncoming.add(to);
1298
+ hasOutgoing.add(from);
1299
+ };
1300
+ for (const s of norm.steps) {
1301
+ for (const d of s.deps ?? []) {
1302
+ if (!idSet.has(d)) continue;
1303
+ addEdge(d, s.stepId);
1304
+ }
1305
+ }
1306
+ // fanout-region structure: fork → chain[0] → … → chain[last] → join (the region edges the
1307
+ // dep list does not carry — chain members are dispatched BY the fork, closed BY the join)
1308
+ const joinFor = new Map((norm.joins ?? []).map((j) => [j.forStage, j.stage]));
1309
+ for (const f of norm.fanouts ?? []) {
1310
+ const chain = (f.chain ?? []).filter((c) => idSet.has(c));
1311
+ let prev = idSet.has(f.stage) ? f.stage : null;
1312
+ for (const c of chain) {
1313
+ if (prev !== null) addEdge(prev, c);
1314
+ prev = c;
1315
+ }
1316
+ const join = joinFor.get(f.stage);
1317
+ if (prev !== null && join !== undefined && idSet.has(join)) addEdge(prev, join);
1318
+ }
1319
+ for (const id of ids) if (!hasIncoming.has(id)) edges.push({ from: 'entry', to: id });
1320
+ for (const id of ids) if (!hasOutgoing.has(id)) edges.push({ from: id, to: 'exit' });
1321
+
1322
+ const joins = new Map((norm.joins ?? []).map((j) => [j.forStage, j]));
1323
+ const forkJoinPairs = (norm.fanouts ?? []).map((f) => {
1324
+ const j = joins.get(f.stage);
1325
+ return {
1326
+ fork: f.stage,
1327
+ join: j?.stage ?? '',
1328
+ policy: j?.joinPolicy ?? '',
1329
+ branches: [...(f.chain ?? [])],
1330
+ maxFanout: typeof f.maxFanout === 'number' ? f.maxFanout : 0,
1331
+ registrySize: Array.isArray(f.registry) ? f.registry.length : 0,
1332
+ };
1333
+ });
1334
+
1335
+ const phaseOrder: string[] = [];
1336
+ for (const s of norm.steps) if (!phaseOrder.includes(s.phase)) phaseOrder.push(s.phase);
1337
+
1338
+ const facts = norm.steps.map((s) => {
1339
+ const d = stepDefault(s);
1340
+ return {
1341
+ id: s.stepId,
1342
+ kind: s.kind,
1343
+ budgetMaxAgents: s.budget?.maxAgents ?? null,
1344
+ maxAttempts: d.maxAttempts,
1345
+ idempotent: d.idempotent,
1346
+ deliverable: d.deliverable,
1347
+ dispatch: s.dispatch ?? 'inline',
1348
+ cacheable: s.cacheable === true,
1349
+ writes: s.artifacts?.writes ?? [],
1350
+ };
1351
+ });
1352
+
1353
+ return {
1354
+ kind: 'lint-projection/1',
1355
+ nodes: ['entry', ...ids, 'exit'],
1356
+ edges,
1357
+ forkJoinPairs,
1358
+ phaseOrder,
1359
+ facts,
1360
+ pauses: (norm.pauses ?? []).map((p) => ({ state: p.state, resumeArg: p.resumeArg })),
1361
+ checkpointingEnabled: norm.checkpointing?.enabled === true,
1362
+ traceEmit: norm.trace?.emit === true,
1363
+ };
1364
+ }
1365
+
1366
+ export interface TraceProjection {
1367
+ kind: 'trace-projection/1';
1368
+ /** Expected happens-before edges: every dispatch of `step` must come after the settle of each
1369
+ * dep. Only DISPATCHING step kinds (agent/gate) appear — fanout/join/pause are structural
1370
+ * pseudo-steps that emit no trace events of their own (their ordering lives in `regions`). */
1371
+ happensBefore: { step: string; afterSettleOf: string }[];
1372
+ /** Parallel regions with the runtime bound + concurrency shape the trace must exhibit.
1373
+ * `after` = the dispatching steps that depend on the region (fanout or join) — the trace-visible
1374
+ * witnesses of the barrier. */
1375
+ regions: { fanout: string; join: string; joinPolicy: string; maxFanout: number; members: string[]; shape: ConcurrencyShape; after: string[] }[];
1376
+ /** Steps expected to appear in the trace, keyed by stepId (iteration/attempt keys are runtime axes). */
1377
+ expectedSteps: string[];
1378
+ }
1379
+
1380
+ /** Expected runtime invariants — the ONLY plan view `loop-trace.ts::runInvariants` consumes. */
1381
+ export function toTraceProjection(plan: LoopPlan): TraceProjection {
1382
+ const norm = normalizePlan(plan);
1383
+ const byId = new Map(norm.steps.map((s) => [s.stepId, s]));
1384
+ const dispatches = (id: string): boolean => {
1385
+ const k = byId.get(id)?.kind;
1386
+ return k === 'agent' || k === 'gate';
1387
+ };
1388
+ const happensBefore: TraceProjection['happensBefore'] = [];
1389
+ for (const s of norm.steps) {
1390
+ if (!dispatches(s.stepId)) continue;
1391
+ for (const d of s.deps ?? []) {
1392
+ if (dispatches(d)) happensBefore.push({ step: s.stepId, afterSettleOf: d });
1393
+ }
1394
+ }
1395
+ const joins = new Map((norm.joins ?? []).map((j) => [j.forStage, j]));
1396
+ const shapes = new Map(norm.steps.map((s) => [s.stepId, s.concurrency ?? 'barrier'] as const));
1397
+ const regions = (norm.fanouts ?? []).map((f) => {
1398
+ const j = joins.get(f.stage);
1399
+ const joinId = j?.stage ?? '';
1400
+ const after = norm.steps
1401
+ .filter((s) => dispatches(s.stepId) && (s.deps ?? []).some((d) => d === f.stage || (joinId !== '' && d === joinId)))
1402
+ .map((s) => s.stepId);
1403
+ return {
1404
+ fanout: f.stage,
1405
+ join: joinId,
1406
+ joinPolicy: j?.joinPolicy ?? '',
1407
+ maxFanout: typeof f.maxFanout === 'number' ? f.maxFanout : 0,
1408
+ members: [...(f.chain ?? [])],
1409
+ shape: shapes.get(f.stage) ?? 'barrier',
1410
+ after,
1411
+ };
1412
+ });
1413
+ return {
1414
+ kind: 'trace-projection/1',
1415
+ happensBefore,
1416
+ regions,
1417
+ expectedSteps: norm.steps.filter((s) => s.kind === 'agent' || s.kind === 'gate').map((s) => s.stepId),
1418
+ };
1419
+ }