@sabaiway/agent-workflow-kit 5.10.0 → 5.11.1
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 +152 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +13 -2
- package/references/hooks/state-block-guard.mjs +14 -2
- package/references/modes/commit-guard.md +11 -8
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/dispatch.md +32 -10
- package/references/modes/worktrees.md +47 -3
- package/references/scripts/archive-changelog.mjs +14 -3
- package/references/scripts/archive-decisions.mjs +14 -3
- package/references/scripts/archive-issues.mjs +14 -3
- package/references/scripts/check-docs-size.mjs +14 -3
- package/references/scripts/migrate-gates.mjs +13 -2
- package/tools/ack-write.mjs +3 -3
- package/tools/advisor-matrix.mjs +165 -0
- package/tools/autonomy-doctor.mjs +2 -3
- package/tools/bridge-settings.mjs +2 -3
- package/tools/cheap-agents.mjs +3 -3
- package/tools/commands.mjs +4 -5
- package/tools/commit-guard.mjs +77 -20
- package/tools/core-evidence.mjs +12 -3
- package/tools/coverage-check.mjs +2 -3
- package/tools/delegation.mjs +2 -3
- package/tools/detect-backends.mjs +2 -3
- package/tools/dispatch-advisor.mjs +323 -0
- package/tools/dispatch.mjs +174 -109
- package/tools/doc-parity.mjs +69 -16
- package/tools/family-registry.mjs +3 -3
- package/tools/flow-adoption-mint.mjs +70 -0
- package/tools/flow-append.mjs +309 -0
- package/tools/flow-chain-state.mjs +91 -0
- package/tools/flow-check-cores.mjs +35 -6
- package/tools/flow-check-rungs.mjs +20 -2
- package/tools/flow-check.mjs +22 -8
- package/tools/flow-delta-proof.mjs +307 -0
- package/tools/flow-record.mjs +1 -1
- package/tools/flow-store-read.mjs +3 -3
- package/tools/flow-store.mjs +35 -812
- package/tools/flow-subset-budget.mjs +81 -0
- package/tools/flow-writer.mjs +3 -3
- package/tools/gate-hook.mjs +3 -3
- package/tools/gates-init.mjs +3 -3
- package/tools/grounding.mjs +2 -3
- package/tools/hide-footprint.mjs +2 -3
- package/tools/inject-methodology.mjs +2 -3
- package/tools/lens-region.mjs +2 -3
- package/tools/manifest/validate.mjs +2 -3
- package/tools/migrate-adr-store.mjs +3 -3
- package/tools/observation-builder.mjs +123 -0
- package/tools/path-inventory.mjs +2 -3
- package/tools/procedures.mjs +3 -3
- package/tools/receipt-deadline.mjs +2 -3
- package/tools/recipes.mjs +2 -3
- package/tools/recommendations.mjs +3 -3
- package/tools/release-scan.mjs +2 -3
- package/tools/repo-search.mjs +2 -3
- package/tools/review-state.mjs +3 -3
- package/tools/run-gates.mjs +2 -3
- package/tools/sandbox-masks.mjs +3 -3
- package/tools/satellite-locator.mjs +179 -0
- package/tools/set-autonomy.mjs +2 -3
- package/tools/set-flow.mjs +3 -3
- package/tools/set-recipe.mjs +2 -3
- package/tools/setup-backends.mjs +3 -3
- package/tools/store-append.mjs +2 -2
- package/tools/uninstall.mjs +2 -3
- package/tools/velocity-profile.mjs +3 -3
- package/tools/worktree-handoff-return.mjs +369 -0
- package/tools/worktree-prompt.mjs +190 -0
- package/tools/worktrees-record.mjs +171 -0
- package/tools/worktrees.mjs +311 -300
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// flow-adoption-mint.mjs — the adoption mint (#58): the frontmatter planId reader and mintAdoption,
|
|
2
|
+
// which binds a plan's chain identity to {frontmatter planId, plan content digest} and lands the
|
|
3
|
+
// chain's FIRST record. Split out of flow-store.mjs unchanged (baseline-practices tranche 2); the
|
|
4
|
+
// facade re-exports both names.
|
|
5
|
+
//
|
|
6
|
+
// The plan file is READ, never written. Imports run ONE way: this leaf mints through the store's
|
|
7
|
+
// ONE append door (flow-append.mjs) and never reaches the flow-store.mjs facade or its sibling mint
|
|
8
|
+
// leaf — the one-line sha256Hex below is a deliberate copy rather than a sideways import.
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import { FLOW_SCHEMA_VERSION, CHAIN_KIND, canonicalFlowDigest } from './flow-record.mjs';
|
|
14
|
+
import { resolveBase, computeTreeFingerprint } from './core-evidence.mjs';
|
|
15
|
+
import { flowStoreStop, resolveFlowStorePath, readFlowStore, deriveFlowOwner } from './flow-store-read.mjs';
|
|
16
|
+
import { appendFlowRecord } from './flow-append.mjs';
|
|
17
|
+
|
|
18
|
+
const stop = flowStoreStop;
|
|
19
|
+
const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
20
|
+
|
|
21
|
+
const PLAN_ID_FRONTMATTER_HINT = 'planId: <your-stable-plan-id>';
|
|
22
|
+
|
|
23
|
+
// Identity binds only a CLOSED leading frontmatter block — an unterminated block never yields an
|
|
24
|
+
// id; CRLF is normalized per line so line endings never fork chain identity.
|
|
25
|
+
export const readPlanFrontmatterId = (text) => {
|
|
26
|
+
const lines = text.split('\n').map((line) => line.replace(/\r$/, ''));
|
|
27
|
+
if (lines[0]?.trim() !== '---') return null;
|
|
28
|
+
const close = lines.findIndex((line, i) => i > 0 && line.trim() === '---');
|
|
29
|
+
if (close === -1) return null;
|
|
30
|
+
for (const line of lines.slice(1, close)) {
|
|
31
|
+
const m = /^planId:[ \t]*(\S+)[ \t]*$/.exec(line);
|
|
32
|
+
if (m) return m[1];
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const mintAdoption = ({ cwd = process.cwd(), env = process.env, deps = {}, planPath, planLabel, cycle = 1, commitEpoch = 0, timestamp = new Date().toISOString() } = {}) => {
|
|
38
|
+
const owner = deriveFlowOwner(cwd);
|
|
39
|
+
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)');
|
|
40
|
+
let planBytes;
|
|
41
|
+
try {
|
|
42
|
+
planBytes = readFileSync(resolve(cwd, planPath));
|
|
43
|
+
} catch (err) {
|
|
44
|
+
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)`);
|
|
45
|
+
}
|
|
46
|
+
const planId = readPlanFrontmatterId(planBytes.toString('utf8'));
|
|
47
|
+
if (planId == null) {
|
|
48
|
+
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)`);
|
|
49
|
+
}
|
|
50
|
+
const planDigest = sha256Hex(planBytes);
|
|
51
|
+
// A pre-append read purely for the NAMED refusal: the locked append would refuse a second
|
|
52
|
+
// adoption anyway, but only this comparison can surface whether the plan content still matches.
|
|
53
|
+
const resolved = resolveFlowStorePath(cwd, env);
|
|
54
|
+
const adopted = resolved == null ? undefined : readFlowStore(resolved).records
|
|
55
|
+
.find((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.planId === planId);
|
|
56
|
+
if (adopted !== undefined) {
|
|
57
|
+
throw stop(adopted.planDigest === planDigest
|
|
58
|
+
? `plan "${planId}" is already adopted (content digest unchanged — a rename never resets chain identity); adoption is only ever the chain's first record`
|
|
59
|
+
: `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`);
|
|
60
|
+
}
|
|
61
|
+
const fingerprint = computeTreeFingerprint(cwd);
|
|
62
|
+
if (fingerprint == null) throw stop('cannot compute the tree fingerprint — the adoption record binds {base, fingerprint} (fail closed)');
|
|
63
|
+
const record = {
|
|
64
|
+
schema: FLOW_SCHEMA_VERSION, kind: CHAIN_KIND, purpose: 'adoption', planId, cycle, round: 0,
|
|
65
|
+
commitEpoch, owner, base: resolveBase(cwd), timestamp, stepId: null, fingerprint,
|
|
66
|
+
planLabel: planLabel ?? planId, createdAt: timestamp, planDigest,
|
|
67
|
+
};
|
|
68
|
+
const { writtenPath } = appendFlowRecord({ cwd, record, env, deps });
|
|
69
|
+
return { writtenPath, record, digest: canonicalFlowDigest(record) };
|
|
70
|
+
};
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// flow-append.mjs — the flow store's ONE write door (flow-orchestration Phase 2, extracted from
|
|
2
|
+
// flow-store.mjs unchanged by baseline-practices tranche 2): the flow lane over the parameterized
|
|
3
|
+
// createStoreAppendLane, the SEMANTIC append preflight, the two generic append entries, the
|
|
4
|
+
// subset-run serializer + the pre-gate lock probe, and the Decision-7 locked subset-attempt factory.
|
|
5
|
+
//
|
|
6
|
+
// The lock/CAS discipline itself lives one module further down in store-append.mjs (delegation
|
|
7
|
+
// Plan 1 D12); this leaf injects the flow store's nouns, env seams, knob names, validator, parser
|
|
8
|
+
// and the per-kind legality the lane runs inside the critical section.
|
|
9
|
+
//
|
|
10
|
+
// Imports run ONE way: this module composes the two PURE leaves (flow-chain-state.mjs,
|
|
11
|
+
// flow-subset-budget.mjs) and the read half; the two mint leaves compose THIS one. Nothing here
|
|
12
|
+
// reaches back up to the flow-store.mjs facade — that edge would be the cycle
|
|
13
|
+
// test/read-graph-purity.test.mjs reds.
|
|
14
|
+
//
|
|
15
|
+
// flowSemanticPreflight, deepFreezeClone and the lane's own captureRecordSnapshot/
|
|
16
|
+
// appendResolvedRecord stay PRIVATE: the factory-only rule for subset-attempt records is kept by
|
|
17
|
+
// NOT publishing them, so a hand-built record can never forge a fresh counting context.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
FLOW_SCHEMA_VERSION, CHAIN_KIND, validateFlowRecord, validateChainSequence, validateSupersessions,
|
|
21
|
+
canonicalFlowDigest, subsetFoldBatchDigest, subsetGateIdsDigest, SUBSET_ATTEMPT_DIAGNOSIS_FROM,
|
|
22
|
+
} from './flow-record.mjs';
|
|
23
|
+
import { derivePregateSubsetIds, GATES_REL } from './gates-declaration.mjs';
|
|
24
|
+
import { CONFIG_REL } from './orchestration-config.mjs';
|
|
25
|
+
import { createStoreAppendLane } from './store-append.mjs';
|
|
26
|
+
import {
|
|
27
|
+
flowStoreStop, resolveFlowStorePath, resolveFlowLockPath, parseFlowStoreText, deriveFlowOwner,
|
|
28
|
+
} from './flow-store-read.mjs';
|
|
29
|
+
import {
|
|
30
|
+
walkChainState, resolveRecordReference, isAuthoritativeReferenceTarget, validateOpenerReference,
|
|
31
|
+
} from './flow-chain-state.mjs';
|
|
32
|
+
import {
|
|
33
|
+
SUBSET_ATTEMPT_MAX_REDS, SUBSET_ATTEMPT_DIAGNOSIS_REDS, subsetAttemptState, subsetAttemptGate,
|
|
34
|
+
} from './flow-subset-budget.mjs';
|
|
35
|
+
|
|
36
|
+
const stop = flowStoreStop;
|
|
37
|
+
|
|
38
|
+
// Wait bound + poll cadence; the env knobs keep hermetic tests off wall-clock.
|
|
39
|
+
export const FLOW_LOCK_WAIT_MS = 10_000;
|
|
40
|
+
export const FLOW_LOCK_POLL_MS = 100;
|
|
41
|
+
|
|
42
|
+
// ── the shared append lane (D12) ──────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
// The lock/CAS discipline, the fd-custody rules and the serialized append are the EXTRACTION of
|
|
45
|
+
// exactly this module's former code into store-append.mjs, so behavior is unchanged by
|
|
46
|
+
// construction: this store injects its nouns (every refusal still names the flow store), its env
|
|
47
|
+
// seam and knob names, its typed-STOP factory, its record validator, its store-text parser, and
|
|
48
|
+
// the SEMANTIC preflight below. The flow suites are the characterization bar for that claim.
|
|
49
|
+
const flowAppendLane = createStoreAppendLane({
|
|
50
|
+
nouns: { store: 'flow store', adj: 'flow-store', record: 'flow record' },
|
|
51
|
+
envNames: { store: 'AW_FLOW_STORE', waitKnob: 'AW_FLOW_LOCK_WAIT_MS', pollKnob: 'AW_FLOW_LOCK_POLL_MS' },
|
|
52
|
+
stop,
|
|
53
|
+
resolveStorePath: resolveFlowStorePath,
|
|
54
|
+
resolveLockPath: resolveFlowLockPath,
|
|
55
|
+
validateRecord: validateFlowRecord,
|
|
56
|
+
parseStoreText: parseFlowStoreText,
|
|
57
|
+
lockWaitMs: FLOW_LOCK_WAIT_MS,
|
|
58
|
+
lockPollMs: FLOW_LOCK_POLL_MS,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const captureRecordSnapshot = flowAppendLane.captureRecordSnapshot;
|
|
62
|
+
|
|
63
|
+
// ── the ONE append (validated, semantic-preflighted, lock-serialized, atomic) ─────────────────────
|
|
64
|
+
|
|
65
|
+
// The store path is always RESOLVED (cwd/env), never caller-supplied — a raw path param would
|
|
66
|
+
// bypass the absolute-normalization door the AW_FLOW_STORE seam enforces. Read, write, and unlock
|
|
67
|
+
// all use the CANONICAL pair acquire returned — nothing is re-derived mid-append.
|
|
68
|
+
export const appendFlowRecord = ({ cwd = process.cwd(), record, env = process.env, deps = {} } = {}) => {
|
|
69
|
+
const { line, snapshot } = captureRecordSnapshot(record);
|
|
70
|
+
// Round-9 fold: subset-attempt records are minted ONLY by the locked factory — foldBatch,
|
|
71
|
+
// subsetDigest, and attemptIndex are DERIVED inside its critical section, and a hand-built
|
|
72
|
+
// record could forge a fresh counting context and bypass the hard-stop budget.
|
|
73
|
+
if (snapshot.kind === 'subset-attempt') {
|
|
74
|
+
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)');
|
|
75
|
+
}
|
|
76
|
+
return flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: () => ({ line, snapshot }) });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// appendFlowRecordWithPreflight — the generic lane plus a caller `preflight(records)` hook that
|
|
80
|
+
// runs INSIDE the critical section on the locked store snapshot (Plan 4 Phase 3 / round-1 fold
|
|
81
|
+
// F6): a writer's lock-free cap/completeness walk is advisory — the locked snapshot decides, so
|
|
82
|
+
// a concurrent append can never slip a stale terminal (or a stranding round) through. The hook
|
|
83
|
+
// receives a DEEP-FROZEN CLONE (round-1 fold M5): a buggy preflight throws on any mutation
|
|
84
|
+
// attempt and can never skew the bytes the semantic validation and the write see. A throwing
|
|
85
|
+
// preflight refuses the append with nothing written. The subset-attempt factory-only rule holds
|
|
86
|
+
// on this lane too.
|
|
87
|
+
export const appendFlowRecordWithPreflight = ({ cwd = process.cwd(), record, env = process.env, deps = {}, preflight = null } = {}) => {
|
|
88
|
+
const { line, snapshot } = captureRecordSnapshot(record);
|
|
89
|
+
if (snapshot.kind === 'subset-attempt') {
|
|
90
|
+
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)');
|
|
91
|
+
}
|
|
92
|
+
return flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: (records) => {
|
|
93
|
+
if (preflight != null) preflight(deepFreezeClone(records));
|
|
94
|
+
return { line, snapshot };
|
|
95
|
+
} });
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const deepFreezeClone = (value) => {
|
|
99
|
+
const freeze = (v) => {
|
|
100
|
+
if (v !== null && typeof v === 'object') {
|
|
101
|
+
Object.values(v).forEach(freeze);
|
|
102
|
+
Object.freeze(v);
|
|
103
|
+
}
|
|
104
|
+
return v;
|
|
105
|
+
};
|
|
106
|
+
return freeze(structuredClone(value));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// The SEMANTIC half of the append, handed to the shared lane and run by it INSIDE the critical
|
|
110
|
+
// section on the LOCKED store snapshot (a writer's lock-free walk is advisory — only the locked
|
|
111
|
+
// snapshot decides): per-kind chain legality, reference resolution, the closure rules, the
|
|
112
|
+
// counting-context gate, and supersession legality. An illegal record never lands. Throws a typed
|
|
113
|
+
// STOP; the lane releases the lock and re-throws.
|
|
114
|
+
const flowSemanticPreflight = ({ records, snapshot, storePath }) => {
|
|
115
|
+
if (snapshot.kind === CHAIN_KIND) {
|
|
116
|
+
const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
|
|
117
|
+
const existingSeq = validateChainSequence(chain);
|
|
118
|
+
if (!existingSeq.ok) {
|
|
119
|
+
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)`);
|
|
120
|
+
}
|
|
121
|
+
const candidateSeq = validateChainSequence([...chain, snapshot]);
|
|
122
|
+
if (!candidateSeq.ok) {
|
|
123
|
+
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`);
|
|
124
|
+
}
|
|
125
|
+
// Reference RESOLUTION (#63) on top of the structural half above: a step-OPENING round must
|
|
126
|
+
// digest-reference the chain's prior terminal; a round REVISION re-states its reference
|
|
127
|
+
// byte-bound (validateRoundRevision), so it is never re-classified against a moved terminal.
|
|
128
|
+
if (snapshot.purpose === 'round' && snapshot.opensFrom !== null && walkChainState(chain).mode === 'boundary') {
|
|
129
|
+
const ref = validateOpenerReference(records, snapshot);
|
|
130
|
+
if (!ref.ok) throw stop(`refusing a step-opening round: ${ref.reason} — nothing was written`);
|
|
131
|
+
}
|
|
132
|
+
if (snapshot.purpose === 'refresh') {
|
|
133
|
+
if (resolveRecordReference(records, snapshot.refreshedRecord) === undefined) {
|
|
134
|
+
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`);
|
|
135
|
+
}
|
|
136
|
+
if (!isAuthoritativeReferenceTarget(records, snapshot.refreshedRecord)) {
|
|
137
|
+
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');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// The closure rule runs UNDER the lock on the captured snapshot — a writer's lock-free
|
|
142
|
+
// usability pre-check can race a concurrent up/clear, and a justification minted after its
|
|
143
|
+
// mark closed can never satisfy the decide layer (#25), so the store refuses to strand it.
|
|
144
|
+
if (snapshot.kind === 'degrade-justification') {
|
|
145
|
+
const closed = records.some((r) => (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') && r.target === snapshot.downMark);
|
|
146
|
+
if (closed) {
|
|
147
|
+
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');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// The same P3-26 discipline for the consult-attestation (Phase-4): the writer derives
|
|
151
|
+
// {cycle, stepId, round} lock-free, so a concurrent converged/park/complete can close or move
|
|
152
|
+
// the step first — under the lock the named plan's chain must be LEGAL and hold an OPEN step
|
|
153
|
+
// (in-step, not parked, not completed) whose {cycle, stepId, round} EQUALS the record's; a
|
|
154
|
+
// stale consult context can never satisfy the decide layer, so the store refuses to strand it.
|
|
155
|
+
if (snapshot.kind === 'consult-attestation') {
|
|
156
|
+
const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
|
|
157
|
+
const seq = chain.length === 0 ? { ok: false, reason: 'no chain exists for that plan' } : validateChainSequence(chain);
|
|
158
|
+
if (!seq.ok) {
|
|
159
|
+
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`);
|
|
160
|
+
}
|
|
161
|
+
const state = walkChainState(chain);
|
|
162
|
+
const open = state.mode === 'in-step' && !state.parked && !state.completed;
|
|
163
|
+
if (!open || state.stepId !== snapshot.stepId || state.cycle !== snapshot.cycle || state.round !== snapshot.round) {
|
|
164
|
+
const shown = !open
|
|
165
|
+
? (state.completed ? 'the plan is completed' : state.parked ? 'the plan is parked' : 'no step is open')
|
|
166
|
+
: `the open step is "${state.stepId}" (cycle ${state.cycle}, round ${state.round})`;
|
|
167
|
+
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`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// The Decision-7/8 counting-context gate runs UNDER the lock for BOTH append lanes (the
|
|
171
|
+
// factory computes a passing record; a hand-built one must satisfy the same rules).
|
|
172
|
+
if (snapshot.kind === 'subset-attempt') {
|
|
173
|
+
const gate = subsetAttemptGate(records, snapshot);
|
|
174
|
+
if (!gate.ok) throw stop(`refusing a subset-attempt: ${gate.reason} — nothing was written`);
|
|
175
|
+
}
|
|
176
|
+
const existingSup = validateSupersessions(records);
|
|
177
|
+
if (!existingSup.ok) {
|
|
178
|
+
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)`);
|
|
179
|
+
}
|
|
180
|
+
const candidateSup = validateSupersessions([...records, snapshot]);
|
|
181
|
+
if (!candidateSup.ok) {
|
|
182
|
+
throw stop(`refusing an illegal supersession: ${candidateSup.reason} — the append-only store never absorbs a record that permanently reddens the checker; nothing was written`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// ── the Decision-7 subset-run serializer (round-6 fold) ──────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
// --pre-review's WHOLE armed cycle (budget preflight → gates → append) holds this lock: without
|
|
189
|
+
// it a parallel run executes gates whose red can no longer be recorded once the winner lands,
|
|
190
|
+
// and an unrecorded red undercounts the budget ("EVERY subset-run red counts"). A SEPARATE lock
|
|
191
|
+
// file beside the store — never the store lock itself, so appends from other lanes never block
|
|
192
|
+
// behind a minutes-long gate run — riding the same CAS/fd-custody/holder-liveness discipline: a
|
|
193
|
+
// crashed holder surfaces as the named DEAD refusal with its rm recovery; a live holder is a
|
|
194
|
+
// bounded loud wait (the queued run then re-reads the budget and re-decides).
|
|
195
|
+
export const SUBSET_RUN_LOCK_INFIX = '.subset-run';
|
|
196
|
+
|
|
197
|
+
export const acquireSubsetRunLock = ({ cwd = process.cwd(), env = process.env, deps = {} } = {}) => {
|
|
198
|
+
const resolved = flowAppendLane.resolveOrStop(cwd, env, 'serialize a subset run against');
|
|
199
|
+
const { lockPath, lockFd, lockIdentity } = flowAppendLane.acquireLock(`${resolved}${SUBSET_RUN_LOCK_INFIX}`, env, deps);
|
|
200
|
+
return { lockPath, release: () => flowAppendLane.releaseLock(lockPath, lockFd, lockIdentity, deps) };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// The pre-gate append-lock readiness probe (round-8 fold): acquire and immediately release the
|
|
204
|
+
// ORDINARY append lock through the full acquire discipline — a DEAD/foreign/malformed lock or
|
|
205
|
+
// an unwritable parent surfaces BEFORE any gate spends, with the acquire's own named refusal.
|
|
206
|
+
// Stated residual: a lock landing between this probe and the post-run append still refuses at
|
|
207
|
+
// append time — closing that would mean holding the append lock across the whole gate run.
|
|
208
|
+
export const probeFlowAppendLock = ({ cwd = process.cwd(), env = process.env, deps = {} } = {}) => {
|
|
209
|
+
const resolved = flowAppendLane.resolveOrStop(cwd, env, 'probe');
|
|
210
|
+
const { lockPath, lockFd, lockIdentity } = flowAppendLane.acquireLock(resolved, env, deps);
|
|
211
|
+
const issue = flowAppendLane.releaseLock(lockPath, lockFd, lockIdentity, deps);
|
|
212
|
+
if (issue != null) throw issue;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// appendSubsetAttempt — the Decision-7 locked append factory: the chain identity is captured
|
|
216
|
+
// BEFORE the gates run (the caller's `expected` {planId, cycle, stepId, round}) and re-checked
|
|
217
|
+
// under the append lock against the OPEN owning chain; attemptIndex, foldBatch/subsetDigest
|
|
218
|
+
// derivation, and the hard-stop state are computed from the captured store snapshot INSIDE the
|
|
219
|
+
// critical section — a concurrent appender never duplicates an index, and a round/park/complete
|
|
220
|
+
// landing mid-run refuses the append (never a silent misfile). subsetGateIds states what the
|
|
221
|
+
// caller RAN — only the caller knows that — but it never DECIDES the counting context: the
|
|
222
|
+
// factory re-derives the subset from the declaration + config itself (the R10 rider, via the
|
|
223
|
+
// gates-declaration leaf) and refuses a mismatch, so a caller-chosen id list can never forge a
|
|
224
|
+
// fresh subsetDigest and bypass the hard-stop budget.
|
|
225
|
+
export const appendSubsetAttempt = ({ cwd = process.cwd(), env = process.env, deps = {}, expected, subsetGateIds, status, diagnosis = null, base, fingerprint, timestamp = new Date().toISOString() } = {}) => {
|
|
226
|
+
const owner = deriveFlowOwner(cwd);
|
|
227
|
+
if (owner == null) throw stop('not inside a git work tree — the subset-attempt mint derives the owning worktree from git (fail closed)');
|
|
228
|
+
if (expected == null || typeof expected.planId !== 'string' || expected.planId.length === 0
|
|
229
|
+
|| !Number.isInteger(expected.cycle) || !Number.isInteger(expected.round)
|
|
230
|
+
|| (expected.stepId !== null && typeof expected.stepId !== 'string')) {
|
|
231
|
+
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)');
|
|
232
|
+
}
|
|
233
|
+
if (!Array.isArray(subsetGateIds)) throw stop("subsetGateIds must be the derived subset's ordered gate-id array (fail closed)");
|
|
234
|
+
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)`);
|
|
235
|
+
if (diagnosis !== null && (typeof diagnosis !== 'string' || diagnosis.length === 0)) {
|
|
236
|
+
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)`);
|
|
237
|
+
}
|
|
238
|
+
let derived;
|
|
239
|
+
try {
|
|
240
|
+
derived = derivePregateSubsetIds(cwd);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
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)`);
|
|
243
|
+
}
|
|
244
|
+
if (derived.length !== subsetGateIds.length || derived.some((id, i) => id !== subsetGateIds[i])) {
|
|
245
|
+
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)`);
|
|
246
|
+
}
|
|
247
|
+
// Everything downstream binds the factory-owned DERIVED ids — the caller array stays mutable in
|
|
248
|
+
// the caller's hands (a deps lock-hook could rewrite it after the check above) and must never
|
|
249
|
+
// reach the digest domain.
|
|
250
|
+
const subsetIds = Object.freeze([...derived]);
|
|
251
|
+
let minted = null;
|
|
252
|
+
const value = flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: (records) => {
|
|
253
|
+
const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === expected.planId);
|
|
254
|
+
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)`);
|
|
255
|
+
const seq = validateChainSequence(chain);
|
|
256
|
+
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)`);
|
|
257
|
+
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)`);
|
|
258
|
+
const state = walkChainState(chain);
|
|
259
|
+
const open = !state.completed && !state.parked;
|
|
260
|
+
const held = open && state.cycle === expected.cycle && state.stepId === expected.stepId && (state.round ?? 0) === expected.round;
|
|
261
|
+
if (!held) {
|
|
262
|
+
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}}`;
|
|
263
|
+
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)`);
|
|
264
|
+
}
|
|
265
|
+
if (expected.stepId === null && state.openers.length > 0) {
|
|
266
|
+
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)`);
|
|
267
|
+
}
|
|
268
|
+
// Round-9 fold: the EXACTLY-ONE-open-owning-chain rule is re-derived UNDER the lock — an
|
|
269
|
+
// adoption/resume landing after the caller's preflight would otherwise record the attempt
|
|
270
|
+
// into an already-ambiguous context. (After the specific refusals above, so a parked or
|
|
271
|
+
// moved TARGET chain keeps its own named diagnosis.)
|
|
272
|
+
const openOwn = [...new Set(records.filter((r) => r.kind === CHAIN_KIND && r.owner === owner).map((r) => r.planId))].filter((planId) => {
|
|
273
|
+
const c = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
|
|
274
|
+
if (c[0].purpose !== 'adoption' || c[0].owner !== owner || !validateChainSequence(c).ok) return false;
|
|
275
|
+
const s = walkChainState(c);
|
|
276
|
+
return !s.completed && !s.parked;
|
|
277
|
+
});
|
|
278
|
+
if (openOwn.length !== 1 || openOwn[0] !== expected.planId) {
|
|
279
|
+
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)`);
|
|
280
|
+
}
|
|
281
|
+
const probe = { planId: expected.planId, cycle: expected.cycle, stepId: expected.stepId, foldBatch: subsetFoldBatchDigest(expected), subsetDigest: subsetGateIdsDigest(subsetIds) };
|
|
282
|
+
const budget = subsetAttemptState(records, probe);
|
|
283
|
+
const attemptIndex = budget.nextIndex;
|
|
284
|
+
if (budget.reds >= SUBSET_ATTEMPT_DIAGNOSIS_REDS && (typeof diagnosis !== 'string' || diagnosis.length === 0)) {
|
|
285
|
+
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`);
|
|
286
|
+
}
|
|
287
|
+
if (attemptIndex < SUBSET_ATTEMPT_DIAGNOSIS_FROM && diagnosis != null) {
|
|
288
|
+
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)`);
|
|
289
|
+
}
|
|
290
|
+
const { line, snapshot } = captureRecordSnapshot({
|
|
291
|
+
schema: FLOW_SCHEMA_VERSION, kind: 'subset-attempt', planId: expected.planId, cycle: expected.cycle,
|
|
292
|
+
stepId: expected.stepId, foldBatch: probe.foldBatch, subsetDigest: probe.subsetDigest, attemptIndex,
|
|
293
|
+
...(typeof diagnosis === 'string' ? { diagnosis } : {}), status, base, fingerprint, timestamp,
|
|
294
|
+
});
|
|
295
|
+
// Computed UNDER the lock from the captured snapshot — a lock-free preflight state could
|
|
296
|
+
// pick the wrong message under a concurrent append.
|
|
297
|
+
const consumedPermit = budget.reds >= SUBSET_ATTEMPT_MAX_REDS;
|
|
298
|
+
const redsAfter = budget.reds + (status === 'red' ? 1 : 0);
|
|
299
|
+
const creditsAfter = budget.credits - (consumedPermit ? 1 : 0);
|
|
300
|
+
minted = {
|
|
301
|
+
attemptIndex,
|
|
302
|
+
redsAtKey: redsAfter,
|
|
303
|
+
reopened: consumedPermit,
|
|
304
|
+
exhaustedAfter: redsAfter >= SUBSET_ATTEMPT_MAX_REDS && creditsAfter <= 0,
|
|
305
|
+
};
|
|
306
|
+
return { line, snapshot };
|
|
307
|
+
} });
|
|
308
|
+
return { ...value, ...minted, digest: canonicalFlowDigest(value.record) };
|
|
309
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// flow-chain-state.mjs — the chain-state walk and the generic prior-terminal reference validator
|
|
2
|
+
// (#63): priorChainTerminal, walkChainState, resolveRecordReference, isAuthoritativeReferenceTarget
|
|
3
|
+
// and validateOpenerReference. Split out of flow-store.mjs unchanged (baseline-practices tranche 2),
|
|
4
|
+
// which now re-exports every name here.
|
|
5
|
+
//
|
|
6
|
+
// PURE over read results and the LOWEST of the store's five leaves: no store IO, no git, no fs, and
|
|
7
|
+
// the record vocabulary is its only tools sibling. Imports run ONE way — flow-append.mjs composes
|
|
8
|
+
// this module; nothing here reaches back up to the facade.
|
|
9
|
+
|
|
10
|
+
import { CHAIN_KIND, canonicalFlowDigest, authoritativeFlowRecords } from './flow-record.mjs';
|
|
11
|
+
|
|
12
|
+
const TERMINAL_PURPOSES = ['adoption', 'converged', 'complete'];
|
|
13
|
+
|
|
14
|
+
// The record a step-opening round must reference: the latest converged/complete, else the adoption
|
|
15
|
+
// record itself (the plan's first step — the exemption is explicit, never inferred).
|
|
16
|
+
export const priorChainTerminal = (chain) => {
|
|
17
|
+
let terminal = null;
|
|
18
|
+
for (const r of chain) {
|
|
19
|
+
if (r.purpose === 'adoption' && terminal === null) terminal = r;
|
|
20
|
+
else if (r.purpose === 'converged' || r.purpose === 'complete') terminal = r;
|
|
21
|
+
}
|
|
22
|
+
return terminal;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// walkChainState(chain) → { mode, parked, completed, cycle, round, stepId, openers, lastTerminal }
|
|
26
|
+
// over ONE plan's raw-order chain. Legality lives in validateChainSequence — callers run it first;
|
|
27
|
+
// this walk only derives state, including each opener with its at-that-point prior terminal.
|
|
28
|
+
export const walkChainState = (chain) => {
|
|
29
|
+
const state = {
|
|
30
|
+
mode: 'boundary', parked: false, completed: false,
|
|
31
|
+
cycle: chain[0]?.cycle ?? null, round: chain[0]?.round ?? null, stepId: null,
|
|
32
|
+
openers: [], lastTerminal: null,
|
|
33
|
+
};
|
|
34
|
+
for (const r of chain) {
|
|
35
|
+
state.cycle = r.cycle;
|
|
36
|
+
if (r.purpose === 'adoption') { state.lastTerminal = r; state.round = r.round; continue; }
|
|
37
|
+
if (r.purpose === 'park') { state.parked = true; continue; }
|
|
38
|
+
if (r.purpose === 'resume') { state.parked = false; continue; }
|
|
39
|
+
if (r.purpose === 'complete') { state.completed = true; state.lastTerminal = r; continue; }
|
|
40
|
+
if (r.purpose === 'converged') { state.mode = 'boundary'; state.lastTerminal = r; state.stepId = null; continue; }
|
|
41
|
+
if (r.purpose === 'unfreeze' && state.mode === 'boundary') { state.mode = 'in-step'; state.stepId = r.stepId; state.round = r.round; continue; }
|
|
42
|
+
if (r.purpose === 'round') {
|
|
43
|
+
if (state.mode === 'boundary') {
|
|
44
|
+
state.openers.push({ record: r, priorTerminal: state.lastTerminal });
|
|
45
|
+
state.mode = 'in-step';
|
|
46
|
+
state.stepId = r.stepId;
|
|
47
|
+
state.round = r.round;
|
|
48
|
+
} else if (r.round > state.round) state.round = r.round;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return state;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Reference checks live ENTIRELY in the digest domain — two byte-different records with one
|
|
55
|
+
// canonical serialization are ONE identity, so object identity never decides resolution or
|
|
56
|
+
// authority. resolveRecordReference returns the LAST matching record (consistent with the
|
|
57
|
+
// latest-per-key authoritative selection); the prefix (records BEFORE the referencing one) is the
|
|
58
|
+
// resolution domain, so an out-of-order reference never resolves.
|
|
59
|
+
export const resolveRecordReference = (prefixRecords, digest) =>
|
|
60
|
+
prefixRecords.findLast((r) => canonicalFlowDigest(r) === digest);
|
|
61
|
+
|
|
62
|
+
export const isAuthoritativeReferenceTarget = (scopeRecords, digest) =>
|
|
63
|
+
authoritativeFlowRecords(scopeRecords).some((r) => canonicalFlowDigest(r) === digest);
|
|
64
|
+
|
|
65
|
+
// validateOpenerReference(prefixRecords, candidate) → { ok } | { ok: false, reason }. The named
|
|
66
|
+
// classification of a step-opening round's prior-terminal reference: unresolved · non-chain ·
|
|
67
|
+
// another plan · non-terminal · superseded · not-the-prior-terminal.
|
|
68
|
+
export const validateOpenerReference = (prefixRecords, candidate) => {
|
|
69
|
+
const target = resolveRecordReference(prefixRecords, candidate.opensFrom);
|
|
70
|
+
if (target === undefined) {
|
|
71
|
+
return { ok: false, reason: `the prior-terminal reference does not match the store — no record digests to ${candidate.opensFrom.slice(0, 12)}…` };
|
|
72
|
+
}
|
|
73
|
+
if (target.kind !== CHAIN_KIND) {
|
|
74
|
+
return { ok: false, reason: `the prior-terminal reference targets a ${target.kind} record, not a chain terminal` };
|
|
75
|
+
}
|
|
76
|
+
if (target.planId !== candidate.planId) {
|
|
77
|
+
return { ok: false, reason: `the prior-terminal reference targets another plan's record ("${target.planId}") — a step never opens from a foreign chain` };
|
|
78
|
+
}
|
|
79
|
+
if (!TERMINAL_PURPOSES.includes(target.purpose)) {
|
|
80
|
+
return { ok: false, reason: `the prior-terminal reference targets a non-terminal record (purpose "${target.purpose}") — an opener references adoption, converged, or complete only` };
|
|
81
|
+
}
|
|
82
|
+
const chain = prefixRecords.filter((r) => r.kind === CHAIN_KIND && r.planId === candidate.planId);
|
|
83
|
+
if (!isAuthoritativeReferenceTarget(chain, candidate.opensFrom)) {
|
|
84
|
+
return { ok: false, reason: 'the prior-terminal reference targets a superseded record — reference the latest record of that key' };
|
|
85
|
+
}
|
|
86
|
+
const prior = priorChainTerminal(chain);
|
|
87
|
+
if (prior == null || canonicalFlowDigest(prior) !== candidate.opensFrom) {
|
|
88
|
+
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` };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true };
|
|
91
|
+
};
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
short, shellQuote, writerCommand,
|
|
21
21
|
collectUnansweredRedRefusals, collectDegradeCoverageRefusals, collectReceiptCoverageRefusals,
|
|
22
22
|
} from './flow-check-rungs.mjs';
|
|
23
|
+
import { CONTENT_FREE_FINGERPRINT } from './core-evidence.mjs';
|
|
23
24
|
|
|
24
25
|
// The checker only refuses — park/resume/complete are explicit writer actions (#59). Printed
|
|
25
26
|
// operand shapes: flag values ride the inline --flag='value' form and positionals follow a
|
|
@@ -128,10 +129,21 @@ const deltaRefusals = (records, owner) => {
|
|
|
128
129
|
// degrade after a final-start at the same fingerprint refuses unless a LATER final-start at that
|
|
129
130
|
// fingerprint completed (its `final` record landed after it). The checker reads raw records,
|
|
130
131
|
// never the authoritative selection (#65).
|
|
131
|
-
const degradeOrderingRefusals = (coreRecords) => {
|
|
132
|
+
const degradeOrderingRefusals = (coreRecords, advisories) => {
|
|
132
133
|
const refusals = [];
|
|
134
|
+
let contentFree = 0;
|
|
133
135
|
coreRecords.forEach((r, i) => {
|
|
134
136
|
if (r.kind !== 'degrade') return;
|
|
137
|
+
// A degrade carries no base and no attempt, so on the content-free fingerprint two records
|
|
138
|
+
// from unrelated clean moments pair up and this rung refuses EVERY commit, whatever tree is
|
|
139
|
+
// being judged. Their order is not a fact about any tree, so it decides nothing here. Stated
|
|
140
|
+
// residual: #64 is therefore unenforceable for a degrade minted on a clean tree until the
|
|
141
|
+
// record carries a base — the queued store migration owns that, and a clean tree gates nothing
|
|
142
|
+
// meanwhile.
|
|
143
|
+
if (r.fingerprint === CONTENT_FREE_FINGERPRINT) {
|
|
144
|
+
contentFree += 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
135
147
|
const startedBefore = coreRecords.some((s, j) => j < i && s.kind === 'final-start' && s.fingerprint === r.fingerprint);
|
|
136
148
|
if (!startedBefore) return;
|
|
137
149
|
const cured = coreRecords.some((s, j) => j > i && s.kind === 'final-start' && s.fingerprint === r.fingerprint
|
|
@@ -140,6 +152,9 @@ const degradeOrderingRefusals = (coreRecords) => {
|
|
|
140
152
|
refusals.push(`a core degrade (backend "${r.backend}") landed AFTER a final-start at its fingerprint (${short(r.fingerprint)}) with no later completed re-run at it — degrades mint strictly BEFORE the final run (#64); re-run run-gates.mjs --final on this tree`);
|
|
141
153
|
}
|
|
142
154
|
});
|
|
155
|
+
if (contentFree > 0) {
|
|
156
|
+
advisories.push(`${contentFree} core degrade(s) minted on a CONTENT-FREE tree are outside the ordering rung (#64): the record carries no base, so two clean moments cannot be shown to be one and their order states nothing about any tree`);
|
|
157
|
+
}
|
|
143
158
|
return refusals;
|
|
144
159
|
};
|
|
145
160
|
|
|
@@ -225,7 +240,7 @@ const baseMotionRefusals = (chain, planId, owner, motion) => {
|
|
|
225
240
|
// Phase-1 rungs (#65/#25/#42 — each self-gates on an OWN adoption). Absent inputs keep the decision
|
|
226
241
|
// byte-identical to the Plan-2 checker. `consumer` rides through to the #65 lane split and defaults
|
|
227
242
|
// to the STRICT lane, so a caller that forgets to thread it inherits strictness.
|
|
228
|
-
export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard' }) => {
|
|
243
|
+
export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard', treeCarriesBytes = true }) => {
|
|
229
244
|
const refusals = [];
|
|
230
245
|
const advisories = [];
|
|
231
246
|
if (flowRead.readError) refusals.push(`the flow store is unreadable (${flowRead.readError}) — the checker consumes the FULL read-result; inspect ${flowPath} (fail closed)`);
|
|
@@ -243,11 +258,25 @@ export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flo
|
|
|
243
258
|
if (motion != null && plan.integrityClean) refusals.push(...baseMotionRefusals(chain, planId, owner, motion));
|
|
244
259
|
}
|
|
245
260
|
refusals.push(...deltaRefusals(records, owner));
|
|
246
|
-
refusals.push(...degradeOrderingRefusals(coreRead.records));
|
|
261
|
+
refusals.push(...degradeOrderingRefusals(coreRead.records, advisories));
|
|
247
262
|
if (evidence != null) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
263
|
+
// The base-keyed rung always runs — it asks about this BASE's gate history, which a tree with
|
|
264
|
+
// no content does not change. The two FINGERPRINT-keyed rungs do not, when the CALLER states
|
|
265
|
+
// that the tree carries no bytes: their coverage would then be demanded of the one fingerprint
|
|
266
|
+
// every clean moment shares, so whatever they found there was minted by another moment and
|
|
267
|
+
// possibly another base (the symmetry the #65 content-free arm and commit-guard's content-free
|
|
268
|
+
// lanes state — such evidence must decide nothing, in either direction). The fact is DECLARED
|
|
269
|
+
// by the caller rather than derived here: only the caller knows whether it is judging a commit
|
|
270
|
+
// at all, and a checker that keyed it off the fingerprint alone would also silence the rungs
|
|
271
|
+
// for every routine clean-tree check, where they are exactly what the operator asked for. The
|
|
272
|
+
// skip is RECORDED, never silent.
|
|
273
|
+
refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner, consumer, currentFingerprint: evidence.tree.fingerprint, advisories }));
|
|
274
|
+
if (!treeCarriesBytes) {
|
|
275
|
+
advisories.push('the caller states this tree carries NO bytes, so the fingerprint-keyed correlations are skipped: degrade coverage (#25) and receipt coverage (#42) here, and the D10 flow-to-final binding at the guard — evidence at a content-free fingerprint belongs to some other clean moment');
|
|
276
|
+
} else {
|
|
277
|
+
refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
|
|
278
|
+
refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
|
|
279
|
+
}
|
|
251
280
|
}
|
|
252
281
|
return { refusals, advisories };
|
|
253
282
|
};
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
CHAIN_KIND, canonicalFlowDigest, authoritativeFlowRecords, flowTreeIdentity,
|
|
14
14
|
} from './flow-record.mjs';
|
|
15
15
|
import { resolveRecordReference } from './flow-store.mjs';
|
|
16
|
-
import { authoritativeOfKind, summarizeReviewReceiptsForTree } from './core-evidence.mjs';
|
|
16
|
+
import { authoritativeOfKind, summarizeReviewReceiptsForTree, CONTENT_FREE_FINGERPRINT } from './core-evidence.mjs';
|
|
17
17
|
import { FALLBACK_LENS_ADDITIONAL_ONLY } from './cheap-agents.mjs';
|
|
18
18
|
|
|
19
19
|
export const short = (digest) => `${digest.slice(0, 12)}…`;
|
|
@@ -161,7 +161,21 @@ export const evaluateVetoOverride = ({ records, vetoReceipt, tree }) => {
|
|
|
161
161
|
// runner-attested capability (a one-time unpublished nonce over stdin or an inherited FD, verified
|
|
162
162
|
// against a one-way commitment recorded in the final-start); it needs its own IPC contract and is
|
|
163
163
|
// QUEUED, never pretended here.
|
|
164
|
-
|
|
164
|
+
//
|
|
165
|
+
// The CONTENT-FREE arm (FINGERPRINT-BASE-BINDING, second face): a red final minted while the work
|
|
166
|
+
// tree was CLEAN hashes an empty payload, and that one value is shared by every clean moment of
|
|
167
|
+
// every repository — so the base correlation resolves to as many bases as the store has clean
|
|
168
|
+
// moments and refuses fail-closed forever, on a record that describes no working state at all.
|
|
169
|
+
// The arm is keyed on the FINGERPRINT, not on how many bases happen to correlate: resolving to
|
|
170
|
+
// exactly one base is an accident of store history, never a statement about the tree. It is one
|
|
171
|
+
// half of a symmetry, and it is only sound WITH the other: content-free evidence must decide
|
|
172
|
+
// nothing in either direction, so commit-guard equally refuses to ATTEST from a content-free
|
|
173
|
+
// receipt (commit-guard.mjs, the two content-free lanes) — without that half, stepping over a red
|
|
174
|
+
// here would leave a stale green free to acquit. The skip is RECORDED in the `advisories` sink —
|
|
175
|
+
// a rung that steps over evidence says so. That sink is the ONLY place the skip is observable:
|
|
176
|
+
// decideFlowCheck always passes one, and a caller that omits it (a test, a future consumer) gets
|
|
177
|
+
// the same refusals and no record of the step.
|
|
178
|
+
export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, currentBase, owner, consumer = 'commit-guard', currentFingerprint = null, advisories = [] }) => {
|
|
165
179
|
if (!hasOwnAdoption(flowRecords, owner)) return [];
|
|
166
180
|
const adoptionInstants = flowRecords
|
|
167
181
|
.filter((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.owner === owner)
|
|
@@ -192,6 +206,10 @@ export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, current
|
|
|
192
206
|
for (const { r, i } of finals) {
|
|
193
207
|
if (r.status !== 'red') continue;
|
|
194
208
|
if (armingInstant !== null && isCanonicalInstant(r.timestamp) && Date.parse(r.timestamp) < armingInstant) continue;
|
|
209
|
+
if (r.fingerprintBefore === CONTENT_FREE_FINGERPRINT) {
|
|
210
|
+
advisories.push(`a red final (attempt "${r.attempt}") is OUTSIDE the rung: its tree fingerprint ${short(r.fingerprintBefore)} is CONTENT-FREE — a clean work tree emits an empty payload, so the value identifies no working state, correlates to no base, and gates no commit (#65 content-free arm)`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
195
213
|
const bases = basesAt(r.fingerprintBefore);
|
|
196
214
|
if (bases.length === 0) {
|
|
197
215
|
refusals.push(`a red final (attempt "${r.attempt}") cannot be base-correlated: no flow record carries its tree fingerprint ${short(r.fingerprintBefore)} — the zero-base lane is a fail-closed ambiguity (#65); the rung demands exactly ONE base through the flow store`);
|