Observation truncation and assertion soundness

September 6, 2026 · View on GitHub

Every observation is budget-limited. QaObservation.truncated is true when the driver returned fewer nodes than the page/app actually exposes (the browser driver defaults to 60 nodes and clamps a request to 100; the computer driver defaults to 200 and clamps to 500, and both also enforce a byte ceiling).

A truncated view is incomplete, not empty. That single fact decides what an assertion may conclude from it.

node-absent means "no driver-OBSERVABLE semantic node"

The driver's projection is of observable semantic nodes: elements hidden by visibility:hidden, display:none, opacity:0, or zero client rects are skipped by the visibility gate and are NOT returned and NOT counted. So node-absent never claims "the element is not in the DOM" — it can only claim "no OBSERVABLE node matched". A control that exists but is momentarily hidden (fade-in, content-visibility, a collapsed panel) is invisible to every absence claim by design, and a reader must not treat an absence pass as a DOM-level fact.

Absence passes require VERIFIED coverage (QA-BL-052 -> C2, contract v9)

Even a complete observation — scoped or whole-page — cannot prove an absence by itself. The driver's projection has boundaries: closed shadow roots inside the subtree are neither pierced nor counted by the in-page walk, and slot assignment may be unresolved, so a view that reports truncated: false can silently miss whole subtrees of semantic nodes. The audit (zcode REPORT.md, F1/F2) showed the old "a complete scoped view proves absence" claim was unsound exactly this way.

Since contract v9 Phase C the terminal absence decision requests the driver's bounded CDP coverage probe on its ONE deciding re-observation — the budget escalation already taken for a truncated view, or the single bounded re-read a complete-but-unverified view triggers. The probe runs over the observed subtree (the within root's subtree, or the whole document) under hard caps (5,000 DOM nodes, 250 ms) and reports per-observation coverage: { verified, closedShadowRoots, probedNodes, reason? }. It NEVER runs on ordinary settle polls — only that one terminal re-read carries verifyCoverage, and the session core applies the probe exactly once for the whole decision (the settle window polls unprobed, then one probed deciding read).

node-absent PASSES iff nothing matched AND truncated: false AND coverage.verified === true — with the Codex-consult wording on the completeness line: "No driver-observable semantic node matching {predicate} was found within {scope|the whole page}; coverage verified (N nodes probed). K hidden candidates excluded." (K is hiddenMatches, a diagnostic of the visibility gate, marked "(lower bound)" when collection stopped early).

  • A probe that found closed shadow roots arrives as a TRUNCATED view (the driver pushes the closed-shadow-root reason): the absence stays INCONCLUSIVE_TRUNCATED, with the reason in completeness.detail and truncationReasons.
  • A probe that did not run to completion (over-budget, cdp-unavailable, root-unresolved, error) pushes shadow-coverage-unverified: same fail-closed outcome, reason named in both places.
  • A COMPLETE deciding view whose coverage is unverified (reason skipped — no probe requested, or no coverage field at all) fails closed with COVERAGE_UNVERIFIED, naming the driver's coverage reason in the detail.
  • A returned matching node still fails node-absent normally.

The gate is per-observation evidence, never a driver version. The probe's cost is the reason it stays off the polls: ~5 ms scoped, and it may be over-budget whole-page on very large pages — and then the absence is UNPROVEN, exactly as reported.

The computer adapter IGNORES verifyCoverage explicitly and reports coverage vacuously verified: the accessibility tree has no shadow-DOM boundary, so nothing like a closed root can hide nodes from it, and the tree's own truncated flag is the completeness gate.

The rule

claimtruncated viewwhy
a node WAS returnedsoundthe driver returned it, so it exists
node-absent and nothing matchedunprovablethe node may exist outside the window
node-present / node-in-viewport and nothing matchedunprovablesame reason, opposite direction
node-value and EXACTLY ONE unflagged node with the expected value was returnedsoundthe node and its value were both reported
node-value and nothing matchedunprovablethe node (and its value) may exist outside the window
node-value and several nodes match, or the match is flaggedrefused, not a passa twin holding the value proves nothing about the recorded target (TARGET_NOT_UNIQUE); a valueWithheld/secure/valueTruncated node can never satisfy it (VALUE_WITHHELD / VALUE_SECURE / VALUE_TRUNCATED) — both fail closed with their code, in a complete or truncated view alike
page-urlsoundthe URL is carried by every observation

