Skip to content

tooling(pm): dispatch-gates — the hand-written tables move out as data, the self-test out as a module with fast/slow tiers, derivation byte-identical - #22531

Draft
objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/issue-22478-dispatch-gates-roster-split
Draft

objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/issue-22478-dispatch-gates-roster-split

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22478
Fixes #22167
Clause-②: no

What this PR is

W3 of the skills-lane diet program, under the maintainer's letter A on #22453 (「同意」, 2026-10-09T11:15Z, record 6079780639): the ruling-208 freeze on scripts/pm/dispatch-gates.mjs is lifted for exactly this split and restores itself when this PR lands. The three conditions of the lift are the three sections below, in the ruling's order. The --tier note remainder of #22167 (ruling C, record 6053791774) rides the same PR.

1. Coverage first (condition 1)

Method. NODE_V8_COVERAGE over one full run of the battery on origin/main (e148ca98, the engine unchanged since dee7692f but for the landed #22451 pin), the parent process and every CLI child it spawns merged (60 processes), read per top-level declaration of the engine with the self-test excluded. Reading is the block granularity V8 reports, so "a path" below is a function or a branch inside one.

Before. 245 engine declarations carry code; 245 of 245 were invoked by some case — no function of the engine was untested. Inside them, 253 of 2,259 blocks had a zero count (merged the way V8 means it: a block is listed only when its count differs from the enclosing range, so an unlisted block inherits the innermost listed count per process, and the maximum across the 60 processes is taken — a first reading that merged listed ranges alone said 338, and over-counted). Those 253 are the measure of condition 1; most are defensive refusals (throw on a key the table does not hold), the arms of a renderer never reached by a fixture, and the classes of the run-record reconciliation no case had landed a family in.

Pins added (commit (a), 59 cases, all in-process on fixtures, in the fast tier): the marker grammars — markerFormKind and populationMarkerPattern refusals, lineFormReason's two no-cut exits and its cut control, markerReasonCutRefusal's unknown-key throw and both the BLOCK and LINE repair texts, refuseCutMarkerReason throwing and passing, unparsedPopulationMarkers + unparsedPopulationMarkerRefusal, readPopulationDeclaration's unknown key; the path matcher's floor (hintCovers on a one-character hint, with a control); the tier renderer's two one-line-class exits (tierLines), the changed-line reading's three states (changedLineLines: NOT MEASURED / under / OVER against the gate's own threshold), the sister-repo tier run's three exits (sisterRepoTierRun: not a sister, no paths, answered), the claim line reader's two absences (readContainerModelLine); the argv splitter's valueless and joined forms (splitArgv); the repo assertion's three refusals, the governed-sister hint and the case-insensitive pass (repoAssertionVerdict); the absent-path verdict with no identity, with one, and settled by an assertion (absentPathVerdict); and the run record end to end — runRecordKillLabel's five answers, parseRunRecord's malformed tails, claims and CRLF, and every class runReconciliation can land a family in (a contradiction resolved toward exit 3, a kill with a stated reason, a kill with none, a kill with a reasonless claim, a reasoned claim, the three explained extras, a near miss, both malformed kinds, the silent class) with runReconciliationLines rendering each and the three evidence kinds (FLOOR, RUNNER'S CLAIM, DERIVED zero).

After. On commit (c)'s head, the same method over a full run of the split battery (60 processes): 248 engine declarations with code, 248 of 248 invoked, 248 of 2,274 blocks at zero — the 59 pins closed their targets (hintCovers, markerFormKind, populationMarkerPattern, readPopulationDeclaration, runRecordKillLabel, splitArgv, two of runReconciliationLines' renderings, one of derivationProvenance's — the rest of the first list had already been reached by the CLI children once the merge was read correctly), and the split's three new load-time binders added four zero blocks of their own: the refusals of a data row naming a predicate, a tier or an exception the engine does not define, and the suspect row with no exception. Commit (e) makes those binders exported functions (same behaviour) and pins all four, plus the fallback fragments the second reading listed (cut.file absent, a lookalike with no comment opener, readContainerModelLine/parseRunRecord on nothing, a record entry with no malformedKind, the repo assertion and absent-path verdict with no identity): 14 more cases. NOT MEASURED on this head (8f1979ee): the full-battery coverage rerun after commit (e) was killed with the session by the second 5-hour usage wall at 1,990 of 2,085 cases (its 60 child files landed, the parent's never did; the battery under V8 block coverage takes ~45 minutes on this box) and is not re-run on the merged head; the (c)-head reading above stands as the measured "after", and commit (e)'s 14 pins name their targets by function and branch — each passes in the battery, so each target ran in the parent process; the block counts are the only thing not re-read. What stays at zero by construction is listed next.

What is not pinned, and why: the two closing-invariant throws (familyReconciliation, runReconciliation: "the classes and the derivation came from different structures") are reachable only by corrupting the function's own locals; derive/machineReadableOutput/discoverFamiliesPass branches that depend on a live tree shape (a workflow with a paths-ignore:, a package-local gate under a second base) are driven by the CLI children over this tree and stay at the block counts the tree gives them; the git-reading branches (runGit, changedPathsFromGit on a shallow checkout) are exercised in the slow tier's temporary repositories. None of these is a derivation path a card's --commands, --tier or --ran answer can reach without a different tree.

2. The split — what moved, what stayed, why (condition 2)

The measured shape of the roster (mechanism assumption 1 — confirmed). The engine embeds no path → family table. Every run re-reads .github/workflows/*.yml, follows each uses: ./.github/actions/… into the action's runs: steps, resolves every check:* through package.json, and scans the gate scripts' own sources for the path literals they operate on (discoverFamiliesPass; the header's "Why derived, never listed"). So the roster was already data — the tree's — and nothing of it moves; inventing a file for it would be the second roster the dispatch order forbids. What IS hand-written in the engine, measured by parsing it (acorn, 363 top-level declarations, 89 of them pure literals): the marker grammar vocabularies, four ledgers, the one hand-written gate-family list (CHANGE_KIND_GATES, the convention-triggered gates keyed by change KIND), the tier globs and the ladder words. Those move.

Why moving them moves no derivation (measured before touching anything). Three families read the engine's hints — check:pm-dispatch-gates through the wrapper's RUN edge, check:declared-population-live and check:watch-hint-literal through an IMPORT edge — and all three inherit not the 33 literals the engine spells but its DECLARED inherited population: .github/workflows and .github/actions (the inherited-population marker on the engine, which declaredInheritedPopulation lets only narrow, never invent). Those two literals are spelled by the discovery code and COMPOSITE_ACTION_DIR, which stay. Neither new file is a watch surface: no family resolves to it and no gate imports it (import edges are one level deep and never expand a run target's own imports). The replay below is the proof; this paragraph is why it was expected.

Line budget (wc -l). dispatch-gates.mjs 30,050 on origin/main (e148ca98) → 30,194 after the pins (commit (a), +144) → 15,911 after the split (commit (b)) → 15,915 after the note (commit (c)) → 15,927 on this head (commit (e), the three binders as named functions). New beside it: dispatch-gates.data.mjs 1,258 (data only, docblocks included), dispatch-gates.self-test.mjs 13,538 (the battery, two tiers, 59 + 14 pins). check-dispatch-gates.mjs 502 → 567 (+72 / −7: the tier decision, its header section and three self-test cases). check-ratchet-remedy-authority.mjs +2 / −2 (commit (d), one ledger row). Commits, each green on its own: (a) 44d264ed pins · (b) 3cbaf471 split · (c) 2a5b52c4 note · (d) c45ee314 the sibling ledger row · (e) 8f1979ee binder functions + 14 pins · then origin/main ee8751d4 merged as 10274911 (12 commits, none touching these files; #22451's pin keeps its landed 3000 / 3001 meaning, it had come in with e148ca98). Changed lines on this head: 29,479 (+15,109 / −14,370).

What Where it lived Where it lives now Why it moves / stays
The gate ROSTER (which paths feed which check:* families) nowhere — derived on every run from .github/workflows/*.yml, the composite actions they uses:, package.json and the gate scripts' own sources unchanged — still derived; there is no path → family table to move, and inventing one would be a second roster that drifts from the workflows measured, not assumed: the engine embeds no list of checks (its header's "Why derived, never listed" section; discoverFamiliesPass)
Marker grammar vocabularies — POPULATION_MARKER_KEYS, REASON_TAIL_MARKER_KEYS, PATH_LIST_MARKER_KEYS, MARKER_COMMENT_FORMS, MARKER_KEY_FORMS engine dispatch-gates.data.mjs (verbatim, docblocks included) pure vocabularies; the regex builders that consume them (MARKER_REASON_GRAMMARS, MARKER_LOOKALIKES, POPULATION_DECLARATION_FIELDS) stay in the engine because they reference engine functions
Ledgers — ROOT_WALK_RESIDUE_LEDGER, COMPOUND_ANCHOR_LEDGER, GOVERNED_READ_FLOOR, ESCAPABLE_LITERAL_LEDGER (rows) engine data (verbatim; the escapable ledger's rows as ESCAPABLE_LITERAL_LEDGER_ROWS, the engine binds the Set) hand-written, shrink-only tables
The change-kind roster CHANGE_KIND_GATES — the one hand-written gate-family list (convention-triggered gates, keyed by the KIND of change) engine, each row holding a predicate FUNCTION data as CHANGE_KIND_ROWS, each row naming its predicate by KEY (test-file, i18n-bundle-package, metadata-form-module, gate-script, root-ts-program, error-code-literal, http-status-emit); the engine resolves the key at load and refuses an unknown one a new gate family under a kind is now one data row; the predicate stays logic, in the engine
Tier globs MANDATORY_TIER_GLOBS, SUSPECT_TIER_GLOBS engine, rows holding the tier VALUE and the isTestPath FUNCTION data as *_GLOB_ROWS, the tier by NAME (CONTRACT_REVIEW_TIER) and the exception by KEY (test-path); the engine resolves both at load and refuses an unknown name the model id keeps its ONE value site in the engine (CONTRACT_REVIEW_TIER), as the one-value-site pin requires
Tier ladder words TIER_FLOOR, TIER_DEFAULT, RETIRED_TIER_WORDS engine data (verbatim) hand-written ladder
DERIVATION_SURFACE, COMPOSITE_ACTION_DIR, MODULE_SPECIFIER_EXTENSIONS, GOVERNED_SURFACE_PREFIXES/FILES, REPO_ROOT_WALK_SPELLINGS, HARVEST_SNIPPET, every regex constant engine engine these describe the engine's own reads, are matcher parameters, or carry regexes — logic-adjacent, and the first two are what keeps the engine's declared inherited population spelled
The self-test (function selfTest) engine, lines 16715–29714 of 30,050 dispatch-gates.self-test.mjs, loaded only by --self-test the ruling's "the engine keeps only roster loading, path matching and derivation"

3. Byte-identical derivation (condition 2) — the replay and its empty diff

Corpus. Every literal argv the battery hands the tool's CLI (runCli / runCliHypothetical in dispatch-gates.self-test.mjs, the battery's own bindings resolved: the seam card, the test card, the guard card, the ran card, the absent path, the governed sister repo's slug, the changeset probe and the value-bearing probe), the lane's three change sets of 2026-10-09 (PR #22457's fleet-write modules and three references; PR #22458's four .claude/** texts and label-write.mjs; PR #22475's automation SKILL.md and its eval), and this card's own surface (scripts/pm/dispatch-gates.mjs + package.json) — each in --commands, --tier, the human rendering, --residue, --json and --commands --residue; plus, for eight cards, the four --ran records the battery builds by construction (complete, short by one, every exit coded 0, one exit 3) in all four renderings and one unreadable record. 69 corpus invocations + 8 --ran cards per engine; stdout, stderr and exit code recorded for each.

Same tree, two engines. The base engine (origin/main e148ca98's file — the merged main with the landed #22451 pin, the engine unchanged since dee7692f but for that one self-test line — as an untracked copy beside the head engine so both resolve the same ROOT) and the head engine, replayed in one checkout — so the "swept over N tracked file(s)" count and every other tree fact are identical by construction and only the engine can differ.

Result: diff -r base head → empty (exit 0, 0 lines), 852 files per side; the base leg took 1,217 s and the head leg 1,222 s at parallel 2 on the contended box. A second head leg with the COMMITTED (b) engine (3cbaf471, which carries four more export keywords than the engine the first head leg ran, for the pins' private helpers) over the same base leg: diff -r base head-b → empty (exit 0, 0 lines), 852 files per side. Two of the 852 entries are --changed runs that derive the change set from the working tree itself (--changed --commands and the no-path --tier), so their output names the tree's own diff; those two were re-taken with both engines on one tree state (the untracked base copy present for both) before the final diff — the first pass had differed on exactly that count, which is the tree, not the engine. The replay runner and its corpus enumeration are below, verbatim minus angle-bracket placeholders; the corpus is read from the committed test module, so the proof reproduces from this PR alone.

Commit (e) (the binders as named functions): the (c) engine as an untracked copy beside the (e) engine, over the lane's three change sets, this card's surface and a *.zod.ts suspect in --tier, the human rendering, --json and --commands (60 files per side): diff -r → empty (exit 0, 0 lines).

Merged head (10274911, base ee8751d4 — 12 later commits on main, none touching the engine): the same replay, same tree, base engine vs the head engine: this base is the pre-split engine with the OLD clause-② sentence and this head carries commit (c), so diff -r base head → 28 of 852 files differ, all of them .out (no .err, no .exit), 62 changed lines, and every one of the 62 is the clause-② sentence or its SUSPECT parenthesis (checked mechanically: 62 of 62 carry the old "accept/reject" or the new "WIDENS" wording; the --commands and --ran outputs are untouched). That is ruling C's rewording and nothing else — modulo the one sentence the ruling ordered changed, the derivation on the merged head is byte-identical to origin/main's.

The replay runner (replay.mjs, kept out of the tree on purpose — a committed golden replay would be a new ratchet; see Acceptance notes):

// Replay corpus: every argv the self-test hands the CLI (runCli / runCliHypothetical arrays, with the
// self-test's own bindings resolved), plus the lane change sets and this card's own surface, in every
// mode. Runs ONE engine file over all of them, writing stdout/stderr/exit per case into OUT/N.*;
// every `--commands` run that answers also gets the four `--ran` legs the battery builds by
// construction (complete, short by one, coded, one refused). Diff two OUT dirs to prove identity.
//   node replay.mjs TREE ENGINE_REL_PATH SELFTEST_SOURCE_FILE OUT_DIR [--parallel=N]
import { readFileSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { pathToFileURL } from 'node:url';
const [tree, engineRel, testSrcPath, outDir] = process.argv.slice(2);
const parallel = Number((process.argv.find((a) => a.startsWith('--parallel=')) ?? '--parallel=2').split('=')[1]);
const testSrc = readFileSync(testSrcPath, 'utf8');
const governed = await import(pathToFileURL(join(tree, 'scripts/pm/check-governed-merges.mjs')).href);
const SISTER_SLUG = governed.GOVERNED_REPOS.find((r) => r.id !== governed.SELF_REPO_ID).slug;
const OWN_SLUG = 'objectstack-ai/objectstack';
const BIND = {
  REPO_FLAG: '--repo', RAN_FLAG: '--ran', SISTER_SLUG,
  ABSENT_CLI: 'packages/this-repo-has-no-such-package/src/index.ts',
  seamCard: 'scripts/measure-partial-retirement-annotation.mjs',
  testCard: 'packages/spec/scripts/authorable-defaults.test.ts',
  guardCard: '.claude/agents/os-dev.md',
  ranCard: 'scripts/measure-durability-swallow-family.mjs',
  VALUE_BEARING_PROBE_SCRIPT: 'scripts/pm/check-half-states.mjs',
  CHANGESET_PROBE_PATH: '.changeset/the-one-you-have-not-written-yet.md',
  "liveSlug ?? 'an-owner/a-repo'": OWN_SLUG,
};
BIND['...vbCard'] = [BIND.CHANGESET_PROBE_PATH, BIND.VALUE_BEARING_PROBE_SCRIPT];
const RAN_PLACEHOLDERS = new Set(['completePath', 'shortPath', 'codedPath', 'refusedPath', 'vbRecord', "nodePath.join(ranTmp, 'no-such-record.list')"]);
const sets = [];
const unresolved = [];
for (const m of testSrc.matchAll(/runCli(?:Hypothetical)?\(\[([^\]]*)\]/g)) {
  const hyp = m[0].startsWith('runCliHypothetical');
  const parts = m[1].split(/,(?=(?:[^']*'[^']*')*[^']*$)/).map((p) => p.trim()).filter(Boolean);
  const argv = [];
  let ok = true;
  let ranLeg = null;
  for (const p of parts) {
    const lit = /^'([^']*)'$/.exec(p) ?? /^"([^"]*)"$/.exec(p);
    if (lit) argv.push(lit[1]);
    else if (p in BIND) argv.push(...[].concat(BIND[p]));
    else if (RAN_PLACEHOLDERS.has(p)) { ranLeg = p; argv.push(p); }
    else { ok = false; unresolved.push(p); }
  }
  if (!ok || argv.length === 0) continue;
  if (hyp) argv.push('--repo', OWN_SLUG);
  if (ranLeg) continue; // the --ran legs are built by construction below, per card
  sets.push({ label: `${hyp ? 'hyp' : 'cli'}:${argv.join(' ')}`, argv });
}
const lane = {
  pr22457: ['scripts/pm/fleet-write/dispatch.mjs', 'scripts/pm/fleet-write/execute.mjs', 'scripts/pm/fleet-write/ops.mjs', 'scripts/pm/fleet-write/validate.mjs', '.claude/skills/pm-dispatch/references/rest-channel.md', '.claude/skills/pm-dispatch/references/landing-operations.md', '.claude/skills/pm-dispatch/references/platform-readings.md'],
  pr22458: ['.claude/skills/pm-dispatch/SKILL.md', '.claude/skills/pm-dispatch/references/execution-duties.md', '.claude/skills/pm-dispatch/references/instrument-discipline.md', '.claude/agents/os-dev.md', 'scripts/pm/label-write.mjs'],
  pr22475: ['skills/objectstack-automation/SKILL.md', 'skills/objectstack-automation/evals/evals.json'],
  thiscard: ['scripts/pm/dispatch-gates.mjs', 'package.json'],
};
for (const [name, paths] of Object.entries(lane)) {
  for (const mode of [['--commands'], ['--tier'], [], ['--residue'], ['--json'], ['--commands', '--residue']]) {
    sets.push({ label: `${name}:${mode.join(' ') || 'human'}`, argv: [...mode, '--repo', OWN_SLUG, ...paths] });
  }
}
// the --ran cards the battery builds records for, by construction
const ranCards = [[BIND.ranCard], [BIND.seamCard], BIND['...vbCard'], [BIND.guardCard], ...Object.values(lane)];
mkdirSync(outDir, { recursive: true });
const run = (argv) => new Promise((res) => {
  const c = spawn(process.execPath, [join(tree, engineRel), ...argv], { cwd: tree, env: { ...process.env, NODE_V8_COVERAGE: '' } });
  let out = '', err = '';
  c.stdout.on('data', (d) => (out += d)); c.stderr.on('data', (d) => (err += d));
  c.on('close', (code) => res({ out, err, code }));
});
const scratch = mkdtempSync(join(tmpdir(), 'dg-replay-'));
const save = (base, label, r, scrub = []) => {
  let err = r.err; for (const [a, b] of scrub) err = err.replaceAll(a, b);
  writeFileSync(`${base}.label`, `${label}\n`); writeFileSync(`${base}.out`, r.out); writeFileSync(`${base}.err`, err); writeFileSync(`${base}.exit`, `${r.code}\n`);
};
let i = 0; const started = Date.now();
const jobs = [];
sets.forEach((s, n) => jobs.push(async () => save(`${outDir}/${String(n).padStart(3, '0')}`, s.label, await run(s.argv))));
ranCards.forEach((card, k) => jobs.push(async () => {
  const base = `${outDir}/ran-${String(k).padStart(2, '0')}`;
  const cmd = await run(['--commands', '--repo', OWN_SLUG, ...card]);
  const rows = cmd.out.split('\n').filter(Boolean);
  save(`${base}-commands`, `ran-source:${card.join(' ')}`, cmd);
  if (cmd.code !== 0 || rows.length === 0) return;
  const records = {
    complete: `${rows.join('\n')}\n`,
    short: `${rows.slice(0, -1).join('\n')}\n`,
    coded: `${rows.map((c) => `${c} :: exit 0`).join('\n')}\n`,
    refused: `${rows.map((c, j) => `${c} :: exit ${j === 0 ? 3 : 0}`).join('\n')}\n`,
  };
  for (const [kind, text] of Object.entries(records)) {
    const rec = join(scratch, `ran-${k}-${kind}.list`);
    writeFileSync(rec, text);
    for (const mode of [[], ['--commands'], ['--json'], ['--tier']]) {
      const r = await run(['--ran', rec, ...mode, '--repo', OWN_SLUG, ...card]);
      save(`${base}-${kind}${mode.length ? mode[0] : ''}`, `ran:${kind}${mode.join('')}:${card.join(' ')}`, r, [[rec, 'RECORD']]);
    }
  }
  const missing = await run(['--ran', join(scratch, 'no-such-record.list'), '--repo', OWN_SLUG, ...card]);
  save(`${base}-missing`, `ran:missing:${card.join(' ')}`, missing, [[scratch, 'SCRATCH']]);
}));
const worker = async () => { while (i < jobs.length) { const j = jobs[i++]; await j(); } };
await Promise.all(Array.from({ length: parallel }, worker));
rmSync(scratch, { recursive: true, force: true });
console.log(`${sets.length} corpus invocations + ${ranCards.length} --ran cards replayed into ${outDir} in ${((Date.now() - started) / 1000).toFixed(0)}s; unresolved bindings: ${[...new Set(unresolved)].join(', ') || 'none'}`);

The same-tree driver (replay-both.sh TREE BASE_SHA OUT_DIR):

#!/usr/bin/env bash
# Same-tree replay: the BASE engine (origin/main's file, untracked copy beside the head engine) and the
# HEAD engine, each replayed over the corpus in the SAME checkout, then diffed byte for byte.
#   bash replay-both.sh WORKTREE BASE_SHA OUT_ROOT
set -uo pipefail
TREE=$1; BASE=$2; OUT=$3; S=$(dirname "$0")
cd "$TREE" || exit 2
git show "$BASE:scripts/pm/dispatch-gates.mjs" > scripts/pm/dispatch-gates.base.mjs || exit 2
trap 'rm -f "$TREE/scripts/pm/dispatch-gates.base.mjs"' EXIT INT TERM
mkdir -p "$OUT"
echo "head=$(git rev-parse --short HEAD) base=$BASE tree=$TREE" | tee "$OUT/README"
node "$S/replay.mjs" "$TREE" scripts/pm/dispatch-gates.base.mjs scripts/pm/dispatch-gates.self-test.mjs "$OUT/base" --parallel=2 2>&1 | tee -a "$OUT/README"
node "$S/replay.mjs" "$TREE" scripts/pm/dispatch-gates.mjs scripts/pm/dispatch-gates.self-test.mjs "$OUT/head" --parallel=2 2>&1 | tee -a "$OUT/README"
rm -f scripts/pm/dispatch-gates.base.mjs
diff -r "$OUT/base" "$OUT/head" > "$OUT/diff.txt"; DIFF=$?
echo "diff exit=$DIFF ($(wc -l < "$OUT/diff.txt") lines)" | tee -a "$OUT/README"
ls "$OUT/base" | wc -l | sed 's/^/files per side: /' | tee -a "$OUT/README"
exit $DIFF

4. Two tiers — the battery's seconds before and after, per tier (condition 3)

Before (origin/main dee7692f, the one selfTest() with no tiers). pnpm check:pm-dispatch-gates on this box: 2,011 cases, 1,093.7 s (the wrapper's own wall-clock line; load average ≈3.4 on 4 cores with one other battery running — the same contended-box shape as the 1,052–1,178 s readings on the card). Per-tier attribution of that battery, from a second run of the same base engine with every printed line timestamped (1,051.3 s total, concurrent with the run above): the thirteen sections that spawn the tool's CLI, build temporary git repositories or sweep the tree — the sections the split names SLOW — took ≈719 s; everything else ≈332 s.

After. On commit (c)'s head (2a5b52c4), same box, same contention shape: both tiers (pnpm check:pm-dispatch-gates --slow): 2,071 cases pass, 1,108.7 s — the battery's own tally: fast 1,811 cases in 274.3 s · slow 260 cases in 834.0 s. Fast tier alone (--fast, what a dev delivery runs; read on commit (b)'s head): 1,811 cases pass in 283.8 s (284 s wall; the six deferred sections named in the log). The 2,071 = the 2,011 cases on origin/main + 59 coverage pins + 1 tier-partition pin; no case dropped (the partition pin reads fast + slow = cases.length with nothing deferred). The fast tier is 1/4 of the battery's wall clock; the slow tier's 834 s is the CLI children, the temporary repositories and the whole-tree sweeps, exactly the before-run's attribution.

Final head (8f1979ee, commits (d) and (e) on top), both tiers: 2,085 cases pass, 1062.0 s on this box (the coverage rerun of the same battery started concurrently in its last minutes) — fast 1,825 cases in 271.3 s · slow 260 cases in 790.4 s; EXIT=0.

Merged head (10274911), both tiers: 2,085 cases pass, 1062.6 s on this box (the gate union and the replay's base leg ran concurrently) — fast 1,825 cases in 285.9 s · slow 260 cases in 776.1 s; EXIT=0.

What each tier is. FAST: every in-process case — fixture derivations, path matching, the marker grammars, the renderings, the one memoised discovery of this tree (and the handful of sections that run an extra discovery pass to pin memoisation — up to ~45 s each on the contended box, derivation cases by the ruling's definition). SLOW: the six slow(label, …) groups in dispatch-gates.self-test.mjs — the governed-sister-repo verdict on the real CLI; the change set derived from git (temporary repositories with real history); base drift; the real CLI end to end (the noise floor, the sister-repo verdict, the three absent-path shapes, the entry guard); end to end on the real tree with the CI-measured family; and the run record built from --commands with the value-bearing bucket and the NOT MEASURED renderings. ⛔ No case dropped: a run of both tiers prints the battery's verdict line exactly as before, and the tail pins that fast + slow = cases.length with nothing deferred (full) or every deferred section named (fast).

Who runs which. pnpm check:pm-dispatch-gates unchanged in package.json; the wrapper check-dispatch-gates.mjs decides: a dev box with no flag → --self-test --fast; --slow on argv, or GITHUB_ACTIONS=true → --self-test (both tiers). lint.yml's step runs the gate with no argument, so CI keeps the slow tier without a workflow edit and without a second family for the derivation to name; the tier chosen and its reason print before the spawn, and the wrapper's own --self-test drives all three cases against children that echo the argv they received. The read's failure direction is the safe one: an unset variable buys a shorter run that says which tier it was, never a quiet one.

5. The --tier note (ruling C on #22167)

Ruling C on #22167 (record 6053791774): Clause-② asks only what execution-duties.md asks — does the card WIDEN the accept set or the public surface — and a card that only NARROWS a published accept set is Clause-②: no, stays in its lane, and owes one contract-review-tier review before the queue (the sentence PR #22223 added there). The engine's --tier note said "a card that changes contract accept/reject behaviour or widens the public surface" in two places — the CONTRACT_REVIEW_TIER docblock and the clause2 line tierLines prints on every run — which gave a narrowing card two answers. Commit (c) rewords both (and the SUSPECT line's parenthesis, the same sentence in its third spelling) to the lane rule's meaning in the engine's own words: a card that WIDENS a published accept set or the public surface owes the contract-review-tier review and is spec-lane work whichever seat found it; a card that only NARROWS one is Clause-②: no, stays in its lane, and owes one such review before the queue. On this head grep -c "changes contract accept/reject" scripts/pm/dispatch-gates.mjs → 0. The only derivation output that moves between commits (b) and (c) is that sentence: with the (b) engine (3cbaf471) as an untracked copy beside the (c) engine, over the lane's three change sets, this card's surface and a *.zod.ts suspect, in --tier, the human rendering, --json and --commands (60 files per side): diff -r → 10 files differ, every one a rendering that prints the note, and every differing line is the clause-② sentence (10 pairs) or the SUSPECT parenthesis (2 pairs); the five --commands outputs are identical.

6. Nothing else in the file changed under the lift

Under the lift the engine file changed in exactly these ways, each a consequence of the split and nothing else: (1) the data import and the re-export block under the existing imports; (2) export added to 25 bindings the moved battery drives (constants, flags and eight functions, four of them the private grammar helpers the coverage pins name — no behaviour), and 10 import specifiers only the battery used pruned; (3) each moved table replaced by a one-line pointer, and the four rebound tables (CHANGE_KIND_GATES, MANDATORY_TIER_GLOBS, SUSPECT_TIER_GLOBS, ESCAPABLE_LITERAL_LEDGER) by their load-time bindings, which refuse an unknown predicate / tier / exception name; (4) the --self-test branch loads the battery module (without a top-level await — the battery imports the engine, so an await here deadlocks at exit 13 with nothing printed); (5) one refusal text whose pointer follows the moved MARKER_COMMENT_FORMS table to the data file; (6) the header's one usage line for --self-test [--fast]. Commit (c) is the two --tier note sentences and nothing more. package.json's check:pm-dispatch-gates line and lint.yml are untouched, so no new family enters the derivation (the family list this card derives is byte-identical, see §3). After landing the freeze is back as ruling 208 states it.

One file off the declared surface, in its own commit (d): scripts/check-ratchet-remedy-authority.mjs's known-instance ledger expected the engine file to be marked — the change-kind sentence carrying the ⛔ MAINTAINER-ONLY token lived there. With that roster moved out as data, the gate's own positive control reddened on this head ("the gate changed shape — correct the entry"), so the row turns excluded with the move recorded in its reason (+2 / −2); the data file classifies excluded on its own and needs no row, and the token still reaches the author verbatim from the data row. Recorded here and in the report as a deviation from "and nothing else": a mechanical consequence of the move, in a separate commit the seat can drop.

Gates on this head

Derivation on this head (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths — the change set read from git off the merge base; derive exit 0): 30 commands, each run with its exit captured before any pipe and recorded as COMMAND :: exit N, then reconciled with --ran (exit 0).

# command exit s
1 node scripts/check-ci-filter-parity.mjs 0 1
2 node scripts/check-closing-keyword-parity.mjs 0 2
3 node scripts/check-closing-keyword-parity.mjs --self-test 0 4
4 node scripts/check-comment-mask-corpus.mjs 0 127
5 node scripts/check-declaration-mirrors.mjs 0 0
6 node scripts/check-declaration-mirrors.mjs --self-test 0 0
7 node scripts/check-scripts-symbol-anchors.mjs 0 4
8 node scripts/check-scripts-symbol-anchors.mjs --self-test 0 4
9 node scripts/check-self-test-wired.mjs 0 1
10 node scripts/check-self-test-wired.mjs --self-test 0 6
11 node scripts/check-self-test-workflow-commands.mjs 0 87
12 node scripts/check-self-test-workflow-commands.mjs --self-test 0 3
13 node scripts/check-whole-set-label-write.mjs 0 2
14 node scripts/check-whole-set-label-write.mjs --self-test 0 0
15 node scripts/pm/bare-root-worklist.mjs --self-test 0 28
16 pnpm check:agent-test-spelling 0 3
17 pnpm check:bash32-floor 0 2
18 pnpm check:cli-command-ids 0 14
19 pnpm check:cross-package-test-inputs 0 31
20 pnpm check:declared-population-live 0 35
21 pnpm check:driver-memory-census 0 6
22 pnpm check:entry-guard 0 37
23 pnpm check:gitlink-declared 0 1
24 pnpm check:nul-bytes 0 3
25 pnpm check:parse-guard 0 2
26 pnpm check:pm-dispatch-gates 0 265
27 pnpm check:pnpm-filter-targets 0 3
28 pnpm check:ratchet-remedy-authority 0 4
29 pnpm check:refd-timer-probe 0 13
30 pnpm check:watch-hint-literal 0 6

Non-zero exits: none.

--ran reconciliation tail:

Run reconciliation — 30 derived, 30 run, 0 NOT-MEASURED, 0 UNRUN.
  The 30 is THIS tree's derivation, recomputed in this process from the same expression --commands prints — never read back from your record. A family you never wrote down is still counted, which is what an arithmetic over your own list cannot do.
  EXIT CODES — all 30 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them. ⛔ This tool ran none of them; it read the codes you recorded.
  ⛔ This answers ONE link: what this card DERIVES against what you RAN. It is not a complete account of what CI runs on the PR — the 54 artifact-roster famil(ies), the 11 declared WIDE-population famil(ies), the 15 pending-changeset famil(ies), the unreachable listing, the 1 path-scheduled CI job(s), the type-check lanes and the always-runs tail are each outside the derived total, each printed under its own heading by a run without --ran.
✓ dispatch-gates --ran: 30 derived famil(ies) accounted for — 30 run, 0 NOT-MEASURED (a DERIVED zero — all 30 recorded an exit code and none of them is 3).

Narrowed lint, measured not skipped: pnpm exec eslint --no-inline-config --format json over the four touched files → 4 files, 0 errors, 0 warnings (eslint files: 4 errors: 0 warnings: 0). Population: the config's one object matching **/*.mjs (no type-aware linting anywhere — parserOptions carries ecmaVersion and sourceType only, no project), with exactly two rules on for scripts/pm/*.mjs per --print-config: no-restricted-imports and comment-swallow/no-code-inside-block-comment, both per-file; so no untouched file's verdict can move with this diff, and the four-file run is the whole measurable effect. Repo-wide pnpm lint is CI's run.

The engine's own no-path --tier reading on this head: Changed lines — 29479 (+15109 / -14370; generated files INCLUDED) vs the human-merge threshold 3000: ⛔ OVER — this PR lands as a Tier H surface does: an authorized APPROVED review (GOVERNED_APPROVERS, on ANY commit) and then the owning seat, or a HUMAN MERGE (maintainer rulings 2026-09-18 and 2026-09-27; no exemption for generated files, regen artefacts, docs builds or reverts). The governed terminal: no seat flips it ready, enqueues it, or arms auto-merge before that — ACCEPT on the card, needs-user-decisionon the PR, the final 维护者速读, review requested from GOVERNED_APPROVERS. The landing pre-checkcheck-governed-merges.mjs --pr N reads the PR's own number.

Acceptance notes

  • Mechanism assumption 1 (the roster may already be data): confirmed, measured. No path → family table exists in the engine; the families are derived from the tree on every run. What moved is the hand-written tables (grammar vocabularies, ledgers, the change-kind roster, tier globs, ladder words). No second roster was invented.
  • Mechanism assumption 2 (the self-test is nearly half the file): confirmed — selfTest() was lines 16715–29714 of 30,050. The two tiers are as the assumption describes; CI runs both through the unchanged pnpm check:pm-dispatch-gates line (the wrapper reads GITHUB_ACTIONS), so lint.yml is untouched and no second family enters the derivation.
  • Mechanism assumption 4 (byte-identical derivation): confirmed on the same tree, the strict way. Both engines were run in one checkout (the base engine as an untracked copy beside the head engine), over every literal CLI argv the battery uses, the lane's three change sets, this card's surface, and the --ran records the battery builds by construction — stdout, stderr and exit code all compared. Comparing across two checkouts instead would have differed on the "swept over N tracked file(s)" count alone (two new tracked files), which is not the engine's doing.
  • Deviation from the suggested route (the replay as a committed self-test case): the claim's file surface is the engine, the new files beside it, the wrapper and package.json:79 — "and nothing else" — and a committed golden replay would be a new ratchet that reds on every unrelated workflow edit, which the ruling forbids (「⛔ 无原话不新增」). The replay script is carried in this PR body (scrubbed of angle-bracket placeholders) and its corpus is derived from the committed test module, so it is reproducible by anyone; it is not a one-off in a temp dir, and it is not a gate.
  • Deviation (model, mechanics): the wrapper's tier decision reads one environment variable (GITHUB_ACTIONS). The wrapper's own header argues against env-controlled child substitution; this read runs in the opposite direction (it only ever adds the slow tier), is announced before the spawn, and is pinned in both directions by its self-test. The alternative — a second package.json script wired into lint.yml — would have edited a workflow off the file surface and added a family to every derivation touching this file.
  • Observed, not filed (class: observation, reach: none): the --self-test branch could not keep a top-level await — the battery imports the engine, so the engine awaiting the battery deadlocks (Node exit 13, nothing printed). Loaded with import(...).then(...) instead; the handshake constant and the exit-1 sentence are unchanged. Recorded here so the next reader does not "simplify" it back.
  • Observed, not filed (class: observation, reach: none): a function selfTestTier( in the wrapper was flagged by the engine's compound-anchor census (COMPOUND_ANCHOR_LEDGER) — the anchor fires on NAMES; the helper is named tierOfThisRun for that reason, and the ledger is untouched.
  • Readings are from a contended box (load ≈3.4 on 4 cores, another battery running); the per-tier seconds are relative, not a runner's. CI's own step log is the runner reading.
  • The 5-hour usage wall killed the session twice (13:0xZ and ~17:5xZ); restarted idempotently at 14:0xZ and 19:1xZ from the pushed empty branch and on-disk readings — nothing was re-derived from memory; the base battery, the timestamped run and the smoke comparison were re-read from their logs, and the first coverage run (killed without a verdict) was re-run; the second kill took the final-head coverage rerun at 1,990 of 2,085 cases, reported NOT MEASURED above rather than re-run.
  • The PR's changed-line count exceeds the human-merge line threshold, by construction. Moving 13,000 lines of battery out of the engine is counted twice by additions + deletions (29,421 on this head: +15,053 / −14,368 against e148ca98; HUMAN_MERGE_LINE_THRESHOLD is 3,000 since governance: lower the human-merge line threshold from 5,000 to 3,000 changed lines (HUMAN_MERGE_LINE_THRESHOLD), per the maintainer's 2026-10-09 ruling #22451 landed). Git cannot see it as a rename — the engine stays the engine. So although the surface is off the governed register, this PR lands the way Multi-agent discipline §7(c) says a diff over the line threshold lands: an authorized APPROVED review and then the owning seat, or a human merge — not the queue on green alone. The card's "非受管面,席位入队落地" expectation does not hold for this diff; the engine's own no-path --tier reading on this head says it in its words: "⛔ OVER — this PR lands as a Tier H surface does: an authorized APPROVED review (GOVERNED_APPROVERS, on ANY commit) and then the owning seat, or a HUMAN MERGE … no seat flips it ready, enqueues it, or arms auto-merge before that". The seat routes it so; the maintainer's letter A authorised exactly this split.
  • Deviation (file surface): commit (d) touches scripts/check-ratchet-remedy-authority.mjs — one ledger row (+2 / −2) whose expectation named the engine as the home of a token the moved roster carried; the gate's own positive control reddened on this head and its text prescribes correcting the entry. Nothing else off the surface was touched; the union's other 29 families were green before and after.

维护者速读(草稿)

改了什么:scripts/pm/dispatch-gates.mjs(派发时给每张卡推导本地门禁和模型档位的引擎)拆成三个文件:引擎本体只留「读表、匹配路径、推导」;手写的表(标记语法词表、各类台账、按改动类型触发的门禁清单、模型档位 glob、档位阶梯词)搬到旁边的 dispatch-gates.data.mjs,纯数据、无逻辑;自检电池搬到 dispatch-gates.self-test.mjs,并分成快档(进程内用例)和慢档(真实起子进程、建临时 git 仓、全树扫描的用例)。另按 #22167 裁决 C 把 --tier 输出里关于条款②的那句话改成「只问放宽」的同一含义(两处)。

为什么改:#22453 维护者批了 A:解冻这一次拆分。拆分前引擎 30,049 行、每次交付都要跑约 17–19 分钟的电池;拆分后开发交付默认只跑快档,CI 仍跑全部;新增一条按类型触发的门禁从此是一行数据(仍只凭维护者原话新增)。

风险与代价(含回滚):推导输出逐字节未变(PR 里有回放脚本与空 diff);旧用例一条不少(快档+慢档=全量,有 pin);被动风险是 CI 的档位判断依赖 GITHUB_ACTIONS 环境变量,失败方向是「少跑但大声说」,不会静默。回滚 = revert 本 PR(三个提交各自独立可 revert)。

席位意见:(留空)

你要做的:这张 PR 的文件面不在受管清单上,但改动行数是 29,421(搬出 1.3 万行自测被 additions + deletions 计了两次),远超人合阈值 3,000——引擎自己的无路径 --tier 读数已判 ⛔ OVER:「this PR lands as a Tier H surface does: an authorized APPROVED review (GOVERNED_APPROVERS, on ANY commit) and then the owning seat, or a HUMAN MERGE」。所以不是「席位入队落地」:需要维护者给一次 APPROVED 评审(之后席位落地),或由维护者人合;席位不翻 ready、不入队、不挂 auto-merge。落地后冻结自动恢复。


Generated by Claude Code

Closing-target claims: #22478 — claim 6080824452 · #22167 — claim 6088380960 (ruling C's remainder rides this branch, per the maintainer's letter A on #22453).

objectstack-fleet Bot and others added 6 commits October 9, 2026 15:17
…not drive, before the split

Condition 1 of the lift: measured with V8 block coverage over a full run of
the battery (the engine's functions and branches, self-test excluded), then
one pin per path no case reached. No behaviour changes in this commit.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA
…, the self-test out as a module with two tiers

The gate roster was never a table — families are derived from the workflows,
the composite actions, package.json and the gate sources on every run — so
what moves is what IS hand-written: the marker grammar vocabularies, the
ledgers, the change-kind roster, the tier globs and the ladder words, into
dispatch-gates.data.mjs with their docblocks and no logic (a predicate or a
tier is named, and the engine resolves the name at load, refusing one it
does not know). The battery moves into dispatch-gates.self-test.mjs, loaded
only by the engine's --self-test branch, and splits into a FAST tier (every
in-process case) and a SLOW tier (every CLI spawn, temporary repository and
whole-tree sweep), the two pinned to partition the battery. The wrapper
check-dispatch-gates.mjs runs the fast tier on a dev box, both under
GITHUB_ACTIONS or --slow, and says which before it spawns; lint.yml and the
package.json line are untouched, so no new family enters the derivation.

The engine keeps roster loading, path matching and derivation; its declared
inherited population (.github/workflows, .github/actions) is what every
follower reads, so no derivation moved — proven by the replay in the PR.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA
…dening only

Both occurrences of the clause-② sentence now carry the lane rule's meaning:
a card that widens a published accept set or the public surface owes the
contract-review-tier review and is spec-lane work; a card that only narrows
one stays in its lane and owes one such review before the queue. The old
sentence gave a narrowing card two answers.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA
…follows the moved table

The gate's known-instance row for scripts/pm/dispatch-gates.mjs expected
`marked`: the change-kind sentence carrying the ⛔ MAINTAINER-ONLY token
lived in the engine. That roster moved out as data, so the sweep's offer
grammar reaches nothing in the engine now and the gate's own positive
control reddened ("the gate changed shape — correct the entry"). The row
turns `excluded` with the move recorded in its reason; the data file
classifies excluded on its own and needs no row. The token still reaches
the author verbatim, from the data row.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA
…acks the second reading showed

The three load-time binders become exported functions with the same
behaviour (bindChangeKindRows, bindMandatoryTierRows, bindSuspectTierRows)
so a row naming a predicate, tier or exception the engine does not define
can be shown to refuse loudly; the battery gains those pins and the ones
for the fallback fragments a caller reaches only by handing in nothing.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 102749113f64ce12728f81c75ad6b7990fee8e00
Local-runs: probe — the seat re-derived --commands and --tier with the base engine and the head engine on one tree over two change sets, to read the ruling's condition 2 independently (the dev's final-head coverage rerun is NOT MEASURED after the second usage wall)

① Derived judgments

  • Accept set: none on any published surface — the diff is the lane's own derivation instrument. Its derivation is unchanged: the seat's probe placed origin/main's engine as an untracked copy beside the head engine in a detached worktree at 10274911 and ran both over PR chore(governance): the human-merge line threshold is 3,000 changed lines (HUMAN_MERGE_LINE_THRESHOLD), per the maintainer's 2026-10-09 ruling #22490's seven paths and the automation skill's two paths — --commands byte-identical (45 and 24 lines), --tier identical but for the one reworded Clause-② sentence (ruling C's remainder, the intended change), stderr identical outside the STALE-tree banner, every exit 0. The dev's replay says the same at scale: 69 corpus invocations plus 8 --ran cards, 852 files per side, diff -r exit 0 on the split and again on the committed engine; the (c) delta over 60 files is the sentence and nothing else. Right — condition 2 holds.
  • The roster was measured, as the dispatch asked, and the card's premise corrected: the gate families are DERIVED from the tree's workflows and scripts — no path → family table exists. What moved out as data is what the engine hand-wrote: the marker grammars, four ledgers, the change-kind roster, the tier globs and the ladder words, into dispatch-gates.data.mjs (1,258 lines, no logic; predicates, tiers and exceptions named by key and resolved at load with a loud refusal). The engine falls 30,049 → 15,927 lines; the battery moves into dispatch-gates.self-test.mjs (13,538) with a FAST tier (1,825 in-process cases, 271–286 s) and a SLOW tier (260 cases — CLI spawns, temporary repos, whole-tree sweeps — 776–834 s); the wrapper check-dispatch-gates.mjs (502 → 567) runs the fast tier on a dev box and both tiers under GITHUB_ACTIONS or --slow, pinned in both directions, so package.json:79 and lint.yml are untouched and no new family enters the derivation. Right — the ruling's shape (roster as reviewable data, a small engine, minutes on a dev box) without a second roster that could drift from the workflows.
  • Condition 1, coverage first: V8 block coverage over a full run of the battery (parent and 59 CLI children merged) read 245 of 245 engine declarations invoked and 253 of 2,259 blocks at zero before any split; 59 in-process pins were added for the derivation paths among them (commit 44d264e, battery 2,070 pass); after the split and the note (commit 2a5b52c) 248 of 248 invoked, 248 of 2,274 at zero; commit 8f1979e exports the three load-time binders and pins their 4 refusal blocks and 10 fallback fragments (14 cases). The final-head coverage rerun is NOT MEASURED — killed with the dev at 1,990 of 2,085 cases by the second wall — and the (c)-head reading stands as the measured after; stated, not hidden. Right.
  • Condition 3, nothing else: the --tier note's three spellings are reworded to ruling C's meaning (widening only; grep -c of the old phrase reads 0 — Fixes #22167); governance: lower the human-merge line threshold from 5,000 to 3,000 changed lines (HUMAN_MERGE_LINE_THRESHOLD), per the maintainer's 2026-10-09 ruling #22451's landed pin keeps its meaning inside the moved self-test (the ruled 3000, dispatch-gates.self-test.mjs:10018, merged from origin/main ee8751d4 at 10274911); the freeze restores on landing — no new gate family, ratchet or patrol line, and the replay corpus was deliberately NOT committed as a golden case (it would be a new ratchet reddening on every workflow edit, which ruling 208's 「⛔ 无原话不新增」 forbids; the runner is in the PR body, the corpus is read from the committed test module). One edit off the declared surface: scripts/check-ratchet-remedy-authority.mjs +2/−2 — its known-instance ledger expected the engine to be marked for a token that moved with the roster into the data file, the gate reddened on this head and its own text prescribes correcting the entry (the row turns excluded). Accepted: a sibling gate's ledger following the move, in its own commit.
  • The battery on the merged head: 2,085 cases pass, fast 1,825 in 285.9 s, slow 260 in 776.1 s, wrapper 1,062.6 s, exit 0 (base: 2,011 cases in 1,093.7 s); a dev delivery now pays the fast tier — under five minutes against eighteen.
  • SIZE: 29,479 changed lines (+15,109 / −14,370; the 13k lines moved out count twice by construction) is over the human-merge line of 3,000 in force since 14:44Z: this PR lands the way §7(c) says — an authorized APPROVED review and then the owning seat, or a human merge — never the queue on green alone. The card's "off the governed register, the queue lands it" reading does not hold for this diff; the four-piece terminal set is posted instead. Right.
  • Public surface: none; Clause-②: no is correct.

② Semver level

Clause-②: no on the claim and the PR body; skip-changeset is the correct declaration — scripts/pm/** and scripts/check-ratchet-remedy-authority.mjs publish nothing. No .changeset/*.md touched.

③ Boundary flags

Implemented-by: claude/issue-22478-dispatch-gates-roster-split
Reviewed-by: session_01JmWtcHfGbC4ncw4GFKWuRA

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 — PR #22531(#22478 + #22167)— skills 席 1,2026-10-09T20:12Z

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment