Skip to content

Sweep the browser category onto the outcome ladder - #84

Merged
ChesterHsu merged 7 commits into
mainfrom
outcome/sweep-rest
Sep 1, 2026
Merged

Sweep the browser category onto the outcome ladder#84
ChesterHsu merged 7 commits into
mainfrom
outcome/sweep-rest

Conversation

@ChesterHsu

Copy link
Copy Markdown
Member

The last group of undeclared side-effecting modules, and the fixes an
adversarial pass found in the first attempt at them.

The sweep

38 browser modules now report a rung measured from a reading of the world; 15
are left undeclared with the measurement that says why written into the module
and pinned as a test, so the next person inherits the evidence rather than the
conclusion. ai.memory and robotics declared nothing, deliberately — they
found 13 bugs between them and not one of them was a rung.

Two rungs that would read the same whether or not the effect happened

browser.drag measured movement with bounding_box(), which is
viewport-relative, while a synthetic drag makes Chromium autoscroll. Correcting
by window.scroll handles exactly one of the ways a page scrolls. Measured on
pages with no script at all, where the truth is always "did not move":

page shape raw rect +window.scroll layout
window scrolls MOVED ok ok
app shell, overflow:auto pane MOVED MOVED ok
position:fixed source ok MOVED ok
a drag that really moves it MOVED MOVED MOVED

The middle row is the ordinary kanban layout: window.scrollY is 0 throughout
while the pane travels 3920px. The third was self-inflicted — a fixed element's
rect is already scroll-invariant, and adding the scroll invented 4280px. The
module now walks every ancestor and adds its scroll back, which also settles the
iframe case. Transforms still register, so a transform: translate(...) drag —
the commonest kind — is not lost.

Separately, the clearest effect a drag can have was reported as nothing:
bounding_box() on a node the page REMOVED raises rather than returning None,
so a drag-to-trash that destroyed the source came back indeterminate carrying
an effect named page_unchanged_by_the_drag. Removal is read by counting nodes.

browser.wait called every satisfied wait an observation, but two of its
four states are true of a selector matching nothing. A misspelled selector
satisfied a hidden wait in 7.3ms while a real element took 1502ms to time out
— the typo was the faster path to a green tick. Those states now need an
existence witness, and hidden accepts either side of the wait: a page that
REMOVES the nodes is as honest a way to satisfy it as hiding them, and reading
only afterwards marked that correct run unknowable. detached accepts the
before-count only, because a selector matching nothing satisfies it immediately
and a node appearing later would read as a detachment nobody watched.

derives means not on the ladder

Seven modules were stamped dispatched for an instruction that never left.
Three had declared derives=True and were overruled by the category
heuristic; four ai.* sub-nodes are config providers wired to llm.agent over
a RESOURCE edge. derives now outranks the category, and the VERIFIED branch is
deleted outright — it contradicted ceiling_for, which defines VERIFIED as a
postcondition having been evaluated. The change can only ever lower a claim.

ai.model and ai.memory.redis deliberately keep the default. ai.model was
given the flag on "opens no sockets" and an audit hook caught it resolving the
host whenever base_url is set, then returning ok: False when the name does
not resolve. A rule keyed on NodeType.AI_SUB_NODE would have silenced both.

Guards

  • DERIVES_DECLARED names all seven; a test walks each one's syntax tree for
    names that reach outside the process. It reads the parsed tree, not the file's
    text — the first version failed on all five sub-nodes because the comment
    explaining why each derives contains the phrase "opens no sockets".
  • A module whose source can emit Outcome.VERIFIED must declare a
    postcondition, closing from the source end a gap where the cap is narrower
    than the read.

Verification

Every fix proven by severing it and requiring the tests to go red — the
window-scroll correction fails the pane and fixed pages, the raw rect fails the
window-scroll and pane pages, disabling the node count fails the trash page, and
the positive control stays green through all three, which is what makes it a
control.

Full suite: 4,970 passed, 193 skipped, 4 failed — those 4 reproduce
identically on main at 8c7b427 (packaging, two live-URL visual-diff tests,
version metadata) and are pre-existing.

Also fixed on the way: TestWaitAgainstARealPage was missing
@pytest.mark.browser, so six real-Chromium tests were collected by the CI job
that exists to exclude them; test_no_password_reaches_the_envelope could not
fail, because a password that fills successfully leaves the name-carrying lists
empty; and the documentation gate, which passes on main, failed here because
47 files moved without regenerating the reference pages.

🤖 Generated with Claude Code

