@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.
- package/CHANGELOG.md +68 -0
- package/README.md +1 -0
- package/SKILL.md +5 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
- package/bridges/antigravity-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +51 -4
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +616 -24
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +700 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
- package/bridges/codex-cli-bridge/capability.json +15 -10
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/dispatch.md +29 -0
- package/references/modes/receipt-deadline.md +3 -3
- package/tools/commands.mjs +7 -0
- package/tools/core-evidence.mjs +37 -3
- package/tools/detect-backends.mjs +5 -4
- package/tools/dispatch-record.mjs +10 -3
- package/tools/dispatch-store.mjs +392 -0
- package/tools/dispatch.mjs +1779 -0
- package/tools/doc-parity.mjs +10 -2
- package/tools/exec-producer.mjs +483 -0
- package/tools/exec-receipt.mjs +263 -0
- package/tools/flow-store.mjs +111 -462
- package/tools/receipt-deadline.mjs +25 -3
- package/tools/release-scan.mjs +33 -0
- package/tools/store-append.mjs +444 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// dispatch-store.mjs — the delegation-ledger IO (delegation Plan 1, Phase 2): common-dir path
|
|
2
|
+
// resolution, the fail-closed reader, the lock-serialized append with its SEMANTIC preflight, and
|
|
3
|
+
// the ONE named tree-fingerprint contract the records bind. No CLI, no side effects on import; the
|
|
4
|
+
// engine (dispatch.mjs, Phase 3) is the only surface that mints records for a human.
|
|
5
|
+
//
|
|
6
|
+
// This is the measurement substrate's storage half. The vocabulary (closed record family, the
|
|
7
|
+
// outcome enum with its allowed-successor table, the metric byte domains) lives in the PURE
|
|
8
|
+
// dispatch-record.mjs and validates ONE record at a time; everything that needs a store SNAPSHOT
|
|
9
|
+
// lives here: thread transitions and terminality, return↔dispatch correlation, fold resolution,
|
|
10
|
+
// the retry rules, and the wave rules.
|
|
11
|
+
//
|
|
12
|
+
// Why its OWN store (D2), separate from the review receipts (`agent-workflow-review-receipts.jsonl`,
|
|
13
|
+
// per-git-dir) and the flow store (`agent-workflow-flow.jsonl`): a shared file would let one class
|
|
14
|
+
// of line answer another class of question — the exact defect the D10 discriminator closes on the
|
|
15
|
+
// review-waiter side. The parity is enforced from BOTH ends: a review receipt entering here is an
|
|
16
|
+
// unknown kind and the reader counts it malformed (the closed family fails closed), and a
|
|
17
|
+
// delegation line reaching the receipts store never satisfies a review waiter. The basename resolves
|
|
18
|
+
// to the git COMMON dir (the flow-store precedent) because delegation accounting is worktree-SHARED,
|
|
19
|
+
// and the `AW_DELEGATION_STORE` seam takes the AW_FLOW_STORE rules verbatim: absolute only, no
|
|
20
|
+
// trailing separator.
|
|
21
|
+
//
|
|
22
|
+
// The lock/CAS discipline is NOT re-implemented here — it is the shared store-append.mjs leaf (D12),
|
|
23
|
+
// so both stores fail closed the same way (bounded lock waits with named refusals per holder class,
|
|
24
|
+
// custody-checked release, fd-based no-follow reads, snapshot-bound rename guarding). This module
|
|
25
|
+
// supplies the nouns, the seams, the validator, the parser and the semantic preflight.
|
|
26
|
+
//
|
|
27
|
+
// Reference domains stay SPLIT (D3): a fold references a return by its per-record CANONICAL digest;
|
|
28
|
+
// thread linkage (`nonce`, `retryOf`) is by nonce identity. The family has NO supersession — a
|
|
29
|
+
// record is never superseded, only closed — so resolution never has to pick a "latest" of a key,
|
|
30
|
+
// and a fold whose returnDigest resolves cross-thread or not at all is a refusal, never a guess.
|
|
31
|
+
//
|
|
32
|
+
// Honest limits: records remain forgeable (a self-discipline mechanism in the git dir, not a
|
|
33
|
+
// security boundary); the fingerprint contract below is blind to the index↔worktree split, which is
|
|
34
|
+
// why the metric additionally requires the dispatch's recorded CLEAN baseline (D5); and the
|
|
35
|
+
// pathname-race residuals of the shared leaf are inherited as declared there.
|
|
36
|
+
|
|
37
|
+
import { join, isAbsolute, normalize, sep } from 'node:path';
|
|
38
|
+
import {
|
|
39
|
+
validateDelegationRecord, canonicalDelegationDigest, allowedSuccessorKinds,
|
|
40
|
+
isThreadTerminalRecord,
|
|
41
|
+
} from './dispatch-record.mjs';
|
|
42
|
+
import { computeTreeFingerprint } from './core-evidence.mjs';
|
|
43
|
+
import { gitLine } from './flow-store-read.mjs';
|
|
44
|
+
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
45
|
+
import { createStoreAppendLane } from './store-append.mjs';
|
|
46
|
+
|
|
47
|
+
export const DELEGATION_STORE_STOP = 'DELEGATION_STORE_STOP';
|
|
48
|
+
// The ONE typed-STOP factory for this store — a caller classifies a delegation refusal by code.
|
|
49
|
+
export const delegationStoreStop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'DelegationStoreStop', code: DELEGATION_STORE_STOP });
|
|
50
|
+
const stop = delegationStoreStop;
|
|
51
|
+
|
|
52
|
+
export const DELEGATION_STORE_BASENAME = 'agent-workflow-delegation.jsonl';
|
|
53
|
+
export const DELEGATION_LOCK_SUFFIX = '.lock';
|
|
54
|
+
|
|
55
|
+
// ── path resolution (common dir + the AW_DELEGATION_STORE seam) ───────────────────────────────────
|
|
56
|
+
|
|
57
|
+
// resolveDelegationStorePath(cwd, env) → the ABSOLUTE store path, or null outside a git WORK tree.
|
|
58
|
+
// is-inside-work-tree gates explicitly (--git-common-dir also succeeds in a bare repo, and the probe
|
|
59
|
+
// prints "false" WITH exit 0, so the STRING is compared). The override must be absolute — a relative
|
|
60
|
+
// one would resolve a different ledger from each worktree/cwd.
|
|
61
|
+
export const resolveDelegationStorePath = (cwd, env = process.env) => {
|
|
62
|
+
if (env.AW_DELEGATION_STORE) {
|
|
63
|
+
if (!isAbsolute(env.AW_DELEGATION_STORE)) {
|
|
64
|
+
throw stop(`AW_DELEGATION_STORE must be an ABSOLUTE path (got "${env.AW_DELEGATION_STORE}") — a relative override resolves a different ledger from each worktree/cwd (fail closed)`);
|
|
65
|
+
}
|
|
66
|
+
const normalized = normalize(env.AW_DELEGATION_STORE);
|
|
67
|
+
// A trailing separator survives normalize() but not the appender's basename/join — refuse the fork.
|
|
68
|
+
if (normalized.endsWith(sep) || normalized.endsWith('/')) {
|
|
69
|
+
throw stop(`AW_DELEGATION_STORE must not end with a path separator (got "${env.AW_DELEGATION_STORE}") — a store is a file, not a directory (fail closed)`);
|
|
70
|
+
}
|
|
71
|
+
return normalized;
|
|
72
|
+
}
|
|
73
|
+
if (gitLine(['rev-parse', '--is-inside-work-tree'], cwd) !== 'true') return null;
|
|
74
|
+
const commonDir = gitLine(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
|
|
75
|
+
return commonDir == null ? null : join(commonDir, DELEGATION_STORE_BASENAME);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// The lock is a SIBLING derived from the resolved store path — one store, one lock, everywhere.
|
|
79
|
+
export const resolveDelegationLockPath = (storePath) => `${storePath}${DELEGATION_LOCK_SUFFIX}`;
|
|
80
|
+
|
|
81
|
+
// ── the fail-closed reader ────────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
// parseDelegationStoreText(raw) → { records, malformed, malformedReasons }. RAW file order is the
|
|
84
|
+
// only view: thread legality is an ordering property, and the family has no supersession, so there
|
|
85
|
+
// is no "authoritative" projection to compute. A line the closed family does not recognise — a
|
|
86
|
+
// review receipt included — is MALFORMED, never silently skipped.
|
|
87
|
+
export const parseDelegationStoreText = (raw) => {
|
|
88
|
+
const records = [];
|
|
89
|
+
const recordLines = [];
|
|
90
|
+
const malformedReasons = [];
|
|
91
|
+
const lines = String(raw).split('\n');
|
|
92
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
93
|
+
if (lines[i].trim() === '') continue;
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(lines[i]);
|
|
97
|
+
} catch {
|
|
98
|
+
malformedReasons.push(`line ${i + 1}: invalid JSON`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const v = validateDelegationRecord(parsed);
|
|
102
|
+
if (v.ok) {
|
|
103
|
+
records.push(parsed);
|
|
104
|
+
// The PHYSICAL line, carried beside the record: a reader that refuses has to say WHERE, and
|
|
105
|
+
// the record's index in a filtered array is not a place anyone can open.
|
|
106
|
+
recordLines.push(i + 1);
|
|
107
|
+
} else malformedReasons.push(`line ${i + 1}: ${v.reason}`);
|
|
108
|
+
}
|
|
109
|
+
return { records, recordLines, malformed: malformedReasons.length, malformedReasons };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// readDelegationStore(path, io?) → { records, malformed, malformedReasons, readError? }. Absent →
|
|
113
|
+
// empty (no records yet is not an error); any other failure → readError, and consumers fail closed
|
|
114
|
+
// on malformed > 0 or readError. A dangling symlink must NOT read as an empty ledger.
|
|
115
|
+
export const readDelegationStore = (path, io = {}) => {
|
|
116
|
+
const empty = () => ({ records: [], recordLines: [], malformed: 0, malformedReasons: [] });
|
|
117
|
+
const read = readRegularFileNoFollow(path, io);
|
|
118
|
+
if (read.outcome === 'absent') return empty();
|
|
119
|
+
if (read.outcome === 'foreign') return { ...empty(), readError: `the store is a ${read.className}, not a regular file — refusing to read it (fail closed)` };
|
|
120
|
+
if (read.outcome === 'error') return { ...empty(), readError: read.code };
|
|
121
|
+
return parseDelegationStoreText(read.content);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// ── D5: the ONE named tree-fingerprint contract ───────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
// Every tree digest this family records — a dispatch's preTreeDigest, a return's postTreeDigest, a
|
|
127
|
+
// fold's treeDigestAtFold — is THIS ONE computation, so two records can be compared for equality at
|
|
128
|
+
// all. The helper DELEGATES to the frozen core rather than re-deriving the payload: a second
|
|
129
|
+
// implementation of "the uncommitted state" is exactly how two digests of one tree would appear.
|
|
130
|
+
//
|
|
131
|
+
// Domain (pinned by the parity fixtures): staged diff + unstaged diff + untracked-not-ignored
|
|
132
|
+
// contents; an untracked symlink contributes name+target, an untracked directory or unstatable path
|
|
133
|
+
// contributes its name only, a binary file contributes a marker, and the never-committable classes
|
|
134
|
+
// (character device, block device, FIFO, socket) are excluded ENTIRELY — no marker at all.
|
|
135
|
+
//
|
|
136
|
+
// Honest limit, stated where it bites: the domain is blind to the index↔worktree split (the staged
|
|
137
|
+
// and unstaged diffs are concatenated), so a hunk moving into the index leaves it unchanged. That is
|
|
138
|
+
// why metric eligibility additionally requires the dispatch's recorded CLEAN baseline (D5) — the
|
|
139
|
+
// fingerprint alone cannot attribute bytes to a dispatch.
|
|
140
|
+
export const UNCOMMITTED_STATE_FINGERPRINT = 'the uncommitted-state fingerprint';
|
|
141
|
+
|
|
142
|
+
export const uncommittedStateFingerprint = (cwd = process.cwd(), fsx) => {
|
|
143
|
+
const fingerprint = computeTreeFingerprint(cwd, fsx);
|
|
144
|
+
if (fingerprint == null) {
|
|
145
|
+
throw stop(`cannot compute ${UNCOMMITTED_STATE_FINGERPRINT} — not inside a git work tree (or a git probe failed); a record never carries a null tree digest (fail closed)`);
|
|
146
|
+
}
|
|
147
|
+
return fingerprint;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// ── thread state (the store's ONE walk; the Phase-3 aggregator consumes it) ───────────────────────
|
|
151
|
+
|
|
152
|
+
const THREAD_KINDS = ['dispatch', 'return', 'fold', 'degrade'];
|
|
153
|
+
|
|
154
|
+
// delegationThreadState(records, nonce) → { records, dispatch, return, closure, last, terminal, open }
|
|
155
|
+
// over ONE nonce thread in RAW file order. A pre-dispatch degrade carries nonce null and therefore
|
|
156
|
+
// belongs to no thread. Terminality is the vocabulary's own predicate — never re-derived here.
|
|
157
|
+
export const delegationThreadState = (records, nonce) => {
|
|
158
|
+
const thread = records.filter((r) => THREAD_KINDS.includes(r.kind) && r.nonce === nonce);
|
|
159
|
+
const last = thread.length === 0 ? null : thread[thread.length - 1];
|
|
160
|
+
const dispatch = thread.find((r) => r.kind === 'dispatch') ?? null;
|
|
161
|
+
const terminal = last !== null && isThreadTerminalRecord(last);
|
|
162
|
+
return {
|
|
163
|
+
records: thread,
|
|
164
|
+
dispatch,
|
|
165
|
+
return: thread.find((r) => r.kind === 'return') ?? null,
|
|
166
|
+
closure: thread.find((r) => r.kind === 'fold' || r.kind === 'degrade') ?? null,
|
|
167
|
+
last,
|
|
168
|
+
terminal,
|
|
169
|
+
open: dispatch !== null && !terminal,
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// The cap that binds a retry chain is the ORIGIN's — walking back to retryIndex 0. A later contract
|
|
174
|
+
// may legitimately differ (a contract-refusal retry MUST differ), so reading the cap off the newest
|
|
175
|
+
// contract would let a fresh header manufacture a fresh budget. The seen-set is a tampered-store
|
|
176
|
+
// guard: an unresolvable or cyclic chain stops at the strictest link proven so far.
|
|
177
|
+
const retryChainOrigin = (records, dispatch) => {
|
|
178
|
+
let current = dispatch;
|
|
179
|
+
const seen = new Set([current.nonce]);
|
|
180
|
+
while (current.retryOf !== null) {
|
|
181
|
+
const prior = records.find((r) => r.kind === 'dispatch' && r.nonce === current.retryOf);
|
|
182
|
+
if (prior === undefined || seen.has(prior.nonce)) return current;
|
|
183
|
+
seen.add(prior.nonce);
|
|
184
|
+
current = prior;
|
|
185
|
+
}
|
|
186
|
+
return current;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const closureLabel = (last) =>
|
|
190
|
+
last.kind === 'fold' ? 'its fold'
|
|
191
|
+
: last.kind === 'degrade' ? 'its recorded degrade'
|
|
192
|
+
: `a terminal return (outcome "${last.outcome}")`;
|
|
193
|
+
|
|
194
|
+
// The kinds that name a wave and therefore require it to be REGISTERED first.
|
|
195
|
+
const WAVE_SCOPED_KINDS = ['dispatch', 'observation', 'degrade'];
|
|
196
|
+
|
|
197
|
+
// ── the semantic preflight (runs INSIDE the critical section, on the LOCKED snapshot) ─────────────
|
|
198
|
+
|
|
199
|
+
// Everything a single record cannot decide about itself. A writer's lock-free walk is advisory: only
|
|
200
|
+
// the snapshot under the lock can refuse a second dispatch, a stale return or a retry of a thread
|
|
201
|
+
// that closed a millisecond ago. Every refusal names the rule it enforces and states that nothing
|
|
202
|
+
// was written.
|
|
203
|
+
const delegationSemanticPreflight = ({ records, snapshot, storePath }) => {
|
|
204
|
+
// Byte-identical replay is the lane's own refusal; this is the CANONICAL one — a key-order
|
|
205
|
+
// permutation serializes differently but IS the same record, and the digest is record identity.
|
|
206
|
+
const digest = canonicalDelegationDigest(snapshot);
|
|
207
|
+
if (records.some((r) => canonicalDelegationDigest(r) === digest)) {
|
|
208
|
+
throw stop(`refusing a canonical duplicate: ${storePath} already carries this exact record (${digest.slice(0, 12)}…), however its keys are ordered — a genuine new record carries new content or a new timestamp; nothing was written`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The registration is RESOLVED and READ, not merely found: it fixes the classes, the pairing key
|
|
212
|
+
// and the minimum per class BEFORE the first observation it will count, so a record naming a
|
|
213
|
+
// class the wave never registered would invent an acceptance set after the fact.
|
|
214
|
+
const registration = records.find((r) => r.kind === 'pre-registration' && r.waveId === snapshot.waveId);
|
|
215
|
+
if (snapshot.kind === 'pre-registration') {
|
|
216
|
+
if (registration !== undefined) {
|
|
217
|
+
throw stop(`refusing a second pre-registration: the wave "${snapshot.waveId}" is already registered and a registration is IMMUTABLE per wave — thresholds a wave was registered under can never move under its own observations; nothing was written`);
|
|
218
|
+
}
|
|
219
|
+
} else if (WAVE_SCOPED_KINDS.includes(snapshot.kind)) {
|
|
220
|
+
if (registration === undefined) {
|
|
221
|
+
throw stop(`refusing a ${snapshot.kind} that names the UNREGISTERED wave "${snapshot.waveId}" — acceptance is PRE-REGISTERED before the first observation it will count, so the thresholds can never be chosen after the fact; register the wave first; nothing was written`);
|
|
222
|
+
}
|
|
223
|
+
if (!registration.stepClasses.includes(snapshot.stepClass)) {
|
|
224
|
+
throw stop(`refusing a ${snapshot.kind}: step class "${snapshot.stepClass}" is not among the classes wave "${snapshot.waveId}" registered (${registration.stepClasses.join(' | ')}) — the registered set IS the acceptance set; nothing was written`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const nonce = typeof snapshot.nonce === 'string' ? snapshot.nonce : null;
|
|
229
|
+
if (nonce === null) return; // a pre-registration, an observation, or a pre-dispatch degrade
|
|
230
|
+
const state = delegationThreadState(records, nonce);
|
|
231
|
+
|
|
232
|
+
if (snapshot.kind === 'dispatch') {
|
|
233
|
+
if (state.dispatch !== null) {
|
|
234
|
+
throw stop(`refusing a duplicate dispatch: nonce "${nonce}" already carries a dispatch — a nonce IS the thread identity and is minted once; nothing was written`);
|
|
235
|
+
}
|
|
236
|
+
if (snapshot.retryOf !== null) {
|
|
237
|
+
const prior = delegationThreadState(records, snapshot.retryOf);
|
|
238
|
+
if (prior.dispatch === null) {
|
|
239
|
+
throw stop(`refusing a retry: no dispatch for nonce "${snapshot.retryOf}" is in the store — a retry names the thread it retries; nothing was written`);
|
|
240
|
+
}
|
|
241
|
+
// At most ONE retry successor per thread. Bounding only the chain's DEPTH would leave its
|
|
242
|
+
// BRANCHING free — n2, n3, n4 … all legally at retryIndex 1 — and the cap would be decorative.
|
|
243
|
+
// It is also what retryChainOrigin already assumes when it walks back to index 0.
|
|
244
|
+
const successor = records.find((r) => r.kind === 'dispatch' && r.retryOf === snapshot.retryOf);
|
|
245
|
+
if (successor !== undefined) {
|
|
246
|
+
throw stop(`refusing a retry: thread "${snapshot.retryOf}" already has the retry successor "${successor.nonce}" — a thread is retried at most ONCE, or the recorded cap would bound only the chain's depth while its branching stayed free; nothing was written`);
|
|
247
|
+
}
|
|
248
|
+
if (!prior.terminal) {
|
|
249
|
+
throw stop(`refusing a retry: thread "${snapshot.retryOf}" is still OPEN — a thread is retried only after it closed (a success or acceptance-failure return stays live until its fold or degrade); nothing was written`);
|
|
250
|
+
}
|
|
251
|
+
// Terminality alone is not enough: a FOLD is also terminal, and a folded thread's work was
|
|
252
|
+
// accepted into the tree. Retrying it would mint a second counted thread over the same work
|
|
253
|
+
// (D7 counts a folded success in `n` with its metric), inflating both the count and the mean.
|
|
254
|
+
// The legal retry origins are the ones that recorded a FAILED attempt: a terminal-failure
|
|
255
|
+
// return, or a degrade (the recorded no-fold closure).
|
|
256
|
+
if (prior.last.kind === 'fold') {
|
|
257
|
+
throw stop(`refusing a retry: thread "${snapshot.retryOf}" was closed by its fold — folding accepts the attempt into the tree, so a folded thread is never a retry origin whatever its return's outcome was; open a NEW thread instead; nothing was written`);
|
|
258
|
+
}
|
|
259
|
+
if (snapshot.retryIndex !== prior.dispatch.retryIndex + 1) {
|
|
260
|
+
throw stop(`refusing a retry: retryIndex ${snapshot.retryIndex} must be ${prior.dispatch.retryIndex + 1} — a retry increments its origin's index by exactly one, so the chain length is the index; nothing was written`);
|
|
261
|
+
}
|
|
262
|
+
if (snapshot.waveId !== prior.dispatch.waveId) {
|
|
263
|
+
throw stop(`refusing a retry: it names wave "${snapshot.waveId}" but its retry origin "${snapshot.retryOf}" was dispatched in wave "${prior.dispatch.waveId}" — a retry stays in its origin's wave, or one thread would count in two acceptance sets; nothing was written`);
|
|
264
|
+
}
|
|
265
|
+
if (snapshot.stepClass !== prior.dispatch.stepClass) {
|
|
266
|
+
throw stop(`refusing a retry: it declares step class "${snapshot.stepClass}" but its retry origin "${snapshot.retryOf}" was dispatched as "${prior.dispatch.stepClass}" — the pairing key is the step class, so a chain that changes it mid-way would split one attempt's accounting across two classes; nothing was written`);
|
|
267
|
+
}
|
|
268
|
+
const origin = retryChainOrigin(records, prior.dispatch);
|
|
269
|
+
if (snapshot.retryIndex > origin.retryCap) {
|
|
270
|
+
throw stop(`refusing a retry: retryIndex ${snapshot.retryIndex} exceeds the retryCap ${origin.retryCap} recorded on the thread's ORIGIN dispatch ("${origin.nonce}") — a fresh contract never manufactures a fresh retry budget; nothing was written`);
|
|
271
|
+
}
|
|
272
|
+
if (prior.return !== null && prior.return.outcome === 'contract-refusal' && snapshot.contractDigest === prior.dispatch.contractDigest) {
|
|
273
|
+
throw stop(`refusing a retry: it retries a contract-refusal thread and must carry a DIFFERENT contractDigest — an unchanged contract would only be refused again, and a retry loop on one contract is exactly what the cap exists to prevent; nothing was written`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (state.dispatch === null) {
|
|
280
|
+
throw stop(`refusing a ${snapshot.kind}: no dispatch for nonce "${nonce}" is in the store — a thread opens with its dispatch, and a record that binds to nothing is never absorbed; nothing was written`);
|
|
281
|
+
}
|
|
282
|
+
if (snapshot.kind === 'return' && state.return !== null) {
|
|
283
|
+
throw stop(`refusing a second return: nonce "${nonce}" already carries a return (outcome "${state.return.outcome}") — one dispatch answers exactly once, so a stale return never lands; nothing was written`);
|
|
284
|
+
}
|
|
285
|
+
const allowed = allowedSuccessorKinds(state.last);
|
|
286
|
+
if (!allowed.includes(snapshot.kind)) {
|
|
287
|
+
throw stop(state.terminal
|
|
288
|
+
? `refusing a ${snapshot.kind}: thread "${nonce}" is already closed by ${closureLabel(state.last)} — a closed thread never absorbs another record; nothing was written`
|
|
289
|
+
: `refusing a ${snapshot.kind}: nonce "${nonce}" carries no return to fold — the thread's last record is a ${state.last.kind}, whose only legal successors are ${allowed.join(' | ')}; nothing was written`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (snapshot.kind === 'return') {
|
|
293
|
+
// The nonce binds the thread; these three bind the IDENTITY of what was dispatched — a return
|
|
294
|
+
// from another backend, against another contract, or from another tree is not this answer.
|
|
295
|
+
for (const field of ['backend', 'contractDigest', 'preTreeDigest']) {
|
|
296
|
+
if (snapshot[field] !== state.dispatch[field]) {
|
|
297
|
+
throw stop(`refusing a return: ${field} "${snapshot[field]}" does not equal its dispatch's "${state.dispatch[field]}" — a return is bound to the dispatch it answers by nonce AND by identity; nothing was written`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// D5's directional implication, enforced where it CAN be: `baselineClean` lives on the DISPATCH,
|
|
301
|
+
// so the record validator had to admit `dirty-baseline` without being able to verify it. A dirty
|
|
302
|
+
// baseline FORCES ineligibility — but never renames a STRICTER reason the return's own fields
|
|
303
|
+
// already substantiate (the validator pinned that one locally, and the producer can prove it);
|
|
304
|
+
// a CLEAN baseline forbids the claim outright, so an eligible metric is never silently
|
|
305
|
+
// downgraded by an unsubstantiated override.
|
|
306
|
+
if (state.dispatch.baselineClean === false) {
|
|
307
|
+
if (snapshot.metric.eligible) {
|
|
308
|
+
throw stop(`refusing a return: its dispatch recorded baselineClean:false, so the metric is INELIGIBLE — the uncommitted-state fingerprint is blind to the index↔worktree split, so a dirty baseline cannot attribute bytes to this dispatch (D5); record eligible:false with ineligibleReason "dirty-baseline" (or the stricter reason this return's own fields substantiate); nothing was written`);
|
|
309
|
+
}
|
|
310
|
+
} else if (snapshot.metric.ineligibleReason === 'dirty-baseline') {
|
|
311
|
+
throw stop(`refusing a return: it claims ineligibleReason "dirty-baseline" while its dispatch recorded baselineClean:true — the override is unsubstantiated, and only the dispatch decides that fact; nothing was written`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (snapshot.kind === 'degrade') {
|
|
316
|
+
// A degrade CLOSES a thread, and a closed thread counts in its wave's aggregation with L = 0
|
|
317
|
+
// (D7). Left unbound, a degrade declaring another (registered) wave or class would report one
|
|
318
|
+
// thread into two acceptance sets — the same reason the cross-wave retry rule exists.
|
|
319
|
+
for (const field of ['waveId', 'stepClass']) {
|
|
320
|
+
if (snapshot[field] !== state.dispatch[field]) {
|
|
321
|
+
throw stop(`refusing a degrade: ${field} "${snapshot[field]}" does not equal its dispatch's "${state.dispatch[field]}" — a thread is closed inside the wave and step class it was dispatched in; nothing was written`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (snapshot.kind === 'fold') {
|
|
327
|
+
const target = records.find((r) => canonicalDelegationDigest(r) === snapshot.returnDigest);
|
|
328
|
+
if (target === undefined) {
|
|
329
|
+
throw stop(`refusing a fold: returnDigest ${snapshot.returnDigest.slice(0, 12)}… matches no record in the store — a fold binds an EXISTING return by its canonical digest; nothing was written`);
|
|
330
|
+
}
|
|
331
|
+
if (target.kind !== 'return') {
|
|
332
|
+
throw stop(`refusing a fold: returnDigest resolves to a ${target.kind} record, not a return — a fold folds a return; nothing was written`);
|
|
333
|
+
}
|
|
334
|
+
if (target.nonce !== nonce) {
|
|
335
|
+
throw stop(`refusing a fold: returnDigest resolves to the return of nonce "${target.nonce}", not this fold's "${nonce}" — a fold never reaches across threads; nothing was written`);
|
|
336
|
+
}
|
|
337
|
+
if (snapshot.treeDigestAtFold !== target.postTreeDigest) {
|
|
338
|
+
throw stop(`refusing a fold: treeDigestAtFold ${snapshot.treeDigestAtFold.slice(0, 12)}… does not equal the folded return's postTreeDigest ${target.postTreeDigest.slice(0, 12)}… — the tree moved between the return and the fold, so what was folded is not what was returned; nothing was written`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// ── the read-side audit (the append path's rules, replayed) ───────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
// auditDelegationStoreSemantics({ records, recordLines, storePath }) → { ok: true } | { ok: false,
|
|
346
|
+
// line, reason }. The reader validates ONE record at a time; every cross-record rule — duplicate
|
|
347
|
+
// nonce, transition legality, correlation, the wave and retry rules, canonical duplicates — lives in
|
|
348
|
+
// the append preflight and was never re-run on read, so a ledger the append path would have REFUSED
|
|
349
|
+
// still parsed as a pile of valid records. A consumer computing over it (the Phase-3 aggregator's
|
|
350
|
+
// per-thread walk) would count a duplicated nonce twice and inflate its own statistic.
|
|
351
|
+
//
|
|
352
|
+
// The replay closes that by running the SAME preflight per record against the prefix before it, in
|
|
353
|
+
// file order — so legality has ONE authority (this store) rather than a second, drifting copy in
|
|
354
|
+
// each reader. It stops at the FIRST illegal record and names its physical line; a consumer never
|
|
355
|
+
// computes over a "legal prefix", because a ledger that lost a record mid-file is not a smaller
|
|
356
|
+
// ledger, it is an unexplained one.
|
|
357
|
+
//
|
|
358
|
+
// Honest limit, unchanged: this is not a security boundary. A forger can write a fully consistent
|
|
359
|
+
// ledger just as easily. What the replay defends against is a BUGGY producer — the exec-side
|
|
360
|
+
// wrapper Plan 2 introduces — and a hand-edit that got the rules wrong.
|
|
361
|
+
export const auditDelegationStoreSemantics = ({ records, recordLines = [], storePath = '(store)' } = {}) => {
|
|
362
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
363
|
+
try {
|
|
364
|
+
delegationSemanticPreflight({ records: records.slice(0, i), snapshot: records[i], storePath });
|
|
365
|
+
} catch (err) {
|
|
366
|
+
return { ok: false, line: recordLines[i] ?? i + 1, reason: err.message };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { ok: true };
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// ── the append ────────────────────────────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
const delegationAppendLane = createStoreAppendLane({
|
|
375
|
+
nouns: { store: 'delegation store', adj: 'delegation-store', record: 'delegation record' },
|
|
376
|
+
envNames: { store: 'AW_DELEGATION_STORE', waitKnob: 'AW_DELEGATION_LOCK_WAIT_MS', pollKnob: 'AW_DELEGATION_LOCK_POLL_MS' },
|
|
377
|
+
stop,
|
|
378
|
+
resolveStorePath: resolveDelegationStorePath,
|
|
379
|
+
resolveLockPath: resolveDelegationLockPath,
|
|
380
|
+
validateRecord: validateDelegationRecord,
|
|
381
|
+
parseStoreText: parseDelegationStoreText,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// The store path is always RESOLVED (cwd/env), never caller-supplied — a raw path param would bypass
|
|
385
|
+
// the absolute-normalization door the AW_DELEGATION_STORE seam enforces. The record is validated and
|
|
386
|
+
// serialized ONCE up front; the semantic rules then run under the lock on the captured snapshot.
|
|
387
|
+
export const appendDelegationRecord = ({ cwd = process.cwd(), record, env = process.env, deps = {} } = {}) => {
|
|
388
|
+
const { line, snapshot } = delegationAppendLane.captureRecordSnapshot(record);
|
|
389
|
+
return delegationAppendLane.appendResolvedRecord({
|
|
390
|
+
cwd, env, deps, preflight: delegationSemanticPreflight, makeRecord: () => ({ line, snapshot }),
|
|
391
|
+
});
|
|
392
|
+
};
|