Skip to content

tooling(pm): measure the scheduled-only routing question and pin its zero - #15236

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-14899-scheduled-only-families
Sep 4, 2026
Merged

tooling(pm): measure the scheduled-only routing question and pin its zero#15236
os-steve merged 3 commits into
mainfrom
claude/issue-14899-scheduled-only-families

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #14899

The card reported that dispatch-gates derives the live half-state board sweep into the per-PR gate family for any diff carrying a changeset, on the reading that the sweep's only caller is a schedule-triggered workflow that no PR runs. It deliberately left the repair undecided and asked for a measurement first. This PR is that measurement, and the answer it selects.

1. Measured, from the workflow text at fa8c1963

Every workflow's on: block classified by its declared events, then every discovered family classified by the events of the workflows that invoke it.

.github/workflows/*.yml                                            30
  declaring `schedule:`                                            15
  ...of those, contributing a discovered check family               8
discovered families                                               252
  reaching a scheduled workflow at all                             21
  reached ONLY through scheduled workflows                         10
  SCHEDULED-ONLY - reached by no PR-time trigger                    0

The ten candidates, by command and workflow, with the trigger that resolves each and the answer to "is this one a dev SHOULD run on a PR", read from the family's own source:

family (command) sole source workflow its on: events PR-time trigger should a dev run it?
scripts/check-engine-split-ratio.mjs --days 90 engine-split-metric.yml schedule, workflow_dispatch, pull_request pull_request paths: the script + the workflow YES for a diff touching the script - that is exactly what the paths filter names
scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE" half-state-patrol.yml schedule, workflow_dispatch, pull_request pull_request paths: the sweeper, scripts/invoked-as.mjs, the workflow CI runs it on those PRs to prove transport/flags/rendering; it is already withheld from --commands as VALUE-BEARING
scripts/check-prerelease-pin-watch.mjs --self-test prerelease-pin-watch.yml schedule, workflow_dispatch, pull_request pull_request paths: the script, the workflow, pnpm-workspace.yaml YES - offline self-test of the pins the diff moves
scripts/check-prerelease-pin-watch.mjs --verbose prerelease-pin-watch.yml schedule, workflow_dispatch, pull_request same YES on the same PRs; CI runs this exact spelling there
scripts/check-release-section-coverage.mjs --strict release-coverage-patrol.yml schedule, workflow_dispatch, pull_request pull_request paths: the script + the workflow YES for a diff touching the script
scripts/render-release-coverage-anchor.mjs --self-test release-coverage-patrol.yml schedule, workflow_dispatch, pull_request same YES - offline self-test
scripts/check-required-contexts.mjs --verify-required-set required-set-patrol.yml schedule, workflow_dispatch, pull_request pull_request paths: the script + the workflow YES for a diff touching the script; CI runs this spelling on that PR
check:override-consistency validate-deps.yml pull_request, schedule, workflow_dispatch pull_request paths incl. **/package.json, pnpm-workspace.yaml, the script YES - an ordinary dependency gate, on a PR-first workflow
scripts/check-osv-exemptions.mjs --self-test validate-deps.yml pull_request, schedule, workflow_dispatch same YES - the ledger and its gate are both in the paths list
scripts/check-osv-exemptions.mjs validate-deps.yml pull_request, schedule, workflow_dispatch same YES - same

All ten dissolve on one deliberate repo posture, stated in the patrols' own comments: every patrol here declares a pull_request: trigger with a paths: filter naming its own script, so "Changes to the patrol itself get exercised before they merge" (half-state-patrol.yml, and the same words in required-set-patrol.yml, release-coverage-patrol.yml, merged-branch-reaper.yml). The card's own specimen already carried that trigger on the day the card was filed - read at afacebb6 (2026-08-31), the newest commit to that file at or before 2026-09-03T08:42Z:

on:
  schedule:
    - cron: '37 1,7,13,19 * * *'
  workflow_dispatch: {}
  pull_request:
    paths:
      - 'scripts/pm/check-half-states.mjs'
      - '.github/workflows/half-state-patrol.yml'

So the premise "it is not triggered by a PR" was already false when the card was written. What was true, and is the reason the card was worth filing, is the cost: the bare node scripts/pm/check-half-states.mjs line really was in --commands and really did sit for 3m09s.

The reading does not depend on where the PR-time line is drawn. Narrowing PR_TIME_TRIGGER_EVENTS from {pull_request, pull_request_target, merge_group, push} to pull_request alone leaves the same zero, because all ten are reached through a pull_request trigger specifically.

