Skip to content

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

Open
thymikee wants to merge 2 commits into
mainfrom
feat/3565-presented-surface-carriage
Open

thymikee wants to merge 2 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 only: nothing computes a value yet, so no 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 the backend result, 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.

It validates against a table it owns rather than the exported PRESENTED_SURFACE_STATES. A load-time
edge from kernel/record to kernel/snapshot makes the latter eager for every consumer of the shared
wire reader, which ADR 0019's loading-shape probe holds at the merge-base's module count (ADR 0027's
no-growth rule): thirteen carried entries grew by one module and 70 eager statements. The table is
keyed by PresentedSurface, 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 exported vocabulary is frozen — as const was only a type-level promise on the array
the reader trusted payloads against.

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.

Both settle routes now mint their SurfaceScopedNodes baseline from one function,
surfaceScopedNodes in @agent-device/capture-kit/snapshot-baseline, replacing two hand-written
literals that each had to be edited whenever a baseline fact was added. 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.

38 files, +699/−33.

Validation

29aaf3be2, rebased onto main (which had moved nine commits, including the vitest 5 upgrade; the
only conflict was two independent test blocks landing at the same spot in snapshot-state.test.ts, and
both were kept): pnpm typecheck, pnpm lint, pnpm check:layering (exit 0), pnpm check:production-exports,
pnpm format clean, and scripts/__tests__/eager-closure-budgets.test.ts (842) green — the gate that
rejected the first version of this layer. The serialized pnpm check:affected --run is running at these
heads; each head's exit code is recorded in the layer-3 body.

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, that the exported vocabulary survives three
mutation attempts, the digest reader's normalization (which fails when the digest path returns the
payload untouched), the baseline minter, carriage into both the response and the session record
(presented-surface-carriage.test.ts, which pins the fact through both backend routes — a tree-side
field and backend-built state), 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; all three Swift unit tests run and
pass on the iPhone 17 simulator. No output change in this layer, so no device run: the live check is
in layer 3.

@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.19 MB 5.20 MB +4.4 kB
Package (unpacked) 5.19 MB 5.20 MB +4.4 kB
Package (download) 1.56 MB 1.56 MB +1.7 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.5 ms 27.0 ms +0.5 ms
CLI --help 78.6 ms 76.7 ms -1.9 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 from 6180ffe to 4afd24c Compare October 13, 2026 15:09
… 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.
@thymikee
thymikee force-pushed the feat/3565-presented-surface-carriage branch 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.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant