@sabaiway/agent-workflow-kit 5.4.0 → 5.5.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.
@@ -10,6 +10,12 @@
10
10
  // validation, malformed-store refusal, replay refusal, chain-sequence and supersession legality) —
11
11
  // an illegal record never lands.
12
12
  //
13
+ // That lock/CAS + serialized-append machinery now lives in the PARAMETERIZED store-append.mjs leaf
14
+ // (delegation Plan 1 D12), extracted from here unchanged so a second store can ride the identical
15
+ // discipline instead of a second copy of it. This module keeps everything flow-SPECIFIC: the seams
16
+ // it injects (path resolution, nouns, knob names, validator, parser) and `flowSemanticPreflight` —
17
+ // the per-kind legality the lane runs inside the critical section.
18
+ //
13
19
  // Phase 3 adds the mint primitives that need the tree: the adoption mint (frontmatter planId +
14
20
  // plan content digest, #58), the canonical owning-worktree identity (#49), the generic reference
15
21
  // validator + prior-terminal resolution in the append preflight (#63), and the bookkeeping-delta
@@ -23,23 +29,25 @@
23
29
  // git dir, not a security boundary.
24
30
 
25
31
  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';
32
+ import { readFileSync, lstatSync, readlinkSync } from 'node:fs';
33
+ import { join, resolve } from 'node:path';
29
34
  import { spawnSync } from 'node:child_process';
30
- import { writeContainedFileAtomic, lstatNoFollow } from './atomic-write.mjs';
31
- import { parsePositiveIntKnob } from './changed-surface.mjs';
35
+ import { lstatNoFollow } from './atomic-write.mjs';
32
36
  import { FLOW_SCHEMA_VERSION, CHAIN_KIND, validateFlowRecord, validateChainSequence, validateSupersessions, authoritativeFlowRecords, canonicalFlowDigest, flowRecordKey, subsetFoldBatchDigest, subsetGateIdsDigest, SUBSET_ATTEMPT_DIAGNOSIS_FROM } from './flow-record.mjs';
33
37
  import { isNeverCommittableStat, isBinaryFile, lexicalRepoRelative, resolveBase, computeTreeFingerprint } from './core-evidence.mjs';
34
38
  import { derivePregateSubsetIds, GATES_REL } from './gates-declaration.mjs';
35
39
  import { CONFIG_REL } from './orchestration-config.mjs';
40
+ // The lock/CAS discipline and the serialized append itself live in the PARAMETERIZED
41
+ // store-append.mjs leaf (D12) — this module injects the flow store's own nouns, seams, validator
42
+ // and semantic preflight.
43
+ import { createStoreAppendLane } from './store-append.mjs';
36
44
  // The read half lives in flow-store-read.mjs (it OWNS no write API — read-only surfaces like the
37
45
  // procedures advisor import it directly) and is RE-EXPORTED here — every existing consumer keeps
38
46
  // its import site.
39
47
  import {
40
48
  FLOW_STORE_STOP, flowStoreStop, FLOW_STORE_BASENAME, FLOW_LOCK_SUFFIX, gitLine,
41
49
  resolveFlowStorePath, resolveFlowLockPath, parseFlowStoreText, readFlowStore,
42
- readRegularFileNoFollow, deriveFlowOwner, describeNonRegular,
50
+ deriveFlowOwner, describeNonRegular,
43
51
  } from './flow-store-read.mjs';
44
52
 
45
53
  export {
@@ -61,278 +69,29 @@ const gitBuf = (args, cwd) => {
61
69
  };
62
70
  const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
63
71
 
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
- };
72
+ // ── the shared append lane (D12) ──────────────────────────────────────────────────────────────────
73
+
74
+ // The lock/CAS discipline, the fd-custody rules and the serialized append are the EXTRACTION of
75
+ // exactly this module's former code into store-append.mjs, so behavior is unchanged by
76
+ // construction: this store injects its nouns (every refusal still names the flow store), its env
77
+ // seam and knob names, its typed-STOP factory, its record validator, its store-text parser, and
78
+ // the SEMANTIC preflight below. The flow suites are the characterization bar for that claim.
79
+ const flowAppendLane = createStoreAppendLane({
80
+ nouns: { store: 'flow store', adj: 'flow-store', record: 'flow record' },
81
+ envNames: { store: 'AW_FLOW_STORE', waitKnob: 'AW_FLOW_LOCK_WAIT_MS', pollKnob: 'AW_FLOW_LOCK_POLL_MS' },
82
+ stop,
83
+ resolveStorePath: resolveFlowStorePath,
84
+ resolveLockPath: resolveFlowLockPath,
85
+ validateRecord: validateFlowRecord,
86
+ parseStoreText: parseFlowStoreText,
87
+ lockWaitMs: FLOW_LOCK_WAIT_MS,
88
+ lockPollMs: FLOW_LOCK_POLL_MS,
89
+ });
90
+
91
+ const captureRecordSnapshot = flowAppendLane.captureRecordSnapshot;
296
92
 
297
93
  // ── the ONE append (validated, semantic-preflighted, lock-serialized, atomic) ─────────────────────
298
94
 
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
95
  // The store path is always RESOLVED (cwd/env), never caller-supplied — a raw path param would
337
96
  // bypass the absolute-normalization door the AW_FLOW_STORE seam enforces. Read, write, and unlock
338
97
  // all use the CANONICAL pair acquire returned — nothing is re-derived mid-append.
@@ -344,7 +103,7 @@ export const appendFlowRecord = ({ cwd = process.cwd(), record, env = process.en
344
103
  if (snapshot.kind === 'subset-attempt') {
345
104
  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
105
  }
347
- return appendResolvedFlowRecord({ cwd, env, deps, makeRecord: () => ({ line, snapshot }) });
106
+ return flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: () => ({ line, snapshot }) });
348
107
  };
349
108
 
350
109
  // appendFlowRecordWithPreflight — the generic lane plus a caller `preflight(records)` hook that
@@ -360,7 +119,7 @@ export const appendFlowRecordWithPreflight = ({ cwd = process.cwd(), record, env
360
119
  if (snapshot.kind === 'subset-attempt') {
361
120
  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
121
  }
363
- return appendResolvedFlowRecord({ cwd, env, deps, makeRecord: (records) => {
122
+ return flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: (records) => {
364
123
  if (preflight != null) preflight(deepFreezeClone(records));
365
124
  return { line, snapshot };
366
125
  } });
@@ -377,184 +136,80 @@ const deepFreezeClone = (value) => {
377
136
  return freeze(structuredClone(value));
378
137
  };
379
138
 
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)`);
139
+ // The SEMANTIC half of the append, handed to the shared lane and run by it INSIDE the critical
140
+ // section on the LOCKED store snapshot (a writer's lock-free walk is advisory only the locked
141
+ // snapshot decides): per-kind chain legality, reference resolution, the closure rules, the
142
+ // counting-context gate, and supersession legality. An illegal record never lands. Throws a typed
143
+ // STOP; the lane releases the lock and re-throws.
144
+ const flowSemanticPreflight = ({ records, snapshot, storePath }) => {
145
+ if (snapshot.kind === CHAIN_KIND) {
146
+ const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
147
+ const existingSeq = validateChainSequence(chain);
148
+ if (!existingSeq.ok) {
149
+ 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)`);
150
+ }
151
+ const candidateSeq = validateChainSequence([...chain, snapshot]);
152
+ if (!candidateSeq.ok) {
153
+ 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`);
154
+ }
155
+ // Reference RESOLUTION (#63) on top of the structural half above: a step-OPENING round must
156
+ // digest-reference the chain's prior terminal; a round REVISION re-states its reference
157
+ // byte-bound (validateRoundRevision), so it is never re-classified against a moved terminal.
158
+ if (snapshot.purpose === 'round' && snapshot.opensFrom !== null && walkChainState(chain).mode === 'boundary') {
159
+ const ref = validateOpenerReference(records, snapshot);
160
+ if (!ref.ok) throw stop(`refusing a step-opening round: ${ref.reason} — nothing was written`);
161
+ }
162
+ if (snapshot.purpose === 'refresh') {
163
+ if (resolveRecordReference(records, snapshot.refreshedRecord) === undefined) {
164
+ 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`);
446
165
  }
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');
166
+ if (!isAuthoritativeReferenceTarget(records, snapshot.refreshedRecord)) {
167
+ 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');
474
168
  }
