space-data-module-sdk 0.8.12 → 0.8.14

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
@@ -779,6 +779,8 @@ inspect the parsed `REC`, `PNM`, and `ENC` records produced by the real
779
779
 
780
780
  - [`spacedatastandards.org`](https://spacedatastandards.org)
781
781
  - [`hd-wallet-wasm`](https://github.com/nicktj-dev/hd-wallet-wasm)
782
+ - [`flatsql`](https://github.com/DigitalArsenal/flatsql) — the FlatBuffer-native
783
+ query engine behind `createFlatSqlRuntimeStore()`
782
784
 
783
785
  ## Development
784
786
 
@@ -817,4 +819,19 @@ helpers for serialized command execution and virtual filesystem access.
817
819
 
818
820
  ## License
819
821
 
820
- [MIT](./LICENSE)
822
+ [Apache-2.0](./LICENSE)
823
+
824
+ ### Dependency licenses
825
+
826
+ The SDK's own license (above) does not change, but one runtime dependency is
827
+ no longer permissive: as of `flatsql` **2.0.0** the FlatSQL engine is licensed
828
+ under the [PolyForm Noncommercial License
829
+ 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0/) — source-available
830
+ and free for any noncommercial purpose, with commercial use requiring a separate
831
+ license from DigitalArsenal.io, Inc.
832
+ ([tj@digitalarsenal.io](mailto:tj@digitalarsenal.io)). Installing this package
833
+ installs `flatsql`, so a commercial deployment of anything built on the SDK's
834
+ FlatSQL-backed runtime-host storage needs that license. Every `flatsql` release
835
+ before 2.0.0 has been withdrawn from npm, so `^2.0.0` is the only resolvable
836
+ floor — see [`docs/flatsql-host-contract.md`](./docs/flatsql-host-contract.md)
837
+ for why the major bump changes no API.
@@ -34,6 +34,8 @@ async function main(argv) {
34
34
  return runCheck(rest);
35
35
  case "compile":
36
36
  return runCompile(rest);
37
+ case "init":
38
+ return runInit(rest);
37
39
  case "flow":
38
40
  return runFlow(rest);
39
41
  case "parity":
@@ -98,6 +100,18 @@ function parseArgs(argv) {
98
100
  case "--out":
99
101
  options.outputPath = path.resolve(requireValue(argv, ++index, value));
100
102
  break;
103
+ case "--family":
104
+ options.family = requireValue(argv, ++index, value);
105
+ break;
106
+ case "--name":
107
+ options.name = requireValue(argv, ++index, value);
108
+ break;
109
+ case "--plugin-id":
110
+ options.pluginId = requireValue(argv, ++index, value);
111
+ break;
112
+ case "--force":
113
+ options.force = true;
114
+ break;
101
115
  case "--recipient-public-key":
102
116
  options.recipientPublicKeyHex = requireValue(argv, ++index, value);
103
117
  break;
@@ -219,6 +233,8 @@ function printUsage() {
219
233
  space-data-module check --repo-root .
220
234
  space-data-module check --manifest ./manifest.json --wasm ./dist/module.wasm
221
235
  space-data-module compile --manifest ./manifest.json --source ./src/module.c --out ./dist/module.wasm
236
+ space-data-module init --family propagator --name my-propagator
237
+ space-data-module init --family propagator --name my-propagator --out ./modules/my-propagator [--plugin-id com.orbpro.my.propagator] [--force] [--json]
222
238
  space-data-module parity --wasm ./dist/isomorphic/module.wasm --fixture ./fixtures/parity/basic.json
223
239
  space-data-module parity --wasm ./dist/isomorphic/module.wasm --fixture ./f.json --lanes browser,wasmedge,docker-wasmedge [--json]
224
240
  space-data-module parity ... --self-test-divergence docker-wasmedge (fire drill: prove the diff fails loudly)
@@ -532,6 +548,42 @@ async function runCompile(argv) {
532
548
  return result.report.ok ? 0 : 1;
533
549
  }
534
550
 
551
+ // space-data-module init --family propagator --name <module-name>
552
+ // [--out <dir>] [--plugin-id <id>] [--force] [--json]
553
+ //
554
+ // Scaffolds a new SDN WASM module skeleton from templates/<family>-module/.
555
+ // An unrecognized --family FAILS LOUDLY (see src/scaffold/index.js) — there
556
+ // is no generic fallback template.
557
+ async function runInit(argv) {
558
+ const options = parseArgs(argv);
559
+ if (!options.family) {
560
+ throw new Error("init requires --family <family> (e.g. --family propagator).");
561
+ }
562
+ if (!options.name) {
563
+ throw new Error("init requires --name <module-name>.");
564
+ }
565
+ const { scaffoldModule } = await import("../src/scaffold/index.js");
566
+ const result = await scaffoldModule({
567
+ family: options.family,
568
+ name: options.name,
569
+ outDir: options.outputPath ?? undefined,
570
+ pluginId: options.pluginId,
571
+ force: options.force === true,
572
+ });
573
+ if (options.json) {
574
+ console.log(JSON.stringify(result, null, 2));
575
+ } else {
576
+ console.log(
577
+ `Scaffolded ${result.family} module "${result.name}" into ${result.outDir}`,
578
+ );
579
+ console.log(` pluginId=${result.pluginId}`);
580
+ for (const file of result.files) {
581
+ console.log(` created ${file}`);
582
+ }
583
+ }
584
+ return result.ok ? 0 : 1;
585
+ }
586
+
535
587
  async function runProtect(argv) {
536
588
  const options = parseArgs(argv);
537
589
  if (!options.manifestPath || !options.wasmPath) {
@@ -1,4 +1,9 @@
1
- # The FlatSQL host contract (why the floor moved to ^1.4.4)
1
+ # The FlatSQL host contract (why the floor moved to ^1.4.4, then to ^2.0.0)
2
+
3
+ The current floor is **`^2.0.0`**. The 1.x history below is what made the
4
+ contract, and it still describes the contract 2.0.0 ships — see
5
+ [The 2.0.0 floor is a licence change, not a port](#the-200-floor-is-a-licence-change-not-a-port)
6
+ at the end for why the major bump costs nothing here.
2
7
 
3
8
  ## The defect the bump repairs
4
9
 
@@ -93,3 +98,31 @@ consumer would actually load. At 1.4.4 it reports `in-surface (WASI + declared
93
98
  capabilities: 7)`, `satisfied` in the real-browser lane and
94
99
  `runner-cannot-supply-declared-capability` under the bare WasmEdge CLI, naming
95
100
  `env.flatsql_io_open` as the single blocker.
101
+
102
+ ## The 2.0.0 floor is a licence change, not a port
103
+
104
+ `flatsql` 2.0.0 is 1.4.5's code republished under the [PolyForm Noncommercial
105
+ License 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0/).
106
+ The major is semver signalling the licence, not an API break. Measured on the
107
+ two published tarballs, every file is byte-identical except three:
108
+
109
+ ```
110
+ LICENSE Apache-2.0 -> PolyForm Noncommercial 1.0.0
111
+ package.json "version" and "license" only
112
+ README.md licence + contact section
113
+ ```
114
+
115
+ The host contract above therefore carries over unchanged. On
116
+ `wasm/flatsql-wasi.wasm`, 2.0.0 imports the same **13** — the seven
117
+ `flatsql_io_*` on `env` plus six WASI preview1 — and exports the same **102**
118
+ as 1.4.4, so the parity gate classifies the 2.0.0 engine exactly as it
119
+ classified 1.4.4.
120
+
121
+ Two consequences for anyone reading this floor:
122
+
123
+ - **Every pre-2.0.0 `flatsql` is withdrawn from npm.** A pin below `^2.0.0`
124
+ resolves to nothing. There is no supported lower floor.
125
+ - **Commercial use of the engine needs a licence** from DigitalArsenal.io, Inc.
126
+ ([tj@digitalarsenal.io](mailto:tj@digitalarsenal.io)). The SDK stays MIT and
127
+ consumes flatsql through its JS API only, but installing the SDK installs the
128
+ engine, so the obligation reaches the SDK's consumers.
@@ -128,7 +128,9 @@ instantiates and the node's capability policy identifies by content hash.
128
128
 
129
129
  The domain prefix exists because the node's publisher key is the NODE key, and
130
130
  that one bonded key signs several unrelated kinds of statement (dataset
131
- publications, module artifacts, andreserved update manifests). If every
131
+ publications, module artifacts, update SIGNALS the advisory pub/sub nudge a
132
+ publisher pushes so every install upgrades itself in place, which has a live
133
+ producer — and, still reserved, update manifests). If every
132
134
  statement were a bare SHA-256 digest, a caller who could reach the node's
133
135
  signing endpoint could submit the bytes of one kind of document and staple the
134
136
  returned signature onto another. An ASCII domain label, a `NUL` that cannot
@@ -137,8 +139,10 @@ in a disjoint message space with no length ambiguity anywhere in the preimage.
137
139
 
138
140
  The domain registry is **closed**: a verifier refuses any label that is not
139
141
  registered, and a module verifier additionally refuses any registered label
140
- other than `SDN-MODULE-PUBLICATION-V1` — so a signature minted for a signed
141
- update can never be replayed into a module trailer. Adding a domain is a
142
+ other than `SDN-MODULE-PUBLICATION-V1` — so a signature minted for an update
143
+ manifest or an update signal can never be replayed into a module trailer, which
144
+ the shared vectors pin as `foreign-registered-domain` and
145
+ `update-signal-domain-is-refused-for-a-module`. Adding a domain is a
142
146
  reviewed change in every implementation at once
143
147
  (`src/bundle/sigdomain.js` here; `internal/sigdomain` and its kubo twin in the
144
148
  node), never a request parameter.
@@ -0,0 +1,477 @@
1
+ # Propagator ABI
2
+
3
+ **Status:** v1 — owner ruling 2026-08-10 ("No JS propagator!!!! WASM ONLY"),
4
+ graph task `harness-w1-propagator-abi-and-reference`, ratified by
5
+ `graph/findings/official-harness-shapes.md` §7 SHIP-1.
6
+
7
+ This is **the** official third-party propagator harness. A propagator that
8
+ touches OrbPro primitives ships as a signed WASM module implementing the
9
+ exports below. The JavaScript `Propagators` registry is INTERNAL engine
10
+ plumbing — how the engine dispatches to compiled modules — and is never
11
+ offered as a public extension point.
12
+
13
+ Nothing in this ABI names Cesium, OrbPro, or a vendor. The same exports serve
14
+ the browser frame-worker pool, a WasmEdge flow runtime, and a deterministic
15
+ test fixture.
16
+
17
+ ## Table of contents
18
+
19
+ - [Doctrine](#doctrine)
20
+ - [Capability](#capability)
21
+ - [The export set](#the-export-set)
22
+ - [Wire layout](#wire-layout)
23
+ - [Units](#units)
24
+ - [Frames](#frames)
25
+ - [Identity](#identity)
26
+ - [Threading](#threading)
27
+ - [Error codes](#error-codes)
28
+ - [Lifetime](#lifetime)
29
+ - [Versioning](#versioning)
30
+ - [Parity envelope](#parity-envelope)
31
+ - [Consumer seam](#consumer-seam)
32
+ - [Guest usage](#guest-usage)
33
+
34
+ ## Doctrine
35
+
36
+ Five rules govern every line below.
37
+
38
+ 1. **One source, generated everywhere.** The structs, enums, size locks and
39
+ offset locks in this ABI are GENERATED from
40
+ `schemas/orbpro/Propagator.fbs`. No hand-written mirror is legitimate
41
+ anywhere in the stack. Before W1.1 there were five, they disagreed, and the
42
+ disagreement was a units contradiction inside a single file — kilometres in
43
+ the field comments, METERS in the layout block three lines below.
44
+ 2. **The wire is bytes at offsets, and the offsets are locked.** Every ABI
45
+ struct carries `_Static_assert` on its size AND on every field offset. This
46
+ is not decoration: JavaScript reads these structs out of linear memory at
47
+ byte offsets, and no runtime check can catch a shifted field — `6778` and
48
+ `6778000` are both finite doubles.
49
+ 3. **Refuse rather than approximate.** A propagator that cannot answer returns
50
+ a documented negative code. It never returns a plausible number it does not
51
+ stand behind, and it never traps on input it was given the chance to
52
+ validate. A `converged` flag is never trusted by a consumer; it is
53
+ adjudicated by verify-by-propagation.
54
+ 4. **Declare what you are.** Frame, validity flags and reserved bytes are
55
+ WRITTEN, every call. A state vector that leaves `reference_frame` at its
56
+ default is unreadable by a host that honours the field, and one that leaves
57
+ the padding bytes alone is handing back the previous call's data.
58
+ 5. **The engine owns the schedule; the module owns one row.** Sharding a batch
59
+ across workers is the host's decision. A module writes only the rows it was
60
+ given and holds no cross-row state, which is what makes it safe under any
61
+ sharding the host chooses.
62
+
63
+ ## Capability
64
+
65
+ A propagator module declares family `propagator` in its
66
+ `plugin-manifest.json`. The family vocabulary is authoritative and
67
+ fail-closed: `normalizePluginFamily` throws `UnknownPluginFamilyError` naming
68
+ the value and the vocabulary (W0.3). There is no silent `ANALYSIS` fallback —
69
+ that fallback silently mislabelled 22 modules.
70
+
71
+ **The namespace rule.** A `$`-prefixed four-byte file identifier is ratified
72
+ SDS. A bare four-byte identifier is a vendor invention, and **a harness MUST
73
+ refuse it**. A port declaring `acceptsAnyFlatbuffer` is unconformable and is
74
+ not admissible on a harnessed family: a wildcard cannot be
75
+ conformance-tested, and six first-party manifests currently declare one on
76
+ both faces.
77
+
78
+ The reference module's ingest port is the worked example: it declares exactly
79
+ `$OMM`, in both its canonical FlatBuffer form and its aligned-binary peer, and
80
+ nothing else.
81
+
82
+ ## The export set
83
+
84
+ Exports are announced with `__attribute__((export_name(...)))`. The SDK
85
+ compiler exports the invoke-surface symbols and every declared `methodId`;
86
+ the propagator ABI entry points are not `methodId`s, so they announce
87
+ themselves.
88
+
89
+ ### Required
90
+
91
+ | Export | Signature | Returns |
92
+ |---|---|---|
93
+ | `plugin_init` | `int32_t(const uint8_t* data, size_t len)` | entities initialized (>0), or a negative [error code](#error-codes) |
94
+ | `plugin_propagate` | `int32_t(double julian_date, uint32_t entity_index, OrbProStateVector* out)` | `0`, or a negative error code |
95
+ | `plugin_destroy` | `void(void)` | — |
96
+
97
+ At least one of `plugin_propagate` or `plugin_propagate_batch` must exist;
98
+ shipping both is expected, and they must agree exactly (see
99
+ [parity envelope](#parity-envelope)).
100
+
101
+ ### Typed ingest
102
+
103
+ | Export | Signature | Returns |
104
+ |---|---|---|
105
+ | `plugin_init_omm` | `int32_t(const OrbProOMMRecord* records, uint32_t count)` | entities now held, or negative |
106
+ | `plugin_ingest_omm_one` | `int32_t(const OrbProOMMRecord* record)` | **the handle it assigned**, or negative |
107
+ | `plugin_init_elements` | `int32_t(const OrbProOrbitalElements* elements, uint32_t count)` | entities initialized, or negative |
108
+
109
+ `plugin_init_omm` REPLACES the element set. `plugin_ingest_omm_one` APPENDS
110
+ and returns its handle — see [identity](#identity).
111
+
112
+ ### Batch and introspection
113
+
114
+ | Export | Signature | Returns |
115
+ |---|---|---|
116
+ | `plugin_propagate_batch` | `int32_t(double julian_date, OrbProStateVector* out, uint32_t count)` | `0`, or negative |
117
+ | `plugin_entity_count` | `int32_t(void)` | entities currently held |
118
+
119
+ `plugin_init` must accept a packed array of `OrbProOMMRecord` and MUST refuse
120
+ a length that is not a whole multiple of `sizeof(OrbProOMMRecord)`. A partial
121
+ trailing record means the caller and the module disagree about the struct
122
+ size, and the size lock cannot see across the boundary.
123
+
124
+ ## Wire layout
125
+
126
+ Generated header: `include/orbpro/orbpro_propagator_abi.h`.
127
+ Generated TS byte offsets: `space-data-module-sdk/generated/propagator-abi`.
128
+ **Read offsets from the generated bindings. Never write a literal `8`.**
129
+
130
+ ### `OrbProStateVector` — 64 bytes, 8-byte aligned
131
+
132
+ | Offset | Size | Type | Field |
133
+ |---|---|---|---|
134
+ | 0 | 8 | float64 | `epoch` — Julian date |
135
+ | 8 | 24 | float64×3 | `position` — **METERS** |
136
+ | 32 | 24 | float64×3 | `velocity` — **METERS/SECOND** |
137
+ | 56 | 1 | uint8 | `reference_frame` |
138
+ | 57 | 3 | uint8×3 | padding — **MUST be written as zero** |
139
+ | 60 | 4 | uint32 | `flags` |
140
+
141
+ The one-byte frame plus three reserved bytes is a DECLARED layout, not an
142
+ accident. The C header formerly declared a `uint32_t` at offset 56, which is
143
+ wire-identical only by little-endian accident (W0.2).
144
+
145
+ ### `OrbProOMMRecord` — 88 bytes, 8-byte aligned
146
+
147
+ | Offset | Size | Field | Units |
148
+ |---|---|---|---|
149
+ | 0 | 8 | `epoch_jd` | Julian date |
150
+ | 8 | 8 | `mean_motion` | REV/DAY |
151
+ | 16 | 8 | `eccentricity` | — |
152
+ | 24 | 8 | `inclination` | DEGREES |
153
+ | 32 | 8 | `ra_of_asc_node` | DEGREES |
154
+ | 40 | 8 | `arg_of_pericenter` | DEGREES |
155
+ | 48 | 8 | `mean_anomaly` | DEGREES |
156
+ | 56 | 8 | `bstar` | 1/earth-radii |
157
+ | 64 | 8 | `mean_motion_dot` | REV/DAY² |
158
+ | 72 | 8 | `mean_motion_ddot` | REV/DAY³ |
159
+ | 80 | 4 | `norad_cat_id` | uint32 |
160
+ | 84 | 4 | padding | MUST be zero |
161
+
162
+ **This struct is also an on-disk format.** The first-party SGP4 module
163
+ persists it verbatim as a SQLite BLOB
164
+ (`sqlite3_bind_blob(..., &omm, sizeof(OrbProOMMRecord), ...)`). Until W1.1 it
165
+ carried no size or offset lock anywhere in the stack, so the layout every
166
+ stored blob depends on was held only by the field order of one hand-written
167
+ C struct in one module.
168
+
169
+ The layout above is that layout, exactly as it has already been written to
170
+ disk, trailing padding included. Locking it revealed **no ambiguity**: the
171
+ IDL-derived layout reproduces the hand-written struct byte for byte, so the
172
+ lock pins the existing wire rather than changing it. Migration is out of
173
+ scope and would invalidate every stored blob.
174
+
175
+ ### `OrbProOrbitalElements` — 64 bytes, 8-byte aligned
176
+
177
+ Eight float64 in declaration order: `semi_major_axis` (**KILOMETRES**),
178
+ `eccentricity`, `inclination`, `raan`, `arg_periapsis`, `true_anomaly`
179
+ (all RADIANS), `epoch` (Julian date), `reserved` (MUST be 0).
180
+
181
+ ## Units
182
+
183
+ > `OrbProStateVector.position` IS IN METERS.
184
+ > `OrbProStateVector.velocity` IS IN METERS PER SECOND.
185
+
186
+ There is no km variant, no per-plugin unit flag, and no negotiation. A plugin
187
+ that writes kilometres here is off by 1000× and its satellites render inside
188
+ the Earth.
189
+
190
+ The ONE place kilometres survive is `OrbProOrbitalElements.semi_major_axis`,
191
+ an INPUT struct that is not the state vector. It is kilometres there and
192
+ stays kilometres. Do not "unify" them.
193
+
194
+ Ruling: finding §4.1 / §8.1; landed as W0.1.
195
+
196
+ ## Frames
197
+
198
+ `reference_frame` is `OrbProReferenceFrame`:
199
+ `TEME=0 J2000=1 ICRF=2 ECEF=3 MCI=4 MCMF=5`.
200
+
201
+ **Plugins output ECEF.** The frame transform happens INSIDE the module. A
202
+ plugin that emits an inertial frame and expects the host to rotate it is
203
+ relying on a host path that is still unimplemented — `PropagatorPlugin.toICRF`
204
+ carries a live TEME≈ICRF approximation, and an ECEF input there is wrong by up
205
+ to a full Earth rotation (`orbpro-toicrf-frame-transform-unimplemented`).
206
+
207
+ **Never let a raw integer frame value cross a boundary unqualified.** Four
208
+ incompatible `ReferenceFrame` vocabularies are live on this seam:
209
+
210
+ | Vocabulary | Values |
211
+ |---|---|
212
+ | `orbpro.propagator` (**this ABI**) | TEME=0 J2000=1 ICRF=2 ECEF=3 MCI=4 MCMF=5 |
213
+ | `orbpro.plugins` (`PropagatorState.fbs`) | ECI=0 ECEF=1 TEME=2 ICRF=3 |
214
+ | `Cesium.ReferenceFrame` | FIXED=0 INERTIAL=1 |
215
+ | `ConjunctionCommon.fbs` | ECI=1 |
216
+
217
+ `ECI==0`, `TEME==0` and `FIXED==0` all collide, and ECEF is 1 in the second
218
+ but 3 here. The second vocabulary's values are frozen by compiled WASM
219
+ artifacts already in the field, so collapsing them is a wire break, tracked as
220
+ `sdk-reference-frame-enum-unification`. Until it lands, **translate by named
221
+ token at every seam**.
222
+
223
+ Use the generated setter `orbpro_state_set_reference_frame()`, never a bare
224
+ assignment: it clears the three padding bytes a consumer reading offset 56 as
225
+ a 32-bit word would otherwise see as garbage.
226
+
227
+ ## Identity
228
+
229
+ **`NORAD_CAT_ID` is the identity authority.** It is carried through, never
230
+ invented, never synthesized to make a lookup succeed.
231
+
232
+ **The entity index is a local handle** into one module instance's own array.
233
+ It is meaningless outside that instance and must never be persisted as an
234
+ identity.
235
+
236
+ ### Creating engine state RETURNS its handle
237
+
238
+ This is the single highest-value primitive the harness adds.
239
+
240
+ ```c
241
+ int32_t handle = plugin_ingest_omm_one(&record); /* -> the handle assigned */
242
+ ```
243
+
244
+ A caller must never derive "the entity I just created" as `count − 1`. Three
245
+ families in this stack independently reinvented that derivation; all three are
246
+ race-unsafe, undeclared and untested, and it is the root of defect B3 (the
247
+ maneuver marker renders from a buffer the seam never writes).
248
+
249
+ The engine-side implementation of this primitive for the FIRST-PARTY
250
+ propagators is **W1.5** (`graph/tasks/official-harness-shapes-program.md`).
251
+ This document states the contract now; third-party modules are expected to
252
+ honour it from day one, and the reference module does.
253
+
254
+ ## Threading
255
+
256
+ Modules compile to `wasm32-wasip1-threads` (clang), per the isomorphic-pthreads
257
+ law. **Never `emcc -pthread`** — that emits the browser-only Web Worker +
258
+ postMessage thread model and has no wasi thread-spawn contract, so it cannot
259
+ thread under WasmEdge.
260
+
261
+ Two thread models are legitimate, and both use that same toolchain:
262
+
263
+ - **`emscripten-pthreads`** — despite the name, the real wasi-threads contract:
264
+ the guest imports `wasi.thread-spawn` and exports `wasi_thread_start` over an
265
+ imported shared memory. The post-link artifact guard fails the build if the
266
+ emitted wasm does not actually carry shared memory and atomics.
267
+ - **`wasi-sequential`** — the module provably never spawns a thread. Requires
268
+ `manifest.sequentialJustification` with a `kind` and a substantive `detail`;
269
+ a mirror guard fails the build if the artifact is not what was claimed.
270
+
271
+ **A propagator is normally `wasi-sequential`, and that is the strong default.**
272
+ Propagation is embarrassingly parallel ACROSS entities and strictly sequential
273
+ WITHIN one, and the sharding belongs to the host. A module that spawns its own
274
+ pool contends with the pool already scheduling it.
275
+
276
+ > **Known defect:** `resolveThreadModel` reads the compile OPTION, not
277
+ > `manifest.threadModel`, and otherwise infers the model from
278
+ > `runtimeTargets` — where `"wasmedge"` infers pthreads. Pass
279
+ > `threadModel: manifest.threadModel` explicitly until
280
+ > `sdk-manifest-threadmodel-silently-ignored` lands, and assert the compiler
281
+ > agreed.
282
+
283
+ ### Shard write discipline
284
+
285
+ When the host runs `plugin_propagate_batch` across a worker pool it hands each
286
+ worker the SAME output base pointer and a DISJOINT index range.
287
+
288
+ - Write **only** rows in your own range. Never write outside your stride.
289
+ - Never READ a neighbour's row. Your output must not depend on rows you were
290
+ not given.
291
+ - Hold no cross-row state between rows of one batch.
292
+ - On failure, zero the offending row before returning, so a host that ignores
293
+ the return value still reads a state marked not-valid rather than stale
294
+ bytes. A partially written batch with no signal is the silent-wrong-numbers
295
+ failure this ABI exists to prevent.
296
+
297
+ A module that satisfies these is safe under any sharding the host chooses,
298
+ which is the property the ABI actually requires — not a particular thread
299
+ count.
300
+
301
+ ## Error codes
302
+
303
+ Every failure returns its OWN documented negative code. A propagator that
304
+ returns `-1` for everything is unconformable: the host cannot tell a bad
305
+ entity index from an uninitialized module, so it cannot place the failure on
306
+ the degradation ladder (transient → skip; fatal → respawn; exhausted → latch).
307
+
308
+ | Code | Name | Meaning |
309
+ |---|---|---|
310
+ | `0` | OK | success |
311
+ | `-1` | NOT_INITIALIZED | no elements ingested yet |
312
+ | `-2` | BAD_ENTITY_INDEX | index ≥ entity count |
313
+ | `-3` | NULL_OUTPUT | caller passed a null output pointer |
314
+ | `-4` | BAD_INPUT | malformed or short input buffer |
315
+ | `-5` | NOT_CONVERGED | the solve failed to converge |
316
+ | `-6` | UNPHYSICAL | the elements describe no closed orbit |
317
+
318
+ Rules that are not negotiable:
319
+
320
+ - **Validated input can never trap.** Malformed input is a code, not a crash.
321
+ - **NaN is its own failure class.** It is never "a number that happened".
322
+ - **A physically impossible result is a refusal**, not an output.
323
+ - **Error classes are identical across runtimes.** A code that differs between
324
+ browser and WasmEdge is a P1 SDK defect.
325
+
326
+ ## Lifetime
327
+
328
+ `plugin_destroy` is **required**, and it must actually release.
329
+
330
+ The test is mechanical: N × ingest / propagate / destroy must reach a steady
331
+ memory baseline. WebAssembly linear memory never shrinks, so "memory went back
332
+ down" is not available and a test asserting it would assert something
333
+ impossible. What a non-leaking module gives you is that growth STOPS: after a
334
+ warm-up that pays for every allocation the cycle will ever need, further
335
+ identical cycles add ZERO pages.
336
+
337
+ `destroy` must also be idempotent, must leave `plugin_entity_count()` at zero,
338
+ and must leave the module usable — a destroyed module refuses to propagate
339
+ (`NOT_INITIALIZED`) rather than reading freed state, and comes back cleanly on
340
+ the next ingest.
341
+
342
+ > **Known defect, stated so it is not mistaken for the standard.**
343
+ > `destroySource()` is literally `{}` in BOTH shipped first-party propagators,
344
+ > and both therefore FAIL this leak test today. sgp4's `createSourceFromState`
345
+ > additionally re-ingests the whole catalogue and tears down the 120 fps worker
346
+ > pool for a single burn, polluting the identity table with a synthetic NORAD.
347
+ > Fixing them is **W1.5** in `graph/tasks/official-harness-shapes-program.md`;
348
+ > the finding's analysis is §4.5. The reference module passes the leak test
349
+ > today, deliberately — it sets the bar W1.5 brings the first-party
350
+ > propagators up to.
351
+
352
+ ## Versioning
353
+
354
+ `abi_version` gates at register/load. `ORBPRO_ABI_VERSION` is declared in
355
+ `orbpro_plugin.h`; a module declares `abiVersion` in its manifest, and a
356
+ mismatch is refused with `ORBPRO_ERROR_ABI_MISMATCH`, never coerced.
357
+
358
+ Shape versions are `SHAPE_MAJOR.SHAPE_MINOR`, independent of the SDS wave
359
+ counter.
360
+
361
+ - **Additive-only within a MAJOR.** Enforced by the drift gate, not by prose:
362
+ `npm run check:propagator-abi` regenerates every artifact from the IDL and
363
+ byte-diffs it against what is committed. Any difference fails.
364
+ - **Consumers declare a FLOOR plus a MAJOR, never exact equality.**
365
+ Exact-equality resolution caused three build outages and a P1 in one week.
366
+ Floors only advance.
367
+ - **Stability promise:** additive-only for two minors; a breaking change
368
+ requires a one-minor deprecation notice.
369
+ - **Deprecation, never deletion.**
370
+ - **Refusals are legible.** A mismatch names the shape, what was required and
371
+ what was offered — never "not found".
372
+
373
+ ## Parity envelope
374
+
375
+ **Inside the envelope** — byte-identical across browser, native WasmEdge and
376
+ Docker WasmEdge, at thread counts 1/2/4/8:
377
+
378
+ - every byte of every `OrbProStateVector` a module writes for given inputs
379
+ - `plugin_propagate` and `plugin_propagate_batch` for the same entity and epoch
380
+ - every error code, for every malformed and unsatisfiable input
381
+ - the handle `plugin_ingest_omm_one` assigns, for a given ingest order
382
+ - the steady-state verdict of the lifecycle leak test
383
+
384
+ Determinism is compared as **bytes, not as numbers**, and must survive a
385
+ destroy / re-ingest cycle: output that changes after a lifecycle round trip is
386
+ state leaking across it.
387
+
388
+ **Outside the envelope**, stated so it is never mistaken for a defect: results
389
+ from a propagator whose physics depends on live external data (space weather,
390
+ EOP) at different acquisition instants. Their ABI BEHAVIOUR — codes, struct
391
+ shapes, frame declaration, padding, copy accounting — is fully inside.
392
+
393
+ **Divergence in anything listed as inside the envelope is a P1 SDK defect.**
394
+ Not a platform quirk. File, block, fix.
395
+
396
+ Run it:
397
+
398
+ ```
399
+ space-data-module parity-gate --artifact <id>=./dist/isomorphic/module.wasm:module
400
+ ```
401
+
402
+ ## Consumer seam
403
+
404
+ Per the pluggable-propagation law (owner, 2026-07-29), **every surface that
405
+ consumes a propagator takes it as a parameter or port** — never hardwired to
406
+ SGP4 or any single provider. A Sandcastle demo resolves it once from
407
+ `?propagator=NAME` and feeds that single value to every consumer downstream.
408
+
409
+ Engine-side, `PropagatedPositionProperty` accepts a propagator instance OR a
410
+ registered name/id, and orbits are drawn by the regular path visualizer.
411
+ Third-party modules reach it through the storefront ADD path —
412
+ `Cesium.registerPlugin(name, source)` then `initPlugins({ plugins: [name] })`.
413
+ Built-in bundle names are refused there (`E92`); a third-party module uses its
414
+ own name.
415
+
416
+ ## Guest usage
417
+
418
+ The complete, buildable reference implementation lives at
419
+ `space-data-network-modules/propagator/keplerian-reference/`. Every export in
420
+ it is annotated with the section of this document it implements, and
421
+ `space-data-module init --family propagator` scaffolds its skeleton.
422
+
423
+ ```c
424
+ #include "space_data_module_invoke.h"
425
+ #include "orbpro/orbpro_propagator_abi.h" /* the ONE generated ABI */
426
+
427
+ #define ORBPRO_ABI_EXPORT(name) __attribute__((export_name(name))) extern "C"
428
+
429
+ ORBPRO_ABI_EXPORT("plugin_propagate")
430
+ int32_t plugin_propagate(double julian_date, uint32_t entity_index,
431
+ OrbProStateVector* out) {
432
+ if (out == NULL) return -3; /* NULL_OUTPUT */
433
+ if (entity_count == 0) return -1; /* NOT_INITIALIZED */
434
+ if (entity_index >= entity_count) return -2; /* BAD_ENTITY_INDEX */
435
+
436
+ /* Start from the initializer: it zeroes the WHOLE struct, including the
437
+ * three reserved bytes the IDL requires to be zero. The host reuses one
438
+ * scratch buffer across every call, so a partial write hands back the
439
+ * previous call's bytes. */
440
+ orbpro_state_init(out);
441
+ out->epoch = julian_date;
442
+ out->position[0] = x_metres; /* METERS. Not kilometres. */
443
+ out->position[1] = y_metres;
444
+ out->position[2] = z_metres;
445
+ out->velocity[0] = vx_metres_per_second;
446
+ out->velocity[1] = vy_metres_per_second;
447
+ out->velocity[2] = vz_metres_per_second;
448
+
449
+ /* Use the generated setter — it clears the padding bytes. */
450
+ orbpro_state_set_reference_frame(out, ORBPRO_FRAME_ECEF);
451
+ out->flags |= (uint32_t)ORBPRO_STATE_VALID;
452
+ return 0;
453
+ }
454
+ ```
455
+
456
+ Reading a state vector from JavaScript, without a single literal offset:
457
+
458
+ ```js
459
+ import { ORBPRO_STATE_VECTOR } from "space-data-module-sdk/generated/propagator-abi";
460
+
461
+ const { offsets, size } = ORBPRO_STATE_VECTOR;
462
+ const view = new DataView(memory.buffer, pointer, size);
463
+ const epoch = view.getFloat64(offsets.epoch, true);
464
+ const x = view.getFloat64(offsets.position, true);
465
+ const frame = view.getUint8(offsets.reference_frame);
466
+ ```
467
+
468
+ ## Regenerating
469
+
470
+ ```
471
+ node scripts/generate-propagator-abi.mjs # regenerate from the IDL
472
+ node scripts/check-propagator-abi.mjs # the drift gate (runs in npm test)
473
+ ```
474
+
475
+ The gate ships its own negative control: a test corrupts a copy of the
476
+ generated tree and requires the gate to name the corruption. A gate never
477
+ observed to fail is indistinguishable from one that cannot fail.