Skip to content

protocol/runtime: requireRuntime() floor assertion + unknown-mark detection (close the silent version-skew quadrant) #245

Description

@lannbot

Preventive hardening for the one version-skew quadrant nothing mechanical
covers today: a shape-preserving, semantics-changing, opt-in mark on a
runtime too old to know it is silently ignored. Two independent proposals,
either shippable alone.

Context: the skew taxonomy

The solver enforces runtime→protocol (@polyengine/runtime@0.5.1 declares
@polyengine/protocol@^0.2.3) but nothing enforces the reverse edge: an
app can hold a protocol new enough to export a mark next to a runtime too
old to read it. Marks are read by brand at wrap/lower time, so the old
runtime ignores unknown marks without a sound. How that lands depends on
the mark's grammatical role:

class example skew behavior
new export, old protocol import { abortable } on 0.2.2 build/module-resolution error — loud
mark changes calling convention abortable (A24) on runtime <0.5.1 host's signal param is undefined, throws on first call → trap naming the import — loud
suspending (A1) unmarked-Promise shape refused at the boundary, NeedsJspi naming the import — loud
opt-out mark requesting legacy behavior deferCancel (A23) on runtime <0.5.1 vacuously honored (old runtimes already run to completion) — silent and harmless
opt-in, shape-preserving, semantics-only (none today) silently ignored — the dangerous quadrant

The last row has no current member, but the counterfactual shows how easily
one lands there: had A23 shipped discard as opt-in (discardCancel(fn))
instead of as the new default, that mark on an old runtime would silently
not discard — guests stall, nothing throws. Same shape for any future
"idempotent, dedupe retries"-style mark: ignored ⇒ duplicated side effects,
zero noise.

Today's guards are non-mechanical: the amendment names the runtime floor a
mark needs, and the consumer raises its own dependency floor when adopting
one.

Proposal 1: requireRuntime(range) in @polyengine/protocol

A packaged assertion over the copy census (A9): every runtime copy
registers {url, runtimeVersion, protocolGeneration} on the shared
globalThis slot, so the one-liner apps can already hand-roll deserves a
blessed spelling:

import { requireRuntime } from "@polyengine/protocol";
requireRuntime("^0.5.1"); // throws, naming every registered copy, if unsatisfied

Design questions to settle at implementation time:

  • All-copies vs any-copy. All-copies is the conservative read (a stale
    sibling bundle is exactly the hazard A9's census exists to name); an
    option bag can relax it if a legitimate mixed deployment appears.
  • Call-order. The census only holds copies whose modules have
    evaluated. Calling before the runtime import registers nothing to check
    — either throw on an empty census by default (fail-closed) or document
    the ordering requirement. Fail-closed seems right for an assertion API.
  • Semver subset. protocol is dependency-free by contract (A9); the
    matcher must be vendored and minimal. Caret + exact + >= covers every
    real use; full semver-range grammar is not worth the surface.
  • Should it also assert protocolGeneration agreement while it is there
    (cheap, and generation mismatches are the other census-visible skew)?

Proposal 2: unknown-mark detection at import wrap time

The runtime can enumerate every registered brand on a host function:
Object.getOwnPropertySymbols(fn) + Symbol.keyFor (all brands are
Symbol.for registry symbols), filter keys under the owned polyengine.*
namespace, and compare against the runtime's known-mark set. An
unrecognized key means "this import carries a mark this runtime is too old
to honor" — exactly the silent quadrant, converted into a named, loud
signal at wrap time:

import 'dial' carries mark polyengine.discardCancel/1, which this runtime
(@polyengine/runtime 0.5.1) does not implement — the mark will be ignored.

Notes:

  • Cost: one symbol scan per import wrap (wrap-time, not call-time).
  • Warn vs refuse: warn by default. A shared imports record serving two
    runtime copies of different vintages is legal under A9; refusing would
    break the newer copy's legitimate use. A strict opt-in (embedder option)
    could upgrade to refusal.
  • Scan sites: the executor's buildLoweredImport sees the
    post-relay wrapper, which since A23/A24 carries exactly the relayed
    marks (relayMarks in embedder/instantiate.ts) — one scan site covers
    the conventions path. Raw-exec embedders hand functions directly; same
    site covers them. A2 prototype-method marks are relayed onto the
    dispatch wrapper before the executor sees it, so no separate
    prototype walk is needed — verify at implementation time.
  • Filter discipline: only diagnose keys matching the owned namespace
    prefix with the current generation suffix or lower; unknown keys of a
    HIGHER generation are the ecosystem-migration case and deserve the
    louder half of the message.
  • Non-mark brands ride prototypes (error classes, handles), not import
    functions, so the scan should not fire on them in practice; keep the
    known-set comparison rather than a mark/non-mark heuristic anyway.

Priority

p3: the dangerous quadrant is empty today, and the introduction discipline
(amendments name their floor) is holding. Escalate to p2 the moment either
(a) an opt-in semantics-only mark is proposed, or (b) a consumer
version-mixing incident lands where either proposal would have named the
skew.

Refs: A9 (census, #83), A23/A24 (the mark family, #241), A22 (host-ABI
versioning).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestp3Lowest priority: watchlists, doc-only adjudications, deletion candidates

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions