STT Conformance
Scope: what it means for an implementation to be a conformant STT writer or reader, and the portable artifacts — a published JSON Schema, committed cross-implementation golden fixtures, and a reference validator — that let anyone check theirs. This page turns the requirements scattered through the packed-format, tile-payload, and time-model specs into one checklist plus the means to test against it.
1. What "conformant" means#
STT has three independently-versioned axes (§9 of the packed spec). An implementation declares conformance per axis:
| axis | current | governed by |
|---|---|---|
| Packed format | writes formatVersion: 3; reads 3 + 2 | packed-format §3, §6, §9.1 + manifest.schema.json |
| Directory codec | directoryVersion: 6 | packed-format §4, §4.1 |
| Tile payload | Arrow IPC + GeoArrow | data-format.md + time-model.md |
A conformant reader opens any dataset these specs permit (both directory
layouts, every vertex_time width, both TILE_META time forms, quantized
and raw per-vertex value columns, unknown additive columns and fields). A
conformant writer emits only datasets a conformant reader can open, with
the integrity and self-description guarantees below.
Conformance is declared against v3. Reference writers emit only v3. Reference readers additionally open v2 read-only so already-published archives are not stranded (packed-format §9.1), but that window is a compatibility affordance, not a conformance requirement: an implementation that reads only v3 is fully conformant, and v1 is refused by both.
2. The portable conformance kit#
Three artifacts travel with the spec so an external implementation can verify itself without reading Rust:
2.1 The manifest JSON Schema#
manifest.schema.json is the cross-language wire
contract for manifest.json. It is served at the absolute URL it declares as
its own $id — https://poopdeck.gl/spec/manifest.schema.json, as
application/schema+json with Access-Control-Allow-Origin: * — so a
validator that resolves the $id gets the schema instead of a web page
(likewise https://poopdeck.gl/spec/scene.schema.json). It is pinned in CI
against three things that must agree
(packages/core/test/manifest-schema.test.ts):
- the Rust writer type (
crate::pack::Manifest), - the TypeScript reader type (
@poopdeck.gl/corePackedManifest), - a committed golden manifest (below).
The schema encodes the format's evolution rules directly: format and
directory.directoryVersion are strict consts, formatVersion is the
closed enum [3]; directory.encoding is an enum that is not required
(absence selects the raw v3 codec bytes); pack/index keys are pattern-checked to
the blake3-128 hex shape; and every envelope level permits unknown fields
(additive evolution). The contract test also asserts five negative cases
drift loudly (wrong format, wrong version, missing packs, bad key pattern,
bad directory version) and that unknown fields validate at every level.
Most of metadata is deliberately opaque to the schema (it is the writer's
Metadata JSON folded in verbatim), with four exceptions pinned key by key:
style_hints, ordering_workload, z_range and content_fingerprint. The
last three are the content claims of §3.1 — fields whose whole purpose is to
assert something about the data rather than describe the container — and a
malformed claim is worse than an absent one, because the validator compares
decoded content against it. Both reference implementations pin their shapes:
crates/stt-core/tests/spec_conformance.rs (the Rust writer, which also
asserts the schema declares every key the type emits) and the same TS contract
test (the reader).
It additionally carries the machine-readable capability registry as the
top-level x-stt-capability-registry array — the single source of truth
both reference implementations pin their constant lists against
(crates/stt-core/tests/capability_registry.rs for Rust
KNOWN_CAPABILITIES; the same TS contract test for
KNOWN_MANIFEST_CAPABILITIES), so a registry addition on either side fails
CI until the schema and both readers agree. Current entries: attr-quant,
coord-quant, elevation-fold, time-delta, triangles-partial,
vertex-time-feature-anchor, vertex-value-quant.
2.2 Committed golden fixtures#
Tiny, deterministic, byte-stable datasets live under conformance/vectors/
(byte-pinned by .github/scripts/check-golden-pins.mjs; the TypeScript reader
vendors the tree verbatim and reads it from its own test fixtures directory) and
prove cross-implementation agreement — the genuine Rust writes → TS reads
cases. conformance/README.md says how a third party consumes them. All
generated archives are formatVersion: 3; there is no other version to emit.
| fixture | exercises |
|---|---|
packed-golden/ | manifest folding, v6 directory decode (12 entries), byte-identical blob dedup (3 tiles share one physical blob), multi-pack cutting. Self-contained frames (inline schemas, no schemas table) |
paged-golden/ + paged-golden-single/ | the paged ⇄ whole-load differential: the same 252-tile corpus emitted both ways, asserting paged queries return byte-identical results to a whole-load directory while fetching only the leaf pages a viewport/zoom/time window touches |
v2-golden/ | the real v3 stt-build writer on points: manifest-level schemas templates, coord-quant, per-tile qa affines, numeric + two adaptive categorical columns with nulls, paged directory |
v2-golden-tracks/ | the same for trajectories: delta vertex_time with the vt TILE_META affine, unquantized Float64 coordinates, single (whole-load) directory |
legacy-shape/ (4 datasets) | frozen real formatVersion 2 archives (no variants registry, directory codec v5) proving the read window of §9.1 actually opens and decodes them, and that formatVersion: 1 is still refused |
They are committed, not regenerated per build, so they double as a
regression corpus. legacy-shape/ in particular must never be
regenerated: it is frozen evidence of what v2 archives look like in the wild,
and regenerating it with the current writer would emit v3 and silently delete
the only coverage the compatibility path has.
Two generators, because the families are produced by different halves of the toolchain:
# packed-golden/, paged-golden/, paged-golden-single/ — hand-built payloads# through stt-core's PackWriter.cargo run -p stt-core --example make-golden-fixture# v2-golden*/ — historical fixture-directory names; the bytes are the current# v3 archive emitted by the real stt-build writer from synthetic DuckDB# sources (the script builds with `--features duckdb` itself).conformance/make-vectors.sh
The first generator (crates/stt-core/examples/make-golden-fixture.rs) uses
BlobOrdering::SpatialMajor (not Auto) so content hashes are stable across
regenerations, and builds each distinct payload once + clones it for the dedup
cases. Builds are byte-reproducible, so re-running it is a no-op diff unless
the writer's bytes intentionally changed.
The hand-built generator derives manifest.capabilities from the same
EncoderConfig used to encode its payloads. This keeps direct PackWriter
fixtures conformant even though stt-core cannot depend on
stt-build::EncoderSettings.
Byte-exact writer pins#
Decoding a fixture proves reader agreement. Pinning a fixture's bytes proves the writer did not drift.
| version | fixtures | asserted by |
|---|---|---|
| 3 (the only version) | crates/stt-core/tests/fixtures/v2-golden/ — current single/ and paged/ v3 datasets plus expected-hashes.json | crates/stt-core/tests/v2_golden.rs |
The byte pin catches a writer that starts emitting different bytes even when
the changed encoder still round-trips cleanly. The v2-golden* path names are
retained only to avoid noisy fixture-path churn; their manifests and objects
are v3. The current format is the only positive archive pin, which is why it must be
regenerated only alongside an intentional, reviewed encoder change:
cargo test -p stt-core --test v2_golden -- --ignored regenerate_v2_golden
v2_golden.rs also carries a value assertion, not just a hash one: it
decodes the committed fixture and asserts both time columns come back as
non-null absolute Int64 anchored at the corpus base. A regressed compact-
time re-inflation still decodes — it just reports times near zero — so only
a value assertion catches it.
2.3 The reference validator#
stt-validate <dataset> (a [[bin]] of the spatiotemporal-tiles crate —
there is no stt-validate package) is the executable specification of the
integrity contract. It accepts a packed dataset directory, its manifest.json,
or a single-file .sttb bundle of one (detected by extension or by the STTB
magic; the integrity tier then verifies each in-bundle object's blake3 against
its key exactly as in the exploded case). The internal single-file .stt
streaming container is refused. It runs, by cost tier:
Cheap (all tiles):
- content-addressing integrity — every
packs/*.sttpandindex/*.sttdblake3-hashes to its filename, and on-disk lengths match the manifest (verify_packed_objects); - manifest schema —
format/formatVersion/directoryVersionconstants; - directory decode — the v6 codec decodes and every entry's
pack_idis in range; - temporal bounds — every tile's
[time_start, time_end]lies withinmetadata.time_range; - metadata totals —
metadata.tile_count/feature_countagree with the directory's entry count and per-entryfeature_countsum; - CRC32C — every compressed blob's integrity tag round-trips.
Expensive (full or --sample N):
- Arrow decode — each tile decodes as an Arrow IPC stream;
- schema contract — required columns (
idU64,start_time/end_timeI64,geometrywith ageoarrow.*extension name) and the permitted optional/ property column types (check_tile_schema); - quantization gate — an
Int32-leafgeometrycolumn MUST carrystt:quant(otherwise a naïve reader misdecodes grid indices as degrees), and aFloat64-leaf one MUST NOT (the affine re-types honest coordinates as grid indices) — both errors; - vertex-time metadata sanity — a delta
vertex_timecolumn (List<UInt16>orList<UInt32>) MUST carry either the layer-anchored pairstt:vertex_time_origin_ms+stt:vertex_time_step_msor the feature-anchoredstt:vertex_time_feature_step_msalone — never both, the two delta tiers being mutually exclusive — each parseable (an integer Unix-ms origin, a positive integer ms step); an absoluteList<Int64>column MUST carry none of the three; - CRS84 tagging — on an unquantized
geometryfield, anARROW:extension:metadatathat is present but does not pinOGC:CRS84is an error; its complete absence is a warning, because the writer MUST of §3 is newer than much of the deployed fleet and a rebuild re-emits it; - interval sanity — every feature satisfies
end_time >= start_time; time_endtightness — each entry'stime_endequals the maximum featureend_timein the tile (the time-model §5 MUST that interval findability rests on);- summary cell-id validity — in a summary layer, every
idis a valid cell index of the declared scheme at the zoom's declared resolution (the check that would have caught the shipped-blank summary archives); - feature-count match — decoded row count equals the directory's
feature_count; - producer-drift detection — distinct per-tile schema signatures are tallied
and the first disagreeing pair reported. Integer-width drift on one
column (
UInt16⇄UInt32⇄Int32⇄Int64) is classified adaptive and passes, as is the presence-vs-absence of a documented optional reserved column (vertex_time,vertex_value,vertex_value_matrix,triangles,part_offsets) — each is emitted per tile, iff that tile's data needs it, which is the format's design and not a producer defect — and so is a categorical property column encoded as plainUtf8in one tile andDictionary(UInt16, Utf8)in another, the dictionary-vs-Utf8verdict the encoder resolves per dataset. A property column appearing in one tile and absent in another, or any other column changing type family, is structural and errors.
The validator sees the payload after re-inflation, so the compact time
forms and per-vertex value quantization are invisible to it by construction —
what it checks is the canonical decoded shape. It does check the two columns
those changes added to the wire directly: vertex_time at all three widths,
and part_offsets as a reserved List<UInt32> column.
Why the drift check is asymmetric. A per-tile encoding choice and a producer that changed mid-build look identical to a schema comparison, so the classifier can only distinguish them by knowing which variation the format sanctions. Three are sanctioned — integer width, optional-reserved presence, and the categorical
Utf8⇄Dictionary(UInt16, Utf8)swing — and all three are properties the reference readers already branch on. Everything else errors. This is why the attribute quantizer's sole refusal keys off value magnitude (a property of the column's domain) rather than span or distribution (properties of the tile's sample): a sample-dependent refusal would flip a column betweenFloat64and an integer leaf from tile to tile, which is a type-family change and correctly errors here. An earlier revision of that quantizer did exactly this and was reverted.
Semantic (only when the archive makes a content claim):
-
content fingerprint (check 12) — when
metadata.content_fingerprintis present, the decoded content's vertex bbox, vertical extent, per-column numeric ranges and categorical cardinalities are recomputed from the tiles and compared against the declared block: containment under--sample(every observed value must lie inside the declared extent, every observed cardinality at or below the declared one), containment plus equality within the declared tolerances under a full decode — a declared box wider than the decoded content means the manifest describes data the archive does not contain, which is a failure in the other direction. A declared tolerance is rejected outright unless its matching capability is declared (coord-quantforcoord_tolerance_deg,attr-quantforcolumn_tolerance), so a writer cannot buy slack it did not earn; thestt:quant/stt:qasteps the archive itself carries on the wire are admitted as slack with no declaration at all, because they are exactly what the reader dequantizes with. A fingerprint whoseversionis newer than the validator understands warns and skips rather than mis-checking. Layers a summary tier declares are excluded (their rows are derived aggregates addressed by cell index, so a cell centroid may legitimately sit outside the source bbox); every other layer is checked.This is the check that closes the gap structural validation cannot: an archive whose coordinates have been silently scrambled hashes, decodes, and schema-checks perfectly. A stride-2 read of a 3D
xyzleaf once flattened and scrambled 106 archives and every one passed the checks above it.Check 12 also enforces a feature-loss floor against
metadata.distinct_feature_count. On a full decode the rows at the fullest single zoom MUST be at least the declared count — per-zoom and not per-archive, because pyramid replication would let a whole-archive floor absorb >85% loss before firing — and a shortfall there is an error. Under--sample, and for any overshoot, it warns.--allow-distinct-shortfalldemotes both feature-loss errors to warnings and is the documented escape hatch for colliding source ids and features the tiler could not place; it suppresses nothing else, and the downgraded finding is still reported. The stricter distinct-ID comparison is armed only from the writer's recordedmetadata.properties.feature_id_construction(source/anchor-hash— a dataset-wide key), or from an explicitfeature_id_scope: global/global-feature-idsattestation, and is disarmed byfeature_id_scope: local; it is never inferred from the numbers happening to agree. On a non-arming construction (row-index,segment-hash) the two counts are different quantities and a deviation is a note, not a defect.When the fingerprint is absent — which is every archive published before the field existed — the run warns and continues, mirroring the CRS84 precedent. The JSON report says which comparisons ran:
fingerprint_checked(falsemeans the archive's CONTENT is unverified and only its structure was checked),bounds_checkedandbounds_enforcedfor check 13, anddistinct_id_basis/feature_id_constructionfor the arming decision above. Distinct-feature counts are compared through a fixed-size HyperLogLog sketch, the one approximation in an otherwise exact validator, so any finding derived from it prints its error bound.--emit-fingerprint <PATH>and--expect-fingerprint <PATH>run the same comparison against an external file rather than the manifest's own block. That is how a lossless transform is accepted without being allowed to vouch for itself — see the transform rule in §3.1.--emit-fingerprintrefuses to run under--sample/--skip-decode: a capture from a subset understates the content, and an understated expectation is a check that cannot fail. -
declared bounds containment (check 13) —
metadata.boundsMUST contain every vertex the archive decodes to, andmetadata.z_range(when declared) the decoded vertical extent. The validator recomputes both from the same decode pass check 12 rides and compares them.The direction is asymmetric and that asymmetry is the rule. A declared box that is too small is silent data loss at query time: tile selection, frustum pre-culling and the opening camera all pre-intersect a query box against
metadata.bounds, so an under-stated box makes them discard tiles that really do carry visible data, with no error anywhere in the stack. A declared box that is too large keeps every reader sound — it merely costs a fetch and opens the camera wide — so it is at most a warning, and only on a full decode (a sampled decode is expected to be narrower).Severity mirrors the fingerprint's: an error when the archive attests vertex-derived bounds — it carries a
content_fingerprint, or abounds_mode = vertexentry inmetadata.properties— and a warning that names the rebuild as the fix otherwise, because every archive published before the vertex-bounds writer carries the centroid bbox, which under-states the extent of every non-point geometry by construction. Four narrowings keep the check from firing on honest archives: thestt:quantstep the wire carries is admitted as slack; an escape explained entirely by the writer's null-island(0,0)sentinel policy (whichmetadata.boundsexcludes and the fingerprint's bbox includes) warns rather than fails; a wrapped antimeridian longitude interval (min_lon > max_lon) is not decidable against an unwrapped decoded bbox, so that axis is skipped with a warning rather than guessed at (the reference writer never emits one — a straddling dataset gets the loose unwrapped interval instead, pinned byantimeridian_crossing_yields_a_loose_unwrapped_bbox_that_still_contains_everything,crates/stt-build/tests/vertex_bounds_multi_tile.rs); and an escape explained entirely by the builder's antimeridian split — which synthesises vertices at exactly ±180 that the source-vertex fold behindmetadata.boundsnever saw — warns, under the three conditions the antimeridian note below spells out.
The invariance rule applies here too, and harder. A fingerprint is a statistic of the dataset domain — computed once, dataset-globally, over the source features before tiling — never of whichever tiles a run happened to sample.
--sampleweakens the comparison (containment instead of equality); it must never weaken the quantity, and no per-tile shortcut may recompute a "local" fingerprint to compare against. That is the same rule the drift classifier above obeys, for the same reason: a sample-dependent claim is not a claim about the archive. Only replication-invariant statistics are admissible in the block at all — a feature lands in N tiles (zoom pyramid, clipping, temporal LOD), so min/max and distinct counts survive tiling unchanged while sums and means do not.
What check 13 still does NOT cover — both gaps are the antimeridian.
- A seam escape warns rather than fails, by design.
stt-buildsplits a ±180°-crossing ring and synthesises vertices at exactly ±180 that the source never carried, whilemetadata.boundsis an unwrapped min/max over the source vertices — so a polygon reaching 178°E and 178°W declares[-178, 178]and decodes to[-180, 180]. The relaxation is scoped by three conditions — no latitude escape, every escaping longitude edge landing on ±180 within the wire's quantization step, and a declared interval already wider than 180° — so a compact archive whose tiles decoded to the world edges (the scrambled-coordinate class) still errors. Pinned incrates/spatiotemporal-tiles/src/bin/stt-validate/fingerprint.rs. The durable fix is at the writer: declare the full[-180, 180]longitude interval when the source straddles the seam.- A seam-crossing LINE is invisible to check 13. Lines are not split at the seam —
clip_trajectoryends the run at the|Δlon| > 180°edge and never emits that edge — so the decode stays strictly inside the declared source bbox and containment passes while geometry is missing, uncounted. Pinned, as a known gap rather than a fixed one, incrates/stt-build/tests/antimeridian_polygon.rs.For both,
stt-optimize export's bbox remains the manual cross-check.
For a paged directory it additionally runs verify_paged_structure: every
leaf descriptor's bounds (geo bbox, zoom range, [t_min, t_max]) cover the
leaf's entries (so a prune never drops a matching tile), and cross-page key order
is monotonic.
stt-validate data/earthquakes # full checkstt-validate data/earthquakes --sample 200 --json # sampled, machine-readablestt-validate data/earthquakes --skip-decode # integrity/hashes only# accepting a lossless transform against the archive as it was BEFORE itstt-validate before/ --emit-fingerprint truth.jsonstt-validate after/ --expect-fingerprint truth.json
A dataset that passes stt-validate with no errors satisfies the integrity,
addressing, schema, temporal (interval sanity, time_end tightness), payload
self-description (stt:quant / vertex-time metadata), summary cell-id, and
(for paged) covering invariants of the spec — and, when it declares a
content_fingerprint, the semantic invariants too: its decoded coordinates,
vertical extent, per-column ranges and cardinalities are the ones the manifest
claims. Without a fingerprint the same clean report certifies structure only;
the run says so, in a warning and in fingerprint_checked: false, and a reader
of the report must not upgrade that to a content guarantee. The one invariant a
clean report never certifies is metadata.bounds containment (see the note
above).
The full numbered list — checks 1–13, cited as "check N" in reports and reviews
— lives in the stt-validate module doc and is restated in
docs/api/cli-reference.md; the three numberings are
pinned against each other by
validator_check_numbering_agrees_across_the_documents
(crates/stt-core/tests/spec_conformance.rs).
2.4 Internal pins (for implementers extending the spec)#
These Rust/TS test suites lock the spec to the code so the spec itself can't drift; an external implementer reads them as worked examples:
crates/stt-core/tests/v2_golden.rs— the byte-exact writer pin againstcrates/stt-core/tests/fixtures/v2-golden/(both directory shapes, every object plus the manifest), plus a decode-and-assert-values pass over the committed bytes.crates/stt-core/tests/capability_registry.rs— pins RustKNOWN_CAPABILITIESagainst the schema'sx-stt-capability-registry(§2.1);packages/core/test/manifest-schema.test.tspins the TS constant against the same array.crates/spatiotemporal-tiles/tests/validate_cli.rs— end-to-end tests of the reference validator: each builds a tiny packed dataset withPackWriter, runs the compiledstt-validatebinary over it with--json, and asserts on the parsed report. The file's convention is one deliberately broken archive per named check, so a check that cannot fail is visible as a missing negative.every_encoder_payload_shape_passes_the_validatorbuilds one archive carrying a two-part holed polygon (part_offsets), a 3-day track (UInt32vertex_time) and quantized vertex values, then re-decodes it to prove each shape really reached the wire — a clean report proves nothing if the corpus stopped producing the shape. Check 12's negatives arefingerprint_catches_scrambled_coordinates(the recorded 106-archive regression class, encoded as a test),sampled_fingerprint_still_catches_out_of_bbox,fingerprint_catches_scaled_property_columnandexpect_fingerprint_accepts_identical_and_rejects_mutated, against the positiveshonest_fingerprint_passes_full_and_sampledandabsent_fingerprint_warns_but_never_fails.crates/stt-core/src/metadata.rsunit tests — the fingerprint comparison itself, below the CLI: containment-vs-equality mode selection (containment_always_equality_only_on_full_decode), an escaping vertex failing even under a sample (escaping_vertex_is_an_error_even_when_sampled), capability gating (tolerance_without_its_capability_is_rejected,on_wire_quant_step_is_admitted_as_slack), accumulator order-independence (accumulator_merge_is_order_independent), and the recorded defect shape reproduced directly (stride_two_xyz_fold_escapes_the_declared_bbox).packages/core/test/legacy-shape-backcompat.test.ts— decodes the frozen pre-2026-07-26 corpus (§2.2) through the current TS reader and pins exact time / vertex-value / offset arrays.packages/core/test/compact-times.test.tsandpackages/core/test/vertex-value-quant-and-parts.test.ts— the TS side of thest/et/vq/part_offsetscontracts, including the malformed-TILE_METArejections.crates/stt-core/tests/spec_conformance.rs— round-trips point / line / polygon / pre-tessellated-polygon layers and asserts the exact documented Arrow schema (column names, types, nullability, GeoArrow extension names, thestt:vertex_time_origin_ms/stt:vertex_time_step_ms/stt:has_trianglesmetadata keys). It also carries the manifest half of the same lock:metadata_honesty_blocks_match_the_published_schemavalidates a populatedcontent_fingerprint/z_range/ordering_workloadagainstmanifest.schema.jsonand fails on any key the Rust type emits that the schema has not declared (the direction a reader-side validator can never check);honesty_blocks_are_absent_from_a_legacy_metadatapins that an unset block is omitted rather than null-filled, so pre-field manifests keep their bytes;validator_check_numbering_agrees_across_the_documentspins §2.3, the CLI reference and the validator's module doc to one numbering.crates/stt-core/tests/hilbert_vectors.rs— pins the normative Hilbert-key test vectors published in packed-format §4 against the reference implementation, and cross-checks the spec's pseudocode (reimplemented independently in the test) exhaustively at low zooms.crates/stt-core/tests/adversarial_decode.rs— property-based adversarial decode tests (proptest): round-trip through the codecs, and never-panic on arbitrary and mutated input bytes. This is the hardening behind the security guidance in packed-format §11.packages/core/test/{manifest-schema,packed-golden,paged-directory,archive}.test.ts— the manifest contract and the golden-fixture reads above.
3. Conformant writer requirements#
A conformant writer MUST:
- emit a
manifest.jsonvalid againstmanifest.schema.json(format: "stt-packed",formatVersion: 3,directory.directoryVersion: 6); - declare the required
variantsregistry, including{id: 0, kind: "raw"}, and qualify every directory entry with a declaredvariant_id; - declare every required-to-understand feature it used in
manifest.capabilities(registry:attr-quant,coord-quant,elevation-fold,time-delta,triangles-partial,vertex-time-feature-anchor,vertex-value-quant— packed-format §3.1), omitting the key when none were used; additive columns (triangles,part_offsets, vector groups, …) are never declared.triangles-partialcovers a polygontrianglescolumn that MIXES baked and empty per-feature lists (an empty list means "the reader earcuts this ring");vertex-time-feature-anchorcoversTILE_META.vtf, which re-types thevertex_timeleaf toUInt16deltas measured from each feature's ownstart_timeinstead of a layer-wide origin. Note thattime-deltaapplies to a default build — the compact time forms are on unless suppressed, so a writer that emits them without declaring the capability is non-conformant even though it "changed nothing"; the same holds fortriangles-partialon any default polygon build whose layer actually mixes; - content-address every pack and directory object by blake3-128 (32 hex chars) and name each file by its hash;
- emit a v6 directory: delta + zig-zag varint key columns, blob-run RLE, the
per-run
pack_idcolumn, pack-relative offset contiguity, and the optional covering section (emitted iff every entry has a covering bound); - tag every
geometryfield withARROW:extension:name=geoarrow.{point,linestring,polygon}(and, unquantized, the CRS84ARROW:extension:metadata); - compute
metadata.boundsfrom geometry vertices — the quantity tiles are addressed by — never from feature centroids: the declared bbox MUST contain every vertex the archive decodes to. A centroid-derived box provably under-states the extent of any geometry wider than a point, and everything that pre-intersects a query box againstmetadata.bounds(tile selection, frustum pre-culling, an opening camera) is unsound on such an archive — it can discard tiles that really do carry visible data. Likewisemetadata.z_range, when declared, MUST contain every altitude the archive decodes to. This is a MUST newer than the deployed fleet, exactly like the CRS84 tagging above: see §3.1 for what enforces it and what does not, and §2.3's antimeridian note for the one measured gap where the reference writer does not yet satisfy it; - write a CRC32C of each compressed blob into its directory entry;
- pad every frame section to an 8-byte boundary with a derived (never stored) pad, and write every Arrow IPC stream at 8-byte buffer alignment — not arrow-rs' 64-byte default, which reproduces neither STT's content addresses nor its payload sizes (packed-format §5.2);
- ship no shared zstd dictionary — each blob is an independent zstd frame;
- emit
trianglesall-or-nothing per layer — once any feature in a layer carries baked indices, every feature in it MUST carry a non-empty list — unless it declares thetriangles-partialcapability, in which case a layer MAY mix baked and empty per-feature lists and an empty list means "the reader earcuts this single ring at decode". The capability gate is what makes the mixed shape safe: all three reference renderers bind the column as one whole-layer index buffer and draw nothing for an empty slice, so an un-capable reader would silently vanish every single-ring polygon; - emit
part_offsetsiff some feature in a polygon layer is multi-part, with feature-local ring indices starting at0and strictly increasing — absence means every feature is single-part; - follow the time model: Unix-ms UTC, one start-anchored bucket per feature, strictly-increasing multiple LOD levels.
A conformant formatVersion-3 writer additionally MUST (packed-format §§3.2, 5.2, 9.2):
- prefix every
.sttpobject with the 8-byteSTTPmagic prelude and every.sttdobject withSTTD(version byte 3, reserved bytes zero), with directory blob offsets object-absolute (first blob at offset 8) and content addresses /lengthfields covering the entire object including magic; - embed every schema template its frames reference in
manifest.schemas, sorted by hash and deduped, each entry'shashequal to blake3-128 of the raw template bytes; - keep templates dataset-constant: hoist the per-tile-varying metadata
keys (
stt:qa,stt:time_offset_ms,stt:vertex_time_origin_ms/stt:vertex_time_step_ms,stt:vertex_value_buckets) into each frame's canonical-JSONTILE_METAsection (sorted keys, no whitespace, a key present iff its feature is); - if it uses the compact time forms, obey
§5.2.4
exactly:
st: "u32"only alongside at0anchor, forms selected per layer only when every feature fitsu32under checked arithmetic,et: "zero"only when every duration is 0 (and then theend_timecolumn omitted, not zero-filled), and an empty layer always taking the absolute pair; - if it quantizes a per-vertex value column, obey
§5.2.6:
TILE_META.vqkeys drawn from the closed set, finite values clamped into[0, 0xFFFE]so none collides with the0xFFFFNaN sentinel, and the two degenerate affines pinned as specified so the bytes stay reproducible; - emit the frame-only encodings (
st/et/vq) only inside a frame — a layer serialized standalone has noTILE_METAto discriminate them and MUST use the canonical shapes; - emit v2 frames per the §5.2 layout:
0xFFFFescape,frame_version 2,flags 0, per-layer ref kinds (inline section or 16-byte template hash), ascending-tag TOC with exact at-rest lengths, derived 8-byte pads; tails aredictionary batch(es) + record batch + EOS(an empty tile still carries one DictionaryBatch per dictionary column); - stable-sort each layer's rows by
start_timeafter feature-id assignment and declareTILE_META.sorted: true; - never mix frame and manifest versions: every payload in a
formatVersion: 3dataset opens with the0xFFFFescape, and readers hard-error on anything else rather than guessing at an older frame shape; - when emitting a paged directory, publish
directory.rootHashand the ordereddirectory.pageHashesarray over the exact at-rest frame bytes, with one page hash per root descriptor.
A conformant writer SHOULD:
- deduplicate byte-identical blobs (one physical blob, multiple directory entries) so the directory's run-length encoding can collapse them — a size optimization, not interop-affecting (a reader cannot observe whether two entries share bytes, so this is unverifiable as a MUST); the reference writer always dedups;
- order blobs and directory entries with the §5 total tiebreaks so a rebuild is
byte-reproducible — and serialize Arrow schema/field custom metadata in a
canonical (lexicographic) key order so content addresses are reproducible
across processes. The reference Rust writer meets both on Arrow ≥59
(sorted-
BTreeMapmetadata assembly + Arrow 59's stable IPC metadata serialization; see packed-format §7-D6); - compress the directory at rest (
directory.encoding: "zstd"); - emit a paged directory (
layout: "paged") for large datasets so cold readers fetch directory bytes proportional to the viewport; - emit
metadata.content_fingerprint(version 1), computed from the source features before tiling and encode — the semantic claim check 12 verifies. SHOULD rather than MUST: a third-party writer without one stays conformant and the validator degrades to a warning, mirroring the CRS84 precedent. A writer that emits one MUST compute it pre-tiling: recomputing it from its own tiles proves only that the tiles agree with themselves (§3.1); - stamp its attestations into
metadata.properties:bounds_mode(vertex|centroid) andfeature_id_construction(source|anchor-hash|row-index|segment-hash), plusfeature_id_scope(global|local) when the writer asserts one. These are what let checks 12 and 13 tell an attested claim from a pre-attestation archive: an unstamped archive is treated as unattested, so the identical finding degrades from an error to a warning. Stamp them in both directions — a build that considered the question and answeredcentroidorlocalis then distinguishable from one that predates the question; - emit the additive
metadata.z_rangewhen any vertex — or a property column the build declares as its elevation source — carries altitude. Metadata only: declaring an elevation column here does not rewrite geometry; - record, alongside
blobOrdering, the workload model the layout was chosen under (orderingWorkload, and itsmetadata.ordering_workloadmirror), including the range-coalescing gap the writer's cost model priced it at.blobOrderingalone says what the layout is; it cannot say whether the layout is still optimal, because a re-fit of the query weights or a change to the reader's coalescing gap invalidates a simulated layout without moving a single archive byte. Informational, never a reader directive.
3.1 Content claims and the transform rule#
Five manifest fields are unlike the rest of the envelope. metadata.bounds,
metadata.z_range, metadata.distinct_feature_count and
metadata.content_fingerprint do not describe the container; they assert
something about the data inside it — distinct_feature_count is the count of
distinct SOURCE features, and the quantity check 12's decoded-row floor is
measured against. (The fifth, orderingWorkload, asserts something about the
assumptions the layout was priced under.) A claim is only worth having if it is
true, so this section states each obligation, and — because a MUST nothing
executes is prose that rots — names exactly what enforces it and what does not.
The transform rule. A tool that transforms an archive losslessly —
reorder, repack, re-optimize — MUST carry metadata.content_fingerprint
through verbatim, and MUST NOT recompute it from its own output.
Recomputing is precisely how a corrupting transform self-certifies: the tool
fingerprints its own scrambled output, the numbers agree with themselves, and
the archive validates. That is not hypothetical — a v1→v2 sweep that re-read
3D xyz coordinate leaves with a stride of 2 flattened and scrambled 106
archives, and a recomputing fingerprint would have blessed every one. Acceptance
for such a transform is therefore never "the output validates"; it is
stt-validate <before> --emit-fingerprint truth.json # capture from the TRUSTED inputstt-validate <after> --expect-fingerprint truth.json # hold the output to it
A transform that legitimately changes content (a re-quantization, a tier drop) is not a lossless transform, and must be rebuilt from source rather than re-stamped.
Readers need nothing new. All five fields are additive metadata, and §4's "ignore unknown fields at every manifest envelope level" already covers them. None of them re-types a tile column, so none of them is a capability — §3's capability rule explicitly exempts additive metadata.
Rule → the test that enforces it#
| obligation | enforced by |
|---|---|
MUST — declared bounds (and z_range) contain every decoded vertex | ✅ writer and validator. Writer: crates/stt-build/src/input.rs pins the honest computation and default_bounds_mode_is_vertex_since_the_r1_rebuild; crates/stt-build/tests/vertex_bounds_multi_tile.rs proves containment end to end through the tiler, with --bounds-mode centroid the documented rollback. Validator: check 13 recomputes containment from the decode (crates/spatiotemporal-tiles/tests/validate_cli.rs). ⚠️ Two antimeridian gaps remain — see §2.3's antimeridian note. |
| MUST — a lossless transform carries the fingerprint verbatim | expect_fingerprint_accepts_identical_and_rejects_mutated (crates/spatiotemporal-tiles/tests/validate_cli.rs) enforces the acceptance half: a mutated copy fails against a captured truth. ⚠️ Nothing tests that a transform tool preserves the key, because no in-repo tool rewrites an archive today. Any successor lands with that test, or the rule is unenforced again. |
SHOULD — emit content_fingerprint v1, computed pre-tiling | crates/spatiotemporal-tiles/tests/validate_cli.rs (the end-to-end build→validate loop, and byte-identity across builds); crates/stt-build/src/input.rs (order-independence). |
| check 12's comparison semantics (containment vs equality, severity, the decoded-row floor) | crates/stt-core/src/metadata.rs and crates/spatiotemporal-tiles/tests/validate_cli.rs. |
| a tolerance without its capability is rejected | tolerance_without_its_capability_is_rejected, on_wire_quant_step_is_admitted_as_slack (crates/stt-core/src/metadata.rs). |
| the recorded regression class stays caught | stride_two_xyz_fold_escapes_the_declared_bbox (crates/stt-core/src/metadata.rs, the defect shape in isolation) and fingerprint_catches_scrambled_coordinates (crates/spatiotemporal-tiles/tests/validate_cli.rs, end to end through the binary). |
SHOULD — emit z_range when altitude is present | crates/stt-core/src/metadata.rs (JSON round-trip, omission when unset, non-finite refusal, four-element TileJSON bounds); crates/stt-build/src/stac.rs (six- vs four-element STAC bbox); elevation_column_folds_into_the_z_range (crates/stt-build/src/input.rs). |
SHOULD — record the workload a blobOrdering was priced under | crates/stt-core/src/pack/mod.rs (omitted unless simulated, written at both pinned keys); crates/stt-optimize/src/order_audit.rs (drift flagged, including the reader gap); crates/spatiotemporal-tiles/tests/validate_cli.rs. |
| the wire shape of the four honesty blocks | metadata_honesty_blocks_match_the_published_schema, honesty_blocks_are_absent_from_a_legacy_metadata (crates/stt-core/tests/spec_conformance.rs, writer side); the manifest honesty blocks (M7) suite in packages/core/test/manifest-schema.test.ts (reader side, incl. wrong-arity bbox, malformed per-column maps, non-integer coalesce_gap_bytes). |
the two orderingWorkload copies stay identical | manifest_records_the_ordering_workload_at_both_pinned_keys (values) and the two ordering-workload copies are pinned to the SAME shape (schema declarations). |
Reference-writer status (2026-08)#
A conformance document that describes intentions rather than shipped behavior is the failure mode this table exists to prevent, so:
content_fingerprintis opt-in. The reference builder emits it only understt-build --content-fingerprint; the key is absent from every published archive. The validator's absent-key path is therefore the normal path today, and is permanent, not transitional.metadata.boundsis vertex-derived by default.DEFAULT_BOUNDS_MODEisBoundsMode::Vertex,stt-build --bounds-mode {vertex,centroid}selects it explicitly, and the choice is stamped intometadata.properties.bounds_mode— the exact key andvertexspellingstt-validate's check 13 reads as an attestation, pinned on both sides bybounds_mode_manifest_stamp_matches_the_validator_contract(crates/stt-build/src/input.rs). The centroid box is the documented rollback, not a deleted path, and an archive built without the stamp stays unattested.metadata.z_rangeis written when the source carries altitude, and stays absent — byte-invisibly — for a 2D source:z_range_is_declared_only_when_the_source_carried_altitude(crates/stt-build/tests/vertex_bounds_multi_tile.rs) pins both halves. ⚠️ A build using--point-elevation-columnmust pass that column to the profiler too (the encoder folds it into pointzlong after the profile runs) —elevation_column_folds_into_the_z_range(crates/stt-build/src/input.rs).orderingWorkloadis emitted only for a SIMULATED ordering — ameasuredbuild with enough tiles to simulate — not for every archive that carries ablobOrdering. Presence/absence is itself the signal: absent means the layout's provenance is unknown, and a consumer must not assume a gap. It is written at two keys (top-level, and ametadata.ordering_workloadmirror) because the shipped TS reader resolves the build-assumed gap through the mirror; the mirror is scheduled for removal once the reader moves.
4. Conformant reader requirements#
A conformant reader MUST:
- reject an unrecognized
format,formatVersion, ordirectoryVersion(refuse, don't guess); - reject manifest object keys outside the exact relative
index/<32-hex>.sttd/packs/<32-hex>.sttpshapes before resolving or fetching them; - refuse a dataset whose
manifest.capabilitiesdeclares a feature the reader does not implement, naming the unknown entries (a capability re-types existing columns, so proceeding is silent misdecode — not an error later); - ignore unknown fields at every manifest envelope level (additive evolution);
- support both directory layouts — whole-load (
single/absent) andpaged(root page + on-demand leaf fetches), including the small-directory whole-load shortcut; - verify every partial paged-directory range against
rootHash/pageHashes; an older paged v2 manifest without those hashes must use the whole-load path and verify the complete object's content address; - validate the fetched directory body length against
directory.lengthbefore decoding, and unwrapdirectory.encodingwhen set; - bound every tile decompression by its directory-declared
uncompressed_size, and bound directory page decompression before allocating attacker-controlled counts; - accept all four
vertex_timeencodings —List<UInt16>andList<UInt32>layer-anchored deltas (both carryingorigin/step, v2:TILE_META.vt),List<UInt16>feature-anchored deltas measured from each feature's ownstart_time(a step and deliberately no origin, v2:TILE_META.vtf, gated by thevertex-time-feature-anchorcapability so a reader that does not implement it refuses at open rather than inventing an origin), and absoluteList<Int64>— keying "is it a delta, and anchored to what?" off the metadata and "how wide?" off the Arrow leaf type, never off the leaf type alone; - accept a quantized
<prop>column at either integer leaf (UInt16,Int32) and reconstruct through the per-tilestt:qaaffine, without caching the affine or the leaf width across tiles; - ignore unknown reserved-looking columns rather than mis-publishing them
as properties (
part_offsetsis the current example: a reader that has never heard of it must not surfaceList<UInt32>ring indices as a numeric property); - earcut an empty
trianglesslice if it accepts thetriangles-partialcapability: a feature whose triangle slice is empty is a single-ring polygon the writer left for the reader to tessellate, never a feature to draw nothing for (a reader that does not implement the capability refuses the dataset at open instead — all three reference renderers bind the column as one whole-layer index buffer, which is why an un-capable reader would silently vanish every such polygon); - coalesce range reads per pack (a range must not bridge two pack objects);
- prune by time with
time_end >= w_start AND (cover_t_min ?? time_start) <= w_end, falling back totime_startwhencover_t_minis absent.
A conformant reader that accepts formatVersion 3 additionally MUST:
- treat
manifest.formatVersionas authoritative and hard-error on a frame of the other version inside a dataset (the0xFFFFescape is defense-in-depth, not negotiation); - validate the
STTP/STTDobject magic (tag, version byte, zero reserved bytes) before any offset math, and read blob/root offsets object-absolute; - validate every
manifest.schemasentry (blake3_128(data) == hash) at open, failing the dataset loudly on any mismatch, and resolve frame template references against the resulting registry (an unresolvable hash is a hard error naming it); - splice
concat(template, section)using exactly the TOC-declared section length, verifying both template and batch section begin with the0xFFFFFFFFcontinuation marker (stray zeros silently EMPTY a tile in arrow-rs — the guard converts that to a loud error); - source the per-tile metadata from
TILE_META(ignoring unknown keys), and skip unknown section tags via their TOC length — while treating an unrecognized value of a key it does know (st,et) as a hard decode error, never a fallback; - support both v2 schema modes: template-hash references and self-contained inline schema sections;
- re-inflate the compact time forms to absolute non-null
Int64before any consumer sees the batch — including synthesizing theend_timecolumn at the index right afterstart_timewhenet == "zero"— and reject the malformed combinations §5.2.4 lists (stwithoutt0,st: "u32"on a non-UInt32column,et: "zero"alongside a presentend_time,et: "dur32"without one, length disagreement); - dequantize a
TILE_META.vqcolumn back toFloat32, mapping the0xFFFFsentinel toNaN, and reject avqkey outside the closed set, naming an absent column, or naming a column whose leaf is notUInt16; - read
part_offsetswhen present as feature-local ring indices, and treat its absence as "every feature is single-part".
A conformant reader SHOULD:
- verify the CRC32C of each blob before decompression (both reference readers do);
- select temporal LOD via
max_zoom_levelwhen the app opts in; - render
anchored-localscene bundles on a neutral basemap.
5. Running the suite#
cargo test -p stt-core spec_conformance # payload schema lockcargo test -p stt-core --test v2_golden # writer byte pincargo test -p stt-core --test capability_registry # registry ⇄ schema pincargo test -p spatiotemporal-tiles --test cli_reference_doc # CLI surface ⇄ docscargo test -p spatiotemporal-tiles --test validate_cli # validator behaviorcargo run -p stt-core --example make-golden-fixture # regenerate the hand-built vectorsconformance/make-vectors.sh # regenerate the stt-build vectorspnpm --filter @poopdeck.gl/core test # manifest contract + golden-fixture readsstt-validate <your-dataset> # validate your own output
stt-validate is a [[bin]] of the spatiotemporal-tiles crate, not a
package — cargo test -p stt-validate has never resolved.
A new implementation demonstrates conformance by (a) producing a dataset that
passes stt-validate and the manifest schema, and (b) reading the committed
golden fixtures to byte-identical results. A writer that also makes the content
claims of §3.1 demonstrates those
by (c) passing a full-decode run — stt-validate <dataset> with no
--sample — since only a full decode compares its fingerprint for equality
rather than containment.