space-data-module-sdk 0.8.11 → 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.
package/README.md CHANGED
@@ -241,6 +241,98 @@ treats that as the explicit "one binary for both" profile. That pair now
241
241
  defaults to a shared `single-thread` artifact so the compiled wasm can be loaded
242
242
  unchanged by the browser harness and the WasmEdge harness.
243
243
 
244
+ ### Composed flows derive their targets
245
+
246
+ A COMPOSED flow artifact (`space-data-module flow compile`) does not declare
247
+ its own `runtimeTargets` — it **derives** them, inside the universe a composed
248
+ artifact can reach at all (`browser` + `wasmedge`; the flow runtime template is
249
+ never a standalone `wasi` command, a `node` package, a `desktop` bundle or an
250
+ `edge` worker). Two things narrow that universe:
251
+
252
+ 1. **Every part's declaration**, tested with `runtimeTargetSatisfies` — THE
253
+ SAME function every loader uses, so a declaration cannot mean one thing to
254
+ the compiler and another at the door. A flow runs only where all of its
255
+ parts run. A part that declares nothing constrains nothing. `wasi` is the
256
+ strict portability baseline rather than a fourth runtime, so a part
257
+ declaring it admits both legs — unless that part's own capabilities say
258
+ otherwise (`pipe` is in both the standalone-WASI subset and the
259
+ browser-incompatible set).
260
+ 2. **The capability union.** A capability in `BrowserIncompatibleCapabilityIds`
261
+ (`wallet_sign`, `tcp`, `storage_write`, …) drops `browser` regardless of what
262
+ anything declared, because the composition provably cannot run there. This is
263
+ the proof-based half: it catches the commonest manifest shape, a plugin that
264
+ carries such a capability and simply omits `runtimeTargets`.
265
+
266
+ Outcomes:
267
+
268
+ - All parts isomorphic, no browser-incompatible capability → the flow keeps
269
+ `["browser", "wasmedge"]`.
270
+ - One WasmEdge-only part, or one browser-incompatible capability → the flow is
271
+ `["wasmedge"]`, its `buildArtifacts[].target` says `wasmedge`, and a
272
+ `narrowed-runtime-targets` warning names what cost it the browser. Losing a
273
+ runtime is never silent.
274
+ - No shared runtime → `empty-runtime-target-intersection`, a hard error naming
275
+ the constraining plugins.
276
+
277
+ This is what makes a host-only capability usable from a flow at all: the
278
+ composed manifest is compliance-validated before the bake, and a
279
+ browser-incompatible capability next to a `browser` target is a hard
280
+ `capability-runtime-conflict`. Deriving the target set keeps that rule intact —
281
+ the conflict still fires whenever a browser target is genuinely claimed — while
282
+ letting the legitimate WasmEdge-only composition exist. `wallet_sign` gates
283
+ `keyslot.unwrap`, so before this the hardcoded pair meant no flow could unwrap a
284
+ host-held key at all.
285
+
286
+ ### Both legs refuse an artifact that is not theirs
287
+
288
+ A legitimate single-leg artifact now exists, so every loader that stands for a
289
+ runtime leg refuses one that declares itself out of scope — by name, quoting
290
+ the declaration, never a silent skip and never a trap five frames deep inside a
291
+ capability that leg cannot serve. The shared assert is
292
+ `src/host/runtimeTargetGate.js`:
293
+
294
+ | Loader | Leg |
295
+ | --- | --- |
296
+ | `createBrowserModuleHarness` | `browser` |
297
+ | `createWorkerModuleHarness` | `browser`, checked before any worker is spawned |
298
+ | `createIsomorphicFlowRuntimeHost` | `browser` (it mounts children in the browser harness) |
299
+ | `createFlowRuntimeHost` | stated via `runtimeTarget`; defaults to `browser` in a real browser, ungated elsewhere |
300
+ | `loadModule({runtimeKind: "wasmedge"})` | `wasmedge` |
301
+
302
+ Both declarations are consulted — the caller-supplied manifest and the
303
+ artifact's own embedded `$PLG` — and **the embedded one wins on conflict**,
304
+ because that is what the artifact's signature covers. The refusal throws a
305
+ `RuntimeTargetError` carrying `declaredTargets`, `leg` and `declarationSource`.
306
+
307
+ A declaration that names the leg outright is trusted as written; the `wasi`
308
+ baseline, which is an inference the SDK makes on the author's behalf, is
309
+ additionally capability-checked. Declaration → trust the author; inference →
310
+ prove it.
311
+
312
+ **Consumer migration.** `createFlowRuntimeHost` is runtime-agnostic and only
313
+ detects a real browser. If you drive a composed flow from Node — a flow test, a
314
+ server-side runner — state the leg, or the mirror gate is principle rather than
315
+ fact:
316
+
317
+ ```js
318
+ const host = await createFlowRuntimeHost({
319
+ wasmSource: bytes,
320
+ runtimeTarget: "wasmedge",
321
+ });
322
+ ```
323
+
324
+ The tri-runtime parity gate reads the same declaration: a lane the artifact
325
+ declared itself out of is scored `out-of-declared-scope`, skipped before it is
326
+ launched, and recorded as evidence rather than compared. Without that, a
327
+ correct refusal on the browser lane would have been counted as a P1
328
+ cross-runtime divergence.
329
+
330
+ Scoping is not a way to be certified without being run. An artifact in the
331
+ gate's certified set that scopes itself out of every active lane fails
332
+ (`artifact-out-of-every-lane`), and one that leaves fewer than two lanes to
333
+ compare fails too (`artifact-not-cross-runtime-comparable`) — a single lane
334
+ proves no parity, and one manifest string must never disarm the gate.
335
+
244
336
  ## WasmEdge Pthreads
