Skip to content

Route snapshot capture sources through one platform-neutral router #4205

Open

Description

@thymikee

Purpose

Snapshot capture source selection must be one product rule for every platform and provider. Today the rule exists only for iOS, in packages/platform-apple/src/snapshot-route.ts, and each channel fills the route's ports with placeholders. Android, macOS, web, WebDriver and Revyl each have one hard-wired source with no shared fallback, refusal or proof vocabulary. Producer facts live in about six owners.

Current state (evidence)

  • The iOS route core: packages/platform-apple/src/snapshot-route.ts (probes, target resolution, acquisition, one fallback, identity stamping, warnings) and snapshot-route-failure-policy.ts (refusal scope).
  • The runner channel (feat(apple): serve the Simulator AX bridge from the XCTest runner #4182) fills the route's ports with constants: isDiscoveryPending: () => false, targetFreshness: 'cached', systemSurfacePresent: async () => 'unknown' (packages/platform-apple/src/runner-runtime.ts). The route reads them at snapshot-route.ts (discovery wait, cached-target refresh, presence probe).
  • Eligibility is split: isEligible in the route, the observe-only bypass in runner-runtime.ts bindBridgeSnapshot, and the preferredBackend verdict pin in packages/capture-kit/src/snapshot-quality-verdict.ts → stable-capture.ts.
  • Producer facts: packages/contracts/src/ios-snapshot.ts, three producer-keyed tables in packages/capture-kit/src/ios-snapshot-acquisition.ts, packages/kernel/src/snapshot.ts (SnapshotProvenance), packages/command-runtime/.../stable-capture.ts, producer switches in packages/command-runtime/src/commands/capture/runtime/snapshot.ts. Revyl's Android tree is labeled appium-source, the same name WebDriver uses for iOS.
  • Single-source platforms: Android packages/platform-android/src/snapshot-capture.ts, macOS packages/platform-apple/src/os/macos/surface-snapshot.ts, Revyl packages/provider-revyl/src/snapshot.ts, WebDriver packages/provider-webdriver/src/webdriver-ios-snapshot.ts.
  • validateTarget is duplicated in snapshot-source/adapter.ts and runner-snapshot-bridge.ts. Three fallback settlers exist: runFallback, runSurfaceFallback, captureOffRoute.
  • ADR 0004 is about 60 KB. docs/adr/FORMAT.md asks for about 8 KB.

Required behavior

  1. A new ADR, "Snapshot capture source routing", states the rule for all platforms. ADR 0004 keeps only the iOS facts (bridge, runner plan, system-surface hosts) and drops its amendment history.

  2. One platform-neutral router in packages/capture-kit owns: the source plan (plan(input) returns the source and the reason), one fallback per capture, the per-target generation circuit, the refusal table (capture scope vs generation scope), occlusion proof, identity stamping, and warnings. It imports no platform package.

  3. Each platform or provider declares its channels. A channel declares only what it has. A channel never fills a port with a constant.

    type SurfaceProof = (device, signal) =>
      Promise<'clear' | Readonly<{ occluded: string }> | 'unproven'>;
    type SourceChannel<T> = Readonly<{
      producer: SnapshotProducer;
      acquire(req: { target: T; hint; signal }): Promise<SourceOutcome>;
      resolveTarget(device, app, signal): Promise<T>;
      refreshTarget?(device, app, signal): Promise<T>;   // replaces targetFreshness
      awaitDiscovery?(device, signal): Promise<void>;    // replaces isDiscoveryPending
      proof: 'none' | SurfaceProof;                      // replaces the presence/foreground pair
      supports: { customActions: boolean; observeOnly: boolean; pinnedBackend: boolean };
    }>;
  4. Owners with more than one channel route through the router: iOS local and iOS provider (Limrun) Simulators (bridge, then apple-runner). Every one-channel owner (Android, macOS, web, WebDriver, Revyl, HarmonyOS, Linux, TV CDP, composing providers, physical iOS, tvOS, visionOS) does not route through the router. It stamps identity through the capture-kit helper and declares its registry row. An owner that gains a second channel moves to the router.

  5. One producer registry in packages/contracts, declared with satisfies Record<SnapshotProducer, ProducerFacts> (channel, truncation, residue, quiet time, ref-namespace owner). The producer union is platform-neutral. Every other producer table derives from the registry.

  6. The refusal fixture contracts/fixtures/ios-snapshot-bridge-refusals.json (feat(apple): serve the Simulator AX bridge from the XCTest runner #4182) becomes the router's refusal table input for every channel that emits typed refusals.

Completion conditions

  • git grep finds no route port filled by a constant (() => false, async () => 'unknown') and no targetFreshness.
  • A layering rule fails when the router module imports a platform or provider package.
  • A repo guard fails when a producer in the registry has no production emitter.
  • A repo guard fails when an ADR cites a file path or test name that does not exist.
  • Existing route behavior tests pass unchanged against the router, for the guest and runner channels. No step deletes, skips, or loosens a behavior assertion to pass.
  • Each step removes the path it replaces in the same PR. No dual routing, no compatibility shim, and no adapter that wraps the old route.
  • Every router interface member has a real implementation in one of the two iOS Simulator channels. No member exists for a hypothetical owner.
  • Each step leaves the touched code simpler: fewer owners of a rule, fewer settlement paths, or fewer producer tables. Reviewers judge this, not line count. A step that only moves code does not merge.
  • Live check: local iOS Simulator, Limrun iOS, local Android emulator. Each capture names the same producer and fallback warnings as before the change.
  • The new ADR is accepted, and ADR 0004 is under about 15 KB.

Migration plan (moved from the ADR 0038 proposal, #4255)

After acceptance, each small PR must merge alone:

  1. Move the provenance carriers and helpers from packages/kernel/src/snapshot.ts to contracts. Migrate the registry consumers atomically. Keep the node and geometry primitives in kernel. Do not allow a kernel-to-contracts cycle.
  2. Simplify in place first, inside packages/platform-apple: remove the placeholder ports, merge the three fallback settlers into one settlement path, and give eligibility one owner. Keep the route behavior tests unchanged.
  3. Lift the simplified route core into packages/capture-kit as the router. Give a source one way to ask for a fresh target: today refreshTarget? and resolveTarget(..., 'refresh') both exist, and the stale-target fallback still calls the second (from the refactor(apple): simplify snapshot route settlement #4278 review). Decide this together with the local source's cached-target refresh, so that local behavior does not change by accident. Then lift, with its refusal-table input and the layering enforcement. Bind both iOS Simulator channels to it and delete the Apple route core in the same PR. If the lifted router is not simpler than the in-place result, stop and record why in this issue.
  4. Move the one-channel owners to the identity-stamp helper and the registry rows. Rename the Revyl Android producer to revyl-android-tree together with its emitter and consumers.
  5. Make ADR 0004 smaller than about 15 KB. First move its unique contracts to their owners. Keep the iOS rules.

Guard inventory

  • Router layering enforcement, with a failing dependency control.
  • Producer-emitter and refusal guards in test/repo-guards/. They reject a reachable production emitter that has no registry row, an unlisted refusal code, and a scope disagreement.
  • An ADR-citation guard that rejects cited paths and test names that do not exist.
  • Select these guards through affected checks, together with docs-prose, docs-links, and agent-guidance.
  • Live producer and fallback parity on the local iOS Simulator, Limrun iOS, and the local Android emulator.

Design detail (moved out of ADR 0038)

ADR 0038 records only the decisions. The detailed design is in this issue comment. It covers the SourceChannel<T> members, planner inputs, circuit keys, target refresh and discovery, the per-owner source list, registry row facts, quiet times, and the Android systemSurfaceOnly classification. Move each item to its owning type or package doc when the router lands. Do not move them back into the ADR.

Dependencies

Blocked by: #4182 (runner-served bridge and refusal fixture), which is stacked on the unmerged Limrun runner layers (stack/limrun-native-runner).
Open question: whether Android's system-UI annotation fits the SurfaceProof port. Decide this in the ADR before code moves.

Activity

  1. apex-sentinel-staging commented on Nov 3, 2026

    @apex-sentinel-staging

    Apex Sentinel · Issue review

    Suggested label: feature

    The issue requests a new platform-neutral snapshot source router, ADR, producer registry, guards, and staged refactor rather than reporting a specific broken capture. It is actionable because it provides concrete current evidence, required interfaces, completion conditions, and a migration plan, though dependencies/open questions need clarification before code starts.

  2. thymikee commented on Nov 6, 2026

    @thymikee
    MemberAuthor

    Design detail moved out of ADR 0038 (#4255)

    ADR 0038 records only the decisions. This comment keeps the detailed design from the proposal text, so it is not lost when the PR branch goes away. Move each item to its owning type or package doc when the router lands. Do not move them back into the ADR.

    Proposal text at fd031ee

    ADR 0038 (proposal): Snapshot capture source routing

    Status

    Proposed for #4205.
    No production migration precedes acceptance.
    On acceptance, this replaces ADR 0004's source-routing rules and ownership.
    ADR 0004 retains iOS acquisition, presentation, geometry, and system-surface contracts.
    Completed migration history stays in git history.

    Rules at a glance

    • Route owners with more than one channel through the capture-kit router.
    • Permit one fallback; share refusal scope, circuits, and warnings.
    • Every producer stamps served identity through capture-kit and declares a contracts registry row.

    Boundary and channels

    @agent-device/capture-kit owns the router: planning, proof, fallback, circuits, identity, warnings.
    Allow neutral contracts, kernel primitives, host-kit capabilities, and capture-kit policy imports.
    Forbid direct, transitive, and dynamic dependencies on platform/provider code.
    Bound runtime owners retain channels across requests and close only owned resources.
    Keep lease/lock authority (ADR 0007).

    Contracts defines SourceChannel<T>:

    Member Contract
    id, producer Separate transport from producer; both bridge transports emit simulator-ax-bridge.
    acquire({ target, hint, proof, signal }) Return acquired/presented evidence, served identity, residue, proof; or typed refusal.
    resolveTarget(device, app, signal) Return real target T, device key, target id, optional verified generation; or typed discovery-pending.
    refreshTarget?(device, app, signal) Verify cached identity; omit when resolution already verifies identity.
    awaitDiscovery?(device, signal) Await detached discovery; omit when none exists.
    proof 'none' or SurfaceProof.
    supports Declare customActions, observeOnly, and supported pinnedBackend values.

    Operations share remaining budget and cancellation.
    Hints separate raw and presented depth; presentation owns scope.
    Discovery-pending requires awaitDiscovery or an available alternate.
    No constant callbacks, targetFreshness, or fabricated pids/generations are allowed.

    SurfaceProof(device, signal) returns 'clear', { occluded: surfaceId }, or 'unproven'.
    'none' requires no separate absence proof and asserts none.
    Occlusion selects a surface-capable channel; unproven acquisition must refuse occluded/unverified reads through integrated checks.
    Never cache absence across captures.

    Both bridges probe registered hosts through runner foreground state.
    Only provider probes may prepare their required runner.
    If unproven, pass host executables into the bridge's pre/post-read process checks.
    Forbid host-side process probes.
    Serve registered hosts in place without activation or rebinding, per ADR 0004.

    Planning, fallback, and refusals

    Pure plan(input) takes ordered owner channels, options, target/proof evidence, circuits, and a current-loop backend pin.
    It returns selected channel, typed reason, and at most one eligible alternate.
    It owns eligibility, observe-only, and pins; unsupported requests fail before acquisition.
    Never relax pins or activate for observe-only.
    Stable capture supplies its current-loop private-AX backend pin; previous snapshots cannot pin its first read.

    One settlement path handles resolution, acquisition, presentation, and alternates.
    Attempt one alternate at most; its failure retains the first reason.
    Cancellation neither falls back nor changes circuits.
    Channels/presenters cannot route recursively.
    XCTest tiers and Android helper recovery stay internal and cannot select another channel.

    Key circuits by owner, device key, channel id, target id, and verified generation.
    Generation refusals disable that channel; new generations clear old circuits.
    Refresh cached targets before honoring disabled circuits and after stale-target evidence.
    If refresh fails, retain only circuit bookkeeping; publish no cached generation.
    Without verified generation, create no generation circuit.

    Generalize contracts/fixtures/ios-snapshot-bridge-refusals.json into router input for all typed-refusal channels.
    Entries key on producer/code and declare scope plus warning consequence; bridge transports share entries.
    Preserve iOS scope:

    Scope Evidence
    Capture Preparation, read-in-flight, tree change, foreground ownership, unresolved coordinates, window placement, surface-host presence or uncertainty.
    Generation Remote-content boundary, abandoned read, malformed tree, presentation invariant failure.
    Runner session Wedged runner-side bridge reader; a new runner session id starts a fresh circuit.

    Unknown codes have generation scope.
    Without a verified generation, an unknown code disables the channel for the current capture and creates no circuit.
    Intercept cancellation before lookup, including abort-signal.
    Unclassified provider errors remain terminal.

    Producers and publication

    Define platform-neutral SnapshotProducer and one contracts registry with satisfies Record<SnapshotProducer, ProducerFacts>.
    Rows declare backend channels, truncation, residue policy, quiet time, and ref-namespace owner.
    Residue distinguishes source-measured and provider-derived facts.
    Derive provenance pairs, iOS subsets, producer warning dispatch, quiet time, and ref behavior here.
    First move provenance-dependent carriers/helpers from packages/kernel/src/snapshot.ts to contracts.
    Keep node/geometry primitives in kernel; forbid kernel-to-contracts cycles.
    Keep bridge quiet time at 300 ms, others at 500 ms; derive it per capture from the served producer.
    Preserve agent-browser refs.

    Local and Limrun iOS Simulators offer bridge first, then apple-runner, and route through the router.
    Every other owner has one channel and does not route through the router: Android, macOS helper/XCTest, web, WebDriver, Revyl, HarmonyOS, Linux, TV CDP, composing providers, and physical iOS, tvOS, and visionOS.
    A one-channel owner calls the capture-kit identity-stamp helper and declares its registry row.
    An owner that gains a second channel moves to the router.
    Unsupported owners such as Vega declare no channel.
    WebDriver keeps appium-source on both backend channels.
    Rename Revyl Android to revyl-android-tree; sharing a parser does not make it Appium.

    Stamp identity once after presentation: actual producer, intent, served target/generation, presentation key, residue.
    Served surfaces override planned app lineage and discard app-generation residue.
    Failed generation resolution carries per-capture unknown-generation residue.
    Channels without generation support declare it unavailable; surfaces carry no app generation.
    Preserve iOS producer names and fallback wording.
    One-channel success adds no fallback warning; assembly retains quality/freshness disclosures.

    Android systemSurfaceOnly stays acquisition metadata, not SurfaceProof.
    packages/platform-android/src/snapshot-content-recovery.ts accepts usable system-only trees.
    This and system-bar labels prove no absence: use proof: 'none', preserving disclosure/recovery.
    A future app-only channel needs independent window-ownership evidence tied to its read.

    Consequences

    The migration plan, its guard inventory, and the live parity checks live in #4205.
    Repo guards enforce the router's layering, the producer emitters, and the refusal table scope.

    Rationale and alternatives

    Readers, decoding, discovery, process checks, native budgets, geometry, and lifecycle stay platform-specific.
    Presentation stays in capture-kit and runner-side Swift.

    Rejected: Apple placeholder ports (absent capabilities), provider planners (duplicated policy), annotations as absence proof (no pre-read evidence), and routing one-channel owners through the router (the planner never chooses for them).
    The cost is provenance migration and explicit channels.

  3. thymikee commented on Nov 7, 2026

    @thymikee
    MemberAuthor

    Status on main (753263f), so the next step is clear:

    The required behavior, completion conditions and guard inventory above still apply as written.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions