Skip to content

A8: process-global brands via @deltic/protocol; loud multi-copy diagnosis (#83) - #105

Merged
lann merged 2 commits into
mainfrom
a8-protocol-83
Aug 11, 2026
Merged

A8: process-global brands via @deltic/protocol; loud multi-copy diagnosis (#83)#105
lann merged 2 commits into
mainfrom
a8-protocol-83

Conversation

@lann

@lann lann commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Implements the operator decision on #83, restructured per the review discussion: the identity mechanism is Symbol.for registry brands (layer 1), @deltic/protocol is their home — a dependency-free vocabulary package — not the mechanism (package identity is exactly what keeps failing in a registry-less ecosystem, and cannot survive the two-bundles case at all), and multi-copy graphs are diagnosed, never refused (layer 3).

Contract (amendment A8)

  • Every cross-boundary brand is a generation-suffixed Symbol.for key (deltic.witError/1, …): N copies agree on every brand by construction, including separately-built bundles. Bumping a generation is a breaking vocabulary change — an ecosystem migration event.
  • Class identity is not part of the embedder API. Same-copy instanceof still works and stays the documented spelling in single-copy graphs; the predicates (isWitError, isTrap, …) are the multi-copy-robust form. No Symbol.hasInstance override (a consumer subclass would inherit it and match any branded value).
  • Hand-rolled brands are legal (contract markers, not a security boundary) — host-module packages can carry zero deltic imports.
  • Stateless vs stateful: a copy-B WitError / suspending() mark is fully honored by copy A. Stateful values (Stream/Future/ErrorContext/resource wrappers) are recognized-but-refused with a named cross-copy error listing both copies' URLs — replacing the silent producer-adaptation fallback that would pump a foreign Stream by its async iterator and quietly void A5's identity guarantees.
  • Copy registry on globalThis[Symbol.for("deltic.runtimeCopies/1")]: registration at embedder-module evaluation; the census rides every cross-copy error, and the unbranded-throw trap names the multi-copy hypothesis (the Module identity across multiple runtime copies: symbol brands, app-owned import maps, loud dual-copy detection #83 signature — typically a pre-A8 copy's WitError) instead of leaving the latent puzzle.

The package

@deltic/protocol: brands, canonical error classes (moved from embedder/errors.ts + cabi/trap.ts), suspending() (moved from jspi/suspending.ts; the module-local-symbol convention is repealed), predicates, registry. Everything re-exported from @deltic/runtime/embedder unchanged — no consumer code change; consumer import maps gain exactly one entry (@deltic/protocol), same family as the existing @deltic/* pins.

Pins

  • tools/release-bundle/dual_copy_test.ts — source tree + release bundle as two genuine copies (query-string cache-busting shares every submodule below the entry and would pass vacuously). Pins: shared census; foreign WitError honored end-to-end through a fallible import; cross-copy suspending honor; foreign Stream refused naming both URLs; hand-rolled brand accepted; unbranded throw carries the census hint.
  • runtime/tests/embedder/cross_copy_test.ts — the foreign paths via hand-rolled brands, no bundle build (runs in test-runtime).

Layer 2 (docs half)

docs/consumers.md: the application owns the import map — sibling packages carry no consumer-resolvable @deltic/* pins; invariant "exactly one deltic source in the resolved graph". Sibling-side moves are their PRs.

Drive-bys

  • smoke-tls recipe was broken at HEAD (fab5c2e added POLYMORPH_ROOT/WOSH_ROOT env reads to smoke-c0's common.ts; the recipe had no --allow-env) — fixed with provenance comment.
  • tools/smoke-c0/deno.json (deliberately non-workspace) maps the new bare specifier; ports lockfiles record the new workspace link.

Gates

Full just gates pass: protocol 22/0 · runtime 392/0/3 · wasi-shims 52/0/1 · ct-runner 17/0/1 · bundle 2/0 (incl. the dual-copy pin) · conformance 69/0 · sched-seeds ×2 · examples · translate · ports · webrtc 10/0 · sm-pinned OK (jsc-pinned skipped, arm64) · websocket-conformance 55/55 · smoke-tls PASS · smoke-c0 legs 1–4 PASS · browsers chromium+firefox OK.

Closes #83 layers 1+3; layer 2 sibling follow-ups tracked there.

…83)

Consumer evidence (wosh finding 26): package-local deno.json pins put four
extra runtime copies in one graph, and every check keyed on class identity
fails there latently — instanceof WitError on the first error path only,
a foreign Stream silently pumped instead of transferred.

- contracts/embedder-api.md amendment A8: every cross-boundary brand is a
  Symbol.for registry symbol (generation-suffixed; bumping one is an
  ecosystem migration event); class identity is not part of the embedder
  API; multi-copy graphs are diagnosed, never refused.
- new @deltic/protocol workspace package (dependency-free): the brand
  vocabulary, canonical error classes, suspending()/isSuspending, the
  recognition predicates, the copy registry, PROTOCOL_GENERATION.
  @deltic/runtime/embedder re-exports it all unchanged; consumer import
  maps gain exactly one new entry (@deltic/protocol).
- runtime: boundary recognition converted to brand predicates
  (instantiate fail path, producer failures); stateful values — Stream/
  Future/ErrorContext/resource wrappers — refuse foreign copies with a
  named cross-copy error incl. the copy census, never the silent
  producer-adaptation fallback; the unbranded-throw trap and the
  cross-store assert append the census hint. Copy-private internals
  (SharedStreamImpl, CopyEnd, NameCollisionError) deliberately keep
  instanceof / carry no brand.
- wasi-shims: Pollable/ExitError branded; poll() already consumes
  pollables structurally.
- pins: tools/release-bundle/dual_copy_test.ts — the release bundle is
  the genuine second copy (query-string cache-busting shares every
  submodule and would pass vacuously); runtime/tests/embedder/
  cross_copy_test.ts covers the foreign paths with hand-rolled brands.
- docs/consumers.md: the app-owned import-map convention (#83 layer 2).
- drive-bys: smoke-tls recipe gains --allow-env (common.ts's env reads
  landed in fab5c2e without it — broken at HEAD); smoke-c0's
  non-workspace config maps the new bare specifier; ports lockfiles
  record the new workspace link.

just gates: full pass (jsc-pinned skipped — no arm64 channel).
main took A8 for the future-drop-abandonment amendment (deltic#90/#97,
c855b33), so the module-identity amendment renumbers A8 -> A9 everywhere
(contracts section + all code comments, messages, and test names; main's
A8 references untouched). Other resolutions: smoke-tls --allow-env fixed
on both sides — kept main's comment with the scoped flag variant; import
unions in resources.ts / task/streams.ts; cross_copy_test's WrapperState
literal gains #86's lends/pendingDrop fields.

Full just gates re-run on the merged tree: runtime 451/0/3, protocol
22/0, wasi-shims 52/0/1, ct-runner 17/0/1, bundle 2/0 (incl. the A9
dual-copy pin), conformance 69/0, sched-seeds x2, examples, translate,
ports, webrtc 10/0, sm-pinned OK (jsc skipped arm64), websocket 55/55,
smoke-tls PASS, smoke-c0 PASS, browsers chromium+firefox OK.
@lann
lann merged commit 751be16 into main Aug 11, 2026
4 checks passed
@lannbot
lannbot deleted the a8-protocol-83 branch August 23, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Module identity across multiple runtime copies: symbol brands, app-owned import maps, loud dual-copy detection

1 participant