245
337
 
246
338
  `space-data-module-sdk` is also the source of truth for module thread-model
@@ -170,6 +170,28 @@ function parseArgs(argv) {
170
170
  });
171
171
  break;
172
172
  }
173
+ case "--artifact-lanes": {
174
+ // --artifact-lanes <id>=<lane,lane> — the GATE'S claim about which
175
+ // lanes an artifact owes evidence on. Without this the strongest of the
176
+ // three scoping rules ("the artifact does not pick its examiners") was
177
+ // reachable only from a gate manifest file, i.e. never for the
178
+ // cross-repo artifacts injected with --artifact, which are exactly the
179
+ // ones whose declarations this repo does not own.
180
+ const spec = requireValue(argv, ++index, value);
181
+ const eq = spec.indexOf("=");
182
+ if (eq < 1) {
183
+ throw new Error(
184
+ `--artifact-lanes expects <id>=<lane,lane>, got "${spec}"`,
185
+ );
186
+ }
187
+ options.expectedLanesById = options.expectedLanesById ?? {};
188
+ options.expectedLanesById[spec.slice(0, eq)] = spec
189
+ .slice(eq + 1)
190
+ .split(",")
191
+ .map((lane) => lane.trim())
192
+ .filter(Boolean);
193
+ break;
194
+ }
173
195
  case "--require-native-wasmedge":
174
196
  options.requireNativeWasmEdge = true;
175
197
  break;
@@ -264,6 +286,7 @@ async function runFlow(argv) {
264
286
  console.log(` ${issue.severity.toUpperCase()} ${issue.code}: ${issue.message}`);
265
287
  }
266
288
  console.log(` capabilities: [${check.capabilities.join(", ")}]`);
289
+ console.log(` runtimeTargets: [${(check.runtimeTargets ?? []).join(", ")}]`);
267
290
  for (const node of check.nodes) {
268
291
  console.log(` node ${node.nodeId}: ${node.pluginId}:${node.methodId} (${node.dispatchModel})`);
269
292
  }
