space-data-module-sdk 0.8.11 → 0.8.13

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.
Files changed (40) hide show
  1. package/README.md +92 -0
  2. package/bin/space-data-module.js +85 -1
  3. package/docs/module-publication-standard.md +7 -3
  4. package/docs/propagator-abi.md +477 -0
  5. package/include/orbpro/orbpro_propagator_abi.h +312 -0
  6. package/package.json +7 -1
  7. package/schemas/PluginManifest.fbs +46 -1
  8. package/schemas/orbpro/Propagator.fbs +161 -3
  9. package/src/browser.js +11 -0
  10. package/src/bundle/index.js +1 -0
  11. package/src/bundle/sigdomain.js +22 -0
  12. package/src/capabilities.js +91 -0
  13. package/src/compliance/index.js +8 -0
  14. package/src/compliance/pluginCompliance.js +76 -32
  15. package/src/flow/flowCompiler.js +231 -3
  16. package/src/flow/flowRuntimeHost.js +26 -0
  17. package/src/flow/isomorphicFlowHost.js +8 -0
  18. package/src/generated/orbpro/manifest/plugin-family.d.ts +9 -1
  19. package/src/generated/orbpro/manifest/plugin-family.js +8 -0
  20. package/src/generated/orbpro/manifest/plugin-family.ts +8 -0
  21. package/src/generated/orbpro/propagator-abi.js +118 -0
  22. package/src/generated/orbpro/propagator-abi.ts +199 -0
  23. package/src/host/browserModuleHarness.js +26 -0
  24. package/src/host/isomorphicLoader.js +57 -11
  25. package/src/host/runtimeTargetGate.js +256 -0
  26. package/src/host/workerModuleHarness.js +7 -0
  27. package/src/index.d.ts +47 -0
  28. package/src/index.js +11 -0
  29. package/src/manifest/normalize.js +113 -4
  30. package/src/scaffold/copyTemplate.js +71 -0
  31. package/src/scaffold/index.js +150 -0
  32. package/src/scaffold/tokens.js +90 -0
  33. package/src/testing/parityBrowserRunner.js +11 -0
  34. package/src/testing/parityGate.js +287 -27
  35. package/templates/propagator-module/README.md +99 -0
  36. package/templates/propagator-module/build.js +103 -0
  37. package/templates/propagator-module/package.json +19 -0
  38. package/templates/propagator-module/plugin-manifest.json +66 -0
  39. package/templates/propagator-module/src/__MODULE_NAME_SNAKE__.cpp +450 -0
  40. package/templates/propagator-module/tests/module.build.test.mjs +103 -0
