Data source

shippedShipped contract. Generated from a ratified .fbs single source, covered by a conformance kit and a reference module.

Status: v1 — owner directive 2026-08-07, graph task sdk-provider-access-abi.

One generalized port that lets a WASM module control imagery and terrain providers and read the already-decoded bytes those providers hold in memory — identical import names and signatures in the browser, in native WasmEdge, and in the Docker WasmEdge container.

This is a provider access port, not a Cesium port and not an OrbPro port. Nothing in the guest ABI names an engine, a tile scheme, or a vendor. The same three imports serve a browser engine's live quadtree cache, a host-side tile store, and a deterministic test fixture.

Doctrine

Four rules govern every line below.

  1. One port, two planes. Control is metadata and rides the existing space_data_module_host sync hostcall bridge as provider.* operations. Data is bulk bytes and rides three dedicated imports that write straight into guest linear memory. Control never carries pixels; data never carries JSON.
  2. No runtime detection in module code. A module that reads terrain never asks which runtime it is in. Every difference is absorbed in the SDK host shims.
  3. Present everywhere, honest everywhere. The imports link in every runtime. There is no lane where space_data_provider.acquire is missing — a missing import is a link-time divergence, the worst class. When nothing can serve a request the port returns a value (a negative error code), never a trap, and the same code in every runtime.
  4. Decoded or nothing, by default. The port hands over buffers the provider already decoded. It never re-fetches and never re-parses unless the caller explicitly raised its cost ceiling. Every acquire carries a declared cost class; the default ceiling admits only already-resident bytes, and an adapter that would have to exceed it returns SDM_PROVIDER_E_UNSUPPORTED rather than quietly paying it.

Capability

The port is gated by the existing coarse capability, with a scope:

json
{
  "capability": "scene_access",
  "scope": "provider.v1",
  "required": false,
  "description": "Read and control imagery/terrain providers."
}

scene_access is already in the SDK recommended capability vocabulary (src/capabilities.js) and already maps to the typed CapabilityKind.SCENE_ACCESS PLG enum (src/manifest/normalize.js). This ABI introduces no new host capability — it is a scope on an existing one, exactly as gpu_compute uses scope: "webgpu.v1". No owner sign-off gate, no SDS enum change, no new generic hook.

required: false is the recommended posture. A module whose analysis degrades gracefully without provider bytes must declare it optional and handle SDM_PROVIDER_E_NO_PROVIDER as a first-class outcome, because that is the answer it will get on a headless node with no tile store configured.

The import set

Import module: space_data_provider. Three functions. Every parameter and every result is i32.

wat
(import "space_data_provider" "acquire"
        (func (param i32 i32 i32) (result i32)))
(import "space_data_provider" "read"
        (func (param i32 i32 i32 i32 i32) (result i32)))
(import "space_data_provider" "release"
        (func (param i32) (result i32)))
functionparamsresult
acquirereqPtr, reqLen, descPtrhandle > 0, or negative error code
readhandle, plane, srcOffset, dstPtr, dstLenbytes written >= 0, or negative error code
releasehandle0, or negative error code

Open, read, close. Three doors.

No i64 appears anywhere in the boundary signature, and no f64 either. This is deliberate. The i64 legalization trap measured on this SDK — a 64-bit-parameter import legalizes differently depending on how the host instantiates it, and the mismatch surfaces as a link failure or a silently truncated argument in exactly one runtime — is sidestepped by construction, not by convention. Every 64-bit quantity in this ABI (bounding rectangles, height extrema, sample coordinates) travels either inside the JSON acquire request or inside the descriptor struct in guest memory, where it is plain little-endian IEEE-754 the guest reads with a normal load. The boundary itself carries only pointers, lengths, indices and codes.

Error detail (a message, a name, the failing operation) is retrieved through the control plane with provider.lastError. It is deliberately not an extra import: strings are metadata and metadata rides the existing bridge.

Tile descriptor

