🔴 Author correction, 2026-09-21T11:30Z — the number this ticket was filed on was the wrong population.
The original body said the guard "scans the tier with 1 waitForTimeout, not the two with 277". That 277
was a grep waitForTimeout count. It is not the guard's population: check-fixed-sleeps.mjs flags only
unjustified waits at or above THRESHOLD_MS = 1000. Run against each tier with the guard's own
findUnjustifiedSleeps, the real counts are component 0 and e2e 3.
That is my own "a name-grep is not a census" rule, violated in a ticket I filed hours after citing it. The
scope below is rewritten around the measured population, and the half that actually explains #19024 has been
separated out rather than smuggled in.
Context
Found while reviewing #19024, where a component spec used await page.waitForTimeout(400) with no stated reason. I went to cite the house rule and discovered the rule does not reach that tier.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-21T10:22Z; no equivalent found.
The Problem
buildScripts/util/check-fixed-sleeps.mjs exists to stop a fixed second-scale wait that does not say what it is waiting for. Its workflow states the invariant it is built on:
"The invariant is scanned ⊆ watched. The guard walks every unit spec, so every unit spec must re-run it."
That sentence is true and it is about the unit tier only. SCAN_ROOT = 'test/playwright/unit' at line 103, and .github/workflows/fixed-sleep-lint.yml filters on test/playwright/unit/**/*.mjs. Two other tiers hold specs and neither is scanned.
What the guard would actually find if it looked. Measured at dev by calling the guard's exported findUnjustifiedSleeps against each tier's .mjs files:
tier
spec files
unjustified sites ≥ 1000 ms
scanned today
unit
411
3 — all three already in the baseline
yes
component
107
0
no
e2e
145
3
no
The three e2e hits, read at source rather than counted:
test/playwright/e2e/grid/RowPinning.spec.mjs:157 — await new Promise(r => setTimeout(r, 12000)) under the comment "Let it run while playwright scrolls". A genuine twelve-second unexplained fixed sleep. This is the real find.
test/playwright/e2e/dashboard/TearOutMatrixRows4To7NL.spec.mjs:248 — a 2000 ms setTimeout inside a Promise.race, i.e. a timeout guard rather than a wait.
test/playwright/e2e/utils/browser-test-helpers.mjs:77 — a MutationObserver timeout guard, cleared on success.
The latter two are exactly what the existing out-waits: justification marker is for; they want a marker, not a rewrite.
And the honest consequence for the finding that produced this ticket: widening the scan would not have caught it.#19024's waitForTimeout(400) is below THRESHOLD_MS. No change proposed here would have flagged it. That half is the threshold question and it is split out below.
The Architectural Reality
buildScripts/util/check-fixed-sleeps.mjs:103 — SCAN_ROOT, a single string.
:209 — SCAN_SURFACE, frozen and derived from SCAN_ROOT; the module's comment names it "the workflow-parity SSOT", and a sibling scanned-subset-of-watched spec reads it to demand the workflow watch everything the guard reads.
:243 — findUnjustifiedSleeps defaults its spec list to path.join(rootDir, SCAN_ROOT).
:104 — THRESHOLD_MS = 1000, the line that decides whether a wait is a site at all.
buildScripts/util/check-fixed-sleeps-baseline.json — three entries, all DragCoordinator.spec.mjs timers under test.
.github/workflows/fixed-sleep-lint.yml:9-23 — the paths: filters, which must stay a superset of whatever the scan becomes.
SCAN_ROOT being singular is the whole constraint: widening it is a plural change through SCAN_SURFACE, findUnjustifiedSleeps and the workflow filter together, and the parity spec is what keeps the "scanned ⊆ watched" invariant honest across the change.
The Fix
SCAN_ROOT becomes SCAN_ROOTS, a list; SCAN_SURFACE and the default spec collection derive from it.
Add e2e and component. e2e is where the real find is; component finds nothing today and is added so that a future second-scale sleep there is caught rather than discovered by a reviewer.
Extend the workflow paths: filters to match, in both the pull_request and push blocks, so scanned stays a subset of watched.
Disposition the three e2e hits rather than baselining them wholesale: RowPinning.spec.mjs:157 states what it waits for or stops being a sleep; the two timeout guards take the out-waits: marker the guard already accepts.
Update the module docblock at line 3, which says "Fails a unit spec…", and the workflow comment that asserts the unit-only invariant.
The baseline file should not grow. Three sites are few enough to disposition, and a baseline entry is a debt marker — adding three when the alternative is fixing three would be using the mechanism to avoid the work it exists to expose.
Contract Ledger Matrix
SCAN_SURFACE is an exported, consumed surface — ScanSurfaceAlignment.spec.mjs imports it as the authority for what the mirror workflow must watch — so its shape change belongs in a ledger rather than only in a diff. @neo-gpt asked for this on PR #19031 and he is right that it was missing.
Target Surface
Source of Authority
Proposed Behavior
Fallback / Edge Case
Docs
Evidence
SCAN_ROOTS (check-fixed-sleeps.mjs)
this ticket
replaces the SCAN_ROOT string with a frozen list; it is the single authority the workflow filter mirrors
a root added here without the matching workflow paths: entry fails ScanSurfaceAlignment.spec.mjs, not silently
module docblock + the constant's own comment
40 passed across guard/alignment/parity specs
SCAN_SURFACE (exported)
check-guard-ci-parity.mjs
still String[] of globs; now derived from SCAN_ROOTS rather than one interpolated root
shape unchanged, so every existing consumer keeps working without edit
JSDoc on the export
every mirror selects every tracked file of its guard's surface passes
findUnjustifiedSleeps({files}) default
this ticket
with no files, collects across every root instead of one
explicit files behaves exactly as before — the widening is opt-out by passing paths
@param on options.files
the guard's own spec passes unchanged; the AC-3 controls pass files implicitly via the CLI
fixed-sleep-lint.ymlpaths:
SCAN_SURFACE
gains the component and e2e globs in both the pull_request and push blocks
a filter narrower than the scan reds the alignment spec rather than landing ungated
the workflow's own trigger comment
alignment spec's scanned ⊆ watched arm
No consumer signature changes, so nothing downstream needs a migration step.
Acceptance Criteria
AC-1 — the guard scans the unit, component and e2e tiers, and the workflow paths: filter covers everything it scans; the existing scanned-⊆-watched parity spec passes unchanged.
AC-2 — the three e2e sites are dispositioned in the PR, each by one of the three shapes the guard already sanctions: a justification marker, a rewrite to a condition wait, or one of the two marker-free exemptions (a named-constant delay, or a self-naming failure deadline). The PR body names which each received and why. The third arm was missing from the original wording and @neo-gpt's review is what found it: a Promise.race losing arm and a cleared observer deadline are not waits at all, so marking them out-waits: enters time nobody spends into a backlog that counts wall clock still being paid.
AC-3 — a deliberately added unjustified ≥ 1000 ms sleep in a component spec and in an e2e spec each fail the guard locally — two positive controls, both shown in the PR body, because component is the tier with zero current hits and therefore the one whose coverage is otherwise unfalsifiable.
AC-4 — check-fixed-sleeps-baseline.json gains no entries.
AC-5 — the module docblock and the workflow comment state which tiers are scanned and what threshold counts, replacing the unit-only wording.
Out of Scope — and the successor that owns the rest
The sub-second threshold question is deliberately not in this ticket, and it is the half that explains #19024. THRESHOLD_MS = 1000 means an unexplained 400 ms wait is not a site. The population behind that question, measured as timed calls rather than guard hits:
tier
timed calls ≥ 1000 ms
timed calls < 1000 ms
unit
22
147
component
4
60
e2e
52
178
385 sub-threshold calls is a different decision with a different blast radius, and folding it in here would turn a five-file change into a repo-wide one. A successor should ask whether the guard's name — second-scale — is still the right boundary, and it is the question that would have caught #19024.
Filing promise retired, 2026-09-21T11:45Z. This section previously read "Filing it is part of this lane." It is not, and leaving that standing would be a commitment nobody is tracking. #19030 — filed from this lane — covers the call-shape gap and explicitly excludes sub-second waits, so it is not the successor this sentence promised. The threshold question is recorded in #19030's Out of Scope with the measured population above and stays an open question rather than becoming a fifth ticket: two of the four filed from this session (#19026, #19030) are already parked unowned, and filing against a queue we are not draining is the anti-pattern. Anyone who wants it has the numbers to file it. Raised by @neo-gpt reviewing #19031.
Also out of scope: rewriting justified sleeps, and any tier that does not exist today.
Related
#19024 — the review that surfaced this, and the sleep that is below the threshold and therefore not fixed by it.
.husky/pre-commit — the local mirror the workflow backs up; it has to widen with the guard or the two disagree.
tobiu referenced in commit 26a4701 - "fix(build): the fixed-sleep guard walks the component and e2e tiers, not just unit (#19028) (#19031) on Sep 21, 2026, 2:16 PM
Context
Found while reviewing #19024, where a component spec used
await page.waitForTimeout(400)with no stated reason. I went to cite the house rule and discovered the rule does not reach that tier.Live latest-open sweep: checked the latest 20 open issues at 2026-09-21T10:22Z; no equivalent found.
The Problem
buildScripts/util/check-fixed-sleeps.mjsexists to stop a fixed second-scale wait that does not say what it is waiting for. Its workflow states the invariant it is built on:That sentence is true and it is about the unit tier only.
SCAN_ROOT = 'test/playwright/unit'at line 103, and.github/workflows/fixed-sleep-lint.ymlfilters ontest/playwright/unit/**/*.mjs. Two other tiers hold specs and neither is scanned.What the guard would actually find if it looked. Measured at
devby calling the guard's exportedfindUnjustifiedSleepsagainst each tier's.mjsfiles:The three e2e hits, read at source rather than counted:
test/playwright/e2e/grid/RowPinning.spec.mjs:157—await new Promise(r => setTimeout(r, 12000))under the comment "Let it run while playwright scrolls". A genuine twelve-second unexplained fixed sleep. This is the real find.test/playwright/e2e/dashboard/TearOutMatrixRows4To7NL.spec.mjs:248— a 2000 mssetTimeoutinside aPromise.race, i.e. a timeout guard rather than a wait.test/playwright/e2e/utils/browser-test-helpers.mjs:77— a MutationObserver timeout guard, cleared on success.The latter two are exactly what the existing
out-waits:justification marker is for; they want a marker, not a rewrite.And the honest consequence for the finding that produced this ticket: widening the scan would not have caught it. #19024's
waitForTimeout(400)is belowTHRESHOLD_MS. No change proposed here would have flagged it. That half is the threshold question and it is split out below.The Architectural Reality
buildScripts/util/check-fixed-sleeps.mjs:103—SCAN_ROOT, a single string.:209—SCAN_SURFACE, frozen and derived fromSCAN_ROOT; the module's comment names it "the workflow-parity SSOT", and a sibling scanned-subset-of-watched spec reads it to demand the workflow watch everything the guard reads.:243—findUnjustifiedSleepsdefaults its spec list topath.join(rootDir, SCAN_ROOT).:104—THRESHOLD_MS = 1000, the line that decides whether a wait is a site at all.buildScripts/util/check-fixed-sleeps-baseline.json— three entries, allDragCoordinator.spec.mjstimers under test..github/workflows/fixed-sleep-lint.yml:9-23— thepaths:filters, which must stay a superset of whatever the scan becomes.SCAN_ROOTbeing singular is the whole constraint: widening it is a plural change throughSCAN_SURFACE,findUnjustifiedSleepsand the workflow filter together, and the parity spec is what keeps the "scanned ⊆ watched" invariant honest across the change.The Fix
SCAN_ROOTbecomesSCAN_ROOTS, a list;SCAN_SURFACEand the default spec collection derive from it.paths:filters to match, in both thepull_requestandpushblocks, so scanned stays a subset of watched.RowPinning.spec.mjs:157states what it waits for or stops being a sleep; the two timeout guards take theout-waits:marker the guard already accepts.The baseline file should not grow. Three sites are few enough to disposition, and a baseline entry is a debt marker — adding three when the alternative is fixing three would be using the mechanism to avoid the work it exists to expose.
Contract Ledger Matrix
SCAN_SURFACEis an exported, consumed surface —ScanSurfaceAlignment.spec.mjsimports it as the authority for what the mirror workflow must watch — so its shape change belongs in a ledger rather than only in a diff. @neo-gpt asked for this on PR #19031 and he is right that it was missing.SCAN_ROOTS(check-fixed-sleeps.mjs)SCAN_ROOTstring with a frozen list; it is the single authority the workflow filter mirrorspaths:entry failsScanSurfaceAlignment.spec.mjs, not silently40 passedacross guard/alignment/parity specsSCAN_SURFACE(exported)check-guard-ci-parity.mjsString[]of globs; now derived fromSCAN_ROOTSrather than one interpolated rootevery mirror selects every tracked file of its guard's surfacepassesfindUnjustifiedSleeps({files})defaultfiles, collects across every root instead of onefilesbehaves exactly as before — the widening is opt-out by passing paths@paramonoptions.filesfilesimplicitly via the CLIfixed-sleep-lint.ymlpaths:SCAN_SURFACEpull_requestandpushblocksscanned ⊆ watchedarmNo consumer signature changes, so nothing downstream needs a migration step.
Acceptance Criteria
paths:filter covers everything it scans; the existing scanned-⊆-watched parity spec passes unchanged.Promise.racelosing arm and a cleared observer deadline are not waits at all, so marking themout-waits:enters time nobody spends into a backlog that counts wall clock still being paid.check-fixed-sleeps-baseline.jsongains no entries.Out of Scope — and the successor that owns the rest
The sub-second threshold question is deliberately not in this ticket, and it is the half that explains #19024.
THRESHOLD_MS = 1000means an unexplained 400 ms wait is not a site. The population behind that question, measured as timed calls rather than guard hits:385 sub-threshold calls is a different decision with a different blast radius, and folding it in here would turn a five-file change into a repo-wide one. A successor should ask whether the guard's name — second-scale — is still the right boundary, and it is the question that would have caught #19024.
Also out of scope: rewriting justified sleeps, and any tier that does not exist today.
Related
.husky/pre-commit— the local mirror the workflow backs up; it has to widen with the guard or the two disagree.Origin Session ID: 2c9d83d3-7879-46f6-b49d-590b631d4f55
Retrieval Hint:
query_raw_memories("fixed sleep guard scan root unit tier component e2e coverage threshold").