475
169
  }
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)`);
170
+ }
171
+ // The closure rule runs UNDER the lock on the captured snapshot a writer's lock-free
172
+ // usability pre-check can race a concurrent up/clear, and a justification minted after its
173
+ // mark closed can never satisfy the decide layer (#25), so the store refuses to strand it.
174
+ if (snapshot.kind === 'degrade-justification') {
175
+ const closed = records.some((r) => (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') && r.target === snapshot.downMark);
176
+ if (closed) {
177
+ 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');
505
178
  }
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`);
179
+ }
180
+ // The same P3-26 discipline for the consult-attestation (Phase-4): the writer derives
181
+ // {cycle, stepId, round} lock-free, so a concurrent converged/park/complete can close or move
182
+ // the step first — under the lock the named plan's chain must be LEGAL and hold an OPEN step
183
+ // (in-step, not parked, not completed) whose {cycle, stepId, round} EQUALS the record's; a
184
+ // stale consult context can never satisfy the decide layer, so the store refuses to strand it.
185
+ if (snapshot.kind === 'consult-attestation') {
186
+ const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
187
+ const seq = chain.length === 0 ? { ok: false, reason: 'no chain exists for that plan' } : validateChainSequence(chain);
188
+ if (!seq.ok) {
189
+ 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`);
509
190
  }
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
191
+ const state = walkChainState(chain);
192
+ const open = state.mode === 'in-step' && !state.parked && !state.completed;
193
+ if (!open || state.stepId !== snapshot.stepId || state.cycle !== snapshot.cycle || state.round !== snapshot.round) {
194
+ const shown = !open
195
+ ? (state.completed ? 'the plan is completed' : state.parked ? 'the plan is parked' : 'no step is open')
196
+ : `the open step is "${state.stepId}" (cycle ${state.cycle}, round ${state.round})`;
197
+ 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`);
552
198
  }
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 */ } }
199
+ }
200
+ // The Decision-7/8 counting-context gate runs UNDER the lock for BOTH append lanes (the
201
+ // factory computes a passing record; a hand-built one must satisfy the same rules).
202
+ if (snapshot.kind === 'subset-attempt') {
203
+ const gate = subsetAttemptGate(records, snapshot);
204
+ if (!gate.ok) throw stop(`refusing a subset-attempt: ${gate.reason} — nothing was written`);
205
+ }
206
+ const existingSup = validateSupersessions(records);
207
+ if (!existingSup.ok) {
208
+ 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)`);
209
+ }
210
+ const candidateSup = validateSupersessions([...records, snapshot]);
211
+ if (!candidateSup.ok) {
212
+ throw stop(`refusing an illegal supersession: ${candidateSup.reason} — the append-only store never absorbs a record that permanently reddens the checker; nothing was written`);
558
213
  }
559
214
  };
560
215
 
@@ -643,12 +298,9 @@ const subsetAttemptGate = (records, snapshot) => {
643
298
  export const SUBSET_RUN_LOCK_INFIX = '.subset-run';
644
299
 
645
300
  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) };
301
+ const resolved = flowAppendLane.resolveOrStop(cwd, env, 'serialize a subset run against');
302
+ const { lockPath, lockFd, lockIdentity } = flowAppendLane.acquireLock(`${resolved}${SUBSET_RUN_LOCK_INFIX}`, env, deps);
303
+ return { lockPath, release: () => flowAppendLane.releaseLock(lockPath, lockFd, lockIdentity, deps) };
652
304
  };
653
305
 
654
306
  // The pre-gate append-lock readiness probe (round-8 fold): acquire and immediately release the
@@ -657,12 +309,9 @@ export const acquireSubsetRunLock = ({ cwd = process.cwd(), env = process.env, d
657
309
  // Stated residual: a lock landing between this probe and the post-run append still refuses at
658
310
  // append time — closing that would mean holding the append lock across the whole gate run.
659
311
  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);
312
+ const resolved = flowAppendLane.resolveOrStop(cwd, env, 'probe');
313
+ const { lockPath, lockFd, lockIdentity } = flowAppendLane.acquireLock(resolved, env, deps);
314
+ const issue = flowAppendLane.releaseLock(lockPath, lockFd, lockIdentity, deps);
666
315
  if (issue != null) throw issue;
667
316
  };
668
317
 
@@ -703,7 +352,7 @@ export const appendSubsetAttempt = ({ cwd = process.cwd(), env = process.env, de
703
352
  // reach the digest domain.
704
353
  const subsetIds = Object.freeze([...derived]);
705
354
  let minted = null;
706
- const value = appendResolvedFlowRecord({ cwd, env, deps, makeRecord: (records) => {
355
+ const value = flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: (records) => {
707
356
  const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === expected.planId);
708
357
  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
358
  const seq = validateChainSequence(chain);