SDM_PROVIDER_TILE_DESC_BYTES = 128. Little-endian. u32 fields are 4-byte aligned; f64 fields start at offset 48 and are all 8-byte aligned, so a guest may read them with aligned loads on every target.

offsettypefieldmeaning
0u32magic0x53445054 ('SDPT')
4u32version1
8u32kind1 terrain, 2 imagery
12u32encodingsee below
16u32widthelements per row
20u32heightrows
24u32planeCountnumber of readable planes, >= 1
28u32bytesPerElementof plane 0
32u32rowStrideBytesof plane 0; >= width * bytesPerElement
36u32byteLengthof plane 0
40u32flagssee below
44u32levelsource level, or 0xFFFFFFFF for derived tiles
48f64westradians
56f64southradians
64f64eastradians
72f64northradians
80f64minValueterrain: min height, metres. imagery: 0
88f64maxValueterrain: max height, metres. imagery: 0
96u32tileXsource tile X, or 0xFFFFFFFF
100u32tileYsource tile Y, or 0xFFFFFFFF
104u32hostCopieshost→guest copies this acquire will cost per whole-plane read
108u32sourceIdFNV-1a 32 of the provider id — stable, comparable across runtimes
112u32costClasswhat this acquire actually cost — see Cost classes
116u32strategyhow the level was chosen: 0 default, 1 grid-matched-level, 2 most-detailed, 3 fixed-level
120u32[2]reservedzero

encoding:

valuenameelement
1SDM_PROVIDER_ENC_HEIGHT_F32float metres above the ellipsoid
2SDM_PROVIDER_ENC_HEIGHT_F64double metres above the ellipsoid
16SDM_PROVIDER_ENC_RGBA84 × uint8_t
17SDM_PROVIDER_ENC_RGB83 × uint8_t
18SDM_PROVIDER_ENC_GRAY81 × uint8_t
19SDM_PROVIDER_ENC_GRAY161 × uint16_t
20SDM_PROVIDER_ENC_RGBA_F324 × float, 0..1

Terrain heights are always delivered in metres, as f32 or f64. A provider's on-the-wire encoding — a 16-bit heightmap with {heightScale, heightOffset, elementsPerHeight, stride, elementMultiplier, isBigEndian} structure, or a quantized-mesh's zig-zag-encoded u16 vertices — is the provider's business. The adapter dequantizes into metres exactly once and pins the result. Guests never see a scale/offset field because guests must never reimplement a provider's quantization; that is the single most likely place for two runtimes to disagree by one ULP, and the ABI removes the opportunity rather than documenting it.

flags:

bitnamemeaning
0SDM_PROVIDER_FLAG_INTERPOLATEDvalues were interpolated from a coarser level than requested
1SDM_PROVIDER_FLAG_STAGEDhost had to stage a copy; hostCopies is 2
2SDM_PROVIDER_FLAG_PARTIALsome elements have no data and hold the no-data sentinel
3SDM_PROVIDER_FLAG_DERIVEDnot a source tile (profile/region request)
4SDM_PROVIDER_FLAG_FIXTUREserved by the deterministic fixture adapter

Bit 4 exists so a parity test can prove it compared fixture bytes against fixture bytes, rather than assuming it.

The no-data sentinel

A terrain sample with no data is not absent, not 0, and not NaN. It is an exact bit pattern:

encodingsentinelbits
HEIGHT_F32-FLT_MAX0xFF7FFFFF
HEIGHT_F64-DBL_MAX0xFFEFFFFFFFFFFFFF

NaN is specifically rejected. WebAssembly does not canonicalize NaN payloads across all producing operations, so two runtimes can hold different bits for "a NaN" and a byte-identical-output assertion would fail on a value that is semantically equal. A sentinel with one exact encoding cannot do that.

This also closes a real defect class in the existing engine seam, where "no data" is expressed as Cartographic.height === undefined — a JS-object-only state that a typed array cannot represent at all, and which therefore silently becomes 0 (sea level) the moment anyone packs those samples into a buffer. Sea level under a ridge is exactly the failure the RF terrain solver was filed for. The sentinel is defined here, in the ABI, so no consumer has to invent it.

