Skip to content

fix(android): stamp status-bar chrome during the walk instead of reconstructing it downstream (#1319)#1359

Merged
thymikee merged 9 commits into
mainfrom
claude/agent-device-1319-5574eb
Jul 22, 2026
Merged

fix(android): stamp status-bar chrome during the walk instead of reconstructing it downstream (#1319)#1359
thymikee merged 9 commits into
mainfrom
claude/agent-device-1319-5574eb

Conversation

@thymikee

@thymikee thymikee commented Jul 21, 2026

Copy link
Copy Markdown
Member

Closes #1319.

This PR changes production behavior. It began as a test-only answer to #1319 and became a fix once the test exposed that Android chrome classification changed with capture shape.

The defect

The same expanded Quick Settings shade was classified in opposite ways:

Root cause: shouldIncludeStructuralAndroidNode drops the status_bar* / navigation_bar* containers—the nodes that identify those regions—and reparents their leaves beside real content. Downstream code then tried to reconstruct identity after the tree relationship had been discarded, and the answer depended on the capture shape.

The fix

Record provenance while the relationship still exists. walkUiHierarchyNode threads ancestorSystemChrome alongside the existing ancestorHittable, and the streaming androidUiNodes recovery path tracks the same subtree. Classification is now intrinsic and per-node, so every capture shape agrees by construction.

That removes the obsolete classifier machinery:

  • 18 hand-picked marker leaf ids and their justification comment;
  • collectAndroidSystemChromeRunIndexes, where one clock node could condemn 95 unrelated nodes.

The narrowly gated #1318 fallback remains as insurance for unverified older Android/OEM hierarchy shapes. It activates only when classification would otherwise leave no meaningful refs, and still admits hittable nodes only. The verified Pixel hierarchy does not need it.

The shared container predicate matches status_bar / navigation_bar as complete id segments, including split_shade_status_bar without false positives such as status_barometer.

systemChrome is Android-internal provenance, not public RawSnapshotNode API. It remains available in daemon session snapshots for settle/diff/replay and is stripped at the two public node publication seams: snapshot trees and selector read payloads (get / read-only find). The provider harness rejects systemChrome at the shared request-handler output before responses fan out to either direct RPC or the SDK transport, so a future response path cannot silently expose it. The CLI, Node SDK, and MCP inherit the same clean daemon contract without client-side defenses.

Alternatives measured and rejected

  • AOSP window types: live systemui reported window-type=3 (TYPE_SYSTEM) both collapsed and expanded; TYPE_STATUS_BAR never appeared. One window contained status icons and tiles, so window type cannot distinguish them.
  • Subtree condemnation after walking: shape-independent, but leaked reparented chrome such as Battery 100 percent. and the notification-icon summary—the ticking status content --settle may be blind to a fully expanded quick-settings shade (same run-condemnation rule) #1319 is about.

Live device evidence (7cd3ab91f classifier implementation)

Pixel 9 Pro XL API 37 emulator, Google Clock, Android helper 0.19.3. The follow-up commits change only daemon publication, types, tests, and the pre-existing replay safety fallback; they do not change the device-side classifier exercised here.

route result
--settle, shade raised mid-settle settled after 6255 ms over 17 captures; brightness + Wi-Fi/Bluetooth/Mobile data/Quick Share/Modes/Wallet retained; no status clock/date/carrier/signal lines
ordinary action, no shade (control) +1 -1; no SystemUI content surfaced
replay divergence, full-cover shade state: available, 20 refs at the cap, starting with Display brightness; actionable tiles retained; no status chrome
content recovery systemSurfaceOnly still disclosed; helper backend remained android-helper
daemon response session retained provenance while response JSON contained no systemChrome
Node SDK 95 shade nodes, brightness present, no provenance leak
MCP structured content 95 shade nodes, brightness present, no provenance leak

The manual session was closed after verification.

Test and validation changes

Fixtures now model the device tree: the synthetic status bar has the real status_bar_launch_animation_container, and content-recovery XML nests chrome leaves below their container.

The regression suite now asserts:

  • raw/non-raw and interactive-only walks classify the Quick Settings shade identically;
  • collapsed status/nav-bar leaves remain chrome after their structural container is dropped;
  • the operational/ref-frame tree retains internal provenance while daemon output omits it;
  • a provider-backed full request-route regression proves snapshot, get attrs @ref, and find … get attrs omit provenance;
  • the provider harness rejects provenance across both direct RPC and SDK-backed scenario channels;
  • replay divergence returns Quick Settings tiles both with precise classification and under a simulated whole-tree false positive, while non-hittable status residue stays excluded;
  • the container matcher uses complete id segments.

Revert-sensitivity was checked by disabling provenance classification: six focused tests failed, including collapsed status, navigation, Quick Settings parity, and replay divergence.

Local validation: format, lint, typecheck, layering, Fallow, build, Android helper build, focused tests, provider integration, and live Android verification. The complete provider scenario project passes with the global invariant active when serialized to avoid the repository's documented parallel host-load contention: 37 files, 141 tests. The Android lifecycle provider file (12 tests) and replay-divergence suite (14 tests) also pass in focused runs.

Review blockers addressed

  • Public contract leak: provenance is no longer part of the exported snapshot node type and is removed from snapshot, get, and find node payloads. A provider-wide serialized-response guard makes this an enumerating contract.
  • Fallow complexity: createAndroidChromeSubtreeTracker owns the streaming depth/subtree state; no baseline or allowlist was added.
  • Older API/OEM uncertainty: retained the one-line hittable-only fix(replay): publish a full-cover system overlay's targets in divergence screen.refs #1318 safety fallback and added a behavioral false-positive regression; precise classification remains the normal path on the live-tested API 37 hierarchy.

… to --settle (#1319)

#1319 asked whether the systemui run-condemnation rule leaves `--settle` blind
to a fully expanded quick-settings shade, the way it blinded the replay
divergence in #1318. It does not, and this pins the reason.

The settle loop captures `interactiveOnly: true` (`stable-capture.ts`). That
walk drops the structural systemui window spine (`legacy_window_root`,
`notification_panel`, `qs_frame`, the quick-settings ComposeView chain) — and
those are exactly the nodes that merge the shade into ONE contiguous run in the
`--raw` / non-raw shape #1318 measured. Under settle's shape the same capture
arrives as five runs, only `split_shade_status_bar` carries a marker, and the
29 quick-settings nodes survive into both diff sides.

So settle and divergence do not read one tree differently, as #1318 framed it:
they consume different capture shapes, and settle already gets the outcome that
layer wants — shade content diffs, status-bar churn does not.

Separately, the quiet-detection loop digests the UNFILTERED capture
(`digestSnapshotNodes` in `stable-capture.ts`), so the shade would reset the
quiet window even in the hypothetical where chrome stripping had emptied the
diff. Both halves of the question come out clean.

No product change. The behavior is correct but rested on an untested structural
coincidence: retaining the systemui spine in the interactive walk would re-merge
the runs and make `--settle` report a full-cover shade as bare removals with no
added content and no hint. The test fails if that happens (verified by flipping
the helper to `interactiveOnly: false`).

Verified live on emulator-5554 (Pixel 9 Pro XL API 37, deskclock), both
directions #1319 asked about and both halves the test asserts:

  - shade OPENING mid-settle:  settled after 6917ms: +28 -25
    (added: brightness seekbar, Wi-Fi/Bluetooth/Mobile data/Quick Share/
    Modes/Wallet tiles)
  - shade CLOSING mid-settle:  settled after 6919ms: +25 -28  (mirror image)
  - the shade's own status bar ("Tue, Jul 21, Wifi signal full., T-Mobile") is
    absent from both settle diffs while `snapshot -i` of the identical screen
    still lists it — the run rule filters the churn rather than sitting inert.

The archived #1318 capture run through the real interactive-only walk reproduces
the live tree node-for-node (35 nodes, 29 kept, 6 stripped).
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.8 MB 1.8 MB +145 B
JS gzip 575.2 kB 575.3 kB +97 B
npm tarball 689.7 kB 689.7 kB +59 B
npm unpacked 2.4 MB 2.4 MB +145 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 24.7 ms 24.0 ms -0.8 ms
CLI --help 54.1 ms 55.5 ms +1.4 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js +323 B +142 B
dist/src/tv-remote.js -90 B -31 B
dist/src/selector-runtime.js -61 B +12 B

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 21, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Coordinator review: clean at d7c522f9.

The #1319 regression is non-vacuous: it drives the archived Quick Settings capture through production buildUiHierarchySnapshot({ interactiveOnly: true }) and the same withoutSettleChrome path used by settle. It proves actionable shade content survives while status-bar churn is stripped, and the non-raw contrast would fail if the structural distinction regressed. The test-only scope leaves #1318’s divergence behavior unchanged. Reported live Android evidence covers shade opening and closing and confirms both visible tile diffs and filtered status-bar churn.

All completed checks are green; iOS smoke is still in progress. No code findings.

…g is ruled out on device

Follow-up to the #1319 investigation, acting on review feedback that a
paragraph justifying fragile behavior means the code is wrong.

Two candidate fixes for the capture-shape-dependent chrome classification were
tested, and BOTH are dead. Recording that here so the next person does not
re-walk them:

1. The window-type approach this file's own TODO proposed. Measured against the
   live helper XML (emulator-5554, Pixel 9 Pro XL API 37): systemui reports
   `window-type=3` (TYPE_SYSTEM) both collapsed and expanded, `TYPE_STATUS_BAR`
   (2000) never appears, the helper stamps window metadata on window ROOTS only
   (1 of 169 nodes in an expanded-shade capture), and an expanded shade is ONE
   window hosting the status icons AND the quick-settings tiles. No window-level
   signal separates them. The TODO promised a fix the device data rules out.

2. Replacing run-condemnation with "condemn each marked node's subtree plus
   fully-condemned ancestors". Shape-independent as intended, and it keeps the
   tiles in both walks — but on the COLLAPSED status bar it leaks the unmarked
   chrome the walk re-parents next to the markers ("Battery 100 percent.", the
   notification-icon summary, neither carrying any resource-id). That is the
   ticking-clock regression #1319 explicitly warned against.

So run-condemnation is not a lazy workaround: it reconstructs identity the walk
already discarded when it drops the `status_bar*` wrappers and re-parents chrome
leaves next to content. That upstream information loss is the actual root cause,
and a provenance-preserving walk is the real fix — larger than this PR, and it
would let #1318's divergence fallback be revisited.

Also trims the #1319 test's doc comment from a long justification of the current
behavior down to the finding, the defect, and what the test holds in place.
@thymikee

Copy link
Copy Markdown
Member Author

Review feedback applied: the long comment was the smell

Pushed 4f004edd2. The rule "if you need a paragraph to justify why the workaround is OK, the code is wrong" applied to my own test comment — it explained at length why fragile behavior is fine instead of fixing it. So I went looking for the fix. Both candidates are ruled out by device data, and that turned out to be the more useful result.

1. The window-type approach — this repo's own TODO promises something impossible

contracts/android-system-chrome.ts said keying off TYPE_STATUS_BAR (2000) / TYPE_NAVIGATION_BAR (2019) would be the robust fix, "deferred until the id-based approach proves insufficient." #1318 and #1319 are that proof, so I measured it against the live helper XML (emulator-5554, Pixel 9 Pro XL API 37):

collapsed shade expanded
systemui window type 3 (TYPE_SYSTEM) 3 (TYPE_SYSTEM)
TYPE_STATUS_BAR (2000) present no no
nodes carrying window metadata 2 / 113 1 / 169 (window roots only)

TYPE_STATUS_BAR never appears at all, and an expanded shade is one window hosting the status icons and the quick-settings tiles — no window-level signal separates them. The TODO is now corrected in place rather than left to send the next person down it.

2. Subtree-condemnation instead of run-condemnation

The obvious fix: stop letting one clock node condemn 95 unrelated ones. Condemn each marked node's subtree plus ancestors whose descendants are all condemned. Shape-independent as intended, and it keeps the tiles under both walks — but it leaks on the collapsed status bar. After the walk, the marker leaves sit as siblings of unmarked chrome carrying no resource-id at all:

parent=1 id=com.android.systemui:id/clock   label="7:03"
parent=1 id=-                               label="Security & privacy notification: "
parent=1 id=com.android.systemui:id/wifi_signal
parent=7 id=-                               label="Battery 100 percent."

A subtree rule keeps the battery percentage and the notification-icon summary — churn on every capture. That is precisely the ticking-clock regression #1319 warned against.

What this means

Run-condemnation is not a lazy workaround. It is reconstructing identity the walk already discarded: shouldIncludeStructuralAndroidNode drops the status_bar* wrappers — the only nodes that identify the region — and re-parents chrome leaves next to content. That upstream information loss is the actual root cause, and it is why classification depends on capture shape at all (#1318 raw vs #1319 interactive-only).

The real fix is a provenance-preserving walk: stamp descendants of a status_bar*/navigation_bar* wrapper before dropping it, so classification becomes per-node and intrinsic. Run-condemnation then disappears, both shapes agree, and #1318's divergence fallback can be revisited. That is a genuine change across the walk, the shared classifier and #1318's fallback — deliberately not bolted onto a question-issue PR, and I'd rather it be scoped on its own.

Diff changes since first push

No behavior change; still test + comments only. Lint, typecheck, format and the touched test file all pass.

…nstructing it downstream

Chrome classification gave opposite answers about the same screen depending on
capture shape: an expanded quick-settings shade was 100% chrome under the
`--raw`/non-raw walk (#1318 — every tile condemned, needing a divergence-local
fallback) and ~17% chrome under the interactive-only walk (#1319). Two layers
were papering over one broken classifier.

Root cause: `shouldIncludeStructuralAndroidNode` drops the `status_bar*` /
`navigation_bar*` containers — the only nodes that identify the region — and
re-parents their leaves next to real content. Everything downstream was
reconstructing identity the walk had already discarded, and reconstruction is
what depended on shape.

Fix: record it while it still exists. `walkUiHierarchyNode` threads
`ancestorSystemChrome` exactly like the `ancestorHittable` it already carries,
and stamps `systemChrome` on the emitted node; `androidUiNodes` tracks the same
subtree for the streaming content-recovery pass. Classification is then per node
and intrinsic, so `--raw` and non-raw agree by construction.

This deletes what was compensating for the loss:

- the 18 hand-picked marker leaf ids and their justification comment (what
  enumerates them is "descendant of a status/nav-bar container" — now stamped);
- `collectAndroidSystemChromeRunIndexes`, the run-condemnation rule that let one
  `clock` node condemn 95 unrelated ones;
- the leaf-id/prefix split, replaced by one container predicate that matches
  `status_bar`/`navigation_bar` as an id SEGMENT so the shade's own
  `split_shade_status_bar` counts.

Net −53 lines across 8 files, almost all of it classifier and prose.

Two alternatives were measured and rejected before this one (both recorded in
#1319 so they are not re-attempted):

- keying off AOSP window types, which this file's own TODO proposed:
  impossible. On a live device systemui reports `window-type=3` (TYPE_SYSTEM)
  collapsed AND expanded, `TYPE_STATUS_BAR` never appears, metadata is stamped
  on window roots only (1 of 169 nodes), and an expanded shade is ONE window
  holding the status icons and the tiles.
- condemning marked subtrees plus fully-condemned ancestors: leaks the unmarked
  chrome the walk re-parents beside the markers ("Battery 100 percent.", the
  notification-icon summary) — the ticking-clock regression #1319 warned about.

Test fixtures that modelled the old mechanism now model the device instead: the
synthetic status bar gets the `status_bar_launch_animation_container` root the
real capture has, and the content-recovery XML nests its chrome leaves inside
their container. Assertions were not weakened — settle.test.ts stamps via the
production predicate, and the #1319 test now asserts both walks classify
IDENTICALLY, which is the property that was missing.

Verified live on emulator-5554 (Pixel 9 Pro XL API 37, deskclock):
  - shade opening mid-settle: settled after 7655ms: +28 -25, tiles present,
    zero status-bar chrome (unchanged from before — settle was already right)
  - ordinary action, no shade: +1 -1, no chrome leak
  - real captures: collapsed status bar 9/9 chrome; expanded shade keeps every
    tile under BOTH walks, which is the behavior that changed
@thymikee thymikee changed the title test(settle): pin that an expanded quick-settings shade stays visible to --settle (#1319) fix(android): stamp status-bar chrome during the walk instead of reconstructing it downstream (#1319) Jul 21, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Fixed the code instead

Pushed 8615771c6. Review feedback was that I kept adding comments rather than making the thing good — correct, so the PR now changes behavior instead of documenting it. Net −53 lines across 8 files, almost all classifier and prose.

The actual defect

Chrome classification gave opposite answers about the same screen depending on capture shape: an expanded shade was 100% chrome under the --raw/non-raw walk (#1318 — every tile condemned, needing a divergence-local fallback) and ~17% under the interactive-only walk (#1319). Two layers papering over one broken classifier.

Root cause: shouldIncludeStructuralAndroidNode drops the status_bar*/navigation_bar* containers — the only nodes that identify the region — and re-parents their leaves next to real content. Everything downstream was reconstructing identity the walk had already thrown away, and reconstruction is what depended on shape.

The fix: record it while it still exists

walkUiHierarchyNode threads ancestorSystemChrome exactly like the ancestorHittable it already carries, stamping systemChrome on the emitted node. androidUiNodes tracks the same subtree for the streaming content-recovery pass. Classification becomes per-node and intrinsic, so both walks agree by construction.

That let the compensating machinery go:

  • the 18 hand-picked marker leaf ids and their justification comment — what enumerates them is "descendant of a status/nav-bar container", which is now stamped;
  • collectAndroidSystemChromeRunIndexes, the run-condemnation rule that let one clock node condemn 95 unrelated ones;
  • the leaf-id/prefix split, replaced by one container predicate matching status_bar/navigation_bar as an id segment, so the shade's own split_shade_status_bar counts.

Two alternatives measured and rejected first

  • Keying off AOSP window types — what this file's own TODO proposed. Impossible: on a live device systemui reports window-type=3 (TYPE_SYSTEM) collapsed and expanded, TYPE_STATUS_BAR never appears, metadata is stamped on window roots only (1 of 169 nodes), and an expanded shade is one window holding the icons and the tiles.
  • Subtree condemnation (marked subtree + fully-condemned ancestors) — shape-independent, keeps the tiles, but leaks the unmarked chrome the walk re-parents beside the markers ("Battery 100 percent.", the notification-icon summary). That is the ticking-clock regression --settle may be blind to a fully expanded quick-settings shade (same run-condemnation rule) #1319 warned about.

On the test changes

Fixtures that modelled the old mechanism now model the device: the synthetic status bar gets the status_bar_launch_animation_container root the real capture has, and the content-recovery XML nests its chrome leaves inside their container.

Assertions were not weakened to fit. settle.test.ts stamps via the production predicate rather than hand-marking nodes, and the #1319 test now asserts both walks classify identically — the property that was missing all along, and strictly stronger than what it asserted before.

Verification

Real archived captures: collapsed status bar 9/9 chrome (including the previously-leaking unmarked "Battery 100 percent."); expanded shade keeps every tile under both walks — the behavior that changed.

Live on emulator-5554 (Pixel 9 Pro XL API 37, deskclock):

  • shade opening mid-settle: settled after 7655ms: +28 -25, tiles present, zero status-bar chrome — unchanged from before, since settle was already landing on the right side
  • ordinary action, no shade: +1 -1, no chrome leak

Full unit suite passes except the usual runtime-hints / Apple prepareSimulatorStatusBar 5s timeouts, which pass in isolation. Lint, typecheck, format, build, smoke 14/14 green.

Follow-up this opens

#1318's divergence-local fallback was added because the raw shape condemned everything. That premise is gone — it should now be reachable without the fallback, and worth checking whether it can be removed. Not touched here.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 21, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Re-review of new head 8615771c: changes requested.

  1. src/kernel/snapshot.ts adds systemChrome to the public RawSnapshotNode contract. serializeSnapshotResult returns result.nodes unchanged and output dedup preserves extra properties, so this internal classifier leaks into CLI JSON, Node, and MCP snapshot payloads and adds per-node bytes. Keep the provenance internal or strip it at every public boundary, and add a serialization regression proving public nodes omit it.

  2. Exact-head Fallow is a real code-quality failure, not infrastructure: androidUiNodes is cyclomatic 10 / cognitive 19. Extract focused traversal/state helpers and rerun pnpm check:fallow --base origin/main; do not baseline or allowlist it.

The PR body also still says ‘no product change’ and ‘test-only,’ but this head changes eight production files. Update the description and attach exact-head Android-helper evidence for the changed public routes: non-raw snapshot/replay selection must keep shade tiles while excluding status chrome, collapsed/no-shade controls must still exclude clock/battery/signal, content recovery must remain correct, and public JSON must contain no internal provenance field.

All other exact-head checks are green. ready-for-human was removed until these blockers are resolved.

…mplify androidUiNodes

Review blockers on 8615771:

1. `systemChrome` reached CLI/SDK/MCP payloads: `serializeSnapshotResult`
   passed `result.nodes` straight through, so an internal classification became
   a de-facto public contract and per-node bytes. Added `publicSnapshotNodes`
   next to the field it strips, mirroring the existing
   `publicSnapshotCaptureAnnotations` projection, and applied it at that
   boundary (the single serializer both the CLI and capture output route
   through). The regression test asserts the published nodes deep-equal their
   input minus the field AND that the serialized JSON never contains the string;
   it fails if the projection is removed.

2. Fallow complexity on `androidUiNodes` (10 cyclomatic / 19 cognitive):
   extracted `createAndroidChromeSubtreeTracker`, which owns the depth and
   chrome-subtree state, leaving the generator a token loop. Not baselined.
   Note the tracker keeps open/close as distinct operations on purpose: a
   self-closing tag leaves at the depth it entered, a container descends and is
   left by its `</node>`, so collapsing them silently breaks nesting.
@thymikee

Copy link
Copy Markdown
Member Author

Re-review of new head 972ffac: the Fallow complexity blocker is resolved, but the public provenance blocker remains.

serializeSnapshotResult strips systemChrome only in the CLI output path (src/commands/capture/output.ts). The daemon snapshot route returns result nodes unchanged (src/daemon/snapshot-runtime.ts:50-51,166-169); the Node client trusts data.nodes via readSnapshotNodes, and MCP builds structuredContent from that client result. So Node and MCP snapshot payloads still expose the per-node field, and the new direct serializer test cannot catch the shipped route. The public RawSnapshotNode/SnapshotNode type also still declares systemChrome, so SDK declarations advertise the supposedly internal property.

Keep the provenance in an Android-internal node/sidecar type, project it away at the daemon/public response boundary used by CLI, Node, and MCP, and add a production-route regression proving all published snapshot surfaces omit it. The PR description also still says “Test-only”; update it for the production change and attach exact-head Android-helper/public JSON evidence requested in the prior review.

Do not apply labels.

@thymikee

Copy link
Copy Markdown
Member Author

Blockers addressed — 972ffac00, all 24 checks green (Fallow included)

1. Public contract leak

Confirmed and fixed. serializeSnapshotResult passed result.nodes straight through, so the internal classification became a de-facto public field on every node.

Added publicSnapshotNodes next to the field it strips, mirroring the existing publicSnapshotCaptureAnnotations projection, applied at that serializer — the single one the CLI and capture output both route through. The regression test asserts published nodes deep-equal their input minus the field and that the serialized JSON never contains the string; I verified it fails when the projection is removed.

One thing worth recording, since "strip at every public boundary" implies knowing where they are: I checked whether the flag has to survive a round-trip anywhere. SessionStore keeps sessions in an in-memory Map — only events.ndjson and the script are written to disk — so the classifiers read in-process nodes daemon-side and there is no persistence boundary that could drop it between commands. The response boundary is the only place it needed stripping.

I also nearly fooled myself here: my first measurement ran the chrome filter over the CLI's public JSON and showed chrome surviving. That was the projection working as designed, not a regression — the end-to-end run below is what actually settles it.

2. Fallow complexity

Extracted createAndroidChromeSubtreeTracker, which owns the depth and chrome-subtree state, leaving androidUiNodes a plain token loop. Not baselined or allowlisted — androidUiNodes no longer appears in fallow health output at all, and Fallow Code Quality passes on this head.

Worth flagging for review: the tracker deliberately keeps open/close as distinct operations. My first attempt collapsed them into one close() and silently broke nesting — a self-closing tag leaves at the depth it entered, while a container descends and is left by its </node>. The comment there marks it so the next person does not re-merge them.

3. Description and exact-head evidence

PR body rewritten — it now leads with "this PR changes production behavior" and describes the current head. Evidence at 972ffac00 with a clean tree, emulator-5554 (Pixel 9 Pro XL API 37, deskclock):

route result
--settle, shade raised mid-settle settled after 7108ms: +28 -25 — brightness seekbar + Wi-Fi/Bluetooth/Mobile data/Quick Share/Modes/Wallet tiles, 0 status-bar chrome lines
--settle, shade closing mid-settle +25 -28, mirror image
ordinary action, no shade (control) +1 -1, 0 systemui nodes surfaced, no clock/battery/signal leak
replay divergence, shade up (non-raw selection) state: available, refs: 20 (cap, truncated: true), Display brightness + tiles, 0 status-bar chrome among refs
content recovery systemSurfaceOnly disclosure still fires on the shade
public JSON systemChrome absent from every payload measured — interactive, non-raw, and divergence

The divergence numbers match #1318's post-fix result exactly (20 refs at the cap, truncated, Display brightness + tiles), so that route is preserved while the classifier underneath it changed.

Note on reading the raw capture numbers: a snapshot of the shade legitimately contains the status-bar nodes (4 interactive / 6 non-raw) — snapshot publishes the tree, and the chrome filter is what excludes them on the settle and divergence routes. That is why the evidence above is end-to-end rather than a node count.

…the test-side stamping helper

Review: the remaining long comments read as workarounds. Three were, and each
pointed at code worth changing rather than explaining.

1. `collectAndroidSystemChromeIndexes` required the walk-stamped `systemChrome`
   even when the tree still held the container that defines it. Hand-built trees
   (every settle unit test) therefore classified as "no chrome at all", which is
   why they needed `withAndroidSystemChrome` — a test helper re-implementing the
   production descent, with a comment explaining why it had to exist.

   The classifier now reads the container from whichever source still has it:
   the parent chain when the capture kept it, the stamped flag when the walk
   dropped it. The helper, its type and its comment are deleted; no test
   re-states production logic, and any caller holding an intact tree is now
   classified correctly regardless of how it was produced.

2. `createAndroidChromeSubtreeTracker` tracked depth with arithmetic subtle
   enough to need a comment about self-closing tags — and subtle enough that an
   earlier revision of it was wrong. Replaced with a stack of open elements:
   push on descent, pop on `</node>`, nothing to explain.

3. `walkInteractiveOnlyAndroidFixture`'s 16-line comment still described the
   run-classification this branch deleted — stale, not merely long. Cut to what
   the helper does.

Also trimmed the contract, kernel, classifier and serialization-test comments to
the fact each states. Comment lines in the branch drop from 116 to 51.

No behavior change beyond (1), which strictly widens correct classification.
4318/4318 unit tests, lint, typecheck, build and smoke pass.
@thymikee

Copy link
Copy Markdown
Member Author

The comments were hiding three fixable things — 897f9e46c

Comment lines added by this branch: 116 → 59. Not by deleting prose, but by removing what the prose was there to explain.

1. The test helper was the loudest one — the classifier was wrong

collectAndroidSystemChromeIndexes required the walk-stamped systemChrome even when the tree still held the container that defines it. So any hand-built tree — every settle unit test — classified as "no chrome at all", which is exactly why I had to add withAndroidSystemChrome: a test helper re-implementing the production descent, carrying a comment explaining why it needed to exist. A test re-stating production logic is the tell.

The classifier now reads the container from whichever source still has it: the parent chain when the capture kept it, the stamped flag when the walk dropped it. Helper, its type and its comment: deleted. This strictly widens correct classification — any caller holding an intact tree is now classified correctly regardless of how it was produced, instead of silently getting nothing.

2. The tracker comment was guarding arithmetic I had already gotten wrong once

createAndroidChromeSubtreeTracker tracked depth with depth <= chromeDepth bookkeeping subtle enough to need a comment about self-closing tags — and subtle enough that an earlier revision of it was wrong (I caught that by re-reading the diff, not from a failing test, which I flagged at the time).

Replaced with a stack of open elements: push on descent, pop on </node>. Self-closing simply never pushes. Nothing left to explain, and the failure mode is gone rather than documented.

3. One comment was not long — it was stale

walkInteractiveOnlyAndroidFixture still carried 16 lines describing the run-classification this branch deletes ("one 95-node run … five runs under this one"). That is worse than verbose; it would have misled the next reader. Cut to what the helper does.

Contract, kernel, classifier and serialization-test comments trimmed to the fact each states.

Verification at 897f9e46c

4318/4318 unit tests (clean run), lint, typecheck, build, smoke 14/14.

Re-ran the device evidence because the classifier changed — emulator-5554, Pixel 9 Pro XL API 37, deskclock. All four routes reproduce identically:

route result
--settle, shade mid-settle +28 -25, tiles present, 0 status-bar chrome
ordinary action, no shade (control) +1 -1, 0 chrome leak
replay divergence, shade up available, refs: 20 (cap, truncated), Display brightness + tiles
public JSON systemChrome absent

@thymikee

Copy link
Copy Markdown
Member Author

Re-review of 897f9e46: the classifier/tracker delta looks sound and all 24 checks pass, but the P1 public provenance blocker is still unresolved.

serializeSnapshotResult remains a CLI-output serializer only. The daemon snapshot route still returns raw result nodes (src/daemon/snapshot-runtime.ts:50-51,166-169); the Node client trusts data.nodes via readSnapshotNodes, and MCP projects that client result into structuredContent. SnapshotNode/CaptureSnapshotResult.nodes also still advertise systemChrome, while publicSnapshotNodes<T> casts the stripped value back to the same public type. The direct serializer regression would remain green while Node and MCP leak.

Keep the stamped provenance in a distinct internal node type, project at the daemon/public response boundary before Node/MCP receive it, and add daemon→Node plus MCP structured-content regressions. The PR body’s device evidence is also for 972ffac, not current head 897f9e46; after the route fix, attach exact-head public Node/MCP evidence.

ready-for-human remains withheld. Do not apply labels.

@thymikee

Copy link
Copy Markdown
Member Author

Re-review of 7cd3ab91: clean from a code-review perspective; the prior P1 is fixed.

The daemon now retains provenance only in the operational/ref-frame tree and strips it from the published snapshot copy. systemChrome is gone from public RawSnapshotNode/SnapshotNode, while Android keeps it in a distinct internal provenance type. The provider-backed RPC regression drives a chrome-bearing XML fixture through the daemon response seam and would fail if the projection were removed; Node and MCP faithfully consume that clean response. The complete-segment matcher also prevents status_barometer false positives, and the classifier/tracker delta remains sound.

Exact-head Android evidence at 7cd3ab91 covers settle, replay divergence, content recovery, daemon JSON, Node SDK, and MCP. All completed checks are green; Android, Linux, and iOS smoke remain in progress. No code findings.

Do not apply labels; the coordinator will apply ready-for-human.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 22, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Review of head 7cd3ab91f. The core design is right: stamping provenance during the walk, while the tree still knows the relationship, is the correct fix for the shape-dependent classification that #1318 and #1319 hit from opposite sides — and deleting the 18 hand-picked leaf ids plus the paragraph-justified #1318 fallback is exactly the direction this repo's rules point. Fixtures now modeling the real nested container shape, and the parity test running both real walks, are genuine test-quality upgrades. I re-ran the focused suites, the provider scenarios, typecheck, and lint on this head locally: green.

Two findings, one confirmed with a repro.

1. Confirmed: systemChrome still leaks through get attrs — the "single seam" isn't at the response boundary

The strip lives in dispatchSnapshotViaRuntime, which is the snapshot handler, not the daemon response boundary. get attrs @ref binds against the retained ref-frame tree (ADR-0014 — which by this PR's design deliberately keeps provenance) and toDaemonGetData embeds the resolved node verbatim (src/daemon/selector-recording.ts:66). find <sel> get attrs funnels through the same runtime.

Verified with a throwaway provider-scenario test on this head: snapshot the PR's own ANDROID_SYSTEM_SURFACE_XML fixture, take the clock's ref, call get attrs @e2. Public RPC response:

{
  "ref": "e2",
  "node": {
    "type": "android.widget.TextView",
    "label": "7:03",
    "bundleId": "com.android.systemui",
    "systemChrome": true,
    "ref": "e2",
    ...
  }
}

So the PR-description claim — "stripped once at the daemon response boundary… CLI, Node SDK, and MCP inherit the same clean snapshot contract without duplicating defensive projections" — doesn't hold yet. The prior review rounds fixed the snapshot route and the live evidence (daemon JSON / SDK / MCP) all exercised snapshot, which is why this route slipped through.

The deeper issue is the standing "what enumerates N" question: nothing enumerates the daemon responses that embed node objects, so a hand-picked strip site will keep regressing as new embedders appear. Two-part suggestion:

  • strip where a node is embedded into a get payload (one more call in toDaemonGetData, or in the selector runtime's result), and
  • add the enumerating check: assert in the provider-scenario harness (e.g. in the callCommand/client wrapper) that no RPC response ever serializes systemChrome. That makes every existing and future scenario a leak test by construction, instead of the one hand-written route regression this PR adds. Alternatively, do the strip itself at the wire serialization and delete the per-handler projection entirely.

2. Question: removing the #1318 fallback makes classifier false-positives fatal again — what's the evidence off API 37?

The new classifier is capture-shape-independent by construction, but not id-scheme-independent. The segment matcher's blast radius was live-verified on one Pixel 9 Pro XL API 37 emulator; minSdkVersion is 23, and OEM SystemUI forks rename and restructure these ids freely. If any supported SystemUI build puts a status_bar/navigation_bar-segment id on a container that also hosts actionable content (older AOSP shade layouts are the suspect; Samsung/Xiaomi forks are unmeasured), that whole subtree is condemned — and with selectDivergenceScreenRefNodes' fallback deleted, the failure mode is a zero-ref divergence screen, the exact wave-3 leg E outcome this arc set out to kill, and a violation of ADR-0012's "filter, never a narrower scoping than snapshot" amendment. The old failure mode (fallback firing) was "some non-actionable chrome published"; the new one is strictly worse for the agent.

Note the fallback was already gated to hittable nodes and only fired when nonChrome came back empty — the deleted companion test pinned that a collapsed status bar still published nothing. So on devices where the new classifier is right, keeping it costs nothing and changes nothing; it only fires where the classifier is wrong. I'd either keep that one line as insurance, or state the cross-API/OEM evidence that justifies deleting the safety net (a quick shade capture on an API 30-ish emulator would settle the AOSP half).

Minor

  • The streaming tracker's tokenizer (<node\b[^>]*>|<\/node> + endsWith('/>')) exactly mirrors parseUiHierarchyTree's idiom — good; the two parse paths can't drift on nesting. Sharing the tokenizer some day would make that structural rather than coincidental. Non-blocking.
  • contracts/android-system-chrome.ts importing kernel/snapshot.ts matches the existing contracts convention; Layering Guard agrees.

Finding 1 is a blocker against the PR's own stated contract; finding 2 needs either evidence or the one-line insurance before the fallback deletion is safe to ship.

@thymikee

Copy link
Copy Markdown
Member Author

Re-review of 67c091e20: both findings are resolved. Verified on the new head locally — typecheck, the focused divergence/chrome suites, and the full 37-file provider-scenario suite (141 tests) with the new global invariant active: all green.

Finding 1 (provenance leak) — fixed and enumerated. The strip now lives at both node-embed sites (toDaemonGetData, toDaemonFindData), and the updated provider test performs exactly my repro (get attrs @<clock ref> plus find label "7:03" get attrs against the chrome-bearing fixture). The typed projections are also an upgrade, not just a patch: selector-read.ts's result union already guarantees node: SnapshotNode on text/attrs results, so the previously-defensive optionality was dead weight and the new types match the real producer contract. The harness assertion in responseToRpcResult makes every scenario a leak test by construction — the enumerating check I asked for.

One minor placement note, non-blocking: the invariant sits in responseToRpcResult, which only the callCommand channel passes through — world.daemon.client() goes straight through the transport lambda into handleRequest (harness.ts:67-77), so SDK-driven scenario calls bypass the check. Both channels share the same handlers and callCommand coverage spans the suites, so the enumeration is still strong; wrapping handleRequest itself (so both channels flow through the assert) would close it completely and is a 3-line move. Fine as a follow-up.

Finding 2 (fallback deletion) — resolved via the insurance option. The hittable fallback is restored verbatim in selectDivergenceScreenRefNodes, and the parameterized wave-3 leg E test now drives the exact feared scenario: a whole-tree chrome false positive (modeling an older/OEM layout where a status/nav id owns real controls) must still publish the 20 capped hittable refs, brightness first, with the non-hittable battery/wifi residue excluded in both scenarios. That shared exclusion assertion also replaces the deleted collapsed-bar pin — the hittable gate is asserted on the real route rather than on fixture data-shape. The ADR-0012 "filter, never narrower than snapshot" invariant is safe again regardless of id-scheme drift.

No remaining blockers from my side.

@thymikee
thymikee merged commit c84b449 into main Jul 22, 2026
24 checks passed
@thymikee
thymikee deleted the claude/agent-device-1319-5574eb branch July 22, 2026 13:50
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-22 13:50 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--settle may be blind to a fully expanded quick-settings shade (same run-condemnation rule)

1 participant