The honest complement, so the zero is not the union quietly hiding a member: exactly four families are reached by no PR-time event at all, and none of them is scheduled - all four come from cut-rc.yml, the human release lane, which is workflow_dispatch-only. Three are already withheld as VALUE-BEARING (--base "$SNAPSHOT_SHA"); the fourth, node scripts/check-changeset-no-major.mjs --self-test, is a runnable command in --commands for a changeset diff, and is one a dev should run anyway - measured at 0.85s, offline, no API, and it proves the parser for the very file the diff adds ("116 assertions ... frontmatter dialects measured against @changesets/parse").

2. The shape taken: (c), plus the pin that makes it honest

(a) is refused as an empty classification. It has zero members on this tree, so shipping a "scheduled-only, not a PR gate" class - a row field, a labelled heading, a --json field, a --ran bucket and a third --commands subtraction - would be a capability with nothing in it. That is the same refusal extractTriggerPaths already records for paths-ignore: two sections up in the same file: "Speculative capability is what this repo's own review standard rejects ... when one does and its families matter, model it then." The precedence question the ruling asks about - what a family that is both value-bearing and scheduled-only would report - is moot for the same reason: no class is shipped, and the one family that would have been the specimen is already withheld by the value-bearing class, which is the order commandsFor states (CI-measured first, then value-bearing; a scheduled-only class would have been the third and last subtraction).

(c) alone would leave the deferral unwatched, so it is not shipped alone. A deferral is only honest while its population stays empty, and nothing was measuring that. So what ships is the reading:

  • declaredTriggerEvents(workflowText) - the third of the file's three narrow on: walkers, beside extractTriggerPaths and declaresPullRequestTrigger so the trio cannot drift. Dependency-free indentation walk, like its two siblings.
  • PR_TIME_TRIGGER_EVENTS - the reviewable definition of "in front of a pull request", in one place.
  • A --self-test block that re-takes the whole measurement from the workflow text on every run, so the day a family really is scheduled-only, the pin reds on the PR that creates it. Its case names the two exits: give the workflow the pull_request paths trigger every patrol here already carries, or ship the class the header defers. Editing the pin's expectation is not one of them - the zero is a reading, not a roster.

No derivation consumes the reader. entry.scheduledOnly does not exist, commandsFor is untouched, and no row gains a field. The measured cost the card reported is separately gone: PR #15114 classified that invocation VALUE-BEARING off its $PROVENANCE argv, so the sweep left --commands with no scheduled-only rule existing.

Four-axis reasoning

  • Real business need - measured, not asserted: the population of the proposed class is zero, at the current head and under every reading of "PR-time". Nobody is in it. The need that IS real is the one the card's own "Scope NOT established" section names - somebody has to know if that ever changes - and that is what ships.
  • Long-term project sense - the North Star direction here is "derived, never listed", and this file's whole contract is that a fact stated twice drifts. A classification with no members would be a second, unexercised statement about routing that only fixtures could ever reach; a reading taken from the workflow text on every run is the same discipline the trigger key and the value-bearing class already follow. No workaround, no temporary patch.
  • Making AI-authored mistakes structurally harder - this is where the pin earns its place. The failure this card is really about is a dev running (or reporting) a command CI never ran on their diff. Shipping (a) speculatively would have withheld ten families that CI DOES run on a PR, including check:override-consistency - a loud tightening in the wrong direction. The pin instead makes the silent direction loud: a scheduled-only family arriving tomorrow currently produces no signal anywhere, and afterwards produces a red on the PR that introduces it, with the remedy named in the case text rather than left to be re-derived.
  • Startup-stage: do not proliferate - the strongest axis here, and it points the same way. 2026-08-04: 「我们是一个创业项目,应该先专注于核心能力」. An unpulled declaration surface is handled implementation-first; a class with zero members is exactly that. Nothing staged, nothing dual-spelled, no grace window: the class is simply not built until something is in it.

3. Rendering before/after: unchanged, and proven

The ruling asks for the rendering before/after on a changeset path. It is byte-identical, which is the intended result - this PR moves no output. Measured by running the tool from a worktree at origin/main (9c1bcda3) and from this branch's head, on the card's own probe path:

diff of the two full human renderings, .changeset/EXAMPLE.md
diff exit=0 (0 = identical)

--commands, three probe paths:
  IDENTICAL (15 commands) :: .changeset/EXAMPLE.md
  IDENTICAL (20 commands) :: scripts/pm/dispatch-gates.mjs
  IDENTICAL (50 commands) :: packages/spec/src/data/filter.zod.ts

The two half-states spellings in that rendering, unchanged on both sides:

Local gates for this card (paste into the dispatch prompt):
  - pnpm check:pm-half-states   [lint.yml]   matched via .changeset/EXAMPLE.md => gate source '.changeset'

Value-bearing argv - matched by path, and NOT runnable here (7 famil(ies)):
  - node scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE"   [half-state-patrol.yml]
      NOT RUNNABLE LOCALLY - 1 value(s) come from the workflow: $PROVENANCE

