Commit Graph
1 Commits
Author SHA1 Message Date
Vance IngallsandClaude Opus 4.8 933b88ec33 feat(studio): resolver-parity shadow tripwire (decoupled telemetry) (#1547)
* feat(studio): resolver-parity shadow tripwire (decoupled telemetry)

New sdkResolverShadow.ts module: checks whether the SDK session resolves
the same element id the server path would address, then verifies value
parity after in-memory dispatch. Emits sdk_resolver_shadow telemetry on
divergence. Decoupled from STUDIO_SDK_CUTOVER_ENABLED via its own flag
STUDIO_SDK_RESOLVER_SHADOW_ENABLED (default false).

Headline signal: element_not_found — the resolver divergence class that
caused the v0.6.110 regression. Writer-parity suite (#1533) cannot see
this class; this tripwire exists specifically to catch it.

All 12 acceptance-test-plan items pass (A1-A3, B4-B6, C7-C10, D11, E12).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): default STUDIO_SDK_RESOLVER_SHADOW_ENABLED to true

Tripwire should run out of the box — operators opt out, not in.

* fix(studio): resolver shadow must not mutate the live session (restore via inverse patches)

The shadow runs on the SAME sdkSession the cutover path uses, one line before
sdkCutoverPersist. sdkResolverShadowCheck dispatched the edit into that session
to read values back but never undid it — so with the shadow enabled the edit was
pre-applied, and sdkCutoverPersist then saw before === after and silently fell
back to the server path. Enabling the tripwire disabled cutover.

Fix: capture the inverse patches of the shadow dispatch (session.on("patch")) and
applyPatches them to restore the session before returning, on every path
(success, dispatch_error, element_not_found after dispatch). The session ends the
check exactly as it started; cutover's before/after diff is unaffected.

Tests: B5 now asserts the live session is restored (color back to original, not
left on the shadow value) and B5b proves a cutover-style before/dispatch/after
diff still fires after a preceding shadow run. The earlier B5 used two separate
sessions and so never exercised the shared-session path the bug lived in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): extend resolver shadow to timing/delete/gsap-add chokepoints

The shadow only ran on the DOM-edit path (inline-style/text/attribute via
onTrySdkPersist) — blind to the rest of the cutover surface, which is where the
resolver bugs that motivated it actually live (v0.6.110 was a GSAP property op;
CF2 #15/#16 were timing-resolver bugs). On-for-everyone telemetry that only sees
style/text/attr edits misses the riskiest paths.

Adds a read-only element-resolution tripwire (recordResolverParity) — emits the
headline `element_not_found` signal when the SDK can't resolve a target the
server path is addressing, with NO dispatch/mutation. Wired before the cutover
gate (decoupled) in the element-targeted chokepoints: sdkTimingPersist,
sdkDeletePersist, and sdkGsapTweenPersist's add op.

To avoid a circular import (sdkResolverShadow imported patchOpsToSdkEditOps from
sdkCutover; sdkCutover now imports recordResolverParity from sdkResolverShadow),
patchOpsToSdkEditOps moves to a neutral sdkOpMapping.ts that both import from.

animationId-resolving GSAP ops (set/remove tween, keyframe ops, deleteAllForSelector)
resolve an animation, not an element, so element-resolution parity doesn't apply —
left as a follow-up (separate animation-resolution signal).

Tests: recordResolverParity emit-on-divergence / parity-no-op / flag-off-no-op /
read-only (no mutation). Full studio suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): resolver shadow covers animationId GSAP ops (animation_not_found)

Extends the tripwire to the GSAP-edit surface that resolves an animationId
rather than an element: setGsapTween/removeGsapTween, addGsapKeyframe,
removeGsapKeyframe, removeGsapProperty, removeAllKeyframes, convertToKeyframes.

Adds recordAnimationResolverParity — read-only, emits the new `animation_not_found`
kind when the SDK can't resolve the animationId the server GSAP path is
addressing. The SDK's resolvable animation ids are the located ids attached to
elements (buildAnimationIdMap), so a target absent from every element's
animationIds is a resolver divergence. No dispatch, no mutation.

Wired centrally in dispatchGsapOpAndPersist via an optional resolverTarget arg
(runs before its cutover gate); sdkGsapTweenPersist records inline before its
own leading gate (set/remove → animation parity, add → element parity).
deleteAllForSelector resolves by selector, not an id — left out.

Tests: animation_not_found on unresolved id / parity no-op on a real located id /
flag-off no-op. Full studio suite green; no circular dep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): resolver shadow review fixes — divergence-only emit + restore in finally

Addresses PR #1547 review (Miga, Rames):

- #1 (medium): runResolverShadow emitted `sdk_resolver_shadow` on every edit,
  including parity (mismatchCount 0) — a PostHog event per style/text/attr edit
  at default-ON. Now emits only on divergence, matching recordResolverParity /
  recordAnimationResolverParity. Parity is silent across all three paths.
- restore() moved into a `finally` in sdkResolverShadowCheck: if checkOpValue
  throws between dispatch and restore, the patch listener no longer leaks and the
  shared session is always undone (the cutover-coupling failure mode this module
  guards against). dispatch errors still return dispatch_error.
- Comment on why batch is compatible with per-op inverse capture (a future SDK
  refactor that coalesces batch must keep emitting inverse patches).

Tests: A2 now forces a divergence to emit; A2b pins parity-is-silent; A4 covers
null/undefined hfId no-op. 26 shadow tests, full studio suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 22:25:29 -07:00