Skip to content

fix(ios): make both rect guards refuse the sentinel and let the fold answer clipped carriers - #2945

Merged
thymikee merged 6 commits into
mainfrom
fix/ios-actionability-unknown-bit
Sep 25, 2026
Merged

thymikee merged 6 commits into
mainfrom
fix/ios-actionability-unknown-bit

Conversation

@thymikee

Copy link
Copy Markdown
Member

Follow-up to #2908, which merged before this review round finished. Carries the four findings against #2891; nothing merged is reverted.

Summary

Both guards refuse the box a failed read leaves behind. The TypeScript isPositiveFiniteRect accepted CGRect.infinite: its four components are finite, and so are both extents, so a check that reads only components or only extents waves it through. It also accepted finite components whose extents overflow. The Swift isPositiveFinite already refused the sentinel by identity, but it too accepted the overflowing shape. Both now refuse non-finite components, non-finite extents, and CG_RECT_INFINITE by value, so the sentinel is unusable on both sides of the boundary instead of only where an Apple frame is parsed. Every other box is classified the same as before; the narrowing is stated in the ADR rather than in prose.

A box is checked where it is made. Box(checked:) is the in-module path from a CGRect to a declared fact, and the viewport factories turn refusal into .missing(reason: .invalid). There is no longer a way to hold a Box that the shared guard would refuse.

The fold answers a carrier its own clip already decided. Application and Window are exempt from visibility, so a retained carrier can reach the fold with a degenerate clipped frame and no source bit. The merged flatMap published nil there, which reads as "containment is undecided" about a node the policy already refuses. clippedHittability keeps the source's own false first, answers from the clipped frame second, and withholds only when containment is the open question.

The golden table says what it runs. The sentinel row now executes in both languages, a row with finite components and a non-finite extent pins the overflow refusal in both, a nonfinite row is labelled as pinning the guard rather than a producer, and a row declaring false in both languages is rejected by both readers instead of being silently skipped.

SnapshotBridgeRuntime.m is deliberately unchanged. Refusing to serialize a box there would turn "the source reported an unusable frame" into "the source reported no frame", which is a different claim about the device, and that file has no test seam to notice the difference.

Validation