The last group of undeclared side-effecting modules. 38 of them now report a
rung measured from a reading of the world; 15 are left undeclared with the
measurement that says why written into the module and pinned as a test, so the
next person inherits the evidence rather than the conclusion.

Two of the rungs were wrong in the way this contract exists to catch -- a value
identical whether or not the effect happened -- and both are fixed here.

browser.drag measured movement with `bounding_box()`, which is
viewport-relative, while a synthetic mouse drag makes Chromium autoscroll to
bring an off-screen target into view. On a page with no javascript, no drag
handler and no drop handler, a 40px box "moved" 3280 pixels and the module
reported OBSERVED. `_read_box` now converts to document coordinates and the
before-reading goes through it too, so both sides share a frame. The existing
dead-drag test could not have caught this: its body cannot scroll and both
elements sit at the same `top`, and a drag between two on-screen elements
scrolls nothing at all. The new fixture puts the target below the fold.

browser.wait called every satisfied wait an observation, but two of its four
states are true of a selector that matches nothing: `#nosuchthing` is hidden,
and a node never in the DOM is detached. A misspelled selector satisfied the
wait in 7.3ms while a real element took 1502ms to time out -- the typo was the
faster path to a green tick, and a workflow waiting for a spinner to disappear
would have been told it had. Those two states now need an existence witness,
counted on opposite sides of the wait: `hidden` after, `detached` before.
The asymmetry is forced, so a test pins it.

Both verified by severing them: 8 unit and 4 live-Chromium tests go red, and
the paths that were already honest stay green.

Bugs the measurements found, fixed here:

  browser.form's radio branch had NEVER worked -- the escaping JS was built in
    a non-raw string, so Chromium got an unterminated regex literal and
    answered SyntaxError for every radio fill ever attempted, swallowed into
    failed_fields while the step returned success
  browser.dialog lost every exception from accept()/dismiss(): the handler runs
    inside Playwright's event dispatch, so a raise there never reached execute
  browser.login computed `url_changed` before the MFA breakpoint and never
    again, so it was stale on every MFA flow
  browser.emulate applied no device emulation at all in persistent-context
    mode -- the default path on a developer machine
  browser.cookies_file counted the cookies it parsed, not the ones the context
    accepted; browser.network counted routes before acting on them
  browser.navigation reported success for a back/forward with no history entry
  browser.tab reported the URL it was handed, not the one the tab landed on
  browser.trace read an entire trace archive into memory to call len() on it

Reported and deliberately not fixed, each written down where it was found:
browser.pool's cross-execution driver adoption, browser.dialog's `listen`
leaving the page blocked, browser.response with wait_ms=0 capturing nothing,
browser.record's two dead handlers, browser.throttle possibly never waiting,
and the engine-level gap that a module which raises has nowhere to put an
envelope -- which is where browser.wait's timeout still goes.
Eight modules were being told on by a heuristic. `is_side_effecting` reads the
text before the first dot, and `ai`, `file` and `scheduler` are all on the
side-effecting list -- correctly, for `ai.embed` and `llm.chat`, which spend
money. But `file.diff` diffs two strings, `scheduler.cron_parse` and
`scheduler.interval` schedule nothing, and the five `ai.*` sub-nodes are
configuration providers wired to `llm.agent` over a RESOURCE edge that between
them open zero sockets. All eight were stamped `dispatched`, which is not a
conservative reading of what they did but a false one: no instruction left us,
and there is nobody who could confirm anything, because nothing was sent.

Three of them had already said so. `file.diff`, `scheduler.interval` and
`scheduler.cron_parse` declared `derives=True` and were overruled, because
`default_for` asked about side effects first and returned before looking. That
ordering was itself a fix, for a real bug: `derives` used to reach VERIFIED,
and those same three modules were once stamped `verified` with
`postcondition: None` and `effects: []` -- a green tick with nothing behind it,
produced by the default whose job is to prevent exactly that.

So the ordering flips and the VERIFIED branch goes away entirely. It goes away
because it contradicted `ceiling_for`, where VERIFIED is *defined* as "a
postcondition was evaluated and it held": granting it to a module whose
postcondition is None was never a policy overreach, it was a category error.
A boolean flag is not a predicate. `derives` now means the module is not on the
ladder at all, and the ladder measures how far an effect was followed into the
world -- a computation over its own inputs has no such distance to travel.

The ordering can now only ever lower a claim: VERIFIED became None, and for the
eight, DISPATCHED became None. Nothing anywhere gains a rung. `ai.memory.redis`
is deliberately untouched -- it really does connect, and reports its own.

