Skip to content

feat(snapshot): carry a presented-surface fact through capture and settle - #3577

Merged
thymikee merged 4 commits into
mainfrom
feat/3565-presented-surface-carriage
Oct 14, 2026
Merged

thymikee merged 4 commits into
mainfrom
feat/3565-presented-surface-carriage

Conversation

@thymikee

@thymikee thymikee commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds the typed presented-surface fact to the iOS capture contract and carries it end to end, so a
consumer of a baseline can ask whether a capture read a modal presentation instead of guessing from
tree shape (#3565). Layer 1 of 3 — the pipe, plus the one positive the runner already had: the
blocking-system-modal snapshot is stamped presented (live excerpts below). No other capture changes its
answer until layer 2 lands.

SnapshotState.presentedSurface?: 'presented' | 'absent' | 'unknown' sits beside the other
producer-stated facts with one wire reader, readPresentedSurfaceFact(payload): a payload with no own
key stays silence, a value under the key the reader cannot name is restated 'unknown', so a
malformed statement can never read as absence. It travels inside SnapshotState through the daemon capture
cache, the snapshot state minter, the digest response views, the Node client reader, and the
interaction and serialization field lists.

The reader takes the payload rather than the value it holds, because reading a wire fact is a property
lookup on data the producer controls and every caller would otherwise have to remember that twice: the
key has to be own, or a payload that carries no fact walks to Object.prototype — where anything
sharing the process could park 'absent', the one state that disarms the confirmation #1831 arms. The
value-level form is removed rather than left exported beside the safe one; nothing in production read
the bare value and the fact is unreleased, so a call site that cannot express the unsafe read beats a
comment warning callers off it.

PresentedSurface is a plain union. The reader validates against a table keyed by it, so a
vocabulary state without a row fails to compile instead of reading as unknown (TS2741), and the
lookup is own-property, so a published value naming constructor is not a state.

The client normalized the fact on the default response path and returned a digest payload unreshaped,
so the same field was read two ways depending on --response-level. A leveled payload now goes
through the same reader; a payload with no key stays byte-identical.

SurfaceScopedNodes baselines are minted by one function, surfaceScopedNodes in
@agent-device/capture-kit/snapshot-baseline: both settle routes, the moving-target gate, and find's
handoff. The one exception is the moving-target rest-proof tree in daemon/core/deferred-interaction-outcome.ts, which builds the same three facts locally: importing the mint there adds a module to the eager closure of most daemon entries (ADR 0027, measured by eager-closure-budgets). On find's handoff, PreresolvedInteractionTarget now carries tree: SurfaceScopedNodes minted from find's
own capture instead of mirroring nodes, surface and presentation field by field. Swift gets the vocabulary type
and payload field, with the blocking-system-modal payload stamped .presented — the one piece of
positive runner evidence that already exists.

45 files, +715/−69.

Validation

9b752cf69, rebased onto main: pnpm typecheck clean; vitest over the touched packages and the
interaction, capture and client suites green. The serialized pnpm check:affected --run result for each
layer is posted on the PR.

Live (iPhone 17 simulator, RN lab app, re-run with the runner built at 105fa6477, after the rebase that moved the stamp into snapshotForBlockingSystemModal; 9b752cf69 changes only the daemon rest-proof baseline since). The microphone permission prompt over the lab app:

{"success":true,"data":{"presentedSurface":"presented","nodes":[{"type":"Alert","label":"Allow “Agent Device Tester” to access your microphone?"}, …]}}
{"success":true,"data":{"presentedSurface":"presented","nodeCount":5}}   // --level digest

Home, the lab after the prompt is dismissed, and Home with --level digest publish no presentedSurface
key.

New tests fail on revert: the reader's malformed-vs-missing split, that a key present with no value
reads unknown rather than the silence of no key (the payload helper takes a required argument so a
test cannot accidentally build the missing-key shape it meant to test), that a payload with no own key
is silent while Object.prototype.presentedSurface = 'absent' is installed (both at the reader and at
the client, for digest and full; swapping the own-key check for a plain === undefined guard fails
three of them), that an inherited
Object.prototype member naming a state is not one, the digest reader's normalization (which fails when the digest path returns the
payload untouched), the baseline minter, find's handoff carrying the capture's
presentation to the leaf's tree (find.test.ts), carriage into both the response and the session record
(presented-surface-carriage.test.ts), the client wire reader, and the Swift payload stamp. The two Swift
payload tests need the unit-test condition to compile at all
(AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 pnpm build:xcuitest:ios), which is also how a missing
import AgentDeviceSnapshotPresentation in this layer was caught; the Swift unit tests compile at this head and ran green
on the iPhone 17 simulator at 29aaf3b. The rebase did not touch the Swift side.