Evidence of presence is sound; absence of evidence is not evidence of absence.

What the code does

  1. node-absent can never pass on a truncated observation. evaluateAssertion (src/replay/assertions.ts) fails it closed, and the result is marked inconclusive rather than silently converted into an ordinary "not present". It can also never pass on a COMPLETE observation whose boundaries the driver has not verified (coverage.verified !== true): that outcome fails closed with COVERAGE_UNVERIFIED and a completeness block explaining that the absence is UNPROVEN — see the section above. The terminal absence decision takes exactly ONE bounded deciding re-observation requesting verifyCoverage (the escalation for a truncated view, or the single coverage re-read for a complete-but-unverified one).

  2. One bounded budget escalation before concluding. When an outcome would be decided against a truncated view — any absent-claim, or a present / in-viewport / value claim that found nothing — decideAssertion re-observes ONCE at QA_ESCALATED_NODE_BUDGET (500, clamped by each driver to its own maximum) and decides against that fuller view. It never loops, never escalates twice, and a present-claim that already found its match never escalates at all. The escalated observation is SETTLED like every other proof observation; an unsettled one is refused and the decision falls back, still fail-closed.

  3. Still truncated ⇒ fail closed with a distinct reason. The result carries completeness.reason: "INCONCLUSIVE_TRUNCATED" and a detail naming the budget, and the runner's failure message says so instead of "assertion node-absent failed".

  4. The report explains itself. Whenever truncation touched a decision, the step / assertion result carries completeness (truncated, nodeBudget, escalated, outcomeDependsOnCompleteView, reason, detail) into report.json, report.jsonl, and a "view completeness" line in report.md. A human triaging a failure can tell "not present" from "we could not see the whole page". The field is additive: schemaVersion stays 1, and a result decided against a complete view is unchanged.

  5. The same blind spot elsewhere.

    • The replay runner resolves an action target the same way: a target missing from a truncated view triggers the same single escalation, and the failure message names INCONCLUSIVE_TRUNCATED instead of claiming the target is not on the page. This is the live scroll case: after a scroll the target was absent from the 60-node view and present at 100. A target whose predicate matches SEVERAL nodes never escalates (a fuller view can only add more matches): it fails closed with TARGET_NOT_UNIQUE — acting on the first match would be a guess.
    • The Explore exporter cannot re-observe a recorded trajectory, so when a proof observation was truncated it records the weakness in the step intent ("Weak proof: the proof observation was truncated at the driver node budget …") exactly like the existing distant-delta weakness — but ONLY when truncation can actually weaken the assertion: a delta-derived node-present / node-in-viewport (an "apparently new" node may have been there all along, outside before's window). A page-url (the URL travels on every observation) and a node-value on a FOUND target (a returned node really carries the reported value, per the rule table above) are sound even on a truncated view, so no weakness note is attached to them.
    • An action ref missing from a truncated preceding observation is excluded with a detail that says the target may have fallen outside the window.
  6. A positive-existence "not found" is retried, bounded by the settle budget. decideAssertionWithRetry (src/replay/assertions.ts) re-observes a node-present / node-value / node-in-viewport / page-url that was first decided "not found" (including the INCONCLUSIVE_TRUNCATED branch taken after the single escalation): a slow page's late node or role switch is not absence. Each re-observation is SETTLED at QA_ESCALATED_NODE_BUDGET; the loop stops as soon as the assertion is found (a returned node is sound on any view) or the budget is exhausted, and the step / assertion result records attempts and elapsedMs (rendered in report.md, excluded from the determinism projection as duration, not outcome). When the retry exhausts the budget without finding its target AND the session's widen gate has not fired AND adaptiveBudgetMs > budgetMs, it widens ONCE through the SAME once-per-session gate as the unstable settle path (recorded with cause: "assertion-retry") and keeps retrying until adaptiveBudgetMs measured from the retry's original start — so a page that settles FAST but renders the node slowly is found instead of failing when the budget is below page latency. node-absent is NEVER retried — absence is never proven by waiting, only by having seen the whole view — and a structural refusal (TARGET_NOT_UNIQUE, VALUE_WITHHELD / VALUE_SECURE / VALUE_TRUNCATED) is deterministic and never resolves by waiting either.

Scoped observation changes what truncation means (browser driver contract v8)

Since contract v8 the browser driver can root an observation at ONE element (observe(owner, { within })); dsh-qa exposes it as qa_observe within_ref (QaObserveOptions.withinRef). The ref comes from the caller's CURRENT (latest, unexpired) observation and is resolved exactly like an action ref — an unknown, expired, consumed, non-element, or detached ref REFUSES the call (REF_INVALID / REF_UNKNOWN / OBSERVATION_REQUIRED / REF_EXPIRED / PAGE_CHANGED / TARGET_UNBINDABLE / TARGET_CHANGED / WITHIN_NOT_ELEMENT), never falling back to a whole-page view.

Inside a scoped observation, maxNodes, the 48 KiB byte ceiling, the scan window, and the iframe marker are all subtree-relative. A subtree that fits reports truncated: false with NO truncationReasons. That is the whole point: the browser clamp stays at 100 nodes (the byte ceiling caps emission at ~221 anyway), so scoping — not a bigger budget — is how a deep target is reached (an absence stays UNPROVEN until coverage is verified, see above). QaObservation.scope ({ ref, rootRef, role, name, tag }) echoes the root the driver observed and is absent for whole-page observations; rootRef (contract v9) is the fresh per-observation ref that chains the next scoped read — it binds the root even when the visibility gate excluded it from nodes, and a driver that mints no rootRef makes the QA layer fail the chained read closed instead of re-matching by role+name+tag. node inViewport keeps whole-page viewport-intersection meaning.

What it changes for the soundness rules above:

  • node-absent with nothing matched passes on a COMPLETE scoped view only with verified coverage (C2): closed shadow roots inside the container and unresolved slot assignment are unverified by the in-page walk, so the deciding re-observation requests the bounded coverage probe over the SUBTREE. coverage.verified: true + truncated: false is the pass; a probe that found roots or did not complete keeps it INCONCLUSIVE_TRUNCATED naming closed-shadow-root / shadow-coverage-unverified — exactly like whole-page absence claims.

  • A scoped view that is STILL truncated escalates within the same scope (one bounded re-read at QA_ESCALATED_NODE_BUDGET carrying the same withinRef), never by widening to the whole page; a still-truncated scoped view fails closed with INCONCLUSIVE_TRUNCATED exactly like an unscoped one. Unscoped truncated views keep their exact escalation and INCONCLUSIVE_TRUNCATED semantics.

  • completeness now names the scope whenever the deciding view was scoped (additive scope: { role, name }), and a scoped deciding view ALWAYS reports completeness — complete or truncated — so "absent from this container" can never be read as "absent from the whole page". Absence decisions also carry the deciding view's coverage evidence and the gate diagnostics hiddenMatches/hiddenMatchesPartial (contract v9): report.md names them as "hidden semantic-selector candidates excluded: N (lower bound)", and a proven absence prints the Codex-consult PASS wording with "coverage verified (N nodes probed)". The detail string and report.md say which container the outcome was decided inside.

  • Replay: a scenario assertion may carry scope: { role, name, tag?, path? } (loader-validated fail-closed; the name may be empty; path is the container's semantic ancestor chain, outermost first). A path is a DISCRIMINATOR, never a proof: it can only ever LOCATE the container; uniqueness is proven per level at replay time, and PASS stays reserved for proven resolution. QA-BL-069 path durability: a path item may OMIT its name — export omits it exactly for a CONTENT-NAMED container role (search/region/list/listbox/group/navigation/main/form/table/menu — the same rule as FRAGILE_PROOF_ONLY) whose aggregated accessible name concatenates its children's text and exceeds 80 chars (it changes with collapse state / render timing), or for an EMPTY name; such an item records { role, tag } only, and replay compares the name only when it was recorded.

    QA-BL-069 top-down path WALK: the outermost ancestor is matched in the whole-page view (with today's ONE bounded budget escalation), observed within (settled), the next path item matched inside that scoped view (one bounded WITHIN-SCOPE escalation when the parent subtree exceeds the default window, re-chained through the driver's fresh scope.rootRef), and so on, until the container itself is matched inside its last ancestor's view; the container's own settled scoped view is then the deciding view. Uniqueness is judged at EACH level inside its PARENT's view — the classification table:

    level evidenceclassification
    exactly 1 match in a COMPLETE parent viewproven at that level
    exactly 1 match in a still-truncated parent viewprovisional at that level
    ≥ 2 matchesTARGET_NOT_UNIQUE — a KNOWN twin is never guessed (definite failure)
    zero matches in a still-truncated parent viewINCONCLUSIVE_TRUNCATED — the step is inconclusive, never a failure
    zero matches in a COMPLETE parent viewdefinite failure — the ancestor/container is not on the page

    The overall resolution is proven only when EVERY level was proven — on a

100-node page the whole-page top level never completes, so the result is provisional, exactly as Codex consult #2 decision (b) requires. Without a recorded path the flat resolution stays (QA-BL-054/062): ONE match in a still-truncated whole-page view resolves PROVISIONALLY for the scoped SCROLL-proof step (B3), and refuses with INCONCLUSIVE_TRUNCATED naming the scope for every other scope. A PROVISIONAL resolution means the step carries scopeResolution: 'provisional' and reason: INCONCLUSIVE_SCOPE — never a pass — and the run aggregates to the three-state inconclusive when nothing definitely failed. QA-BL-069 (C) classification: ZERO matches in a still-truncated view AT ANY LEVEL (flat included) is NOT a definite failure — the step carries reason: INCONCLUSIVE_TRUNCATED, scopeNotLocated: true, assertionPassed: false, the run status inconclusive (never fail), and report.md says "the container could not be located in the truncated view; it may exist outside the returned window"; the scopeLevels field and report.md name EACH level's resolution (outermost ancestor first, the container last). Definite failures (zero in a complete view, TARGET_NOT_UNIQUE) remain fail. The replayed scrolled proof is still executed; its verifying scoped read requests anchorLastAction + verifyCoverage, and the decision requires the identity anchor to be connected, contained, bound to the SAME node ref as the asserted target, and in the viewport — a lost binding or mismatch is disclosed as scopeRefusal (escalationRefused-style) on the step, never a predicate reselect. Target predicate matches are counted BEFORE the inViewport filter inside the scope: ≥2 → TARGET_NOT_UNIQUE; exactly 1 in a truncated or coverage-unverified subtree stays provisional; exactly 1 in a complete verified subtree MAY be proven (given a proven container resolution). Driver refusals on the scoped read surface as themselves (their code rides into failure.code), never as a "not found". After a scoped decision the runner refreshes the whole-page view (settled, fail-closed on an unstable refresh) because the scoped read consumed the observation the container was resolved from.

  • Export records the scope when the explorer used one AND the scope is PROVEN durable (QA-BL-054, amended by QA-BL-062/069). WITH a recorded ancestor path, durability is judged PER LEVEL from the recorded evidence — the same top-down walk replay performs: level 1 in a whole-page recorded view, each next level inside a recorded observation scoped within its parent item, and the container inside the first recorded view that contained it as a NON-root node (its own subtree never counts — the scoped-complete-baseline trick stays closed). A container unique inside its ancestor's COMPLETE scoped view is durable at that level; the export is PROVISIONAL exactly where a level stays unproven (on a >100-node page the whole-page top level always is) — the scope (with the path) is still EXPORTED, so an explicit scoped assertion whose whole-page baseline was truncated is no longer excluded with SCOPE_NOT_DURABLE merely for that; an AMBIGUOUS or definitely-absent level IS excluded, never guessed. WITHOUT a path the flat rule applies: the predicate (role+name, plus tag when needed to disambiguate) must match EXACTLY ONE node in the recorded BASELINE observation (the action's pre-action view), that baseline must be COMPLETE (truncated: false), and must NOT be the container's OWN scoped subtree. path is the container's ancestor chain from an observation that contained it as a NON-root node, recorded when available — never manufactured from a scoped root's parentRef: null. An empty accessible NAME is a legitimate predicate value and is kept literally (name: ''). QA-BL-062: when uniqueness is UNPROVEN and the recorded action is an identity-anchored scroll proof (its proof observation carries the driver's truthful identity anchor — the acceptance marker hub-107's re-bind produced), the scope is exported explicitly PROVISIONAL (the step intent names the weakness) instead of being excluded; every other unproven scope EXCLUDES the step with SCOPE_NOT_DURABLE — a scoped proof is never silently exported as if it were a whole-page proof. page-url is never scoped (the URL travels on every observation). When the action's PRECEDING observation was scoped, the step intent records that the target's whole-page uniqueness was not verified at export.
  • The computer driver has no scoping: a withinRef there is REFUSED with a clear error, never silently ignored.

Record-time scroll-proof escalation (Explore recording only)

Explore cannot re-observe a recorded trajectory, so the recording session takes the ONE bounded escalation at RECORD time instead: a browser scroll-by-ref whose settled post-action proof observation is truncated AND still lacks the action target in the viewport gets one more SETTLED read at QA_ESCALATED_NODE_BUDGET.

Scoped-baseline proof read (QA-BL-067). When the acted ref came from a SCOPED baseline (the observation the ref belongs to carries scope), the action's PROOF settle itself is taken INSIDE that scope: observeSettled({ withinRef: <baseline scope.rootRef>, anchorLastAction: true }) with the same settle options — the settle loop re-keys the within ref per poll to the driver's fresh scope.rootRef. The acceptance is decided by the driver's identity anchor, never by matching role/name/tag: the scoped window settled AND anchor.connected && anchor.contained === true && anchor.ref !== null AND the anchored node has inViewport: true. On acceptance the result carries proofScope: { role, name } plus anchor (NO escalation happened, so there is deliberately no proofEscalated), the recorder binds the scoped observation as the action's proof through the ordinary settle binding, and export carries the scope through the unchanged withProofScope path (QA-BL-054/062: scope.path recorded from record-time ancestry, PROVISIONAL at replay on pages that can never complete). VERIFIED against the real driver (contract v9, dsh-browser d069f4f): a dispatched action that consumed a SCOPED observation and did NOT navigate RETAINS that observation's scope root until the next successful observe, so a scroll from a scoped view is PROVEN inside that scope. The FIRST poll is keyed by the EXPLICIT baseline scope.rootRef — deliberately not the 'last-scope' alias, which would silently rebind a stale baseline to whatever root was last acted and measure the anchor's containment against the WRONG container (a false scoped proof); the explicit rootRef is refused instead. That poll resolves through the retained handle and CONSUMES the retention, minting a fresh rootRef in its observation; the settle loop re-keys every later poll to that fresh rootRef (scopedRootRefOf), riding the driver's transition from the retained root to a live observation. When the driver refuses the root, the refusal is DISCLOSED with the driver's code and the proof falls back to today's whole-page read: OBSERVATION_REQUIRED when nothing was retained (a pre-retention driver, a plain node ref from the consumed observation, or the acted ref IS the scope root itself — the driver deliberately creates no retention for that single-owner handle, while the anchor still works), and SCOPE_UNAVAILABLE when a retained root was released (navigation, dispose, or an intervening observe).

Scoped escalation RE-ENABLED via the identity anchor (B3, contract v9). The QA-BL-050 container-heuristic is still RETIRED: the audit (F4) showed the nearest-container heuristic can pick a NON-ancestor (the driver exposed no ancestry), and a same-identity twin inside the wrong container can then satisfy the proof — matching by role+name+tag across observations is not identity. Its replacement picks the container by ancestry: the target's parentRef chain in the BASELINE observation, up to the first container- role node (region/main/navigation/list/table/form/group/complementary/ article/section). The container is re-keyed into the settled view by its unique role+name+tag match, and the ONE escalated read is observe({ withinRef, anchorLastAction: true, maxNodes: QA_ESCALATED_NODE_BUDGET }) — accepted ONLY when the window settled AND the driver's identity anchor reports the ORIGINAL acted element connected, contained in the within subtree, emitted with a fresh ref, and that anchored node in the viewport. Identity comes from the anchor, never from matching role/name/tag. Refusals (ANCHOR_UNAVAILABLE, connected:false, contained:false, a null anchor ref) are disclosed as escalationRefused and the proof stays the settled observation. No container on the chain, or an un-re-keyable container (zero/twin matches in the settled view), falls back to the whole-page read:

Whole-page acceptance rule (unchanged). The escalated view must EXTEND the settled one (same page URL/title and every settled node unchanged at the front in the same order — the page is still the exact state the settle window proved), the window must have settled (stable), and the target must be returned with inViewport: true. The scoped form drops the prefix-extension check (it is meaningless across scopes) — the anchor is the stronger replacement.

The recorded proof observation keeps its own honest truncated flag and its scope, and export carries the scope onto the synthesized assertion (withProofScope), so a scoped proof is never exported as a whole-page proof. Replay resolves a scoped step's action target INSIDE the same container (settled, with one in-scope escalation when the scoped view is still truncated), so such scenarios replay even though the target is unreachable whole-page.

Anything else — an unsettled window, a page that changed between the reads, or a fuller view that still does not place the target in the viewport — keeps the settled observation (fail closed). The escalation is at most ONCE per action, never loops, and is browser-only and recording-only (replay and plain adapters never escalate). It is SIDE-EFFECT-FREE: the escalated read never widens the settle budget, never flips the once-per-session widen gate, never re-persists the session policy, and never replaces the session baseline — the next action still compares against the action's own settled observation. An accepted escalation is visible on the qa_act result as proofEscalated: true plus the escalated window's report under escalatedSettle (the action's own window stays under settle).

Every non-acceptance exit is disclosed (QA-BL-058, completed by QA-BL-067). escalationRefused: { reason, code? } carries one of a FIXED vocabulary words — target-not-in-baseline (the acted ref is absent from the baseline), container-not-in-view (the scope root / container can no longer be re-keyed into any fresh view — including the post-action refusal of the baseline rootRef: OBSERVATION_REQUIRED when no scope root was retained and SCOPE_UNAVAILABLE when a retained root was released by navigation or an intervening observe), escalated-window-unstable (the escalated window never settled, or the escalated view does not stably extend the settled one), target-not-returned / target-not-in-viewport (the escalated view lacks the target / returned it off-viewport), anchor-not-connected, anchor-not-contained, anchor-unavailable (the identity anchor's truth) — plus the driver's machine code when the driver THREW. already-in-viewport is deliberately NOT a refusal: no escalation is needed, and the result then simply has no escalation fields. When both the scoped proof read and the escalation were refused, the escalation's refusal is the one disclosed (the more terminal truth); an accepted escalation supersedes the earlier refusal. The refusal rides on the qa_act result and, through the recording adapter, on the exported scenario step (additive escalationRefused), so report.md prints it on the step line. The proof stays the settled observation either way (fail closed). The recorder re-binds the proof by the EXACT recorded action id carried on the receipt: a null or non-matching id (e.g. a concurrent act settled in between) is refused and recorded as a recording issue, never silently re-bound to the wrong action.

Role drift on real pages: server-rendered → hydrated (QA-BL-039 / QA-BL-064)

A control's role is not stable over time on a real page. The canonical case is Wikipedia's search box: the server renders a plain <input> (textbox), and once Vector's Vue typeahead module mounts it replaces the control with a same-named component carrying role="combobox". The owner's release blocker (QA-BL-064) pressed Enter while the combobox role was live; a fast replay loads the page quickly enough that step 2 still observes the server-rendered textbox — the node is present in every observation under a drifted role, not outside the observation window. Pin the LIVE role and a fast replay fails with a misleading "no observable node matches"; pin only the name and the same target resolves on both sides of the switch.

The name-only rule therefore applies to BOTH selector families:

  • Assertions (QA-BL-039). The exporter synthesizes a drifted node-value assertion on the name-only predicate ({ name, value }, role omitted) when the target's role changed during the action and the accessible name is unique among all nodes in the settled view; role+name is kept only when the name alone is ambiguous. The same discriminator logic drives every proof predicate on a role-drifted node.
  • Action targets (QA-BL-064). The exporter writes the action target NAME-only whenever the accessible name is non-empty and unique in the recorded BASELINE view, keeping the live role only as an advisory roleHint (additive schema; the loader accepts it and replay IGNORES it for matching). A name that is empty or not unique keeps the role+name form and its TARGET_NOT_UNIQUE exclusion.
  • Replay fallback. When a recorded role+name action target has ZERO matches in the DECIDING view (after the existing one escalated read when that view is truncated), the runner falls back to a NAME-only match iff exactly one node in that view carries the same non-empty name, and discloses targetResolution: { mode: "name-only", recordedRole, observedRole } on the step result and in report.md. The fallback is a refusal, never a guess, when the name is empty or matches two or more nodes: two or more same-named nodes fail closed with TARGET_NOT_UNIQUE and the wording "the action target is present under a different role: recorded X, observed Y — N nodes carry the accessible name, so the name-only fallback was refused rather than guessed". A zero-match failure otherwise distinguishes the other two cases honestly: "the target is absent from the returned window" (the view was still truncated — INCONCLUSIVE_TRUNCATED, the target may exist outside it) from "the target is absent from a complete view" (the absence is proven). Existing scenario files with role+name targets keep replaying through the same fallback.
  • Dispatch-time drift. The same hydration swap can land BETWEEN target resolution and dispatch: the driver then REFUSES the action with TARGET_CHANGED (identity staleness — the page replaced the bound element mid-flight, and NOTHING was dispatched). Only that one code is retried, and the retry is BOUNDED, never one-shot: a fresh settled observation, a re-resolution of the SAME semantic target, and a re-dispatch, repeated WITHIN the session settle budget until the resolve->dispatch pair lands (QA-BL-070). The budget is re-read every iteration and widened ONCE through the shared once-per-session gate when the retry exhausts it — the same bounded-retry/adaptive-widening machinery positive-existence assertions use (decideAssertionWithRetry / session.widenForRetry, cause assertion-retry). The step discloses targetChangedRetries: N (the refusal count) in report.json/report.md. Every other rejection (a safety/policy refusal) stays a hard stop with ZERO retries — as do TARGET_NOT_UNIQUE and a target absent from a COMPLETE view. When the budget is exhausted with the target still changing identity, the step is inconclusive with reason INCONCLUSIVE_UNSTABLE and assertionPassed: false (the run aggregates to inconclusive, NEVER fail): "the target kept changing identity between resolution and dispatch for the whole settle budget (N retries): the page did not hold still, so the step is unproven" — and the driver's last refusal receipt (its verbatim reason plus any additive fields such as changed) rides on the step so triage can see WHAT changed.
  • Walk-time drift (QA-BL-073). The SAME TARGET_CHANGED refusal can be thrown by a within read during the scoped path walk (a level's ancestor changed identity between its parent read and the scoped read — the same churn as at dispatch, on the walk instead; the pre-677cdc2 Wikipedia sidebar story was one cause, and a label-named ancestor whose label flips is still possible). The walk shares the ONE bounded retry the dispatch uses (runBoundedIdentityRetry: same budget re-read every iteration, same once-per-session widening, same targetChangedRetries counter — dispatch and walk refusals count together, because the step is classified once and both refusals are the same churn at two uses of the same bound element). Only TARGET_CHANGED is ever retried: REF_UNKNOWN / OBSERVATION_REQUIRED / SCOPE_UNAVAILABLE and every policy refusal stay hard failures with their own code. A refusal re-resolves the level from the level ABOVE with a fresh settled read (the whole page for level 1, the parent's fresh scope.rootRef otherwise) and repeats until the level resolves or the budget is exhausted. Exhaustion classifies the step inconclusive with reason INCONCLUSIVE_UNSTABLE and assertionPassed: false, the message names the level — "path level N () kept changing identity for the whole settle budget (N retries): the page did not hold still, so the step is unproven" — the driver's changed/before/after ride verbatim on scopeIdentityRefusal, and the run aggregates to inconclusive, never fail. Separately (driver 677cdc2), a CONTENT-named container whose aggregated accessible name changed between reads is never a refusal at all: the driver resolves informationally with scope.nameChanged: true (accumulated across the settle window's polls), and the step records scopeNameChanged: true — informational, never a classification input.

Budget

QA_ESCALATED_NODE_BUDGET is a named constant in src/replay/assertions.ts. Raising it is a deliberate, bounded change; escalation stays single-shot because an unbounded retry loop would turn an honest "I cannot see the whole page" into an expensive one.