@sabaiway/agent-workflow-kit 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/SKILL.md +13 -1
  3. package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
  4. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
  6. package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
  8. package/bridges/antigravity-cli-bridge/capability.json +19 -13
  9. package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
  10. package/bridges/codex-cli-bridge/SKILL.md +8 -5
  11. package/bridges/codex-cli-bridge/bin/codex-exec.sh +3 -2
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
  14. package/bridges/codex-cli-bridge/capability.json +8 -6
  15. package/bridges/codex-cli-bridge/references/driving-codex.md +2 -2
  16. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +2 -2
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/flow-writer.md +37 -0
  20. package/references/modes/gates.md +4 -4
  21. package/references/modes/procedures.md +4 -2
  22. package/references/modes/receipt-deadline.md +16 -0
  23. package/references/modes/review-state.md +1 -1
  24. package/references/modes/set-flow.md +22 -0
  25. package/tools/cheap-agents.mjs +8 -2
  26. package/tools/commands.mjs +24 -2
  27. package/tools/commit-guard.mjs +44 -9
  28. package/tools/core-evidence.mjs +25 -22
  29. package/tools/detect-backends.mjs +32 -11
  30. package/tools/doc-parity.mjs +21 -6
  31. package/tools/flow-check.mjs +806 -0
  32. package/tools/flow-record.mjs +795 -0
  33. package/tools/flow-store-read.mjs +114 -0
  34. package/tools/flow-store.mjs +1178 -0
  35. package/tools/flow-writer.mjs +1265 -0
  36. package/tools/fs-read-nofollow.mjs +128 -0
  37. package/tools/gates-declaration.mjs +184 -0
  38. package/tools/gates-init.mjs +59 -17
  39. package/tools/orchestration-config.mjs +87 -10
  40. package/tools/orchestration-write.mjs +3 -3
  41. package/tools/plan-files.mjs +35 -0
  42. package/tools/procedures.mjs +75 -11
  43. package/tools/receipt-deadline.mjs +242 -0
  44. package/tools/recipes.mjs +21 -0
  45. package/tools/repo-lex.mjs +22 -0
  46. package/tools/review-state.mjs +240 -80
  47. package/tools/run-gates.mjs +361 -139
  48. package/tools/set-flow.mjs +465 -0
  49. package/tools/velocity-profile.mjs +8 -2