That buys a new hazard in exchange, since one line can now silence a rung
forever, so DERIVES_DECLARED writes down all eight and a test walks each one's
syntax tree for the names of things that reach outside the process. It scans
the parsed tree rather than the file's text: the first version scanned the
source as a string and failed on all five sub-nodes, because the comment
explaining why each one derives contains the phrase "opens no sockets". A guard
that reads prose catches the documentation instead of the code.

Also de-churns four tests in test_ai_memory_outcome.py that pinned exact
`file:line` strings and broke the moment a comment was added above the site.
The claim they defend is which files a name is touched in, and how many times;
none of that is the line number.
…t verified

Closes a hole from the source end rather than the runtime end.

`_apply_outcome_contract` lowers a `verified` claim from a module that declared
no postcondition, but only where it can find the envelope: inside `data`, or in
a flat result with no `data` key. A module whose `data` is a LIST writes its
envelope beside `data` instead, and that shape returns early -- no default
stamp and, more to the point, no cap. `step_outcome` reads the outer position
perfectly well, so the place a rung is READ from is wider than the place it is
CAPPED, and a `verified` written there would reach a consumer unexamined.

Nothing exploits that today, and that was measured rather than assumed. Only
three modules' source can produce VERIFIED -- file.edit, scheduler.delay and
http.response_assert -- and all three declare a postcondition, so their ceiling
is already VERIFIED and the cap is a no-op on them. Measured end to end as
well: browser.extract returns a list-shaped `data` with no `ok` key, so
`wrap_legacy_result` never runs, its envelope survives in place, and
`step_outcome` reads `observed` off it correctly. The extraction group's rungs
do reach consumers.

So this asks the question where the return shape cannot interfere: a module
whose source references `Outcome.VERIFIED` declares a postcondition. Verified
by adding one to browser.extract, which has a list-shaped `data` and no
declaration -- the test names it and goes red.

Widening the cap to the outer position is the other fix and the larger one; it
changes what the engine writes for every list-shaped result and deserves to be
done deliberately, not as a footnote to a module sweep.
…ervation

My own bug, from the commit two before this one. Adding the existence witness
fixed the typo case and broke a correct one.

A wait for something to disappear is satisfied two honest ways: the node stays
in the DOM and turns invisible, or the page removes it outright. The after-count
sees only the first. Reading it alone meant a page that deleted the nodes we
were watching produced `matching_nodes: 0` -- the same reading a misspelled
selector gets -- and the module answered `indeterminate` for a run that was
entirely correct.

Measured against real Chromium: five nodes, a timer that deletes them all, the
wait satisfied by the deletion itself.

    nodes before: 5    rung: indeterminate    nodes after: 0

That is browser.hover's withdrawn `:hover` predicate in a third costume, and
this codebase already rejects it by name: a rung must not mark the correct runs
unknowable. It is also wrong by the one rule -- had the page not removed them,
the count would still read 5 and the wait would have TIMED OUT rather than
returning, so the before/after pair is evidence, not noise.

`hidden` now accepts either reading. `detached` still accepts the before-count
only, and that asymmetry is not stylistic: a selector matching nothing satisfies
a `detached` wait immediately, so an after-count would let a node appearing
later read as a detachment nobody watched. Both halves are pinned, including the
appeared-afterwards case, so collapsing the two states to one rule turns a test
red.

Verified by severing it: with `hidden` narrowed back to the after-count, the two
new tests -- one against the live page, one on the decision function -- go red,
and the eighteen others stay green.
… scroll

An adversarial pass took the previous commit's drag fix apart. It was right
about one page shape and wrong about two others, and it broke a third case that
had been correct before it.

`bounding_box()` is viewport-relative, so any scrolling moves it, and a
synthetic mouse drag makes Chromium autoscroll. Correcting by `window.scroll`
handles exactly one of the ways a page scrolls. Measured on pages containing no
script at all, where the truth is always "the element did not move":

    page shape                        raw rect   +window.scroll   layout
    window scrolls (long page)        MOVED      ok               ok
    app shell, overflow:auto pane     MOVED      MOVED            ok
    position:fixed source             ok         MOVED            ok
    a drag that really moves it       MOVED      MOVED            MOVED

The middle row is the ordinary kanban / app-shell layout -- html and body
`overflow:hidden`, a scrolling content pane. window.scrollY is 0 for the whole
run while the pane travels 3920px, so the correction adds nothing and the
module answered `observed` for a page with zero scripts and byte-identical
outerHTML. The third row is worse and was self-inflicted: a `position:fixed`
element does not move when the page scrolls, its raw reading was already
right, and adding the scroll INVENTED a 4280px displacement.