@@ -47,3 +47,94 @@ 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
+ ]);
81
+
82
+ /**
83
+ * ENGINE-HOSTED capabilities: the three that reach into a live 3D engine —
84
+ * its scene graph, its entity collection, its render loop.
85
+ *
86
+ * These used to sit in `BrowserIncompatibleCapabilityIds`, which was exactly
87
+ * backwards and produced a compliance paradox: they are also in
88
+ * `RecommendedCapabilityIds`, so a module was simultaneously told to declare
89
+ * them and refused for declaring them alongside the only runtime where they
90
+ * can possibly work. The flow compiler compounded it by unconditionally
91
+ * subtracting `browser` from any composed flow whose capability union touched
92
+ * one — meaning no flow that needs the engine could ever target the runtime
93
+ * the engine lives in.
94
+ *
95
+ * The truth, established by sweep:
96
+ * - The ONE implementation of `scene_access` in the stack is OrbPro's
97
+ * `ProviderAccessPort` + the SDK's `providerAccessEngineAdapter`, both of
98
+ * which require a live Cesium/OrbPro `Scene`. That is a BROWSER object.
99
+ * - `NodeHostSupportedCapabilities` contains none of the three, and
100
+ * `assertCapability()` fails them closed with `host-capability-unsupported`.
101
+ * - `entity_access` and `render_hooks` have no serving implementation on any
102
+ * runtime yet — only manifest-vocabulary codec entries. They are declared
103
+ * engine-hosted here because that is where they can ever be served, not
104
+ * because they are wired today.
105
+ *
106
+ * So they are wasmedge-incompatible, not browser-incompatible. (The `wasi`
107
+ * portability baseline already refuses them via
108
+ * `StandaloneWasiCapabilityIds` — nothing outside that five-member subset is
109
+ * admitted to a standalone WASI artifact.)
110
+ *
111
+ * Ruling: graph/findings/official-harness-shapes.md §8.4
112
+ * Task: graph/tasks/harness-w0-immediate-fixes.md (W0.4)
113
+ */
114
+ export const EngineHostedCapabilityIds = Object.freeze([
115
+ "scene_access",
116
+ "entity_access",
117
+ "render_hooks",
118
+ ]);
119
+
120
+ /**
121
+ * Capabilities the headless WasmEdge leg cannot serve. There is no scene, no
122
+ * entity collection and no render loop in a headless server runtime, and no
123
+ * adapter can invent one.
124
+ */
125
+ export const WasmEdgeIncompatibleCapabilityIds = Object.freeze([
126
+ ...EngineHostedCapabilityIds,
127
+ ]);
128
+
129
+ /**
130
+ * Which capabilities each runtime leg cannot serve.
131
+ *
132
+ * Consumed by `runtimeTargetGate.js` (load-time admission), the flow compiler's
133
+ * `collectFlowRuntimeTargets` (derived runtimeTargets), and
134
+ * `pluginCompliance.js` (publish-time declaration policy) — all three read
135
+ * THIS table, so a leg's policy is stated once.
136
+ */
137
+ export const LegIncompatibleCapabilityIds = Object.freeze({
138
+ browser: BrowserIncompatibleCapabilityIds,
139
+ wasmedge: WasmEdgeIncompatibleCapabilityIds,
140
+ });
@@ -11,8 +11,12 @@ import {
11
11
  validatePluginManifest,
12
12
  } from "./pluginCompliance.js";
13
13
  import {
14
+ BrowserIncompatibleCapabilityIds,
15
+ EngineHostedCapabilityIds,
16
+ LegIncompatibleCapabilityIds,
14
17
  RecommendedCapabilityIds,
15
18
  StandaloneWasiCapabilityIds,
19
+ WasmEdgeIncompatibleCapabilityIds,
16
20
  } from "../capabilities.js";
17
21
  import { validateManifestAgainstStandardsCatalog } from "../standards/index.js";
18
22
 
@@ -48,6 +52,10 @@ export async function validateArtifactWithStandards(options = {}) {
48
52
  }
49
53
 