@@ -0,0 +1,795 @@
1
+ // flow-record.mjs — the CLOSED flow-record vocabulary (flow-orchestration, Phase 1). Pure form:
2
+ // no filesystem, no git, no CLI, no side effects on import. The store IO (flow-store.mjs) and the
3
+ // checker (flow-check.mjs) consume these predicates; nothing here reads or writes a store.
4
+ //
5
+ // The vocabulary is seeded VERBATIM from the flow design §5 closed kind set and fixed BEFORE any
6
+ // reader/writer exists. Every seed member is ASSIGNED to a family (DESIGN_SEED_ASSIGNMENT — the
7
+ // drift-guard test binds the shipped vocabulary to exactly this assignment):
8
+ // • chain family — ONE record kind ("chain") keyed {planId, cycle, stepId, round, purpose};
9
+ // design §5's "round-chain" member is realized as the "round" purpose. stepId ENTERS the key
10
+ // domain here (the Phase-1 fixture decision): a record of one step can then never supersede a
11
+ // record of another step. The round purpose carries the dispatch ledger (per-dispatch
12
+ // watermark + nonce + landed receipt/manifest digests — the #41/#42 binding) and the
13
+ // disposition ledger (folded/queued/rejected per finding, each with its proof — #13/#33).
14
+ // • store-global kinds — the rest, each with its own shape and supersession key; the down-mark
15
+ // family (down-mark / down-mark-up / down-mark-clear) shares ONE key so up/clear supersede the
16
+ // mark; maintainer-override keys on its veto instance (vetoReceiptDigest) and every override
17
+ // after the first must supersede the CURRENT head of that instance (#56 — explicit, unforkable
18
+ // supersession). internal-attestation carries the full #28 bound schema (lenses, degraded set,
19
+ // posture object, authority, plan/cycle/step/round + tree identity); consult-attestation binds
20
+ // the consult to the finding AND the proposed fix (#11/#33 — form-provable, semantics stay an
21
+ // honest limit).
22
+ // Fail-closed in both directions: unknown schema, unknown kind, unknown purpose, a missing field,
23
+ // a malformed field, and an unknown EXTRA field are all refusals — the per-record canonical digest
24
+ // is the record's identity, so a stray key would fork it.
25
+ //
26
+ // Plan-4 addition (Decision 7/8 — NOT a design-§5 seed member; the drift-guard test records it as
27
+ // the one post-seed addition): the store-global kind "subset-attempt" — the --pre-review subset
28
+ // hard-stop counter. Key {planId, cycle, stepId, foldBatch, subsetDigest}; attemptIndex is the
29
+ // monotonic per-key index (the locked append factory in flow-store.mjs computes it under the
30
+ // lock); `diagnosis` rides ONLY attemptIndex >= 3 (Decision 8 — the recorded, self-servable
31
+ // continuation past two reds; never a wait-for-maintainer).
32
+ //
33
+ // Tree identity (#21): every record carries {base, fingerprint}; the transition-shaped records
34
+ // (chain/refresh, bookkeeping-delta) carry {fingerprintBefore, fingerprintAfter} and their singular
35
+ // identity fingerprint IS fingerprintAfter (flowTreeIdentity). The bookkeeping-delta custody proof
36
+ // persists the THREE-LAYER pre-state digest set {headDigest, indexDigest, worktreeDigest} plus the
37
+ // derived tracked-ness (#60) — the fingerprint domain distinguishes staged, unstaged, and untracked
38
+ // bytes, so a single content digest cannot represent the pre-state.
39
+ //
40
+ // Reference domain (#63): every inter-record reference is the per-record canonical digest — sha256
41
+ // over the canonical (recursively key-sorted, byte-layout-independent) serialization of ONE record.
42
+ // Newline rule: the single-record serialization carries NO trailing newline; multi-record framing
43
+ // (one line per record) belongs to stores, never to this primitive. A parity test pins these bytes
44
+ // against core-evidence's canonicalKindSerialization so the two disciplines cannot drift; the core
45
+ // module itself is frozen and stays untouched.
46
+ //
47
+ // Honest residuals: reference RESOLUTION against a real store (forged / mismatched / superseded
48
+ // targets, adoption content-digest binding) lands with flow-store/flow-check; this module resolves
49
+ // references only inside an in-memory record list (validateSupersessions) and validates form,
50
+ // per-chain sequence legality, and selection. Records remain forgeable — a self-discipline
51
+ // mechanism, not a security boundary.
52
+
53
+ import { createHash } from 'node:crypto';
54
+ import { FLOW_SCHEMA_VERSION } from './orchestration-config.mjs';
55
+ import { lexicalRepoRelative } from './repo-lex.mjs';
56
+
57
+ export { FLOW_SCHEMA_VERSION };
58
+
59
+ const deepFreeze = (value) => {
60
+ if (value !== null && typeof value === 'object') {
61
+ Object.values(value).forEach(deepFreeze);
62
+ Object.freeze(value);
63
+ }
64
+ return value;
65
+ };
66
+
67
+ // ── the closed vocabulary ─────────────────────────────────────────────────────────────────────────
68
+
69
+ export const CHAIN_KIND = 'chain';
70
+ export const CHAIN_PURPOSES = deepFreeze(['adoption', 'round', 'refresh', 're-baseline', 'freeze', 'unfreeze', 'park', 'resume', 'converged', 'complete']);
71
+ export const STEP_SCOPED_PURPOSES = deepFreeze(['round', 'refresh', 're-baseline', 'freeze', 'unfreeze', 'converged']);
72
+ export const PLAN_LANE_PURPOSES = deepFreeze(['adoption', 'park', 'resume', 'complete']);
73
+ export const GLOBAL_KINDS = deepFreeze(['internal-attestation', 'down-mark', 'down-mark-up', 'down-mark-clear', 'degrade-justification', 'rerun-cause', 'bookkeeping-delta', 'maintainer-override', 'consult-attestation', 'subset-attempt']);
74
+ export const FLOW_KINDS = deepFreeze([CHAIN_KIND, ...GLOBAL_KINDS]);
75
+
76
+ // Reserved lane-typed terminals (#16): converged terminates a CYCLE (its step's sequence), complete
77
+ // terminates the PLAN. Park is a resumable suspension, never a terminal (#59).
78
+ export const TERMINAL_LANES = deepFreeze({ converged: 'cycle', complete: 'plan' });
79
+
80
+ // Design §5 seed member → family assignment; the drift-guard test binds this map to the verbatim
81
+ // seed list on one side and to the shipped CHAIN_PURPOSES/GLOBAL_KINDS on the other.
82
+ export const DESIGN_SEED_ASSIGNMENT = deepFreeze({
83
+ adoption: { family: 'chain', purpose: 'adoption' },
84
+ 'round-chain': { family: 'chain', purpose: 'round' },
85
+ refresh: { family: 'chain', purpose: 'refresh' },
86
+ 're-baseline': { family: 'chain', purpose: 're-baseline' },
87
+ unfreeze: { family: 'chain', purpose: 'unfreeze' },
88
+ freeze: { family: 'chain', purpose: 'freeze' },
89
+ converged: { family: 'chain', purpose: 'converged' },
90
+ park: { family: 'chain', purpose: 'park' },
91
+ resume: { family: 'chain', purpose: 'resume' },
92
+ complete: { family: 'chain', purpose: 'complete' },
93
+ 'internal-attestation': { family: 'global', kind: 'internal-attestation' },
94
+ 'down-mark': { family: 'global', kind: 'down-mark' },
95
+ 'down-mark up': { family: 'global', kind: 'down-mark-up' },
96
+ 'down-mark clear': { family: 'global', kind: 'down-mark-clear' },
97
+ 'degrade-justification': { family: 'global', kind: 'degrade-justification' },
98
+ 'rerun-cause': { family: 'global', kind: 'rerun-cause' },
99
+ 'bookkeeping-delta': { family: 'global', kind: 'bookkeeping-delta' },
100
+ 'maintainer-override': { family: 'global', kind: 'maintainer-override' },
101
+ 'consult-attestation': { family: 'global', kind: 'consult-attestation' },
102
+ });
103
+
104
+ // The allowed-transition table — an exported frozen structure, never prose. Within a step:
105
+ // converged ends the sequence (only the unfreeze lane reopens it, and only in its own cycle);
106
+ // freeze admits only unfreeze/converged. Plan lane: park admits only resume (and both preserve the
107
+ // pre-park cycle/round); complete admits nothing; adoption is only ever the chain's first record.
108
+ // The boundary lane (between steps): the opener round, the unfreeze reopen, and a re-baseline for
109
+ // disjoint base motion that reopens nothing. The cross-step edge (the opener's prior-terminal
110
+ // reference) is enforced by validateChainSequence, distinct from the within-step successor rule.
111
+ export const ALLOWED_TRANSITIONS = deepFreeze({
112
+ stepOpening: 'round',
113
+ withinStep: {
114
+ round: ['round', 'refresh', 're-baseline', 'freeze', 'converged'],
115
+ refresh: ['round', 'refresh', 're-baseline', 'freeze', 'converged'],
116
+ 're-baseline': ['round', 'refresh', 're-baseline', 'freeze', 'converged'],
117
+ freeze: ['unfreeze', 'converged'],
118
+ unfreeze: ['round', 'refresh', 're-baseline', 'freeze', 'converged'],
119
+ converged: ['unfreeze'],
120
+ },
121
+ planLane: {
122
+ adoption: ['round', 'park', 'complete'],
123
+ park: ['resume'],
124
+ resume: ['round', 'park', 'complete'],
125
+ complete: [],
126
+ },
127
+ boundary: ['round', 'unfreeze', 're-baseline'],
128
+ });
129
+
130
+ // ── field shapes (closed key set per kind/purpose) ────────────────────────────────────────────────
131
+
132
+ const HEX64_RE = /^[0-9a-f]{64}$/;
133
+ const HEX40_RE = /^[0-9a-f]{40}$/;
134
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
135
+ const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
136
+ const isHex64 = (v) => typeof v === 'string' && HEX64_RE.test(v);
137
+ const isSha = (v) => typeof v === 'string' && (HEX40_RE.test(v) || HEX64_RE.test(v));
138
+ const isCanonicalInstant = (v) => typeof v === 'string' && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v;
139
+ const isUniqueNonEmptyStrings = (v) => Array.isArray(v) && v.every(isNonEmptyString) && new Set(v).size === v.length;
140
+
141
+ const POSTURE_KEYS = ['model', 'effort', 'tier'];
142
+ const isClosedPosture = (v) => isPlainObject(v)
143
+ && Object.keys(v).length === POSTURE_KEYS.length && POSTURE_KEYS.every((k) => k in v)
144
+ && isNonEmptyString(v.model)
145
+ && (v.effort === null || isNonEmptyString(v.effort))
146
+ && (v.tier === null || isNonEmptyString(v.tier));
147
+
148
+ const FIELD_CHECKS = {
149
+ base: { ok: (v) => v === null || isSha(v), want: 'the 40- or 64-hex base sha, or null on an unborn branch' },
150
+ baseBefore: { ok: isSha, want: 'the 40- or 64-hex pre-motion base sha' },
151
+ timestamp: { ok: isNonEmptyString, want: 'a non-empty timestamp string' },
152
+ fingerprint: { ok: isHex64, want: 'a 64-hex tree fingerprint' },
153
+ fingerprintBefore: { ok: isHex64, want: 'a 64-hex tree fingerprint' },
154
+ fingerprintAfter: { ok: isHex64, want: 'a 64-hex tree fingerprint' },
155
+ planId: { ok: isNonEmptyString, want: 'a non-empty plan id' },
156
+ cycle: { ok: (v) => Number.isInteger(v) && v >= 1, want: 'a positive integer cycle index' },
157
+ round: { ok: (v) => Number.isInteger(v) && v >= 0, want: 'a non-negative integer round index' },
158
+ commitEpoch: { ok: (v) => Number.isInteger(v) && v >= 0, want: 'a non-negative integer commit epoch' },
159
+ owner: { ok: isNonEmptyString, want: 'the non-empty owning-worktree identity' },
160
+ stepId: { ok: isNonEmptyString, want: 'a non-empty step id' },
161
+ stepIdNull: { ok: (v) => v === null, want: 'null — this purpose is plan-lane, never step-scoped' },
162
+ stepIdOrNull: { ok: (v) => v === null || isNonEmptyString(v), want: 'a non-empty step id, or null at a pre-first-step boundary' },
163
+ opensFrom: { ok: (v) => v === null || isHex64(v), want: 'the 64-hex prior-terminal record digest, or null off the step opening' },
164
+ dispatches: { ok: Array.isArray, want: 'the dispatch-ledger array (may be empty until dispatches land)' },
165
+ dispositions: { ok: Array.isArray, want: 'the disposition-ledger array (may be empty until findings land)' },
166
+ planLabel: { ok: isNonEmptyString, want: 'the non-empty plan label' },
167
+ createdAt: { ok: isNonEmptyString, want: 'a non-empty created-at string' },
168
+ planDigest: { ok: isHex64, want: 'the 64-hex plan content digest' },
169
+ cause: { ok: isNonEmptyString, want: 'a non-empty declared cause' },
170
+ refreshedRecord: { ok: isHex64, want: 'the 64-hex digest of the record this refresh re-attests' },
171
+ backend: { ok: isNonEmptyString, want: 'a non-empty backend name' },
172
+ reason: { ok: isNonEmptyString, want: 'a non-empty reason' },
173
+ expiresAt: { ok: isCanonicalInstant, want: 'a canonical UTC ISO instant (toISOString round-trip)' },
174
+ target: { ok: isHex64, want: 'the 64-hex digest of the down-mark this record supersedes' },
175
+ downMark: { ok: isHex64, want: 'the 64-hex digest of the down-mark this justification rides on' },
176
+ degradeDigest: { ok: isHex64, want: 'the 64-hex per-record canonical digest of the core degrade record' },
177
+ attempt: { ok: isNonEmptyString, want: 'the non-empty red final attempt id' },
178
+ path: { ok: (v) => isNonEmptyString(v) && lexicalRepoRelative(v).ok, want: 'a non-empty lexically repo-relative path' },
179
+ contentDigest: { ok: (v) => v === null || isHex64(v), want: 'the 64-hex post-change content digest, or null when the path lands absent' },
180
+ custodyProof: { ok: isPlainObject, want: 'the persisted proof object {preClass, tracked, headDigest, indexDigest, worktreeDigest, maskedFingerprint}' },
181
+ vetoReceiptDigest: { ok: isHex64, want: 'the 64-hex digest of the vetoing receipt' },
182
+ verdict: { ok: isNonEmptyString, want: 'the non-empty vetoing verdict' },
183
+ chainRecord: { ok: isHex64, want: 'the 64-hex digest of the bound chain record' },
184
+ supersedes: { ok: (v) => v === null || isHex64(v), want: 'the 64-hex digest of the superseded override, or null on the first override of a veto instance' },
185
+ nonce: { ok: isNonEmptyString, want: 'the non-empty wrapper nonce' },
186
+ lenses: { ok: (v) => isUniqueNonEmptyStrings(v) && v.length > 0, want: 'a non-empty array of unique non-empty lens names (the required-lens set)' },
187
+ degraded: { ok: isUniqueNonEmptyStrings, want: 'an array of unique non-empty backend names (may be empty)' },
188
+ posture: { ok: isClosedPosture, want: 'the closed posture object {model: non-empty, effort: non-empty|null, tier: non-empty|null}' },
189
+ authority: { ok: isNonEmptyString, want: 'the non-empty attesting authority' },
190
+ findingDigest: { ok: isHex64, want: 'the 64-hex digest of the consulted finding' },
191
+ proposedFixDigest: { ok: isHex64, want: 'the 64-hex digest of the proposed fix under consult' },
192
+ foldBatch: { ok: isHex64, want: 'the 64-hex digest of the owning round identity projection {planId, cycle, stepId, round}' },
193
+ subsetDigest: { ok: isHex64, want: "the 64-hex digest of the derived subset's ordered gate ids" },
194
+ attemptIndex: { ok: (v) => Number.isInteger(v) && v >= 1, want: 'a positive integer attempt index (monotonic per counting context)' },
195
+ status: { ok: (v) => v === 'green' || v === 'red', want: 'the closed enum green | red' },
196
+ diagnosis: { ok: isNonEmptyString, want: 'a non-empty diagnosis statement (Decision 8 — the recorded continuation past two reds)' },
197
+ };
198
+
199
+ const CHAIN_COMMON_FIELDS = ['planId', 'cycle', 'round', 'commitEpoch', 'owner', 'base', 'timestamp'];
200
+
201
+ // Per-purpose closed field sets. "stepIdNull" routes stepId through the plan-lane check; the
202
+ // transition-shaped refresh carries fingerprintBefore/After and deliberately NO singular
203
+ // fingerprint field (flowTreeIdentity supplies it). re-baseline's stepId is the prior-terminal
204
+ // anchor at a boundary (null only before the first step) or the open step inside one.
205
+ const PURPOSE_SHAPES = {
206
+ adoption: { stepId: 'stepIdNull', fields: ['fingerprint', 'planLabel', 'createdAt', 'planDigest'] },
207
+ round: { stepId: 'stepId', fields: ['fingerprint', 'opensFrom', 'dispatches', 'dispositions'] },
208
+ refresh: { stepId: 'stepId', fields: ['fingerprintBefore', 'fingerprintAfter', 'cause', 'refreshedRecord'] },
209
+ 're-baseline': { stepId: 'stepIdOrNull', fields: ['fingerprint', 'baseBefore'] },
210
+ freeze: { stepId: 'stepId', fields: ['fingerprint'] },
211
+ unfreeze: { stepId: 'stepId', fields: ['fingerprint'] },
212
+ park: { stepId: 'stepIdNull', fields: ['fingerprint'] },
213
+ resume: { stepId: 'stepIdNull', fields: ['fingerprint'] },
214
+ converged: { stepId: 'stepId', fields: ['fingerprint'] },
215
+ complete: { stepId: 'stepIdNull', fields: ['fingerprint'] },
216
+ };
217
+
218
+ const GLOBAL_SHAPES = {
219
+ 'internal-attestation': ['fingerprint', 'planId', 'stepId', 'cycle', 'round', 'lenses', 'degraded', 'posture', 'authority', 'base', 'timestamp'],
220
+ 'down-mark': ['fingerprint', 'backend', 'reason', 'expiresAt', 'base', 'timestamp'],
221
+ 'down-mark-up': ['fingerprint', 'backend', 'target', 'base', 'timestamp'],
222
+ 'down-mark-clear': ['fingerprint', 'backend', 'target', 'base', 'timestamp'],
223
+ 'degrade-justification': ['fingerprint', 'downMark', 'degradeDigest', 'base', 'timestamp'],
224
+ 'rerun-cause': ['fingerprint', 'cause', 'attempt', 'base', 'timestamp'],
225
+ 'bookkeeping-delta': ['fingerprintBefore', 'fingerprintAfter', 'path', 'contentDigest', 'custodyProof', 'base', 'timestamp'],
226
+ 'maintainer-override': ['fingerprint', 'vetoReceiptDigest', 'backend', 'verdict', 'chainRecord', 'supersedes', 'base', 'timestamp'],
227
+ 'consult-attestation': ['fingerprint', 'backend', 'nonce', 'planId', 'cycle', 'stepId', 'round', 'findingDigest', 'proposedFixDigest', 'base', 'timestamp'],
228
+ 'subset-attempt': ['planId', 'cycle', 'stepId', 'foldBatch', 'subsetDigest', 'attemptIndex', 'status', 'base', 'fingerprint', 'timestamp'],
229
+ };
230
+
231
+ // Kind-scoped check overrides where a field name collides across kinds (Decision 7): the
232
+ // subset-attempt stepId is nullable — attempts before any round key the ADOPTION context.
233
+ const GLOBAL_FIELD_CHECK_OVERRIDES = {
234
+ 'subset-attempt': { stepId: 'stepIdOrNull' },
235
+ };
236
+
237
+ // Decision 8: attempts 1-2 are the blind budget and never carry a diagnosis — this is the
238
+ // earliest index one MAY ride. REQUIRED-ness keys on the key's red count (>= 2, past the second
239
+ // red) and lives in the store gate + the locked factory: a record-local validator cannot see
240
+ // the key history, and a green history never owes a diagnosis.
241
+ export const SUBSET_ATTEMPT_DIAGNOSIS_FROM = 3;
242
+
243
+ const refuse = (reason) => ({ ok: false, reason });
244
+
245
+ const checkFields = (label, record, fieldToCheck) => {
246
+ const allowed = ['schema', 'kind', ...(record.kind === CHAIN_KIND ? ['purpose'] : []), ...Object.keys(fieldToCheck)];
247
+ const stray = Object.keys(record).find((k) => !allowed.includes(k));
248
+ if (stray !== undefined) return refuse(`${label}: unknown field "${stray}" — the key set is closed (the digest is identity; a stray key would fork it)`);
249
+ for (const [field, checkId] of Object.entries(fieldToCheck)) {
250
+ if (!(field in record)) return refuse(`${label}: missing field "${field}" — every required field is pinned`);
251
+ const check = FIELD_CHECKS[checkId];
252
+ if (!check.ok(record[field])) return refuse(`${label}: ${field} must be ${check.want}`);
253
+ }
254
+ return { ok: true };
255
+ };
256
+
257
+ // The per-dispatch ledger entry (#41/#42): watermark + nonce minted BEFORE dispatch; the receipt
258
+ // digest and the finding-manifest digest land TOGETHER once the receipt arrives.
259
+ const DISPATCH_KEYS = ['backend', 'dispatchBase', 'receiptWatermark', 'dispatchNonce', 'receiptDigest', 'findingManifestDigest'];
260
+
261
+ const validateDispatches = (label, list) => {
262
+ const seenIdentities = new Set();
263
+ for (let i = 0; i < list.length; i += 1) {
264
+ const d = list[i];
265
+ const at = `${label}: dispatches[${i}]`;
266
+ if (!isPlainObject(d)) return refuse(`${at} must be an object`);
267
+ const stray = Object.keys(d).find((k) => !DISPATCH_KEYS.includes(k));
268
+ if (stray !== undefined) return refuse(`${at}: unknown field "${stray}" — the dispatch key set is closed`);
269
+ const missing = DISPATCH_KEYS.find((k) => !(k in d));
270
+ if (missing !== undefined) return refuse(`${at}: missing field "${missing}"`);
271
+ const identity = JSON.stringify([d.backend, d.dispatchNonce]);
272
+ if (seenIdentities.has(identity)) return refuse(`${at}: duplicate dispatch identity {backend, dispatchNonce} — one ledger entry per dispatch (the watermark is payload, never identity)`);
273
+ seenIdentities.add(identity);
274
+ if (!isNonEmptyString(d.backend)) return refuse(`${at}: backend must be a non-empty backend name`);
275
+ if (d.dispatchBase !== null && !isSha(d.dispatchBase)) return refuse(`${at}: dispatchBase must be the 40- or 64-hex base at dispatch, or null on an unborn branch`);
276
+ if (!Number.isInteger(d.receiptWatermark) || d.receiptWatermark < 0) return refuse(`${at}: receiptWatermark must be a non-negative integer (the receipts-file position minted before dispatch)`);
277
+ if (!isNonEmptyString(d.dispatchNonce)) return refuse(`${at}: dispatchNonce must be a non-empty string`);
278
+ for (const field of ['receiptDigest', 'findingManifestDigest']) {
279
+ if (d[field] !== null && !isHex64(d[field])) return refuse(`${at}: ${field} must be a 64-hex digest, or null while the dispatch is pending`);
280
+ }
281
+ if ((d.receiptDigest === null) !== (d.findingManifestDigest === null)) {
282
+ return refuse(`${at}: receiptDigest and findingManifestDigest land together — both null while pending, both 64-hex once the receipt landed`);
283
+ }
284
+ }
285
+ return { ok: true };
286
+ };
287
+
288
+ // The per-finding disposition ledger (#13/#33): every council finding lands as exactly one of the
289
+ // three closed arms, each carrying its proof (a consult-attestation/red-proof digest, a debt entry,
290
+ // or a stated rejection reason).
291
+ const DISPOSITION_KEYS = {
292
+ folded: ['findingDigest', 'action', 'proofKind', 'proofDigest'],
293
+ queued: ['findingDigest', 'action', 'debtId', 'debtDigest'],
294
+ rejected: ['findingDigest', 'action', 'reason'],
295
+ };
296
+
297
+ const validateDispositions = (label, list) => {
298
+ const seenFindings = new Set();
299
+ for (let i = 0; i < list.length; i += 1) {
300
+ const d = list[i];
301
+ const at = `${label}: dispositions[${i}]`;
302
+ if (!isPlainObject(d)) return refuse(`${at} must be an object`);
303
+ const armKeys = Object.hasOwn(DISPOSITION_KEYS, d.action) ? DISPOSITION_KEYS[d.action] : undefined;
304
+ if (armKeys === undefined) return refuse(`${at}: action must be one of ${Object.keys(DISPOSITION_KEYS).join(' | ')} (got ${JSON.stringify(d.action)}) — an inherited prototype key never resolves an arm (fail closed)`);
305
+ const stray = Object.keys(d).find((k) => !armKeys.includes(k));
306
+ if (stray !== undefined) return refuse(`${at}: unknown field "${stray}" — the ${d.action} arm's key set is closed`);
307
+ const missing = armKeys.find((k) => !(k in d));
308
+ if (missing !== undefined) return refuse(`${at}: missing field "${missing}"`);
309
+ if (!isHex64(d.findingDigest)) return refuse(`${at}: findingDigest must be the 64-hex digest of the finding`);
310
+ if (seenFindings.has(d.findingDigest)) return refuse(`${at}: duplicate findingDigest — every finding gets exactly one disposition, whichever the arm`);
311
+ seenFindings.add(d.findingDigest);
312
+ if (d.action === 'folded') {
313
+ if (d.proofKind !== 'consult-attestation' && d.proofKind !== 'red-proof') return refuse(`${at}: proofKind must be consult-attestation | red-proof (the fold's proof record class)`);
314
+ if (!isHex64(d.proofDigest)) return refuse(`${at}: proofDigest must be the 64-hex digest of the proof record`);
315
+ } else if (d.action === 'queued') {
316
+ if (!isNonEmptyString(d.debtId)) return refuse(`${at}: debtId must be the non-empty stable debt-queue id`);
317
+ if (!isHex64(d.debtDigest)) return refuse(`${at}: debtDigest must be the 64-hex digest of the debt entry`);
318
+ } else if (!isNonEmptyString(d.reason)) {
319
+ return refuse(`${at}: reason must be a non-empty statement of why the finding is rejected`);
320
+ }
321
+ }
322
+ return { ok: true };
323
+ };
324
+
325
+ // The three-layer pre-state digest set (#60): HEAD entry, index entry, worktree bytes — null means
326
+ // "no entry in that layer". tracked-ness is derived (a HEAD or index entry exists); the presence
327
+ // class is the WORKTREE layer. The fingerprint domain (staged + unstaged + untracked) is exactly
328
+ // what these three layers reconstruct for the masked recompute.
329
+ const CUSTODY_PROOF_KEYS = ['preClass', 'tracked', 'headDigest', 'indexDigest', 'worktreeDigest', 'maskedFingerprint'];
330
+ const PRE_STATE_CLASSES = ['present', 'absent'];
331
+
332
+ const validateCustodyProof = (label, record) => {
333
+ const proof = record.custodyProof;
334
+ const stray = Object.keys(proof).find((k) => !CUSTODY_PROOF_KEYS.includes(k));
335
+ if (stray !== undefined) return refuse(`${label}: custodyProof carries unknown field "${stray}" — the proof key set is closed`);
336
+ const missing = CUSTODY_PROOF_KEYS.find((k) => !(k in proof));
337
+ if (missing !== undefined) return refuse(`${label}: custodyProof is missing field "${missing}"`);
338
+ if (!PRE_STATE_CLASSES.includes(proof.preClass)) return refuse(`${label}: custodyProof.preClass must be one of ${PRE_STATE_CLASSES.join(' | ')} — any other pre-state class refuses to mint by name (fail closed)`);
339
+ if (typeof proof.tracked !== 'boolean') return refuse(`${label}: custodyProof.tracked must be a boolean`);
340
+ for (const field of ['headDigest', 'indexDigest', 'worktreeDigest']) {
341
+ if (proof[field] !== null && !isHex64(proof[field])) return refuse(`${label}: custodyProof.${field} must be a 64-hex content digest, or null when that layer has no entry`);
342
+ }
343
+ if (!isHex64(proof.maskedFingerprint)) return refuse(`${label}: custodyProof.maskedFingerprint must be the 64-hex masked-recompute fingerprint`);
344
+ if ((proof.preClass === 'absent') !== (proof.worktreeDigest === null)) {
345
+ return refuse(`${label}: custodyProof.worktreeDigest must be null exactly when preClass is "absent" — the worktree layer IS the presence class`);
346
+ }
347
+ if (proof.tracked !== (proof.headDigest !== null || proof.indexDigest !== null)) {
348
+ return refuse(`${label}: custodyProof.tracked must equal the presence of a HEAD or index entry — a mismatched tracked-ness is a forged pre-state`);
349
+ }
350
+ if (proof.preClass === 'absent' && record.contentDigest === null) {
351
+ return refuse(`${label}: the absent→absent pre-state transition is unsupported — supported: present→present, present→absent, absent→present (fail closed)`);
352
+ }
353
+ return { ok: true };
354
+ };
355
+
356
+ // validateFlowRecord(record) → { ok: true } | { ok: false, reason }. Fail closed on unknown
357
+ // schema/kind/purpose, a missing/malformed field, or any key outside the closed per-kind set.
358
+ export const validateFlowRecord = (record) => {
359
+ if (!isPlainObject(record)) return refuse('record is not an object');
360
+ if (record.schema !== FLOW_SCHEMA_VERSION) {
361
+ return refuse(`unknown schema ${JSON.stringify(record.schema)} — this reader accepts flow schema ${FLOW_SCHEMA_VERSION} only (fail closed)`);
362
+ }
363
+ if (!FLOW_KINDS.includes(record.kind)) {
364
+ return refuse(`unknown kind ${JSON.stringify(record.kind)} — closed set: ${FLOW_KINDS.join(' | ')} (fail closed)`);
365
+ }
366
+ if (record.kind === CHAIN_KIND) {
367
+ if (!CHAIN_PURPOSES.includes(record.purpose)) {
368
+ return refuse(`chain: unknown purpose ${JSON.stringify(record.purpose)} — closed set: ${CHAIN_PURPOSES.join(' | ')} (fail closed)`);
369
+ }
370
+ const shape = PURPOSE_SHAPES[record.purpose];
371
+ const label = `chain/${record.purpose}`;
372
+ const fieldToCheck = Object.fromEntries([
373
+ ...CHAIN_COMMON_FIELDS.map((f) => [f, f]),
374
+ ['stepId', shape.stepId],
375
+ ...shape.fields.map((f) => [f, f]),
376
+ ]);
377
+ const checked = checkFields(label, record, fieldToCheck);
378
+ if (!checked.ok) return checked;
379
+ if (record.purpose !== 'round') return { ok: true };
380
+ const dispatches = validateDispatches(label, record.dispatches);
381
+ if (!dispatches.ok) return dispatches;
382
+ return validateDispositions(label, record.dispositions);
383
+ }
384
+ const overrides = GLOBAL_FIELD_CHECK_OVERRIDES[record.kind] ?? {};
385
+ const fieldToCheck = Object.fromEntries(GLOBAL_SHAPES[record.kind].map((f) => [f, overrides[f] ?? f]));
386
+ if (record.kind === 'subset-attempt' && 'diagnosis' in record) {
387
+ if (!(Number.isInteger(record.attemptIndex) && record.attemptIndex >= SUBSET_ATTEMPT_DIAGNOSIS_FROM)) {
388
+ return refuse(`subset-attempt: diagnosis rides only attemptIndex ${SUBSET_ATTEMPT_DIAGNOSIS_FROM} and later (Decision 8) — attempts 1-2 are the blind budget and never carry one`);
389
+ }
390
+ fieldToCheck.diagnosis = 'diagnosis';
391
+ }
392
+ const checked = checkFields(record.kind, record, fieldToCheck);
393
+ if (!checked.ok) return checked;
394
+ if (record.kind === 'down-mark') {
395
+ if (!isCanonicalInstant(record.timestamp)) return refuse('down-mark: timestamp must be a canonical UTC ISO instant (toISOString round-trip) — the TTL window needs comparable instants');
396
+ if (Date.parse(record.expiresAt) <= Date.parse(record.timestamp)) return refuse('down-mark: expiresAt must be strictly after timestamp — an already-expired mark is refused at the record level');
397
+ return { ok: true };
398
+ }
399
+ return record.kind === 'bookkeeping-delta' ? validateCustodyProof(record.kind, record) : { ok: true };
400
+ };
401
+
402
+ // ── per-kind keys + the authoritative latest-per-key selection ────────────────────────────────────
403
+
404
+ // JSON-array keys (collision-proof across free-form fields — space-joining would let a planId forge
405
+ // a separator). The down-mark family shares ONE key per backend so up/clear supersede the mark;
406
+ // maintainer-override keys on its veto instance; internal-attestation keys on
407
+ // {plan, cycle, step, round, tree}.
408
+ export const flowRecordKey = (record) =>
409
+ record.kind === CHAIN_KIND ? JSON.stringify([CHAIN_KIND, record.planId, record.cycle, record.stepId, record.round, record.purpose])
410
+ : record.kind === 'internal-attestation' ? JSON.stringify([record.kind, record.planId, record.cycle, record.stepId, record.round, record.base, record.fingerprint])
411
+ : record.kind === 'down-mark' || record.kind === 'down-mark-up' || record.kind === 'down-mark-clear' ? JSON.stringify(['down-mark', record.backend])
412
+ : record.kind === 'degrade-justification' ? JSON.stringify([record.kind, record.downMark])
413
+ : record.kind === 'rerun-cause' ? JSON.stringify([record.kind, record.attempt])
414
+ : record.kind === 'bookkeeping-delta' ? JSON.stringify([record.kind, record.fingerprintBefore, record.fingerprintAfter, record.path])
415
+ : record.kind === 'maintainer-override' ? JSON.stringify([record.kind, record.vetoReceiptDigest])
416
+ : record.kind === 'consult-attestation' ? JSON.stringify([record.kind, record.backend, record.nonce])
417
+ : record.kind === 'subset-attempt' ? JSON.stringify([record.kind, record.planId, record.cycle, record.stepId, record.foldBatch, record.subsetDigest])
418
+ : null;
419
+
420
+ // The authoritative subset: the LATEST record per key, in file order of that latest appearance.
421
+ // Raw file order is a separate, surviving view — the transition/ordering checks consume ONLY raw.
422
+ export const authoritativeFlowRecords = (records) => {
423
+ const lastByKey = new Map();
424
+ records.forEach((r, i) => {
425
+ const k = flowRecordKey(r);
426
+ if (k != null) lastByKey.set(k, i);
427
+ });
428
+ const keep = new Set(lastByKey.values());
429
+ return records.filter((_, i) => keep.has(i));
430
+ };
431
+
432
+ // ── tree identity (#21) ───────────────────────────────────────────────────────────────────────────
433
+
434
+ export const isTransitionShaped = (record) =>
435
+ record.kind === 'bookkeeping-delta' || (record.kind === CHAIN_KIND && record.purpose === 'refresh');
436
+
437
+ // The compound tree identity every flow record carries; for transition-shaped records the singular
438
+ // fingerprint IS fingerprintAfter.
439
+ export const flowTreeIdentity = (record) => ({
440
+ base: record.base,
441
+ fingerprint: isTransitionShaped(record) ? record.fingerprintAfter : record.fingerprint,
442
+ });
443
+
444
+ // ── per-record canonical digest (#63) — the record-reference id domain ────────────────────────────
445
+
446
+ const serializeCanonical = (v) => {
447
+ if (Array.isArray(v)) return `[${v.map(serializeCanonical).join(',')}]`;
448
+ if (v !== null && typeof v === 'object') {
449
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${serializeCanonical(v[k])}`).join(',')}}`;
450
+ }
451
+ return JSON.stringify(v);
452
+ };
453
+
454
+ // Canonical bytes of ONE record: recursively key-sorted JSON, NO trailing newline (the newline is
455
+ // store framing, not record identity). A parity test pins these bytes against core-evidence's
456
+ // canonicalKindSerialization on single-record fixtures.
457
+ export const flowCanonicalSerialization = (record) => serializeCanonical(record);
458
+
459
+ export const canonicalFlowDigest = (record) => createHash('sha256').update(flowCanonicalSerialization(record), 'utf8').digest('hex');
460
+
461
+ // ── Decision-7 derivation helpers (Plan 4) — pure digests over canonical bytes ────────────────────
462
+
463
+ // foldBatch keys the IMMUTABLE round identity projection: a round-ledger REVISION keeps
464
+ // {planId, cycle, stepId, round} (same digest — the budget never resets on supersession, #47),
465
+ // a NEW round moves it (fresh budget).
466
+ export const subsetFoldBatchDigest = ({ planId, cycle, stepId, round }) =>
467
+ createHash('sha256').update(flowCanonicalSerialization({ planId, cycle, stepId, round }), 'utf8').digest('hex');
468
+
469
+ // The derived subset's counting identity — declaring pregateExclude changes the ordered gate-id
470
+ // list, therefore the key, therefore the counting context (#47/#66).
471
+ export const subsetGateIdsDigest = (gateIds) =>
472
+ createHash('sha256').update(flowCanonicalSerialization(gateIds), 'utf8').digest('hex');
473
+
474
+ // ── the owner-scoped projection (Plan 4 Decision 2 / D10) — ONE pure helper, producer + consumer ──
475
+
476
+ // The hash domain is the OWNER-SCOPED projection, never the whole common store (#57): (a) every
477
+ // chain record whose owner is the committing worktree; (b) every planId-bearing global whose
478
+ // planId belongs to an owned chain; (c) every planId-less global (the down-mark family,
479
+ // degrade-justification, rerun-cause, bookkeeping-delta, maintainer-override — the rule is
480
+ // structural over every planId-less kind) whose tree identity (fingerprintAfter for transitions)
481
+ // is in {fingerprints appearing in owned-chain records} ∪ {the current tree fingerprint}. A
482
+ // foreign worktree's records fall outside (a)-(c) and never move the hash; a same-fingerprint
483
+ // foreign global is IN by (c) — same tree, same decision context. Raw store order is preserved:
484
+ // the projection hash is order-sensitive, so any in-projection append moves it.
485
+ export const ownerScopedFlowProjection = (records, { owner, currentFingerprint }) => {
486
+ const ownedChain = records.filter((r) => r.kind === CHAIN_KIND && r.owner === owner);
487
+ const ownedPlanIds = new Set(ownedChain.map((r) => r.planId));
488
+ const fingerprints = new Set(currentFingerprint == null ? [] : [currentFingerprint]);
489
+ for (const r of ownedChain) {
490
+ for (const field of ['fingerprint', 'fingerprintBefore', 'fingerprintAfter']) {
491
+ if (typeof r[field] === 'string') fingerprints.add(r[field]);
492
+ }
493
+ }
494
+ return records.filter((r) => {
495
+ if (r.kind === CHAIN_KIND) return r.owner === owner;
496
+ if (typeof r.planId === 'string') return ownedPlanIds.has(r.planId);
497
+ return fingerprints.has(flowTreeIdentity(r).fingerprint);
498
+ });
499
+ };
500
+
501
+ export const flowProjectionHash = (records, ctx) =>
502
+ createHash('sha256').update(ownerScopedFlowProjection(records, ctx).map(flowCanonicalSerialization).join('\n'), 'utf8').digest('hex');
503
+
504
+ // A same-index round REVISION re-states its round: opensFrom/base/fingerprint/commitEpoch are
505
+ // byte-equal to the previous version (the receipt attests the DISPATCHED tree, even when the live
506
+ // tree has moved on), existing ledger entries keep their order (a pending dispatch may land IN
507
+ // PLACE — both digests arrive together; a landed entry stays byte-identical), and new entries
508
+ // append only at the tail. Entry equality is canonical, never insertion-order-sensitive.
509
+ const validateRoundRevision = (prev, r) => {
510
+ for (const field of ['opensFrom', 'base', 'fingerprint', 'commitEpoch']) {
511
+ if (r[field] !== prev[field]) {
512
+ return refuse(`chain sequence: a round revision re-states its round — ${field} stays unchanged (the receipt attests the DISPATCHED tree)`);
513
+ }
514
+ }
515
+ if (r.dispatches.length < prev.dispatches.length) {
516
+ return refuse('chain sequence: a round revision never regresses its dispatch ledger (an entry disappeared)');
517
+ }
518
+ for (let i = 0; i < prev.dispatches.length; i += 1) {
519
+ const before = prev.dispatches[i];
520
+ const after = r.dispatches[i];
521
+ if (flowCanonicalSerialization(before) === flowCanonicalSerialization(after)) continue;
522
+ const landedInPlace = before.receiptDigest === null && after.receiptDigest !== null
523
+ && flowCanonicalSerialization({ ...after, receiptDigest: null, findingManifestDigest: null }) === flowCanonicalSerialization(before);
524
+ if (!landedInPlace) {
525
+ return refuse(`chain sequence: a round revision never regresses or mutates its dispatch ledger (entry ${i} — only pending → landed enriches, in place)`);
526
+ }
527
+ }
528
+ if (r.dispositions.length < prev.dispositions.length) {
529
+ return refuse('chain sequence: a round revision never regresses its disposition ledger (an entry disappeared)');
530
+ }
531
+ for (let i = 0; i < prev.dispositions.length; i += 1) {
532
+ if (flowCanonicalSerialization(prev.dispositions[i]) !== flowCanonicalSerialization(r.dispositions[i])) {
533
+ return refuse(`chain sequence: a round revision never regresses its disposition ledger (entry ${i} — existing dispositions stay byte-identical, new ones append at the tail)`);
534
+ }
535
+ }
536
+ return { ok: true };
537
+ };
538
+
539
+ // ── chain sequence legality (raw order, one chain) ────────────────────────────────────────────────
540
+
541
+ // validateChainSequence(records) → { ok } | { ok: false, reason }. Input: the RAW-order chain
542
+ // records of ONE plan's chain. Enforces: starts at adoption and adoption never recurs (#44/#58);
543
+ // serial-monotonic step grouping with closure scoped per {cycle, stepId} (a stepId reopens in a
544
+ // LATER cycle through an ordinary opener — the redesign valve); the within-step successor table;
545
+ // a step opens with "round" carrying the prior-terminal reference (structural half — digest
546
+ // resolution against the store lands with flow-check); a boundary re-baseline records disjoint
547
+ // base motion anchored to the prior terminal without reopening anything; park admits only resume
548
+ // and both preserve the pre-park {cycle, round}; complete admits no successor. Park/resume/complete
549
+ // are explicit writer actions — this validator only refuses (#59).
550
+ export const validateChainSequence = (records) => {
551
+ if (!Array.isArray(records)) return refuse('chain sequence: records must be an array');
552
+ if (records.length === 0) return { ok: true };
553
+ for (const r of records) {
554
+ if (r?.kind !== CHAIN_KIND) return refuse(`chain sequence: the validator accepts chain records only (got kind ${JSON.stringify(r?.kind)})`);
555
+ const v = validateFlowRecord(r);
556
+ if (!v.ok) return refuse(`chain sequence: malformed member — ${v.reason}`);
557
+ if (r.planId !== records[0].planId) return refuse(`chain sequence: one validator run covers one plan's chain (got "${records[0].planId}" and "${r.planId}")`);
558
+ if (r.owner !== records[0].owner) return refuse(`chain sequence: chain records never migrate owners — every record carries the adoption owner ("${records[0].owner}", got "${r.owner}"); an ownership transfer needs an explicit protocol, never a silent field change`);
559
+ }
560
+ if (records[0].purpose !== 'adoption') {
561
+ return refuse(`chain sequence: the chain starts at adoption — first record is "${records[0].purpose}"`);
562
+ }
563
+ const closureKey = (cycle, stepId) => JSON.stringify([cycle, stepId]);
564
+ const state = {
565
+ mode: 'boundary',
566
+ parked: null,
567
+ completed: false,
568
+ currentStep: null,
569
+ stepCycle: null,
570
+ currentRound: null,
571
+ lastPurpose: null,
572
+ lastTerminated: null,
573
+ boundaryRound: records[0].round,
574
+ closedSteps: new Set(),
575
+ lastCycle: records[0].cycle,
576
+ lastEpoch: records[0].commitEpoch,
577
+ roundLedgers: new Map(),
578
+ };
579
+ const contextCycle = () => (state.mode === 'in-step' ? state.stepCycle : state.lastCycle);
580
+ const contextRound = () => (state.mode === 'in-step' ? state.currentRound : state.boundaryRound);
581
+ const ledgerKey = (r) => JSON.stringify([r.cycle, r.stepId, r.round]);
582
+ for (const r of records.slice(1)) {
583
+ const p = r.purpose;
584
+ if (state.completed) return refuse('chain sequence: complete admits no successor');
585
+ if (r.cycle < state.lastCycle) return refuse(`chain sequence: the cycle index is monotonic (${state.lastCycle} → ${r.cycle})`);
586
+ // A same-index round record is a LEDGER REVISION — a non-lifecycle enrichment that repeats the
587
+ // DISPATCHED tree's epoch and never enters the lifecycle epoch cursor.
588
+ const isRevision = p === 'round' && state.mode === 'in-step' && state.parked === null
589
+ && r.stepId === state.currentStep && r.round === state.currentRound;
590
+ if (!isRevision) {
591
+ if (r.commitEpoch < state.lastEpoch) return refuse(`chain sequence: commitEpoch never regresses (${state.lastEpoch} → ${r.commitEpoch})`);
592
+ state.lastEpoch = r.commitEpoch;
593
+ }
594
+ if (state.parked !== null) {
595
+ if (p !== 'resume') return refuse(`chain sequence: park admits only resume (got "${p}")`);
596
+ if (r.cycle !== state.parked.cycle || r.round !== state.parked.round) {
597
+ return refuse(`chain sequence: resume must carry the pre-park cycle and round (${state.parked.cycle}/${state.parked.round}, got ${r.cycle}/${r.round}) — a new cycle starts by an explicit transition after resume`);
598
+ }
599
+ state.parked = null;
600
+ continue;
601
+ }
602
+ if (p === 'adoption') return refuse("chain sequence: adoption is only ever the chain's first record");
603
+ if (PLAN_LANE_PURPOSES.includes(p)) {
604
+ if (p === 'park') {
605
+ if (r.cycle !== contextCycle() || r.round !== contextRound()) {
606
+ return refuse(`chain sequence: park must carry the pre-park cycle and round (${contextCycle()}/${contextRound()}, got ${r.cycle}/${r.round})`);
607
+ }
608
+ state.parked = { cycle: r.cycle, round: r.round };
609
+ } else if (p === 'resume') {
610
+ return refuse('chain sequence: resume without a preceding park');
611
+ } else {
612
+ if (state.mode === 'in-step') return refuse('chain sequence: complete may not interrupt an open step — the step ends at converged');
613
+ state.completed = true;
614
+ state.lastCycle = r.cycle;
615
+ }
616
+ continue;
617
+ }
618
+ if (state.mode === 'in-step') {
619
+ if (r.stepId !== state.currentStep) {
620
+ return refuse(`chain sequence: step sequences are serial — a record of step "${r.stepId}" interleaves open step "${state.currentStep}"`);
621
+ }
622
+ if (r.cycle !== state.stepCycle) return refuse('chain sequence: the cycle changes only at a step boundary');
623
+ if (!ALLOWED_TRANSITIONS.withinStep[state.lastPurpose].includes(p)) {
624
+ return refuse(`chain sequence: illegal within-step transition ${state.lastPurpose} → ${p} (allowed: ${ALLOWED_TRANSITIONS.withinStep[state.lastPurpose].join(', ')})`);
625
+ }
626
+ if (p === 'round') {
627
+ if (r.round === state.currentRound) {
628
+ const revised = validateRoundRevision(state.roundLedgers.get(ledgerKey(r)), r);
629
+ if (!revised.ok) return revised;
630
+ state.roundLedgers.set(ledgerKey(r), r);
631
+ continue;
632
+ }
633
+ if (r.round < state.currentRound) return refuse(`chain sequence: the round index must increase within a step (${state.currentRound} → ${r.round})`);
634
+ if (r.opensFrom !== null) return refuse('chain sequence: only a step-opening round carries a prior-terminal reference');
635
+ state.currentRound = r.round;
636
+ state.roundLedgers.set(ledgerKey(r), r);
637
+ } else if (r.round !== state.currentRound) {
638
+ return refuse(`chain sequence: a non-round record carries its step's current round index (${state.currentRound}, got ${r.round})`);
639
+ }
640
+ state.lastPurpose = p;
641
+ if (p === 'converged') {
642
+ state.mode = 'boundary';
643
+ state.closedSteps.add(closureKey(state.stepCycle, state.currentStep));
644
+ state.lastTerminated = { step: state.currentStep, round: state.currentRound, cycle: state.stepCycle };
645
+ state.boundaryRound = state.currentRound;
646
+ }
647
+ } else if (p === 'unfreeze') {
648
+ if (state.lastTerminated === null) return refuse('chain sequence: unfreeze requires a prior converged terminal');
649
+ if (r.stepId !== state.lastTerminated.step) {
650
+ return refuse(`chain sequence: unfreeze reopens only the step that just converged ("${state.lastTerminated.step}", got "${r.stepId}")`);
651
+ }
652
+ if (r.round !== state.lastTerminated.round) return refuse('chain sequence: unfreeze carries the converged round index');
653
+ if (r.cycle !== state.lastTerminated.cycle) {
654
+ return refuse("chain sequence: unfreeze reopens only in its terminal's cycle — a later cycle reopens the stepId through an ordinary opening round");
655
+ }
656
+ state.mode = 'in-step';
657
+ state.currentStep = r.stepId;
658
+ state.stepCycle = r.cycle;
659
+ state.currentRound = r.round;
660
+ state.lastPurpose = 'unfreeze';
661
+ state.closedSteps.delete(closureKey(r.cycle, r.stepId));
662
+ } else if (p === 're-baseline') {
663
+ const anchorStep = state.lastTerminated === null ? null : state.lastTerminated.step;
664
+ if (r.stepId !== anchorStep) {
665
+ return refuse(`chain sequence: a boundary re-baseline anchors to the prior terminal's stepId (${JSON.stringify(anchorStep)}, got ${JSON.stringify(r.stepId)}) — it reopens nothing`);
666
+ }
667
+ if (r.round !== state.boundaryRound) return refuse(`chain sequence: a boundary re-baseline carries the boundary round index (${state.boundaryRound}, got ${r.round})`);
668
+ if (r.cycle !== state.lastCycle) return refuse('chain sequence: a re-baseline never moves the cycle — base motion is not a redesign');
669
+ } else if (p === ALLOWED_TRANSITIONS.stepOpening) {
670
+ if (state.closedSteps.has(closureKey(r.cycle, r.stepId))) {
671
+ return refuse(`chain sequence: step "${r.stepId}" already converged in cycle ${r.cycle} — a converged step reopens only through the unfreeze lane`);
672
+ }
673
+ if (r.opensFrom === null) {
674
+ return refuse("chain sequence: a step-opening round must carry the prior-terminal reference (opensFrom) — the plan's first step references the adoption record itself");
675
+ }
676
+ if (r.round < 1) return refuse('chain sequence: a step opens at round 1 or later');
677
+ state.mode = 'in-step';
678
+ state.currentStep = r.stepId;
679
+ state.stepCycle = r.cycle;
680
+ state.currentRound = r.round;
681
+ state.lastPurpose = 'round';
682
+ state.roundLedgers.set(ledgerKey(r), r);
683
+ } else {
684
+ return refuse(`chain sequence: a step sequence opens with "${ALLOWED_TRANSITIONS.stepOpening}" (got "${p}")`);
685
+ }
686
+ state.lastCycle = r.cycle;
687
+ }
688
+ return { ok: true };
689
+ };
690
+
691
+ // ── the wrapper finding manifest (Phase 4.2, Decision 2 / P5 / P24) — pure form ──────────────────
692
+
693
+ // The SAFE nonce grammar (containment-checked): the nonce enters a DERIVED FILENAME in the git
694
+ // dir, so only this closed byte set is accepted — anything else refuses before a name composes.
695
+ export const SAFE_NONCE_RE = /^[A-Za-z0-9._-]{1,64}$/;
696
+
697
+ export const FINDING_MANIFEST_PREFIX = 'agent-workflow-finding-manifest-';
698
+
699
+ // The manifest filename derives from the DISPATCH IDENTITY {backend, nonce} — two backends can
700
+ // never collide on one nonce (P24). Both halves are containment-checked; null on any violation.
701
+ export const findingManifestBasename = (backend, nonce) => {
702
+ if (typeof backend !== 'string' || !SAFE_NONCE_RE.test(backend)) return null;
703
+ if (typeof nonce !== 'string' || !SAFE_NONCE_RE.test(nonce)) return null;
704
+ return `${FINDING_MANIFEST_PREFIX}${backend}-${nonce}.json`;
705
+ };
706
+
707
+ // The closed manifest shape {schema, backend, nonce, fingerprint, findings} (P24) — findings is
708
+ // the wrapper-captured findings payload VERBATIM (form-provable; semantics stay an honest limit).
709
+ const FINDING_MANIFEST_KEYS = ['schema', 'backend', 'nonce', 'fingerprint', 'findings'];
710
+
711
+ export const validateFindingManifest = (manifest) => {
712
+ if (!isPlainObject(manifest)) return refuse('finding manifest: not an object');
713
+ const stray = Object.keys(manifest).find((k) => !FINDING_MANIFEST_KEYS.includes(k));
714
+ if (stray !== undefined) return refuse(`finding manifest: unknown field "${stray}" — the key set is closed (fail closed)`);
715
+ const missing = FINDING_MANIFEST_KEYS.find((k) => !(k in manifest));
716
+ if (missing !== undefined) return refuse(`finding manifest: missing field "${missing}"`);
717
+ if (manifest.schema !== FLOW_SCHEMA_VERSION) {
718
+ return refuse(`finding manifest: unknown schema ${JSON.stringify(manifest.schema)} — this reader accepts schema ${FLOW_SCHEMA_VERSION} only (fail closed)`);
719
+ }
720
+ if (typeof manifest.backend !== 'string' || !SAFE_NONCE_RE.test(manifest.backend)) return refuse('finding manifest: backend must satisfy the safe name grammar ([A-Za-z0-9._-]{1,64})');
721
+ if (typeof manifest.nonce !== 'string' || !SAFE_NONCE_RE.test(manifest.nonce)) return refuse('finding manifest: nonce must satisfy the safe nonce grammar ([A-Za-z0-9._-]{1,64})');
722
+ if (manifest.fingerprint !== null && !isHex64(manifest.fingerprint)) return refuse('finding manifest: fingerprint must be a 64-hex tree fingerprint, or null when the wrapper could not compute one');
723
+ if (!isNonEmptyString(manifest.findings)) return refuse('finding manifest: findings must be the non-empty captured findings payload (one string)');
724
+ if (!manifest.findings.isWellFormed()) return refuse('finding manifest: findings must be a well-formed Unicode string — utf8-hashing a lone surrogate would substitute U+FFFD and corrupt the findingDigest domain (fail closed)');
725
+ return { ok: true };
726
+ };
727
+
728
+ // The ONE manifest reader both kit consumers share (flow-writer's consult arm, the
729
+ // receipt-deadline runner): FATAL UTF-8 decode — a lossy toString would substitute U+FFFD and
730
+ // silently mutate the digest domain — with ignoreBOM, so a BOM-prefixed file keeps refusing at
731
+ // JSON.parse exactly as the pre-helper path did (no behavior widening).
732
+ export const decodeFindingManifest = (bytes) => {
733
+ let text;
734
+ try {
735
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
736
+ } catch {
737
+ return refuse('finding manifest: not valid UTF-8 — a lossy decode would silently mutate the findings digest domain (fail closed)');
738
+ }
739
+ let manifest;
740
+ try {
741
+ manifest = JSON.parse(text);
742
+ } catch {
743
+ return refuse('finding manifest: not valid JSON (fail closed)');
744
+ }
745
+ const valid = validateFindingManifest(manifest);
746
+ if (!valid.ok) return valid;
747
+ return { ok: true, manifest };
748
+ };
749
+
750
+ // ── stateful-kind supersession legality (raw order, in-memory list) ───────────────────────────────
751
+
752
+ // validateSupersessions(records) → { ok } | { ok: false, reason }. Walks RAW order and resolves
753
+ // supersession targets among EARLIER records by per-record canonical digest: down-mark-up/clear
754
+ // must target an earlier down-mark of the SAME backend; a maintainer-override chain is linear per
755
+ // veto instance — the first override carries supersedes: null, every later one must supersede the
756
+ // CURRENT head (a stale target would fork the chain and let latest-per-key bury a live override
757
+ // without explicit supersession, #56). Out-of-order and mis-targeted supersessions refuse by name.
758
+ export const validateSupersessions = (records, digestOf = canonicalFlowDigest) => {
759
+ const seen = new Map();
760
+ const overrideHeads = new Map();
761
+ const activeMarks = new Map();
762
+ for (const r of records) {
763
+ if (r.kind === 'down-mark') {
764
+ if (activeMarks.has(r.backend)) {
765
+ return refuse(`down-mark: backend "${r.backend}" already carries an ACTIVE down-mark — it must be explicitly closed by up/clear before a new mark lands (supersession is explicit, never silent)`);
766
+ }
767
+ activeMarks.set(r.backend, digestOf(r));
768
+ }
769
+ if (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') {
770
+ const target = seen.get(r.target);
771
+ if (target === undefined) return refuse(`${r.kind}: the supersession target does not resolve to an EARLIER record (out-of-order or unknown) — a supersession lands only after its down-mark`);
772
+ if (target.kind !== 'down-mark') return refuse(`${r.kind}: the supersession target is a ${target.kind}, not a down-mark (mis-targeted)`);
773
+ if (target.backend !== r.backend) return refuse(`${r.kind}: the supersession target belongs to backend "${target.backend}", not "${r.backend}" (mis-targeted)`);
774
+ const active = activeMarks.get(r.backend);
775
+ if (active === undefined) return refuse(`${r.kind}: no active down-mark for backend "${r.backend}" — the family is closed (or never opened); a new down-mark opens a new instance`);
776
+ if (r.target !== active) return refuse(`${r.kind}: the supersession targets a stale down-mark — up/clear must target the backend's ACTIVE mark`);
777
+ activeMarks.delete(r.backend);
778
+ }
779
+ if (r.kind === 'maintainer-override') {
780
+ const head = overrideHeads.get(r.vetoReceiptDigest);
781
+ if (r.supersedes === null) {
782
+ if (head !== undefined) return refuse('maintainer-override: only the first override of a veto instance carries supersedes: null — a later override must supersede the CURRENT head');
783
+ } else {
784
+ const target = seen.get(r.supersedes);
785
+ if (target === undefined) return refuse('maintainer-override: supersedes does not resolve to an EARLIER record (out-of-order or unknown)');
786
+ if (target.kind !== 'maintainer-override') return refuse(`maintainer-override: supersedes must target a maintainer-override record, not a ${target.kind} (mis-targeted)`);
787
+ if (target.vetoReceiptDigest !== r.vetoReceiptDigest) return refuse('maintainer-override: the supersession crosses veto instances — one override binds exactly one veto instance (mis-targeted)');
788
+ if (r.supersedes !== head) return refuse('maintainer-override: supersedes targets a STALE override — a later override must supersede the CURRENT head of its veto instance');
789
+ }
790
+ overrideHeads.set(r.vetoReceiptDigest, digestOf(r));
791
+ }
792
+ seen.set(digestOf(r), r);
793
+ }
794
+ return { ok: true };
795
+ };