Readiness contracts: stop a render when the page says it is complete, and report it when it is not - #187
Conversation
…sking the page; v1.30.0
Every settle signal today is a timer, and a timer cannot be wrong out loud. A
render that missed a widget, or that serialized pre-hydration markup, reports
200, non-empty and indexable, and nothing downstream can tell. Measured under 6x
CPU throttling: 8 of 15 renders finish incomplete and all 15 report outcome=ok,
one storing 426KB instead of 958KB with 11 of 27 islands unhydrated and zero
product links — while the hand-written review gate was satisfied.
A contract states per page type what a COMPLETE render contains. The renderer
holds until that is true AND the DOM has gone quiet, then stops, and posts the
per-clause result back so an incomplete render is a fact rather than a silence.
Six assertion forms, each because something else could not express it: presence;
anyOf (an empty-but-legitimate listing page is structurally identical to one
whose grid has not arrived); absent (skeletons); shed (frameworks drop a marker
on hydrate — the check that catches pre-hydration markup); every+contains ("every
rail is filled" survives a template change where "at least 3 rails" does not);
and nonEmptyText. Any clause may carry onlyIf, keying it on what the page's own
JSON-LD declares or on what the DOM holds — the difference between guessing
whether reviews should exist and asking the document.
Three things the measurements forced:
- The quiet window is not optional and is where the speed comes from. The same
policy with an EMPTY contract saves the same time to within 30ms on 7 of 8
pages. Stopping the instant a contract holds saves more and loses the rails
(99% of product links on one page, 100% on an empty facet): rails have no
server-rendered placeholder, so no clause can assert one is coming.
- No verdict before the parser finishes, and a shed clause requires its elements
to exist. Both were violated: a contract declared a page complete at 4ms and
stored zero review nodes, because absence-shaped clauses are true on an empty
document and it is quiet for the same reason.
- Contracts rot when templates change, so a clause that has never been true —
once every other clause holds and the DOM is quiet — stands aside after
unmetGraceMs and is reported unsatisfied. Cost then degrades to roughly today's
behaviour instead of waiting out the timeout on every render forever; without
that valve one unsatisfiable clause measured +385% wall.
Also lands per-URL learned expectations: observations are compared against what
this URL last produced, and three consecutive shortfalls re-baseline rather than
alarming forever, so a rail removed site-wide converges instead of failing that
URL permanently.
Verified on five live page types (two product, listing, empty facet, home) with a
paired A/B/A parity gate against a measured per-page churn floor: -31% to -54%
wall, -14% to -35% CPU, content identical on every one.
An unsatisfied contract always falls through to the normal settle, so a badly
written contract can cost time but never content. No-op when unconfigured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces 'Readiness contracts' to the browser rendering library, providing a mechanism to define page-type-specific assertions that determine when a render is complete. This replaces blind timer-based settling with a more reliable approach that combines content assertions and DOM quiescence monitoring. The changes include a new document-start monitor for tracking DOM stability and a history-based expectation system to detect content regressions. The review feedback correctly identifies the need for more robust configuration validation—specifically regarding timeout limits and regex patterns—and suggests implementing defensive error handling within the browser-side evaluation logic to prevent potential runtime crashes.
| export function validateReadiness(readiness: unknown): void { | ||
| if (readiness === undefined) return; | ||
| const cfg = readiness as ReadinessConfig; | ||
| if (typeof cfg !== 'object' || cfg === null) throw new Error('prerender config: readiness must be an object'); | ||
| if (cfg.onSatisfied !== undefined && cfg.onSatisfied !== 'quiet' && cfg.onSatisfied !== 'plateau') { | ||
| throw new Error("prerender config: readiness.onSatisfied must be 'quiet' or 'plateau'"); | ||
| } | ||
| if (!Array.isArray(cfg.contracts)) throw new Error('prerender config: readiness.contracts must be an array'); | ||
| for (const contract of cfg.contracts) { | ||
| if (!contract.name) throw new Error('prerender config: every readiness contract needs a name'); | ||
| if (!Array.isArray(contract.require) || contract.require.length === 0) { | ||
| throw new Error(`prerender config: readiness contract "${contract.name}" needs at least one require assertion`); | ||
| } | ||
| if (contract.pathPattern) { | ||
| try { | ||
| new RegExp(contract.pathPattern); | ||
| } catch { | ||
| throw new Error(`prerender config: readiness contract "${contract.name}" has an invalid pathPattern`); | ||
| } | ||
| } | ||
| for (const assertion of [...contract.require, ...(contract.observe ?? [])]) { | ||
| const a = assertion as Record<string, unknown>; | ||
| const forms = [ | ||
| a.selector && !a.shed && !a.nonEmptyText, | ||
| a.anyOf, | ||
| a.absent, | ||
| a.shed, | ||
| a.every, | ||
| a.nonEmptyText, | ||
| ].filter(Boolean).length; | ||
| if (!assertion.name || forms !== 1) { | ||
| throw new Error( | ||
| `prerender config: readiness assertion in "${contract.name}" must have a name and exactly one of ` + | ||
| 'selector / anyOf / absent / (selector + shed) / (every + contains) / (selector + nonEmptyText)' | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The configuration fields like unmetGraceMs, quietMs, expectations.graceMs, contract.timeoutMs, contract.pollMs, contract.quietMs, and contract.stableMs are not validated in validateReadiness. In Node.js, passing a value greater than 2147483647 to setTimeout causes it to execute immediately (after 1ms), which can trigger unexpected hot loops or immediate timeouts. Additionally, if pollMs is zero or negative, it can cause an extremely fast, CPU-intensive loop in renderer.ts (line 716). We should validate these fields to be non-negative (or positive for pollMs) and not exceed 2147483647. Also, if textMatches is provided, we should validate it by trying to compile it with new RegExp(textMatches) to catch invalid regex patterns at config load time.
export function validateReadiness(readiness: unknown): void {
if (readiness === undefined) return;
const cfg = readiness as ReadinessConfig;
if (typeof cfg !== 'object' || cfg === null) throw new Error('prerender config: readiness must be an object');
if (cfg.onSatisfied !== undefined && cfg.onSatisfied !== 'quiet' && cfg.onSatisfied !== 'plateau') {
throw new Error("prerender config: readiness.onSatisfied must be 'quiet' or 'plateau'");
}
const MAX_TIMER_MS = 2147483647;
if (cfg.unmetGraceMs !== undefined && (typeof cfg.unmetGraceMs !== 'number' || cfg.unmetGraceMs < 0 || cfg.unmetGraceMs > MAX_TIMER_MS)) {
throw new Error(`prerender config: readiness.unmetGraceMs must be a non-negative number up to ${MAX_TIMER_MS}`);
}
if (cfg.quietMs !== undefined && (typeof cfg.quietMs !== 'number' || cfg.quietMs < 0 || cfg.quietMs > MAX_TIMER_MS)) {
throw new Error(`prerender config: readiness.quietMs must be a non-negative number up to ${MAX_TIMER_MS}`);
}
if (cfg.expectations !== undefined) {
if (typeof cfg.expectations !== 'object' || cfg.expectations === null) {
throw new Error('prerender config: readiness.expectations must be an object');
}
const exp = cfg.expectations;
if (exp.graceMs !== undefined && (typeof exp.graceMs !== 'number' || exp.graceMs < 0 || exp.graceMs > MAX_TIMER_MS)) {
throw new Error(`prerender config: readiness.expectations.graceMs must be a non-negative number up to ${MAX_TIMER_MS}`);
}
if (exp.tolerance !== undefined && (typeof exp.tolerance !== 'number' || exp.tolerance < 0 || exp.tolerance > 1)) {
throw new Error('prerender config: readiness.expectations.tolerance must be a number between 0 and 1');
}
if (exp.rebaselineAfter !== undefined && (!Number.isInteger(exp.rebaselineAfter) || exp.rebaselineAfter < 1)) {
throw new Error('prerender config: readiness.expectations.rebaselineAfter must be a positive integer');
}
}
if (!Array.isArray(cfg.contracts)) throw new Error('prerender config: readiness.contracts must be an array');
for (const contract of cfg.contracts) {
if (!contract.name) throw new Error('prerender config: every readiness contract needs a name');
if (!Array.isArray(contract.require) || contract.require.length === 0) {
throw new Error(`prerender config: readiness contract "${contract.name}" needs at least one require assertion`);
}
if (contract.stableMs !== undefined && (typeof contract.stableMs !== 'number' || contract.stableMs < 0 || contract.stableMs > MAX_TIMER_MS)) {
throw new Error(`prerender config: contract "${contract.name}" stableMs must be a non-negative number up to ${MAX_TIMER_MS}`);
}
if (contract.timeoutMs !== undefined && (typeof contract.timeoutMs !== 'number' || contract.timeoutMs < 0 || contract.timeoutMs > MAX_TIMER_MS)) {
throw new Error(`prerender config: contract "${contract.name}" timeoutMs must be a non-negative number up to ${MAX_TIMER_MS}`);
}
if (contract.pollMs !== undefined && (typeof contract.pollMs !== 'number' || contract.pollMs <= 0 || contract.pollMs > MAX_TIMER_MS)) {
throw new Error(`prerender config: contract "${contract.name}" pollMs must be a positive number up to ${MAX_TIMER_MS}`);
}
if (contract.quietMs !== undefined && (typeof contract.quietMs !== 'number' || contract.quietMs < 0 || contract.quietMs > MAX_TIMER_MS)) {
throw new Error(`prerender config: contract "${contract.name}" quietMs must be a non-negative number up to ${MAX_TIMER_MS}`);
}
if (contract.pathPattern) {
try {
new RegExp(contract.pathPattern);
} catch {
throw new Error(`prerender config: readiness contract "${contract.name}" has an invalid pathPattern`);
}
}
for (const assertion of [...contract.require, ...(contract.observe ?? [])]) {
const a = assertion as Record<string, unknown>;
const forms = [
a.selector && !a.shed && !a.nonEmptyText,
a.anyOf,
a.absent,
a.shed,
a.every,
a.nonEmptyText,
].filter(Boolean).length;
if (!assertion.name || forms !== 1) {
throw new Error(
`prerender config: readiness assertion in "${contract.name}" must have a name and exactly one of ` +
'selector / anyOf / absent / (selector + shed) / (every + contains) / (selector + nonEmptyText)'
);
}
if (a.textMatches !== undefined) {
if (typeof a.textMatches !== 'string') {
throw new Error(`prerender config: readiness assertion "${assertion.name}" has an invalid textMatches (must be a string)`);
}
try {
new RegExp(a.textMatches);
} catch {
throw new Error(`prerender config: readiness assertion "${assertion.name}" has an invalid textMatches regex pattern`);
}
}
}
}
}References
- In Node.js, configuration options representing timeouts or delays passed to
setTimeoutmust not exceed2147483647(the maximum 32-bit signed integer), as exceeding this limit causes Node.js to execute the callback immediately (after 1ms). Rather than silently clamping the value at the timer, validate and refuse invalid timeout values at configuration load time to prevent silent failures.
There was a problem hiding this comment.
Fixed in f2da097 — both parts of this were real, and one was worse than the comment suggests.
pollMs must be positive, not merely non-negative. I had allowed 0, and both the gate loop and the report-mode observer sleep on it, so zero is a tight loop calling into the page as fast as the event loop allows — a burned core per render. Now rejected.
The 2147483647 ceiling. You are right that this is the load-bearing one, and it is a consistency bug as much as a correctness one: scroll.topSettleMs is already guarded against exactly this in the same config module (a settle dwell past the timer ceiling is rejected — it would become NO dwell), and I did not follow the precedent I was sitting next to.
I widened the coverage past your list rather than field-by-field, because the same argument applies to every one of them: readiness.quietMs, readiness.unmetGraceMs, expectations.graceMs, expectations.tolerance (now bounded 0..1) and expectations.rebaselineAfter (positive integer) all reach a timer or a comparison where a wrong value does nothing visible. They now go through one shared checkMs helper so the next ms field added cannot be forgotten.
One correction to the suggested patch: it validates contract.stableMs, which no longer exists. It was documented on the contract type and never read by the gate — dead config that someone would set and believe — so it was removed rather than validated.
Tests added in readiness.test.ts for the ceiling, the zero poll, and each top-level/expectation field.
| if (a.nonEmptyText === true && typeof a.selector === 'string') { | ||
| let ok = false; | ||
| let seen = 0; | ||
| const pattern = typeof a.textMatches === 'string' ? new RegExp(a.textMatches as string) : null; | ||
| for (const root of roots) { | ||
| try { | ||
| for (const el of root.querySelectorAll(a.selector as string)) { | ||
| seen++; | ||
| const text = (el.textContent ?? '').trim(); | ||
| if (text.length > 0 && (!pattern || pattern.test(text))) ok = true; | ||
| } | ||
| } catch { | ||
| return { name: assertion.name, ok: false, count: 0 }; | ||
| } | ||
| } | ||
| return { name: assertion.name, ok, count: seen }; | ||
| } |
There was a problem hiding this comment.
If a.textMatches is an invalid regular expression string, it is not validated at config load time. Furthermore, in evaluateContract (which runs in the browser context), new RegExp(a.textMatches) is called outside of any try...catch block. An invalid regex will throw a SyntaxError synchronously, crashing the entire page evaluation loop. We should validate textMatches in validateReadiness and wrap the new RegExp instantiation in a try...catch block inside evaluateContract to fail gracefully.
if (a.nonEmptyText === true && typeof a.selector === 'string') {
let ok = false;
let seen = 0;
let pattern: RegExp | null = null;
try {
pattern = typeof a.textMatches === 'string' ? new RegExp(a.textMatches as string) : null;
} catch {
return { name: assertion.name, ok: false, count: 0 };
}
for (const root of roots) {
try {
for (const el of root.querySelectorAll(a.selector as string)) {
seen++;
const text = (el.textContent ?? '').trim();
if (text.length > 0 && (!pattern || pattern.test(text))) ok = true;
}
} catch {
return { name: assertion.name, ok: false, count: 0 };
}
}
return { name: assertion.name, ok, count: seen };
}References
- In browser-evaluated contexts (such as Puppeteer's
page.evaluate), implement defensive checks (e.g.,Array.isArrayguards, try/catch blocks) even if they are redundant with upstream validation. A runtime exception insidepage.evaluatecan fail the entire render job, so it is critical to handle errors gracefully and skip malformed rules rather than throwing.
There was a problem hiding this comment.
Fixed in b3d06c6, and your reasoning about why it matters in a page.evaluate context is the part I would highlight for anyone reading this later.
The RegExp was built outside the loop’s try, so a malformed textMatches threw out of the whole evaluator — discarding every other clause’s result for that tick, not just its own. Since the evaluator is the thing deciding whether the page is complete, that failure mode is worse than a crash: the contract would report every clause unmet and the render would look incomplete for a reason that has nothing to do with the page.
Both halves are now in place, as you suggested: compiled inside a try so a bad pattern fails only its own clause, and validated at config load (f2da097) so it cannot reach a render at all. The in-evaluator guard is kept deliberately as defence in depth — it is unreachable through the public API now, and the comment says so, because the alternative shape is the one that just bit.
… overrides array `resolveConfigForJob` cached resolved configs keyed by (device + matching override names) and cleared that cache only when the IDENTITY of `config.overrides` changed. Two configs that share an overrides array — which is what you get by spreading one config and changing a field — therefore resolved to whichever one was seen first, for every URL matching an override, for the rest of the process. A URL matching NO override returns before the cache, so it was unaffected. That is what makes the bug hard: it presents as "this setting works on the home page and nowhere else", and it cost two wasted measurement runs before it was found. Production has been safe only because a config reload reparses and so happens to produce a new array; nothing enforces that. A WeakMap keyed on the base config cannot fail that way — a different config is a different key by construction, and an old config's entries are collected with it. Also posts the readiness verdict back with the render result (`VariantMetadata. readiness`): the contract name, satisfied, the names of unmet and skipped clauses, and the observation counts the consumer stores as this URL's expectation. Without it on the wire the consumer cannot make an unsatisfiable clause visible, which is the trap contracts are supposed to avoid rather than reproduce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…at each URL produces; v0.79.0 The browser now posts what its readiness contract said. Without something on this side reading it, contracts would reproduce the exact trap they exist to avoid: a gate that fails on every render and costs a timeout, with nothing anywhere saying so. Two consumers. 1. `render_readiness`, a new metric. `verdict` is the share of renders that finished COMPLETE — the only signal that separates a render missing a widget from a good one, since both are 200, non-empty and indexable (measured under CPU contention: 8 of 15 renders finished unsatisfied and all 15 reported outcome=ok). `unmet` names the CLAUSE, so a contract that has rotted against a template change reads as one clause failing across a page type rather than as an unexplained slowdown. `satisfied_ms` is what `timeoutMs` should be tuned from: a p95 approaching the timeout means the contract is being abandoned under load and the optimisation is quietly gone. 2. `RenderExpectation`, per-URL, node-local, written on the result path beside the probe claim. A contract bounds what it NAMES, and recommendation rails cannot usefully be named — they have no server-rendered placeholder, so "every rail that exists is filled" is true of a page that ended up with one rail instead of three. Measured: a render that satisfied its contract stored 610 product links where that URL normally stores 674. The page's own history is the only oracle that covers it. It converges rather than alarming forever, which is the whole difficulty: a rail removed site-wide must not fail that URL on every future render. A shortfall is a vote, not a verdict — three in a row and the expectation is re-learned. A SUSPECTED shortfall deliberately does not re-learn, because learning from a render we believe is short would ratchet the expectation down to whatever the page just failed to produce, which is how a real regression would erase its own evidence. The comparison runs where the data already is, on the result. Handing the expectation to the renderer at claim time would cost a cross-database point read per job or a denormalization onto RenderSchedule — residency-pinned and rewritten on every render, the two costs its own schema comments exist to avoid. The browser still supports being given expectations for the in-render path; nothing needs it for detection. Both are best-effort on the render path, like recordPageClaim: a regression signal must never cost a render. Version note: main is at 0.78.0 and open PR #178 bumps to 0.75.0, which would downgrade it. This takes 0.79.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… from the fleet A contract's `timeoutMs` cannot honestly be chosen from a developer machine. Measured on live pages with the timeout lifted to 30s so each render reports what it needed: the product contract holds at 856ms unthrottled, 1,749ms at 2x CPU throttle, and NOT AT ALL within 30s at 4x. The fleet's mean render is ~11s against ~4s on that machine, so production sits inside that band — and a gate armed with the first number would clip its own tail, presenting as "renders are incomplete" rather than as "the timeout is wrong". `onSatisfied: 'report'` evaluates the contract alongside the ordinary settle and reports the verdict while gating nothing: same clauses, same per-clause timings, same wire format, and a render that behaves exactly as it does with no contract configured. The observer stops as soon as the settle it watches finishes, which is what guarantees it cannot extend a render, and takes one final look afterwards so the verdict describes the DOM that is about to be serialized. That yields the `satisfied_ms` distribution the gate should be tuned from, at no risk, which is the same shape of rollout the change probe used. It also documents a real limit found while measuring: the rot valve interprets "every clause either holds or has never been true, and the DOM is quiet" as a rotted contract, and a CPU-starved page looks exactly like that — it goes quiet between bursts of work. At 4x throttle a product render reported unsatisfied after ~8.7s while its final content was complete. It fails safe (the fallback settle runs, the content is fine) but it is a false alarm in the one metric that exists to say renders are incomplete. Rot is a property of a page type ACROSS renders, which is what the per-URL `unsatisfiable` list is for; report mode sidesteps it entirely, and arming should follow the fleet's own numbers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
None are in the happy path; all five are the shapes that go unnoticed. 1. A malformed `textMatches` built its RegExp outside the evaluator's try, so one bad pattern threw out of the WHOLE evaluation and discarded every other clause's result for that tick. It now fails only its own clause — and is rejected at config load, which is where it should have been stopped. 2. Readiness numerics were unvalidated. A NaN or negative `timeoutMs` makes the gate's deadline NaN, the loop never runs, and the contract is silently disabled: a config that looks enabled and protects nothing, which is the worst outcome available. `waitFor` already validated its numerics; this now matches. 3. `stableMs` was documented on the contract type and never read. Dead config is worse than absent config — someone sets it and believes it. Removed. 4. Report mode claimed it could never change a render's duration, and that was nearly true rather than true: stopping the observer waited out whatever remained of its poll interval. The sleep is now interruptible, so the stop is prompt. The one final evaluation stays — the verdict has to describe the DOM being serialized — and it is a single ~1-2ms call. 5. The `verdict` metric double-counted. RenderQueue emits one verdict per variant and the expectation store emitted an extra `rebaselined` on top, so the series stopped summing to one per render — which is exactly how its own documentation frames it. Rebaselines now ride their own series. Review provenance, stated because it matters: every outside-model leg was unavailable on this machine (agy not installed, cursor-agent missing, codex auth failure), so this was an advisory same-family pass and NOT independent coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eiling, and refuse a zero poll The repo's CI reviewer caught two things the local pass did not, both real. A zero or negative `pollMs` was accepted. The gate and the observer both sleep on it, so zero is a tight loop calling into the page as fast as the event loop allows — a burned core per render. It must be POSITIVE, not merely non-negative. None of the ms fields were checked against 2147483647. Past that, setTimeout fires after 1ms instead of waiting, so an over-large dwell silently becomes NO dwell — exactly the trap `scroll.topSettleMs` is already guarded against in this same config module, which makes the omission a consistency bug as much as a correctness one. Coverage was also too narrow: only the contract-level numbers were validated. `readiness.quietMs`, `readiness.unmetGraceMs`, `expectations.graceMs`, `expectations.tolerance` (0..1) and `expectations.rebaselineAfter` (a positive integer) all reach a timer or a comparison where a wrong value does nothing visible. All of them now fail at config load, through one shared checker so the next field added cannot be forgotten. The reviewer also flagged `textMatches` — validated at load AND caught per-clause in the evaluator as of the previous commit — and suggested validating `contract.stableMs`, which no longer exists: it was documented and never read, and was removed rather than validated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds readiness contracts: a per-page-type statement of what a complete render contains, so the renderer stops when the page says it is done instead of when a timer expires — and so an incomplete render becomes a reported fact rather than a silence.
Spans both packages, because the browser produces the signal and the plugin is what makes it mean anything:
@harperfast/prerender-browserv1.30.0,@harperfast/prerenderv0.79.0. No-op unless configured; an unsatisfied contract always falls through to the existing settle, so a badly written contract can cost a render time but never content.Why
Every settle signal we have is a timer, and a timer cannot be wrong out loud. A render that missed a widget, or that serialized pre-hydration markup, reports 200, non-empty and indexable, and nothing downstream can tell.
Measured under 6× CPU throttling: 8 of 15 renders finish incomplete and all 15 report
outcome=ok— one storing 426KB instead of 958KB with 11 of 27 islands unhydrated and zero product links, while the existingwaitForreview gate was satisfied. "A gate covering one widget is not a gate", measured.Results
Live, five page types, paired A/B/A against a per-page churn floor measured from repeated baseline renders — counting content markers is not enough, since product-link and image counts move ±5% between two renders of the same code because the rails are personalised:
What is in it
Browser — six assertion forms, each because something else could not express it: presence,
anyOf,absent,shed(frameworks drop a marker on hydrate),every+contains(relative, so no magic numbers),nonEmptyText. Any clause can carryonlyIf, keying it on what the page's own JSON-LD declares or what the DOM holds. A document-startMutationObserveranswers "how long has this page been still?" in O(1), judged against the same tolerance the existing plateau uses.Plugin —
render_readiness, a new metric (verdict/unmet/shortfall/satisfied_ms), andRenderExpectation, a per-URL node-local record of what this URL last produced, written on the result path beside the probe claim.The three properties the measurements forced
shedclause requires its elements to exist. Both were violated in development: a contract declared a page complete at 4ms and stored zero review nodes, because absence-shaped clauses are true on an empty document and it is quiet for the same reason.unmetGraceMs, is reported unsatisfied, and the render falls back to the ordinary settle. Cost degrades to roughly today's behaviour rather than waiting out the timeout on every render forever; without that valve one unsatisfiable clause measured +385% wall. A slow page is a different failure and needs its own control — it is still mutating, so the valve does not fire — which is whytimeoutMsshould be capped low and tuned from thesatisfied_msdistribution.Learned expectations close the gap the contract cannot: a URL that carried three rails and 350 product links yesterday and none today has lost something no clause names. It converges rather than alarming forever — a shortfall is a vote, three in a row re-learns — and a suspected shortfall deliberately does not re-learn, because learning from a render we believe is short is how a real regression would erase its own evidence.
Also in here
resolveConfigForJobcached resolved configs keyed by the identity of the overrides array, so two configs sharing that array resolved to whichever was seen first, for every URL matching an override. It presents as "this setting works on the home page and nowhere else" and it cost two wasted measurement runs. Now keyed on the config itself, with a regression test.Review notes
Not in this PR
The per-page-type contract content is configuration and lives in the consumer — HarperFast/render-service#89, which is blocked on the v1.30.0 release.
🤖 Generated with Claude Code
Review provenance and what it changed
No independent cross-model coverage was available on this machine —
agyis not installed,cursor-agentis missing, and the Codex leg failed on auth. What ran was an advisory same-family pass, which does not count as outside coverage, plus this repo's own CI reviewer. Stating that rather than implying the change was independently reviewed.Seven defects were found and fixed before merge, none in the happy path:
pollMs: 0accepted — both the gate and the observer sleep on it, so zero is a tight loop calling into the page as fast as the event loop allows2147483647, past whichsetTimeoutfires after 1ms — an over-large dwell silently becomes no dwell.scroll.topSettleMsis already guarded against this in the same module, so this was a consistency bug tootextMatchesbuilt itsRegExpoutside the evaluator'stry, so one bad pattern threw out of the whole evaluation and discarded every other clause's result for that ticktimeoutMsmakes the deadline NaN, the loop never runs, and the contract is silently disabledstableMsdocumented on the contract and never read — dead config someone would set and believe. Removed rather than validatedverdictmetric double-counted on rebaseline, so the series stopped summing to one per render — which is how its own documentation frames it. Rebaselines now ride their own seriesValidation coverage was widened past what was reported, because the same argument applies to every ms field: they all reach a timer or a comparison where a wrong value does nothing visible. They now go through one shared checker.