@@ -307,6 +330,9 @@ async function runFlow(argv) {
307
330
  } else {
308
331
  console.log(`Wrote ${result.outputs.moduleWasmPath}`);
309
332
  console.log(` capabilities: [${result.check.capabilities.join(", ")}]`);
333
+ console.log(
334
+ ` runtimeTargets: [${(result.manifest?.runtimeTargets ?? result.check.runtimeTargets ?? []).join(", ")}]`,
335
+ );
310
336
  for (const node of result.check.nodes) {
311
337
  console.log(` node ${node.nodeId}: ${node.pluginId}:${node.methodId} (${node.dispatchModel})`);
312
338
  }
@@ -379,7 +405,13 @@ async function runParityGateCommand(argv) {
379
405
  );
380
406
  const report = await runParityGate({
381
407
  manifestPath: options.gateManifestPath,
382
- extraArtifacts: options.extraArtifacts,
408
+ extraArtifacts: (options.extraArtifacts ?? []).map((artifact) => ({
409
+ ...artifact,
410
+ ...(options.expectedLanesById?.[artifact.id]
411
+ ? { expectedLanes: options.expectedLanesById[artifact.id] }
412
+ : {}),
413
+ })),
414
+ expectedLanesById: options.expectedLanesById,
383
415
  lanes: options.lanes,
384
416
  timeoutMs: options.timeoutMs,
385
417
  chromeBinary: options.chromeBinary,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "space-data-module-sdk",
3
- "version": "0.8.11",
3
+ "version": "0.8.12",
4
4
  "description": "Module SDK for building, validating, signing, and deploying WebAssembly modules on the Space Data Network.",
5
5
  "type": "module",
6
6
  "types": "./src/index.d.ts",
@@ -55,6 +55,7 @@
55
55
  "default": "./src/host/isomorphicLoader.js"
56
56
  },
57
57
  "./host/browser-module": "./src/host/browserModuleHarness.js",
58
+ "./host/runtime-target-gate": "./src/host/runtimeTargetGate.js",
58
59
  "./host/node-builtin": "./src/host/nodeBuiltinSpecifier.js",
59
60
  "./host/worker-module": "./src/host/workerModuleHarness.js",
60
61
  "./testing/browser": "./src/testing/browser.js",
package/src/browser.js CHANGED
@@ -23,10 +23,21 @@ export * from "./host/isomorphicLoaderBrowser.js";
23
23
  // Browser module hosts. These are runtime surface and live in src/host/;
24
24
  // nothing under src/testing/ is reachable from this entry.
25
25
  export {
26
+ assertBrowserRuntimeTarget,
26
27
  createBrowserModuleHarness,
27
28
  detectArtifactProfile,
28
29
  zeroWasmBytes,
29
30
  isSharedArrayBufferLike,
30
31
  } from "./host/browserModuleHarness.js";
32
+ // The runtime-target gate is reachable so a consumer can catch the refusal by
33
+ // CLASS (`error instanceof RuntimeTargetError`) rather than by matching a
34
+ // message string, and can ask the same question the loaders ask before it
35
+ // offers a module to a leg.
36
+ export {
37
+ RuntimeTargetError,
38
+ runtimeTargetSatisfies,
39
+ resolveRuntimeTargetRefusal,
40
+ embeddedRuntimeTargets,
41
+ } from "./host/runtimeTargetGate.js";
31
42
  export { createWorkerModuleHarness } from "./host/workerModuleHarness.js";
32
43
  export { createModuleFlatBufferStreamPump } from "./host/moduleFlatbufferStreamPump.js";
@@ -47,3 +47,42 @@ export const StandaloneWasiCapabilityIds = Object.freeze([
47
47
  "filesystem",
48
48
  "pipe",
49
49
  ]);
50
+
51
+ /**
52
+ * Capabilities a PORTABLE artifact may not claim against the canonical browser
53
+ * runtime target. The browser HOST can route several of these when the
54
+ * embedder injects an adapter (see BrowserHostSupportedCapabilities) — this
55
+ * list is the DECLARATION policy, not the dispatch table: a published module
56
+ * cannot assume its embedder wired one.
57
+ *
58
+ * It lives here, beside the other capability vocabularies, rather than inside
59
+ * the compliance module, because two browser-facing surfaces need it: the flow
60
+ * compiler (which subtracts `browser` from a composed artifact's derived
61
+ * runtimeTargets on exactly this list, so the compiler never stamps a target
62
+ * its own compliance pass would reject) and the runtime-target gate (which
63
+ * must not admit an artifact to a leg on the `wasi` portability baseline while
64
+ * it carries a capability that leg cannot serve).
65
+ */
66
+ export const BrowserIncompatibleCapabilityIds = Object.freeze([
67
+ "pipe",
68
+ "network",
69
+ "tcp",
70
+ "udp",
71
+ "mqtt",
72
+ "tls",
73
+ "database",
74
+ "storage_write",
75
+ "protocol_dial",
76
+ "protocol_handle",
77
+ "process_exec",
78
+ "wallet_sign",
79
+ "ipfs",
80
+ "scene_access",
81
+ "entity_access",
82
+ "render_hooks",
83
+ ]);
84
+
85
+ /** Which capabilities each runtime leg cannot serve. */
86
+ export const LegIncompatibleCapabilityIds = Object.freeze({
87
+ browser: BrowserIncompatibleCapabilityIds,
88
+ });
@@ -11,6 +11,7 @@ import {
11
11
  validatePluginManifest,
12
12
  } from "./pluginCompliance.js";
13
13
  import {
14
+ BrowserIncompatibleCapabilityIds,
14
15
  RecommendedCapabilityIds,
15
16
  StandaloneWasiCapabilityIds,
16
17
  } from "../capabilities.js";
@@ -48,6 +49,7 @@ export async function validateArtifactWithStandards(options = {}) {
48
49
  }
49
50
 
50
51
  export {
52
+ BrowserIncompatibleCapabilityIds,
51
53
  findManifestFiles,
52
54
  getWasmExportNames,
53
55
  getWasmExportNamesFromFile,
@@ -13,6 +13,7 @@ import {
13
13
  RuntimeTarget,
14
14
  } from "../runtime/constants.js";
15
15
  import {
16
+ BrowserIncompatibleCapabilityIds,
16
17
  RecommendedCapabilityIds,
17
18
  StandaloneWasiCapabilityIds,
18
19
  } from "../capabilities.js";
@@ -49,24 +50,7 @@ const ExternalInterfaceDirectionSet = new Set(
49
50
  const ExternalInterfaceKindSet = new Set(Object.values(ExternalInterfaceKind));
50
51
  const ProtocolRoleSet = new Set(Object.values(ProtocolRole));
51
52
  const ProtocolTransportKindSet = new Set(Object.values(ProtocolTransportKind));
52
- const BrowserIncompatibleCapabilitySet = new Set([
53
- "pipe",
54
- "network",
55
- "tcp",
56
- "udp",
57
- "mqtt",
58
- "tls",
59
- "database",
60
- "storage_write",
61
- "protocol_dial",
62
- "protocol_handle",
63
- "process_exec",
64
- "wallet_sign",
65
- "ipfs",
66
- "scene_access",
67
- "entity_access",
68
- "render_hooks",
69
- ]);
53
+ const BrowserIncompatibleCapabilitySet = new Set(BrowserIncompatibleCapabilityIds);
70
54
  const StandaloneWasiProtocolTransportKindSet = new Set([
71
55
  ProtocolTransportKind.WASI_PIPE,
72
56
  ]);
@@ -56,6 +56,7 @@ import {
56
56
  } from "../compiler/pthreadArtifactGuard.js";
57
57
  import { resolveWasiThreadsToolchain } from "../compiler/wasiThreadsToolchain.js";
58
58
  import {
59
+ BrowserIncompatibleCapabilityIds,
59
60
  validateArtifactWithStandards,
60
61
  validatePluginManifest,
61
62
  } from "../compliance/index.js";
@@ -66,6 +67,7 @@ import {
66
67
  payloadSchemaIdentitiesEqual,
67
68
  } from "../manifest/index.js";
68
69
  import { appendWasmCustomSection } from "../bundle/wasm.js";
70
+ import { runtimeTargetSatisfies } from "../host/runtimeTargetGate.js";
69
71
  import { SDS_MANIFEST_SECTION_NAME } from "../bundle/constants.js";
70
72
  import { verifyModuleArtifact } from "../bundle/signing.js";
71
73
  import { DOMAIN_MODULE_PUBLICATION_V1 } from "../bundle/sigdomain.js";
@@ -162,6 +164,16 @@ export function flowEngineLinkage(flow) {
162
164
  );
163
165
  }
164
166
 
167
+ // The runtimes a COMPOSED flow artifact can possibly reach. The flow runtime
168
+ // template is baked once, as the isomorphic browser/WasmEdge artifact — it is
169
+ // never a standalone `wasi` command, a `node` package, a `desktop` bundle, or
170
+ // an `edge` worker, so those targets are not in the universe a composition can
171
+ // claim. A node's own declaration can only ever SUBTRACT from this set.
172
+ const COMPOSED_FLOW_RUNTIME_TARGET_UNIVERSE = Object.freeze([
173
+ "browser",
174
+ "wasmedge",
175
+ ]);
176
+
165
177
  const GUEST_LINK_OBJECT_FILENAME = "module-link.o";
166
178
  const GUEST_LINK_METADATA_FILENAME = "metadata.json";
167
179
  const GUEST_LINK_LINKED_DIRNAME = "guest-link-linked";
@@ -882,7 +894,9 @@ function collectComponentDependencies({ nodePluginIds, dependencies, issues, cap
882
894
  "warning",
883
895
  "unresolved-component-dependency",
884
896
  `Component dependency "${declared.pluginId}" (declared by "${declaredBy}") is not resolvable from the ` +
885
- "dependency set; it is propagated into the bundle DEPENDENCIES but its capabilities cannot be verified.",
897
+ "dependency set; it is propagated into the bundle DEPENDENCIES but neither its capabilities nor its " +
898
+ "runtimeTargets can be verified — so it constrains neither the flow's capability union nor its " +
899
+ "derived runtime targets, and the composed artifact may claim a runtime this component cannot reach.",
886
900
  "flow.dependencies",
887
901
  );
888
902
  }
@@ -890,6 +904,174 @@ function collectComponentDependencies({ nodePluginIds, dependencies, issues, cap
890
904
  return [...components.values()].sort((a, b) => a.pluginId.localeCompare(b.pluginId));
891
905
  }
892
906
 
907
+ const BROWSER_RUNTIME_TARGET = "browser";
908
+ const BrowserIncompatibleCapabilitySet = new Set(
909
+ BrowserIncompatibleCapabilityIds,
910
+ );
911
+
912
+ function declaredRuntimeTargets(manifest) {
913
+ const declared = Array.isArray(manifest?.runtimeTargets)
914
+ ? manifest.runtimeTargets
915
+ .map((target) => String(target ?? "").trim().toLowerCase())
916
+ .filter(Boolean)
917
+ : [];
918
+ return declared.length > 0 ? declared : null;
919
+ }
920
+
921
+ /**
922
+ * collectFlowRuntimeTargets derives the COMPOSED artifact's runtimeTargets from
923
+ * its constituents instead of asserting a fixed pair.
924
+ *
925
+ * A flow runs only where ALL of its parts run, so the composition's target set
926
+ * is the INTERSECTION of the declared target sets of every node plugin and
927
+ * every resolved component dependency, taken inside the universe a composed
928
+ * artifact can reach at all (browser + WasmEdge). A part that declares no
929
+ * targets is unconstraining — it says nothing, so it subtracts nothing. The
930
+ * admission test is `runtimeTargetSatisfies`, THE SAME ONE every loader uses,
931
+ * so a declaration cannot mean one thing to the compiler and another at the
932
+ * door.
933
+ *
934
+ * The CAPABILITY UNION subtracts too. A declared target set is not the only
935
+ * way a part can be non-browser: a plugin that carries `wallet_sign` and
936
+ * simply omits `runtimeTargets` says nothing about where it runs, yet the
937
+ * composition provably cannot run in a browser, because compliance forbids
938
+ * exactly that capability against a browser target. Deriving only from the
939
+ * declarations left the commonest manifest shape re-creating the original
940
+ * defect — the compiler stamping a target its own compliance pass then
941
+ * rejects. So `browser` is also dropped whenever the flow's capability union
942
+ * meets `BrowserIncompatibleCapabilityIds`, and the diagnostic names the
943
+ * capability that cost it.
944
+ *
945
+ * This is the difference between a truthful manifest and a wish. The hardcoded
946
+ * ["browser","wasmedge"] made every composition claim the browser, which made
947
+ * `capability-runtime-conflict` fire on any flow containing a node with a
948
+ * browser-incompatible capability (wallet_sign, tcp, storage_write, …) — so a
949
+ * WasmEdge-only flow was not merely mislabelled, it was IMPOSSIBLE TO BAKE.
950
+ * Deriving the set keeps the real invariant intact (a browser-incompatible
951
+ * capability and a browser target still cannot coexist) while letting the
952
+ * legitimate WasmEdge-only composition exist.
953
+ */
954
+ function collectFlowRuntimeTargets({
955
+ resolvedNodes,
956
+ componentDependencies,
957
+ dependencies,
958
+ capabilities,
959
+ issues,
960
+ }) {
961
+ let targets = new Set(COMPOSED_FLOW_RUNTIME_TARGET_UNIVERSE);
962
+ // pluginId -> the declared set, for the diagnostic when nothing survives.
963
+ const constraints = new Map();
964
+ // target -> why it is gone. Losing a runtime is never silent.
965
+ const droppedBecause = new Map();
966
+
967
+ const narrow = (allowed, reasonFor) => {
968
+ for (const target of [...targets]) {
969
+ if (allowed(target)) continue;
970
+ targets.delete(target);
971
+ if (!droppedBecause.has(target)) {
972
+ droppedBecause.set(target, reasonFor(target));
973
+ }
974
+ }
975
+ };
976
+
977
+ const consider = (pluginId, manifest) => {
978
+ if (!pluginId || constraints.has(pluginId)) return;
979
+ const declared = declaredRuntimeTargets(manifest);
980
+ if (!declared) return;
981
+ constraints.set(pluginId, [...declared].sort());
982
+ // ONE RULE, shared with the loaders (`runtimeTargetSatisfies`). Two rules
983
+ // for one field is how a manifest comes to mean different things to the
984
+ // compiler and to the door: a `["wasi","wasmedge"]` part had the browser
985
+ // leg stripped from every flow it joined while the browser harness happily
986
+ // admitted that same declaration. `wasi` is the portability baseline, so
987
+ // it admits both legs unless the part's own capabilities say otherwise;
988
+ // proof-based narrowing is the capability-union pass below.
989
+ narrow(
990
+ (target) =>
991
+ runtimeTargetSatisfies(declared, target, manifest?.capabilities),
992
+ () =>
993
+ `"${pluginId}" declares runtimeTargets [${[...declared].sort().join(", ")}]`,
994
+ );
995
+ };
996
+
997
+ for (const entry of resolvedNodes) {
998
+ if (!entry?.dependency) continue;
999
+ consider(entry.node?.pluginId, entry.dependency.manifest);
1000
+ }
1001
+ for (const component of componentDependencies ?? []) {
1002
+ if (!component?.resolved) continue;
1003
+ consider(
1004
+ component.pluginId,
1005
+ dependencies.get(component.pluginId)?.manifest,
1006
+ );
1007
+ }
1008
+
1009
+ const browserIncompatible = [...(capabilities ?? [])]
1010
+ .filter((capability) => BrowserIncompatibleCapabilitySet.has(capability))
1011
+ .sort();
1012
+ if (browserIncompatible.length > 0) {
1013
+ narrow(
1014
+ (target) => target !== BROWSER_RUNTIME_TARGET,
1015
+ () =>
1016
+ `the flow's capability union contains ${browserIncompatible
1017
+ .map((capability) => `"${capability}"`)
1018
+ .join(", ")}, which the canonical browser runtime target cannot serve`,
1019
+ );
1020
+ }
1021
+
1022
+ const ordered = COMPOSED_FLOW_RUNTIME_TARGET_UNIVERSE.filter((target) =>
1023
+ targets.has(target),
1024
+ );
1025
+ const reasons = [...droppedBecause.entries()]
1026
+ .map(([target, why]) => `${target} (${why})`)
1027
+ .join("; ");
1028
+ if (ordered.length === 0) {
1029
+ pushIssue(
1030
+ issues,
1031
+ "error",
1032
+ "empty-runtime-target-intersection",
1033
+ "No runtime runs every part of this flow: every candidate target was ruled " +
1034
+ `out — ${reasons}. A composed flow can only target the runtimes shared by ` +
1035
+ "all of its nodes and resolved component dependencies; split the flow, or " +
1036
+ "widen the constraining plugin's runtimeTargets.",
1037
+ "flow.nodes",
1038
+ );
1039
+ } else if (droppedBecause.size > 0) {
1040
+ pushIssue(
1041
+ issues,
1042
+ "warning",
1043
+ "narrowed-runtime-targets",
1044
+ `This flow composes to runtimeTargets [${ordered.join(", ")}] — narrower than ` +
1045
+ `the isomorphic pair. Dropped: ${reasons}. The artifact is still written to ` +
1046
+ "dist/isomorphic/module.wasm, but it is NOT loadable on the dropped runtime " +
1047
+ "and every harness for that leg will refuse it by name.",
1048
+ "flow.nodes",
1049
+ );
1050
+ }
1051
+ return ordered;
1052
+ }
1053
+
1054
+ /**
1055
+ * Rebuild the derived target set from a check record produced by an older SDK
1056
+ * (one with no `runtimeTargets` field). Issues are discarded here on purpose —
1057
+ * the caller re-raises the only one that matters, an empty result, as a throw.
1058
+ */
1059
+ function recomputeFlowRuntimeTargets(check, dependencies) {
1060
+ return collectFlowRuntimeTargets({
1061
+ resolvedNodes: (check?.nodes ?? [])
1062
+ .filter((node) => node.dispatchModel !== "host")
1063
+ .map((node) => ({
1064
+ node,
1065
+ dependency: dependencies.get(node.pluginId) ?? null,
1066
+ }))
1067
+ .filter((entry) => entry.dependency),
1068
+ componentDependencies: check?.componentDependencies,
1069
+ dependencies,
1070
+ capabilities: check?.capabilities ?? [],
1071
+ issues: [],
1072
+ });
1073
+ }
1074
+
893
1075
  /**
894
1076
  * findFlowCycles returns every elementary cycle reachable in the node graph
895
1077
  * as { nodeIds, edges } (iterative DFS with back-edge extraction; each cycle
@@ -1773,6 +1955,14 @@ export function checkFlowProgram({ flow, dependencies = new Map() } = {}) {
1773
1955
  capabilities,
1774
1956
  });
1775
1957
 
1958
+ const runtimeTargets = collectFlowRuntimeTargets({
1959
+ resolvedNodes,
1960
+ componentDependencies,
1961
+ dependencies,
1962
+ capabilities,
1963
+ issues,
1964
+ });
1965
+
1776
1966
  const errors = issues.filter((issue) => issue.severity === "error");
1777
1967
  return {
1778
1968
  ok: errors.length === 0,
@@ -1780,6 +1970,7 @@ export function checkFlowProgram({ flow, dependencies = new Map() } = {}) {
1780
1970
  errors,
1781
1971
  warnings: issues.filter((issue) => issue.severity === "warning"),
1782
1972
  capabilities: [...capabilities].sort(),
1973
+ runtimeTargets,
1783
1974
  engineLinkage,
1784
1975
  threadModel,
1785
1976
  componentDependencies,
@@ -1908,6 +2099,23 @@ export function buildFlowModuleManifest({ flow, check, dependencies }) {
1908
2099
  const checkedNodeById = new Map(
1909
2100
  check.nodes.map((node) => [node.nodeId, node]),
1910
2101
  );
2102
+ // `check.runtimeTargets` is the derived intersection. It is absent only when
2103
+ // a caller hands in a check record built by an older SDK; recompute rather
2104
+ // than silently re-asserting the historical pair — and THROW on the empty
2105
+ // intersection instead of swallowing it. A check record has an issue sink;
2106
+ // this path does not, and an artifact that declares no runtime at all is
2107
+ // read as unconstrained by every loader downstream, which is the most
2108
+ // permissive possible answer to the least certain question.
2109
+ const runtimeTargets = Array.isArray(check.runtimeTargets)
2110
+ ? [...check.runtimeTargets]
2111
+ : recomputeFlowRuntimeTargets(check, dependencies);
2112
+ if (runtimeTargets.length === 0) {
2113
+ throw new Error(
2114
+ `Flow "${flow?.programId ?? "(unknown)"}" has no runtime target: no runtime runs ` +
2115
+ "every one of its parts. Refusing to build a manifest that declares nothing — " +
2116
+ "run checkFlowProgram and read the empty-runtime-target-intersection error.",
2117
+ );
2118
+ }
1911
2119
 
1912
2120
  const hostModelNodeIds = new Set(
1913
2121
  check.nodes.filter((node) => node.dispatchModel === "host").map((node) => node.nodeId),
@@ -2032,13 +2240,17 @@ export function buildFlowModuleManifest({ flow, check, dependencies }) {
2032
2240
  externalInterfaces,
2033
2241
  methods,
2034
2242
  schemasUsed,
2035
- runtimeTargets: ["browser", "wasmedge"],
2243
+ // DERIVED, never asserted: the runtimes shared by every node plugin and
2244
+ // resolved component dependency (see collectFlowRuntimeTargets). A flow of
2245
+ // fully isomorphic nodes keeps both targets; a flow containing one
2246
+ // WasmEdge-only node is a WasmEdge-only flow and says so.
2247
+ runtimeTargets,
2036
2248
  buildArtifacts: [
2037
2249
  {
2038
2250
  artifactId: `${cIdent(flow.programId)}-flow-runtime`,
2039
2251
  kind: "wasm",
2040
2252
  path: "dist/isomorphic/module.wasm",
2041
- target: "browser,wasmedge",
2253
+ target: runtimeTargets.join(","),
2042
2254
  },
2043
2255
  ],
2044
2256
  flowNodes: nodes.map((node) => {
@@ -15,6 +15,10 @@
15
15
  */
16
16
 
17
17
  import { createBrowserWasiShim } from "../host/wasiShim.js";
18
+ import {
19
+ assertArtifactRuntimeTarget,
20
+ detectBrowserLeg,
21
+ } from "../host/runtimeTargetGate.js";
18
22
  import {
19
23
  isPayloadSchemaHashValid,
20
24
  normalizePayloadSchemaHash,
@@ -211,6 +215,28 @@ export async function createFlowRuntimeHost(options = {}) {
211
215
  wasmModule = await WebAssembly.compile(bytes);
212
216
  }
213
217
 
218
+ // THE COMPOSED FLOW'S DOOR. A composed artifact now derives its
219
+ // runtimeTargets from its parts, so a WasmEdge-only flow is a real thing —
220
+ // and this host is how one gets loaded. It is genuinely runtime-agnostic
221
+ // (the same function backs the Node/WasmEdge leg), so the leg is stated by
222
+ // the caller; absent that, a real browser is detected and gated. Passing
223
+ // runtimeTarget: null opts out explicitly, which is not the same as
224
+ // forgetting.
225
+ const declaredLeg =
226
+ options.runtimeTarget === undefined
227
+ ? detectBrowserLeg()
228
+ ? "browser"
229
+ : null
230
+ : options.runtimeTarget;
231
+ if (declaredLeg) {
232
+ assertArtifactRuntimeTarget({
233
+ wasmModule,
234
+ manifest: options.manifest,
235
+ leg: String(declaredLeg).trim().toLowerCase(),
236
+ what: "composed flow artifact",
237
+ });
238
+ }
239
+
214
240
  const wasi = createBrowserWasiShim({
215
241
  args: options.args ?? ["flow-runtime"],
216
242
  env: options.env ?? {},
@@ -220,6 +220,14 @@ export async function createIsomorphicFlowRuntimeHost(options = {}) {
220
220
  );
221
221
  const parent = await createFlowRuntimeHost({
222
222
  wasmSource: parentArtifactBytes,
223
+ // UNCONDITIONAL, with no opt-out. This host mounts every isomorphic child
224
+ // through the BROWSER module harness, whose gate cannot be turned off — so
225
+ // an escape hatch here would only ever produce an ungated parent with
226
+ // gated children, and a half-gate is not a gate. There is no legitimate
227
+ // non-browser use of this host precisely because of that mount; callers
228
+ // that want the runtime-agnostic host call createFlowRuntimeHost, which
229
+ // does take a stated leg.
230
+ runtimeTarget: "browser",
223
231
  args: options.args,
224
232
  env: options.env,
225
233
  logOutput: options.logOutput,
@@ -49,6 +49,10 @@ import {
49
49
  resolveModuleSignaturePolicy,
50
50
  verifyModuleArtifact,
51
51
  } from "../bundle/signing.js";
52
+ import {
53
+ assertArtifactRuntimeTarget,
54
+ RuntimeTargetError,
55
+ } from "./runtimeTargetGate.js";
52
56
  // Artifact byte reduction is an ARTIFACT concern shared by all three runtimes,
53
57
  // so it lives on `space-data-module-sdk/bundle`. Re-exported here because a
54
58
  // caller loading a module in the browser needs it in the same breath.
@@ -259,6 +263,27 @@ function createImportedMemory(options = {}) {
259
263
  return new WebAssembly.Memory(descriptor);
260
264
  }
261
265
 
266
+ const BROWSER_RUNTIME_TARGET = "browser";
267
+
268
+ export { RuntimeTargetError };
269
+
270
+ /**
271
+ * TRI-RUNTIME ISOMORPHISM, ENFORCED AT THE DOOR.
272
+ *
273
+ * This harness IS the browser leg of the tri-runtime contract, wherever the
274
+ * JavaScript happens to be running — asking it to load an artifact that does
275
+ * not declare the browser is asking for a divergence. The shared gate in
276
+ * `runtimeTargetGate.js` does the work; both declarations are consulted and
277
+ * the artifact's own embedded record wins on conflict.
278
+ */
279
+ export function assertBrowserRuntimeTarget(wasmModule, manifest) {
280
+ assertArtifactRuntimeTarget({
281
+ wasmModule,
282
+ manifest,
283
+ leg: BROWSER_RUNTIME_TARGET,
284
+ });
285
+ }
286
+
262
287
  function resolveManifestSurface(manifest) {
263
288
  const surfaces = Array.isArray(manifest?.invokeSurfaces)
264
289
  ? manifest.invokeSurfaces
@@ -471,6 +496,7 @@ export async function createBrowserModuleHarness(options = {}) {
471
496
  ownedArtifactBytes = null;
472
497
  wasmSource = null;
473
498
  options.wasmSource = null;
499
+ assertBrowserRuntimeTarget(wasmModule, options.manifest);
474
500
  const moduleImports = WebAssembly.Module.imports(wasmModule);
475
501
  const needsHostBridge = moduleImports.some(
476
502
  (entry) => entry.module === DEFAULT_HOSTCALL_IMPORT_MODULE,