@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
package/src/loop-plan.ts
ADDED
|
@@ -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
|
+
}
|