@sabaiway/agent-workflow-kit 5.11.1 → 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 CHANGED
@@ -4,6 +4,56 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 5.11.2 — the record vocabulary becomes a facade over five leaves (AD-103)
8
+
9
+ **Nothing you import changes, and that is the whole claim — now certified rather than asserted.**
10
+ `tools/flow-record.mjs` was the kit's most expensive module to read: 795 lines holding eleven of its
11
+ own section seams, from the closed record vocabulary through the field shapes, the identity family,
12
+ the two legality walks and the wrapper finding manifest. It keeps its path and its 29 export names,
13
+ and becomes a 55-line re-export facade over five single-responsibility leaves.
14
+
15
+ - **`tools/flow-record.mjs` keeps its path, its 29 export names and every one of its 30 import
16
+ sites.** The internals moved verbatim into `flow-vocabulary.mjs` (96, the lowest leaf: the closed
17
+ kinds, purposes, terminal lanes, seed assignment and transition table) · `flow-record-shape.mjs`
18
+ (283, the closed per-kind field shapes and `validateFlowRecord`) · `flow-record-identity.mjs` (115,
19
+ every answer to "what identifies this record or this set" — keys, tree identity, the canonical
20
+ digest, the owner-scoped projection) · `flow-legality.mjs` (248, the two raw-order legality walks)
21
+ · `flow-finding-manifest.mjs` (70). The edges run one way and the family is acyclic by
22
+ construction; no leaf reaches a write module, so "pure form" is a structural pin instead of a
23
+ header sentence.
24
+ - **Conservation is certified by ORDER as well as by count.** A comparator normalises both sides
25
+ identically and then checks, per leaf, that its line SEQUENCE equals the concatenation of its
26
+ declared source ranges in declared order — a reordering is caught, not just a loss — AND that the
27
+ multiset over the union of the five leaves equals the pre-split module. Over the 587 executable
28
+ lines: five sequences EQUAL, the difference EMPTY in both directions. It was then run three more
29
+ times — against the unsplit module alone, with one leaf omitted, and with one line duplicated — so
30
+ a comparator that could not report loss or excess could not pass for one that does.
31
+ - **The five shared grammars live in exactly one place.** `HEX64_RE`, `isHex64`, `isPlainObject`,
32
+ `isNonEmptyString` and `refuse` are exported off-surface from the lowest leaf and used by the
33
+ others. They are the record family's named grammars — `isHex64` IS the 64-hex digest grammar every
34
+ consumer takes by reference — and a per-leaf copy is the only way two of them could ever drift.
35
+ The layout suite pins that off-surface set exactly, so it cannot widen unnoticed.
36
+ - **The owning suite never moved.** `tools/flow-record.test.mjs` is BYTE-IDENTICAL and reproduces
37
+ 58/58; the fifteen named suites show an empty `git diff --name-only` in the worktree and under
38
+ `--cached`. `test/flow-record-layout.test.mjs` (not shipped) pins the frozen 29-name surface, the
39
+ facade-declared owner of every name, that the facade carries no logic at all, the size caps, and
40
+ the one-way edges including their negative direction — four of its five cases were red-proofed on
41
+ the unsplit tree before the split existed.
42
+ - **`test/package-content.test.mjs` moves its payload pin from 227 to 232 files** and names the five
43
+ leaves: the facade imports all five, so a leaf falling out of the published tarball would break
44
+ every flow read at load, and the pin is what says so.
45
+ - **One consumer line changed in the whole tree, and it is a comment.** `dispatch-record.mjs:15`
46
+ cited the fail-closed rule by line number (`flow-record.mjs:22-24`); the split falsified that
47
+ reference, so it now names the rule semantically.
48
+
49
+ Recorded size effect, reason `tranche 3: flow-record split`:
50
+
51
+ ```text
52
+ agent-workflow-kit/test/package-content.test.mjs: lines 600 → 617 (raise)
53
+ agent-workflow-kit/tools/flow-record.mjs: lines 795 → none
54
+ agent-workflow-kit: aggregate lines 125315 → 125669 (raise)
55
+ ```
56
+
7
57
  ## 5.11.1 — the flow store becomes a facade over five leaves, and the direct-run guard fix finally reaches you (AD-102)
8
58
 