The offline half lint.yml runs on every PR stays a runnable command; the live sweep stays out of --commands on the value-bearing class. Both are pinned below.

4. Self-test cases (18 added; battery now 1371)

Extractor fixtures, shaped from the real on: blocks in this tree:

  1. the on: mapping's events are read in declaration order
  2. a key nested UNDER an event is not an event, however event-shaped its name (a push: under pull_request:)
  3. a decoy event under jobs: is not read
  4. an event's own sub-keys (types, paths, branches) never enter the list
  5. the flow-sequence spelling is read
  6. the bare-scalar spelling is read
  7. the block-sequence spelling is read
  8. the quoted and YAML-1.1 spellings of the key are all read ('on', "on", true)
  9. a workflow declaring no on: block yields an empty list, not a fabricated event

Against the real workflows, read from the tree rather than pasted (a quoted copy of a workflow is a second revision of it waiting to rot):

  1. the card's own specimen declares a pull_request trigger beside its schedule
  2. a genuinely scheduled-only workflow reads as one, so the predicate is not answering pull_request to everything (stale.yml) - the control that keeps case 10 from being satisfied by a broken reader

Live, non-vacuity first:

  1. the tree really declares 15 schedule-triggered workflows, so the sweep has a population
  2. 8 of them really contribute discovered families (21 families), so the zero is a reading and not an empty instrument
  3. ZERO of the 252 discovered families is SCHEDULED-ONLY, with the two exits named in the case text
  4. the reading does not depend on how wide PR-time is drawn: narrowing to pull_request alone leaves the same zero
  5. the complement agrees: all 4 families reached by no PR-time event at all come from workflows that declare no schedule

The control the ruling names - a family with a PR-time trigger keeps its class - plus the half this card must not move:

  1. the card's specimen is still discovered, still reached only through its patrol, and still classified VALUE-BEARING, not withheld for being scheduled
  2. the offline half is untouched: check:pm-half-states reaches lint.yml, carries neither withholding class, and still renders a runnable command

The pin is proven to be able to red

Reverse verification, from the committed state, with a restore trap and both legs proven on disk. The mutation removed the pull_request: trigger block from half-state-patrol.yml, making its family genuinely scheduled-only:

HEAD blob for .github/workflows/half-state-patrol.yml: 46579667e3128a8462aca25eca481801c08e953f
before: '  pull_request:' occurrences = 1
after:  '  pull_request:' occurrences = 0
mutated blob: c7f387fd46bcf9937b5ec4b560f846767607dd40
--- on: block now reads ---
  schedule:
  workflow_dispatch: {}
--- running the pinned cases against the MUTATED tree ---
  FAIL: the card's own specimen declares a pull_request trigger beside its schedule
  FAIL: ZERO of the 251 discovered families is SCHEDULED-ONLY ...
  FAIL: ...and the reading does not depend on how wide PR-time is drawn
  FAIL: the complement agrees: all 5 famil(ies) reached by no PR-time event ...
  FAIL: the card's specimen is still discovered ... and still classified VALUE-BEARING
  FAILED 5
restored blob: 46579667e3128a8462aca25eca481801c08e953f
git diff HEAD after restore: [ empty ]
ABLATION VERDICT: mutation landed, pin exit=1, restore proven byte-identical