50
54
  export {
55
+ BrowserIncompatibleCapabilityIds,
56
+ EngineHostedCapabilityIds,
57
+ LegIncompatibleCapabilityIds,
58
+ WasmEdgeIncompatibleCapabilityIds,
51
59
  findManifestFiles,
52
60
  getWasmExportNames,
53
61
  getWasmExportNamesFromFile,
@@ -13,6 +13,7 @@ import {
13
13
  RuntimeTarget,
14
14
  } from "../runtime/constants.js";
15
15
  import {
16
+ LegIncompatibleCapabilityIds,
16
17
  RecommendedCapabilityIds,
17
18
  StandaloneWasiCapabilityIds,
18
19
  } from "../capabilities.js";
@@ -25,6 +26,10 @@ import {
25
26
  isPayloadSchemaHashValid,
26
27
  payloadSchemaIdentitiesEqual,
27
28
  } from "../manifest/typeRefs.js";
29
+ import {
30
+ PluginFamilyNames,
31
+ pluginFamilyByName,
32
+ } from "../manifest/normalize.js";
28
33
  import { isLegacyWildcardPort } from "./legacyWildcardPorts.js";
29
34
  import { SDS_MANIFEST_SECTION_NAME } from "../bundle/constants.js";
30
35
  import {
@@ -49,24 +54,18 @@ const ExternalInterfaceDirectionSet = new Set(
49
54
  const ExternalInterfaceKindSet = new Set(Object.values(ExternalInterfaceKind));
50
55
  const ProtocolRoleSet = new Set(Object.values(ProtocolRole));
51
56
  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
- ]);
57
+ // One entry per runtime leg that has a declaration policy, built from the
58
+ // single table in capabilities.js. This used to be a browser-only constant,
59
+ // which is why an engine-hosted capability could only ever be described as
60
+ // "not available in the browser" — the exact inverse of the truth.
61
+ const LegIncompatibleCapabilitySets = Object.freeze(
62
+ Object.fromEntries(
63
+ Object.entries(LegIncompatibleCapabilityIds).map(([leg, ids]) => [
64
+ leg,
65
+ new Set(ids),
66
+ ]),
67
+ ),
68
+ );
70
69
  const StandaloneWasiProtocolTransportKindSet = new Set([
71
70
  ProtocolTransportKind.WASI_PIPE,
72
71
  ]);
@@ -142,6 +141,45 @@ function validateStringField(issues, value, location, label) {
142
141
  return true;
143
142
  }
144
143
 
144
+ /**
145
+ * `pluginFamily` is an ENUM, not a free string.
146
+ *
147
+ * This used to be `validateStringField`, so any spelling passed compliance and
148
+ * `normalizePluginFamily` then silently coerced it to ANALYSIS — the two
149
+ * halves of the same defect. Compliance now enforces the same vocabulary the
150
+ * normalizer refuses on, so a bad family is caught at check time with the
151
+ * valid set named, instead of at load time as a mislabelled module.
152
+ *
153
+ * Ruling: graph/findings/official-harness-shapes.md §4.7 / §8.3
154
+ */
155
+ function validatePluginFamilyField(issues, value, location) {
156
+ if (!isNonEmptyString(value)) {
157
+ pushIssue(
158
+ issues,
159
+ "error",
160
+ "missing-string",
161
+ "pluginFamily must be a non-empty string.",
162
+ location,
163
+ );
164
+ return false;
165
+ }
166
+ const normalized = value.trim().toLowerCase();
167
+ if (pluginFamilyByName[normalized] === undefined) {
168
+ pushIssue(
169
+ issues,
170
+ "error",
171
+ "unknown-plugin-family",
172
+ `pluginFamily "${value}" is not a member of the plugin family vocabulary. ` +
173
+ `Valid families: ${PluginFamilyNames.join(", ")}. ` +
174
+ `A new family is appended to schemas/PluginManifest.fbs (a projection ` +
175
+ `of the authoritative SDS pluginCategory), never invented in a manifest.`,
176
+ location,
177
+ );
178
+ return false;
179
+ }
180
+ return true;
181
+ }
182
+
145
183
  function validateCapabilityEntry(capability, issues, location) {
146
184
  if (isNonEmptyString(capability)) {
147
185
  return capability;
@@ -1428,21 +1466,27 @@ function validateRuntimeTargets(runtimeTargets, declaredCapabilities, issues, so
1428
1466
  );
1429
1467
  }
1430
1468
  }
1431
- if (
1432
- seenTargets.has(RuntimeTarget.BROWSER) &&
1433
- Array.isArray(declaredCapabilities)
1434
- ) {
1435
- for (const capability of declaredCapabilities) {
1436
- if (!BrowserIncompatibleCapabilitySet.has(capability)) {
1469
+ if (Array.isArray(declaredCapabilities)) {
1470
+ // Check EVERY declared leg that has a policy, not just the browser. An
1471
+ // engine-hosted capability (scene_access/entity_access/render_hooks) is
1472
+ // browser-ONLY, so the conflict it can raise is against `wasmedge`.
1473
+ for (const target of seenTargets) {
1474
+ const incompatible = LegIncompatibleCapabilitySets[target];
1475
+ if (!incompatible) {
1437
1476
  continue;
1438
1477
  }
1439
- pushIssue(
1440
- issues,
1441
- "error",
1442
- "capability-runtime-conflict",
1443
- `Capability "${capability}" is not available in the canonical browser runtime target.`,
1444
- `${sourceName}.capabilities`,
1445
- );
1478
+ for (const capability of declaredCapabilities) {
1479
+ if (!incompatible.has(capability)) {
1480
+ continue;
1481
+ }
1482
+ pushIssue(
1483
+ issues,
1484
+ "error",
1485
+ "capability-runtime-conflict",
1486
+ `Capability "${capability}" is not available in the canonical ${target} runtime target.`,
1487
+ `${sourceName}.capabilities`,
1488
+ );
1489
+ }
1446
1490
  }
1447
1491
  }
1448
1492
  }
@@ -1750,7 +1794,7 @@ export function validatePluginManifest(manifest, options = {}) {
1750
1794
  validateStringField(issues, manifest.pluginId, `${sourceName}.pluginId`, "pluginId");
1751
1795
  validateStringField(issues, manifest.name, `${sourceName}.name`, "name");
1752
1796
  validateStringField(issues, manifest.version, `${sourceName}.version`, "version");
1753
- validateStringField(issues, manifest.pluginFamily, `${sourceName}.pluginFamily`, "pluginFamily");
1797
+ validatePluginFamilyField(issues, manifest.pluginFamily, `${sourceName}.pluginFamily`);
1754
1798
 
1755
1799
  const rawDeclaredCapabilities = manifest.capabilities;
1756
1800
  let declaredCapabilities = null;
@@ -56,6 +56,7 @@ import {
56
56
  } from "../compiler/pthreadArtifactGuard.js";
57
57
  import { resolveWasiThreadsToolchain } from "../compiler/wasiThreadsToolchain.js";
58
58
  import {
59
+ LegIncompatibleCapabilityIds,
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,190 @@ function collectComponentDependencies({ nodePluginIds, dependencies, issues, cap
890
904
  return [...components.values()].sort((a, b) => a.pluginId.localeCompare(b.pluginId));
891
905
  }
892
906
 
907
+ // Per-leg incompatibility, read from the ONE table in capabilities.js rather
908
+ // than a browser-only constant. An engine-hosted capability
909
+ // (scene_access/entity_access/render_hooks) is browser-ONLY, so the leg it
910
+ // narrows away is `wasmedge`; the old browser-only form could only ever
911
+ // subtract `browser`, which is how a flow that needs the engine ended up
912
+ // unable to target the runtime the engine lives in.
913
+ const LegIncompatibleCapabilitySets = Object.freeze(
914
+ Object.fromEntries(
915
+ Object.entries(LegIncompatibleCapabilityIds).map(([leg, ids]) => [
916
+ leg,
917
+ new Set(ids),
918
+ ]),
919
+ ),
920
+ );
921
+
922
+ function declaredRuntimeTargets(manifest) {
923
+ const declared = Array.isArray(manifest?.runtimeTargets)
924
+ ? manifest.runtimeTargets
925
+ .map((target) => String(target ?? "").trim().toLowerCase())
926
+ .filter(Boolean)
927
+ : [];
928
+ return declared.length > 0 ? declared : null;
929
+ }
930
+
931
+ /**
932
+ * collectFlowRuntimeTargets derives the COMPOSED artifact's runtimeTargets from
933
+ * its constituents instead of asserting a fixed pair.
934
+ *
935
+ * A flow runs only where ALL of its parts run, so the composition's target set
936
+ * is the INTERSECTION of the declared target sets of every node plugin and
937
+ * every resolved component dependency, taken inside the universe a composed
938
+ * artifact can reach at all (browser + WasmEdge). A part that declares no
939
+ * targets is unconstraining — it says nothing, so it subtracts nothing. The
940
+ * admission test is `runtimeTargetSatisfies`, THE SAME ONE every loader uses,
941
+ * so a declaration cannot mean one thing to the compiler and another at the
942
+ * door.
943
+ *
944
+ * The CAPABILITY UNION subtracts too. A declared target set is not the only
945
+ * way a part can be non-browser: a plugin that carries `wallet_sign` and
946
+ * simply omits `runtimeTargets` says nothing about where it runs, yet the
947
+ * composition provably cannot run in a browser, because compliance forbids
948
+ * exactly that capability against a browser target. Deriving only from the
949
+ * declarations left the commonest manifest shape re-creating the original
950
+ * defect — the compiler stamping a target its own compliance pass then
951
+ * rejects. So a leg is also dropped whenever the flow's capability union meets
952
+ * that leg's entry in `LegIncompatibleCapabilityIds`, and the diagnostic names
953
+ * the capability that cost it. That table is per-leg, not browser-only: the
954
+ * engine-hosted capabilities (scene_access/entity_access/render_hooks) narrow
955
+ * away `wasmedge`, in the other direction.
956
+ *
957
+ * This is the difference between a truthful manifest and a wish. The hardcoded
958
+ * ["browser","wasmedge"] made every composition claim the browser, which made
959
+ * `capability-runtime-conflict` fire on any flow containing a node with a
960
+ * browser-incompatible capability (wallet_sign, tcp, storage_write, …) — so a
961
+ * WasmEdge-only flow was not merely mislabelled, it was IMPOSSIBLE TO BAKE.
962
+ * Deriving the set keeps the real invariant intact (a browser-incompatible
963
+ * capability and a browser target still cannot coexist) while letting the
964
+ * legitimate WasmEdge-only composition exist.
965
+ */
966
+ function collectFlowRuntimeTargets({
967
+ resolvedNodes,
968
+ componentDependencies,
969
+ dependencies,
970
+ capabilities,
971
+ issues,
972
+ }) {
973
+ let targets = new Set(COMPOSED_FLOW_RUNTIME_TARGET_UNIVERSE);
974
+ // pluginId -> the declared set, for the diagnostic when nothing survives.
975
+ const constraints = new Map();
976
+ // target -> why it is gone. Losing a runtime is never silent.
977
+ const droppedBecause = new Map();
978
+
979
+ const narrow = (allowed, reasonFor) => {
980
+ for (const target of [...targets]) {
981
+ if (allowed(target)) continue;
982
+ targets.delete(target);
983
+ if (!droppedBecause.has(target)) {
984
+ droppedBecause.set(target, reasonFor(target));
985
+ }
986
+ }
987
+ };
988
+
989
+ const consider = (pluginId, manifest) => {
990
+ if (!pluginId || constraints.has(pluginId)) return;
991
+ const declared = declaredRuntimeTargets(manifest);
992
+ if (!declared) return;
993
+ constraints.set(pluginId, [...declared].sort());
994
+ // ONE RULE, shared with the loaders (`runtimeTargetSatisfies`). Two rules
995
+ // for one field is how a manifest comes to mean different things to the
996
+ // compiler and to the door: a `["wasi","wasmedge"]` part had the browser
997
+ // leg stripped from every flow it joined while the browser harness happily
998
+ // admitted that same declaration. `wasi` is the portability baseline, so
999
+ // it admits both legs unless the part's own capabilities say otherwise;
1000
+ // proof-based narrowing is the capability-union pass below.
1001
+ narrow(
1002
+ (target) =>
1003
+ runtimeTargetSatisfies(declared, target, manifest?.capabilities),
1004
+ () =>
1005
+ `"${pluginId}" declares runtimeTargets [${[...declared].sort().join(", ")}]`,
1006
+ );
1007
+ };
1008
+
1009
+ for (const entry of resolvedNodes) {
1010
+ if (!entry?.dependency) continue;
1011
+ consider(entry.node?.pluginId, entry.dependency.manifest);
1012
+ }
1013
+ for (const component of componentDependencies ?? []) {
1014
+ if (!component?.resolved) continue;
1015
+ consider(
1016
+ component.pluginId,
1017
+ dependencies.get(component.pluginId)?.manifest,
1018
+ );
1019
+ }
1020
+
1021
+ const unionCapabilities = [...(capabilities ?? [])];
1022
+ for (const leg of COMPOSED_FLOW_RUNTIME_TARGET_UNIVERSE) {
1023
+ const incompatible = LegIncompatibleCapabilitySets[leg];
1024
+ if (!incompatible) continue;
1025
+ const offending = unionCapabilities
1026
+ .filter((capability) => incompatible.has(capability))
1027
+ .sort();
1028
+ if (offending.length === 0) continue;
1029
+ narrow(
1030
+ (target) => target !== leg,
1031
+ () =>
1032
+ `the flow's capability union contains ${offending
1033
+ .map((capability) => `"${capability}"`)
1034
+ .join(", ")}, which the canonical ${leg} runtime target cannot serve`,
1035
+ );
1036
+ }
1037
+
1038
+ const ordered = COMPOSED_FLOW_RUNTIME_TARGET_UNIVERSE.filter((target) =>
1039
+ targets.has(target),
1040
+ );
1041
+ const reasons = [...droppedBecause.entries()]
1042
+ .map(([target, why]) => `${target} (${why})`)
1043
+ .join("; ");
1044
+ if (ordered.length === 0) {
1045
+ pushIssue(
1046
+ issues,
1047
+ "error",
1048
+ "empty-runtime-target-intersection",
1049
+ "No runtime runs every part of this flow: every candidate target was ruled " +
1050
+ `out — ${reasons}. A composed flow can only target the runtimes shared by ` +
1051
+ "all of its nodes and resolved component dependencies; split the flow, or " +
1052
+ "widen the constraining plugin's runtimeTargets.",
1053
+ "flow.nodes",
1054
+ );
1055
+ } else if (droppedBecause.size > 0) {
1056
+ pushIssue(
1057
+ issues,
1058
+ "warning",
1059
+ "narrowed-runtime-targets",
1060
+ `This flow composes to runtimeTargets [${ordered.join(", ")}] — narrower than ` +
1061
+ `the isomorphic pair. Dropped: ${reasons}. The artifact is still written to ` +
1062
+ "dist/isomorphic/module.wasm, but it is NOT loadable on the dropped runtime " +
1063
+ "and every harness for that leg will refuse it by name.",
1064
+ "flow.nodes",
1065
+ );
1066
+ }
1067
+ return ordered;
1068
+ }
1069
+
1070
+ /**
1071
+ * Rebuild the derived target set from a check record produced by an older SDK
1072
+ * (one with no `runtimeTargets` field). Issues are discarded here on purpose —
1073
+ * the caller re-raises the only one that matters, an empty result, as a throw.
1074
+ */
1075
+ function recomputeFlowRuntimeTargets(check, dependencies) {
1076
+ return collectFlowRuntimeTargets({
1077
+ resolvedNodes: (check?.nodes ?? [])
1078
+ .filter((node) => node.dispatchModel !== "host")
1079
+ .map((node) => ({
1080
+ node,
1081
+ dependency: dependencies.get(node.pluginId) ?? null,
1082
+ }))
1083
+ .filter((entry) => entry.dependency),
1084
+ componentDependencies: check?.componentDependencies,
1085
+ dependencies,
1086
+ capabilities: check?.capabilities ?? [],
1087
+ issues: [],
1088
+ });
1089
+ }
1090
+
893
1091
  /**
894
1092
  * findFlowCycles returns every elementary cycle reachable in the node graph
895
1093
  * as { nodeIds, edges } (iterative DFS with back-edge extraction; each cycle
@@ -1773,6 +1971,14 @@ export function checkFlowProgram({ flow, dependencies = new Map() } = {}) {
1773
1971
  capabilities,
1774
1972
  });
1775
1973
 
1974
+ const runtimeTargets = collectFlowRuntimeTargets({
1975
+ resolvedNodes,
1976
+ componentDependencies,
1977
+ dependencies,
1978
+ capabilities,
1979
+ issues,
1980
+ });
1981
+
1776
1982
  const errors = issues.filter((issue) => issue.severity === "error");
1777
1983
  return {
1778
1984
  ok: errors.length === 0,
@@ -1780,6 +1986,7 @@ export function checkFlowProgram({ flow, dependencies = new Map() } = {}) {
1780
1986
  errors,
1781
1987
  warnings: issues.filter((issue) => issue.severity === "warning"),
1782
1988
  capabilities: [...capabilities].sort(),
1989
+ runtimeTargets,
1783
1990
  engineLinkage,
1784
1991
  threadModel,
1785
1992
  componentDependencies,
@@ -1908,6 +2115,23 @@ export function buildFlowModuleManifest({ flow, check, dependencies }) {
1908
2115
  const checkedNodeById = new Map(
1909
2116
  check.nodes.map((node) => [node.nodeId, node]),
1910
2117
  );
2118
+ // `check.runtimeTargets` is the derived intersection. It is absent only when
2119
+ // a caller hands in a check record built by an older SDK; recompute rather
2120
+ // than silently re-asserting the historical pair — and THROW on the empty
2121
+ // intersection instead of swallowing it. A check record has an issue sink;
2122
+ // this path does not, and an artifact that declares no runtime at all is
2123
+ // read as unconstrained by every loader downstream, which is the most
2124
+ // permissive possible answer to the least certain question.
2125
+ const runtimeTargets = Array.isArray(check.runtimeTargets)
2126
+ ? [...check.runtimeTargets]
2127
+ : recomputeFlowRuntimeTargets(check, dependencies);
2128
+ if (runtimeTargets.length === 0) {
2129
+ throw new Error(
2130
+ `Flow "${flow?.programId ?? "(unknown)"}" has no runtime target: no runtime runs ` +
2131
+ "every one of its parts. Refusing to build a manifest that declares nothing — " +
2132
+ "run checkFlowProgram and read the empty-runtime-target-intersection error.",
2133
+ );
2134
+ }
1911
2135
 
1912
2136
  const hostModelNodeIds = new Set(
1913
2137
  check.nodes.filter((node) => node.dispatchModel === "host").map((node) => node.nodeId),
@@ -2032,13 +2256,17 @@ export function buildFlowModuleManifest({ flow, check, dependencies }) {
2032
2256
  externalInterfaces,
2033
2257
  methods,
2034
2258
  schemasUsed,
2035
- runtimeTargets: ["browser", "wasmedge"],
2259
+ // DERIVED, never asserted: the runtimes shared by every node plugin and
2260
+ // resolved component dependency (see collectFlowRuntimeTargets). A flow of
2261
+ // fully isomorphic nodes keeps both targets; a flow containing one
2262
+ // WasmEdge-only node is a WasmEdge-only flow and says so.
2263
+ runtimeTargets,
2036
2264
  buildArtifacts: [
2037
2265
  {
2038
2266
  artifactId: `${cIdent(flow.programId)}-flow-runtime`,
2039
2267
  kind: "wasm",
2040
2268
  path: "dist/isomorphic/module.wasm",
2041
- target: "browser,wasmedge",
2269
+ target: runtimeTargets.join(","),
2042
2270
  },
2043
2271
  ],
2044
2272
  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,
@@ -12,6 +12,14 @@ export declare enum PluginFamily {
12
12
  SDF = 7,
13
13
  INFRASTRUCTURE = 8,
14
14
  FLOW = 9,
15
- BRIDGE = 10
15
+ BRIDGE = 10,
16
+ MANEUVER = 11,
17
+ ORBIT_DETERMINATION = 12,
18
+ FOUNDATION = 13,
19
+ PARSER = 14,
20
+ VALIDATOR = 15,
21
+ EXPORTER = 16,
22
+ PUBLISHER = 17,
23
+ BASILISK = 18
16
24
  }
17
25
  //# sourceMappingURL=plugin-family.d.ts.map
@@ -16,5 +16,13 @@ export var PluginFamily;
16
16
  PluginFamily[PluginFamily["INFRASTRUCTURE"] = 8] = "INFRASTRUCTURE";
17
17
  PluginFamily[PluginFamily["FLOW"] = 9] = "FLOW";
18
18
  PluginFamily[PluginFamily["BRIDGE"] = 10] = "BRIDGE";
19
+ PluginFamily[PluginFamily["MANEUVER"] = 11] = "MANEUVER";
20
+ PluginFamily[PluginFamily["ORBIT_DETERMINATION"] = 12] = "ORBIT_DETERMINATION";
21
+ PluginFamily[PluginFamily["FOUNDATION"] = 13] = "FOUNDATION";
22
+ PluginFamily[PluginFamily["PARSER"] = 14] = "PARSER";
23
+ PluginFamily[PluginFamily["VALIDATOR"] = 15] = "VALIDATOR";
24
+ PluginFamily[PluginFamily["EXPORTER"] = 16] = "EXPORTER";
25
+ PluginFamily[PluginFamily["PUBLISHER"] = 17] = "PUBLISHER";
26
+ PluginFamily[PluginFamily["BASILISK"] = 18] = "BASILISK";
19
27
  })(PluginFamily || (PluginFamily = {}));
20
28
  //# sourceMappingURL=plugin-family.js.map
@@ -17,4 +17,12 @@ export enum PluginFamily {
17
17
  INFRASTRUCTURE = 8,
18
18
  FLOW = 9,
19
19
  BRIDGE = 10,
20
+ MANEUVER = 11,
21
+ ORBIT_DETERMINATION = 12,
22
+ FOUNDATION = 13,
23
+ PARSER = 14,
24
+ VALIDATOR = 15,
25
+ EXPORTER = 16,
26
+ PUBLISHER = 17,
27
+ BASILISK = 18,
20
28
  }