Tested SHA 1e1fa90d84, on top of origin/main at fa1082dd14.

  • pnpm test:ios-snapshot-differential: Swift 17 tests, node 6, 0 failures. The sentinel row now runs in both languages.
  • pnpm check:xctest-selection: 321 declared RunnerTests methods, 0 reachable by no lane. check:layering and the eager-closure budget pass, and presentIosRunnerSnapshot keeps its zero fold call count.
  • Runner test target built with AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 on an iPhone 17 / iOS 26.2 simulator: Executed 9 tests, with 0 failures, including both new cases and testRegularFoldKeepsWindowCarriersButNeverHittableOutsideClip, the reported-viewport neighbour of the new fold policy.
  • Reverts fail where they should: dropping the Swift sentinel refusal fails ActionabilityPolicyTests, dropping the TypeScript one fails rect.test.ts and tree.test.ts, and a false/false row fails both table readers.
  • Live A/B on one simulator, open com.apple.Preferences, this head against an fa1082dd14 runner on separate derived paths: snapshot --json and snapshot -i --json return the same ordered payload — 58 nodes (52 true, 6 false, none absent) and 18 (16, 2, none absent). Only snapshotDiagnostics timings and refsGeneration differ.
  • ios-lifecycle.test.ts passes at this head and at clean fa1082dd14; the earlier failure did not reproduce.
  • A fourth adversarial round closed the fold policy and its proof and found four claims wider than the code, all corrected in the last commit.
  • `pnpm check:affected --run__:AFFECTED_PLACEHOLDER__

Not checked live: a .missing viewport cannot be forced on a device, so the absent-bit and fold paths are unit-covered only.

…ehind

`isPositiveFiniteRect` compared four components, and `CGRect.infinite` is
built of four finite Doubles whose center is (0, 0): x=y=-8.988465674311579e+307,
w=h=1.7976931348623157e+308. The bridge's `rectDictionary` refuses only a
non-finite component, so that box crossed the wire intact, `viewportFromRoot`
called it `.reported`, and every node center on the screen landed inside it --
the host path publishing `hittable: true` for a whole tree whose viewport read
had failed. The Swift twin already refused it with `!rect.isInfinite`; the two
declarations of one rule disagreed on exactly the input that started #2891.

Refused now, in both twins: a non-finite component, a box whose finite
components overflow its own right or bottom edge, and the sentinel itself. The
extent check is a separate hole, not the sentinel's fix -- the sentinel's own
extents are finite, which is why a value refusal stays and is pinned as
non-vacuous. It lives in the shared guard rather than in one producer's parser
because every TypeScript producer (the simulator bridge, the runner wire, a
remote provider's tree) feeds this one function.

The old `isPlottable` accepted both newly-refused classes and nothing else
changed: for every other box the five comparisons are the ones it had.
The sentinel row declared `typescript: false` on the claim that `frameFromGuest`
would refuse the frame before the predicate saw it. Its components are finite and
non-negative, so that refusal never happens, and the row's `asymmetry` note said
the shared rule contained a magic value it did not have. The row now runs in both
languages against the guard that actually refuses the box.

Adds the overflow class the component checks cannot reach, and pins three things
that were previously only argued in prose: a refused box never becomes a declared
viewport on either side; the wire carries no `hittable` key at all when the
viewport is unknown, while a disabled node and a root stay declared `false`; and a
table row neither language runs is now rejected instead of accepted.

Non-vacuity, verified by mutation: dropping `!rect.isInfinite` reddens the Swift
rows and the declaration test; dropping the value refusal from the TypeScript
guard reddens the kernel test and the bridge reader's viewport case; flipping a
row to `swift: false, typescript: false` reddens both lanes.
`Box`'s memberwise initializer was internal, so any code in the package could
still write `Box(positiveFinite: .infinite)` and wrap it in `.reported`: the
guarantee held for callers outside the module and nowhere else, while the comment
on the type claimed the factories were the only constructors.

The check moves into the initializer, which is now failable, so an unchecked box
is not a thing a caller can name and the factories simply translate a refusal
into `.missing(reason: .invalid)`. This is a compile-shape guarantee, so it has no
runtime test of its own; the declaration test added alongside it covers the
behavior both callers observe.
A third review pass at `f620148f60` refuted the reason this had been left alone:
`shouldInclude` exempts `Application` and `Window` from the visibility test, so a
carrier a scroll anchor clips to nothing is RETAINED, and it reaches the
`hittable` decision with the source bit undecided. There `flatMap` over the
source bit publishes absence for a node whose own frame is degenerate, which is
exactly the corner where ADR 0004 promises "disabled or degenerate nodes stay
declared `false`" and where the `nil` case is documented as "only containment is
left to decide".

The source bit is now consulted only for a declared `false`; anything undecided
is re-decided on the clipped frame, so `nil` means containment is the open
question and nothing else. With a box the source bit is never `nil`, so the
reported-viewport path is untouched.

Also two comments that overclaimed: the wire test does not prove what the Swift
encoder could regress into, and the TypeScript predicate does not validate the
viewport it is handed -- it cannot, because it answers in `boolean`, so the
requirement on callers is now stated where a caller reads it.
Both were added without compiling the runner target, which is not reachable from
the differential lane: `SnapshotRect` takes Doubles and the new fold test spelled
its y offset in Ints, and the fold test reached for `RawAXNode.replacing`, which
is internal to the presentation module and invisible to the runner tests. The
fold cases now state their source bit where the node is built, through a defaulted
parameter on the test's own builder, which is also what `normalized()` leaves
behind when the viewport read failed.

Verified with the runner test target built with
`AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1` and run on the `rnav-repro`
simulator: 9 selected cases, 0 failures, including both new ones and
`testRegularFoldKeepsWindowCarriersButNeverHittableOutsideClip`, which is the
reported-viewport neighbour of the new fold policy.
…mise

Four claims around the previous commits were wider than the code.

`rect.ts` justified trusting its `viewport` argument by saying an unknown viewport is not
representable in `SnapshotViewport`, which has a `.missing` case for exactly that. The reason the
Swift twin needs no such request is that it takes that fact and returns `Bool?`, so it can answer
"no idea"; a `boolean`-returning rule cannot, which is why the request exists at all.

The wire test's comment promised that a failed viewport read publishes no `hittable` at all, over
its own assertions, which require a declared `false` from the root and from the disabled node. The
bit that goes absent is the one containment would have decided.

The overflow fixture row said the box has finite extents and then handed it to a guard that refuses
it because `x + width` is infinity. Its components are finite; its right edge is not.

The ADR paragraph on the two guards said the guard being replaced had accepted both newly-refused
classes. Nothing was replaced here: the Swift twin already refused the sentinel by identity, and
this branch adds the extent check there and both refusals on the TypeScript side. The narrowing now
names which twin lost what, which is also the answer to whether any input changed classification.

Differential lane green after the row rename: Swift 17 tests, node 6 tests, 0 failures. Runner test
target rebuilt with unit tests and rerun on the iPhone 17 / iOS 26.2 simulator: 9 selected cases,
0 failures.
@github-actions

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.81 MB 4.81 MB +264 B
Package (unpacked) 4.81 MB 4.81 MB +264 B
Package (download) 1.44 MB 1.44 MB -237 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.7 ms 26.6 ms -0.2 ms
CLI --help 77.6 ms 77.8 ms +0.2 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift">

<violation number="1" location="apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift:247">
P3: This comment says a carrier clipped to nothing is `nil` today, but the helper now returns `false`; update the comment to match the implemented policy.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment on lines +247 to +248
/// open question. A `visibilityExempt` carrier clipped to nothing by a scroll anchor is that
/// `nil` today, and its own frame already answers the question.

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This comment says a carrier clipped to nothing is nil today, but the helper now returns false; update the comment to match the implemented policy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotVisibilityFold.swift, line 247:

<comment>This comment says a carrier clipped to nothing is `nil` today, but the helper now returns `false`; update the comment to match the implemented policy.</comment>

<file context>
@@ -244,4 +241,23 @@ public enum SnapshotVisibilityFold {
+  /// The fold's share of the `hittable` policy (#2891). A declared `false` is kept; anything the
+  /// source left undecided is re-decided on the clipped frame, which refuses a disabled or
+  /// degenerate node with no viewport to consult and answers `nil` only while containment is the
+  /// open question. A `visibilityExempt` carrier clipped to nothing by a scroll anchor is that
+  /// `nil` today, and its own frame already answers the question.
+  private static func clippedHittability(
</file context>
Suggested change
/// open question. A `visibilityExempt` carrier clipped to nothing by a scroll anchor is that
/// `nil` today, and its own frame already answers the question.
/// open question. A `visibilityExempt` carrier clipped to nothing by a scroll anchor is
/// `false` today, not `nil`, and its own frame already answers the question.
Fix with cubic

@thymikee

Copy link
Copy Markdown
Member Author

This is ready for human review at 1e1fa90. Code looks correct: both rect guards now refuse the sentinel, and the fold's answer for a clipped carrier is the one the callers need. All 19 checks are green, and I saw no conflicts. Nothing is blocking.

The live A/B on Preferences, the XCTest runner run, and the gate outputs are the author's own reports; I did not re-run them. I also did not check whether SnapshotBridgeRuntime.m serializes CGRectInfinite with full precision — if it prints shortened digits, the value won't match the TS identity check exactly, and the finite checks would only catch it when the rounding overflows to Infinity. A missing viewport can't be forced on a device, so the changed fold route (nil source plus a clipped carrier) has only unit coverage, which the author already states.

Not blocking: readSnapshotKeyboardBandFact in packages/kernel/src/record.ts#L110 still inlines the old, wider box guard even though its comment claims it matches isPositiveFiniteRect, so it could stand to pick up the same extent and sentinel checks (or have the comment corrected) the same way rect.ts and SnapshotGeometry.swift now do; the doc comment on SnapshotVisibilityFold.swift#L247 still says the clipped carrier "is that nil today," which described the old merged behavior and should just say the clipped-to-nothing carrier is a declared false; and CG_RECT_INFINITE is declared three times (kernel/rect.ts, rect.test.ts, scripts/ios-snapshot-differential.test.ts#L588) with the same value, so exporting it from rect.ts and importing it in both tests would remove the duplication — none of these are blocking.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee
thymikee merged commit 5baa18f into main Sep 25, 2026
19 checks passed
@thymikee
thymikee deleted the fix/ios-actionability-unknown-bit branch September 25, 2026 05:59
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-25 05:59 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.

1 participant