Sweep the browser category onto the outcome ladder - #84
Merged
Conversation
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).
ChesterHsu
force-pushed
the
outcome/sweep-rest
branch
from
September 1, 2026 04:49
0e958c3 to
b35909e
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.memoryandroboticsdeclared nothing, deliberately — theyfound 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.dragmeasured movement withbounding_box(), which isviewport-relative, while a synthetic drag makes Chromium autoscroll. Correcting
by
window.scrollhandles exactly one of the ways a page scrolls. Measured onpages with no script at all, where the truth is always "did not move":
overflow:autopaneposition:fixedsourceThe middle row is the ordinary kanban layout:
window.scrollYis 0 throughoutwhile 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
indeterminatecarryingan effect named
page_unchanged_by_the_drag. Removal is read by counting nodes.browser.waitcalled every satisfied wait an observation, but two of itsfour states are true of a selector matching nothing. A misspelled selector
satisfied a
hiddenwait 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
hiddenaccepts either side of the wait: a page thatREMOVES the nodes is as honest a way to satisfy it as hiding them, and reading
only afterwards marked that correct run unknowable.
detachedaccepts thebefore-count only, because a selector matching nothing satisfies it immediately
and a node appearing later would read as a detachment nobody watched.
derivesmeans not on the ladderSeven modules were stamped
dispatchedfor an instruction that never left.Three had declared
derives=Trueand were overruled by the categoryheuristic; four
ai.*sub-nodes are config providers wired tollm.agentovera RESOURCE edge.
derivesnow outranks the category, and the VERIFIED branch isdeleted outright — it contradicted
ceiling_for, which defines VERIFIED as apostcondition having been evaluated. The change can only ever lower a claim.
ai.modelandai.memory.redisdeliberately keep the default.ai.modelwasgiven the flag on "opens no sockets" and an audit hook caught it resolving the
host whenever
base_urlis set, then returningok: Falsewhen the name doesnot resolve. A rule keyed on
NodeType.AI_SUB_NODEwould have silenced both.Guards
DERIVES_DECLAREDnames all seven; a test walks each one's syntax tree fornames 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".
Outcome.VERIFIEDmust declare apostcondition, 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
mainat8c7b427(packaging, two live-URL visual-diff tests,version metadata) and are pre-existing.
Also fixed on the way:
TestWaitAgainstARealPagewas missing@pytest.mark.browser, so six real-Chromium tests were collected by the CI jobthat exists to exclude them;
test_no_password_reaches_the_envelopecould notfail, because a password that fills successfully leaves the name-carrying lists
empty; and the documentation gate, which passes on
main, failed here because47 files moved without regenerating the reference pages.
🤖 Generated with Claude Code