Skip to content

feat(pacing): honor the pause a site asks for, end to end - #437

Open
jonybur wants to merge 5 commits into
mainfrom
feat/adaptive-origin-pacing
Open

feat(pacing): honor the pause a site asks for, end to end#437
jonybur wants to merge 5 commits into
mainfrom
feat/adaptive-origin-pacing

Conversation

@jonybur

@jonybur jonybur commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Closes #234.

What broke

Nothing shipped learned or enforced a site's stated rate limit. PR #218 was closed for being an unwired policy core, and its closing review named the defect to not repeat: Retry-After was stored as an interval measured from the prior action, and negative jitter could move an action earlier still than the instant the server named.

What changed

Per-origin pacing, as one enforcement slice.

  • Absolute deadlines. notBeforeMs is anchored to the response that stated it, survives a service-worker restart, and a later smaller answer cannot shorten it. The only additive term is a fixed positive-only clock-skew guard, so nothing can release an action inside a stated window. No tempo de-regularization: the posture is compliance, not disguise.
  • Two enforcement points. The dispatcher meters browser actions on the origin the action is about to touch (destination for a navigation, live tab origin for an in-page action) and waits inside a per-origin FIFO lane, which is what stops concurrent sessions passing the limiter together. web-fetch.js meters network requests on a much shorter budget, because every caller runs it inside its own fetch timeout.
  • The ceiling stops instead of napping. A fixed refusal ends the turn. The deadline is durable and keyed by origin, not session, so a retry or a fresh actor meets the same refusal.
  • The model cannot move a rule. The dispatcher gets two read-shaped closures; the policy core exports no setter. Descent is time decay or a person on Settings -> Paced sites. Unreadable state refuses browser writes rather than reading as "no limits".
  • The panel explains a wait instead of looking hung: the site, a local countdown off the deadline, and the way out in the same row.

Honest limits, stated

Only requests peerd makes through its own network path expose a status and a Retry-After. A page navigation does not, on any package. Requests a page makes for itself, including anything an action causes, are the page's own and are never metered.

Not in this slice

The one-action descent probe from #234. It is the only mechanism that acts faster than the learned rule; decay plus the human forget control already cover descent. It should land once the enforcement half has field evidence.

Verified

6482 bun · 976 in-browser · 42 e2e states / 316 checks · 15/15 red-team scenarios (11 new probes behind a new INV-21) · lint, typecheck, boundary, imports, pages, copy, invariants, preflight.

The new e2e state drives a real seeded rule through the real dispatcher and proves Stop cancels the pending action rather than letting it fire late.

badges/inbrowser-gecko.json is not refreshed here: the pinned Firefox is not installed locally. Its count will come from the Gecko job.

Issue #234, landed as one enforcement slice rather than an unwired policy
core. PR #218 was closed for being the latter, and its closing review named
the defect this must not repeat: Retry-After was stored as an interval
measured from the PRIOR action, and negative jitter could then move an
action earlier still than the instant the server named.

Both halves are fixed and pinned by tests. The recorded deadline is
ABSOLUTE and anchored to the response that stated it, so it survives a
service-worker restart and a later smaller answer cannot shorten it. The
only additive term anywhere is a fixed positive-only clock-skew guard;
nothing can release an action inside a stated window. There is no tempo
de-regularization: the posture is compliance, not disguise.

Two brakes per origin, both minted only by the egress choke point holding a
real Response:
- notBeforeMs, an absolute deadline, gates every request, read or write.
- minIntervalMs, a learned floor, gates browser write actions.

Enforcement has two points. The dispatcher meters browser actions on the
origin the action is about to touch - the destination for a navigation, the
live tab origin for an in-page action - and waits inside the per-origin
FIFO lane, which is what stops concurrent sessions passing the limiter
together. web-fetch.js meters network requests, and holds them for a much
shorter budget because every caller runs it inside its own fetch timeout.

Past the inline ceiling peerd stops instead of napping: a fixed refusal that
ends the turn. Because the deadline is durable and keyed by origin rather
than session, a retry or a fresh actor meets the same refusal.

Nothing reachable from a tool can move a rule. The dispatcher gets two
read-shaped closures; the policy core exports no setter. Descent is time
decay or a person on Settings -> Paced sites. Unreadable state refuses
browser writes rather than reading as "no limits".

The side panel explains a wait instead of looking hung, naming the site and
counting down locally off the deadline, with the way out in the same row.

Verified: 6482 bun, 976 in-browser, 42 e2e states / 316 checks (including a
new one that drives a real seeded rule through the real dispatcher and
proves Stop cancels the pending action), 15/15 red-team scenarios with 11
new probes behind a new INV-21, lint, typecheck, boundary, imports, pages,
copy, invariants.

Deliberately not in this slice: the one-action descent probe from #234. It
is the only mechanism that acts FASTER than the learned rule, and decay plus
the human forget control already cover descent. It should land once the
enforcement half has field evidence.

Signed-off-by: Jonathan Bursztyn <jobur93@gmail.com>
@jonybur
jonybur requested a review from NotASithLord as a code owner August 25, 2026 01:11
github-actions Bot added a commit that referenced this pull request Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

Visual regression

⚠️ The UI renders differently than the merge base - 19 of 70 screens moved.

