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).
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.1declares@polyengine/protocol@^0.2.3) but nothing enforces the reverse edge: anapp 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:
import { abortable }on 0.2.2abortable(A24) on runtime <0.5.1signalparam isundefined, throws on first call → trap naming the import — loudsuspending(A1) unmarked-Promise shapeNeedsJspinaming the import — louddeferCancel(A23) on runtime <0.5.1The 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/protocolA packaged assertion over the copy census (A9): every runtime copy
registers
{url, runtimeVersion, protocolGeneration}on the sharedglobalThisslot, so the one-liner apps can already hand-roll deserves ablessed spelling:
Design questions to settle at implementation time:
sibling bundle is exactly the hazard A9's census exists to name); an
option bag can relax it if a legitimate mixed deployment appears.
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.
matcher must be vendored and minimal. Caret + exact +
>=covers everyreal use; full semver-range grammar is not worth the surface.
protocolGenerationagreement 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 areSymbol.forregistry symbols), filter keys under the ownedpolyengine.*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:
Notes:
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.
buildLoweredImportsees thepost-relay wrapper, which since A23/A24 carries exactly the relayed
marks (
relayMarksin embedder/instantiate.ts) — one scan site coversthe 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.
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.
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).