@thymikee
thymikee added this pull request to stack #3580 October 13, 2026 12:50

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 38 files

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

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread src/agent-device-client.ts Outdated
Comment thread packages/kernel/src/snapshot.ts Outdated
Comment thread packages/kernel/src/record.ts Outdated
Comment thread packages/contracts/src/interaction.ts Outdated
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.21 MB 5.22 MB +3.7 kB
Package (unpacked) 5.21 MB 5.22 MB +3.7 kB
Package (download) 1.57 MB 1.57 MB +1.3 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 19.2 ms 21.1 ms +1.9 ms
CLI --help 53.1 ms 60.6 ms +7.5 ms

thymikee added a commit that referenced this pull request Oct 13, 2026
…er's closure

Review of #3577 found four problems with how the fact crosses the wire.

`readPresentedSurfaceFact` validated against `PRESENTED_SURFACE_STATES`, which made every eager
consumer of `kernel/record` evaluate `kernel/snapshot.ts` as well. ADR 0019's loading-shape probe
measures exactly that: thirteen carried entries grew by one module and 70 eager statements, which
`scripts/__tests__/eager-closure-budgets.test.ts` refuses — the growth is a pre-existing module made
eager, so no split explains it and shrinkage elsewhere cannot fund it. The reader now decides from a
table it owns, keyed by `PresentedSurface`, so a vocabulary state without a row fails to compile
rather than reading as `unknown`. The lookup is own-property, which also closes the inherited-member
path an `in` test would have opened: a payload naming `constructor` is not a state.

`PRESENTED_SURFACE_STATES` is frozen. `as const` was only a type-level promise on an array the reader
trusted payloads against, so any consumer could move the boundary between a state and `unknown` for
the whole process.

The client normalized the fact on the default response path and returned a digest payload verbatim,
which meant the same field was read two ways depending on `--response-level`. Passing a leveled
payload through without reshaping it is not a licence to skip the fact's own vocabulary, so the
digest path reads it through the same reader; a payload with no key stays byte-identical.

Two doc comments claimed a capture "came from read" something. Both now name the producer that did
the reading.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 6 files (changes from recent commits).

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

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread src/agent-device-client.ts Outdated
Comment thread src/agent-device-client.ts Outdated

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/kernel/src/presented-surface.test.ts Outdated
@thymikee
thymikee force-pushed the feat/3565-presented-surface-carriage branch 2 times, most recently from 4afd24c to 29aaf3b Compare October 13, 2026 15:28
@thymikee

Copy link
Copy Markdown
Member Author

The runner's blocking-system-modal snapshot now stamps presentedSurface: .presented (RunnerTests+SystemModal.swift#L74), and that payload returns ahead of the capture plan, so it reaches snapshot --json, the digest view and client.capture.snapshot(). The PR says "No output change in this layer", which is not true for this route. Unit tests cover the Swift factory and the TS reader, but nothing covers the encoded runner reply crossing between them. Please run one iPhone 17 simulator with a SpringBoard blocking alert up, for example a permission prompt raised by the RN test app. agent-device snapshot --json should show data.presentedSurface == "presented", the same with --response-level digest, and an ordinary screen like Home should show no presentedSurface key. Paste the three JSON excerpts. Or drop the Swift stamp from this layer and keep the "no output change" claim.