options-paced-sites - options-paced-sites · up to 6.174% changed

dark - 6.174% of pixels (merge base ⟶ this branch)

options-paced-sites, dark: merge base then this branch

light - 6.148% of pixels (merge base ⟶ this branch)

options-paced-sites, light: merge base then this branch

Vault gate - initial-screen · up to 3.959% changed

light - 3.959% of pixels (merge base ⟶ this branch)

Vault gate, light: merge base then this branch

options-local-models - options-local-models · up to 0.388% changed

dark - 0.335% of pixels (merge base ⟶ this branch)

options-local-models, dark: merge base then this branch

light - 0.388% of pixels (merge base ⟶ this branch)

options-local-models, light: merge base then this branch

options-transfer - options-transfer · up to 0.177% changed

dark - 0.177% of pixels (merge base ⟶ this branch)

options-transfer, dark: merge base then this branch

light - 0.177% of pixels (merge base ⟶ this branch)

options-transfer, light: merge base then this branch

Settings (full tab) - options-fulltab · up to 0.177% changed

dark - 0.177% of pixels (merge base ⟶ this branch)

Settings (full tab), dark: merge base then this branch

light - 0.177% of pixels (merge base ⟶ this branch)

Settings (full tab), light: merge base then this branch

options-behavior - options-behavior · up to 0.177% changed

dark - 0.177% of pixels (merge base ⟶ this branch)

options-behavior, dark: merge base then this branch

light - 0.177% of pixels (merge base ⟶ this branch)

options-behavior, light: merge base then this branch

options-denylist - options-denylist · up to 0.177% changed

dark - 0.177% of pixels (merge base ⟶ this branch)

options-denylist, dark: merge base then this branch

light - 0.177% of pixels (merge base ⟶ this branch)

options-denylist, light: merge base then this branch

options-learned-sites - options-learned-sites · up to 0.177% changed

dark - 0.177% of pixels (merge base ⟶ this branch)

options-learned-sites, dark: merge base then this branch

light - 0.177% of pixels (merge base ⟶ this branch)

options-learned-sites, light: merge base then this branch

options-contributor-metrics - options-contributor-metrics · up to 0.177% changed

dark - 0.177% of pixels (merge base ⟶ this branch)

options-contributor-metrics, dark: merge base then this branch

light - 0.177% of pixels (merge base ⟶ this branch)

options-contributor-metrics, light: merge base then this branch

options-transfer-conflict - options-transfer-conflict · up to 0.169% changed

dark - 0.169% of pixels (merge base ⟶ this branch)

options-transfer-conflict, dark: merge base then this branch

light - 0.169% of pixels (merge base ⟶ this branch)

options-transfer-conflict, light: merge base then this branch

Every screen at full size, both sides, plus the diff masks: the visual-screens artifact on this run.

Intended? Then there is nothing to do here. peerd commits no reference screenshots, so a deliberate redesign has no baseline to reseed - this lane reports, it does not gate. Both sides above were rendered from source in this run.

09032f7bf7c6 (merge base) ⟶ 498702c8307e. The montages live on the visual-diffs branch and are deleted when this PR closes.

@jonybur

jonybur commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

The visual lane reports 18 of 70 screens moved. All 18 are accounted for, and all are intended:

  • options-paced-sites (6.17%) - the new Settings page. There is nothing at that route on the merge base, so the "before" is whatever the router falls back to.
  • The other 8 states (~0.177% each, both themes) - options-local-models, options-transfer, options-fulltab, options-behavior, options-denylist, options-learned-sites, options-contributor-metrics, options-transfer-conflict. Every one is an options page, and every one moves by the same amount: the new "Paced sites" nav entry adds a row to the Security group, shifting the list below it.

No side-panel screen moved. The wait bar renders only during an actual paced wait, which the new pacing-wait-stop functional state drives and screenshots rather than the visual lane.

An observation arriving while the durable record is unreadable would have
persisted a partial state built from whatever this session happened to see -
overwriting the evidence while browser writes still failed closed, since
loadFailed does not clear itself. The record now survives untouched, and the
human "forget all" remains the one sanctioned way back to a readable one.

Also commits the Gecko badge count the CI job measured (970/970); the pinned
Firefox is not installed locally.

Signed-off-by: Jonathan Bursztyn <jobur93@gmail.com>
Signed-off-by: Jonathan Bursztyn <jobur93@gmail.com>
That fix lives in #439, not here. Claiming it in this PR's entry would
credit this change with something it does not contain.

Signed-off-by: Jonathan Bursztyn <jobur93@gmail.com>
github-actions Bot added a commit that referenced this pull request Aug 25, 2026
`strikes` was written on every block, validated on every read, and cleared
by a whole transition of its own - and no decision ever consulted it.
Escalation already compounds from the rule's own current interval, and decay
is what walks that back down.

Removing it also removes the write it caused on every successful request to
a paced origin, since recording "we were fine once" was that path's only
effect. Persisted state that nothing reads is state that drifts.

Signed-off-by: Jonathan Bursztyn <jobur93@gmail.com>
github-actions Bot added a commit that referenced this pull request Aug 25, 2026
github-actions Bot added a commit that referenced this pull request Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adaptive per-origin action pacing (ANTI-BOT-POSTURE Option 0 resolution)

1 participant