space-data-module-sdk 0.8.10 → 0.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,12 +21,14 @@
21
21
  import {
22
22
  createBrowserModuleHarness,
23
23
  toLoadableWasmBytes,
24
+ zeroWasmBytes,
24
25
  } from "./browserModuleHarness.js";
25
26
  import {
26
27
  resolveModuleSignaturePolicy,
27
28
  verifyModuleArtifact,
28
29
  } from "../bundle/signing.js";
29
30
  import { attachHostDispatch, inspectModule } from "./isomorphicLoaderCore.js";
31
+ import { assertArtifactRuntimeTarget } from "./runtimeTargetGate.js";
30
32
 
31
33
  export { inspectModule, attachHostDispatch };
32
34
 
@@ -203,21 +205,65 @@ export async function loadModule(options = {}) {
203
205
  );
204
206
  }
205
207
 
206
- if (signaturePolicy) {
208
+ const runtimeKind = options.runtimeKind ?? "wasmedge";
209
+
210
+ // ONE READ of the artifact, serving every question this function asks of the
211
+ // bytes: the signature, the runtime-target declaration, and the profile
212
+ // inspection below. Each of those used to open the file for itself. The
213
+ // plaintext buffer this function materialized is scrubbed the moment the
214
+ // last of them is answered, matching the browser harness's
215
+ // ownedArtifactBytes contract.
216
+ const runtimeHostRequested =
217
+ String(options.hostProfile ?? "").trim().toLowerCase() === "runtime-host" ||
218
+ Array.isArray(options.modules) ||
219
+ (typeof options.defaultModuleId === "string" &&
220
+ options.defaultModuleId.trim().length > 0);
221
+ // The profile inspection is the ONLY consumer here that needs a compiled
222
+ // module. The runtime-host path does not take it, and it must not start
223
+ // paying a V8 compile of the whole artifact on every load just because the
224
+ // runtime-target gate arrived — the gate reads a custom section, which is a
225
+ // byte-level question.
226
+ const needsCompiledModule =
227
+ runtimeKind === "wasmedge" &&
228
+ !runtimeHostRequested &&
229
+ !options.wasmEdgeRunnerBinary;
230
+ const needsBytes = Boolean(signaturePolicy) || runtimeKind === "wasmedge";
231
+ let artifactBytes = null;
232
+ let wasmModule = null;
233
+ let embeddedManifest = null;
234
+ if (needsBytes) {
207
235
  const { readFile } = await import("node:fs/promises");
208
- await verifyModuleArtifact(await readFile(source), signaturePolicy);
236
+ artifactBytes = new Uint8Array(await readFile(source));
237
+ if (signaturePolicy) {
238
+ await verifyModuleArtifact(artifactBytes, signaturePolicy);
239
+ }
240
+ if (runtimeKind === "wasmedge") {
241
+ const { locateEmbeddedPlgManifest } = await import(
242
+ "../compliance/index.js"
243
+ );
244
+ embeddedManifest =
245
+ locateEmbeddedPlgManifest(artifactBytes)?.decoded ?? null;
246
+ }
247
+ if (needsCompiledModule) {
248
+ wasmModule = await WebAssembly.compile(toLoadableWasmBytes(artifactBytes));
249
+ }
250
+ zeroWasmBytes(artifactBytes);
251
+ artifactBytes = null;
209
252
  }
210
253
 
211
- const runtimeKind = options.runtimeKind ?? "wasmedge";
212
254
  if (runtimeKind === "wasmedge") {
213
- const runtimeHostRequested =
214
- String(options.hostProfile ?? "").trim().toLowerCase() === "runtime-host" ||
215
- Array.isArray(options.modules) ||
216
- (typeof options.defaultModuleId === "string" &&
217
- options.defaultModuleId.trim().length > 0);
218
- if (!runtimeHostRequested && !options.wasmEdgeRunnerBinary) {
219
- const { readFile } = await import("node:fs/promises");
220
- const inspection = await inspectModule(await readFile(source));
255
+ // MIRROR OF THE BROWSER GATE. Enforcing the declaration on one leg only is
256
+ // itself a divergence between the legs: a `["browser"]`-only artifact
257
+ // handed to WasmEdge would run until it reached something WasmEdge does
258
+ // not serve, while the reverse case is refused at the door. Both legs read
259
+ // the same declaration and refuse the same way.
260
+ assertArtifactRuntimeTarget({
261
+ embeddedManifest,
262
+ manifest: options.manifest,
263
+ leg: "wasmedge",
264
+ });
265
+ if (needsCompiledModule) {
266
+ const inspection = await inspectModule(wasmModule);
221
267
  if (
222
268
  (inspection.profile === "standalone" || inspection.profile === "module-host-abi") &&
223
269
  inspection.exports.includes("_start")
@@ -0,0 +1,256 @@
1
+ /**
2
+ * THE RUNTIME-TARGET GATE — one assert, called by every loader that stands for
3
+ * a runtime leg.
4
+ *
5
+ * Composed flows derive their `runtimeTargets` from their parts (see
6
+ * `collectFlowRuntimeTargets` in `src/flow/flowCompiler.js`), so an artifact
7
+ * that legitimately runs on only ONE leg now exists. That artifact must never
8
+ * be loaded QUIETLY on a leg it does not declare: the failure would surface as
9
+ * a hostcall trap several frames deep in a capability that leg cannot serve,
10
+ * which is indistinguishable from the cross-runtime divergence the isomorphism
11
+ * invariant exists to catch. Refuse at the door, by name, quoting the
12
+ * declaration that made it ineligible.
13
+ *
14
+ * The gate lives here rather than inside one harness because there are two
15
+ * doors into a composed artifact — `createBrowserModuleHarness` (a single
16
+ * module) and `createFlowRuntimeHost` (the composed flow runtime) — and a gate
17
+ * on only one of them is not a gate.
18
+ *
19
+ * Browser-safe: no node builtins, no bundle/signing surface, and the embedded
20
+ * declaration is read with `WebAssembly.Module.customSections`, which needs no
21
+ * copy of the source bytes (they are scrubbed by the time this runs).
22
+ */
23
+
24
+ import { SDS_MANIFEST_SECTION_NAME } from "../bundle/constants.js";
25
+ import { decodePlgManifest, isPlgManifestBuffer } from "../manifest/plgCodec.js";
26
+ import { LegIncompatibleCapabilityIds } from "../capabilities.js";
27
+
28
+ /**
29
+ * `wasi` is the STRICT PORTABILITY BASELINE, not a fourth runtime: compliance
30
+ * confines a `wasi`-declaring artifact to the pure WASI capability subset and
31
+ * the `command` invoke surface. So a declaration of `wasi` admits either leg —
32
+ * WITH ONE EXCEPTION, which is why capabilities are part of this question.
33
+ * `pipe` is in both the standalone-WASI subset and the browser-incompatible
34
+ * set, so `runtimeTargets:["wasi"] + capabilities:["pipe"]` passes compliance
35
+ * (it names no browser target for the rule to fire on) and would otherwise be
36
+ * admitted to the browser leg against the SDK's own policy. The baseline
37
+ * admits a leg only when the artifact carries nothing that leg cannot serve.
38
+ *
39
+ * DO NOT "UNIFY" THIS WITH THE LITERAL PATH. A declaration that names the leg
40
+ * outright is NOT capability-checked here, and that asymmetry is deliberate:
41
+ *
42
+ * declaration -> trust the author; inference -> prove it.
43
+ *
44
+ * An explicit `browser` target is the author's statement and the embedder's
45
+ * business — five of the browser-incompatible capabilities (`network`, `ipfs`,
46
+ * `protocol_dial`, `protocol_handle`, `wallet_sign`) ARE served by
47
+ * `BrowserHost` when the embedder injects an adapter, so refusing them at load
48
+ * time would break legitimate embedders. Publish-time compliance already
49
+ * refuses the incoherent declaration. The `wasi` path, by contrast, is an
50
+ * inference the SDK makes on the author's behalf, and an inference must be
51
+ * conservative.
52
+ */
53
+ const LEG_SATISFYING_TARGETS = Object.freeze({
54
+ browser: Object.freeze(["browser", "wasi"]),
55
+ wasmedge: Object.freeze(["wasmedge", "wasi"]),
56
+ });
57
+
58
+ const WASI_PORTABILITY_TARGET = "wasi";
59
+
60
+ function legIncompatibleCapabilities(capabilities, leg) {
61
+ const incompatible = LegIncompatibleCapabilityIds[leg];
62
+ if (!incompatible || !Array.isArray(capabilities)) return [];
63
+ return capabilities
64
+ .map((capability) =>
65
+ typeof capability === "string"
66
+ ? capability
67
+ : (capability?.capabilityId ?? capability?.name ?? ""),
68
+ )
69
+ .filter((capability) => incompatible.includes(capability))
70
+ .sort();
71
+ }
72
+
73
+ /**
74
+ * Does a declared target set admit this leg?
75
+ *
76
+ * ONE RULE, used by the loaders AND by the flow compiler's derivation. Two
77
+ * rules for one field is how a manifest comes to mean different things to the
78
+ * compiler and to the door: the compiler stripped the browser leg from a
79
+ * `["wasi","wasmedge"]` part while the gate happily admitted the same
80
+ * declaration to the browser.
81
+ *
82
+ * An empty/absent declaration admits everything, matching compliance, which
83
+ * skips the runtime-target rule on an absent field.
84
+ *
85
+ * @param {string[]|undefined} targets declared runtimeTargets
86
+ * @param {string} leg the runtime target being asked about
87
+ * @param {Array<string|{capabilityId?: string, name?: string}>} [capabilities]
88
+ * the artifact's declared capabilities; consulted only to keep the `wasi`
89
+ * baseline from admitting a leg that cannot serve them
90
+ */
91
+ export function runtimeTargetSatisfies(targets, leg, capabilities) {
92
+ if (!Array.isArray(targets) || targets.length === 0) return true;
93
+ const accepted = LEG_SATISFYING_TARGETS[leg] ?? [leg];
94
+ if (targets.includes(leg)) return true;
95
+ if (!targets.some((target) => accepted.includes(target))) return false;
96
+ // Admitted only via the portability baseline — prove it, do not assume it.
97
+ return (
98
+ targets.includes(WASI_PORTABILITY_TARGET) &&
99
+ legIncompatibleCapabilities(capabilities, leg).length === 0
100
+ );
101
+ }
102
+
103
+ export class RuntimeTargetError extends Error {
104
+ constructor(message, { declaredTargets, source, leg }) {
105
+ super(message);
106
+ this.name = "RuntimeTargetError";
107
+ this.code = "runtime-target-out-of-scope";
108
+ this.declaredTargets = declaredTargets;
109
+ this.leg = leg;
110
+ // "embedded" (the artifact's own signed record) or "caller" (the manifest
111
+ // the loader was handed). Which one refused is evidence, not trivia.
112
+ this.declarationSource = source;
113
+ }
114
+ }
115
+
116
+ export function normalizedRuntimeTargets(manifest) {
117
+ return Array.isArray(manifest?.runtimeTargets)
118
+ ? manifest.runtimeTargets
119
+ .map((target) => String(target ?? "").trim().toLowerCase())
120
+ .filter(Boolean)
121
+ : [];
122
+ }
123
+
124
+ /**
125
+ * The artifact's OWN declaration, read straight out of the embedded `$PLG`
126
+ * custom section, so a caller that hands a loader nothing but bytes still gets
127
+ * the refusal.
128
+ *
129
+ * @param {WebAssembly.Module} wasmModule
130
+ * @returns {string[]} declared targets, or [] when the artifact declares none
131
+ */
132
+ export function embeddedPlgManifest(wasmModule) {
133
+ let sections;
134
+ try {
135
+ sections = WebAssembly.Module.customSections(
136
+ wasmModule,
137
+ SDS_MANIFEST_SECTION_NAME,
138
+ );
139
+ } catch {
140
+ return null;
141
+ }
142
+ for (const section of sections ?? []) {
143
+ try {
144
+ const bytes = new Uint8Array(section);
145
+ if (!isPlgManifestBuffer(bytes)) continue;
146
+ // FIRST DECODABLE MANIFEST WINS — the same rule the node-side locator
147
+ // (`locateEmbeddedPlgManifest`) applies. Skipping a manifest merely
148
+ // because its runtimeTargets are empty made this reader disagree with
149
+ // that one on the very same bytes, so an artifact could be scoped by one
150
+ // declaration and enforced against another.
151
+ return decodePlgManifest(bytes);
152
+ } catch {
153
+ // An UNDECODABLE section is not a declaration. Keep looking.
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ export function embeddedRuntimeTargets(wasmModule) {
160
+ return normalizedRuntimeTargets(embeddedPlgManifest(wasmModule));
161
+ }
162
+
163
+ /**
164
+ * Resolve which declaration refuses this leg, if either does.
165
+ *
166
+ * EITHER source can refuse, and the EMBEDDED one wins on conflict. The
167
+ * embedded `$PLG` is what the artifact's signature covers; `manifest` is
168
+ * caller input. Trusting the caller first would make
169
+ * `{manifest: {runtimeTargets: ["browser"]}}` a bypass for a signed
170
+ * WasmEdge-only artifact — and the isomorphic flow host passes exactly such a
171
+ * caller-side manifest for every child it mounts.
172
+ *
173
+ * An artifact that declares NOTHING is unconstrained, matching compliance,
174
+ * which skips the runtime-target rule on an absent field.
175
+ *
176
+ * @returns {{targets: string[], source: "embedded"|"caller"}|null}
177
+ */
178
+ export function resolveRuntimeTargetRefusal({
179
+ wasmModule,
180
+ manifest,
181
+ leg,
182
+ embeddedManifest: providedEmbeddedManifest,
183
+ }) {
184
+ // A caller that already located the artifact's own manifest from BYTES (the
185
+ // node leg does, via `locateEmbeddedPlgManifest`) passes it here rather than
186
+ // making this gate compile the module just to read a custom section. The two
187
+ // locators apply the same first-decodable-wins rule; the byte-side one can
188
+ // additionally reach a manifest carried in a bundle entry or a data segment,
189
+ // which only ever makes that leg MORE likely to refuse — the safe direction.
190
+ const embeddedManifest =
191
+ providedEmbeddedManifest ??
192
+ (wasmModule ? embeddedPlgManifest(wasmModule) : null);
193
+ const embedded = normalizedRuntimeTargets(embeddedManifest);
194
+ if (!runtimeTargetSatisfies(embedded, leg, embeddedManifest?.capabilities)) {
195
+ return { targets: embedded, source: "embedded" };
196
+ }
197
+ const caller = normalizedRuntimeTargets(manifest);
198
+ if (!runtimeTargetSatisfies(caller, leg, manifest?.capabilities)) {
199
+ return { targets: caller, source: "caller" };
200
+ }
201
+ return null;
202
+ }
203
+
204
+ /**
205
+ * @param {object} options
206
+ * @param {WebAssembly.Module} [options.wasmModule] compiled artifact
207
+ * @param {object} [options.manifest] caller-supplied manifest
208
+ * @param {string} options.leg the runtime target this loader stands for
209
+ * @param {string} [options.what] noun used in the message ("module" by default)
210
+ */
211
+ export function assertArtifactRuntimeTarget({
212
+ wasmModule,
213
+ manifest,
214
+ embeddedManifest,
215
+ leg,
216
+ what = "module",
217
+ }) {
218
+ const refusal = resolveRuntimeTargetRefusal({
219
+ wasmModule,
220
+ manifest,
221
+ embeddedManifest,
222
+ leg,
223
+ });
224
+ if (!refusal) return;
225
+ const { targets, source } = refusal;
226
+ throw new RuntimeTargetError(
227
+ `Refusing to load a ${what} that does not target "${leg}": its ` +
228
+ `${source === "embedded" ? "embedded manifest" : "supplied manifest"} declares ` +
229
+ `runtimeTargets [${targets.join(", ")}], which does not admit this leg. Loading it ` +
230
+ `here would trap on the first capability the "${leg}" host cannot serve. Run it on ` +
231
+ "a host that provides one of its declared targets, or split the out-of-scope " +
232
+ "surface into its own module.",
233
+ { declaredTargets: targets, source, leg },
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Is this JavaScript running in a browser (window or a Worker)? Used only to
239
+ * DEFAULT the leg for loaders that are genuinely runtime-agnostic; every
240
+ * caller may state its leg explicitly and that always wins.
241
+ */
242
+ export function detectBrowserLeg() {
243
+ try {
244
+ if (typeof window !== "undefined" && typeof window.document !== "undefined") {
245
+ return true;
246
+ }
247
+ const workerScope = globalThis.WorkerGlobalScope;
248
+ return (
249
+ typeof workerScope === "function" &&
250
+ typeof self !== "undefined" &&
251
+ self instanceof workerScope
252
+ );
253
+ } catch {
254
+ return false;
255
+ }
256
+ }
@@ -27,6 +27,7 @@ import {
27
27
  } from "./sabHostcallChannel.js";
28
28
  import { WASI_THREAD_HOSTCALL_MESSAGE } from "./wasiThreadWorkerRuntime.js";
29
29
  import { importNodeBuiltin } from "./nodeBuiltinSpecifier.js";
30
+ import { assertBrowserRuntimeTarget } from "./browserModuleHarness.js";
30
31
 
31
32
  const WORKER_URL = new URL("./workerModuleHarnessWorker.js", import.meta.url);
32
33
 
@@ -130,6 +131,12 @@ async function toWasmModule(source, label) {
130
131
  */
131
132
  export async function createWorkerModuleHarness(options = {}) {
132
133
  const wasmModule = await toWasmModule(options.wasmSource, "wasmSource");
134
+ // Fail on THIS thread, before a worker, a SAB channel and a BroadcastChannel
135
+ // are stood up for an artifact the browser leg must not run. The in-worker
136
+ // createBrowserModuleHarness would refuse it too, but a rejection that
137
+ // arrives after the whole rig exists reads like a worker fault rather than
138
+ // the declaration it actually is.
139
+ assertBrowserRuntimeTarget(wasmModule, options.harnessOptions?.manifest);
133
140
  const host = options.host ?? createBrowserHost(options.hostOptions);
134
141
  const dispatch = options.dispatchHost ?? createAsyncHostDispatcher(host);
135
142
  const buffer = createSabHostcallBuffer({
package/src/index.d.ts CHANGED
@@ -2337,8 +2337,55 @@ export interface FlowRuntimeHostOptions {
2337
2337
  engineLink?: {
2338
2338
  exports: WebAssembly.Exports & { memory: WebAssembly.Memory };
2339
2339
  } | null;
2340
+ /**
2341
+ * The runtime leg this host stands for. A composed flow DERIVES its
2342
+ * runtimeTargets from its parts, so a single-leg artifact exists and must be
2343
+ * refused by name on any other leg. Omit and a real browser is detected and
2344
+ * gated; pass `null` to opt out explicitly.
2345
+ */
2346
+ runtimeTarget?: string | null;
2347
+ /** Caller-side manifest; the artifact's embedded record wins on conflict. */
2348
+ manifest?: PluginManifest | Record<string, unknown>;
2349
+ }
2350
+
2351
+ export declare class RuntimeTargetError extends Error {
2352
+ code: "runtime-target-out-of-scope";
2353
+ declaredTargets: string[];
2354
+ leg: string;
2355
+ declarationSource: "embedded" | "caller";
2340
2356
  }
2341
2357
 
2358
+ /**
2359
+ * Does a declared target set admit this leg? `wasi` is the portability
2360
+ * baseline and admits either leg, but only when `capabilities` carries nothing
2361
+ * that leg cannot serve.
2362
+ */
2363
+ export function runtimeTargetSatisfies(
2364
+ targets: string[] | undefined,
2365
+ leg: string,
2366
+ capabilities?: Array<string | { capabilityId?: string; name?: string }>,
2367
+ ): boolean;
2368
+
2369
+ /** The artifact's own declared targets, read from its embedded `$PLG`. */
2370
+ export function embeddedRuntimeTargets(wasmModule: WebAssembly.Module): string[];
2371
+
2372
+ /**
2373
+ * Which declaration refuses this leg, if either does. The artifact's embedded
2374
+ * record wins over a caller-supplied manifest.
2375
+ */
2376
+ export function resolveRuntimeTargetRefusal(options: {
2377
+ wasmModule?: WebAssembly.Module;
2378
+ manifest?: PluginManifest | Record<string, unknown>;
2379
+ embeddedManifest?: PluginManifest | Record<string, unknown> | null;
2380
+ leg: string;
2381
+ }): { targets: string[]; source: "embedded" | "caller" } | null;
2382
+
2383
+ /** Throws a RuntimeTargetError when the artifact does not target the browser. */
2384
+ export function assertBrowserRuntimeTarget(
2385
+ wasmModule: WebAssembly.Module,
2386
+ manifest?: PluginManifest | Record<string, unknown>,
2387
+ ): void;
2388
+
2342
2389
  export const FLOW_INVALID_INDEX: number;
2343
2390
  export function createFlowRuntimeHost(
2344
2391
  options: FlowRuntimeHostOptions,
package/src/index.js CHANGED
@@ -16,6 +16,17 @@ export * from "./deployment/index.js";
16
16
  export * from "./app/index.js";
17
17
  export { FLOW_INVALID_INDEX, createFlowRuntimeHost } from "./flow/flowRuntimeHost.js";
18
18
  export { createIsomorphicFlowRuntimeHost } from "./flow/isomorphicFlowHost.js";
19
+ // The runtime-target gate: a composed flow derives its runtimeTargets, so a
20
+ // single-leg artifact exists and every loader refuses one that is not its own.
21
+ // Exported so a consumer can catch the refusal by CLASS rather than by
22
+ // matching a message string, and can ask the same question before offering a
23
+ // module to a leg.
24
+ export {
25
+ RuntimeTargetError,
26
+ runtimeTargetSatisfies,
27
+ resolveRuntimeTargetRefusal,
28
+ embeddedRuntimeTargets,
29
+ } from "./host/runtimeTargetGate.js";
19
30
  export {
20
31
  DefaultInvokeExports,
21
32
  DefaultManifestExports,
@@ -62,8 +62,35 @@ function collectFlatbufferTableFields(idl, tableName) {
62
62
  .filter(Boolean);
63
63
  }
64
64
 
65
+ // A schema has TWO written forms in this ecosystem and they mean the same file:
66
+ //
67
+ // "SCV.fbs" — the catalog's canonical form, built as `<CODE>.fbs` from
68
+ // the standards manifest (parseStandardsEntry below).
69
+ // "SCV/main.fbs" — the PATH form, which is how the schema actually lives in
70
+ // spacedatastandards.org (`schema/SCV/main.fbs`) and is
71
+ // therefore what modules, tests and generated bindings across
72
+ // the stack write.
73
+ //
74
+ // Matching them as raw strings made every manifest using the path form fail
75
+ // resolution with `standards-type-identity-mismatch` — "mixes a known
76
+ // schemaName ... with a different standards entry" — because the `$SCV` file
77
+ // identifier DID resolve while the name did not. That is not a manifest defect
78
+ // and not a schema defect; it is this matcher refusing a spelling the standards
79
+ // repository itself uses. The rest of the toolchain already treats the two as
80
+ // one schema (see the closed-modules builder's
81
+ // `^([A-Z][A-Z0-9]{2})(?:\/main)?\.fbs$` header-injection regex), so the
82
+ // catalog must too, or a correct module cannot be compiled.
83
+ //
84
+ // Deliberately narrow: ONLY the exact `<CODE>/main.fbs` shape collapses. Any
85
+ // other path is left alone so two genuinely different schemas can never be
86
+ // merged by a loose rule.
87
+ const SCHEMA_PATH_FORM = /^([A-Za-z][A-Za-z0-9_]*)\/main\.fbs$/;
88
+
65
89
  function normalizeSchemaName(value) {
66
- return value === undefined || value === null ? "" : String(value);
90
+ if (value === undefined || value === null) return "";
91
+ const text = String(value);
92
+ const pathForm = SCHEMA_PATH_FORM.exec(text);
93
+ return pathForm ? `${pathForm[1]}.fbs` : text;
67
94
  }
68
95
 
69
96
  function normalizeFileIdentifier(value) {
@@ -17,6 +17,7 @@ import { createBrowserModuleHarness } from "../host/browserModuleHarness.js";
17
17
  const OK = "ok";
18
18
  const GUEST_ERROR = "guest-error";
19
19
  const TRAP = "trap";
20
+ const OUT_OF_SCOPE = "out-of-declared-scope";
20
21
 
21
22
  function base64ToBytes(value) {
22
23
  const binary = atob(String(value ?? ""));
@@ -42,6 +43,16 @@ function classifyBrowserError(error) {
42
43
  if (error?.name === "WasiExitError") {
43
44
  return { exitClass: GUEST_ERROR, exitDetail: `exit=${error.code}` };
44
45
  }
46
+ // The harness refused the artifact because the ARTIFACT declares it does not
47
+ // run here. That is the contract working. Classing it TRAP would score a
48
+ // correct refusal as a P1 cross-runtime divergence — the gate failing the
49
+ // very artifacts the compiler now legitimately emits.
50
+ if (error?.name === "RuntimeTargetError") {
51
+ return {
52
+ exitClass: OUT_OF_SCOPE,
53
+ exitDetail: `declared runtimeTargets [${(error.declaredTargets ?? []).join(", ")}]`,
54
+ };
55
+ }
45
56
  return {
46
57
  exitClass: TRAP,
47
58
  exitDetail: `${error?.name ?? "Error"}: ${error?.message ?? String(error)}`,