The ablation ran at 45a9153b, before the main merge, which is why its output says 251 families rather than 252. Five of the eighteen turn red and thirteen stay green, so the pin discriminates rather than reacting to any edit; the complement case correctly re-counts 5 no-PR-time families instead of 4. No build leg exists for this ablation and none is claimed: the tool reads .github/workflows/*.yml and the gate sources from the working tree at runtime, so there is no dist/ between the mutation and the reading.

5. Gates - verification run at head ff0c7a56

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) after the final commit. Each exit code captured before any pipe; each row quotes the gate's own verdict line.

Run reconciliation - 20 derived, 20 run, 0 NOT-MEASURED, 0 UNRUN.
dispatch-gates --ran: 20 derived famil(ies) accounted for - 20 run, 0 NOT-MEASURED.
command exit its own verdict line
node scripts/check-ci-filter-parity.mjs 0 OK: all 143 declared cross-package glob(s) (99 unique) are covered ...
node scripts/check-closing-keyword-parity.mjs 0 check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords ...)
node scripts/check-closing-keyword-parity.mjs --self-test 0 check-closing-keyword-parity --self-test: 24 assertions, 5 mutations ... driven to red.
node scripts/check-comment-mask-corpus.mjs 0 comment-mask corpus sweep: 5874 files, 0 disagree, 0 unparseable, 64.6s
node scripts/check-self-test-wired.mjs 0 check-self-test-wired: every one of the 168 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-self-test-wired.mjs --self-test 0 4 live ledger row(s) verified ... every battery at or above its pinned floor.
node scripts/check-whole-set-label-write.mjs 0 check-whole-set-label-write: 0 violations - 260 file(s) over 3 root(s) ...
node scripts/check-whole-set-label-write.mjs --self-test 0 all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)
pnpm check:agent-test-spelling 0 check-agent-test-spelling: 0 violations - 442 file(s) ... 9 separator(s) JUDGED
pnpm check:bash32-floor 0 26 tracked shell file(s) ... name no bash 4+ construct
pnpm check:cli-command-ids 0 329 command-id literal(s) across 116 file(s) ... all resolve to a real command path
pnpm check:cross-package-test-inputs 0 OK: 26 package(s) read outside themselves, all declared
pnpm check:declared-population-live 0 201 of 252 famil(ies) declare a path population, and every one of them reaches this tree's 8276 tracked file(s).
pnpm check:entry-guard 0 209 scripts/ file(s) - every entry guard goes through invoked-as.mjs; 155 export bindings, 155 of them inert on import
pnpm check:nul-bytes 0 OK (scanned 8269 text file(s) ... no raw ASCII control bytes).
pnpm check:parse-guard 0 check:parse-guard: 208 scripts/ file(s) - every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-dispatch-gates 0 dispatch-gates self-test: 1371 cases pass.
pnpm check:pnpm-filter-targets 0 142/181 --filter occurrence(s) across 33 file(s) resolve against 79 workspace package(s)
pnpm check:refd-timer-probe 0 5869 source file(s) swept; the process-global timer probe is read in ... and nowhere else.
pnpm check:watch-hint-literal 0 49 declaration(s) across 4 rostered name(s) ... no unrostered spelling of the idiom in the tree.

Beyond the derived list, both explicitly required by the dispatch:

command exit its own verdict line
node scripts/pm/bare-root-worklist.mjs --self-test 0 OK self-test: 54 live row(s), 46 unreachable as spelled, 46 recorded verdict(s) - none stale, none missing, none contradicted.
pnpm lint (eslint . --no-inline-config, whole repo) 0 clean - no output

The long self-test also ran through the shared verify lock: os-verify-lock: VERDICT command-exit 0 - held the lock 394s (6m34s) - waited 47s. check-test-completeness is a value-bearing family and is NOT MEASURED here, in its own words.

skip-changeset: scripts/pm/** publishes nothing.

Draft, per the dispatch: scripts/pm/** with no .md is not a governed surface, and the seat reviews at the contract tier before flipping ready.


Generated by Claude Code

…zero (#14899)

The card reported that dispatch-gates derives the live half-state board sweep
into the per-PR gate family for any diff carrying a changeset, on the reading
that its only caller is a `schedule`-triggered workflow, and asked for a
general "scheduled-only, not a PR gate" class before anything was built.

Measured first, from the workflow text at 50d6c92: of 30 workflows, 15
declare `schedule:` and 8 of those contribute a discovered check family; of
251 discovered families, 21 reach a scheduled workflow and 10 are reached
ONLY through scheduled workflows — and ZERO are scheduled-only, because every
patrol here declares a `pull_request:` trigger with a `paths:` filter naming
its own script, so changes to a patrol are exercised before they merge. The
card's own specimen already carried that trigger on the day it was filed, and
three of the ten are `validate-deps.yml`'s, including `check:override-consistency`.
The zero does not depend on how wide PR-time is drawn: narrowing it to
`pull_request` alone leaves the same zero.

So the class is not shipped — an empty classification is a capability with
nothing in it. What ships is the reading that makes the deferral honest:
`declaredTriggerEvents` re-takes the measurement from the workflow text on
every `--self-test`, so the day a family really is scheduled-only the pin reds
on the PR that creates it, and its case names the two exits.

No derivation consumes the reader, and `--commands` is byte-identical for
every path: the cost the card measured is separately gone, because #15083
classified that invocation value-bearing off its `$PROVENANCE` argv.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…14899)

`main` moved five commits under the branch and added one check family, so the
header's table is re-measured rather than left describing the pre-merge tree:
252 discovered families, not 251. Every other row is unchanged, and the answer
is unchanged — ZERO scheduled-only families.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@github-actions github-actions Bot added the size/m label Sep 4, 2026
@os-steve
os-steve marked this pull request as ready for review September 4, 2026 07:45
@os-steve
os-steve enabled auto-merge September 4, 2026 07:45
@os-steve
os-steve added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 5e724a2 Sep 4, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-14899-scheduled-only-families branch September 4, 2026 08:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] dispatch-gates derives the SCHEDULED live half-state board sweep into the per-PR gate family for any diff with a changeset

2 participants