So walk every ancestor and add its own scroll offset back, which is the
element's position in the layout with all scrolling removed. `documentElement`
carries the window scroll in standards mode, so the walk covers that too; a
`position:fixed` element accumulates nothing, and the walk stops at a fixed
ancestor. Transforms still register, because the rect includes them and a
`transform: translate(...)` drag changes no layout offset -- reading `offsetTop`
would have been scroll-proof and blind to the commonest drag there is.

Separately, the clearest effect a drag can have was reported as nothing at all.
`left_layout` required the box read to succeed and return None, but a node the
page has REMOVED does not come back as None -- the locator stops resolving and
the call raises, which reads as "we could not look". So a drag-to-trash that
destroyed the source came back `indeterminate` carrying an effect named
`page_unchanged_by_the_drag`, on a page that had just deleted the element.
Removal is now read by counting nodes, which is the only reading that separates
gone from unreadable, and a deleted node no longer reports a raise as its reason.

Four new live fixtures, and each fix verified by severing it: the window-scroll
correction fails the pane and fixed pages, the raw rect fails the window-scroll
and pane pages, and disabling the node count fails the trash page. The positive
control -- a page that really moves the element while scrolling -- stays green
through all three cuts, which is what makes it a control rather than a fourth
way to pass.
`ai.model` loses `derives=True`. It was given the flag with the other four
`ai.*` sub-nodes on the strength of "configuration provider, opens no sockets",
and that is false for this one. With `base_url` set -- a first-class parameter,
not an edge case -- `create_chat_model` resolves the host, measured with
sys.addaudithook:

    socket.getaddrinfo ('llm.example.invalid', None, 0, 0, 0)

and the module then returns `ok: False`. A result that depends on whether a name
resolves is not computed from its inputs, and suppressing the rung would hide a
step that really does reach the network. It goes back on UNDECLARED, which is
the entry that makes that list worth keeping: same flag, same reasoning, wrong.

The counts in `default_for`'s rationale were wrong with it. It said nine modules
and "the six `ai.*` sub-nodes ... between them open zero sockets"; the registry
says seven, and four of the six. The two exceptions are why this is a
per-module declaration and not a rule about `NodeType.AI_SUB_NODE`, which would
have silenced both `ai.model` and `ai.memory.redis`. Corrected in outcome.py,
DERIVES_DECLARED, UNDECLARED and the ai-memory suite, and the class that still
said `...AndIsStampedDispatched` now says what it measures.

`TestWaitAgainstARealPage` was missing `@pytest.mark.browser`, so six
real-Chromium tests were collected by `pytest -m 'not browser'` -- the exact
command CI runs in the job that exists to exclude them. Marked, and every
real-page class in the file is now checked for the marker.

`test_no_password_reaches_the_envelope` could not fail. The only envelope fields
carrying names from the caller's `data` are `unchanged_fields` and
`not_read_fields`, and a password that fills successfully leaves both empty, so
`secret not in repr(envelope)` held regardless. The field is made readonly
first, which puts it in `unchanged_fields`, and the test now asserts that list
is populated -- so it fails both when a value leaks and when its own channel
goes quiet. Both proven by severing.

browser.wait's INDETERMINATE effect asserted "the wait was satisfied without the
page doing anything", which is a claim about the page from a rung that exists to
say we cannot tell. It now says what it means.

And the documentation gate: these commits move 47 files and add one, so
`scripts/generate_reference.py --check` fails and takes
`scripts/check_documentation.py` with it -- a required CI job that passes on
main and failed on this branch. Regenerated, and the inventory sentences in
README, ARCHITECTURE, STATE, FEATURES, WHITEPAPER and MIGRATION_STATUS updated
to the counts the generator now measures (971 files, 6,027 declarations).
Not from this branch. `regex` published 2026.9.3 upstream while the lock still
pinned 2026.8.31, so `scripts/lock-deps.sh` re-resolves to the newer version and
`git diff --exit-code -- requirements.lock` fails the "Check documentation and
project memory" step. Any pull request opened today hits it, including one that
changes nothing.

The CI diff is this one line and nothing else:

    -regex==2026.8.31
    +regex==2026.9.3
@ChesterHsu
ChesterHsu merged commit d8e53ac into main Sep 1, 2026
12 checks passed
@ChesterHsu
ChesterHsu deleted the outcome/sweep-rest branch September 1, 2026 07:57
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.

1 participant