Not blocking, and you can take or leave these: adoptPreresolvedRefTarget (ref-target-resolution.ts#L72) hand-builds SurfaceScopedNodes, and the find route copies each fact three times (find.ts L211-216 and L275-282, then PreresolvedInteractionTarget), so the rule should be that every SurfaceScopedNodes is minted by surfaceScopedNodes, which a grep for surfaceBundleId: literals can check; no test reads presentedSurface through find, then the preresolved target, then the settle baseline, or through refFrameSnapshot to authorizedRefBaseline, so deleting find.ts L215, L282 or ref-target-resolution.ts L78 keeps the suite green; PRESENTED_SURFACE_STATES (snapshot.ts#L396) is a frozen runtime export that only its own test reads, and record.ts L131-141 spends a paragraph justifying a second copy of the vocabulary; BackendSnapshotResult.presentedSurface (backend.ts#L67) has no production writer, and the interaction converters at interaction-snapshot-capture.ts L35-41 and selector-read-shared.ts L115-131 drop it; the doc comment in PresentedSurface.swift#L9 points at a SnapshotPresentedSurface that does not exist yet, and client-api.md does not mention the new field, so please drop the reference or add one line to the docs, or defer both to layer 2.

Could this be simpler? For example: make PresentedSurface a plain union and drop the runtime export and its freeze tests, have PreresolvedInteractionTarget carry a tree: SurfaceScopedNodes minted in find.ts instead of the mirrored fields, and drop the top-level BackendSnapshotResult.presentedSurface since every production backend returns SnapshotState. That removes about 40 production lines and leaves one mint for every baseline route, and nothing has to change first because capture-kit/snapshot-baseline already exists.

The code needs the live simulator run above and the find-route baseline mint before merge. The cubic-dev-ai threads are all fixed at 29aaf3b, so please resolve them: #3577 (comment), #3577 (comment), #3577 (comment), #3577 (comment), #3577 (comment), #3577 (comment) and #3577 (comment).

Checks are still pending; "Affected decision-kernel mutants" is queued and only reports scores. I reviewed 29aaf3b by reading the diff and did not run any tests, so I have not verified your gate results or the Swift simulator unit-test pass, or that the Swift encoding reaches the daemon unchanged over the live runner transport. I also inferred, not confirmed, that snapshotFast takes the same pre-plan alert path as snapshotRaw.

@thymikee
thymikee force-pushed the feat/3565-presented-surface-carriage branch 2 times, most recently from 518f568 to 3c785c3 Compare October 14, 2026 11:59
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main; head is now 3c785c3. The live run and the find-route mint you asked for are both in.

Live run. iPhone 17 simulator with the RN lab app, built at 518f568, runner included. Since then 3c785c3 only removed the beside-the-tree backend field (see below), which no production backend wrote, so CLI output is unchanged. The SpringBoard alert is the microphone prompt from Automation lab → Request microphone.

Blocking alert, snapshot --json:

{"success":true,"data":{"presentedSurface":"presented","nodes":[{"type":"Alert","label":"Allow “Agent Device Tester” to access your microphone?"},{"type":"Button","label":"Don’t Allow"},{"type":"Button","label":"Allow"}, …]}}

Same alert, snapshot --json --level digest:

{"success":true,"data":{"presentedSurface":"presented","nodeCount":5}}

Home, snapshot --json (keys: appBundleId, appName, nodes, refsGeneration, snapshotDiagnostics, truncated, viewport, visibility):

{"success":true,"data":{"nodes":[{"type":"Application","label":"Agent Device Tester"},{"type":"ScrollView"}, …]}}

Home with --level digest and the lab after the alert is dismissed carry no presentedSurface key either. The body now says this layer changes output on the blocking-modal route.

Find-route mint. PreresolvedInteractionTarget now carries tree: SurfaceScopedNodes, minted by surfaceScopedNodes from find's own capture. It replaces the mirrored nodes, iosSystemSurfaceBundleId and presentedSurface fields. adoptPreresolvedRefTarget returns that value as-is, so no hand-built SurfaceScopedNodes literal is left on the find route. The new test in find.test.ts stamps presentedSurface: 'presented' on find's capture and asserts it on the leaf's tree, with the target node inside that tree. Building the tree from nodes alone in find.ts fails it.

Your take-or-leave notes:

  • PresentedSurface is now a plain union. PRESENTED_SURFACE_STATES, its freeze test, and the record.ts paragraph justifying a second copy are gone.
  • PresentedSurface.swift no longer points at a type this layer doesn't have.
  • Not taken: the docs line for client-api.md, which layer 3 adds with the rest of the field's documentation.
  • Taken: the top-level BackendSnapshotResult.presentedSurface and its two reads in normalizeBackendSnapshot are gone, so the fact only travels inside SnapshotState. The two interaction converters you named therefore have nothing to drop. The carriage test keeps the state-inside route, in all three states plus silence.

The cubic threads were already resolved.

Rebasing onto main also moved #3471's new moving-target gate onto @agent-device/capture-kit/snapshot-baseline. The test call sites now pass IOS_SIMULATOR to readAppleSnapshotResult to match its new signature.

@thymikee
thymikee force-pushed the feat/3565-presented-surface-carriage branch from 3c785c3 to ac8289f Compare October 14, 2026 13:04
@thymikee

Copy link
Copy Markdown
Member Author

Rebased again onto main after #3592, #3608 and #3600 landed. New heads: #3577 ac8289f15, #3578 0851a1bab, #3579 7cd60ec96. The conflicts:

#3592's interpretIosNode adds checked/password/editable facts only. It does not change the type or class facts the rule reads, so the live evidence above still applies. Build, typecheck and 3,904 tests over capture-kit, kernel, platform-apple and the interaction and capture suites pass locally.

@thymikee

Copy link
Copy Markdown
Member Author

The PR is ready at ac8289f. The code now fixes what the earlier review at 29aaf3b raised (#3577 (comment)), and I found nothing new that must change.

Not blocking, and you can take it or leave it: movingTargetRestTree in https://github.com/callstack/agent-device/blob/ac8289f/packages/daemon/src/core/deferred-interaction-outcome.ts#L105 still copies the old body by hand and leaves out presentedSurface, so the baseline after a rest proof differs from every other baseline and contradicts the PR body's "every SurfaceScopedNodes is minted by one function". The copy existed because daemon/core could not import the old helper, but that reason is gone now that the mint lives in capture-kit, which generic-settle.ts already imports. The rule is that every SurfaceScopedNodes value is built by surfaceScopedNodes, so a grep for surfaceBundleId: literals outside capture-kit/snapshot-baseline.ts should return nothing. Replacing the body with snapshot ? surfaceScopedNodes(snapshot) : undefined would satisfy it, or the PR body claim can be corrected.

On the other threads, these cubic-dev-ai threads are fixed at this head and can be resolved: the leveled path now normalizes through readPresentedSurfaceFact (#3577 (comment)); the runtime vocabulary export is gone and PresentedSurface is a type-only union (#3577 (comment)); record.ts imports the kernel type only and validates against its own table (#3577 (comment)); the doc now sits on SurfaceScopedNodes.presentedSurface (#3577 (comment)); the reader checks the key with Object.hasOwn against an own-key table (#3577 (comment)); the comment is down to two lines at agent-device-client.ts:392-393 (#3577 (comment)); and carrying() keeps an own undefined key with the test asserting "unknown" (#3577 (comment)). No open thread from other reviewers still applies.

No conflicts. CI is still pending, and no job has failed yet. Smoke Tests and the interaction-settle and snapshot-occlusion Mutants jobs exercise the baseline mint, find's handoff and the response views that this PR changes, so they are the checks to wait for before merge.

The live simulator excerpts come from runner 518f568, which predates the rebase that moved the stamp into main's split-out snapshotForBlockingSystemModal. I checked that route by reading the code, not by a new run. I ran no tests, so my view of the regression tests comes from reading them, not from running them against reverted code.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 14, 2026
… consumers

A settle confirmation that has to prove a dismissal (#1831) has nothing to compare
against: the capture contract says nothing about whether a modal presentation was over
the app, so a consumer can only infer it from tree shape — and a tree shape that happens
to hold before a tap is exactly what makes the confirmation arm on a no-op. iOS is where
it matters most, because there an overlay is usually the same app process, so it moves
neither the surface identity nor the foreground-owner fact.

Publish the producer's own reading instead, on the state that is already built from the
capture's unfiltered nodes:

- `SnapshotState.presentedSurface` takes 'presented' | 'absent' | 'unknown', and
  `readPresentedSurfaceFact` is the one wire reader. A producer that inspected its whole
  subject and saw nothing presenting says 'absent'; one whose evidence cannot answer says
  'unknown'; only a producer that never classified stays silent. Anything the vocabulary
  cannot name reads as 'unknown', so an unrecognised value can never become a false
  absence.
- The fact rides the response envelope on every iOS capture route, including a digest, and
  travels with the nodes wherever a capture crosses a layer: the backend capture and
  interactor snapshot results, the daemon capture cache, the interactive-retry and
  deferred-re-observation nodes, the ref-target frame, and the settle baseline. A consumer
  that adopts an interaction's own capture therefore keeps the fact that capture
  established rather than re-deriving a weaker one.
- Both settle routes mint that baseline from one function, which moves to capture-kit
  beside the assembly that builds the state it reads: the interaction route lives in
  `commands/` and the generic route in `daemon/`, and the layering forbids the second from
  importing the first. So the next fact a baseline owes cannot be carried by one route and
  dropped by the other.
- The runner forwards the reading of the tier that answered. Its accessibility tree carries
  no window classes, so it states 'presented' from a resolved blocking system modal and
  never 'absent'; the shared classifier comes next.

Consumers are untouched: nothing decides on the fact yet.
…er's closure

Review of #3577 found four problems with how the fact crosses the wire.

`readPresentedSurfaceFact` validated against `PRESENTED_SURFACE_STATES`, which made every eager
consumer of `kernel/record` evaluate `kernel/snapshot.ts` as well. ADR 0019's loading-shape probe
measures exactly that: thirteen carried entries grew by one module and 70 eager statements, which
`scripts/__tests__/eager-closure-budgets.test.ts` refuses — the growth is a pre-existing module made
eager, so no split explains it and shrinkage elsewhere cannot fund it. The reader now decides from a
table it owns, keyed by `PresentedSurface`, so a vocabulary state without a row fails to compile
rather than reading as `unknown`. The lookup is own-property, which also closes the inherited-member
path an `in` test would have opened: a payload naming `constructor` is not a state.

`PRESENTED_SURFACE_STATES` is frozen. `as const` was only a type-level promise on an array the reader
trusted payloads against, so any consumer could move the boundary between a state and `unknown` for
the whole process.

The client normalized the fact on the default response path and returned a digest payload verbatim,
which meant the same field was read two ways depending on `--response-level`. Passing a leveled
payload through without reshaping it is not a licence to skip the fact's own vocabulary, so the
digest path reads it through the same reader; a payload with no key stays byte-identical.

Two doc comments claimed a capture "came from read" something. Both now name the producer that did
the reading.
…es minted

PreresolvedInteractionTarget carries one SurfaceScopedNodes minted from
find's own capture instead of mirroring nodes, surface and presentation
field by field, so the adopting leaf's settle baseline is that value
as-is. PresentedSurface becomes a plain union, and the fact travels
only inside SnapshotState: the top-level BackendSnapshotResult field had
no production writer. Moves the moving-target gate onto the capture-kit
baseline mint.
@thymikee
thymikee force-pushed the feat/3565-presented-surface-carriage branch from ac8289f to 105fa64 Compare October 14, 2026 14:48
The moving-target rest tree copied the baseline shape by hand and left
out presentedSurface, so the baseline after a rest proof differed from
every other one. It now carries the same facts surfaceScopedNodes mints;
it stays local because the snapshot-baseline import would grow the eager
closure of most daemon entries.
@thymikee
thymikee force-pushed the feat/3565-presented-surface-carriage branch from 105fa64 to 9b752cf Compare October 14, 2026 15:05
@thymikee

Copy link
Copy Markdown
Member Author

Thanks. Head is now 9b752cf69, and the stack is rebased onto the latest main (#3594, #3479).

movingTargetRestTree. You were right that the rest-proof baseline dropped presentedSurface. It now carries it.

I first replaced the body with surfaceScopedNodes(snapshot) as you suggested, but CI's eager-closure-budgets refused it. deferred-interaction-outcome.ts is on the eager path of most daemon entries: it went from 38 to 39 modules, and so did ref-frame.ts, snapshot-capture.ts, src/daemon.ts and 22 more. The new module was capture-kit/snapshot-baseline.ts. That eager-closure cost, not the old layering, is why the copy is there.

So the rest tree stays local, carries the same three facts the mint does, and a comment there names the closure reason. moving-target-baseline.test.ts gains a case where a stored settled tree keeps surfaceBundleId and presentedSurface; it fails on the old body. The PR body now names this one exception instead of claiming a single mint.

Live re-run at the new layer-1 code. I rebuilt the runner at 105fa6477, after the rebase that moved the stamp into snapshotForBlockingSystemModal, and ran it on the iPhone 17 simulator:

  • the microphone prompt reads "presentedSurface":"presented" with snapshot --json (5 nodes, Alert first) and with --level digest (nodeCount 5);
  • Home, Home with digest, and the lab after dismissal carry no presentedSurface key.

9b752cf69 changes only the daemon rest-proof baseline since.

The cubic threads you listed were already resolved.

@thymikee
thymikee merged commit 4ae5441 into main Oct 14, 2026
38 checks passed
@thymikee
thymikee deleted the feat/3565-presented-surface-carriage branch October 14, 2026 16:18
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-14 16:48 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