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