Cost classes

costClass states what an acquire actually cost. The acquire request carries "maxCost", and an adapter that cannot serve within it returns SDM_PROVIDER_E_UNSUPPORTED.

valuenamemeaning
0SDM_PROVIDER_COST_RESIDENTbytes already decoded and resident; pure memcpy
1SDM_PROVIDER_COST_DEQUANTIZEresident, but dequantized to metres by the adapter
2SDM_PROVIDER_COST_REDECODEadapter re-decoded already-fetched source data
3SDM_PROVIDER_COST_REFETCHadapter went to the network
4SDM_PROVIDER_COST_READBACKadapter stalled the GPU to read a texture back

maxCost defaults to 1. That default is the owner's "never re-fetch or re-parse" rule, enforced by the ABI rather than by discipline. A caller who genuinely wants the expensive path must raise the ceiling and thereby say so in its own source.

Class 1 exists because it is unavoidable and honest: a quantized-mesh terrain tile holds uint16 vertices and no metre-space array exists anywhere in the provider. The adapter dequantizes with the provider's own formula, once, into the pinned buffer. That is not a re-decode — nothing is re-parsed and nothing is re-fetched — but it is not free either, so it gets its own class instead of being hidden inside class 0.

Acquire requests

The JSON request selects one of three shapes. All angles are radians.

tile — one source tile, exactly as the provider decoded it. Zero resampling, zero interpolation.

json
{"op":"tile","providerId":"terrain.fixture","level":9,"x":123,"y":45}

profile — heights along a path. The seam the RF terrain solver consumes: one call, one copy, width = samples, height = 1, encoding = SDM_PROVIDER_ENC_HEIGHT_F64.

json
{"op":"profile","providerId":"terrain.fixture",
 "start":[-1.9,0.65],"end":[-1.88,0.66],"samples":256,"level":"mostDetailed"}

or with explicit positions:

json
{"op":"profile","providerId":"terrain.fixture",
 "positions":[[-1.9,0.65],[-1.899,0.6501]]}

Raster in. A coverage field is commonly 512x512 = 262,144 positions. Encoding those as JSON would be a multi-megabyte request string, parsed on every call — a worse cost than the read it is asking for, and it would make the control plane carry bulk data, which this ABI exists to prevent. So positions may instead be a pointer:

json
{"op":"profile","providerId":"terrain.fixture",
 "positionsPtr":1048576,"positionsCount":262144,"spacing":30}

The host reads interleaved f64 lon/lat pairs straight out of guest memory with ONE copy, symmetric with the way results come back. The request string stays under 200 bytes no matter how large the field is. A pointer or count outside linear memory is SDM_PROVIDER_E_BOUNDS — a value, never a trap and never a silent short read.

spacing beats level. Any shape may carry "spacing": <metres> — the sample stride the caller intends to march at — and the port picks the coarsest level whose sample spacing is at or below it. This is the preferred form: a consumer knows its own stride, it does not know a provider's level scheme. Asking for "mostDetailed" everywhere is what makes a solve slow; asking for coarser than the march stride is what makes it wrong. Level selection lives in one shared helper (providerLevelForSpacing) used by every adapter, so the browser and a host tile store resolve the same spacing to the same level — two adapters doing their own arithmetic would sample different ground and lose byte parity for a reason no diff would show.

The level actually used comes back in descriptor.level, always. A consumer that cannot see which level answered cannot tell a solve that resolved the ridges from one that interpolated them away.