9
59
  **A tool invoked through a symlink used to run nothing and exit 0 — and five of the affected tools are
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '5.11.1'
6
+ version: '5.11.2'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "5.11.1",
6
+ "version": "5.11.2",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "5.11.1",
3
+ "version": "5.11.2",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -12,7 +12,7 @@
12
12
  //
13
13
  // The record family (D3, versioned + closed): pre-registration · dispatch · return · fold ·
14
14
  // observation · degrade. Fail-closed in BOTH directions — unknown schema, unknown kind, a missing
15
- // field, a malformed field and an unknown EXTRA field are all refusals (the flow-record.mjs:22-24
15
+ // field, a malformed field and an unknown EXTRA field are all refusals (the flow-record.mjs header's
16
16
  // discipline): the per-record canonical digest is the record's identity, so a stray key would fork
17
17
  // it.
18
18
  //
@@ -0,0 +1,70 @@
1
+ // flow-finding-manifest.mjs — the wrapper finding manifest (Phase 4.2, Decision 2 / P5 / P24): the
2
+ // SAFE nonce grammar, the manifest filename prefix and the basename derived from the dispatch
3
+ // identity, the closed manifest shape, and the ONE fatal-UTF-8 decoder both kit consumers share.
4
+ // Split out of flow-record.mjs unchanged (baseline-practices tranche 3), which now re-exports every
5
+ // name here.
6
+ //
7
+ // Pure form: no filesystem, no git, no CLI, no side effects on import — it validates and DERIVES a
8
+ // name, it never opens the file the name denotes. Imports run ONE way: the vocabulary leaf owns the
9
+ // schema version and the shared form bindings, and nothing here reaches back up to the facade.
10
+
11
+ import { FLOW_SCHEMA_VERSION, isHex64, isNonEmptyString, isPlainObject, refuse } from './flow-vocabulary.mjs';
12
+
13
+ // ── the wrapper finding manifest (Phase 4.2, Decision 2 / P5 / P24) — pure form ──────────────────
14
+
15
+ // The SAFE nonce grammar (containment-checked): the nonce enters a DERIVED FILENAME in the git
16
+ // dir, so only this closed byte set is accepted — anything else refuses before a name composes.
17
+ export const SAFE_NONCE_RE = /^[A-Za-z0-9._-]{1,64}$/;
18
+
19
+ export const FINDING_MANIFEST_PREFIX = 'agent-workflow-finding-manifest-';
20
+
21
+ // The manifest filename derives from the DISPATCH IDENTITY {backend, nonce} — two backends can
22
+ // never collide on one nonce (P24). Both halves are containment-checked; null on any violation.
23
+ export const findingManifestBasename = (backend, nonce) => {
24
+ if (typeof backend !== 'string' || !SAFE_NONCE_RE.test(backend)) return null;
25
+ if (typeof nonce !== 'string' || !SAFE_NONCE_RE.test(nonce)) return null;
26
+ return `${FINDING_MANIFEST_PREFIX}${backend}-${nonce}.json`;
27
+ };
28
+
29
+ // The closed manifest shape {schema, backend, nonce, fingerprint, findings} (P24) — findings is
30
+ // the wrapper-captured findings payload VERBATIM (form-provable; semantics stay an honest limit).
31
+ const FINDING_MANIFEST_KEYS = ['schema', 'backend', 'nonce', 'fingerprint', 'findings'];
32
+
33
+ export const validateFindingManifest = (manifest) => {
34
+ if (!isPlainObject(manifest)) return refuse('finding manifest: not an object');
35
+ const stray = Object.keys(manifest).find((k) => !FINDING_MANIFEST_KEYS.includes(k));
36
+ if (stray !== undefined) return refuse(`finding manifest: unknown field "${stray}" — the key set is closed (fail closed)`);
37
+ const missing = FINDING_MANIFEST_KEYS.find((k) => !(k in manifest));
38
+ if (missing !== undefined) return refuse(`finding manifest: missing field "${missing}"`);
39
+ if (manifest.schema !== FLOW_SCHEMA_VERSION) {
40
+ return refuse(`finding manifest: unknown schema ${JSON.stringify(manifest.schema)} — this reader accepts schema ${FLOW_SCHEMA_VERSION} only (fail closed)`);
41
+ }
42
+ 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})');
43
+ 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})');
44
+ 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');
45
+ if (!isNonEmptyString(manifest.findings)) return refuse('finding manifest: findings must be the non-empty captured findings payload (one string)');
46
+ 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)');
47
+ return { ok: true };
48
+ };
49
+
50
+ // The ONE manifest reader both kit consumers share (flow-writer's consult arm, the
51
+ // receipt-deadline runner): FATAL UTF-8 decode — a lossy toString would substitute U+FFFD and
52
+ // silently mutate the digest domain — with ignoreBOM, so a BOM-prefixed file keeps refusing at
53
+ // JSON.parse exactly as the pre-helper path did (no behavior widening).
54
+ export const decodeFindingManifest = (bytes) => {
55
+ let text;
56
+ try {
57
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
58
+ } catch {
59
+ return refuse('finding manifest: not valid UTF-8 — a lossy decode would silently mutate the findings digest domain (fail closed)');
60
+ }
61
+ let manifest;
62
+ try {
63
+ manifest = JSON.parse(text);
64
+ } catch {
65
+ return refuse('finding manifest: not valid JSON (fail closed)');
66
+ }
67
+ const valid = validateFindingManifest(manifest);
68
+ if (!valid.ok) return valid;
69
+ return { ok: true, manifest };
70
+ };
@@ -0,0 +1,248 @@
1
+ // flow-legality.mjs — the two raw-order legality walks over an in-memory record list: chain
2
+ // sequence legality (validateChainSequence, with the private round-revision rule it enforces) and
3
+ // stateful-kind supersession legality (validateSupersessions). Split out of flow-record.mjs
4
+ // unchanged (baseline-practices tranche 3), which now re-exports both names here.
5
+ //
6
+ // Pure form: no filesystem, no git, no CLI, no side effects on import. RAW order is the input — the
7
+ // authoritative latest-per-key view deliberately never reaches these walks. Reference RESOLUTION
8
+ // against a real store lands with flow-store/flow-check; this module resolves references only
9
+ // inside the list it is handed. The HIGHEST leaf of the family: it composes the vocabulary, the
10
+ // shape validator and the identity digests, and nothing here reaches back up to the facade.
11
+
12
+ import { ALLOWED_TRANSITIONS, CHAIN_KIND, PLAN_LANE_PURPOSES, refuse } from './flow-vocabulary.mjs';
13
+ import { validateFlowRecord } from './flow-record-shape.mjs';
14
+ import { canonicalFlowDigest, flowCanonicalSerialization } from './flow-record-identity.mjs';
15
+
16
+ // A same-index round REVISION re-states its round: opensFrom/base/fingerprint/commitEpoch are
17
+ // byte-equal to the previous version (the receipt attests the DISPATCHED tree, even when the live
18
+ // tree has moved on), existing ledger entries keep their order (a pending dispatch may land IN
19
+ // PLACE — both digests arrive together; a landed entry stays byte-identical), and new entries
20
+ // append only at the tail. Entry equality is canonical, never insertion-order-sensitive.
21
+ const validateRoundRevision = (prev, r) => {
22
+ for (const field of ['opensFrom', 'base', 'fingerprint', 'commitEpoch']) {
23
+ if (r[field] !== prev[field]) {
24
+ return refuse(`chain sequence: a round revision re-states its round — ${field} stays unchanged (the receipt attests the DISPATCHED tree)`);
25
+ }
26
+ }
27
+ if (r.dispatches.length < prev.dispatches.length) {
28
+ return refuse('chain sequence: a round revision never regresses its dispatch ledger (an entry disappeared)');
29
+ }
30
+ for (let i = 0; i < prev.dispatches.length; i += 1) {
31
+ const before = prev.dispatches[i];
32
+ const after = r.dispatches[i];
33
+ if (flowCanonicalSerialization(before) === flowCanonicalSerialization(after)) continue;
34
+ const landedInPlace = before.receiptDigest === null && after.receiptDigest !== null
35
+ && flowCanonicalSerialization({ ...after, receiptDigest: null, findingManifestDigest: null }) === flowCanonicalSerialization(before);
36
+ if (!landedInPlace) {
37
+ return refuse(`chain sequence: a round revision never regresses or mutates its dispatch ledger (entry ${i} — only pending → landed enriches, in place)`);
38
+ }
39
+ }
40
+ if (r.dispositions.length < prev.dispositions.length) {
41
+ return refuse('chain sequence: a round revision never regresses its disposition ledger (an entry disappeared)');
42
+ }
43
+ for (let i = 0; i < prev.dispositions.length; i += 1) {
44
+ if (flowCanonicalSerialization(prev.dispositions[i]) !== flowCanonicalSerialization(r.dispositions[i])) {
45
+ 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)`);
46
+ }
47
+ }
48
+ return { ok: true };
49
+ };
50
+
51
+ // ── chain sequence legality (raw order, one chain) ────────────────────────────────────────────────
52
+
53
+ // validateChainSequence(records) → { ok } | { ok: false, reason }. Input: the RAW-order chain
54
+ // records of ONE plan's chain. Enforces: starts at adoption and adoption never recurs (#44/#58);
55
+ // serial-monotonic step grouping with closure scoped per {cycle, stepId} (a stepId reopens in a
56
+ // LATER cycle through an ordinary opener — the redesign valve); the within-step successor table;
57
+ // a step opens with "round" carrying the prior-terminal reference (structural half — digest
58
+ // resolution against the store lands with flow-check); a boundary re-baseline records disjoint
59
+ // base motion anchored to the prior terminal without reopening anything; park admits only resume
60
+ // and both preserve the pre-park {cycle, round}; complete admits no successor. Park/resume/complete
61
+ // are explicit writer actions — this validator only refuses (#59).
62
+ export const validateChainSequence = (records) => {
63
+ if (!Array.isArray(records)) return refuse('chain sequence: records must be an array');
64
+ if (records.length === 0) return { ok: true };
65
+ for (const r of records) {
66
+ if (r?.kind !== CHAIN_KIND) return refuse(`chain sequence: the validator accepts chain records only (got kind ${JSON.stringify(r?.kind)})`);
67
+ const v = validateFlowRecord(r);
68
+ if (!v.ok) return refuse(`chain sequence: malformed member — ${v.reason}`);
69
+ 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}")`);
70
+ 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`);
71
+ }
72
+ if (records[0].purpose !== 'adoption') {
73
+ return refuse(`chain sequence: the chain starts at adoption — first record is "${records[0].purpose}"`);
74
+ }
75
+ const closureKey = (cycle, stepId) => JSON.stringify([cycle, stepId]);
76
+ const state = {
77
+ mode: 'boundary',
78
+ parked: null,
79
+ completed: false,
80
+ currentStep: null,
81
+ stepCycle: null,
82
+ currentRound: null,
83
+ lastPurpose: null,
84
+ lastTerminated: null,
85
+ boundaryRound: records[0].round,
86
+ closedSteps: new Set(),
87
+ lastCycle: records[0].cycle,
88
+ lastEpoch: records[0].commitEpoch,
89
+ roundLedgers: new Map(),
90
+ };
91
+ const contextCycle = () => (state.mode === 'in-step' ? state.stepCycle : state.lastCycle);
92
+ const contextRound = () => (state.mode === 'in-step' ? state.currentRound : state.boundaryRound);
93
+ const ledgerKey = (r) => JSON.stringify([r.cycle, r.stepId, r.round]);
94
+ for (const r of records.slice(1)) {
95
+ const p = r.purpose;
96
+ if (state.completed) return refuse('chain sequence: complete admits no successor');
97
+ if (r.cycle < state.lastCycle) return refuse(`chain sequence: the cycle index is monotonic (${state.lastCycle} → ${r.cycle})`);
98
+ // A same-index round record is a LEDGER REVISION — a non-lifecycle enrichment that repeats the
99
+ // DISPATCHED tree's epoch and never enters the lifecycle epoch cursor.
100
+ const isRevision = p === 'round' && state.mode === 'in-step' && state.parked === null
101
+ && r.stepId === state.currentStep && r.round === state.currentRound;
102
+ if (!isRevision) {
103
+ if (r.commitEpoch < state.lastEpoch) return refuse(`chain sequence: commitEpoch never regresses (${state.lastEpoch} → ${r.commitEpoch})`);
104
+ state.lastEpoch = r.commitEpoch;
105
+ }
106
+ if (state.parked !== null) {
107
+ if (p !== 'resume') return refuse(`chain sequence: park admits only resume (got "${p}")`);
108
+ if (r.cycle !== state.parked.cycle || r.round !== state.parked.round) {
109
+ 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`);
110
+ }
111
+ state.parked = null;
112
+ continue;
113
+ }
114
+ if (p === 'adoption') return refuse("chain sequence: adoption is only ever the chain's first record");
115
+ if (PLAN_LANE_PURPOSES.includes(p)) {
116
+ if (p === 'park') {
117
+ if (r.cycle !== contextCycle() || r.round !== contextRound()) {
118
+ return refuse(`chain sequence: park must carry the pre-park cycle and round (${contextCycle()}/${contextRound()}, got ${r.cycle}/${r.round})`);
119
+ }
120
+ state.parked = { cycle: r.cycle, round: r.round };
121
+ } else if (p === 'resume') {
122
+ return refuse('chain sequence: resume without a preceding park');
123
+ } else {
124
+ if (state.mode === 'in-step') return refuse('chain sequence: complete may not interrupt an open step — the step ends at converged');
125
+ state.completed = true;
126
+ state.lastCycle = r.cycle;
127
+ }
128
+ continue;
129
+ }
130
+ if (state.mode === 'in-step') {
131
+ if (r.stepId !== state.currentStep) {
132
+ return refuse(`chain sequence: step sequences are serial — a record of step "${r.stepId}" interleaves open step "${state.currentStep}"`);
133
+ }
134
+ if (r.cycle !== state.stepCycle) return refuse('chain sequence: the cycle changes only at a step boundary');
135
+ if (!ALLOWED_TRANSITIONS.withinStep[state.lastPurpose].includes(p)) {
136
+ return refuse(`chain sequence: illegal within-step transition ${state.lastPurpose} → ${p} (allowed: ${ALLOWED_TRANSITIONS.withinStep[state.lastPurpose].join(', ')})`);
137
+ }
138
+ if (p === 'round') {
139
+ if (r.round === state.currentRound) {
140
+ const revised = validateRoundRevision(state.roundLedgers.get(ledgerKey(r)), r);
141
+ if (!revised.ok) return revised;
142
+ state.roundLedgers.set(ledgerKey(r), r);
143
+ continue;
144
+ }
145
+ if (r.round < state.currentRound) return refuse(`chain sequence: the round index must increase within a step (${state.currentRound} → ${r.round})`);
146
+ if (r.opensFrom !== null) return refuse('chain sequence: only a step-opening round carries a prior-terminal reference');
147
+ state.currentRound = r.round;
148
+ state.roundLedgers.set(ledgerKey(r), r);
149
+ } else if (r.round !== state.currentRound) {
150
+ return refuse(`chain sequence: a non-round record carries its step's current round index (${state.currentRound}, got ${r.round})`);
151
+ }
152
+ state.lastPurpose = p;
153
+ if (p === 'converged') {
154
+ state.mode = 'boundary';
155
+ state.closedSteps.add(closureKey(state.stepCycle, state.currentStep));
156
+ state.lastTerminated = { step: state.currentStep, round: state.currentRound, cycle: state.stepCycle };
157
+ state.boundaryRound = state.currentRound;
158
+ }
159
+ } else if (p === 'unfreeze') {
160
+ if (state.lastTerminated === null) return refuse('chain sequence: unfreeze requires a prior converged terminal');
161
+ if (r.stepId !== state.lastTerminated.step) {
162
+ return refuse(`chain sequence: unfreeze reopens only the step that just converged ("${state.lastTerminated.step}", got "${r.stepId}")`);
163
+ }
164
+ if (r.round !== state.lastTerminated.round) return refuse('chain sequence: unfreeze carries the converged round index');
165
+ if (r.cycle !== state.lastTerminated.cycle) {
166
+ return refuse("chain sequence: unfreeze reopens only in its terminal's cycle — a later cycle reopens the stepId through an ordinary opening round");
167
+ }
168
+ state.mode = 'in-step';
169
+ state.currentStep = r.stepId;
170
+ state.stepCycle = r.cycle;
171
+ state.currentRound = r.round;
172
+ state.lastPurpose = 'unfreeze';
173
+ state.closedSteps.delete(closureKey(r.cycle, r.stepId));
174
+ } else if (p === 're-baseline') {
175
+ const anchorStep = state.lastTerminated === null ? null : state.lastTerminated.step;
176
+ if (r.stepId !== anchorStep) {
177
+ 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`);
178
+ }
179
+ if (r.round !== state.boundaryRound) return refuse(`chain sequence: a boundary re-baseline carries the boundary round index (${state.boundaryRound}, got ${r.round})`);
180
+ if (r.cycle !== state.lastCycle) return refuse('chain sequence: a re-baseline never moves the cycle — base motion is not a redesign');
181
+ } else if (p === ALLOWED_TRANSITIONS.stepOpening) {
182
+ if (state.closedSteps.has(closureKey(r.cycle, r.stepId))) {
183
+ return refuse(`chain sequence: step "${r.stepId}" already converged in cycle ${r.cycle} — a converged step reopens only through the unfreeze lane`);
184
+ }
185
+ if (r.opensFrom === null) {
186
+ 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");
187
+ }
188
+ if (r.round < 1) return refuse('chain sequence: a step opens at round 1 or later');
189
+ state.mode = 'in-step';
190
+ state.currentStep = r.stepId;
191
+ state.stepCycle = r.cycle;
192
+ state.currentRound = r.round;
193
+ state.lastPurpose = 'round';
194
+ state.roundLedgers.set(ledgerKey(r), r);
195
+ } else {
196
+ return refuse(`chain sequence: a step sequence opens with "${ALLOWED_TRANSITIONS.stepOpening}" (got "${p}")`);
197
+ }
198
+ state.lastCycle = r.cycle;
199
+ }
200
+ return { ok: true };
201
+ };
202
+
203
+ // ── stateful-kind supersession legality (raw order, in-memory list) ───────────────────────────────
204
+
205
+ // validateSupersessions(records) → { ok } | { ok: false, reason }. Walks RAW order and resolves
206
+ // supersession targets among EARLIER records by per-record canonical digest: down-mark-up/clear
207
+ // must target an earlier down-mark of the SAME backend; a maintainer-override chain is linear per
208
+ // veto instance — the first override carries supersedes: null, every later one must supersede the
209
+ // CURRENT head (a stale target would fork the chain and let latest-per-key bury a live override
210
+ // without explicit supersession, #56). Out-of-order and mis-targeted supersessions refuse by name.
211
+ export const validateSupersessions = (records, digestOf = canonicalFlowDigest) => {
212
+ const seen = new Map();
213
+ const overrideHeads = new Map();
214
+ const activeMarks = new Map();
215
+ for (const r of records) {
216
+ if (r.kind === 'down-mark') {
217
+ if (activeMarks.has(r.backend)) {
218
+ 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)`);
219
+ }
220
+ activeMarks.set(r.backend, digestOf(r));
221
+ }
222
+ if (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') {
223
+ const target = seen.get(r.target);
224
+ 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`);
225
+ if (target.kind !== 'down-mark') return refuse(`${r.kind}: the supersession target is a ${target.kind}, not a down-mark (mis-targeted)`);
226
+ if (target.backend !== r.backend) return refuse(`${r.kind}: the supersession target belongs to backend "${target.backend}", not "${r.backend}" (mis-targeted)`);
227
+ const active = activeMarks.get(r.backend);
228
+ 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`);
229
+ if (r.target !== active) return refuse(`${r.kind}: the supersession targets a stale down-mark — up/clear must target the backend's ACTIVE mark`);
230
+ activeMarks.delete(r.backend);
231
+ }
232
+ if (r.kind === 'maintainer-override') {
233
+ const head = overrideHeads.get(r.vetoReceiptDigest);
234
+ if (r.supersedes === null) {
235
+ 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');
236
+ } else {
237
+ const target = seen.get(r.supersedes);
238
+ if (target === undefined) return refuse('maintainer-override: supersedes does not resolve to an EARLIER record (out-of-order or unknown)');
239
+ if (target.kind !== 'maintainer-override') return refuse(`maintainer-override: supersedes must target a maintainer-override record, not a ${target.kind} (mis-targeted)`);
240
+ if (target.vetoReceiptDigest !== r.vetoReceiptDigest) return refuse('maintainer-override: the supersession crosses veto instances — one override binds exactly one veto instance (mis-targeted)');
241
+ 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');
242
+ }
243
+ overrideHeads.set(r.vetoReceiptDigest, digestOf(r));
244
+ }
245
+ seen.set(digestOf(r), r);
246
+ }
247
+ return { ok: true };
248
+ };
@@ -0,0 +1,115 @@
1
+ // flow-record-identity.mjs — every answer to "what identifies this record, or this set of records":
2
+ // the per-kind supersession keys and the authoritative latest-per-key selection, the compound tree
3
+ // identity (#21), the canonical single-record serialization and its digest (#63 — the whole
4
+ // inter-record reference domain), the two Decision-7 derivation digests, and the owner-scoped
5
+ // projection with its order-sensitive hash. Split out of flow-record.mjs unchanged
6
+ // (baseline-practices tranche 3), which now re-exports every name here.
7
+ //
8
+ // Pure form: no filesystem, no git, no CLI, no side effects on import — node:crypto for the digests
9
+ // and the vocabulary leaf for the one kind constant are all it reaches. Imports run ONE way: the
10
+ // legality leaf composes this module; nothing here reaches back up to the facade.
11
+
12
+ import { createHash } from 'node:crypto';
13
+ import { CHAIN_KIND } from './flow-vocabulary.mjs';
14
+
15
+ // ── per-kind keys + the authoritative latest-per-key selection ────────────────────────────────────
16
+
17
+ // JSON-array keys (collision-proof across free-form fields — space-joining would let a planId forge
18
+ // a separator). The down-mark family shares ONE key per backend so up/clear supersede the mark;
19
+ // maintainer-override keys on its veto instance; internal-attestation keys on
20
+ // {plan, cycle, step, round, tree}.
21
+ export const flowRecordKey = (record) =>
22
+ record.kind === CHAIN_KIND ? JSON.stringify([CHAIN_KIND, record.planId, record.cycle, record.stepId, record.round, record.purpose])
23
+ : record.kind === 'internal-attestation' ? JSON.stringify([record.kind, record.planId, record.cycle, record.stepId, record.round, record.base, record.fingerprint])
24
+ : record.kind === 'down-mark' || record.kind === 'down-mark-up' || record.kind === 'down-mark-clear' ? JSON.stringify(['down-mark', record.backend])
25
+ : record.kind === 'degrade-justification' ? JSON.stringify([record.kind, record.downMark])
26
+ : record.kind === 'rerun-cause' ? JSON.stringify([record.kind, record.attempt])
27
+ : record.kind === 'bookkeeping-delta' ? JSON.stringify([record.kind, record.fingerprintBefore, record.fingerprintAfter, record.path])
28
+ : record.kind === 'maintainer-override' ? JSON.stringify([record.kind, record.vetoReceiptDigest])
29
+ : record.kind === 'consult-attestation' ? JSON.stringify([record.kind, record.backend, record.nonce])
30
+ : record.kind === 'subset-attempt' ? JSON.stringify([record.kind, record.planId, record.cycle, record.stepId, record.foldBatch, record.subsetDigest])
31
+ : null;
32
+
33
+ // The authoritative subset: the LATEST record per key, in file order of that latest appearance.
34
+ // Raw file order is a separate, surviving view — the transition/ordering checks consume ONLY raw.
35
+ export const authoritativeFlowRecords = (records) => {
36
+ const lastByKey = new Map();
37
+ records.forEach((r, i) => {
38
+ const k = flowRecordKey(r);
39
+ if (k != null) lastByKey.set(k, i);
40
+ });
41
+ const keep = new Set(lastByKey.values());
42
+ return records.filter((_, i) => keep.has(i));
43
+ };
44
+
45
+ // ── tree identity (#21) ───────────────────────────────────────────────────────────────────────────
46
+
47
+ export const isTransitionShaped = (record) =>
48
+ record.kind === 'bookkeeping-delta' || (record.kind === CHAIN_KIND && record.purpose === 'refresh');
49
+
50
+ // The compound tree identity every flow record carries; for transition-shaped records the singular
51
+ // fingerprint IS fingerprintAfter.
52
+ export const flowTreeIdentity = (record) => ({
53
+ base: record.base,
54
+ fingerprint: isTransitionShaped(record) ? record.fingerprintAfter : record.fingerprint,
55
+ });
56
+
57
+ // ── per-record canonical digest (#63) — the record-reference id domain ────────────────────────────
58
+
59
+ const serializeCanonical = (v) => {
60
+ if (Array.isArray(v)) return `[${v.map(serializeCanonical).join(',')}]`;
61
+ if (v !== null && typeof v === 'object') {
62
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${serializeCanonical(v[k])}`).join(',')}}`;
63
+ }
64
+ return JSON.stringify(v);
65
+ };
66
+
67
+ // Canonical bytes of ONE record: recursively key-sorted JSON, NO trailing newline (the newline is
68
+ // store framing, not record identity). A parity test pins these bytes against core-evidence's
69
+ // canonicalKindSerialization on single-record fixtures.
70
+ export const flowCanonicalSerialization = (record) => serializeCanonical(record);
71
+
72
+ export const canonicalFlowDigest = (record) => createHash('sha256').update(flowCanonicalSerialization(record), 'utf8').digest('hex');
73
+
74
+ // ── Decision-7 derivation helpers (Plan 4) — pure digests over canonical bytes ────────────────────
75
+
76
+ // foldBatch keys the IMMUTABLE round identity projection: a round-ledger REVISION keeps
77
+ // {planId, cycle, stepId, round} (same digest — the budget never resets on supersession, #47),
78
+ // a NEW round moves it (fresh budget).
79
+ export const subsetFoldBatchDigest = ({ planId, cycle, stepId, round }) =>
80
+ createHash('sha256').update(flowCanonicalSerialization({ planId, cycle, stepId, round }), 'utf8').digest('hex');
81
+
82
+ // The derived subset's counting identity — declaring pregateExclude changes the ordered gate-id
83
+ // list, therefore the key, therefore the counting context (#47/#66).
84
+ export const subsetGateIdsDigest = (gateIds) =>
85
+ createHash('sha256').update(flowCanonicalSerialization(gateIds), 'utf8').digest('hex');
86
+
87
+ // ── the owner-scoped projection (Plan 4 Decision 2 / D10) — ONE pure helper, producer + consumer ──
88
+
89
+ // The hash domain is the OWNER-SCOPED projection, never the whole common store (#57): (a) every
90
+ // chain record whose owner is the committing worktree; (b) every planId-bearing global whose
91
+ // planId belongs to an owned chain; (c) every planId-less global (the down-mark family,
92
+ // degrade-justification, rerun-cause, bookkeeping-delta, maintainer-override — the rule is
93
+ // structural over every planId-less kind) whose tree identity (fingerprintAfter for transitions)
94
+ // is in {fingerprints appearing in owned-chain records} ∪ {the current tree fingerprint}. A
95
+ // foreign worktree's records fall outside (a)-(c) and never move the hash; a same-fingerprint
96
+ // foreign global is IN by (c) — same tree, same decision context. Raw store order is preserved:
97
+ // the projection hash is order-sensitive, so any in-projection append moves it.
98
+ export const ownerScopedFlowProjection = (records, { owner, currentFingerprint }) => {
99
+ const ownedChain = records.filter((r) => r.kind === CHAIN_KIND && r.owner === owner);
100
+ const ownedPlanIds = new Set(ownedChain.map((r) => r.planId));
101
+ const fingerprints = new Set(currentFingerprint == null ? [] : [currentFingerprint]);
102
+ for (const r of ownedChain) {
103
+ for (const field of ['fingerprint', 'fingerprintBefore', 'fingerprintAfter']) {
104
+ if (typeof r[field] === 'string') fingerprints.add(r[field]);
105
+ }
106
+ }
107
+ return records.filter((r) => {
108
+ if (r.kind === CHAIN_KIND) return r.owner === owner;
109
+ if (typeof r.planId === 'string') return ownedPlanIds.has(r.planId);
110
+ return fingerprints.has(flowTreeIdentity(r).fingerprint);
111
+ });
112
+ };
113
+
114
+ export const flowProjectionHash = (records, ctx) =>
115
+ createHash('sha256').update(ownerScopedFlowProjection(records, ctx).map(flowCanonicalSerialization).join('\n'), 'utf8').digest('hex');