From 062356ad35dcc7075e00a578e0700191a7b9c658 Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Thu, 16 Jul 2026 13:59:36 +0100 Subject: [PATCH 1/2] feat(web-pace): the pure policy core of adaptive per-origin pacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the reducers specified in docs/store/ADAPTIVE-PACING.md (#213) — the "learned, not hardcoded; code, not prompt" direction. Pure core only: no storage, no hooks, no tool. The clock and RNG are injected, so every transition is pinned exactly under Bun. observe -> learn -> enforce -> decay/probe: - nextRuleOnBlock: sizes the interval from the cadence that ACTUALLY got blocked (x slowdownMult), compounds on repeats, honors a larger Retry-After, clamps at maxPaceMs. A seed floor (a K the spec's list did not enumerate) makes a block with no cadence sample still learn a rule — otherwise max() over unknowns is 0 and a real block teaches nothing. - decay: lazy + MULTI-period (rules are recomputed on access, not on a timer — spec open question C), so five quiet periods decay five steps. Never mid-probe, never on a backwards clock. isRetired() tells the caller to drop the record: no permanent tax. - waitForAction: a catch-up, not a fixed tax. First action free; natural latency counts; +/- jitterFrac de-regularizes the tempo; the trial interval is what a probe enforces. - startProbe/resolveProbe: the reversible descent. Clean adopts the lower value (possibly retiring the rule); blocked snaps back, re-escalates from the trial cadence, and resets the quiet timer. - needsHandOff: at the ceiling the caller escalates to the posture ladder rather than silently napping a turn. DESCENT IS CONSTRAINED (a deliberate change from the spec's tool sketch). The spec's pace_rule offers set/clear clamped to [0, maxPaceMs] and re-checked SW-side. But an SW re-check validates a call's ARGS, never its INTENT — it cannot tell "the actor judged this rule stale" from "a page talked the actor into clearing it", and the web actor is precisely the heap that ingests untrusted page bytes. The asymmetry matters: raising is self-limiting (peerd is slow on one origin), while lowering is what an injected page wants — retire the rule, peerd hammers, and the user's own logged-in account eats the ban. So there is no clearRule() and applyAgentSet() is RAISE-ONLY; the only ways down are automatic decay and a probe that reverts on a block. That costs nothing: probe already IS the owner's stated use case ("test whether it's still needed"). Tests pin the arithmetic and the safety property, including that the export surface offers no clear/reset hatch. Signed-off-by: Jonathan Bursztyn --- extension/peerd-runtime/web-pace/reducer.js | 293 +++++++++++++++++++ packaging/check-tscheck.ts | 4 +- tests/peerd-runtime/web-pace-reducer.test.ts | 217 ++++++++++++++ 3 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 extension/peerd-runtime/web-pace/reducer.js create mode 100644 tests/peerd-runtime/web-pace-reducer.test.ts diff --git a/extension/peerd-runtime/web-pace/reducer.js b/extension/peerd-runtime/web-pace/reducer.js new file mode 100644 index 00000000..387ae8a1 --- /dev/null +++ b/extension/peerd-runtime/web-pace/reducer.js @@ -0,0 +1,293 @@ +// @ts-check +// web-pace/reducer.js — the PURE policy core of adaptive per-origin action +// pacing. Design spec: docs/store/ADAPTIVE-PACING.md (PR #213), which resolves +// ANTI-BOT-POSTURE.md Option 0 as "code, not prompt; learned, not a hardcoded +// list; targeted, not blanket". +// +// THE SHAPE. peerd paces NOTHING by default (minIntervalMs 0 everywhere). When +// a site pushes back in its own response bytes (a 429 + Retry-After, a velocity +// wall, a challenge interstitial), the observing hook feeds that signal through +// nextRuleOnBlock and persists the result: a NUMBER for that origin, sized from +// the cadence that actually got blocked. Quiet time decays it back toward zero; +// a probe tests whether the site still cares. The whole loop is +// observe -> learn -> enforce -> decay/probe. +// +// why a pure core: every transition here is a function of (rule, signal, now). +// No storage, no clock, no Math.random — the caller injects them. That makes the +// policy deterministic under Bun (see web-pace-reducer.test.ts) and keeps the +// enforcement/observation hooks as thin imperative shells, per the module's +// functional-core rule. +// +// THE FENCE (why this file holds no strings). The rule is data the RUNTIME +// interprets, never prose the model reads. It is computed and stored SW-side, +// behind the actor-heap fence: the offscreen worker never holds it and the +// model never receives it as prompt. An untrusted reasoning heap therefore +// cannot argue peerd out of pacing — there is no sentence to argue with. +// +// THE DESCENT RULE (the load-bearing safety property). Pacing may be raised by +// anyone, but it may only ever come DOWN two ways: automatic time-based decay, +// or a bounded probe that snaps back on a block. There is deliberately NO +// clearRule() and applyAgentSet() is RAISE-ONLY. why: raising is self-limiting +// (worst case peerd is slow on one origin), but LOWERING is the direction an +// injected page benefits from — talk the web actor into retiring the rule, peerd +// hammers the site, and it is the user's own logged-in account that eats the +// ban. An SW-side re-check of a "clear" call can validate its args but never its +// INTENT — it cannot tell "the actor judged this stale" from "a page talked the +// actor into it". So the descent is not a decision the actor is allowed to make; +// probe() is the safe expression of the same wish, because a block during the +// trial window restores the old value and re-escalates. + +import { clamp } from '/shared/util.js'; + +/** + * One learned rule for one origin. The FIELD SET is the contract (the numbers + * are seeds, below); `minIntervalMs: 0` means "no rule" — the default state of + * every origin peerd has never been blocked by. + * + * @typedef {Object} PaceRule + * @property {string} origin the key this rule binds to + * @property {number} minIntervalMs enforced gap between action tools; 0 == no rule + * @property {number} jitterFrac ± fraction applied to the gap (a UNIFORM gap is itself a tell) + * @property {number} observations how many blocks have fed this rule + * @property {number} lastBlockAt epoch ms of the last observed block; drives decay eligibility + * @property {number} lastDecayAt epoch ms of the last decay/adopt + * @property {number} createdAt epoch ms + * @property {number} updatedAt epoch ms + * @property {'learned'|'retry-after'|'agent'} source provenance of the current value + * @property {null | { trialMs: number, until: number, prevMinIntervalMs: number }} probe + * a live staleness test: pace at trialMs until `until`, restoring + * prevMinIntervalMs if the site blocks us during the window. + */ + +/** + * The tunables bundle (`K`). Seeds, not doctrine — start conservative and tune + * from the eval harness + field reports (spec open question F). Injected into + * every reducer so a test can pin exact arithmetic. + * + * @typedef {Object} PaceTunables + * @property {number} growth multiplier on a REPEAT block (compounding escalation) + * @property {number} slowdownMult "we were going at X and got blocked -> go slowdownMult * X" + * @property {number} seedMs floor for a FIRST block when no cadence sample exists + * @property {number} decay multiplier applied per elapsed quiet period (< 1) + * @property {number} quietMs block-free time that earns one decay step + * @property {number} maxPaceMs ceiling; at/over it the limiter HANDS OFF rather than napping + * @property {number} jitterFrac default ± fraction for a new rule + * @property {number} retireFloorMs decayed below this, the rule is retired (deleted) + */ + +/** @type {PaceTunables} */ +export const PACE_TUNABLES = Object.freeze({ + growth: 2, + slowdownMult: 2, + // why a seed: a block is evidence even when we have no cadence sample to size + // from (an SW restart lost the interval ring, or the block landed on the first + // action). Without a floor, `max()` of unknowns is 0 and we would "learn" + // no rule from a real block. + seedMs: 1_000, + decay: 0.5, + quietMs: 30 * 60_000, + // why a ceiling at all: pacing is for shaving seconds. A site that wants us + // minutes slower is not a pacing problem — needsHandOff() sends it up the + // posture ladder (challenge hand-back / assist-only) instead of silently + // sleeping a turn. + maxPaceMs: 30_000, + jitterFrac: 0.3, + retireFloorMs: 250, +}); + +/** + * The default state of an origin: known, but costing nothing. Callers mint this + * lazily on the first signal for an origin — a rule that never leaves + * `minIntervalMs: 0` is indistinguishable from having no rule at all. + * + * @param {string} origin @param {number} now @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const newRule = (origin, now, K = PACE_TUNABLES) => ({ + origin, + minIntervalMs: 0, + jitterFrac: K.jitterFrac, + observations: 0, + lastBlockAt: 0, + lastDecayAt: now, + createdAt: now, + updatedAt: now, + source: 'learned', + probe: null, +}); + +/** + * A block was observed -> escalate. Sized from the speed that ACTUALLY got + * blocked (times a slowdown multiplier) rather than a blind constant, so a first + * block lands near the right cadence in one step instead of ramping through + * many. An explicit server ask (Retry-After) always wins if it is larger — that + * is the site telling us its own number, and honoring it is the whole compliance + * story. + * + * @param {PaceRule} rule + * @param {{ recentIntervalMs?: number, retryAfterMs?: number, now: number }} signal + * recentIntervalMs: our observed gap between recent actions on this origin. + * @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const nextRuleOnBlock = (rule, { recentIntervalMs, retryAfterMs, now }, K = PACE_TUNABLES) => { + const fromCadence = Number.isFinite(recentIntervalMs) + ? /** @type {number} */ (recentIntervalMs) * K.slowdownMult + : 0; + const fromServer = Number.isFinite(retryAfterMs) ? /** @type {number} */ (retryAfterMs) : 0; + const desired = Math.max( + rule.minIntervalMs * K.growth, // compounding: repeats mean we are still too fast + fromCadence, + fromServer, + K.seedMs, // a block always yields SOME rule + ); + return { + ...rule, + minIntervalMs: clamp(desired, 0, K.maxPaceMs), + observations: rule.observations + 1, + lastBlockAt: now, + updatedAt: now, + // A block during a probe is the probe's answer; resolveProbe() owns that + // transition, so a bare block here just drops the trial. + probe: null, + source: fromServer > 0 ? 'retry-after' : 'learned', + }; +}; + +/** + * Block-free time relaxes the rule toward zero, so a one-off block during a + * burst does not tax an origin forever. LAZY + multi-period by design (spec + * open question C): callers recompute on next access rather than running a + * timer, so a rule untouched for five quiet periods must decay five steps at + * once — not one. Never decays mid-probe (the probe owns the value). + * + * @param {PaceRule} rule @param {number} now @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const decay = (rule, now, K = PACE_TUNABLES) => { + if (rule.probe) return rule; + if (rule.minIntervalMs <= 0) return rule; + const since = now - Math.max(rule.lastBlockAt, rule.lastDecayAt); + if (!Number.isFinite(since) || since < 0) return rule; // clock skew — never relax on a bad clock + const periods = Math.floor(since / K.quietMs); + if (periods < 1) return rule; + return { + ...rule, + minIntervalMs: rule.minIntervalMs * (K.decay ** periods), + lastDecayAt: now, + updatedAt: now, + }; +}; + +/** + * Decayed into irrelevance — the caller deletes the record. Keeping a rule at + * a sub-floor interval would be a permanent row for a site that stopped caring. + * + * @param {PaceRule} rule @param {PaceTunables} [K] + */ +export const isRetired = (rule, K = PACE_TUNABLES) => + !rule.probe && rule.minIntervalMs < K.retireFloorMs; + +/** + * The site wants us slower than pacing is willing to go. Not a nap — the caller + * escalates to the posture ladder (challenge hand-back, or assist-only for this + * origin). Pacing shaves seconds; it does not stall a turn for minutes. + * + * @param {PaceRule} rule @param {PaceTunables} [K] + */ +export const needsHandOff = (rule, K = PACE_TUNABLES) => rule.minIntervalMs >= K.maxPaceMs; + +/** + * The per-action delay: a CATCH-UP, not a fixed tax. The first action on an + * origin (no prior timestamp) is always free, and an action that naturally + * followed a slow read waits little or nothing — we only ever make up the + * shortfall. Jitter de-regularizes the tempo (a metronome is its own tell). + * While probing, the trial interval is the one enforced. + * + * @param {PaceRule | null | undefined} rule + * @param {number | undefined} lastActionAt epoch ms of the previous paced action on this origin + * @param {number} now + * @param {() => number} [rng] [0,1) source; injected for tests + * @returns {number} ms to sleep (>= 0) + */ +export const waitForAction = (rule, lastActionAt, now, rng = Math.random) => { + if (!rule) return 0; + const base = rule.probe ? rule.probe.trialMs : rule.minIntervalMs; + if (!(base > 0)) return 0; // no rule (or a zero trial) — full speed + if (!Number.isFinite(lastActionAt)) return 0; // first action on this origin is free + const elapsed = now - /** @type {number} */ (lastActionAt); + if (!Number.isFinite(elapsed) || elapsed < 0) return 0; // clock skew — never stall + const jitter = base * rule.jitterFrac * (rng() * 2 - 1); // ± jitterFrac + return Math.max(0, (base + jitter) - elapsed); +}; + +/** + * Start a staleness test: drop to a trial interval for a bounded window and see + * whether the site still minds. This is the ONLY way a rule comes down other + * than automatic decay — and it is safe precisely because it is reversible: + * resolveProbe() restores prevMinIntervalMs and re-escalates on a block. + * + * The trial is clamped to at most the current interval — a "probe" that made us + * SLOWER would be a contradiction, and one that could set any value would be + * the very lever this design denies the agent. + * + * @param {PaceRule} rule @param {number} trialMs @param {number} windowMs @param {number} now + * @returns {PaceRule} + */ +export const startProbe = (rule, trialMs, windowMs, now) => { + if (rule.probe) return rule; // already probing — don't restart the window + if (rule.minIntervalMs <= 0) return rule; // nothing to probe + return { + ...rule, + probe: { + trialMs: clamp(trialMs, 0, rule.minIntervalMs), + until: now + windowMs, + prevMinIntervalMs: rule.minIntervalMs, + }, + updatedAt: now, + }; +}; + +/** The probe window has run out; the caller resolves it. @param {PaceRule} rule @param {number} now */ +export const probeExpired = (rule, now) => !!rule.probe && now >= rule.probe.until; + +/** + * End a probe. Blocked during the trial -> the site still cares: restore the + * previous interval, re-escalate from the trial cadence that just got blocked, + * and reset the quiet timer (so decay does not immediately undo the lesson). + * Clean -> adopt the lower trial value; if that is under the retire floor the + * rule is now retirable and the caller drops it entirely. + * + * @param {PaceRule} rule @param {boolean} blockedDuringProbe @param {number} now @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const resolveProbe = (rule, blockedDuringProbe, now, K = PACE_TUNABLES) => { + if (!rule.probe) return rule; + const { trialMs, prevMinIntervalMs } = rule.probe; + if (blockedDuringProbe) { + const restored = { ...rule, minIntervalMs: prevMinIntervalMs, probe: null }; + return nextRuleOnBlock(restored, { recentIntervalMs: trialMs, now }, K); + } + return { ...rule, minIntervalMs: trialMs, probe: null, lastDecayAt: now, updatedAt: now }; +}; + +/** + * The agent's only write into the rule: RAISE-ONLY, and clamped. + * + * why not a symmetric set(): see THE DESCENT RULE in the module header. A + * request to go SLOWER is always safe to honor — the agent has a real signal + * (a Retry-After it read, or the user saying "take it easy on this one") and + * the worst case is a slow origin. A request to go FASTER is refused outright, + * because that is the outcome an injected page wants and no downstream check + * can distinguish a genuine judgement from a laundered instruction. An agent + * that believes a rule is stale asks for a probe instead. + * + * @param {PaceRule} rule @param {number} requestedMs @param {number} now @param {PaceTunables} [K] + * @returns {PaceRule} the rule unchanged when the request would lower it + */ +export const applyAgentSet = (rule, requestedMs, now, K = PACE_TUNABLES) => { + if (!Number.isFinite(requestedMs)) return rule; + const requested = clamp(requestedMs, 0, K.maxPaceMs); + if (requested <= rule.minIntervalMs) return rule; // refuse to lower — probe is the way down + return { ...rule, minIntervalMs: requested, source: 'agent', updatedAt: now }; +}; diff --git a/packaging/check-tscheck.ts b/packaging/check-tscheck.ts index f8399e70..a4a4bac3 100644 --- a/packaging/check-tscheck.ts +++ b/packaging/check-tscheck.ts @@ -143,7 +143,9 @@ import { computeCoverage } from './tscheck-coverage.ts'; // 633 → 635: App asset classification and the full binary runner test. // 635 → 636: the recoverable publish transaction is shared by dweb hosts. // 636 → 639: dweb reseed, content ownership, and share rollback are checked. -const COVERED_FLOOR = 647; +// 647 → 648: the adaptive per-origin pacing reducer is checked from its first +// commit, keeping the pure policy core inside the extension typecheck ratchet. +const COVERED_FLOOR = 648; // The scan (walk + // @ts-check detection + the ES5-injected exemption set) // lives in tscheck-coverage.ts so the badge generator reports the same number. diff --git a/tests/peerd-runtime/web-pace-reducer.test.ts b/tests/peerd-runtime/web-pace-reducer.test.ts new file mode 100644 index 00000000..68bb0b8f --- /dev/null +++ b/tests/peerd-runtime/web-pace-reducer.test.ts @@ -0,0 +1,217 @@ +// The pure policy core of adaptive per-origin pacing (docs/store/ADAPTIVE-PACING.md, +// PR #213). Every transition is a function of (rule, signal, now) with the clock +// and RNG injected, so the arithmetic is pinned exactly rather than approximated. +// +// The load-bearing properties, in order of how much a regression would cost: +// 1. DESCENT IS CONSTRAINED — the agent can raise pacing but never lower it; +// the only ways down are automatic decay and a reversible probe. This is +// the anti-injection property: a page that talks the actor into "clearing" +// a rule would get peerd hammering a site on the user's real logged-in +// account. There is no clearRule() to test because there deliberately isn't one. +// 2. A block always yields a rule (a block with no cadence sample still learns). +// 3. Decay is lazy + multi-period (rules self-heal; no permanent tax). +// 4. First action is free; pacing is a catch-up, not a fixed tax. + +import { describe, test, expect } from 'bun:test'; +import { + PACE_TUNABLES, newRule, nextRuleOnBlock, decay, isRetired, needsHandOff, + waitForAction, startProbe, probeExpired, resolveProbe, applyAgentSet, +} from '../../extension/peerd-runtime/web-pace/reducer.js'; + +const K = PACE_TUNABLES; +const T0 = 1_000_000; +// A deterministic RNG: 0.5 → the jitter term is exactly 0 (rng()*2-1 === 0), so +// the catch-up arithmetic is assertable without a band. +const noJitter = () => 0.5; + +describe('web-pace — the default is OFF', () => { + test('a fresh rule costs nothing: no interval, no wait', () => { + const r = newRule('https://x.com', T0); + expect(r.minIntervalMs).toBe(0); + expect(r.observations).toBe(0); + expect(r.probe).toBe(null); + // even with a prior action a moment ago, a rule-less origin runs full speed + expect(waitForAction(r, T0 - 1, T0, noJitter)).toBe(0); + }); + + test('waitForAction on a null rule is 0 (an origin peerd never got blocked by)', () => { + expect(waitForAction(null, T0 - 1, T0, noJitter)).toBe(0); + }); +}); + +describe('web-pace — learning from a block', () => { + test('sizes the interval from the cadence that ACTUALLY got blocked', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + expect(r.minIntervalMs).toBe(4_000 * K.slowdownMult); // "we were doing X → go slower than X" + expect(r.observations).toBe(1); + expect(r.lastBlockAt).toBe(T0); + expect(r.source).toBe('learned'); + }); + + test('a block with NO cadence sample still learns a rule (the seed floor)', () => { + // An SW restart lost the interval ring, or the block landed on action #1. + // max() over unknowns would be 0 — i.e. "learn nothing from a real block". + const r = nextRuleOnBlock(newRule('https://x.com', T0), { now: T0 }, K); + expect(r.minIntervalMs).toBe(K.seedMs); + }); + + test("an explicit Retry-After wins when it is larger, and is recorded as the site's own ask", () => { + const r = nextRuleOnBlock( + newRule('https://x.com', T0), + { recentIntervalMs: 100, retryAfterMs: 9_000, now: T0 }, + K, + ); + expect(r.minIntervalMs).toBe(9_000); + expect(r.source).toBe('retry-after'); // honoring it is the whole compliance story + }); + + test('repeat blocks compound (still too fast) and stay bounded by the ceiling', () => { + let r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 2_000, now: T0 }, K); + const first = r.minIntervalMs; + r = nextRuleOnBlock(r, { now: T0 + 1 }, K); + expect(r.minIntervalMs).toBe(first * K.growth); + expect(r.observations).toBe(2); + + // hammer it well past the ceiling — pacing must never promise a multi-minute nap + for (let i = 0; i < 20; i++) r = nextRuleOnBlock(r, { now: T0 + 2 + i }, K); + expect(r.minIntervalMs).toBe(K.maxPaceMs); + expect(needsHandOff(r, K)).toBe(true); // → posture ladder, not a sleep + }); +}); + +describe('web-pace — decay self-heals a one-off block', () => { + test('quiet time relaxes the rule; not yet quiet is a no-op', () => { + const blocked = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + expect(decay(blocked, T0 + K.quietMs - 1, K).minIntervalMs).toBe(blocked.minIntervalMs); + expect(decay(blocked, T0 + K.quietMs + 1, K).minIntervalMs).toBe(blocked.minIntervalMs * K.decay); + }); + + test('decay is LAZY: five quiet periods later it decays five steps, not one', () => { + // Rules are recomputed on next access rather than on a timer, so a single + // decay() call must account for all the quiet time that actually elapsed. + const blocked = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const d = decay(blocked, T0 + K.quietMs * 5 + 1, K); + expect(d.minIntervalMs).toBeCloseTo(blocked.minIntervalMs * (K.decay ** 5), 6); + }); + + test('a site that stopped caring eventually retires its own rule', () => { + let r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + expect(isRetired(r, K)).toBe(false); + r = decay(r, T0 + K.quietMs * 40, K); // a long quiet stretch + expect(r.minIntervalMs).toBeLessThan(K.retireFloorMs); + expect(isRetired(r, K)).toBe(true); // → caller deletes the record; no permanent tax + }); + + test('never decays mid-probe, and never on a backwards clock', () => { + const blocked = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const probing = startProbe(blocked, 500, 60_000, T0); + expect(decay(probing, T0 + K.quietMs * 5, K)).toEqual(probing); // the probe owns the value + expect(decay(blocked, T0 - K.quietMs * 5, K)).toEqual(blocked); // clock skew → don't relax + }); +}); + +describe('web-pace — the per-action wait is a catch-up, not a tax', () => { + test('first action on an origin is free even under a rule', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + expect(waitForAction(r, undefined, T0, noJitter)).toBe(0); + }); + + test('only the shortfall is waited — natural latency already counts', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 1_000, now: T0 }, K); + expect(r.minIntervalMs).toBe(2_000); + // 1500ms already elapsed since the last action → wait the remaining 500 + expect(waitForAction(r, T0, T0 + 1_500, noJitter)).toBe(500); + // an action that followed a slow read waits nothing at all + expect(waitForAction(r, T0, T0 + 5_000, noJitter)).toBe(0); + }); + + test('jitter stays within ±jitterFrac of the interval (a metronome is its own tell)', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 1_000, now: T0 }, K); + const lo = waitForAction(r, T0, T0, () => 0); // jitter = -jitterFrac + const hi = waitForAction(r, T0, T0, () => 1); // jitter = +jitterFrac + expect(lo).toBeCloseTo(r.minIntervalMs * (1 - r.jitterFrac), 6); + expect(hi).toBeCloseTo(r.minIntervalMs * (1 + r.jitterFrac), 6); + expect(lo).toBeLessThan(hi); + }); + + test('clock skew never stalls an action', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + expect(waitForAction(r, T0 + 10_000, T0, noJitter)).toBe(0); // "last action" in the future + }); +}); + +describe('web-pace — probe is the reversible way down', () => { + test('a probe paces at the trial interval, and is clamped to at most the current one', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const p = startProbe(r, 500, 60_000, T0); + expect(p.probe).toEqual({ trialMs: 500, until: T0 + 60_000, prevMinIntervalMs: r.minIntervalMs }); + expect(waitForAction(p, T0, T0, noJitter)).toBe(500); // the TRIAL is enforced, not the rule + // a "probe" that asked to go slower is not a probe — clamped to the current value + expect(startProbe(r, 99_999, 60_000, T0).probe?.trialMs).toBe(r.minIntervalMs); + expect(startProbe(p, 10, 60_000, T0)).toEqual(p); // already probing → window not restarted + expect(probeExpired(p, T0 + 59_999)).toBe(false); + expect(probeExpired(p, T0 + 60_000)).toBe(true); + }); + + test('a CLEAN probe adopts the lower value (and can make the rule retirable)', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const done = resolveProbe(startProbe(r, 100, 60_000, T0), false, T0 + 60_000, K); + expect(done.minIntervalMs).toBe(100); + expect(done.probe).toBe(null); + expect(isRetired(done, K)).toBe(true); // 100 < retireFloorMs → the site stopped caring + }); + + test('a BLOCKED probe snaps back, re-escalates from the trial, and resets the quiet timer', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const p = startProbe(r, 500, 60_000, T0); + const after = resolveProbe(p, true, T0 + 60_000, K); + expect(after.probe).toBe(null); + // it must NOT keep the trial value it just got blocked at… + expect(after.minIntervalMs).toBeGreaterThan(500); + // …and must not merely restore the old one either — the trial taught us more + expect(after.minIntervalMs).toBe(r.minIntervalMs * K.growth); + expect(after.lastBlockAt).toBe(T0 + 60_000); // quiet timer reset → decay can't undo the lesson + expect(after.observations).toBe(r.observations + 1); + }); +}); + +// The reason this module has no clearRule(): lowering is the direction an +// injected page benefits from. Raising is self-limiting; retiring a rule makes +// peerd hammer a site on the user's REAL logged-in session. An SW-side re-check +// can validate a call's args but never its intent, so the descent simply isn't +// a decision the actor is allowed to make. +describe('web-pace — the agent may raise pacing, never lower it (anti-injection)', () => { + test('a raise is honored and attributed to the agent', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 1_000, now: T0 }, K); + expect(r.minIntervalMs).toBe(2_000); + const raised = applyAgentSet(r, 5_000, T0 + 1, K); + expect(raised.minIntervalMs).toBe(5_000); + expect(raised.source).toBe('agent'); + }); + + test('every lowering request is a NO-OP — including the zero a "clear" would use', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + for (const attempt of [0, 1, 500, r.minIntervalMs - 1, r.minIntervalMs]) { + expect(applyAgentSet(r, attempt, T0 + 1, K).minIntervalMs).toBe(r.minIntervalMs); + } + // …and junk can't sneak past the guard either + expect(applyAgentSet(r, NaN, T0 + 1, K)).toEqual(r); + expect(applyAgentSet(r, -5_000, T0 + 1, K).minIntervalMs).toBe(r.minIntervalMs); + }); + + test('a raise is still clamped to the ceiling (no agent-driven multi-minute nap)', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 1_000, now: T0 }, K); + expect(applyAgentSet(r, 10 * 60_000, T0 + 1, K).minIntervalMs).toBe(K.maxPaceMs); + }); + + test('the export surface offers no clear/reset escape hatch — descent is decay or probe only', async () => { + // Pinned exactly: adding a clearRule()/resetRule() later fails here, which + // is the point. The only downward transitions are decay() (time, automatic) + // and resolveProbe() (bounded, reverts on a block). + const mod = await import('../../extension/peerd-runtime/web-pace/reducer.js'); + expect(Object.keys(mod).sort()).toEqual([ + 'PACE_TUNABLES', 'applyAgentSet', 'decay', 'isRetired', 'needsHandOff', + 'newRule', 'nextRuleOnBlock', 'probeExpired', 'resolveProbe', 'startProbe', 'waitForAction', + ].sort()); + }); +}); From dfd30f374bf8c7417fe8ecbe486cf8bfc69cca71 Mon Sep 17 00:00:00 2001 From: NotASithLord <48842926+NotASithLord@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:46:33 +0200 Subject: [PATCH 2/2] fix(web-pace): harden reducer boundaries --- extension/peerd-runtime/web-pace/reducer.js | 322 ++++++++++++++----- tests/peerd-runtime/web-pace-reducer.test.ts | 234 ++++++++++---- 2 files changed, 419 insertions(+), 137 deletions(-) diff --git a/extension/peerd-runtime/web-pace/reducer.js b/extension/peerd-runtime/web-pace/reducer.js index 387ae8a1..c6389141 100644 --- a/extension/peerd-runtime/web-pace/reducer.js +++ b/extension/peerd-runtime/web-pace/reducer.js @@ -1,16 +1,18 @@ // @ts-check // web-pace/reducer.js — the PURE policy core of adaptive per-origin action -// pacing. Design spec: docs/store/ADAPTIVE-PACING.md (PR #213), which resolves -// ANTI-BOT-POSTURE.md Option 0 as "code, not prompt; learned, not a hardcoded -// list; targeted, not blanket". +// pacing. Issue #234 is the decision record. // -// THE SHAPE. peerd paces NOTHING by default (minIntervalMs 0 everywhere). When -// a site pushes back in its own response bytes (a 429 + Retry-After, a velocity -// wall, a challenge interstitial), the observing hook feeds that signal through -// nextRuleOnBlock and persists the result: a NUMBER for that origin, sized from -// the cadence that actually got blocked. Quiet time decays it back toward zero; -// a probe tests whether the site still cares. The whole loop is -// observe -> learn -> enforce -> decay/probe. +// This module is not wired into storage, hooks, dispatch, or UI. It changes no +// extension behavior on its own. A future integration must keep rules in the +// trusted service-worker control plane, feed only fixed trusted classifiers +// into these transitions, and keep rule history out of model context. Arbitrary +// page text is never a pacing instruction. +// +// THE SHAPE. The default interval is zero. A trusted observer may pass a block +// signal through nextRuleOnBlock, producing an interval for that exact origin. +// Quiet time decays it toward zero. A probe may test one action at a lower +// interval, then adopt that interval only after a clean outcome and settle +// period. The intended loop is observe -> learn -> enforce -> decay/probe. // // why a pure core: every transition here is a function of (rule, signal, now). // No storage, no clock, no Math.random — the caller injects them. That makes the @@ -18,24 +20,18 @@ // enforcement/observation hooks as thin imperative shells, per the module's // functional-core rule. // -// THE FENCE (why this file holds no strings). The rule is data the RUNTIME -// interprets, never prose the model reads. It is computed and stored SW-side, -// behind the actor-heap fence: the offscreen worker never holds it and the -// model never receives it as prompt. An untrusted reasoning heap therefore -// cannot argue peerd out of pacing — there is no sentence to argue with. +// THE FENCE (why this file holds no page strings). The integration must treat a +// rule as control-plane data, not prose for the model. This reducer does not +// establish that boundary by itself. // -// THE DESCENT RULE (the load-bearing safety property). Pacing may be raised by -// anyone, but it may only ever come DOWN two ways: automatic time-based decay, -// or a bounded probe that snaps back on a block. There is deliberately NO -// clearRule() and applyAgentSet() is RAISE-ONLY. why: raising is self-limiting -// (worst case peerd is slow on one origin), but LOWERING is the direction an -// injected page benefits from — talk the web actor into retiring the rule, peerd -// hammers the site, and it is the user's own logged-in account that eats the -// ban. An SW-side re-check of a "clear" call can validate its args but never its -// INTENT — it cannot tell "the actor judged this stale" from "a page talked the -// actor into it". So the descent is not a decision the actor is allowed to make; -// probe() is the safe expression of the same wish, because a block during the -// trial window restores the old value and re-escalates. +// THE DESCENT RULE (the load-bearing safety property). A rule rises only from +// trusted block signals, and it may come down only through automatic time-based +// decay or a bounded probe that snaps back on a block. There is deliberately no +// clearRule() or model-controlled set operation. LOWERING is the direction an +// injected page benefits from: retire the rule, peerd hammers the site, and the +// user's logged-in account absorbs the risk. An argument check cannot validate +// intent. Descent is therefore limited to time-based decay and the one-action +// probe state machine below. import { clamp } from '/shared/util.js'; @@ -45,6 +41,7 @@ import { clamp } from '/shared/util.js'; * every origin peerd has never been blocked by. * * @typedef {Object} PaceRule + * @property {1} version persisted shape version * @property {string} origin the key this rule binds to * @property {number} minIntervalMs enforced gap between action tools; 0 == no rule * @property {number} jitterFrac ± fraction applied to the gap (a UNIFORM gap is itself a tell) @@ -53,16 +50,20 @@ import { clamp } from '/shared/util.js'; * @property {number} lastDecayAt epoch ms of the last decay/adopt * @property {number} createdAt epoch ms * @property {number} updatedAt epoch ms - * @property {'learned'|'retry-after'|'agent'} source provenance of the current value - * @property {null | { trialMs: number, until: number, prevMinIntervalMs: number }} probe - * a live staleness test: pace at trialMs until `until`, restoring - * prevMinIntervalMs if the site blocks us during the window. + * @property {'learned'|'retry-after'} lastEscalationSource source of the last increase + * @property {null | { + * trialMs: number, + * startedAt: number, + * until: number, + * prevMinIntervalMs: number, + * actionStartedAt: number | null, + * cleanObservedAt: number | null, + * }} probe a one-action staleness test */ /** - * The tunables bundle (`K`). Seeds, not doctrine — start conservative and tune - * from the eval harness + field reports (spec open question F). Injected into - * every reducer so a test can pin exact arithmetic. + * The tunables bundle (`K`). These are starting values to tune through the eval + * harness and field reports. Injection lets tests pin exact arithmetic. * * @typedef {Object} PaceTunables * @property {number} growth multiplier on a REPEAT block (compounding escalation) @@ -70,9 +71,12 @@ import { clamp } from '/shared/util.js'; * @property {number} seedMs floor for a FIRST block when no cadence sample exists * @property {number} decay multiplier applied per elapsed quiet period (< 1) * @property {number} quietMs block-free time that earns one decay step - * @property {number} maxPaceMs ceiling; at/over it the limiter HANDS OFF rather than napping + * @property {number} maxPaceMs ceiling; at/over it the future caller must hand off rather than wait * @property {number} jitterFrac default ± fraction for a new rule * @property {number} retireFloorMs decayed below this, the rule is retired (deleted) + * @property {number} minProbeWindowMs shortest useful observation window + * @property {number} maxProbeWindowMs longest a probe may suppress normal decay + * @property {number} probeSettleMs quiet time after a clean observed result */ /** @type {PaceTunables} */ @@ -87,14 +91,84 @@ export const PACE_TUNABLES = Object.freeze({ decay: 0.5, quietMs: 30 * 60_000, // why a ceiling at all: pacing is for shaving seconds. A site that wants us - // minutes slower is not a pacing problem — needsHandOff() sends it up the - // posture ladder (challenge hand-back / assist-only) instead of silently - // sleeping a turn. + // minutes slower is not a pacing problem. needsHandOff() tells the future + // controller to use a challenge hand-back or assist-only posture instead of + // silently sleeping a turn. maxPaceMs: 30_000, jitterFrac: 0.3, retireFloorMs: 250, + minProbeWindowMs: 5_000, + maxProbeWindowMs: 5 * 60_000, + probeSettleMs: 2_000, }); +export const PACE_RULE_VERSION = 1; + +/** @param {unknown} value @returns {value is number} */ +const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value); + +/** + * Validate data at the persistence boundary before any action uses it. An + * unreadable rule must make a future write path fail closed, not turn pacing + * off. The optional origin check prevents a valid record being replayed under + * another key. + * + * @param {unknown} value + * @param {string | undefined} [expectedOrigin] + * @param {PaceTunables} [K] + * @returns {value is PaceRule} + */ +export const isValidRule = (value, expectedOrigin, K = PACE_TUNABLES) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const rule = /** @type {Partial} */ (value); + if (rule.version !== PACE_RULE_VERSION) return false; + if (typeof rule.origin !== 'string' || rule.origin.length === 0) return false; + if (expectedOrigin !== undefined && rule.origin !== expectedOrigin) return false; + if (!isFiniteNumber(rule.minIntervalMs) + || rule.minIntervalMs < 0 + || rule.minIntervalMs > K.maxPaceMs) return false; + if (!isFiniteNumber(rule.jitterFrac) + || rule.jitterFrac < 0 + || rule.jitterFrac > K.jitterFrac) return false; + const observations = rule.observations; + if (!isFiniteNumber(observations) || !Number.isInteger(observations) || observations < 0) return false; + const { lastBlockAt, lastDecayAt, createdAt, updatedAt } = rule; + if (!isFiniteNumber(lastBlockAt) || lastBlockAt < 0 + || !isFiniteNumber(lastDecayAt) || lastDecayAt < 0 + || !isFiniteNumber(createdAt) || createdAt < 0 + || !isFiniteNumber(updatedAt) || updatedAt < 0) return false; + if (updatedAt < createdAt || lastDecayAt < createdAt || lastDecayAt > updatedAt) return false; + if (observations === 0) { + if (lastBlockAt !== 0 || rule.minIntervalMs !== 0) return false; + } else if (lastBlockAt < createdAt || lastBlockAt > updatedAt) return false; + if (rule.lastEscalationSource !== 'learned' && rule.lastEscalationSource !== 'retry-after') return false; + if (observations === 0 && rule.lastEscalationSource !== 'learned') return false; + if (rule.probe === null) return true; + if (!rule.probe || typeof rule.probe !== 'object') return false; + const probe = rule.probe; + if (!isFiniteNumber(probe.trialMs) + || probe.trialMs < 0 + || probe.trialMs > rule.minIntervalMs) return false; + if (!isFiniteNumber(probe.prevMinIntervalMs) + || probe.prevMinIntervalMs !== rule.minIntervalMs) return false; + const { startedAt, until, actionStartedAt, cleanObservedAt } = probe; + if (!isFiniteNumber(startedAt) || !isFiniteNumber(until)) return false; + const windowMs = until - startedAt; + if (windowMs < K.minProbeWindowMs || windowMs > K.maxProbeWindowMs) return false; + if (startedAt < createdAt || startedAt > updatedAt) return false; + if (actionStartedAt !== null + && (!isFiniteNumber(actionStartedAt) + || actionStartedAt < startedAt + || actionStartedAt >= until + || actionStartedAt > updatedAt)) return false; + if (cleanObservedAt !== null + && (actionStartedAt === null + || !isFiniteNumber(cleanObservedAt) + || cleanObservedAt < actionStartedAt + || cleanObservedAt !== updatedAt)) return false; + return true; +}; + /** * The default state of an origin: known, but costing nothing. Callers mint this * lazily on the first signal for an origin — a rule that never leaves @@ -104,6 +178,7 @@ export const PACE_TUNABLES = Object.freeze({ * @returns {PaceRule} */ export const newRule = (origin, now, K = PACE_TUNABLES) => ({ + version: PACE_RULE_VERSION, origin, minIntervalMs: 0, jitterFrac: K.jitterFrac, @@ -112,7 +187,7 @@ export const newRule = (origin, now, K = PACE_TUNABLES) => ({ lastDecayAt: now, createdAt: now, updatedAt: now, - source: 'learned', + lastEscalationSource: 'learned', probe: null, }); @@ -131,12 +206,17 @@ export const newRule = (origin, now, K = PACE_TUNABLES) => ({ * @returns {PaceRule} */ export const nextRuleOnBlock = (rule, { recentIntervalMs, retryAfterMs, now }, K = PACE_TUNABLES) => { - const fromCadence = Number.isFinite(recentIntervalMs) + if (!isValidRule(rule, undefined, K) || !Number.isFinite(now)) return rule; + const effectiveNow = Math.max(now, rule.createdAt, rule.updatedAt, rule.lastBlockAt, rule.lastDecayAt); + const fromCadence = Number.isFinite(recentIntervalMs) && /** @type {number} */ (recentIntervalMs) >= 0 ? /** @type {number} */ (recentIntervalMs) * K.slowdownMult : 0; - const fromServer = Number.isFinite(retryAfterMs) ? /** @type {number} */ (retryAfterMs) : 0; + const fromServer = Number.isFinite(retryAfterMs) && /** @type {number} */ (retryAfterMs) >= 0 + ? /** @type {number} */ (retryAfterMs) + : 0; + const fromExisting = rule.minIntervalMs * K.growth; const desired = Math.max( - rule.minIntervalMs * K.growth, // compounding: repeats mean we are still too fast + fromExisting, // compounding: repeats mean we are still too fast fromCadence, fromServer, K.seedMs, // a block always yields SOME rule @@ -145,12 +225,16 @@ export const nextRuleOnBlock = (rule, { recentIntervalMs, retryAfterMs, now }, K ...rule, minIntervalMs: clamp(desired, 0, K.maxPaceMs), observations: rule.observations + 1, - lastBlockAt: now, - updatedAt: now, + lastBlockAt: effectiveNow, + updatedAt: effectiveNow, // A block during a probe is the probe's answer; resolveProbe() owns that // transition, so a bare block here just drops the trial. probe: null, - source: fromServer > 0 ? 'retry-after' : 'learned', + // Attribute the VALUE that won, not merely the presence of a header. A + // smaller Retry-After did not set this rule and must not get credit for it. + lastEscalationSource: fromServer > 0 && fromServer >= Math.max(fromExisting, fromCadence, K.seedMs) + ? 'retry-after' + : 'learned', }; }; @@ -165,6 +249,7 @@ export const nextRuleOnBlock = (rule, { recentIntervalMs, retryAfterMs, now }, K * @returns {PaceRule} */ export const decay = (rule, now, K = PACE_TUNABLES) => { + if (!isValidRule(rule, undefined, K)) return rule; if (rule.probe) return rule; if (rule.minIntervalMs <= 0) return rule; const since = now - Math.max(rule.lastBlockAt, rule.lastDecayAt); @@ -186,7 +271,10 @@ export const decay = (rule, now, K = PACE_TUNABLES) => { * @param {PaceRule} rule @param {PaceTunables} [K] */ export const isRetired = (rule, K = PACE_TUNABLES) => - !rule.probe && rule.minIntervalMs < K.retireFloorMs; + isValidRule(rule, undefined, K) + && !rule.probe + && rule.minIntervalMs < K.retireFloorMs + && rule.observations > 0; /** * The site wants us slower than pacing is willing to go. Not a nap — the caller @@ -195,7 +283,8 @@ export const isRetired = (rule, K = PACE_TUNABLES) => * * @param {PaceRule} rule @param {PaceTunables} [K] */ -export const needsHandOff = (rule, K = PACE_TUNABLES) => rule.minIntervalMs >= K.maxPaceMs; +export const needsHandOff = (rule, K = PACE_TUNABLES) => + !isValidRule(rule, undefined, K) || rule.minIntervalMs >= K.maxPaceMs; /** * The per-action delay: a CATCH-UP, not a fixed tax. The first action on an @@ -208,17 +297,28 @@ export const needsHandOff = (rule, K = PACE_TUNABLES) => rule.minIntervalMs >= K * @param {number | undefined} lastActionAt epoch ms of the previous paced action on this origin * @param {number} now * @param {() => number} [rng] [0,1) source; injected for tests - * @returns {number} ms to sleep (>= 0) + * @param {PaceTunables} [K] + * @returns {number | null} ms to sleep, or null when the rule cannot authorize an action */ -export const waitForAction = (rule, lastActionAt, now, rng = Math.random) => { +export const waitForAction = (rule, lastActionAt, now, rng = Math.random, K = PACE_TUNABLES) => { if (!rule) return 0; + if (!isValidRule(rule, undefined, K)) return null; + if (needsHandOff(rule, K)) return null; + if (rule.probe && !probeAllowsAction(rule, now, K)) return null; + if (lastActionAt === undefined) return rule.probe ? null : 0; + if (!Number.isFinite(lastActionAt) || lastActionAt < 0) return null; const base = rule.probe ? rule.probe.trialMs : rule.minIntervalMs; - if (!(base > 0)) return 0; // no rule (or a zero trial) — full speed - if (!Number.isFinite(lastActionAt)) return 0; // first action on this origin is free + if (!(base > 0)) return 0; const elapsed = now - /** @type {number} */ (lastActionAt); - if (!Number.isFinite(elapsed) || elapsed < 0) return 0; // clock skew — never stall - const jitter = base * rule.jitterFrac * (rng() * 2 - 1); // ± jitterFrac - return Math.max(0, (base + jitter) - elapsed); + // A backwards or broken wall clock is not evidence that the interval elapsed. + // Require the full base rather than turning clock skew into a pacing bypass. + if (!Number.isFinite(elapsed) || elapsed < 0) return Math.min(base, K.maxPaceMs); + const rawRandom = rng(); + const random = Number.isFinite(rawRandom) ? clamp(rawRandom, 0, 1) : 0.5; + const jitterFrac = rule.jitterFrac; + const jitter = base * jitterFrac * (random * 2 - 1); // ± jitterFrac + // The ceiling governs the ACTUAL wait, not only the pre-jitter interval. + return clamp((base + jitter) - elapsed, 0, K.maxPaceMs); }; /** @@ -234,22 +334,87 @@ export const waitForAction = (rule, lastActionAt, now, rng = Math.random) => { * @param {PaceRule} rule @param {number} trialMs @param {number} windowMs @param {number} now * @returns {PaceRule} */ -export const startProbe = (rule, trialMs, windowMs, now) => { +export const startProbe = (rule, trialMs, windowMs, now, K = PACE_TUNABLES) => { + if (!isValidRule(rule, undefined, K)) return rule; if (rule.probe) return rule; // already probing — don't restart the window if (rule.minIntervalMs <= 0) return rule; // nothing to probe + if (!Number.isFinite(trialMs) || trialMs < 0) return rule; + if (!Number.isFinite(windowMs) || windowMs <= 0) return rule; + if (!Number.isFinite(now) || now < rule.updatedAt) return rule; + const boundedWindow = clamp(windowMs, K.minProbeWindowMs, K.maxProbeWindowMs); return { ...rule, probe: { trialMs: clamp(trialMs, 0, rule.minIntervalMs), - until: now + windowMs, + startedAt: now, + until: now + boundedWindow, prevMinIntervalMs: rule.minIntervalMs, + actionStartedAt: null, + cleanObservedAt: null, }, updatedAt: now, }; }; /** The probe window has run out; the caller resolves it. @param {PaceRule} rule @param {number} now */ -export const probeExpired = (rule, now) => !!rule.probe && now >= rule.probe.until; +export const probeExpired = (rule, now, K = PACE_TUNABLES) => + isValidRule(rule, undefined, K) && Number.isFinite(now) && !!rule.probe && now >= rule.probe.until; + +/** + * A probe authorizes exactly one action. The future controller must reserve it + * immediately before dispatch so a second call cannot run at the trial pace. + * + * @param {PaceRule} rule @param {number} now + * @param {PaceTunables} [K] + * @returns {boolean} + */ +export const probeAllowsAction = (rule, now, K = PACE_TUNABLES) => + isValidRule(rule, undefined, K) + && !!rule.probe + && Number.isFinite(now) + && now >= rule.probe.startedAt + && now < rule.probe.until + && rule.probe.actionStartedAt === null; + +/** + * @param {PaceRule} rule @param {number | undefined} lastActionAt + * @param {number} now @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const beginProbeAction = (rule, lastActionAt, now, K = PACE_TUNABLES) => { + if (!Number.isFinite(lastActionAt) + || /** @type {number} */ (lastActionAt) < 0 + || !probeAllowsAction(rule, now, K) + || !rule.probe) return rule; + return { + ...rule, + probe: { ...rule.probe, actionStartedAt: now }, + updatedAt: now, + }; +}; + +/** + * Record a completed non-blocking outcome from the trusted post-action + * classifier. The action may finish after the probe window, but it must be the + * single action reserved inside that window. + * + * @param {PaceRule} rule @param {number} actionStartedAt @param {number} observedAt + * @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const noteCleanProbeAction = (rule, actionStartedAt, observedAt, K = PACE_TUNABLES) => { + if (!isValidRule(rule, undefined, K) || !rule.probe) return rule; + if (!Number.isFinite(actionStartedAt) + || !Number.isFinite(observedAt) + || rule.probe.cleanObservedAt !== null + || rule.probe.actionStartedAt !== actionStartedAt + || observedAt < actionStartedAt) return rule; + return { + ...rule, + probe: { ...rule.probe, cleanObservedAt: observedAt }, + updatedAt: observedAt, + }; +}; /** * End a probe. Blocked during the trial -> the site still cares: restore the @@ -258,36 +423,27 @@ export const probeExpired = (rule, now) => !!rule.probe && now >= rule.probe.unt * Clean -> adopt the lower trial value; if that is under the retire floor the * rule is now retirable and the caller drops it entirely. * - * @param {PaceRule} rule @param {boolean} blockedDuringProbe @param {number} now @param {PaceTunables} [K] + * @param {PaceRule} rule @param {'clean'|'blocked'|'inconclusive'} outcome + * @param {number} now @param {PaceTunables} [K] * @returns {PaceRule} */ -export const resolveProbe = (rule, blockedDuringProbe, now, K = PACE_TUNABLES) => { - if (!rule.probe) return rule; - const { trialMs, prevMinIntervalMs } = rule.probe; - if (blockedDuringProbe) { +export const resolveProbe = (rule, outcome, now, K = PACE_TUNABLES) => { + if (!isValidRule(rule, undefined, K) || !rule.probe || !Number.isFinite(now)) return rule; + const { trialMs, prevMinIntervalMs, startedAt, actionStartedAt, cleanObservedAt } = rule.probe; + if (now < startedAt) return rule; + if (outcome === 'blocked') { + if (actionStartedAt === null || now < actionStartedAt) return rule; const restored = { ...rule, minIntervalMs: prevMinIntervalMs, probe: null }; return nextRuleOnBlock(restored, { recentIntervalMs: trialMs, now }, K); } + if (outcome === 'inconclusive') { + if (now < rule.updatedAt || (actionStartedAt !== null && now < actionStartedAt)) return rule; + return { ...rule, minIntervalMs: prevMinIntervalMs, probe: null, updatedAt: now }; + } + if (outcome !== 'clean' || actionStartedAt === null || cleanObservedAt === null) return rule; + // A quiet timer is not a test. Adopt only after the one authorized action + // completed cleanly and a bounded post-result settle period also stayed clean. + const readyAt = Math.max(rule.probe.until, cleanObservedAt + K.probeSettleMs); + if (now < readyAt) return rule; return { ...rule, minIntervalMs: trialMs, probe: null, lastDecayAt: now, updatedAt: now }; }; - -/** - * The agent's only write into the rule: RAISE-ONLY, and clamped. - * - * why not a symmetric set(): see THE DESCENT RULE in the module header. A - * request to go SLOWER is always safe to honor — the agent has a real signal - * (a Retry-After it read, or the user saying "take it easy on this one") and - * the worst case is a slow origin. A request to go FASTER is refused outright, - * because that is the outcome an injected page wants and no downstream check - * can distinguish a genuine judgement from a laundered instruction. An agent - * that believes a rule is stale asks for a probe instead. - * - * @param {PaceRule} rule @param {number} requestedMs @param {number} now @param {PaceTunables} [K] - * @returns {PaceRule} the rule unchanged when the request would lower it - */ -export const applyAgentSet = (rule, requestedMs, now, K = PACE_TUNABLES) => { - if (!Number.isFinite(requestedMs)) return rule; - const requested = clamp(requestedMs, 0, K.maxPaceMs); - if (requested <= rule.minIntervalMs) return rule; // refuse to lower — probe is the way down - return { ...rule, minIntervalMs: requested, source: 'agent', updatedAt: now }; -}; diff --git a/tests/peerd-runtime/web-pace-reducer.test.ts b/tests/peerd-runtime/web-pace-reducer.test.ts index 68bb0b8f..3b8275a7 100644 --- a/tests/peerd-runtime/web-pace-reducer.test.ts +++ b/tests/peerd-runtime/web-pace-reducer.test.ts @@ -1,21 +1,19 @@ -// The pure policy core of adaptive per-origin pacing (docs/store/ADAPTIVE-PACING.md, -// PR #213). Every transition is a function of (rule, signal, now) with the clock +// The pure policy core of adaptive per-origin pacing (issue #234). Every +// transition is a function of (rule, signal, now) with the clock // and RNG injected, so the arithmetic is pinned exactly rather than approximated. // // The load-bearing properties, in order of how much a regression would cost: -// 1. DESCENT IS CONSTRAINED — the agent can raise pacing but never lower it; -// the only ways down are automatic decay and a reversible probe. This is -// the anti-injection property: a page that talks the actor into "clearing" -// a rule would get peerd hammering a site on the user's real logged-in -// account. There is no clearRule() to test because there deliberately isn't one. +// 1. DESCENT IS CONSTRAINED — the only ways down are automatic decay and a +// one-action reversible probe. There is no model-controlled set or clear. // 2. A block always yields a rule (a block with no cadence sample still learns). // 3. Decay is lazy + multi-period (rules self-heal; no permanent tax). // 4. First action is free; pacing is a catch-up, not a fixed tax. import { describe, test, expect } from 'bun:test'; import { - PACE_TUNABLES, newRule, nextRuleOnBlock, decay, isRetired, needsHandOff, - waitForAction, startProbe, probeExpired, resolveProbe, applyAgentSet, + PACE_RULE_VERSION, PACE_TUNABLES, isValidRule, newRule, nextRuleOnBlock, + decay, isRetired, needsHandOff, waitForAction, startProbe, probeExpired, + probeAllowsAction, beginProbeAction, noteCleanProbeAction, resolveProbe, } from '../../extension/peerd-runtime/web-pace/reducer.js'; const K = PACE_TUNABLES; @@ -28,8 +26,10 @@ describe('web-pace — the default is OFF', () => { test('a fresh rule costs nothing: no interval, no wait', () => { const r = newRule('https://x.com', T0); expect(r.minIntervalMs).toBe(0); + expect(r.version).toBe(PACE_RULE_VERSION); expect(r.observations).toBe(0); expect(r.probe).toBe(null); + expect(isRetired(r, K)).toBe(false); // even with a prior action a moment ago, a rule-less origin runs full speed expect(waitForAction(r, T0 - 1, T0, noJitter)).toBe(0); }); @@ -45,7 +45,7 @@ describe('web-pace — learning from a block', () => { expect(r.minIntervalMs).toBe(4_000 * K.slowdownMult); // "we were doing X → go slower than X" expect(r.observations).toBe(1); expect(r.lastBlockAt).toBe(T0); - expect(r.source).toBe('learned'); + expect(r.lastEscalationSource).toBe('learned'); }); test('a block with NO cadence sample still learns a rule (the seed floor)', () => { @@ -62,7 +62,25 @@ describe('web-pace — learning from a block', () => { K, ); expect(r.minIntervalMs).toBe(9_000); - expect(r.source).toBe('retry-after'); // honoring it is the whole compliance story + expect(r.lastEscalationSource).toBe('retry-after'); + }); + + test('a smaller Retry-After does not claim credit for a cadence-sized rule', () => { + const r = nextRuleOnBlock( + newRule('https://x.com', T0), + { recentIntervalMs: 4_000, retryAfterMs: 1_500, now: T0 }, + K, + ); + expect(r.minIntervalMs).toBe(8_000); + expect(r.lastEscalationSource).toBe('learned'); + }); + + test('negative and non-finite samples cannot create or rewrite a rule', () => { + const fresh = newRule('https://x.com', T0); + expect(nextRuleOnBlock(fresh, { recentIntervalMs: -1, retryAfterMs: -2, now: T0 }, K).minIntervalMs) + .toBe(K.seedMs); + expect(nextRuleOnBlock(fresh, { now: NaN }, K)).toEqual(fresh); + expect(nextRuleOnBlock(fresh, { now: Infinity }, K)).toEqual(fresh); }); test('repeat blocks compound (still too fast) and stay bounded by the ceiling', () => { @@ -77,6 +95,37 @@ describe('web-pace — learning from a block', () => { expect(r.minIntervalMs).toBe(K.maxPaceMs); expect(needsHandOff(r, K)).toBe(true); // → posture ladder, not a sleep }); + + test('malformed persisted state is rejected and cannot fail open', () => { + const valid = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 2_000, now: T0 }, K); + const wrongOrigin = { ...valid, origin: 'https://attacker.example' }; + const invalidInterval = { ...valid, minIntervalMs: NaN }; + const invalidJitter = { ...valid, jitterFrac: 1 }; + const incoherentTime = { + ...valid, + createdAt: T0 + 10_000, + updatedAt: T0 + 10_000, + lastBlockAt: 0, + lastDecayAt: 0, + }; + expect(isValidRule(valid, 'https://x.com', K)).toBe(true); + expect(isValidRule(wrongOrigin, 'https://x.com', K)).toBe(false); + expect(isValidRule(invalidInterval, 'https://x.com', K)).toBe(false); + expect(isValidRule(invalidJitter, 'https://x.com', K)).toBe(false); + expect(isValidRule(incoherentTime, 'https://x.com', K)).toBe(false); + expect(waitForAction(invalidInterval, T0, T0 + 1, noJitter, K)).toBe(null); + expect(waitForAction(invalidJitter, T0, T0 + 1, () => 0, K)).toBe(null); + expect(needsHandOff(invalidInterval, K)).toBe(true); + expect(nextRuleOnBlock(invalidInterval, { now: T0 + 1 }, K)).toEqual(invalidInterval); + }); + + test('a block on a regressed clock preserves a valid monotonic rule', () => { + const prior = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 2_000, now: T0 }, K); + const next = nextRuleOnBlock(prior, { now: -1 }, K); + expect(isValidRule(next, 'https://x.com', K)).toBe(true); + expect(next.lastBlockAt).toBe(prior.updatedAt); + expect(next.updatedAt).toBe(prior.updatedAt); + }); }); describe('web-pace — decay self-heals a one-off block', () => { @@ -102,6 +151,13 @@ describe('web-pace — decay self-heals a one-off block', () => { expect(isRetired(r, K)).toBe(true); // → caller deletes the record; no permanent tax }); + test('decay underflow still retires a learned rule', () => { + const blocked = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const decayed = decay(blocked, T0 + K.quietMs * 10_000, K); + expect(decayed.minIntervalMs).toBe(0); + expect(isRetired(decayed, K)).toBe(true); + }); + test('never decays mid-probe, and never on a backwards clock', () => { const blocked = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); const probing = startProbe(blocked, 500, 60_000, T0); @@ -116,6 +172,13 @@ describe('web-pace — the per-action wait is a catch-up, not a tax', () => { expect(waitForAction(r, undefined, T0, noJitter)).toBe(0); }); + test('only undefined means no prior action; malformed cadence fails closed', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + for (const invalid of [NaN, Infinity, -Infinity, -1, null]) { + expect(waitForAction(r, invalid as unknown as number, T0, noJitter, K)).toBe(null); + } + }); + test('only the shortfall is waited — natural latency already counts', () => { const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 1_000, now: T0 }, K); expect(r.minIntervalMs).toBe(2_000); @@ -129,23 +192,59 @@ describe('web-pace — the per-action wait is a catch-up, not a tax', () => { const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 1_000, now: T0 }, K); const lo = waitForAction(r, T0, T0, () => 0); // jitter = -jitterFrac const hi = waitForAction(r, T0, T0, () => 1); // jitter = +jitterFrac + if (lo === null || hi === null) throw new Error('valid rules must produce a wait'); expect(lo).toBeCloseTo(r.minIntervalMs * (1 - r.jitterFrac), 6); expect(hi).toBeCloseTo(r.minIntervalMs * (1 + r.jitterFrac), 6); expect(lo).toBeLessThan(hi); }); - test('clock skew never stalls an action', () => { + test('clock skew cannot turn a learned interval into a bypass', () => { const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); - expect(waitForAction(r, T0 + 10_000, T0, noJitter)).toBe(0); // "last action" in the future + expect(waitForAction(r, T0 + 10_000, T0, noJitter)).toBe(r.minIntervalMs); + expect(waitForAction(r, T0, NaN, noJitter)).toBe(r.minIntervalMs); + }); + + test('invalid RNG is neutral and the actual wait never exceeds the ceiling', () => { + const nearCeiling = { + ...nextRuleOnBlock(newRule('https://x.com', T0), { retryAfterMs: K.maxPaceMs - 1, now: T0 }, K), + }; + expect(waitForAction(nearCeiling, T0, T0, () => Infinity, K)).toBe(nearCeiling.minIntervalMs); + expect(waitForAction(nearCeiling, T0, T0, () => 1, K)).toBe(K.maxPaceMs); + }); + + test('a handoff rule never authorizes an action or a wait', () => { + const ceiling = nextRuleOnBlock( + newRule('https://x.com', T0), + { retryAfterMs: K.maxPaceMs, now: T0 }, + K, + ); + expect(needsHandOff(ceiling, K)).toBe(true); + expect(waitForAction(ceiling, undefined, T0, noJitter, K)).toBe(null); + expect(waitForAction(ceiling, T0, T0, noJitter, K)).toBe(null); }); }); describe('web-pace — probe is the reversible way down', () => { - test('a probe paces at the trial interval, and is clamped to at most the current one', () => { + test('a probe offers one action at a clamped trial interval', () => { const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); const p = startProbe(r, 500, 60_000, T0); - expect(p.probe).toEqual({ trialMs: 500, until: T0 + 60_000, prevMinIntervalMs: r.minIntervalMs }); - expect(waitForAction(p, T0, T0, noJitter)).toBe(500); // the TRIAL is enforced, not the rule + expect(p.probe).toEqual({ + trialMs: 500, + startedAt: T0, + until: T0 + 60_000, + prevMinIntervalMs: r.minIntervalMs, + actionStartedAt: null, + cleanObservedAt: null, + }); + expect(waitForAction(p, T0, T0, noJitter)).toBe(500); + expect(waitForAction(p, undefined, T0, noJitter)).toBe(null); + expect(probeAllowsAction(p, T0, K)).toBe(true); + expect(beginProbeAction(p, -1, T0 + 1, K)).toEqual(p); + const begun = beginProbeAction(p, T0, T0 + 1, K); + expect(begun.probe?.actionStartedAt).toBe(T0 + 1); + expect(probeAllowsAction(begun, T0 + 2, K)).toBe(false); + expect(waitForAction(begun, T0, T0 + 2, noJitter, K)).toBe(null); + expect(beginProbeAction(begun, T0, T0 + 2, K)).toEqual(begun); // a "probe" that asked to go slower is not a probe — clamped to the current value expect(startProbe(r, 99_999, 60_000, T0).probe?.trialMs).toBe(r.minIntervalMs); expect(startProbe(p, 10, 60_000, T0)).toEqual(p); // already probing → window not restarted @@ -153,18 +252,60 @@ describe('web-pace — probe is the reversible way down', () => { expect(probeExpired(p, T0 + 60_000)).toBe(true); }); - test('a CLEAN probe adopts the lower value (and can make the rule retirable)', () => { + test('a clean observed action adopts the lower value only after the window and settle time', () => { const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); - const done = resolveProbe(startProbe(r, 100, 60_000, T0), false, T0 + 60_000, K); + const p = startProbe(r, 100, 60_000, T0); + const begun = beginProbeAction(p, T0, T0 + 1, K); + const observed = noteCleanProbeAction(begun, T0 + 1, T0 + 2, K); + expect(resolveProbe(observed, 'clean', T0 + 59_999, K)).toEqual(observed); + expect(resolveProbe(observed, 'clean', T0 + 60_000, K).probe).toBe(null); + const done = resolveProbe(observed, 'clean', T0 + 60_000, K); expect(done.minIntervalMs).toBe(100); expect(done.probe).toBe(null); expect(isRetired(done, K)).toBe(true); // 100 < retireFloorMs → the site stopped caring }); - test('a BLOCKED probe snaps back, re-escalates from the trial, and resets the quiet timer', () => { + test('a probe cannot resolve clean without a reserved and observed action', () => { const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); - const p = startProbe(r, 500, 60_000, T0); - const after = resolveProbe(p, true, T0 + 60_000, K); + const idle = startProbe(r, 100, 60_000, T0); + expect(resolveProbe(idle, 'clean', T0 + 60_000, K)).toEqual(idle); + expect(beginProbeAction(idle, T0, T0 - 1, K)).toEqual(idle); + expect(beginProbeAction(idle, T0, T0 + 60_000, K)).toEqual(idle); + const begun = beginProbeAction(idle, T0, T0 + 1, K); + expect(noteCleanProbeAction(begun, T0, T0 + 2, K)).toEqual(begun); + expect(resolveProbe(begun, 'clean', T0 + 60_000, K)).toEqual(begun); + }); + + test('a result observed after the window can settle, while a delayed block still wins', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const begun = beginProbeAction(startProbe(r, 500, 60_000, T0), T0, T0 + 59_999, K); + const observedAt = T0 + 60_500; + const observed = noteCleanProbeAction(begun, T0 + 59_999, observedAt, K); + expect(resolveProbe(observed, 'clean', observedAt + K.probeSettleMs - 1, K)).toEqual(observed); + const blocked = resolveProbe(observed, 'blocked', observedAt + 1, K); + expect(blocked.probe).toBe(null); + expect(blocked.minIntervalMs).toBeGreaterThan(r.minIntervalMs); + }); + + test('probe inputs are finite, non-negative, and window-bounded', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + for (const bad of [NaN, Infinity, -Infinity, -1]) { + expect(startProbe(r, bad, 60_000, T0, K)).toEqual(r); + } + for (const bad of [NaN, Infinity, -Infinity, 0, -1]) { + expect(startProbe(r, 500, bad, T0, K)).toEqual(r); + } + expect(startProbe(r, 500, 1, T0, K).probe?.until).toBe(T0 + K.minProbeWindowMs); + expect(startProbe(r, 500, K.maxProbeWindowMs * 2, T0, K).probe?.until) + .toBe(T0 + K.maxProbeWindowMs); + expect(startProbe(r, 500, 60_000, NaN, K)).toEqual(r); + expect(startProbe(r, 500, 60_000, T0 - 1, K)).toEqual(r); + }); + + test('a blocked probe snaps back, re-escalates, and resets the quiet timer', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const p = beginProbeAction(startProbe(r, 500, 60_000, T0), T0, T0 + 1, K); + const after = resolveProbe(p, 'blocked', T0 + 60_000, K); expect(after.probe).toBe(null); // it must NOT keep the trial value it just got blocked at… expect(after.minIntervalMs).toBeGreaterThan(500); @@ -173,45 +314,30 @@ describe('web-pace — probe is the reversible way down', () => { expect(after.lastBlockAt).toBe(T0 + 60_000); // quiet timer reset → decay can't undo the lesson expect(after.observations).toBe(r.observations + 1); }); -}); - -// The reason this module has no clearRule(): lowering is the direction an -// injected page benefits from. Raising is self-limiting; retiring a rule makes -// peerd hammer a site on the user's REAL logged-in session. An SW-side re-check -// can validate a call's args but never its intent, so the descent simply isn't -// a decision the actor is allowed to make. -describe('web-pace — the agent may raise pacing, never lower it (anti-injection)', () => { - test('a raise is honored and attributed to the agent', () => { - const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 1_000, now: T0 }, K); - expect(r.minIntervalMs).toBe(2_000); - const raised = applyAgentSet(r, 5_000, T0 + 1, K); - expect(raised.minIntervalMs).toBe(5_000); - expect(raised.source).toBe('agent'); - }); - test('every lowering request is a NO-OP — including the zero a "clear" would use', () => { + test('an inconclusive or zero-interval probe cannot leave a dead rule behind', () => { const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); - for (const attempt of [0, 1, 500, r.minIntervalMs - 1, r.minIntervalMs]) { - expect(applyAgentSet(r, attempt, T0 + 1, K).minIntervalMs).toBe(r.minIntervalMs); - } - // …and junk can't sneak past the guard either - expect(applyAgentSet(r, NaN, T0 + 1, K)).toEqual(r); - expect(applyAgentSet(r, -5_000, T0 + 1, K).minIntervalMs).toBe(r.minIntervalMs); - }); - - test('a raise is still clamped to the ceiling (no agent-driven multi-minute nap)', () => { - const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 1_000, now: T0 }, K); - expect(applyAgentSet(r, 10 * 60_000, T0 + 1, K).minIntervalMs).toBe(K.maxPaceMs); + const idle = startProbe(r, 0, 60_000, T0); + expect(resolveProbe(idle, 'inconclusive', T0 + 1, K)).toEqual({ + ...r, + updatedAt: T0 + 1, + }); + const begun = beginProbeAction(idle, T0, T0 + 1, K); + const observed = noteCleanProbeAction(begun, T0 + 1, T0 + 2, K); + const done = resolveProbe(observed, 'clean', T0 + 60_000, K); + expect(done.minIntervalMs).toBe(0); + expect(isRetired(done, K)).toBe(true); }); +}); - test('the export surface offers no clear/reset escape hatch — descent is decay or probe only', async () => { - // Pinned exactly: adding a clearRule()/resetRule() later fails here, which - // is the point. The only downward transitions are decay() (time, automatic) - // and resolveProbe() (bounded, reverts on a block). +describe('web-pace — the export surface has no model-controlled rule mutation', () => { + test('descent remains decay or the one-action probe state machine', async () => { const mod = await import('../../extension/peerd-runtime/web-pace/reducer.js'); expect(Object.keys(mod).sort()).toEqual([ - 'PACE_TUNABLES', 'applyAgentSet', 'decay', 'isRetired', 'needsHandOff', - 'newRule', 'nextRuleOnBlock', 'probeExpired', 'resolveProbe', 'startProbe', 'waitForAction', + 'PACE_RULE_VERSION', 'PACE_TUNABLES', 'beginProbeAction', 'decay', + 'isRetired', 'isValidRule', 'needsHandOff', 'newRule', + 'nextRuleOnBlock', 'noteCleanProbeAction', 'probeAllowsAction', + 'probeExpired', 'resolveProbe', 'startProbe', 'waitForAction', ].sort()); }); });