level is an integer, "mostDetailed", or omitted (adapter's default). The sampled positions are great-circle-interpolated between start and end by the host adapter, using the adapter's native sampler, so that two runtimes sampling the same source agree byte-for-byte. A guest that interpolates its own positions and passes them explicitly gets exactly what it asked for.

region — a rectangle resampled onto a width × height grid. What a viewshed, a coverage raster, or a cloud-mask lane wants.

json
{"op":"region","providerId":"imagery.fixture",
 "rectangle":[-1.91,0.64,-1.87,0.67],"width":256,"height":256,"level":9}

region on a terrain provider yields heights; on an imagery provider it yields pixels. The port does not care which — that is the whole point.

Every shape accepts "maxCost": N (default 1) and "plane": "height" | "pixels" | N.

Imagery is not terrain, and the ABI says so

Terrain can honour maxCost: 0..1 because a decoded terrain tile stays resident in the provider for as long as the tile is loaded. Imagery cannot, on the current engine tree: the CPU-side pixel buffer is released immediately after the texture is uploaded to the GPU, so for any tile the renderer has finished with, the decoded pixels are simply gone.

The consequence is stated here rather than discovered later:

An adapter that wants to offer resident imagery must tap the pixels before upload; that is an engine-side change and belongs to the engine owner, not to this ABI. Until it exists, SDM_PROVIDER_E_UNSUPPORTED is the correct and final answer for resident imagery pixels, and it is the same answer in all three runtimes. The ABI does not promise imagery parity with terrain, because the engine cannot currently deliver it, and a port that promised it would be lying in exactly the way this SDK exists to prevent.

Control operations

Ride the existing space_data_module_host.call bridge. No new imports.

operationrequestresponse
provider.list`{"kind":"terrain"\"imagery"\null}`{"providers":[{id,kind,name,ready,minLevel,maxLevel,tileWidth,tileHeight,encoding,credit}]}
provider.describe{"id":"..."}the same record, plus adapter-specific attributes
provider.select{"kind":"terrain","id":"..."}{"selected":"..."}
provider.configure{"id":"...","settings":{...}}{"applied":["alpha","show"],"rejected":[]}
provider.availability{"id":"...","level":9,"x":1,"y":2}{"available":true}
provider.prefetch{"id":"...","rectangle":[w,s,e,n],"level":9}{"requested":N,"pending":M,"supported":bool}
provider.await{"id":"...","rectangle":[...],"level":9,"timeoutMs":30000}{"ready":true,"pending":0}
provider.lastError{}{"code":-3,"name":"...","message":"...","operation":"..."}
provider.stats{}{"acquires":N,"reads":N,"bytesCopied":N,"hostCopies":N,"pinned":N}

provider.list returning {"providers":[]} is a success, not an error. A headless node with nothing configured is a legitimate, enumerable state; making it an error would mean the "nothing here" path differs from the "something here" path in shape, and modules would grow runtime-shaped branches. They return the same shape, so they don't.

provider.configure is intentionally a key/value bag with an explicit applied/rejected split. Adapters apply what their underlying surface natively supports and report the rest as rejected. An adapter must never emulate a setting its provider does not have.

provider.stats.hostCopies is what a copy-count assertion reads. It is maintained identically by every adapter.

provider.prefetch carries "supported" because region prefetch is a genuine engine LACK, ruled by the engine owner: the browser engine loads tiles from camera position only, and its one region-shaped API loads availability metadata, not geometry. Rather than invent an engine API — which the native-API law forbids — the port reports "supported": false with "requested": 0. A host-side tile-store adapter, which has no camera and can simply fetch, reports "supported": true. This is a real capability difference and it is declared in the response, not hidden: a module reads one boolean and does not branch on runtime. Closing the gap is engine work, tracked separately as orbpro-provider-access-port.

Error codes

Negative i32, returned from acquire, read and release. Identical value, identical meaning, identical trap class (none — these are values) in every runtime.

codenamewhen
-1SDM_PROVIDER_E_INVALID_REQUESTmalformed JSON, unknown op, out-of-range field
-2SDM_PROVIDER_E_NO_CAPABILITYscene_access/provider.v1 not granted
-3SDM_PROVIDER_E_NO_PROVIDERno provider with that id or kind
-4SDM_PROVIDER_E_NOT_READYprovider exists but is not ready; provider.await first
-5SDM_PROVIDER_E_NOT_AVAILABLEno data at that level/x/y
-6SDM_PROVIDER_E_BOUNDSpointer/length outside guest memory, or offset past the plane
-7SDM_PROVIDER_E_BAD_HANDLEunknown or already-released handle
-8SDM_PROVIDER_E_BAD_PLANEplane >= planeCount
-9SDM_PROVIDER_E_UNSUPPORTEDadapter cannot serve this without re-fetch or re-decode
-10SDM_PROVIDER_E_TIMEOUTload did not complete in the adapter's budget
-11SDM_PROVIDER_E_HOSTadapter threw; detail via provider.lastError
-12SDM_PROVIDER_E_PORT_UNAVAILABLEno provider port bound in this runtime at all

-9 is the honesty code. An adapter that could produce the answer by re-downloading a tile or re-decoding a PNG must return -9 rather than pay a cost the caller did not ask for. A caller that wants the expensive path asks for it explicitly through provider.prefetch + provider.await.

Copy contract

The owner's budget is one copy host→wasm, zero re-decode. The port meets it where the memory topology permits and reports honestly where it does not. descriptor.hostCopies and provider.stats.hostCopies are the measurement, readable from inside the guest, identical field in every runtime.

lanecopies for a whole-plane readwhy
WasmEdge native1host writes the pinned decoded buffer into linear memory
WasmEdge in Docker1same host path
Browser, wasi-threads guest1guest memory is a SharedArrayBuffer; the controlling thread that owns the provider caches writes into it directly at dstPtr
Browser, sequential guest2 (FLAG_STAGED)guest memory is not shared, so the controlling thread stages into the SAB and the worker performs the final copy

Zero of these paths encode the bytes into a hostcall envelope. That matters: the generic space_data_module_host envelope route costs five copies for a tile in the browser worker topology (provider array → envelope encode → SAB data.set → worker new Uint8Array(length) → bridge lastResponseBytesread_response into linear memory). A 262 KB heightmap through the generic route is over a megabyte of memcpy per tile. That measured cost is the entire justification for three dedicated imports instead of one more provider.* operation on the existing bridge.

The sequential-browser 2 is the already-sanctioned browser-sequential throughput divergence and nothing more. Bytes are identical, error codes are identical, FLAG_STAGED and hostCopies state the difference out loud, and a module that wants to assert hostCopies == 1 can — it will simply be asserting that it is running threaded. It closes when browser-worker-topology lands.

Runtime satisfaction

The imports are always present. What is bound behind them varies, and only in the SDK host shims.

Browser, engine present. createEngineProviderAdapter({ scene }) satisfies the port from the engine's live provider objects, through engine-native APIs only. It binds in two tiers, and which tier answered is visible to the guest in descriptor.costClass:

Tier B works today against real provider objects with no engine change, which is what makes the port usable before the engine port lands; Tier A is what makes it cheap afterwards. Neither tier ever silently upgrades its cost.

WasmEdge, native or Docker. There is no engine. The ruling, explicitly:

The server-side satisfaction is a host-side tile-store adapter, not a refusal — and not an engine port. createTileStoreProviderAdapter(...) serves the identical operations from tile sources reachable by the host, using only capabilities that already exist: filesystem for a local tileset directory, http for a remote tile service. It introduces no new generic hook, so it needs no owner sign-off and no new connector. Where the SDN node wants to expose its own curated terrain/imagery sources through this port, that is a node-side configuration of this adapter — a sdn escalation for wiring, never a change to this ABI.

Any runtime with nothing bound. The port answers and does not lie: provider.list{"providers":[]}; acquireSDM_PROVIDER_E_NO_PROVIDER; with the capability withheld entirely, SDM_PROVIDER_E_NO_CAPABILITY; with no port installed by the host at all, SDM_PROVIDER_E_PORT_UNAVAILABLE. All three are values, none is a trap, and all three are reachable in the browser too — ask a browser adapter for a provider id that does not exist and you get -3, the same -3. The failure path is therefore parity-testable in every lane rather than being a WasmEdge-only special case, which is the difference between a mirror and an excuse.

A module never branches on runtime. It branches on -3, and that branch is exercised in all three lanes.

Parity envelope

Inside the envelope — byte-identical across browser, native WasmEdge and Docker WasmEdge, at any thread count:

The fixture provider is a deterministic analytic source (a closed-form height field and a closed-form pixel field, both functions of tile coordinates only). It is bound in the browser as a real engine-shaped provider object and under WasmEdge as a fixture tile store, so both lanes exercise the adapter code paths rather than a shared shortcut. FLAG_FIXTURE proves which one answered.

Outside the envelope — and this is stated so it is never mistaken for a defect: bytes from live network providers. Two runtimes reading a live world-terrain service are not obliged to hold the same tiles at the same instant, and asserting so would be false rigor. Their ABI behaviour — codes, descriptor shapes, plane counts, bounds handling, copy accounting — is fully inside the envelope.

Divergence in anything listed as inside the envelope is a P1 SDK defect. Not a platform quirk. File, block, fix.

Consumer seam — terrain source

The immediate consumer is the RF terrain solver, and the point of defining the terrain half first is that it must not ship a private terrain path.

src/host/terrainSourceSeam.js defines the one JS interface both the engine port and the SDK adapters implement, plus assertTerrainSourceConformance(source) so a consumer can prove at wiring time that whatever it was handed is the real seam:

js
{
  id,                                   // stable provider id
  costClass,                            // what readHeights will cost
  readHeights(positions, out, opts) -> Promise<Float64Array>,   // bulk metres
  readProfile(positions, opts) -> Promise<{                     // + provenance
    heights, level, strategy, costClass, partial, interpolated
  }>,
  sampleCompat(provider, positions) -> Promise<Cartographic[]>, // legacy shape
}

opts carries spacing (metres — the caller's march stride, preferred), level, maxCost, and out. One call carries a whole field: the solver's coverage rasters reach 512×512 = 262,144 points, and a per-point call cannot survive a boundary crossing at that count, which is why the seam is batched and in-place rather than scalar.

sampleCompat is signature-compatible with the sampler the solver's injectable statics already hold, so the seam can be assigned on day one with no call-site change. readHeights is the real entry: contiguous metres, no per-sample JS object, no-data expressed as the sentinel rather than undefined.

The cutover is therefore two recorded steps, not a rewrite:

  1. Now — the solver keeps its injectable statics and receives a conforming seam through them. No private terrain path is introduced.
  2. On engine-port landing — the terrain source becomes an explicit parameter on the analysis entry points (the pluggable-propagation law applied to terrain) and call sites move from sampleCompat to readHeights. Tracked as orbpro-provider-access-port.

Guest usage

templates/provider-access-module/include/space_data_provider_abi.h declares the imports and the descriptor for clang --target=wasm32-wasip1-threads. The module is EH-free and compiles identically for every lane; there is exactly one dist/isomorphic/module.wasm.

c
#include "space_data_provider_abi.h"

sdm_provider_tile_desc_t desc;
const char *req =
    "{\"op\":\"profile\",\"providerId\":\"terrain.fixture\","
    "\"start\":[-1.9,0.65],\"end\":[-1.88,0.66],\"samples\":256,\"maxCost\":1}";

int32_t h = sdm_provider_acquire(req, (int32_t)strlen(req), &desc);
if (h < 0) {
  /* -3 here on a node with no terrain configured. Same branch, every lane. */
  return handle_no_terrain(h);
}
double heights[256];
int32_t n = sdm_provider_read(h, 0, 0, (int32_t)(uintptr_t)heights,
                              (int32_t)sizeof heights);
sdm_provider_release(h);

desc.hostCopies is 1 on WasmEdge and on a threaded browser guest, 2 on a sequential browser guest, and the 2048 bytes in heights are identical in all of them.

Playground

An in-browser build-and-run playground for the data-source harness mounts here. It is being built under the graph task sdk-playground-emception; this slot is its reserved mount point and is intentionally empty until that lands.

SDN Stack

Connected sites