@sabaiway/agent-workflow-kit 5.11.0 → 5.11.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/CHANGELOG.md +111 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +13 -2
- package/references/hooks/state-block-guard.mjs +14 -2
- package/references/scripts/archive-changelog.mjs +14 -3
- package/references/scripts/archive-decisions.mjs +14 -3
- package/references/scripts/archive-issues.mjs +14 -3
- package/references/scripts/check-docs-size.mjs +14 -3
- package/references/scripts/migrate-gates.mjs +13 -2
- package/tools/ack-write.mjs +3 -3
- package/tools/autonomy-doctor.mjs +2 -3
- package/tools/bridge-settings.mjs +2 -3
- package/tools/cheap-agents.mjs +3 -3
- package/tools/commands.mjs +2 -3
- package/tools/commit-guard.mjs +3 -3
- package/tools/core-evidence.mjs +2 -3
- package/tools/coverage-check.mjs +2 -3
- package/tools/delegation.mjs +2 -3
- package/tools/detect-backends.mjs +2 -3
- package/tools/dispatch-record.mjs +1 -1
- package/tools/doc-parity.mjs +2 -3
- package/tools/family-registry.mjs +3 -3
- package/tools/flow-adoption-mint.mjs +70 -0
- package/tools/flow-append.mjs +309 -0
- package/tools/flow-chain-state.mjs +91 -0
- package/tools/flow-check.mjs +2 -3
- package/tools/flow-delta-proof.mjs +307 -0
- package/tools/flow-finding-manifest.mjs +70 -0
- package/tools/flow-legality.mjs +248 -0
- package/tools/flow-record-identity.mjs +115 -0
- package/tools/flow-record-shape.mjs +283 -0
- package/tools/flow-record.mjs +49 -789
- package/tools/flow-store-read.mjs +3 -3
- package/tools/flow-store.mjs +35 -812
- package/tools/flow-subset-budget.mjs +81 -0
- package/tools/flow-vocabulary.mjs +96 -0
- package/tools/flow-writer.mjs +3 -3
- package/tools/gate-hook.mjs +3 -3
- package/tools/gates-init.mjs +3 -3
- package/tools/grounding.mjs +2 -3
- package/tools/hide-footprint.mjs +2 -3
- package/tools/inject-methodology.mjs +2 -3
- package/tools/lens-region.mjs +2 -3
- package/tools/manifest/validate.mjs +2 -3
- package/tools/migrate-adr-store.mjs +3 -3
- package/tools/path-inventory.mjs +2 -3
- package/tools/procedures.mjs +3 -3
- package/tools/receipt-deadline.mjs +2 -3
- package/tools/recipes.mjs +2 -3
- package/tools/recommendations.mjs +3 -3
- package/tools/release-scan.mjs +2 -3
- package/tools/repo-search.mjs +2 -3
- package/tools/review-state.mjs +3 -3
- package/tools/run-gates.mjs +2 -3
- package/tools/sandbox-masks.mjs +3 -3
- package/tools/set-autonomy.mjs +2 -3
- package/tools/set-flow.mjs +3 -3
- package/tools/set-recipe.mjs +2 -3
- package/tools/setup-backends.mjs +3 -3
- package/tools/store-append.mjs +2 -2
- package/tools/uninstall.mjs +2 -3
- package/tools/velocity-profile.mjs +3 -3
- package/tools/worktrees.mjs +3 -3
package/tools/flow-record.mjs
CHANGED
|
@@ -1,795 +1,55 @@
|
|
|
1
|
-
// flow-record.mjs — the CLOSED flow-record vocabulary (flow-orchestration,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// flow-record.mjs — the CLOSED flow-record vocabulary's PUBLIC SURFACE (flow-orchestration,
|
|
2
|
+
// Phase 1): the 29 names every consumer of the record family imports through this ONE path. Pure
|
|
3
|
+
// form: no filesystem, no git, no CLI, no side effects on import — and no logic, this module is
|
|
4
|
+
// re-exports only. The store IO (flow-store.mjs) and the checker (flow-check.mjs) consume these
|
|
5
|
+
// predicates; nothing here reads or writes a store.
|
|
4
6
|
//
|
|
5
7
|
// The vocabulary is seeded VERBATIM from the flow design §5 closed kind set and fixed BEFORE any
|
|
6
|
-
// reader/writer exists
|
|
7
|
-
// drift-guard test
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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.
|
|
8
|
+
// reader/writer exists; every seed member is ASSIGNED to a family (DESIGN_SEED_ASSIGNMENT, bound by
|
|
9
|
+
// the drift-guard test), the chain family carrying ONE record kind keyed
|
|
10
|
+
// {planId, cycle, stepId, round, purpose} and the store-global kinds each their own shape and
|
|
11
|
+
// supersession key. Fail-closed in BOTH directions: unknown schema, unknown kind, unknown purpose,
|
|
12
|
+
// a missing field, a malformed field, and an unknown EXTRA field are all refusals — the per-record
|
|
13
|
+
// canonical digest is the record's identity, so a stray key would fork it.
|
|
39
14
|
//
|
|
40
15
|
// 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
|
-
//
|
|
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.
|
|
16
|
+
// over the canonical (recursively key-sorted, byte-layout-independent) serialization of ONE record,
|
|
17
|
+
// with NO trailing newline; multi-record framing belongs to stores, never to this primitive.
|
|
46
18
|
//
|
|
47
19
|
// Honest residuals: reference RESOLUTION against a real store (forged / mismatched / superseded
|
|
48
|
-
// targets, adoption content-digest binding) lands with flow-store/flow-check; this
|
|
49
|
-
// references only inside an in-memory record list
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
export
|
|
71
|
-
|
|
72
|
-
export
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
export
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
};
|
|
20
|
+
// targets, adoption content-digest binding) lands with flow-store/flow-check; this family resolves
|
|
21
|
+
// references only inside an in-memory record list and validates form, per-chain sequence legality,
|
|
22
|
+
// and selection. Records remain forgeable — a self-discipline mechanism, not a security boundary.
|
|
23
|
+
//
|
|
24
|
+
// LAYOUT (baseline-practices tranche 3): the code lives in five leaves, imports running ONE way —
|
|
25
|
+
// legality → shape and identity → vocabulary, with the manifest leaf beside them:
|
|
26
|
+
// • flow-vocabulary.mjs — the closed kind/purpose sets, the terminal lanes, the seed
|
|
27
|
+
// assignment, the transition table, and (off this surface) the five
|
|
28
|
+
// shared form bindings every validator states its refusals in
|
|
29
|
+
// • flow-record-shape.mjs — the closed per-kind field shapes and validateFlowRecord
|
|
30
|
+
// • flow-record-identity.mjs — keys, latest-per-key selection, tree identity (#21), the
|
|
31
|
+
// canonical digest (#63), the Decision-7 digests, the projection
|
|
32
|
+
// • flow-legality.mjs — the two raw-order legality walks over an in-memory list
|
|
33
|
+
// • flow-finding-manifest.mjs — the wrapper finding manifest (P24) and its fatal-UTF-8 decoder
|
|
34
|
+
// No leaf imports this facade — that edge would be the cycle test/read-graph-purity.test.mjs reds,
|
|
35
|
+
// and test/flow-record-layout.test.mjs pins the surface, the bindings, the caps and the direction.
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
FLOW_SCHEMA_VERSION, CHAIN_KIND, CHAIN_PURPOSES, STEP_SCOPED_PURPOSES, PLAN_LANE_PURPOSES,
|
|
39
|
+
GLOBAL_KINDS, FLOW_KINDS, TERMINAL_LANES, DESIGN_SEED_ASSIGNMENT, ALLOWED_TRANSITIONS,
|
|
40
|
+
} from './flow-vocabulary.mjs';
|
|
41
|
+
|
|
42
|
+
export { SUBSET_ATTEMPT_DIAGNOSIS_FROM, validateFlowRecord } from './flow-record-shape.mjs';
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
flowRecordKey, authoritativeFlowRecords, isTransitionShaped, flowTreeIdentity,
|
|
46
|
+
flowCanonicalSerialization, canonicalFlowDigest, subsetFoldBatchDigest, subsetGateIdsDigest,
|
|
47
|
+
ownerScopedFlowProjection, flowProjectionHash,
|
|
48
|
+
} from './flow-record-identity.mjs';
|
|
49
|
+
|
|
50
|
+
export { validateChainSequence, validateSupersessions } from './flow-legality.mjs';
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
SAFE_NONCE_RE, FINDING_MANIFEST_PREFIX, findingManifestBasename, validateFindingManifest,
|
|
54
|
+
decodeFindingManifest,
|
|
55
|
+
} from './flow-finding-manifest.mjs';
|