From 912ad718043ee23bfa55b5f6c50324713c8cf2c4 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 22 Aug 2026 21:04:23 +0530 Subject: [PATCH] feat(spec): the runtime entry declares the action encoding it serializes @sanderling/spec 0.0.3 and earlier put an authored Scroll's container point on the wire as both endpoints. A host that reads pre-computed endpoints as authoritative executes that as a 250ms press and hold travelling zero distance, and neither half fails. ACTION_WIRE_CONTRACT lets the host refuse the pairing rather than run it. --- pkg/spec/src/runtime-entry.ts | 14 ++++++++++++++ pkg/spec/test/web-runtime.test.ts | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/pkg/spec/src/runtime-entry.ts b/pkg/spec/src/runtime-entry.ts index 2436982..a2dd7b6 100644 --- a/pkg/spec/src/runtime-entry.ts +++ b/pkg/spec/src/runtime-entry.ts @@ -43,6 +43,19 @@ type SerializedActionShape = // its login steps measures a policy's exposure as larger than it was. export type ActionSource = "setup" | "seeded"; +// ACTION_WIRE_CONTRACT names the encoding serializeAction emits, and is +// declared to the host so it can refuse a package this binary cannot decode. +// It is versioned apart from the package because the encoding and the package +// move independently: the host has to know which reading of these fields it is +// being handed, not which release shipped it. Bump it whenever the meaning of +// a field changes, not only its name. +// +// Revision 2 is the first to declare itself. Revision 1 (@sanderling/spec +// 0.0.3 and earlier) sent an authored Scroll's container point as BOTH +// endpoints, which a host that reads pre-computed endpoints as authoritative +// executes as a drag from a point to itself. +export const ACTION_WIRE_CONTRACT = "action-wire/2"; + const DEFAULT_SWIPE_DURATION = 250; // pointOf resolves a target to {x, y, selector?}. Builtins and resolved ax @@ -157,6 +170,7 @@ export function installRuntime( evaluateExtractors: () => Record, ): void { const rng = new Pcg(host.seedHi(), host.seedLo()); + defineLockedGlobal("__sanderlingActionEncoding__", ACTION_WIRE_CONTRACT); const resolveRoot = typeof root === "function" ? root : () => root; const resolveSetup = () => (globalThis as { setup?: GeneratorNode }).setup ?? null; diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index e1e8c18..5628852 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -83,6 +83,14 @@ test("installRuntime defined the host-invoked globals", () => { assert.equal(typeof g.__sanderling__, "object"); }); +// The host refuses to run a bundle whose declaration is not the encoding it +// decodes (ActionWireContract, internal/verifier/marshal.go). The literal is +// asserted on both sides so neither half can move to a new encoding alone. +test("installRuntime declares the action wire contract to the host", () => { + const g = globalThis as Record; + assert.equal(g.__sanderlingActionEncoding__, "action-wire/2"); +}); + const { fakeElement, withFakeDocument } = await import("./web-dom-harness.ts"); type FakeElementSpec = Parameters[0];