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).

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