@sabaiway/agent-workflow-kit 5.1.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/SKILL.md +13 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
- package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
- package/bridges/antigravity-cli-bridge/capability.json +19 -13
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +17 -7
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +156 -36
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +228 -4
- package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
- package/bridges/codex-cli-bridge/capability.json +10 -7
- package/bridges/codex-cli-bridge/references/driving-codex.md +7 -5
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +26 -12
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/flow-writer.md +37 -0
- package/references/modes/gates.md +4 -4
- package/references/modes/procedures.md +4 -2
- package/references/modes/receipt-deadline.md +16 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/set-flow.md +22 -0
- package/references/scripts/archive-decisions.mjs +340 -15
- package/references/scripts/archive-decisions.test.mjs +522 -2
- package/tools/cheap-agents.mjs +8 -2
- package/tools/commands.mjs +24 -2
- package/tools/commit-guard.mjs +44 -9
- package/tools/core-evidence.mjs +25 -22
- package/tools/detect-backends.mjs +33 -11
- package/tools/dispatch-record.mjs +926 -0
- package/tools/doc-parity.mjs +21 -6
- package/tools/flow-check.mjs +842 -0
- package/tools/flow-record.mjs +795 -0
- package/tools/flow-store-read.mjs +114 -0
- package/tools/flow-store.mjs +1178 -0
- package/tools/flow-writer.mjs +1265 -0
- package/tools/fs-read-nofollow.mjs +128 -0
- package/tools/gates-declaration.mjs +184 -0
- package/tools/gates-init.mjs +59 -17
- package/tools/orchestration-config.mjs +87 -10
- package/tools/orchestration-write.mjs +3 -3
- package/tools/plan-files.mjs +35 -0
- package/tools/procedures.mjs +75 -11
- package/tools/receipt-deadline.mjs +242 -0
- package/tools/recipes.mjs +21 -0
- package/tools/repo-lex.mjs +22 -0
- package/tools/review-state.mjs +240 -80
- package/tools/run-gates.mjs +361 -139
- package/tools/set-flow.mjs +465 -0
- package/tools/velocity-profile.mjs +8 -2
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
// flow-store.mjs — the flow-store IO (flow-orchestration, Phase 2): common-dir path resolution, the
|
|
2
|
+
// fail-closed reader, and the lock/CAS serialized append. No CLI, no side effects on import.
|
|
3
|
+
//
|
|
4
|
+
// The store pins to the git COMMON dir because flow records must be shared across worktrees
|
|
5
|
+
// (#49/#57), which the per-git-dir core store cannot do; appends are serialized by an exclusive-
|
|
6
|
+
// create lock file beside the store because the reusable atomic writer is last-writer-wins with no
|
|
7
|
+
// cross-process lock. Everything fails closed: bounded lock waits with named refusals per holder
|
|
8
|
+
// class, custody-checked release (only the inode the winning CAS fd proved is ever removed),
|
|
9
|
+
// fd-based no-follow reads, and a SEMANTIC append preflight on one captured snapshot (per-record
|
|
10
|
+
// validation, malformed-store refusal, replay refusal, chain-sequence and supersession legality) —
|
|
11
|
+
// an illegal record never lands.
|
|
12
|
+
//
|
|
13
|
+
// Phase 3 adds the mint primitives that need the tree: the adoption mint (frontmatter planId +
|
|
14
|
+
// plan content digest, #58), the canonical owning-worktree identity (#49), the generic reference
|
|
15
|
+
// validator + prior-terminal resolution in the append preflight (#63), and the bookkeeping-delta
|
|
16
|
+
// custody proof (masked revert-and-recompute, #60).
|
|
17
|
+
//
|
|
18
|
+
// Declared residuals no dependency-free core-Node mechanism can close: the pathname lstat→rename
|
|
19
|
+
// and reread→rename windows (no flock/fcntl, no inode-conditional unlink or rename) and bind-mount
|
|
20
|
+
// aliasing. The decideCheck arms, guard/gates wiring, and the arming + writer CLIs (set-flow,
|
|
21
|
+
// flow-writer) are LIVE (Plan 3 Phases 2–3); the remaining Plan-3 surface is the deadline runner +
|
|
22
|
+
// wrapper manifest lane (Phase 4). Records remain forgeable — a self-discipline mechanism in the
|
|
23
|
+
// git dir, not a security boundary.
|
|
24
|
+
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { readFileSync, writeFileSync, writeSync, readSync, rmSync, lstatSync, realpathSync, openSync, closeSync, fstatSync, renameSync, readlinkSync } from 'node:fs';
|
|
27
|
+
import { join, dirname, basename, resolve } from 'node:path';
|
|
28
|
+
import { hostname } from 'node:os';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import { writeContainedFileAtomic, lstatNoFollow } from './atomic-write.mjs';
|
|
31
|
+
import { parsePositiveIntKnob } from './changed-surface.mjs';
|
|
32
|
+
import { FLOW_SCHEMA_VERSION, CHAIN_KIND, validateFlowRecord, validateChainSequence, validateSupersessions, authoritativeFlowRecords, canonicalFlowDigest, flowRecordKey, subsetFoldBatchDigest, subsetGateIdsDigest, SUBSET_ATTEMPT_DIAGNOSIS_FROM } from './flow-record.mjs';
|
|
33
|
+
import { isNeverCommittableStat, isBinaryFile, lexicalRepoRelative, resolveBase, computeTreeFingerprint } from './core-evidence.mjs';
|
|
34
|
+
import { derivePregateSubsetIds, GATES_REL } from './gates-declaration.mjs';
|
|
35
|
+
import { CONFIG_REL } from './orchestration-config.mjs';
|
|
36
|
+
// The read half lives in flow-store-read.mjs (it OWNS no write API — read-only surfaces like the
|
|
37
|
+
// procedures advisor import it directly) and is RE-EXPORTED here — every existing consumer keeps
|
|
38
|
+
// its import site.
|
|
39
|
+
import {
|
|
40
|
+
FLOW_STORE_STOP, flowStoreStop, FLOW_STORE_BASENAME, FLOW_LOCK_SUFFIX, gitLine,
|
|
41
|
+
resolveFlowStorePath, resolveFlowLockPath, parseFlowStoreText, readFlowStore,
|
|
42
|
+
readRegularFileNoFollow, deriveFlowOwner, describeNonRegular,
|
|
43
|
+
} from './flow-store-read.mjs';
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
FLOW_STORE_STOP, FLOW_STORE_BASENAME, FLOW_LOCK_SUFFIX,
|
|
47
|
+
resolveFlowStorePath, resolveFlowLockPath, parseFlowStoreText, readFlowStore, deriveFlowOwner,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const stop = flowStoreStop;
|
|
51
|
+
|
|
52
|
+
// Wait bound + poll cadence; the env knobs keep hermetic tests off wall-clock.
|
|
53
|
+
export const FLOW_LOCK_WAIT_MS = 10_000;
|
|
54
|
+
export const FLOW_LOCK_POLL_MS = 100;
|
|
55
|
+
|
|
56
|
+
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
57
|
+
const gitBuf = (args, cwd) => {
|
|
58
|
+
const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
59
|
+
if (r.error || r.status !== 0) return null;
|
|
60
|
+
return r.stdout;
|
|
61
|
+
};
|
|
62
|
+
const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
63
|
+
|
|
64
|
+
// ── the lock/CAS ──────────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
// Sync sleep (the append is a sync flow end-to-end); injectable so a hermetic test can intercept it.
|
|
67
|
+
const sleepSyncMs = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
68
|
+
|
|
69
|
+
// Monotonic — a system clock stepped backwards must not stretch the wait bound.
|
|
70
|
+
const monotonicNowMs = () => performance.now();
|
|
71
|
+
|
|
72
|
+
// POSIX single-quoting for paths pasted into recovery commands — a raw interpolation would execute
|
|
73
|
+
// path bytes on paste.
|
|
74
|
+
const shellQuotePath = (p) => `'${p.replaceAll("'", "'\\''")}'`;
|
|
75
|
+
|
|
76
|
+
const foreignObjectStop = (noun, path, className, isDirectory) =>
|
|
77
|
+
stop(`the ${noun} ${path} is a ${className}, not a regular file — refusing to touch it. To recover: inspect it, then remove it by hand: ${isDirectory ? 'rmdir' : 'rm'} -- ${shellQuotePath(path)} — it is never removed silently (fail closed)`);
|
|
78
|
+
|
|
79
|
+
// A non-regular object at the store or lock path is never read (a FIFO read blocks forever) and
|
|
80
|
+
// never removed silently — an immediate named refusal. Returns the lstat result (null = absent).
|
|
81
|
+
const assertRegularOrAbsent = (path, noun, lstat) => {
|
|
82
|
+
const st = lstatNoFollow(path, lstat);
|
|
83
|
+
if (st && !st.isFile()) throw foreignObjectStop(noun, path, describeNonRegular(st), st.isDirectory());
|
|
84
|
+
return st;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Bounded positional comparison of a HELD fd against the snapshot: at most snapshot-length bytes
|
|
88
|
+
// plus ONE growth-probe byte (positional — the fd offset sits at EOF). Changed bytes, truncation,
|
|
89
|
+
// or growth report false.
|
|
90
|
+
const READ_CHUNK_BYTES = 65536;
|
|
91
|
+
const fdContentEquals = (fd, expected) => {
|
|
92
|
+
const buf = Buffer.alloc(READ_CHUNK_BYTES);
|
|
93
|
+
let position = 0;
|
|
94
|
+
while (position < expected.length) {
|
|
95
|
+
const want = Math.min(buf.length, expected.length - position);
|
|
96
|
+
const n = readSync(fd, buf, 0, want, position);
|
|
97
|
+
if (n === 0) return false; // truncated below the snapshot length
|
|
98
|
+
if (!buf.subarray(0, n).equals(expected.subarray(position, position + n))) return false;
|
|
99
|
+
position += n;
|
|
100
|
+
}
|
|
101
|
+
return readSync(fd, buf, 0, 1, position) === 0; // any byte here means the store GREW
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Trusted only with parsed, valid metadata; anything else is the crash/corruption lane — never
|
|
105
|
+
// probed, never stolen.
|
|
106
|
+
const isValidHolder = (holder) =>
|
|
107
|
+
holder !== null && typeof holder === 'object' && !Array.isArray(holder)
|
|
108
|
+
&& Number.isInteger(holder.pid) && holder.pid > 0
|
|
109
|
+
&& typeof holder.host === 'string' && holder.host.length > 0;
|
|
110
|
+
|
|
111
|
+
const describeHolder = (holder) => `pid ${holder.pid} (host ${holder.host}, started ${holder.startedAt ?? 'unknown'})`;
|
|
112
|
+
|
|
113
|
+
// ESRCH on a same-host signal-0 probe only; a foreign host is unprobeable — never treated as dead.
|
|
114
|
+
const isProvablyDead = (holder) => {
|
|
115
|
+
if (holder.host !== hostname()) return false;
|
|
116
|
+
try {
|
|
117
|
+
process.kill(holder.pid, 0);
|
|
118
|
+
return false;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return err && err.code === 'ESRCH';
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// The shared parser accepts any digit string — hundreds of digits parse to Infinity and would
|
|
125
|
+
// erase the wait bound; gated locally because the shared helper feeds the frozen core-evidence.
|
|
126
|
+
const parseLockKnob = (env, name, fallback) => {
|
|
127
|
+
const value = parsePositiveIntKnob(env, name, fallback, stop);
|
|
128
|
+
if (!Number.isSafeInteger(value)) {
|
|
129
|
+
throw stop(`${name} must be a positive safe integer — the provided value overflows (fail closed)`);
|
|
130
|
+
}
|
|
131
|
+
return value;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Containment + canonical pinning, once per append: a symlinked IMMEDIATE parent refuses by name;
|
|
135
|
+
// the ancestor chain is then realpath-rebased so every spelling funnels to ONE physical store+lock
|
|
136
|
+
// pair (refusing ancestor links would break legitimately symlinked prefixes like a distro /home).
|
|
137
|
+
// realpath ENOENT keeps the lexical path — a missing parent still refuses at lock creation.
|
|
138
|
+
const canonicalFlowWritePaths = (resolvedStorePath, lstat) => {
|
|
139
|
+
const parent = dirname(resolvedStorePath);
|
|
140
|
+
if (lstatNoFollow(parent, lstat)?.isSymbolicLink()) {
|
|
141
|
+
throw stop(`${parent} is a symlink — refusing to write the flow store through a symlinked parent (pre-mutation containment)`);
|
|
142
|
+
}
|
|
143
|
+
let canonicalParent;
|
|
144
|
+
try {
|
|
145
|
+
canonicalParent = realpathSync(parent);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (err && err.code === 'ENOENT') canonicalParent = parent;
|
|
148
|
+
else throw stop(`cannot canonicalize the flow-store parent dir ${parent} (${(err && err.code) || (err && err.message) || err}) — refusing to write through an unresolvable path (fail closed)`);
|
|
149
|
+
}
|
|
150
|
+
const storePath = join(canonicalParent, basename(resolvedStorePath));
|
|
151
|
+
const lockPath = resolveFlowLockPath(storePath);
|
|
152
|
+
assertRegularOrAbsent(storePath, 'flow store', lstat);
|
|
153
|
+
assertRegularOrAbsent(lockPath, 'flow-store lock', lstat);
|
|
154
|
+
return { storePath, lockPath };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Returns the OWNED canonical { storePath, lockPath, lockFd, lockIdentity }; throws BEFORE
|
|
158
|
+
// ownership on every refusal lane. The caller must reuse exactly these values end-to-end.
|
|
159
|
+
const acquireFlowLock = (resolvedStorePath, env, deps) => {
|
|
160
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
161
|
+
const openLock = deps.openLock ?? ((p) => openSync(p, 'wx'));
|
|
162
|
+
const sleep = deps.sleep ?? sleepSyncMs;
|
|
163
|
+
const now = deps.now ?? monotonicNowMs;
|
|
164
|
+
const waitBoundMs = parseLockKnob(env, 'AW_FLOW_LOCK_WAIT_MS', FLOW_LOCK_WAIT_MS);
|
|
165
|
+
const pollMs = parseLockKnob(env, 'AW_FLOW_LOCK_POLL_MS', FLOW_LOCK_POLL_MS);
|
|
166
|
+
const { storePath, lockPath } = canonicalFlowWritePaths(resolvedStorePath, lstat);
|
|
167
|
+
const holderBody = JSON.stringify({ pid: process.pid, host: hostname(), startedAt: new Date().toISOString() });
|
|
168
|
+
const deadline = now() + waitBoundMs;
|
|
169
|
+
// Every retry lane passes this gate — else lock churn extends the wait past the bound forever.
|
|
170
|
+
const refuseIfPastDeadline = (why) => {
|
|
171
|
+
if (now() >= deadline) {
|
|
172
|
+
throw stop(`the flow-store lock ${lockPath} could not be acquired within the ${waitBoundMs}ms wait (${why}) — retry, or raise AW_FLOW_LOCK_WAIT_MS`);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
for (;;) {
|
|
176
|
+
// CAS: exclusive-create ('wx' also refuses a symlink leaf); the winning fd stamps the holder
|
|
177
|
+
// and yields the lock's {dev, ino} — a pathname stat could already see a replacement.
|
|
178
|
+
let fd = null;
|
|
179
|
+
try {
|
|
180
|
+
fd = openLock(lockPath);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (!err || err.code !== 'EEXIST') {
|
|
183
|
+
throw stop(`cannot create the flow-store lock ${lockPath} (${(err && err.code) || (err && err.message) || err}) — the store's parent dir must exist and be writable`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (fd !== null) {
|
|
187
|
+
let won = false;
|
|
188
|
+
try {
|
|
189
|
+
writeSync(fd, holderBody);
|
|
190
|
+
const st = fstatSync(fd);
|
|
191
|
+
won = true;
|
|
192
|
+
// The fd stays open through the whole append — its inode cannot be recycled under us.
|
|
193
|
+
return { storePath, lockPath, lockFd: fd, lockIdentity: { dev: st.dev, ino: st.ino } };
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// Without the fd-proven identity, removing the pathname would be an unproven-ownership rm.
|
|
196
|
+
throw stop(`cannot stamp or verify the just-created flow-store lock ${lockPath} (${(err && err.code) || (err && err.message) || err}) — the lock file is left in place; inspect it, then remove it by hand: rm -- ${shellQuotePath(lockPath)} (fail closed)`);
|
|
197
|
+
} finally {
|
|
198
|
+
if (!won) { try { closeSync(fd); } catch { /* the stamp failure above already decided the lane */ } }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// The holder read HOLDS its fd (keepFd) until the lane decides: while the fd is open the
|
|
202
|
+
// inode cannot be recycled, so the DEAD re-verify below can trust an identity match only
|
|
203
|
+
// together with the held inode still being linked (FLOW-LOCK-HOLDER-FD-RECHECK). The lane
|
|
204
|
+
// verdict is computed FIRST (its error captured), the held fd then closes unconditionally,
|
|
205
|
+
// and a close failure is a typed STOP — thrown alone, or preserved on the primary error as
|
|
206
|
+
// holderCloseFailure (the releaseFlowLock never-mask discipline; P28).
|
|
207
|
+
const holderIo = deps.holderIo ?? {};
|
|
208
|
+
// The read itself is wrapped: on the early error/foreign lanes the reader closes its own fd
|
|
209
|
+
// in a finally, and a close throw there would otherwise escape as a RAW error outside the
|
|
210
|
+
// typed-STOP guarantee.
|
|
211
|
+
let holderRead;
|
|
212
|
+
try {
|
|
213
|
+
holderRead = readRegularFileNoFollow(lockPath, { ...holderIo, keepFd: true });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
throw stop(`cannot read the flow-store lock holder (${(err && err.code) || (err && err.message) || err}) — the read/close custody failed (fail closed)`);
|
|
216
|
+
}
|
|
217
|
+
if (holderRead.closeFailure !== undefined) {
|
|
218
|
+
throw stop(`cannot read the flow-store lock holder (${holderRead.closeFailure}) — the read/close custody failed (fail closed)`);
|
|
219
|
+
}
|
|
220
|
+
const holderFd = holderRead.outcome === 'ok' ? holderRead.fd : null;
|
|
221
|
+
let verdict = null;
|
|
222
|
+
let primary = null;
|
|
223
|
+
try {
|
|
224
|
+
verdict = (() => {
|
|
225
|
+
if (holderRead.outcome === 'absent') {
|
|
226
|
+
refuseIfPastDeadline('the lock kept appearing and vanishing (churn)');
|
|
227
|
+
return { retry: true }; // released between attempts — retry the CAS at once
|
|
228
|
+
}
|
|
229
|
+
if (holderRead.outcome === 'foreign') throw foreignObjectStop('flow-store lock', lockPath, holderRead.className, holderRead.isDirectory);
|
|
230
|
+
let holder = null;
|
|
231
|
+
if (holderRead.outcome === 'ok') {
|
|
232
|
+
try {
|
|
233
|
+
holder = JSON.parse(holderRead.content);
|
|
234
|
+
} catch { holder = null; }
|
|
235
|
+
}
|
|
236
|
+
const validHolder = isValidHolder(holder);
|
|
237
|
+
if (validHolder && isProvablyDead(holder)) {
|
|
238
|
+
// The DEAD verdict binds to the inode the holder was read from — a lock released or
|
|
239
|
+
// replaced since then means the observed holder is gone: retry, never refuse a
|
|
240
|
+
// vanished lock.
|
|
241
|
+
let lockNow = null;
|
|
242
|
+
try {
|
|
243
|
+
lockNow = lstatNoFollow(lockPath, lstat); // null ONLY on a true ENOENT
|
|
244
|
+
} catch (err) {
|
|
245
|
+
throw stop(`cannot re-verify the flow-store lock identity before the DEAD refusal (${(err && err.code) || (err && err.message) || err}) — refusing to guess (fail closed)`);
|
|
246
|
+
}
|
|
247
|
+
if (lockNow == null || lockNow.dev !== holderRead.dev || lockNow.ino !== holderRead.ino) {
|
|
248
|
+
refuseIfPastDeadline('the observed dead holder was released (churn)');
|
|
249
|
+
return { retry: true };
|
|
250
|
+
}
|
|
251
|
+
// A pathname identity match alone can be a recycled lie (release + re-create landing
|
|
252
|
+
// the same {dev, ino}); the held fd settles it — an unlinked held inode (nlink 0)
|
|
253
|
+
// proves the observed holder's lock is GONE, whatever the pathname claims.
|
|
254
|
+
let heldNow;
|
|
255
|
+
try {
|
|
256
|
+
heldNow = (holderIo.fstat ?? fstatSync)(holderFd);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
throw stop(`cannot re-verify the flow-store lock through its held descriptor (${(err && err.code) || (err && err.message) || err}) — refusing to guess (fail closed)`);
|
|
259
|
+
}
|
|
260
|
+
if (heldNow.nlink === 0) {
|
|
261
|
+
refuseIfPastDeadline('the observed dead holder was released (churn)');
|
|
262
|
+
return { retry: true };
|
|
263
|
+
}
|
|
264
|
+
throw stop(`the flow-store lock ${lockPath} is held by a DEAD process (${describeHolder(holder)}) — a crashed appender left it behind. To recover: inspect it, then remove it by hand: rm -- ${shellQuotePath(lockPath)} — it is never stolen silently (a steal could tear a live append; fail closed)`);
|
|
265
|
+
}
|
|
266
|
+
// ONE observation drives the deadline check AND the sleep cap — no overshoot by a full poll.
|
|
267
|
+
const observedAt = now();
|
|
268
|
+
if (observedAt >= deadline) {
|
|
269
|
+
if (!validHolder) {
|
|
270
|
+
throw stop(`the flow-store lock ${lockPath} carries an UNREADABLE or malformed holder after the full ${waitBoundMs}ms wait — a crashed appender may have died before writing its holder line, or the file is corrupted. To recover: inspect it, then remove it by hand: rm -- ${shellQuotePath(lockPath)} — it is never stolen silently (fail closed)`);
|
|
271
|
+
}
|
|
272
|
+
if (holder.host !== hostname()) {
|
|
273
|
+
throw stop(`the flow-store lock ${lockPath} is still held by pid ${holder.pid} on host ${holder.host} (liveness unprobeable from ${hostname()}) after the full ${waitBoundMs}ms wait — retry after that holder finishes, or raise AW_FLOW_LOCK_WAIT_MS`);
|
|
274
|
+
}
|
|
275
|
+
throw stop(`the flow-store lock ${lockPath} is still held by ${describeHolder(holder)} after the full ${waitBoundMs}ms wait — retry after the holder finishes, or raise AW_FLOW_LOCK_WAIT_MS`);
|
|
276
|
+
}
|
|
277
|
+
return { sleepMs: Math.min(pollMs, deadline - observedAt) };
|
|
278
|
+
})();
|
|
279
|
+
} catch (err) {
|
|
280
|
+
primary = err;
|
|
281
|
+
}
|
|
282
|
+
if (holderFd !== null) {
|
|
283
|
+
try {
|
|
284
|
+
(holderIo.close ?? closeSync)(holderFd);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
const closeStop = stop(`cannot close the held flow-store holder descriptor (${(err && err.code) || (err && err.message) || err}) — the fd-custody guarantee is violated (fail closed)`);
|
|
287
|
+
if (primary == null) primary = closeStop;
|
|
288
|
+
else primary.holderCloseFailure = closeStop.message;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (primary != null) throw primary;
|
|
292
|
+
if (verdict.retry) continue;
|
|
293
|
+
sleep(verdict.sleepMs);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// ── the ONE append (validated, semantic-preflighted, lock-serialized, atomic) ─────────────────────
|
|
298
|
+
|
|
299
|
+
// ONE custody-checked release: only the inode the winning fd proved is ever removed (the fd is
|
|
300
|
+
// still open, so a pathname {dev, ino} match is proof of the same file); absent or replaced =
|
|
301
|
+
// a mutual-exclusion violation, the foreign lock stays. Closes the fd on EVERY outcome without
|
|
302
|
+
// losing a close failure. Returns a typed STOP or null, never throws — the caller sequences it
|
|
303
|
+
// after the body's own error so neither masks the other.
|
|
304
|
+
const releaseFlowLock = (lockPath, lockFd, lockIdentity, deps) => {
|
|
305
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
306
|
+
const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
|
|
307
|
+
const close = deps.close ?? closeSync;
|
|
308
|
+
let issue = null;
|
|
309
|
+
let st = null;
|
|
310
|
+
try {
|
|
311
|
+
st = lstatNoFollow(lockPath, lstat); // null ONLY on a true ENOENT
|
|
312
|
+
} catch (err) {
|
|
313
|
+
issue = stop(`cannot verify the flow-store lock before release (${(err && err.code) || (err && err.message) || err}) — the lock is left in place; inspect ${lockPath} (fail closed)`);
|
|
314
|
+
}
|
|
315
|
+
if (issue == null) {
|
|
316
|
+
if (st == null || st.dev !== lockIdentity.dev || st.ino !== lockIdentity.ino) {
|
|
317
|
+
issue = stop(`the flow-store lock ${lockPath} was removed or replaced under this append — mutual exclusion was violated and another appender may have run concurrently; the current lock (if any) is left untouched; inspect the store and the lock (fail closed)`);
|
|
318
|
+
} else {
|
|
319
|
+
try {
|
|
320
|
+
rm(lockPath);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
issue = stop(`cannot remove the flow-store lock at release (${(err && err.code) || (err && err.message) || err}) — inspect ${lockPath} (fail closed)`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
close(lockFd);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
const closeStop = stop(`cannot close the flow-store lock descriptor at release (${(err && err.code) || (err && err.message) || err})`);
|
|
330
|
+
if (issue == null) issue = closeStop;
|
|
331
|
+
else issue.closeFailure = closeStop.message;
|
|
332
|
+
}
|
|
333
|
+
return issue;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// The store path is always RESOLVED (cwd/env), never caller-supplied — a raw path param would
|
|
337
|
+
// bypass the absolute-normalization door the AW_FLOW_STORE seam enforces. Read, write, and unlock
|
|
338
|
+
// all use the CANONICAL pair acquire returned — nothing is re-derived mid-append.
|
|
339
|
+
export const appendFlowRecord = ({ cwd = process.cwd(), record, env = process.env, deps = {} } = {}) => {
|
|
340
|
+
const { line, snapshot } = captureRecordSnapshot(record);
|
|
341
|
+
// Round-9 fold: subset-attempt records are minted ONLY by the locked factory — foldBatch,
|
|
342
|
+
// subsetDigest, and attemptIndex are DERIVED inside its critical section, and a hand-built
|
|
343
|
+
// record could forge a fresh counting context and bypass the hard-stop budget.
|
|
344
|
+
if (snapshot.kind === 'subset-attempt') {
|
|
345
|
+
throw stop('subset-attempt records are minted ONLY by the locked append factory (appendSubsetAttempt) — a hand-built record could forge a fresh counting context and bypass the hard-stop budget (fail closed)');
|
|
346
|
+
}
|
|
347
|
+
return appendResolvedFlowRecord({ cwd, env, deps, makeRecord: () => ({ line, snapshot }) });
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// appendFlowRecordWithPreflight — the generic lane plus a caller `preflight(records)` hook that
|
|
351
|
+
// runs INSIDE the critical section on the locked store snapshot (Plan 4 Phase 3 / round-1 fold
|
|
352
|
+
// F6): a writer's lock-free cap/completeness walk is advisory — the locked snapshot decides, so
|
|
353
|
+
// a concurrent append can never slip a stale terminal (or a stranding round) through. The hook
|
|
354
|
+
// receives a DEEP-FROZEN CLONE (round-1 fold M5): a buggy preflight throws on any mutation
|
|
355
|
+
// attempt and can never skew the bytes the semantic validation and the write see. A throwing
|
|
356
|
+
// preflight refuses the append with nothing written. The subset-attempt factory-only rule holds
|
|
357
|
+
// on this lane too.
|
|
358
|
+
export const appendFlowRecordWithPreflight = ({ cwd = process.cwd(), record, env = process.env, deps = {}, preflight = null } = {}) => {
|
|
359
|
+
const { line, snapshot } = captureRecordSnapshot(record);
|
|
360
|
+
if (snapshot.kind === 'subset-attempt') {
|
|
361
|
+
throw stop('subset-attempt records are minted ONLY by the locked append factory (appendSubsetAttempt) — a hand-built record could forge a fresh counting context and bypass the hard-stop budget (fail closed)');
|
|
362
|
+
}
|
|
363
|
+
return appendResolvedFlowRecord({ cwd, env, deps, makeRecord: (records) => {
|
|
364
|
+
if (preflight != null) preflight(deepFreezeClone(records));
|
|
365
|
+
return { line, snapshot };
|
|
366
|
+
} });
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const deepFreezeClone = (value) => {
|
|
370
|
+
const freeze = (v) => {
|
|
371
|
+
if (v !== null && typeof v === 'object') {
|
|
372
|
+
Object.values(v).forEach(freeze);
|
|
373
|
+
Object.freeze(v);
|
|
374
|
+
}
|
|
375
|
+
return v;
|
|
376
|
+
};
|
|
377
|
+
return freeze(structuredClone(value));
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// ONE serialization captured up front; validation and every preflight walk run on its PARSED
|
|
381
|
+
// snapshot — a toJSON or getter can never make the written line differ from what validated.
|
|
382
|
+
const captureRecordSnapshot = (record) => {
|
|
383
|
+
let line;
|
|
384
|
+
let snapshot;
|
|
385
|
+
try {
|
|
386
|
+
line = JSON.stringify(record);
|
|
387
|
+
snapshot = JSON.parse(line);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
throw stop(`cannot capture a canonical serialization of the record (${(err && err.message) || err}) — refusing to write (fail closed)`);
|
|
390
|
+
}
|
|
391
|
+
const v = validateFlowRecord(snapshot);
|
|
392
|
+
if (!v.ok) throw stop(`refusing to write a malformed flow record: ${v.reason}`);
|
|
393
|
+
return { line, snapshot };
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// The lock-serialized core both append lanes share: resolve → acquire → makeRecord (UNDER the
|
|
397
|
+
// lock, over the captured store snapshot) → semantic preflights → atomic write → custody release.
|
|
398
|
+
// The Decision-7 factory lane COMPUTES its record inside the critical section — attemptIndex and
|
|
399
|
+
// the hard-stop state cannot be derived lock-free — so makeRecord runs under the lock by contract.
|
|
400
|
+
const appendResolvedFlowRecord = ({ cwd, env, deps, makeRecord }) => {
|
|
401
|
+
const resolved = resolveFlowStorePath(cwd, env);
|
|
402
|
+
if (resolved == null) {
|
|
403
|
+
throw stop('not inside a git work tree (and no AW_FLOW_STORE override) — there is no flow store to append to');
|
|
404
|
+
}
|
|
405
|
+
const { storePath, lockPath, lockFd, lockIdentity } = acquireFlowLock(resolved, env, deps);
|
|
406
|
+
const body = appendUnderLock({ storePath, makeRecord, deps });
|
|
407
|
+
const releaseIssue = releaseFlowLock(lockPath, lockFd, lockIdentity, deps);
|
|
408
|
+
if (body.err) {
|
|
409
|
+
if (releaseIssue) {
|
|
410
|
+
body.err.releaseViolation = releaseIssue.message;
|
|
411
|
+
if (releaseIssue.closeFailure) body.err.releaseCloseFailure = releaseIssue.closeFailure;
|
|
412
|
+
}
|
|
413
|
+
throw body.err;
|
|
414
|
+
}
|
|
415
|
+
if (releaseIssue) throw releaseIssue;
|
|
416
|
+
return body.value;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// Captured-result shape ({ value } | { err }) — never throws past the caller, so release always
|
|
420
|
+
// runs. The snapshot fd is held until after the final rename and closed on every exit lane.
|
|
421
|
+
const appendUnderLock = ({ storePath, makeRecord, deps }) => {
|
|
422
|
+
let snapshotFd = null;
|
|
423
|
+
try {
|
|
424
|
+
const storeRead = readRegularFileNoFollow(storePath, { keepFd: true });
|
|
425
|
+
if (storeRead.outcome === 'ok') snapshotFd = storeRead.fd;
|
|
426
|
+
if (storeRead.outcome === 'foreign') throw foreignObjectStop('flow store', storePath, storeRead.className, storeRead.isDirectory);
|
|
427
|
+
if (storeRead.outcome === 'error') throw stop(`cannot read the flow store before appending (${storeRead.code}) — refusing to overwrite it (fail closed)`);
|
|
428
|
+
// A second hard-link path would derive its OWN lock and the two appends would race one inode.
|
|
429
|
+
if (storeRead.outcome === 'ok' && storeRead.nlink !== 1) {
|
|
430
|
+
throw stop(`the flow store ${storePath} has ${storeRead.nlink} hard links — two path-derived locks would race one inode; remove the extra links and retry (fail closed)`);
|
|
431
|
+
}
|
|
432
|
+
const existing = storeRead.outcome === 'absent' ? '' : storeRead.content;
|
|
433
|
+
const parsed = parseFlowStoreText(existing);
|
|
434
|
+
if (parsed.malformed > 0) {
|
|
435
|
+
throw stop(`refusing to append to a flow store carrying ${parsed.malformed} malformed line(s) (${parsed.malformedReasons[0]}) — inspect ${storePath}; nothing was written (fail closed)`);
|
|
436
|
+
}
|
|
437
|
+
const { line, snapshot } = makeRecord(parsed.records);
|
|
438
|
+
if (existing.split('\n').some((l) => l === line)) {
|
|
439
|
+
throw stop('refusing a byte-identical replayed line (duplicate) — a genuine new record carries new content or timestamp; nothing was written');
|
|
440
|
+
}
|
|
441
|
+
if (snapshot.kind === CHAIN_KIND) {
|
|
442
|
+
const chain = parsed.records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
|
|
443
|
+
const existingSeq = validateChainSequence(chain);
|
|
444
|
+
if (!existingSeq.ok) {
|
|
445
|
+
throw stop(`refusing to append to a flow store whose existing chain for plan "${snapshot.planId}" is already illegal (${existingSeq.reason}) — inspect ${storePath}; nothing was written (fail closed)`);
|
|
446
|
+
}
|
|
447
|
+
const candidateSeq = validateChainSequence([...chain, snapshot]);
|
|
448
|
+
if (!candidateSeq.ok) {
|
|
449
|
+
throw stop(`refusing an illegal chain record: ${candidateSeq.reason} — the append-only store never absorbs a record that permanently reddens the checker; nothing was written`);
|
|
450
|
+
}
|
|
451
|
+
// Reference RESOLUTION (#63) on top of the structural half above: a step-OPENING round must
|
|
452
|
+
// digest-reference the chain's prior terminal; a round REVISION re-states its reference
|
|
453
|
+
// byte-bound (validateRoundRevision), so it is never re-classified against a moved terminal.
|
|
454
|
+
if (snapshot.purpose === 'round' && snapshot.opensFrom !== null && walkChainState(chain).mode === 'boundary') {
|
|
455
|
+
const ref = validateOpenerReference(parsed.records, snapshot);
|
|
456
|
+
if (!ref.ok) throw stop(`refusing a step-opening round: ${ref.reason} — nothing was written`);
|
|
457
|
+
}
|
|
458
|
+
if (snapshot.purpose === 'refresh') {
|
|
459
|
+
if (resolveRecordReference(parsed.records, snapshot.refreshedRecord) === undefined) {
|
|
460
|
+
throw stop(`refusing a refresh whose refreshedRecord does not match the store (no record digests to ${snapshot.refreshedRecord.slice(0, 12)}…) — a re-attestation binds an existing record; nothing was written`);
|
|
461
|
+
}
|
|
462
|
+
if (!isAuthoritativeReferenceTarget(parsed.records, snapshot.refreshedRecord)) {
|
|
463
|
+
throw stop('refusing a refresh whose refreshedRecord targets a superseded record — a re-attestation binds the authoritative latest record of its key; nothing was written');
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// The closure rule runs UNDER the lock on the captured snapshot — a writer's lock-free
|
|
468
|
+
// usability pre-check can race a concurrent up/clear, and a justification minted after its
|
|
469
|
+
// mark closed can never satisfy the decide layer (#25), so the store refuses to strand it.
|
|
470
|
+
if (snapshot.kind === 'degrade-justification') {
|
|
471
|
+
const closed = parsed.records.some((r) => (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') && r.target === snapshot.downMark);
|
|
472
|
+
if (closed) {
|
|
473
|
+
throw stop('refusing a degrade-justification whose down-mark is already closed by up/clear — minted-after-close can never satisfy (#25); nothing was written');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// The same P3-26 discipline for the consult-attestation (Phase-4): the writer derives
|
|
477
|
+
// {cycle, stepId, round} lock-free, so a concurrent converged/park/complete can close or move
|
|
478
|
+
// the step first — under the lock the named plan's chain must be LEGAL and hold an OPEN step
|
|
479
|
+
// (in-step, not parked, not completed) whose {cycle, stepId, round} EQUALS the record's; a
|
|
480
|
+
// stale consult context can never satisfy the decide layer, so the store refuses to strand it.
|
|
481
|
+
if (snapshot.kind === 'consult-attestation') {
|
|
482
|
+
const chain = parsed.records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
|
|
483
|
+
const seq = chain.length === 0 ? { ok: false, reason: 'no chain exists for that plan' } : validateChainSequence(chain);
|
|
484
|
+
if (!seq.ok) {
|
|
485
|
+
throw stop(`refusing a consult-attestation: the plan "${snapshot.planId}" chain is not a legal open carrier under the lock (${seq.reason}); nothing was written`);
|
|
486
|
+
}
|
|
487
|
+
const state = walkChainState(chain);
|
|
488
|
+
const open = state.mode === 'in-step' && !state.parked && !state.completed;
|
|
489
|
+
if (!open || state.stepId !== snapshot.stepId || state.cycle !== snapshot.cycle || state.round !== snapshot.round) {
|
|
490
|
+
const shown = !open
|
|
491
|
+
? (state.completed ? 'the plan is completed' : state.parked ? 'the plan is parked' : 'no step is open')
|
|
492
|
+
: `the open step is "${state.stepId}" (cycle ${state.cycle}, round ${state.round})`;
|
|
493
|
+
throw stop(`refusing a consult-attestation whose {cycle, stepId, round} does not match the OPEN step under the lock — ${shown}; a consult binds the open step's round, and a stale context can never satisfy; nothing was written`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// The Decision-7/8 counting-context gate runs UNDER the lock for BOTH append lanes (the
|
|
497
|
+
// factory computes a passing record; a hand-built one must satisfy the same rules).
|
|
498
|
+
if (snapshot.kind === 'subset-attempt') {
|
|
499
|
+
const gate = subsetAttemptGate(parsed.records, snapshot);
|
|
500
|
+
if (!gate.ok) throw stop(`refusing a subset-attempt: ${gate.reason} — nothing was written`);
|
|
501
|
+
}
|
|
502
|
+
const existingSup = validateSupersessions(parsed.records);
|
|
503
|
+
if (!existingSup.ok) {
|
|
504
|
+
throw stop(`refusing to append to a flow store whose existing records already violate supersession legality (${existingSup.reason}) — inspect ${storePath}; nothing was written (fail closed)`);
|
|
505
|
+
}
|
|
506
|
+
const candidateSup = validateSupersessions([...parsed.records, snapshot]);
|
|
507
|
+
if (!candidateSup.ok) {
|
|
508
|
+
throw stop(`refusing an illegal supersession: ${candidateSup.reason} — the append-only store never absorbs a record that permanently reddens the checker; nothing was written`);
|
|
509
|
+
}
|
|
510
|
+
const prefix = existing === '' ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
|
511
|
+
// The final rename is bound to the SNAPSHOT: (a) the held fd is re-read and byte-compared
|
|
512
|
+
// (a same-inode in-place mutation refuses instead of being clobbered with stale bytes), then
|
|
513
|
+
// (b) the leaf must still show the snapshot inode — or still-absent for a fresh store —
|
|
514
|
+
// immediately before the rename. Rides the frozen writer's deps.rename seam.
|
|
515
|
+
const renameBase = deps.rename ?? renameSync;
|
|
516
|
+
const guardedRename = (from, to) => {
|
|
517
|
+
if (to === storePath) {
|
|
518
|
+
if (storeRead.outcome === 'ok') {
|
|
519
|
+
let same;
|
|
520
|
+
try {
|
|
521
|
+
same = fdContentEquals(snapshotFd, storeRead.bytes);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
throw stop(`cannot re-read the flow store snapshot before the final rename (${(err && err.code) || (err && err.message) || err}) — nothing was written (fail closed)`);
|
|
524
|
+
}
|
|
525
|
+
if (!same) {
|
|
526
|
+
throw stop(`the flow store ${storePath} content changed under the lock (same-inode in-place mutation) — refusing the final rename; nothing was written (fail closed)`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
let leaf = null;
|
|
530
|
+
try {
|
|
531
|
+
leaf = lstatNoFollow(to, deps.lstat ?? lstatSync);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
throw stop(`cannot verify the flow store leaf before the final rename (${(err && err.code) || (err && err.message) || err}) — nothing was written (fail closed)`);
|
|
534
|
+
}
|
|
535
|
+
const identityHeld = storeRead.outcome === 'absent'
|
|
536
|
+
? leaf == null
|
|
537
|
+
: leaf != null && leaf.isFile() && leaf.dev === storeRead.dev && leaf.ino === storeRead.ino;
|
|
538
|
+
if (!identityHeld) {
|
|
539
|
+
throw stop(`the flow store ${storePath} changed identity under the lock (concurrent or foreign mutation) — refusing the final rename; nothing was written (fail closed)`);
|
|
540
|
+
}
|
|
541
|
+
if (leaf != null && leaf.nlink !== 1) {
|
|
542
|
+
throw stop(`the flow store ${storePath} has ${leaf.nlink} hard links — two path-derived locks would race one inode; remove the extra links and retry (fail closed)`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return renameBase(from, to);
|
|
546
|
+
};
|
|
547
|
+
writeContainedFileAtomic(dirname(storePath), storePath, `${prefix}${line}\n`, { ...deps, rename: guardedRename }, { stop, label: storePath });
|
|
548
|
+
if (snapshotFd !== null) {
|
|
549
|
+
const fd = snapshotFd;
|
|
550
|
+
snapshotFd = null;
|
|
551
|
+
closeSync(fd); // a success-lane close failure surfaces as the append's own error
|
|
552
|
+
}
|
|
553
|
+
return { value: { writtenPath: storePath, record: snapshot } };
|
|
554
|
+
} catch (err) {
|
|
555
|
+
return { err };
|
|
556
|
+
} finally {
|
|
557
|
+
if (snapshotFd !== null) { try { closeSync(snapshotFd); } catch { /* the failure above stays primary */ } }
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// ── the Decision-7/8 subset-attempt lane (Plan 4) — counting-context gate + locked factory ────────
|
|
562
|
+
|
|
563
|
+
// The waste bound is the CAP, not the prose (Decision 8): a counting context allows at most
|
|
564
|
+
// THREE red attempts (two blind + one diagnosed) on its own; past that, no diagnosis reopens it —
|
|
565
|
+
// only a recorded FRESH-EYES consult verdict does, one further attempt per consult.
|
|
566
|
+
export const SUBSET_ATTEMPT_MAX_REDS = 3;
|
|
567
|
+
|
|
568
|
+
// Past the SECOND red every further attempt at the key rides a recorded diagnosis — the
|
|
569
|
+
// obligation keys on REDS, never on the attempt index (a green history stays blind-legal).
|
|
570
|
+
export const SUBSET_ATTEMPT_DIAGNOSIS_REDS = 2;
|
|
571
|
+
|
|
572
|
+
// subsetAttemptState(records, probe) → { attempts, nextIndex, reds, credits, exhausted } — the
|
|
573
|
+
// ONE Decision-7/8 budget walk both consumers share (the locked gate below re-runs it under the
|
|
574
|
+
// lock; run-gates' pre-gate check reads it lock-free). The exhaustion ladder is PERMIT-based
|
|
575
|
+
// and foldBatch-GLOBAL (round-3 disposition): red counts stay per key, but permits and their
|
|
576
|
+
// consumption span EVERY subsetDigest of the round context — one consult verdict is exactly
|
|
577
|
+
// ONE further attempt across the whole foldBatch, whichever subset spends it. Consult identity
|
|
578
|
+
// {backend, nonce} is tracked STORE-WIDE before any relevance filtering, so a replay from
|
|
579
|
+
// another round (or any seen identity — pre-exhaustion or spent) never credits; a credit is
|
|
580
|
+
// granted only for a NEW identity whose {planId, cycle, stepId, round} digests to this
|
|
581
|
+
// foldBatch while SOME key of the foldBatch is base-exhausted at that point in raw order.
|
|
582
|
+
// EVERY attempt recorded past its own key's base budget consumes one credit, whatever its
|
|
583
|
+
// status; a tampered store that drove credits negative stays exhausted (fail closed).
|
|
584
|
+
export const subsetAttemptState = (records, probe) => {
|
|
585
|
+
const key = flowRecordKey({ kind: 'subset-attempt', ...probe });
|
|
586
|
+
const attempts = [];
|
|
587
|
+
const seenConsults = new Set();
|
|
588
|
+
const redsByKey = new Map();
|
|
589
|
+
let credits = 0;
|
|
590
|
+
const someKeyExhausted = () => [...redsByKey.values()].some((n) => n >= SUBSET_ATTEMPT_MAX_REDS);
|
|
591
|
+
for (const r of records) {
|
|
592
|
+
if (r.kind === 'subset-attempt' && r.foldBatch === probe.foldBatch) {
|
|
593
|
+
const rKey = flowRecordKey(r);
|
|
594
|
+
const priorReds = redsByKey.get(rKey) ?? 0;
|
|
595
|
+
if (priorReds >= SUBSET_ATTEMPT_MAX_REDS) credits -= 1;
|
|
596
|
+
if (r.status === 'red') redsByKey.set(rKey, priorReds + 1);
|
|
597
|
+
if (rKey === key) attempts.push(r);
|
|
598
|
+
} else if (r.kind === 'consult-attestation') {
|
|
599
|
+
const identity = JSON.stringify([r.backend, r.nonce]);
|
|
600
|
+
const relevant = subsetFoldBatchDigest({ planId: r.planId, cycle: r.cycle, stepId: r.stepId, round: r.round }) === probe.foldBatch;
|
|
601
|
+
if (relevant && !seenConsults.has(identity) && someKeyExhausted()) credits += 1;
|
|
602
|
+
seenConsults.add(identity);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const reds = redsByKey.get(key) ?? 0;
|
|
606
|
+
return {
|
|
607
|
+
attempts,
|
|
608
|
+
nextIndex: attempts.reduce((m, r) => Math.max(m, r.attemptIndex), 0) + 1,
|
|
609
|
+
reds,
|
|
610
|
+
credits,
|
|
611
|
+
exhausted: reds >= SUBSET_ATTEMPT_MAX_REDS && credits <= 0,
|
|
612
|
+
};
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
export const subsetExhaustionRemedy = 'the fresh-eyes lane reopens it (Decision 8 — never a human wait-state): dispatch a MANDATORY grounded bridge consult (a different model) carrying the full attempt/diagnosis trail; its recorded consult-attestation at this round context reopens exactly ONE further diagnosed attempt. Otherwise park the stuck work with its trail and switch to independent work; a fresh context opens with the next round (new foldBatch) or a declared pregateExclude change (new subsetDigest)';
|
|
616
|
+
|
|
617
|
+
// The under-lock rules the factory does NOT already enforce itself: the exhaustion ladder and
|
|
618
|
+
// the byte-distinct diagnosis (blind thrashing refuses; a NEW hypothesis proceeds). The
|
|
619
|
+
// monotonic index and the reds-based diagnosis REQUIREMENT live in the factory alone — it is
|
|
620
|
+
// the ONLY entry for this kind (the generic lane refuses it by name, round-9 fold), computes
|
|
621
|
+
// the index from the SAME locked snapshot this gate sees, and throws its own named stops first.
|
|
622
|
+
const subsetAttemptGate = (records, snapshot) => {
|
|
623
|
+
const { attempts, reds, exhausted } = subsetAttemptState(records, snapshot);
|
|
624
|
+
if (exhausted) {
|
|
625
|
+
return { ok: false, reason: `this counting context already holds ${reds} red attempts — EXHAUSTED (two blind + one diagnosed, Decision 8) and no diagnosis reopens it; ${subsetExhaustionRemedy}` };
|
|
626
|
+
}
|
|
627
|
+
const prior = attempts.find((r) => r.attemptIndex === snapshot.attemptIndex - 1);
|
|
628
|
+
if (typeof snapshot.diagnosis === 'string' && prior != null && prior.diagnosis === snapshot.diagnosis) {
|
|
629
|
+
return { ok: false, reason: "the diagnosis is byte-identical to the prior attempt's — a diagnosed continuation states a NEW hypothesis (Decision 8); blind thrashing refuses" };
|
|
630
|
+
}
|
|
631
|
+
return { ok: true };
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// ── the Decision-7 subset-run serializer (round-6 fold) ──────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
// --pre-review's WHOLE armed cycle (budget preflight → gates → append) holds this lock: without
|
|
637
|
+
// it a parallel run executes gates whose red can no longer be recorded once the winner lands,
|
|
638
|
+
// and an unrecorded red undercounts the budget ("EVERY subset-run red counts"). A SEPARATE lock
|
|
639
|
+
// file beside the store — never the store lock itself, so appends from other lanes never block
|
|
640
|
+
// behind a minutes-long gate run — riding the same CAS/fd-custody/holder-liveness discipline: a
|
|
641
|
+
// crashed holder surfaces as the named DEAD refusal with its rm recovery; a live holder is a
|
|
642
|
+
// bounded loud wait (the queued run then re-reads the budget and re-decides).
|
|
643
|
+
export const SUBSET_RUN_LOCK_INFIX = '.subset-run';
|
|
644
|
+
|
|
645
|
+
export const acquireSubsetRunLock = ({ cwd = process.cwd(), env = process.env, deps = {} } = {}) => {
|
|
646
|
+
const resolved = resolveFlowStorePath(cwd, env);
|
|
647
|
+
if (resolved == null) {
|
|
648
|
+
throw stop('not inside a git work tree (and no AW_FLOW_STORE override) — there is no flow store to serialize a subset run against');
|
|
649
|
+
}
|
|
650
|
+
const { lockPath, lockFd, lockIdentity } = acquireFlowLock(`${resolved}${SUBSET_RUN_LOCK_INFIX}`, env, deps);
|
|
651
|
+
return { lockPath, release: () => releaseFlowLock(lockPath, lockFd, lockIdentity, deps) };
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// The pre-gate append-lock readiness probe (round-8 fold): acquire and immediately release the
|
|
655
|
+
// ORDINARY append lock through the full acquire discipline — a DEAD/foreign/malformed lock or
|
|
656
|
+
// an unwritable parent surfaces BEFORE any gate spends, with the acquire's own named refusal.
|
|
657
|
+
// Stated residual: a lock landing between this probe and the post-run append still refuses at
|
|
658
|
+
// append time — closing that would mean holding the append lock across the whole gate run.
|
|
659
|
+
export const probeFlowAppendLock = ({ cwd = process.cwd(), env = process.env, deps = {} } = {}) => {
|
|
660
|
+
const resolved = resolveFlowStorePath(cwd, env);
|
|
661
|
+
if (resolved == null) {
|
|
662
|
+
throw stop('not inside a git work tree (and no AW_FLOW_STORE override) — there is no flow store to probe');
|
|
663
|
+
}
|
|
664
|
+
const { lockPath, lockFd, lockIdentity } = acquireFlowLock(resolved, env, deps);
|
|
665
|
+
const issue = releaseFlowLock(lockPath, lockFd, lockIdentity, deps);
|
|
666
|
+
if (issue != null) throw issue;
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// appendSubsetAttempt — the Decision-7 locked append factory: the chain identity is captured
|
|
670
|
+
// BEFORE the gates run (the caller's `expected` {planId, cycle, stepId, round}) and re-checked
|
|
671
|
+
// under the append lock against the OPEN owning chain; attemptIndex, foldBatch/subsetDigest
|
|
672
|
+
// derivation, and the hard-stop state are computed from the captured store snapshot INSIDE the
|
|
673
|
+
// critical section — a concurrent appender never duplicates an index, and a round/park/complete
|
|
674
|
+
// landing mid-run refuses the append (never a silent misfile). subsetGateIds states what the
|
|
675
|
+
// caller RAN — only the caller knows that — but it never DECIDES the counting context: the
|
|
676
|
+
// factory re-derives the subset from the declaration + config itself (the R10 rider, via the
|
|
677
|
+
// gates-declaration leaf) and refuses a mismatch, so a caller-chosen id list can never forge a
|
|
678
|
+
// fresh subsetDigest and bypass the hard-stop budget.
|
|
679
|
+
export const appendSubsetAttempt = ({ cwd = process.cwd(), env = process.env, deps = {}, expected, subsetGateIds, status, diagnosis = null, base, fingerprint, timestamp = new Date().toISOString() } = {}) => {
|
|
680
|
+
const owner = deriveFlowOwner(cwd);
|
|
681
|
+
if (owner == null) throw stop('not inside a git work tree — the subset-attempt mint derives the owning worktree from git (fail closed)');
|
|
682
|
+
if (expected == null || typeof expected.planId !== 'string' || expected.planId.length === 0
|
|
683
|
+
|| !Number.isInteger(expected.cycle) || !Number.isInteger(expected.round)
|
|
684
|
+
|| (expected.stepId !== null && typeof expected.stepId !== 'string')) {
|
|
685
|
+
throw stop('the captured chain identity must be {planId, cycle, stepId|null, round} — the factory re-checks exactly this projection under the lock (fail closed)');
|
|
686
|
+
}
|
|
687
|
+
if (!Array.isArray(subsetGateIds)) throw stop("subsetGateIds must be the derived subset's ordered gate-id array (fail closed)");
|
|
688
|
+
if (status !== 'green' && status !== 'red') throw stop(`status must be green | red (got ${JSON.stringify(status)}) — an unrunnable subset refuses with NO attempt record (fail closed)`);
|
|
689
|
+
if (diagnosis !== null && (typeof diagnosis !== 'string' || diagnosis.length === 0)) {
|
|
690
|
+
throw stop(`diagnosis must be null or a non-empty string (got ${JSON.stringify(diagnosis)}) — a mistyped input would otherwise record diagnosis-less silently (round-11 fold; fail closed)`);
|
|
691
|
+
}
|
|
692
|
+
let derived;
|
|
693
|
+
try {
|
|
694
|
+
derived = derivePregateSubsetIds(cwd);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
throw stop(`the pregate subset cannot be re-derived (${(err && err.message) || err}) — an attempt records only a subset the declaration derives (R10; fail closed)`);
|
|
697
|
+
}
|
|
698
|
+
if (derived.length !== subsetGateIds.length || derived.some((id, i) => id !== subsetGateIds[i])) {
|
|
699
|
+
throw stop(`subsetGateIds [${subsetGateIds.join(', ')}] does not match the subset derived from ${GATES_REL} + ${CONFIG_REL} flow.pregateExclude [${derived.join(', ')}] — the factory re-derives the subset itself (R10), so a caller-chosen id list never binds a counting context (fail closed)`);
|
|
700
|
+
}
|
|
701
|
+
// Everything downstream binds the factory-owned DERIVED ids — the caller array stays mutable in
|
|
702
|
+
// the caller's hands (a deps lock-hook could rewrite it after the check above) and must never
|
|
703
|
+
// reach the digest domain.
|
|
704
|
+
const subsetIds = Object.freeze([...derived]);
|
|
705
|
+
let minted = null;
|
|
706
|
+
const value = appendResolvedFlowRecord({ cwd, env, deps, makeRecord: (records) => {
|
|
707
|
+
const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === expected.planId);
|
|
708
|
+
if (chain.length === 0) throw stop(`no chain exists for plan "${expected.planId}" under the lock — the captured identity is stale; re-run the subset under the current context (fail closed)`);
|
|
709
|
+
const seq = validateChainSequence(chain);
|
|
710
|
+
if (!seq.ok) throw stop(`the plan "${expected.planId}" chain is illegal under the lock (${seq.reason}) — refusing to bind an attempt to it (fail closed)`);
|
|
711
|
+
if (chain[0].owner !== owner) throw stop(`the plan "${expected.planId}" chain is owned by "${chain[0].owner}", not this worktree ("${owner}") — a foreign chain never records this tree's attempts (fail closed)`);
|
|
712
|
+
const state = walkChainState(chain);
|
|
713
|
+
const open = !state.completed && !state.parked;
|
|
714
|
+
const held = open && state.cycle === expected.cycle && state.stepId === expected.stepId && (state.round ?? 0) === expected.round;
|
|
715
|
+
if (!held) {
|
|
716
|
+
const shown = state.completed ? 'the plan completed' : state.parked ? 'the plan parked' : `the open context is {cycle ${state.cycle}, step ${JSON.stringify(state.stepId)}, round ${state.round ?? 0}}`;
|
|
717
|
+
throw stop(`the chain identity moved under the run — captured {cycle ${expected.cycle}, step ${JSON.stringify(expected.stepId)}, round ${expected.round}}, but ${shown} under the lock (a round/park/complete landed mid-run); re-run the subset under the current context (fail closed)`);
|
|
718
|
+
}
|
|
719
|
+
if (expected.stepId === null && state.openers.length > 0) {
|
|
720
|
+
throw stop(`the plan "${expected.planId}" chain sits at a post-convergence boundary — the stepId-null context is legal only before the FIRST round (the adoption context, round-6 fold); open the next step round first (fail closed)`);
|
|
721
|
+
}
|
|
722
|
+
// Round-9 fold: the EXACTLY-ONE-open-owning-chain rule is re-derived UNDER the lock — an
|
|
723
|
+
// adoption/resume landing after the caller's preflight would otherwise record the attempt
|
|
724
|
+
// into an already-ambiguous context. (After the specific refusals above, so a parked or
|
|
725
|
+
// moved TARGET chain keeps its own named diagnosis.)
|
|
726
|
+
const openOwn = [...new Set(records.filter((r) => r.kind === CHAIN_KIND && r.owner === owner).map((r) => r.planId))].filter((planId) => {
|
|
727
|
+
const c = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
|
|
728
|
+
if (c[0].purpose !== 'adoption' || c[0].owner !== owner || !validateChainSequence(c).ok) return false;
|
|
729
|
+
const s = walkChainState(c);
|
|
730
|
+
return !s.completed && !s.parked;
|
|
731
|
+
});
|
|
732
|
+
if (openOwn.length !== 1 || openOwn[0] !== expected.planId) {
|
|
733
|
+
throw stop(`this worktree ("${owner}") owns ${openOwn.length} open chains under the lock (${openOwn.join(', ') || 'none'}) — an attempt records only when exactly ONE open owning chain exists and it is the captured one ("${expected.planId}"); a chain landed mid-run — re-run the subset under the current context (fail closed)`);
|
|
734
|
+
}
|
|
735
|
+
const probe = { planId: expected.planId, cycle: expected.cycle, stepId: expected.stepId, foldBatch: subsetFoldBatchDigest(expected), subsetDigest: subsetGateIdsDigest(subsetIds) };
|
|
736
|
+
const budget = subsetAttemptState(records, probe);
|
|
737
|
+
const attemptIndex = budget.nextIndex;
|
|
738
|
+
if (budget.reds >= SUBSET_ATTEMPT_DIAGNOSIS_REDS && (typeof diagnosis !== 'string' || diagnosis.length === 0)) {
|
|
739
|
+
throw stop(`attempt ${attemptIndex} follows ${budget.reds} reds at this counting context and requires a recorded diagnosis (Decision 8 — the blind budget is spent): investigate, then re-run with a non-empty diagnosis byte-distinct from the prior attempt's; never a wait-for-maintainer`);
|
|
740
|
+
}
|
|
741
|
+
if (attemptIndex < SUBSET_ATTEMPT_DIAGNOSIS_FROM && diagnosis != null) {
|
|
742
|
+
throw stop(`attempt ${attemptIndex} is inside the blind budget (attempts 1-2) — a diagnosis rides only attempt ${SUBSET_ATTEMPT_DIAGNOSIS_FROM} and later (Decision 8); drop the diagnosis input (the captured context may be stale — fail closed, never silently dropped)`);
|
|
743
|
+
}
|
|
744
|
+
const { line, snapshot } = captureRecordSnapshot({
|
|
745
|
+
schema: FLOW_SCHEMA_VERSION, kind: 'subset-attempt', planId: expected.planId, cycle: expected.cycle,
|
|
746
|
+
stepId: expected.stepId, foldBatch: probe.foldBatch, subsetDigest: probe.subsetDigest, attemptIndex,
|
|
747
|
+
...(typeof diagnosis === 'string' ? { diagnosis } : {}), status, base, fingerprint, timestamp,
|
|
748
|
+
});
|
|
749
|
+
// Computed UNDER the lock from the captured snapshot — a lock-free preflight state could
|
|
750
|
+
// pick the wrong message under a concurrent append.
|
|
751
|
+
const consumedPermit = budget.reds >= SUBSET_ATTEMPT_MAX_REDS;
|
|
752
|
+
const redsAfter = budget.reds + (status === 'red' ? 1 : 0);
|
|
753
|
+
const creditsAfter = budget.credits - (consumedPermit ? 1 : 0);
|
|
754
|
+
minted = {
|
|
755
|
+
attemptIndex,
|
|
756
|
+
redsAtKey: redsAfter,
|
|
757
|
+
reopened: consumedPermit,
|
|
758
|
+
exhaustedAfter: redsAfter >= SUBSET_ATTEMPT_MAX_REDS && creditsAfter <= 0,
|
|
759
|
+
};
|
|
760
|
+
return { line, snapshot };
|
|
761
|
+
} });
|
|
762
|
+
return { ...value, ...minted, digest: canonicalFlowDigest(value.record) };
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
// ── chain-state walk + the generic reference validator (#63) ──────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
const TERMINAL_PURPOSES = ['adoption', 'converged', 'complete'];
|
|
768
|
+
const HEX64_RE = /^[0-9a-f]{64}$/;
|
|
769
|
+
|
|
770
|
+
// The record a step-opening round must reference: the latest converged/complete, else the adoption
|
|
771
|
+
// record itself (the plan's first step — the exemption is explicit, never inferred).
|
|
772
|
+
export const priorChainTerminal = (chain) => {
|
|
773
|
+
let terminal = null;
|
|
774
|
+
for (const r of chain) {
|
|
775
|
+
if (r.purpose === 'adoption' && terminal === null) terminal = r;
|
|
776
|
+
else if (r.purpose === 'converged' || r.purpose === 'complete') terminal = r;
|
|
777
|
+
}
|
|
778
|
+
return terminal;
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
// walkChainState(chain) → { mode, parked, completed, cycle, round, stepId, openers, lastTerminal }
|
|
782
|
+
// over ONE plan's raw-order chain. Legality lives in validateChainSequence — callers run it first;
|
|
783
|
+
// this walk only derives state, including each opener with its at-that-point prior terminal.
|
|
784
|
+
export const walkChainState = (chain) => {
|
|
785
|
+
const state = {
|
|
786
|
+
mode: 'boundary', parked: false, completed: false,
|
|
787
|
+
cycle: chain[0]?.cycle ?? null, round: chain[0]?.round ?? null, stepId: null,
|
|
788
|
+
openers: [], lastTerminal: null,
|
|
789
|
+
};
|
|
790
|
+
for (const r of chain) {
|
|
791
|
+
state.cycle = r.cycle;
|
|
792
|
+
if (r.purpose === 'adoption') { state.lastTerminal = r; state.round = r.round; continue; }
|
|
793
|
+
if (r.purpose === 'park') { state.parked = true; continue; }
|
|
794
|
+
if (r.purpose === 'resume') { state.parked = false; continue; }
|
|
795
|
+
if (r.purpose === 'complete') { state.completed = true; state.lastTerminal = r; continue; }
|
|
796
|
+
if (r.purpose === 'converged') { state.mode = 'boundary'; state.lastTerminal = r; state.stepId = null; continue; }
|
|
797
|
+
if (r.purpose === 'unfreeze' && state.mode === 'boundary') { state.mode = 'in-step'; state.stepId = r.stepId; state.round = r.round; continue; }
|
|
798
|
+
if (r.purpose === 'round') {
|
|
799
|
+
if (state.mode === 'boundary') {
|
|
800
|
+
state.openers.push({ record: r, priorTerminal: state.lastTerminal });
|
|
801
|
+
state.mode = 'in-step';
|
|
802
|
+
state.stepId = r.stepId;
|
|
803
|
+
state.round = r.round;
|
|
804
|
+
} else if (r.round > state.round) state.round = r.round;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return state;
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
// Reference checks live ENTIRELY in the digest domain — two byte-different records with one
|
|
811
|
+
// canonical serialization are ONE identity, so object identity never decides resolution or
|
|
812
|
+
// authority. resolveRecordReference returns the LAST matching record (consistent with the
|
|
813
|
+
// latest-per-key authoritative selection); the prefix (records BEFORE the referencing one) is the
|
|
814
|
+
// resolution domain, so an out-of-order reference never resolves.
|
|
815
|
+
export const resolveRecordReference = (prefixRecords, digest) =>
|
|
816
|
+
prefixRecords.findLast((r) => canonicalFlowDigest(r) === digest);
|
|
817
|
+
|
|
818
|
+
export const isAuthoritativeReferenceTarget = (scopeRecords, digest) =>
|
|
819
|
+
authoritativeFlowRecords(scopeRecords).some((r) => canonicalFlowDigest(r) === digest);
|
|
820
|
+
|
|
821
|
+
// validateOpenerReference(prefixRecords, candidate) → { ok } | { ok: false, reason }. The named
|
|
822
|
+
// classification of a step-opening round's prior-terminal reference: unresolved · non-chain ·
|
|
823
|
+
// another plan · non-terminal · superseded · not-the-prior-terminal.
|
|
824
|
+
export const validateOpenerReference = (prefixRecords, candidate) => {
|
|
825
|
+
const target = resolveRecordReference(prefixRecords, candidate.opensFrom);
|
|
826
|
+
if (target === undefined) {
|
|
827
|
+
return { ok: false, reason: `the prior-terminal reference does not match the store — no record digests to ${candidate.opensFrom.slice(0, 12)}…` };
|
|
828
|
+
}
|
|
829
|
+
if (target.kind !== CHAIN_KIND) {
|
|
830
|
+
return { ok: false, reason: `the prior-terminal reference targets a ${target.kind} record, not a chain terminal` };
|
|
831
|
+
}
|
|
832
|
+
if (target.planId !== candidate.planId) {
|
|
833
|
+
return { ok: false, reason: `the prior-terminal reference targets another plan's record ("${target.planId}") — a step never opens from a foreign chain` };
|
|
834
|
+
}
|
|
835
|
+
if (!TERMINAL_PURPOSES.includes(target.purpose)) {
|
|
836
|
+
return { ok: false, reason: `the prior-terminal reference targets a non-terminal record (purpose "${target.purpose}") — an opener references adoption, converged, or complete only` };
|
|
837
|
+
}
|
|
838
|
+
const chain = prefixRecords.filter((r) => r.kind === CHAIN_KIND && r.planId === candidate.planId);
|
|
839
|
+
if (!isAuthoritativeReferenceTarget(chain, candidate.opensFrom)) {
|
|
840
|
+
return { ok: false, reason: 'the prior-terminal reference targets a superseded record — reference the latest record of that key' };
|
|
841
|
+
}
|
|
842
|
+
const prior = priorChainTerminal(chain);
|
|
843
|
+
if (prior == null || canonicalFlowDigest(prior) !== candidate.opensFrom) {
|
|
844
|
+
return { ok: false, reason: `the prior-terminal reference must target the chain's PRIOR terminal (${prior == null ? 'none' : `${canonicalFlowDigest(prior).slice(0, 12)}…`}), not another step's or an earlier terminal — step minting cannot manufacture fresh budgets` };
|
|
845
|
+
}
|
|
846
|
+
return { ok: true };
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
// ── the adoption mint (#58) — frontmatter planId + plan content digest, read-only plan file ──────
|
|
850
|
+
|
|
851
|
+
const PLAN_ID_FRONTMATTER_HINT = 'planId: <your-stable-plan-id>';
|
|
852
|
+
|
|
853
|
+
// Identity binds only a CLOSED leading frontmatter block — an unterminated block never yields an
|
|
854
|
+
// id; CRLF is normalized per line so line endings never fork chain identity.
|
|
855
|
+
export const readPlanFrontmatterId = (text) => {
|
|
856
|
+
const lines = text.split('\n').map((line) => line.replace(/\r$/, ''));
|
|
857
|
+
if (lines[0]?.trim() !== '---') return null;
|
|
858
|
+
const close = lines.findIndex((line, i) => i > 0 && line.trim() === '---');
|
|
859
|
+
if (close === -1) return null;
|
|
860
|
+
for (const line of lines.slice(1, close)) {
|
|
861
|
+
const m = /^planId:[ \t]*(\S+)[ \t]*$/.exec(line);
|
|
862
|
+
if (m) return m[1];
|
|
863
|
+
}
|
|
864
|
+
return null;
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
export const mintAdoption = ({ cwd = process.cwd(), env = process.env, deps = {}, planPath, planLabel, cycle = 1, commitEpoch = 0, timestamp = new Date().toISOString() } = {}) => {
|
|
868
|
+
const owner = deriveFlowOwner(cwd);
|
|
869
|
+
if (owner == null) throw stop('not inside a git work tree — the adoption mint derives the owning worktree and the tree fingerprint from git (fail closed)');
|
|
870
|
+
let planBytes;
|
|
871
|
+
try {
|
|
872
|
+
planBytes = readFileSync(resolve(cwd, planPath));
|
|
873
|
+
} catch (err) {
|
|
874
|
+
throw stop(`cannot read the plan file ${planPath} (${(err && err.code) || (err && err.message) || err}) — the adoption mint READS an existing plan file (fail closed)`);
|
|
875
|
+
}
|
|
876
|
+
const planId = readPlanFrontmatterId(planBytes.toString('utf8'));
|
|
877
|
+
if (planId == null) {
|
|
878
|
+
throw stop(`the plan file ${planPath} carries no frontmatter planId — plan filenames are never chain identity. Add this line inside a leading "---" frontmatter block:\n${PLAN_ID_FRONTMATTER_HINT}\nand re-run; the plan file is never written by this mint (fail closed)`);
|
|
879
|
+
}
|
|
880
|
+
const planDigest = sha256Hex(planBytes);
|
|
881
|
+
// A pre-append read purely for the NAMED refusal: the locked append would refuse a second
|
|
882
|
+
// adoption anyway, but only this comparison can surface whether the plan content still matches.
|
|
883
|
+
const resolved = resolveFlowStorePath(cwd, env);
|
|
884
|
+
const adopted = resolved == null ? undefined : readFlowStore(resolved).records
|
|
885
|
+
.find((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.planId === planId);
|
|
886
|
+
if (adopted !== undefined) {
|
|
887
|
+
throw stop(adopted.planDigest === planDigest
|
|
888
|
+
? `plan "${planId}" is already adopted (content digest unchanged — a rename never resets chain identity); adoption is only ever the chain's first record`
|
|
889
|
+
: `plan "${planId}" is already adopted and the plan file content no longer matches its adoption record (recorded ${adopted.planDigest.slice(0, 12)}…, current ${planDigest.slice(0, 12)}…) — re-adopting edited plan content is refused; the digest mismatch is surfaced, never silent`);
|
|
890
|
+
}
|
|
891
|
+
const fingerprint = computeTreeFingerprint(cwd);
|
|
892
|
+
if (fingerprint == null) throw stop('cannot compute the tree fingerprint — the adoption record binds {base, fingerprint} (fail closed)');
|
|
893
|
+
const record = {
|
|
894
|
+
schema: FLOW_SCHEMA_VERSION, kind: CHAIN_KIND, purpose: 'adoption', planId, cycle, round: 0,
|
|
895
|
+
commitEpoch, owner, base: resolveBase(cwd), timestamp, stepId: null, fingerprint,
|
|
896
|
+
planLabel: planLabel ?? planId, createdAt: timestamp, planDigest,
|
|
897
|
+
};
|
|
898
|
+
const { writtenPath } = appendFlowRecord({ cwd, record, env, deps });
|
|
899
|
+
return { writtenPath, record, digest: canonicalFlowDigest(record) };
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
// ── the bookkeeping-delta custody proof (#60) — masked revert-and-recompute at mint time ─────────
|
|
903
|
+
|
|
904
|
+
// The supported pre-state model; everything else refuses BY NAME (fail closed): the delta lives in
|
|
905
|
+
// the WORKTREE layer of one plain-ASCII, non-binary, non-executable regular path. A tracked path
|
|
906
|
+
// must be CLEAN at the path before the delta (pre-change worktree bytes = its index entry), so the
|
|
907
|
+
// pre-state contributes NO unstaged diff section and the mask is pure section REMOVAL plus
|
|
908
|
+
// untracked-entry splicing — the recompute never regenerates git diff bytes, whose exact form this
|
|
909
|
+
// module cannot promise. Supported transitions: present→present, present→absent, absent→present.
|
|
910
|
+
|
|
911
|
+
const GIT_PLAIN_PATH_RE = /^[\x20-\x7e]+$/;
|
|
912
|
+
const pathNeedsGitQuoting = (rel) => !GIT_PLAIN_PATH_RE.test(rel) || rel.includes('"') || rel.includes('\\');
|
|
913
|
+
const bufferLooksBinary = (buf) => buf.subarray(0, 8192).includes(0);
|
|
914
|
+
const REGULAR_FILE_MODE = '100644';
|
|
915
|
+
|
|
916
|
+
const defaultRunGit = (args, dir) => spawnSync('git', args, { cwd: dir, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
917
|
+
|
|
918
|
+
// The declared path enters git as a LITERAL pathspec and comes back through a strict -z parse:
|
|
919
|
+
// exactly one NUL-terminated record whose path field EQUALS the declared rel, full-octal mode,
|
|
920
|
+
// an OID of exactly 40 or 64 hex — a glob-capable name ([]*?) or a prefix-valid truncated answer
|
|
921
|
+
// can then never bind the proof to another file (fail closed on every mismatch).
|
|
922
|
+
const OID_PART = '(?:[0-9a-f]{40}|[0-9a-f]{64})';
|
|
923
|
+
const INDEX_META_RE = new RegExp(`^([0-7]{6}) (${OID_PART}) (\\d)$`);
|
|
924
|
+
const TREE_META_RE = new RegExp(`^([0-7]{6}) (\\w+) (${OID_PART})$`);
|
|
925
|
+
|
|
926
|
+
const parseZRecords = (stdout) => {
|
|
927
|
+
const text = stdout.toString('utf8');
|
|
928
|
+
if (text === '') return [];
|
|
929
|
+
if (!text.endsWith('\0')) return null;
|
|
930
|
+
return text.slice(0, -1).split('\0');
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const splitZEntry = (entry, metaRe) => {
|
|
934
|
+
const at = entry.indexOf('\t');
|
|
935
|
+
if (at === -1) return null;
|
|
936
|
+
const meta = metaRe.exec(entry.slice(0, at));
|
|
937
|
+
return meta == null ? null : { meta, path: entry.slice(at + 1) };
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
const readIndexEntry = (top, rel, runGit) => {
|
|
941
|
+
const out = runGit(['ls-files', '-s', '-z', '--', `:(literal)${rel}`], top);
|
|
942
|
+
if (out.error || out.status !== 0) throw stop(`cannot read the index entry of ${rel} (git ls-files failed) — refusing to mint (fail closed)`);
|
|
943
|
+
const recordsZ = parseZRecords(out.stdout);
|
|
944
|
+
if (recordsZ == null) throw stop(`cannot parse the index entry of ${rel} (unterminated git ls-files output) — refusing to mint (fail closed)`);
|
|
945
|
+
if (recordsZ.length === 0) return null;
|
|
946
|
+
const entry = splitZEntry(recordsZ[0], INDEX_META_RE);
|
|
947
|
+
if (recordsZ.length > 1 || entry == null || entry.meta[3] !== '0' || entry.path !== rel) {
|
|
948
|
+
throw stop(`the declared path ${rel} carries an unmerged or unparseable index entry — an unsupported pre-state class (fail closed)`);
|
|
949
|
+
}
|
|
950
|
+
return { mode: entry.meta[1], sha: entry.meta[2] };
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
// An absent HEAD layer is PROVEN unborn, never assumed: rev-parse must answer with EXACTLY the
|
|
954
|
+
// clean verify-miss status (1) AND HEAD must still resolve as a symbolic ref; any operational
|
|
955
|
+
// fault fails closed. "No entry" is ONLY an empty ls-tree stdout — a non-empty answer must parse
|
|
956
|
+
// as exactly one entry line, else the repository is at fault (a false custody proof otherwise).
|
|
957
|
+
const GIT_VERIFY_MISS_STATUS = 1;
|
|
958
|
+
const readHeadEntry = (top, rel, runGit) => {
|
|
959
|
+
const probe = runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], top);
|
|
960
|
+
if (probe.error || probe.status !== 0) {
|
|
961
|
+
const verifyMiss = !probe.error && probe.status === GIT_VERIFY_MISS_STATUS;
|
|
962
|
+
const sym = verifyMiss ? runGit(['symbolic-ref', '--quiet', 'HEAD'], top) : null;
|
|
963
|
+
if (sym == null || sym.error || sym.status !== 0) {
|
|
964
|
+
throw stop('cannot decide the HEAD state (git rev-parse --verify HEAD did not answer with a clean verify miss, or symbolic-ref HEAD failed) — refusing to mint (fail closed)');
|
|
965
|
+
}
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
const out = runGit(['ls-tree', '-z', 'HEAD', '--', `:(literal)${rel}`], top);
|
|
969
|
+
if (out.error || out.status !== 0) throw stop(`cannot read the HEAD entry of ${rel} (git ls-tree failed with an existing HEAD) — refusing to mint (fail closed)`);
|
|
970
|
+
const recordsZ = parseZRecords(out.stdout);
|
|
971
|
+
if (recordsZ == null) throw stop(`cannot parse the HEAD entry of ${rel} (unterminated git ls-tree output) — refusing to mint (fail closed)`);
|
|
972
|
+
if (recordsZ.length === 0) return null;
|
|
973
|
+
const entry = splitZEntry(recordsZ[0], TREE_META_RE);
|
|
974
|
+
if (recordsZ.length > 1 || entry == null || entry.path !== rel) {
|
|
975
|
+
throw stop(`cannot parse the HEAD entry of ${rel} (unexpected git ls-tree output) — refusing to mint (fail closed)`);
|
|
976
|
+
}
|
|
977
|
+
if (entry.meta[2] !== 'blob') {
|
|
978
|
+
throw stop(`the HEAD entry of ${rel} is a ${entry.meta[2]}, not a blob — an unsupported pre-state class (fail closed)`);
|
|
979
|
+
}
|
|
980
|
+
return { mode: entry.meta[1], sha: entry.meta[3] };
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const readBlob = (top, sha, runGit) => {
|
|
984
|
+
const out = runGit(['cat-file', 'blob', sha], top);
|
|
985
|
+
if (out.error || out.status !== 0) throw stop(`cannot read blob ${sha} from the object store — refusing to mint (fail closed)`);
|
|
986
|
+
return out.stdout;
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
// Byte-level removal of ONE file's section from a git diff buffer. Hunk lines start with
|
|
990
|
+
// [ +\-\\@], so a line starting "diff --git " is always a section header; the declared path is
|
|
991
|
+
// plain-ASCII by refusal, so its header is these exact bytes. No section = a no-op mask.
|
|
992
|
+
const DIFF_SECTION_START = Buffer.from('\ndiff --git ');
|
|
993
|
+
const removeDiffSection = (buf, rel) => {
|
|
994
|
+
const header = Buffer.from(`diff --git a/${rel} b/${rel}\n`);
|
|
995
|
+
let at = -1;
|
|
996
|
+
if (buf.subarray(0, header.length).equals(header)) at = 0;
|
|
997
|
+
else {
|
|
998
|
+
const i = buf.indexOf(Buffer.concat([Buffer.from('\n'), header]));
|
|
999
|
+
if (i !== -1) at = i + 1;
|
|
1000
|
+
}
|
|
1001
|
+
if (at === -1) return buf;
|
|
1002
|
+
const next = buf.indexOf(DIFF_SECTION_START, at + header.length - 1);
|
|
1003
|
+
const end = next === -1 ? buf.length : next + 1;
|
|
1004
|
+
return Buffer.concat([buf.subarray(0, at), buf.subarray(end)]);
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
// One untracked entry's payload chunks, branch-for-branch the frozen core's discipline
|
|
1008
|
+
// (computeFingerprintPayload) — the NULL-mask parity test pins the byte equality.
|
|
1009
|
+
const untrackedEntryChunks = (top, rel, lstat) => {
|
|
1010
|
+
const full = join(top, rel);
|
|
1011
|
+
let stat = null;
|
|
1012
|
+
try {
|
|
1013
|
+
stat = lstat(full);
|
|
1014
|
+
} catch {
|
|
1015
|
+
stat = null;
|
|
1016
|
+
}
|
|
1017
|
+
if (isNeverCommittableStat(stat)) return [];
|
|
1018
|
+
if (stat?.isSymbolicLink()) {
|
|
1019
|
+
let target = '?';
|
|
1020
|
+
try {
|
|
1021
|
+
target = readlinkSync(full);
|
|
1022
|
+
} catch {
|
|
1023
|
+
target = '?';
|
|
1024
|
+
}
|
|
1025
|
+
return [Buffer.from(`untracked-symlink:${rel} -> ${target}\n`)];
|
|
1026
|
+
}
|
|
1027
|
+
if (!stat?.isFile()) return [Buffer.from(`untracked-nonregular:${rel}\n`)];
|
|
1028
|
+
if (isBinaryFile(full)) return [Buffer.from(`untracked-binary:${rel}\n`)];
|
|
1029
|
+
return [Buffer.from(`untracked:${rel}\n`), readFileSync(full)];
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
// ONE captured read set — every assembly over it (masked and unmasked) binds the SAME tree
|
|
1033
|
+
// snapshot, so a tree move between two independent snapshots can never be certified. The three
|
|
1034
|
+
// git reads themselves are separate processes; that window is the frozen core's own inherent
|
|
1035
|
+
// residual and stays declared, not closed.
|
|
1036
|
+
const captureFingerprintPieces = (cwd, { lstat = lstatSync } = {}) => {
|
|
1037
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
1038
|
+
if (top == null) return null;
|
|
1039
|
+
const staged = gitBuf(['diff', '--cached', '--no-ext-diff'], top);
|
|
1040
|
+
const unstaged = gitBuf(['diff', '--no-ext-diff'], top);
|
|
1041
|
+
const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
|
|
1042
|
+
if (staged == null || unstaged == null || untrackedZ == null) return null;
|
|
1043
|
+
const entries = untrackedZ.toString('utf8').split('\0').filter(Boolean)
|
|
1044
|
+
.map((rel) => ({ rel, chunks: untrackedEntryChunks(top, rel, lstat) }));
|
|
1045
|
+
return { staged, unstaged, entries };
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
// mask: null = the exact frozen-core payload; { layer: 'diff', rel } removes the path's unstaged
|
|
1049
|
+
// section (its pre-state section is EMPTY by the clean-at-path rule); { layer: 'untracked', rel,
|
|
1050
|
+
// insert, preBytes } splices the untracked entry (git emits ls-files sorted by path bytes).
|
|
1051
|
+
const assembleMaskedPayload = (pieces, mask) => {
|
|
1052
|
+
const unstaged = mask?.layer === 'diff' ? removeDiffSection(pieces.unstaged, mask.rel) : pieces.unstaged;
|
|
1053
|
+
let entries = pieces.entries;
|
|
1054
|
+
if (mask?.layer === 'untracked') {
|
|
1055
|
+
entries = entries.filter((e) => e.rel !== mask.rel);
|
|
1056
|
+
if (mask.insert) {
|
|
1057
|
+
const at = entries.findIndex((e) => e.rel > mask.rel);
|
|
1058
|
+
entries = [...entries];
|
|
1059
|
+
entries.splice(at === -1 ? entries.length : at, 0, { rel: mask.rel, chunks: [Buffer.from(`untracked:${mask.rel}\n`), mask.preBytes] });
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return Buffer.concat([pieces.staged, unstaged, ...entries.flatMap((e) => e.chunks)]);
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
export const computeMaskedFingerprintPayload = (cwd, mask = null, fsx) => {
|
|
1066
|
+
const pieces = captureFingerprintPieces(cwd, fsx);
|
|
1067
|
+
return pieces == null ? null : assembleMaskedPayload(pieces, mask);
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
// mintBookkeepingDelta: the FULL pre-state arrives as EXPLICIT inputs (pre-change worktree bytes +
|
|
1071
|
+
// the presence class; tracked-ness derives from the window-constant HEAD/index layers) — never
|
|
1072
|
+
// reconstructed from ambient git state. The computation only READS: the working tree is never
|
|
1073
|
+
// mutated. The mint refuses unless the masked recompute reproduces fingerprintBefore — an
|
|
1074
|
+
// unconfined delta never lands; the proof payload persists so the checker can verify a PROVEN
|
|
1075
|
+
// mint against a bare declaration.
|
|
1076
|
+
export const mintBookkeepingDelta = ({ cwd = process.cwd(), env = process.env, deps = {}, path: rel, fingerprintBefore, preContent = null, timestamp = new Date().toISOString() } = {}) => {
|
|
1077
|
+
if (typeof fingerprintBefore !== 'string' || !HEX64_RE.test(fingerprintBefore)) {
|
|
1078
|
+
throw stop('fingerprintBefore must be the 64-hex PRE-DELTA tree fingerprint — the proof compares the masked recompute against it (fail closed)');
|
|
1079
|
+
}
|
|
1080
|
+
const lex = lexicalRepoRelative(rel);
|
|
1081
|
+
if (!lex.ok) throw stop(`the declared path must be lexically repo-relative — ${lex.reason} (fail closed)`);
|
|
1082
|
+
if (pathNeedsGitQuoting(rel)) {
|
|
1083
|
+
throw stop(`the declared path "${rel}" needs git diff-header quoting — an unsupported pre-state class (the masked recompute matches plain header bytes only; fail closed)`);
|
|
1084
|
+
}
|
|
1085
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
1086
|
+
if (top == null) throw stop('not inside a git work tree — the custody proof has no meaning outside the fingerprint domain; refusing to mint');
|
|
1087
|
+
const preBytes = preContent == null ? null : Buffer.from(preContent);
|
|
1088
|
+
if (preBytes !== null && bufferLooksBinary(preBytes)) {
|
|
1089
|
+
throw stop(`the pre-change bytes of ${rel} carry binary content — an unsupported pre-state class (fail closed)`);
|
|
1090
|
+
}
|
|
1091
|
+
const full = join(top, rel);
|
|
1092
|
+
const st = lstatNoFollow(full, deps.lstat ?? lstatSync);
|
|
1093
|
+
if (st?.isSymbolicLink()) throw stop(`the declared path ${rel} is a symlink — an unsupported pre-state class (fail closed)`);
|
|
1094
|
+
if (st && !st.isFile()) throw stop(`the declared path ${rel} is a ${describeNonRegular(st)} — an unsupported pre-state class (fail closed)`);
|
|
1095
|
+
if (st && (st.mode & 0o111) !== 0) throw stop(`the declared path ${rel} carries an executable mode — an unsupported pre-state class (mode motion cannot be expressed; fail closed)`);
|
|
1096
|
+
const nowBytes = st ? readFileSync(full) : null;
|
|
1097
|
+
if (nowBytes !== null && bufferLooksBinary(nowBytes)) {
|
|
1098
|
+
throw stop(`the declared path ${rel} carries binary content — an unsupported pre-state class (fail closed)`);
|
|
1099
|
+
}
|
|
1100
|
+
const preClass = preBytes === null ? 'absent' : 'present';
|
|
1101
|
+
if (preClass === 'absent' && nowBytes === null) {
|
|
1102
|
+
throw stop('the absent→absent transition is unsupported — supported: present→present, present→absent, absent→present (fail closed)');
|
|
1103
|
+
}
|
|
1104
|
+
const runGit = deps.runGit ?? defaultRunGit;
|
|
1105
|
+
const index = readIndexEntry(top, rel, runGit);
|
|
1106
|
+
const head = readHeadEntry(top, rel, runGit);
|
|
1107
|
+
for (const [layer, entry] of [['index', index], ['HEAD', head]]) {
|
|
1108
|
+
if (entry && entry.mode !== REGULAR_FILE_MODE) {
|
|
1109
|
+
throw stop(`the ${layer} entry of ${rel} carries mode ${entry.mode} — an unsupported pre-state class (only plain ${REGULAR_FILE_MODE} regular files are expressible; fail closed)`);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (index == null && head != null) {
|
|
1113
|
+
throw stop(`the declared path ${rel} has a HEAD entry but no index entry (a staged deletion) — an unsupported pre-state class (fail closed)`);
|
|
1114
|
+
}
|
|
1115
|
+
const tracked = index != null || head != null;
|
|
1116
|
+
const headBytes = head == null ? null : readBlob(top, head.sha, runGit);
|
|
1117
|
+
const indexBytes = index == null ? null : readBlob(top, index.sha, runGit);
|
|
1118
|
+
let mask;
|
|
1119
|
+
if (tracked) {
|
|
1120
|
+
if (preClass === 'absent') {
|
|
1121
|
+
throw stop(`the declared path ${rel} is tracked while its pre-change worktree state is absent — a dirty pre-state at the declared path is an unsupported pre-state class (the masked proof covers a clean-at-path pre-state only; fail closed)`);
|
|
1122
|
+
}
|
|
1123
|
+
if (!preBytes.equals(indexBytes)) {
|
|
1124
|
+
throw stop(`the declared path ${rel} has a dirty pre-state (the pre-change worktree bytes do not equal the index entry) — an unsupported pre-state class (the masked proof covers a clean-at-path pre-state only; fail closed)`);
|
|
1125
|
+
}
|
|
1126
|
+
mask = { layer: 'diff', rel };
|
|
1127
|
+
} else {
|
|
1128
|
+
// --no-index: the ignore ANSWER must come from the rules alone — with the index consulted, a
|
|
1129
|
+
// tracked glob neighbor (feature-a.md vs the literal feature-[a].md) flips the answer and a
|
|
1130
|
+
// genuinely ignored path would spuriously refuse to mint.
|
|
1131
|
+
const ig = runGit(['check-ignore', '-q', '--no-index', '--', rel], top);
|
|
1132
|
+
if (ig.error || (ig.status !== 0 && ig.status !== 1)) {
|
|
1133
|
+
throw stop(`cannot decide the ignore state of ${rel} (git check-ignore failed) — refusing to mint (fail closed)`);
|
|
1134
|
+
}
|
|
1135
|
+
// An ignored path is outside the fingerprint domain in BOTH states — the mask is a no-op there.
|
|
1136
|
+
// Honest limit: an untracked path's MODE is likewise invisible to the frozen payload in both
|
|
1137
|
+
// states (an entry is name + bytes only) — untracked mode motion is neither expressible nor
|
|
1138
|
+
// claimed; only the CURRENT tree's non-plain modes refuse by name above.
|
|
1139
|
+
mask = { layer: 'untracked', rel, insert: preClass === 'present' && ig.status !== 0, preBytes };
|
|
1140
|
+
}
|
|
1141
|
+
const pieces = captureFingerprintPieces(cwd, deps);
|
|
1142
|
+
if (pieces == null) throw stop('cannot capture the fingerprint read set (a git probe failed) — refusing to mint (fail closed)');
|
|
1143
|
+
// Bracket: the declared path must still be EXACTLY what the class checks and contentDigest
|
|
1144
|
+
// observed — the no-follow class checks repeat first, then presence + bytes must match, so the
|
|
1145
|
+
// digest and the captured payload can never bind two different post-states.
|
|
1146
|
+
const stAfter = lstatNoFollow(full, deps.lstat ?? lstatSync);
|
|
1147
|
+
if (stAfter?.isSymbolicLink()) throw stop(`the declared path ${rel} is a symlink — an unsupported pre-state class (fail closed)`);
|
|
1148
|
+
if (stAfter && !stAfter.isFile()) throw stop(`the declared path ${rel} is a ${describeNonRegular(stAfter)} — an unsupported pre-state class (fail closed)`);
|
|
1149
|
+
if (stAfter && (stAfter.mode & 0o111) !== 0) throw stop(`the declared path ${rel} carries an executable mode — an unsupported pre-state class (mode motion cannot be expressed; fail closed)`);
|
|
1150
|
+
const bytesAfter = stAfter ? readFileSync(full) : null;
|
|
1151
|
+
const declaredMoved = (stAfter == null) !== (nowBytes === null)
|
|
1152
|
+
|| (nowBytes !== null && bytesAfter !== null && !bytesAfter.equals(nowBytes));
|
|
1153
|
+
if (declaredMoved) {
|
|
1154
|
+
throw stop(`the declared path ${rel} moved under the mint (its bytes or presence changed during the capture) — contentDigest and the captured payload must bind ONE post-state; retry on a quiescent tree (fail closed)`);
|
|
1155
|
+
}
|
|
1156
|
+
const maskedFingerprint = sha256Hex(assembleMaskedPayload(pieces, mask));
|
|
1157
|
+
if (maskedFingerprint !== fingerprintBefore) {
|
|
1158
|
+
throw stop(`the delta is NOT confined to the declared path ${rel} — the masked revert-and-recompute (${maskedFingerprint.slice(0, 12)}…) does not reproduce fingerprintBefore (${fingerprintBefore.slice(0, 12)}…); something else moved in the window (fail closed)`);
|
|
1159
|
+
}
|
|
1160
|
+
// Both fingerprints derive from the ONE captured read set — a tree move between two independent
|
|
1161
|
+
// snapshots can never be certified as a confined delta.
|
|
1162
|
+
const fingerprintAfter = sha256Hex(assembleMaskedPayload(pieces, null));
|
|
1163
|
+
const record = {
|
|
1164
|
+
schema: FLOW_SCHEMA_VERSION, kind: 'bookkeeping-delta', fingerprintBefore, fingerprintAfter,
|
|
1165
|
+
path: rel, contentDigest: nowBytes === null ? null : sha256Hex(nowBytes),
|
|
1166
|
+
custodyProof: {
|
|
1167
|
+
preClass, tracked,
|
|
1168
|
+
headDigest: headBytes === null ? null : sha256Hex(headBytes),
|
|
1169
|
+
indexDigest: indexBytes === null ? null : sha256Hex(indexBytes),
|
|
1170
|
+
worktreeDigest: preBytes === null ? null : sha256Hex(preBytes),
|
|
1171
|
+
maskedFingerprint,
|
|
1172
|
+
},
|
|
1173
|
+
base: resolveBase(cwd), timestamp,
|
|
1174
|
+
};
|
|
1175
|
+
const { writtenPath } = appendFlowRecord({ cwd, record, env, deps });
|
|
1176
|
+
return { writtenPath, record, digest: canonicalFlowDigest(record) };
|
|
1177
|
+
};
|
|
1178
|
+
|