diff --git a/.github/workflows/merge-queue-triage.yml b/.github/workflows/merge-queue-triage.yml
index f8567ec772..4afd00800e 100644
--- a/.github/workflows/merge-queue-triage.yml
+++ b/.github/workflows/merge-queue-triage.yml
@@ -143,8 +143,19 @@ jobs:
const run = context.payload.workflow_run;
const { owner, repo } = context.repo;
- // Queue branches are named gh-readonly-queue//pr--.
- const m = /^gh-readonly-queue\/.+\/pr-(\d+)-[0-9a-f]{40}$/.exec(run.head_branch ?? '');
+ // Queue branches are named gh-readonly-queue//pr--, and
+ // BOTH captures are load-bearing. The trailing sha is not decoration:
+ // it is the commit the queue built this PR ON TOP OF, so it names the
+ // PR's position in GitHub's speculative stack. Reading it is what lets
+ // limb ② tell "another PR hit this too" from "another PR inherited this
+ // one's tree" — see `stackEdges` below. It was matched but discarded
+ // until 2026-08-29, which is why the victim counts read as flake
+ // evidence when they were measuring queue depth.
+ //
+ // The base-branch segment is `.+` rather than `[^/]+` because a base
+ // branch may itself contain slashes (`release/v5`).
+ const QUEUE_REF = /^gh-readonly-queue\/.+\/pr-(\d+)-([0-9a-f]{40})$/;
+ const m = QUEUE_REF.exec(run.head_branch ?? '');
if (!m) {
core.info(`head_branch '${run.head_branch}' is not a merge-queue branch — nothing to do.`);
return;
@@ -324,6 +335,97 @@ jobs:
r.conclusion === 'failure' && (r.head_branch ?? '').includes(`/pr-${prNumber}-`)).length;
const queueFailures24h = ciRuns.filter((r) => r.conclusion === 'failure').length;
+ // runId -> { base, head } for every merge_group run in the window,
+ // plus this one. `recent` is ALREADY in hand for the two counts above,
+ // and a run object already carries `head_branch` and `head_sha` — so
+ // the speculative stack costs ZERO additional API calls. That is the
+ // whole reason this discriminator is affordable in a job whose header
+ // calls itself rate-limit-shaped.
+ //
+ // `recent` is deliberately NOT narrowed to this workflow_id here: the
+ // sightings below are keyed by run id, and a wider index can only
+ // resolve more of them.
+ const stackEdges = new Map();
+ const noteRun = (r) => {
+ const q = QUEUE_REF.exec(r?.head_branch ?? '');
+ // A head_sha that is not a full sha cannot be compared to a base by
+ // equality, and an equality that silently never holds is the same
+ // failure as no data at all — so it is left OUT of the index rather
+ // than stored as something a comparison would quietly reject.
+ if (!q || !/^[0-9a-f]{40}$/.test(String(r?.head_sha ?? ''))) return;
+ stackEdges.set(String(r.id), { base: q[2], head: String(r.head_sha) });
+ };
+ for (const r of recent) noteRun(r);
+ noteRun(run);
+
+ /**
+ * Partition a key's victim PRs into speculative STACKS.
+ *
+ * Under speculative stacking GitHub builds each queued PR on top of
+ * the previous entry's queue head, so a build whose BASE commit IS
+ * another victim's queue HEAD contains that victim's tree by
+ * construction. One deterministic break therefore ejects everyone
+ * behind it, and the raw victim count measures QUEUE DEPTH.
+ *
+ * ⚠️ The test is full-sha equality in the strict direction: one
+ * victim's base == another victim's head. ⛔ Never a base PREFIX and
+ * ⛔ never time ordering — both would fuse genuinely independent hits
+ * into one stack, trading this limb's false positive for a false
+ * negative, which is strictly worse. The count exists to be true.
+ *
+ * A victim whose runs are not in the index (a run older than the
+ * window, or a ref that is not a queue ref) is left as its own group:
+ * "cannot prove downstream" must read as INDEPENDENT, never as
+ * same-stack. `unresolved` reports how many of those there were, so a
+ * partial index cannot pass as a complete one.
+ */
+ const stacksFor = (byPr) => {
+ const parent = new Map([...byPr.keys()].map((p) => [p, p]));
+ const find = (x) => { while (parent.get(x) !== x) x = parent.get(x); return x; };
+ const union = (a, b) => { const ra = find(a); const rb = find(b); if (ra !== rb) parent.set(ra, rb); };
+
+ // head sha -> the victim PRs that PRODUCED it.
+ const producers = new Map();
+ for (const [pr, runs] of byPr) {
+ for (const id of runs) {
+ const e = stackEdges.get(String(id));
+ if (!e) continue;
+ if (!producers.has(e.head)) producers.set(e.head, new Set());
+ producers.get(e.head).add(pr);
+ }
+ }
+
+ const inherited = new Set();
+ let unresolved = 0;
+ for (const [pr, runs] of byPr) {
+ let resolved = false;
+ for (const id of runs) {
+ const e = stackEdges.get(String(id));
+ if (!e) continue;
+ resolved = true;
+ for (const upstream of producers.get(e.base) ?? []) {
+ if (upstream === pr) continue;
+ inherited.add(pr);
+ union(pr, upstream);
+ }
+ }
+ if (!resolved) unresolved++;
+ }
+
+ const groups = new Map();
+ for (const pr of byPr.keys()) {
+ const root = find(pr);
+ if (!groups.has(root)) groups.set(root, []);
+ groups.get(root).push(pr);
+ }
+ const ordered = [...groups.values()]
+ .map((g) => g.sort((x, y) => x - y))
+ .sort((a, b) => a[0] - b[0]);
+ const stackOf = new Map();
+ ordered.forEach((g, i) => { for (const pr of g) stackOf.set(pr, { index: i + 1, size: g.length }); });
+ return { groups: ordered, stackOf, inherited, unresolved };
+ };
+
// ── Limb ②: cross-PR signature aggregation ─────────────────────
//
// The ledger is this workflow's OWN comments. Every triage comment
@@ -374,11 +476,39 @@ jobs:
// Only keys THIS ejection actually hit are aggregated: an ejection
// must not re-file anchors for signatures it had nothing to do with.
+ // ⛔ The trigger stays `a.prs.size >= 2`. Stack inheritance changes
+ // what this limb REPORTS, never what it notices: raising the bar to
+ // "2 independent hits" would silence a real deterministic break that
+ // has already eaten the whole queue behind it, which is precisely the
+ // case a reader most needs to see. Honest counting, not a quieter one.
const aggregated = runKeys
- .map((key) => ({ key, prs: sightings.get(key) ?? new Map() }))
+ .map((key) => {
+ const prs = sightings.get(key) ?? new Map();
+ return { key, prs, ...stacksFor(prs) };
+ })
.filter((a) => a.prs.size >= 2)
.sort((a, b) => b.prs.size - a.prs.size);
+ /**
+ * The one sentence four call sites need: how many PRs, and how many of
+ * them are actually independent.
+ *
+ * Single-sourced because the four `anchorNotes` branches below (refresh
+ * / refresh-failed / not-established / create-failed) each render it,
+ * and a phrase copied four times is a phrase that drifts in three.
+ */
+ const victimPhrase = (a) => {
+ const n = a.prs.size;
+ const c = a.groups.length;
+ const list = [...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、');
+ const tail = a.unresolved > 0
+ ? `(其中 ${a.unresolved} 个的队列构建不在 24h 窗口索引内,无法判定栈关系,已按独立计)`
+ : '';
+ if (c === n) return `24h 内已弹出 **${n} 个互相独立的 PR**(${list})${tail}`;
+ if (c === 1) return `24h 内已弹出 **${n} 个 PR**(${list}),但它们同属 **1 条投机栈** ⇒ **1 次独立命中**${tail}`;
+ return `24h 内已弹出 **${n} 个 PR**(${list}),分属 **${c} 条投机栈** ⇒ **${c} 次独立命中**${tail}`;
+ };
+
const ANCHOR_LABEL = 'finding';
const MAX_ANCHOR_PAGES = 3;
const anchorNotes = [];
@@ -387,19 +517,47 @@ jobs:
// Stable across refreshes — the victim count lives in the body, so
// a growing count cannot make the anchor unfindable by title.
const title = `Queue-flake anchor: ${a.key}`;
- const prRows = [...a.prs.entries()].sort((x, y) => x[0] - y[0]).map(([pr, runs]) =>
- `| #${pr} | ${[...runs].map((r) => `[${r}](https://github.com/${owner}/${repo}/actions/runs/${r})`).join(' · ')} |`);
+ const prRows = [...a.prs.entries()].sort((x, y) => x[0] - y[0]).map(([pr, runs]) => {
+ const s = a.stackOf.get(pr);
+ const cell = !s || s.size < 2
+ ? 'independent'
+ : `S${s.index} · ${a.inherited.has(pr) ? 'inherited' : 'root'}`;
+ return `| #${pr} | ${cell} | ${[...runs].map((r) => `[${r}](https://github.com/${owner}/${repo}/actions/runs/${r})`).join(' · ')} |`;
+ });
+ const independent = a.groups.length;
const body = [
- `\`${a.key}\` has ejected **${a.prs.size} distinct pull requests** from the merge queue`,
- 'within a rolling 24 hours. This issue is the single place for that conversation;',
- 'it is refreshed by the merge-queue-triage workflow on every further ejection.',
+ `\`${a.key}\` has ejected **${a.prs.size} pull request${a.prs.size === 1 ? '' : 's'}** from the merge`,
+ `queue within a rolling 24 hours — **${independent} independent hit${independent === 1 ? '' : 's'}** once`,
+ "GitHub's speculative stacking is accounted for. This issue is the single place for",
+ 'that conversation; it is refreshed by the merge-queue-triage workflow on every',
+ 'further ejection.',
'',
- '| PR | queue build |',
- '|---|---|',
+ '| PR | stack | queue build |',
+ '|---|---|---|',
...prRows,
'',
+ ...(independent < a.prs.size
+ ? [
+ "⚠️ **Queue depth is not evidence.** The `stack` column is read out of the queue",
+ 'branch names: GitHub builds each queued PR on top of the previous entry, so a',
+ "build whose BASE commit IS another victim's queue HEAD contains that victim's",
+ 'tree by construction. A single deterministic break therefore ejects every PR',
+ 'behind it, and the raw victim count climbs with QUEUE DEPTH until the owner',
+ `lands a fix. Start with the ${independent === 1 ? 'root' : 'roots'} above; an `
+ + '`inherited` row is a bystander until shown otherwise.',
+ '',
+ ]
+ : []),
+ ...(a.unresolved > 0
+ ? [
+ `⚠️ ${a.unresolved} of the rows above could not be placed in a stack at all (their`,
+ 'queue build fell outside the 24 h run index). They are counted as INDEPENDENT',
+ 'here, which is the safe direction but may overstate the independent-hit count.',
+ '',
+ ]
+ : []),
'**This issue is a NAME, not a diagnosis.** The workflow that files it reads the',
- 'failing test file path out of the job logs and counts distinct PRs; it does not',
+ 'failing test file path out of the job logs and counts PRs; it does not',
'know whether this is a flake, a load/timing cliff, a semantic conflict between',
'queued PRs, or a real regression, and it does not act on any of those. No test is',
'skipped, quarantined or re-queued by it, and no PR is labelled by it — weakening',
@@ -446,9 +604,9 @@ jobs:
await github.rest.issues.update({
owner, repo, issue_number: existing.number, body,
});
- anchorNotes.push(`- \`${a.key}\` — 24h 内已弹出 **${a.prs.size} 个不同 PR**(${[...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、')})。汇总 issue:#${existing.number}(已刷新)`);
+ anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。汇总 issue:#${existing.number}(已刷新)`);
} catch (error) {
- anchorNotes.push(`- \`${a.key}\` — 24h 内已弹出 **${a.prs.size} 个不同 PR**(${[...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、')})。⚠️ 汇总 issue #${existing.number} 刷新失败(${describe(error)}),上面的名单就是全部事实。`);
+ anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。⚠️ 汇总 issue #${existing.number} 刷新失败(${describe(error)}),上面的名单就是全部事实。`);
core.warning(`Could not refresh the anchor issue #${existing.number} for ${a.key} (${describe(error)}).`,
{ title: 'Queue-signature anchor not refreshed' });
}
@@ -460,7 +618,7 @@ jobs:
// second anchor for a key that already has one, which is exactly
// the duplication the marker exists to prevent — so this says so
// instead of guessing.
- anchorNotes.push(`- \`${a.key}\` — 24h 内已弹出 **${a.prs.size} 个不同 PR**(${[...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、')})。⚠️ 没能确认是否已有汇总 issue(${refusal ?? `open issue 列表超过 ${MAX_ANCHOR_PAGES} 页仍未匹配`}),本次不新建,避免开出重复的锚点。`);
+ anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。⚠️ 没能确认是否已有汇总 issue(${refusal ?? `open issue 列表超过 ${MAX_ANCHOR_PAGES} 页仍未匹配`}),本次不新建,避免开出重复的锚点。`);
core.warning(`Could not establish whether an anchor issue already exists for ${a.key}; not creating one.`,
{ title: 'Queue-signature anchor not created' });
continue;
@@ -470,9 +628,9 @@ jobs:
const created = await github.rest.issues.create({
owner, repo, title, body, labels: [ANCHOR_LABEL],
});
- anchorNotes.push(`- \`${a.key}\` — 24h 内已弹出 **${a.prs.size} 个不同 PR**(${[...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、')})。汇总 issue:#${created.data.number}(新建)`);
+ anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。汇总 issue:#${created.data.number}(新建)`);
} catch (error) {
- anchorNotes.push(`- \`${a.key}\` — 24h 内已弹出 **${a.prs.size} 个不同 PR**(${[...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、')})。⚠️ 汇总 issue 新建失败(${describe(error)}),上面的名单就是全部事实。`);
+ anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。⚠️ 汇总 issue 新建失败(${describe(error)}),上面的名单就是全部事实。`);
core.warning(`Could not file the anchor issue for ${a.key} (${describe(error)}).`,
{ title: 'Queue-signature anchor not created' });
}
diff --git a/scripts/check-merge-queue-triage-outcome.mjs b/scripts/check-merge-queue-triage-outcome.mjs
index 05cf906e7c..357a4125fb 100644
--- a/scripts/check-merge-queue-triage-outcome.mjs
+++ b/scripts/check-merge-queue-triage-outcome.mjs
@@ -235,6 +235,11 @@ function makeDoubles(world) {
prNumber: 10008,
workflowId: WORKFLOW_ID,
headBranch: `gh-readonly-queue/main/pr-10008-${'a'.repeat(40)}`,
+ // The queue HEAD this build produced. The branch name's suffix is the queue
+ // BASE, and the PAIR is what limb ② compares to tell an independent hit from
+ // a speculative-stack inheritance — so a world carrying only one of the two
+ // could exercise nothing but the "cannot tell" path.
+ headSha: 'f'.repeat(40),
jobs: [],
logs: {},
logErrors: {},
@@ -375,6 +380,7 @@ function makeDoubles(world) {
id: w.runId,
workflow_id: w.workflowId,
head_branch: w.headBranch,
+ head_sha: w.headSha,
html_url: `https://github.com/objectstack-ai/objectstack/actions/runs/${w.runId}`,
},
},
@@ -437,6 +443,40 @@ const shardJob = (id, name = 'Test Core (3/3)') => ({
const sightingComment = (key, pr, runId) =>
`### merge queue build failed\n\n`;
+/**
+ * A merge_group run as `listWorkflowRunsForRepo` returns it.
+ *
+ * `base` is the sha the queue branch NAME carries (the commit this build was
+ * stacked on); `head` is the commit the build actually produced. Limb ②'s stack
+ * test is `one victim's base === another victim's head`, so a fixture that let
+ * these two default to the same shape could not tell the test from a tautology.
+ */
+const queueRun = (id, pr, base, head) => ({
+ id,
+ workflow_id: WORKFLOW_ID,
+ conclusion: 'failure',
+ head_branch: `gh-readonly-queue/main/pr-${pr}-${base}`,
+ head_sha: head,
+});
+
+/**
+ * The 2026-08-27 incident's three queue builds, as the card that filed this
+ * work recorded them.
+ *
+ * The shas are the incident's OWN abbreviations right-padded to 40 hex — the
+ * source table abbreviates, so the full commits are not recoverable from it and
+ * inventing look-alike full shas would misrepresent the record. What is
+ * reproduced faithfully is the SHAPE that matters: `pr-12851`'s base IS
+ * `pr-12843`'s head, and `pr-12855`'s base IS `pr-12851`'s head.
+ */
+const pad = (short) => short.padEnd(40, '0');
+const INCIDENT = {
+ mainTip: pad('b489d3c72'), // main before the queue
+ head12843: pad('e875b2f18'), // #12843's queue head — and #12851's base
+ head12851: pad('e391b5fb14'),// #12851's queue head — and #12855's base
+ head12855: pad('1a24e9778'),
+};
+
/** The anchor issue an earlier pair already produced. */
const anchorIssue = (number, key) => ({
number,
@@ -679,6 +719,81 @@ function scenarios(root) {
];
},
},
+ {
+ id: 'A12',
+ name: 'SPECULATIVE STACK: three victims of ONE stack score as ONE independent hit',
+ // The recorded incident, re-scored. Its three "distinct pull requests"
+ // were #12843 and the two PRs GitHub had queued on top of it; only #12843
+ // owned the failure, and establishing that by hand cost two seats real
+ // time. A change to a counting rule that cannot re-score the incident that
+ // motivated it is not verified, so this scenario IS that incident.
+ world: () => base({
+ runId: 33141213420,
+ prNumber: 12855,
+ headSha: INCIDENT.head12855,
+ headBranch: `gh-readonly-queue/main/pr-12855-${INCIDENT.head12851}`,
+ repoComments: [
+ sightingComment(KEY_A, 12843, 33140380738),
+ sightingComment(KEY_A, 12851, 33141029941),
+ ],
+ queueRuns: [
+ queueRun(33140380738, 12843, INCIDENT.mainTip, INCIDENT.head12843),
+ queueRun(33141029941, 12851, INCIDENT.head12843, INCIDENT.head12851),
+ ],
+ }),
+ check(r, t) {
+ const body = r.calls.issueCreate[0]?.body ?? '';
+ return [
+ t(r.calls.issueCreate.length === 1,
+ `the anchor is STILL filed — the trigger is untouched, only the count is honest (got ${r.calls.issueCreate.length})`),
+ t(/\*\*3 pull requests\*\*/.test(body),
+ `the raw victim count is still reported as 3 rather than hidden, got: ${JSON.stringify(body.slice(0, 240))}`),
+ t(/\*\*1 independent hit\*\*/.test(body),
+ `and it is SCORED as one independent hit, got: ${JSON.stringify(body.slice(0, 240))}`),
+ t(/\|\s*#12843\s*\|\s*S1 · root\s*\|/.test(body),
+ `#12843 — the only PR that owned the failure — is the stack ROOT, got: ${JSON.stringify(body)}`),
+ t(/\|\s*#12851\s*\|\s*S1 · inherited\s*\|/.test(body)
+ && /\|\s*#12855\s*\|\s*S1 · inherited\s*\|/.test(body),
+ '#12851 and #12855 are marked INHERITED — they were merely behind it in the queue'),
+ t(body.includes('Queue depth is not evidence'),
+ 'the anchor says WHY the raw count overstates, instead of just printing a smaller number'),
+ t(postedBody(r).includes('同属 **1 条投机栈**'),
+ "the victim's own triage comment carries the same reading as the anchor"),
+ ];
+ },
+ },
+ {
+ id: 'A13',
+ name: 'FALSE-NEGATIVE GUARD: three UNRELATED victims still count as three',
+ // The symmetric risk, and the reason the stack test is `base === head` on
+ // FULL shas: a prefix comparison or a time ordering would fuse these three
+ // into one stack and under-report a real cross-PR signal. Trading the false
+ // positive for a false negative is strictly worse here.
+ world: () => base({
+ repoComments: [
+ sightingComment(KEY_A, 10105, 32328768059),
+ sightingComment(KEY_A, 10003, 32328768060),
+ ],
+ queueRuns: [
+ queueRun(32328768059, 10105, 'b'.repeat(40), 'c'.repeat(40)),
+ queueRun(32328768060, 10003, 'd'.repeat(40), 'e'.repeat(40)),
+ ],
+ }),
+ check(r, t) {
+ const body = r.calls.issueCreate[0]?.body ?? '';
+ return [
+ t(r.calls.issueCreate.length === 1, `one anchor is filed, got ${r.calls.issueCreate.length}`),
+ t(/\*\*3 pull requests\*\*/.test(body) && /\*\*3 independent hits\*\*/.test(body),
+ `three victims, three independent hits — no stack was invented, got: ${JSON.stringify(body.slice(0, 240))}`),
+ t((body.match(/\|\s*independent\s*\|/g) ?? []).length === 3,
+ `every row is marked independent, got: ${JSON.stringify(body)}`),
+ t(!body.includes('Queue depth is not evidence'),
+ 'the stack-inheritance caveat is ABSENT — it must not become boilerplate on every anchor'),
+ t(!/could not be placed in a stack/.test(body),
+ 'every victim WAS placed, so the "unresolved" hedge does not appear either'),
+ ];
+ },
+ },
{
id: 'A3',
name: 'NEGATIVE: two ejections with DIFFERENT keys produce no anchor at all',
@@ -949,6 +1064,14 @@ function list() {
// is a FAILURE here, not a skip: the substitution would then be a no-op, the
// battery would stay green, and the self-test would report a detector it never
// exercised.
+//
+// `expect` names the scenarios that must go RED. The optional `keepGreen` names
+// scenarios that must NOT move — the other half of the reading, and the only
+// half that can catch a detector which reds on everything. A rule that decides
+// "these two victims are the same hit" has a false NEGATIVE direction as well as
+// a false positive one, and a mutation test that only ever asks for more red
+// cannot see it: a stack test degraded into "always one stack" would turn every
+// count into 1 and still satisfy every `expect` in this table.
const MUTATIONS = [
{
@@ -1049,6 +1172,40 @@ const MUTATIONS = [
to: ' core.setFailed(`Could not file the anchor issue for ${a.key} (${describe(error)}).`,',
expect: ['A9'],
},
+ {
+ id: 'M15',
+ what: 'the queue base sha goes back to being MATCHED but not CAPTURED, so speculative-stack inheritance is invisible again and queue depth reads as victim count',
+ from: 'const QUEUE_REF = /^gh-readonly-queue\\/.+\\/pr-(\\d+)-([0-9a-f]{40})$/;',
+ to: 'const QUEUE_REF = /^gh-readonly-queue\\/.+\\/pr-(\\d+)-[0-9a-f]{40}$/;',
+ expect: ['A12'],
+ // ⚠️ A13 must NOT move. Three unrelated victims read as three either way, so
+ // it is the control that proves A12's red comes from the stack test rather
+ // than from the anchor body simply having changed shape.
+ keepGreen: ['A13'],
+ },
+ {
+ id: 'M16',
+ what: 'same-stack is inferred from CO-OCCURRENCE instead of base === head, so unrelated victims fuse into one stack and a real cross-PR signal is under-reported',
+ from: ' for (const upstream of producers.get(e.base) ?? []) {',
+ to: ' for (const upstream of [...producers.values()].flatMap((s) => [...s])) {',
+ // The mirror of M15, and the reason both exist. M15 removes the evidence and
+ // the count goes UP; M16 stops requiring it and the count goes DOWN. A stack
+ // rule can be wrong in either direction, and a battery that only catches one
+ // of them would sign off on "everything is one stack" — which reports 1
+ // independent hit for every anchor it ever files.
+ //
+ // ⚠️ A12 is listed here as MEASURED, not as predicted: this mutation leaves
+ // A12's count at "1 independent hit" (its three victims really are one
+ // stack) and it was first written with A12 as the control. A12 reds anyway,
+ // because co-occurrence marks #12843 `inherited` too — it unions with the
+ // PRs BEHIND it — and a stack whose every row is inherited names no owner to
+ // start from. That the root marker is the sharper detector here, not the
+ // count, is the useful half of this entry.
+ expect: ['A13', 'A12'],
+ // A1 carries no stack of its own, so it is the control that this mutation
+ // does not simply red the battery.
+ keepGreen: ['A1'],
+ },
{
id: 'M13',
what: 'the redelivery guard stops returning, so a repeated delivery re-posts and double-counts its own signature',
@@ -1089,6 +1246,15 @@ async function selfTest() {
assert(red.failures.some((f) => f.id === id),
`${m.id}: scenario ${id} is one of the ones that catches it, got [${[...new Set(red.failures.map((f) => f.id))].join(', ')}]`);
}
+ // The dual. A scenario named here must survive the mutation untouched --
+ // that is what makes its GREEN a reading rather than an absence, and it is
+ // the only assertion that can fail a detector which simply reds on
+ // everything.
+ for (const id of m.keepGreen ?? []) {
+ assert(!red.failures.some((f) => f.id === id),
+ `${m.id}: scenario ${id} must NOT move -- it is the control for this mutation, `
+ + `got failures [${red.failures.filter((f) => f.id === id).map((f) => f.message).join(' | ')}]`);
+ }
}
// 3. A script that does not compile is caught before any scenario runs.