The stt-serve Protocol
Scope: the HTTP surface of
stt-serve(crates/spatiotemporal-tiles/src/bin/stt-serve.rs) — an axum server that generates STT tiles on the fly, one per request, from a live PostGIS table or a DuckDB database, with nomanifest.jsonor packs written to disk. This is theST_AsMVTanalog for the STT format: request a(z, x, y, t), get back one tile. Route shapes, status codes, response headers, and the JSON descriptor are documented here; the full CLI flag surface (every generation-parity flagstt-serveshares withstt-build) is in../api/cli-reference.md, and the rationale for dynamic serving as a database-input capability is in../roadmap/db-input-adaptors.md. This page does not repeat either.
1. Overview#
stt-serve answers tile requests by querying a live source per request
instead of reading a pre-built packed archive:
(z, x, y)maps to a WGS84 bounding box;tmaps to a temporal bucket (§4).- A source query filters by that bbox (spatial index) and time window.
- The matching rows decode to features and encode into exactly one tile
payload, using the same per-tile encoder
stt-builduses.
There is one binary with two mutually-exclusive backends (--postgres /
--duckdb, §5) and two serving modes (single-dataset / multi-dataset, §2).
Nothing is cached server-side: every response is generated fresh (§7).
2. Serving modes#
| Mode | Selected by | Routes live at |
|---|---|---|
| Single-dataset | default (no --config) | the process root: /metadata.json, /tiles/… |
| Multi-dataset | --config <file.json> | /{dataset}/metadata.json, /{dataset}/tiles/…, plus a catalog at /datasets |
A multi-dataset --config file lists one JSON object per dataset, each with
the same fields as the CLI flags (§6). Every dataset gets its own connection
pool, its own resolved TileConfig, and its own explicit encoder
configuration (never a process-wide global) — so several datasets with
different quantization, vector grouping, or temporal bucketing are served
concurrently from one process without cross-contaminating each other's
settings. Single-dataset mode is the CLI-flags-describe-one-dataset case,
served at the root for backward compatibility.
3. Routes#
| Method & path (single-dataset) | Method & path (multi-dataset) | § |
|---|---|---|
GET /health | GET /health | 3.1 |
GET /metadata.json | GET /{dataset}/metadata.json | 3.2 |
| — | GET /datasets | 3.3 |
GET /tiles/{z}/{x}/{y}/{t}.stt | GET /{dataset}/tiles/{z}/{x}/{y}/{t}.stt | 3.4 |
The protocol defines no query parameters. Every request is fully addressed by its path; any query string is ignored by every route.
3.1 GET /health#
Liveness probe. Always 200 OK, body ok (plain text). No dataset lookup —
present in both modes even though it does nothing dataset-specific.
3.2 GET /metadata.json (or /{dataset}/metadata.json)#
Returns 200 OK with a Content-Type: application/json body describing the
dataset: its spatial/temporal extent, zoom range, temporal bucket, feature
count, and (if configured) a heatmap domain. It is computed once at server
startup from a whole-source SQL aggregate — it does not reflect writes to
the source made after startup. Field shapes are in §4.
In multi-dataset mode, an unknown {dataset} segment returns 404 Not Found
with a plain-text body unknown dataset '<name>'.
3.3 GET /datasets (multi-dataset mode only)#
The dataset catalog:
{"datasets": [{"name": "obs","metadata": {/* the same object /metadata.json returns for this dataset */},},{ "name": "trips", "metadata": { "...": "..." } },],}
Entries are sorted by name. Not present in single-dataset mode (there is
nothing to catalog).
3.4 GET /tiles/{z}/{x}/{y}/{t}.stt (or /{dataset}/tiles/{z}/{x}/{y}/{t}.stt)#
Generates and returns exactly one tile.
Path parameters:
| Param | Type | Meaning |
|---|---|---|
z | u8 | Zoom level (slippy-map convention). Hard-bounded: z > 31 returns 400 (§3.4.5) — beyond 31 the 2^z grid exceeds the u32 x/y space. It is also range-checked against the advertised [--min-zoom, --max-zoom] band: a zoom outside that band returns 404 (§3.4.5) before any source query — the guard that stops a z = 0 request over a global source from becoming a full-table scan. |
x, y | u32 | Tile column/row at z (slippy-map convention); must lie in the 2^z grid — x ≥ 2^z or y ≥ 2^z returns 400 (§3.4.5). |
t | the whole final path segment, as text | A Unix-ms integer. A trailing .stt suffix (the convention tileUrlTemplate in /metadata.json advertises) is stripped before parsing, so it is accepted but not required — .../1700000000000 and .../1700000000000.stt are equivalent requests. |
z, x, and y are extracted by axum's typed path matching: a segment that
doesn't parse as its declared integer type (non-numeric, or out of u8/u32
range) never reaches the handler — axum's own path-extraction rejection
applies (400 Bad Request, framework-generated body, distinct from the
handler's own t-specific 400 below).
3.4.1 Bounding-box resolution#
(z, x, y) maps to a WGS84 [min_lon, min_lat, max_lon, max_lat] box via the
standard Web Mercator slippy-tile formula, then widened on each side by
max(5% of the tile's span, 0.001°) before it is used as the SQL
pre-filter. This buffer exists so a feature that straddles a tile edge is
never missed by the bbox predicate; it is deliberately loose. The floor takes
over from about z 15 up, where 5% of a tile is narrower than the clipper's
fixed 0.001° pad — without it the pre-filter would be tighter than the clip
band and would drop edge features the encoder keeps. The exact per-tile
placement (which features actually belong in this tile, including trajectory
clipping) is performed afterward by the same placement code
stt-build uses — the buffer only affects which rows the SQL query fetches as
candidates, never which end up in the tile.
3.4.2 Temporal bucket resolution#
The effective bucket for a request is not always --temporal-bucket:
- Start from the base bucket (
--temporal-bucket, parsed once at startup). - If
--temporal-lodis configured, find every LOD level whosemax_zoom_level >= z("applies at this zoom") and take the largestbucket_msamong them (coarsest applicable level wins). If none applies, the base bucket stands. tis floored to a multiple of the effective bucket:bucket_start = floor(max(t, 0) / bucket) * bucket, and the query window is the half-open interval[bucket_start, bucket_start + bucket). A negativetis clamped to0before flooring — it is not rejected, it lands in the same bucket ast = 0.tneed not already be bucket-aligned; the server aligns it. A client should still request bucket-aligned values (as/metadata.jsonand anytemporalLodentries describe) so consecutive requests address distinct tiles.
When an LOD level applies, the response is generated as if the dataset had
been offline-built with that level's bucket size for this tile — the same
tile a static stt-build --temporal-lod archive would serve at that zoom.
3.4.3 Success response#
200 OKContent-Type: application/x-stt-tileCache-Control: no-storex-stt-gen-micros: <integer><tile bytes>
- Body. The raw (uncompressed) layer-frame payload — the same
tile-payload bytes a packed writer would
zstd-compress into a
packs/*.sttpblob for this(z, x, y, t)and these source rows.stt-serveperforms that encode step but not the compression step: there is noContent-Encodingon the response and no compression middleware in front of it, so the bytes on the wire are the pre-compression layer frame, not a stored pack blob. A client decodes it exactly as it would decode one tile's payload after unwrapping the corresponding pack blob's zstd frame. Content-Type: application/x-stt-tile. A custom media type; there is no registered IANA type for this payload, and thex-prefix is deprecated practice. The planned vendor-tree registrations (packed spec §9.2) will add avnd.type for the uncompressed layer frame; when one is registered the server will switch and this label retires.Cache-Control: no-store. Explicit and unconditional — see §7.x-stt-gen-micros. Server-side generation time in microseconds, as a decimal integer string. The clock starts once the request parameters have been parsed (before the bucket/bbox math and the source query) and stops the instant the tile bytes are ready, so it covers pool checkout + query + row/ValueRefdecode + encode, but excludes parameter parsing, connection setup already paid for by a warm pool, and response serialization/network time. It is the same header on both backends (backend-agnostic).- No CORS headers. The server sets no
Access-Control-*headers. A browser client on another origin needs a CORS-adding proxy or gateway in front ofstt-serve(the same place TLS and auth belong — see §7).
3.4.4 204 No Content — the empty-tile response#
No custom headers, no body. Returned whenever the tile has nothing to serve, which happens for any of three reasons:
- No candidate rows. The bbox+time source query returns zero rows.
- Nothing placed. Rows existed in the buffered bbox, but after exact
per-tile placement (trajectory clipping, precise point-in-tile test, exact
bucket membership) none actually belong to this
(z, x, y, t)— expected, since the SQL bbox is a deliberately loose superset (§3.4.1). - Below
--min-features-per-tile. Features placed, but fewer than the configured minimum (default1, so this reason is inert unless the flag is raised) — mirrors the offline writer's--min-features-per-tiletile-drop.
A conformant client MUST treat 204 as "this tile is legitimately empty," not
as an error — it is the expected response for the (common) case of a sparse
dataset's off-data tiles.
3.4.5 Error responses#
| Status | Cause | Body |
|---|---|---|
400 Bad Request | z > 31, or x/y outside the 2^z tile grid | tile out of range: need z <= 31 and x, y < 2^z (plain text) |
400 Bad Request | t does not parse as an integer after stripping a trailing .stt | t must be an integer (ms since epoch) (plain text) |
404 Not Found | z outside [--min-zoom, --max-zoom] (both modes) | zoom <z> outside served range <min>..=<max> (plain text) |
404 Not Found | (multi-dataset mode only) {dataset} does not match any configured dataset | unknown dataset '<name>' (plain text) |
500 Internal Server Error | the source query, row decode, or tile encode fails (connection error, malformed SQL from a bad --sql/--where, encoder error, …) | the fixed string internal error generating tile (plain text) — the full anyhow error chain (which can contain SQL and connection strings) goes to the server log only, never the client |
Checks run in this order: grid bounds (400) → served zoom range (404) →
t parse (400), so an out-of-range zoom with an unparseable t yields 404,
not 400.
Every 500 is also logged server-side (tracing::error!) with the failing
(z, x, y, t) and the full error chain.
4. /metadata.json field reference#
{"format": "stt-postgis-dynamic", // or "stt-duckdb-dynamic""formatVersion": 2,"capabilities": [], // e.g. ["coord-quant", "time-delta", "triangles-partial"]"name": "hurricane_obs","boundingBox": [[-179.9, -71.2],[179.8, 81.0],],"timeRange": { "start": 946684800000, "end": 1700000000000 },"minZoom": 3,"maxZoom": 8,"temporalBucketMs": 604800000,"featureCount": 48538,"tileUrlTemplate": "/tiles/{z}/{x}/{y}/{t}.stt","heatmapDomain": {"classes": [{ "id": "default", "min": 0.0, "max": 4.7, "property": "wind_kt" },],},"temporalLod": [{ "bucket_ms": 2592000000, "max_zoom_level": 4 }],}
| Key | Type | Always present? | Meaning |
|---|---|---|---|
format | string | yes | "stt-postgis-dynamic" or "stt-duckdb-dynamic" — identifies the live origin, distinct from the packed manifest's format: "stt-packed". |
formatVersion | u32 | yes | The layer-frame version this server emits — currently 2 (§8), the only frame version there is. ⚠️ Despite the shared key name this is not the packed manifest's formatVersion (3, the container version); a live server has no container. Frames are self-describing to decode_tile, but a client that pins a decoder needs to know before it fetches. |
capabilities | string[] | yes | The protocol's capability channel — the twin of the packed manifest's capabilities, derived from EncoderSettings::required_capabilities(), plus triangles-partial when --partial-triangles is set without --pre-tessellate; sorted and de-duplicated. Always present, empty when the server declares nothing, so its ABSENCE unambiguously means "server predates this key". Advisory — see §8. |
name | string | yes | Resolved dataset name: the --name override, else the table name (schema-qualified table's last segment) or "query" for a --sql source. |
boundingBox | [[minLon,minLat],[maxLon,maxLat]] | yes | The whole source's spatial extent from a startup ST_Extent-style aggregate (reprojected to 4326 first when --source-srid is set). Falls back to [[-180,-90],[180,90]] if the source is empty. |
timeRange | { start, end } (Unix ms) | yes | MIN/MAX of --time-field over the whole source, converted to ms per --time-format for an integer time column. Falls back to {0, 0} if the source is empty. |
minZoom / maxZoom | u8 | yes | Echo --min-zoom / --max-zoom. |
temporalBucketMs | u64 | yes | The base bucket (--temporal-bucket, parsed to ms). Does not reflect per-request LOD widening (§3.4.2) — a client reads temporalLod for that. |
featureCount | i64 | yes | COUNT(*) over the whole source at startup — a dataset-wide count, not a per-tile count. |
tileUrlTemplate | string | yes | Always the literal "/tiles/{z}/{x}/{y}/{t}.stt", even in multi-dataset mode — it is not prefixed with /{dataset}. A multi-dataset client must prepend the dataset segment itself. |
heatmapDomain | { classes: HeatmapClassDomain[] } | only if a heatmap flag is set and classes were found | See §4.1. Key is absent (not null) when neither --heatmap-weight nor --heatmap-class is set, and also when the startup aggregate found no classes at all (an all-NULL weight/class column, or an empty source). |
temporalLod | TemporalLodLevel[] | only if --temporal-lod is set | The parsed LOD pyramid, so a client can discover which zooms get a coarser bucket. Key is absent when --temporal-lod is unset. Note: unlike every other key in this object, array elements keep their Rust field names verbatim — bucket_ms / max_zoom_level, snake_case — because TemporalLodLevel has no camelCase rename. |
Every top-level key besides the temporalLod array's own field names is
camelCase (the loaders.gl TileSource-style runtime-descriptor convention),
which deliberately diverges from the packed manifest's snake_case
heatmap_domain / temporal_lod keys — no client reads the serve response
and the packed manifest with the same code path, so the two are free to use
their own casing conventions.
4.1 heatmapDomain — computed from --heatmap-weight / --heatmap-class#
Mirrors the packed archive's build-time heatmap domain
(stt-build --heatmap-weight/--heatmap-class), computed here once at
startup via a SQL aggregate over the whole source rather than at build time
over the whole dataset — same shape, live source:
"heatmapDomain": {"classes": [{ "id": "default", "min": 0.0, "max": 4.7, "property": "wind_kt" }]}
--heatmap-weight | --heatmap-class | Aggregate | classes |
|---|---|---|---|
| set | unset | min(weight), percentile_cont(0.95) (Postgres) / quantile_cont(weight, 0.95) (DuckDB), over the whole source | one entry, id: "default", property = the --heatmap-weight column |
| set | set | the same aggregate, GROUP BY class, ORDER BY class, capped at 8 groups | one entry per distinct class value (as id), property set |
| unset | set | DISTINCT class values, capped at 8, ORDER BY class | one entry per value, min: 1.0, max: 1.0, property absent (an enumeration, not an intensity domain) |
| unset | unset | not computed | key absent from /metadata.json |
The percentile is the database's own continuous percentile function, which may differ marginally from the offline build's floor-index percentile — this is a rendering style hint (the heatmap's color ramp domain), not tile payload bytes, so the two need not match exactly.
If the aggregate yields no classes — an all-NULL weight column, an all-NULL
class column, or an empty source — the heatmapDomain key is omitted entirely
even though a heatmap flag was set.
5. Backends#
--postgres <CONN> | --duckdb <PATH> | |
|---|---|---|
| Engine | PostgreSQL/PostGIS, external server | DuckDB, embedded (statically bundled — no system lib, no server) |
| Pool | deadpool_postgres, async, RecyclingMethod::Fast; NoTls unless the connection string carries sslmode=require (or stricter), which selects a native-tls connector — available only in a build with the non-default serve-postgres-tls cargo feature, otherwise startup fails with an explicit rebuild-with-the-feature error. sslmode=disable and prefer both keep the plaintext path. | r2d2, blocking (duckdb::Connection is Send but !Sync) |
--pool-size | deadpool_postgres::Pool max_size | r2d2::Pool max_size, with the CLI value itself clamped to a minimum of 1 before it reaches the pool builder |
| Env fallback | STT_POSTGRES_URL, then DATABASE_URL | STT_DUCKDB_PATH |
| Per-request execution | runs on the async reactor for the query; row decode + tile encode are CPU-bound and run on spawn_blocking | pool checkout, query, decode, and encode all run on one spawn_blocking worker (nothing about DuckDB is async) |
| Extra setup | none beyond the pool | every new physical connection (not every checkout) runs INSTALL spatial; LOAD spatial; SET TimeZone='UTC'; — a one-time network fetch for the extension, cached under ~/.duckdb, and UTC pinning so epoch_ms/ST_AsWKB math is timezone-independent |
| File access | n/a (server-managed) | a real .duckdb file opens read-only (never mutates the source, coexists with another process holding it); :memory: (or an empty path) opens a fresh in-memory database, logged with a warning that pooled connections share one in-memory DB via try_clone starting empty — only a --sql that scans external files (e.g. read_parquet(...)) works there, not a pre-existing table name |
| Malformed rows | both backends decode with InputStrictness::Warn — a row that fails to parse is warned about and coerced/dropped rather than failing the whole request | (same) |
--postgres and --duckdb are mutually exclusive; stt-serve refuses to
start if both (or neither, with no env fallback resolving one) are given.
Which backends exist in a given binary is a build-time choice. The default
build (cargo install spatiotemporal-tiles) ships the PostGIS backend only;
DuckDB is the non-default serve-duckdb cargo feature (--features serve for
both backends, --features cli for everything). A flag for a compiled-out
backend does not exist at all, and the matching --config key is rejected as
an unknown field (§6).
At startup, both backends additionally probe the source's result schema
(DuckDB: a LIMIT 0 execution of the tile projection; PostGIS: a statement
prepare) to pin each property column's tile kind — the same schema-pinning an
offline GeoParquet build derives from the Parquet schema — so a column that
happens to be all-NULL within one tile still gets its (all-null) column there
instead of drifting the layer schema across tiles. A failed probe logs a
warning and falls back to per-tile value sniffing.
6. Multi-dataset --config file#
{"datasets": [{"name": "obs","postgres": "postgresql://x","table": "hurricane_obs","time-field": "iso_time","temporal-bucket": "7d","quantize-coords": 50.0,},{"name": "trips","duckdb": "trips.duckdb","sql": "SELECT * FROM t","geom-column": "the_geom","time-format": "unix-ms","min-zoom": 2,"max-zoom": 10,},],}
- Every dataset entry accepts the same fields as the CLI flags, keyed by
the flag's kebab-case name (
temporal-bucket, nottemporal_bucket). A field a dataset entry omits falls back to the same default the CLI flag would use — there is one source of defaults (theclapdefinitions), shared by both parse paths. - Unknown keys are rejected (a typo'd field name fails to parse the config file, rather than being silently ignored).
nameis optional per dataset — omitting it derives one from--table(the part after the last., e.g.public.obs→obs) orqueryfor--sql. Whatever name is used (explicit or derived) MUST be unique within the file — a duplicate name fails at startup before the server binds.--configitself is CLI-only (not a field a config entry can set).
7. Caching semantics#
| Route | Cache-Control |
|---|---|
GET /tiles/… | no-store, set explicitly on every 200 response |
GET /metadata.json, /{dataset}/metadata.json | none set — no explicit caching directive either way |
GET /datasets, GET /health | none set |
Tiles are regenerated on every request; there is no server-side response
cache and no on-disk artifact analogous to a pack. This is the deliberate
live-source trade-off: a pre-baked packed archive is
content-addressed and edge-cacheable forever, while stt-serve trades that
away for always-current data. no-store instructs every cache, shared
caches included, not to store the response (RFC 9111 §5.2.2.5), so a conformant
CDN will not cache a served tile. Edge caching in front of stt-serve
therefore requires deliberately overriding the origin header in the proxy's own
configuration — it is not something the protocol permits by default. See
db-input-adaptors.md §6.3
for when a pre-bake is the better fit than dynamic serving.
8. Generation-parity contract#
stt-serve and stt-build parse the same flags through the same
stt_build::build_options module into the same TileConfig /
EncoderConfig types, and both call the same per-tile encode path
(encode_single_tile_counted, backing encode_tile_with) — so a served tile
for a given (z, x, y, t) is generated by the identical code the offline
writer would use for that tile, over the same source rows. The full list of
shared per-tile flags (clip, simplify, pre-tessellate, min-/max-zoom-field,
per-tile budgets, attribute filter) and encoder-global flags
(--quantize-*, --vector-group, --point-elevation-column,
--vertex-time-precision) is in
cli-reference.md; the design rationale
for the shared seam is in
db-input-adaptors.md §3.
Two features are rejected at startup (a clear error, not a runtime failure per request) because a single tile's rows cannot answer them:
--summary-tier— cross-tile H3/quadbin aggregation spans many spatial tiles by construction.--adaptive-temporal— its window size is chosen from a cell's whole time range, not a single bucket's rows.
Both must be pre-baked with stt-build and served as a static archive
instead.
Frame version: serve emits formatVersion 2 — the layer-frame version,
which is the only frame version there is (§4), not the packed container's 3.
Tiles use inline schemas
(data-format.md §Layer frame):
inline is the only mode that makes sense for a manifest-less server — there
is nowhere to carry a schemas registry — and since responses are no-store
(§7), the template amortization the registry buys would be dead weight anyway.
The frame version is advertised as formatVersion on /metadata.json (§4), and
the encoder features that re-type a column are advertised alongside it as
capabilities. Both are written from the same EncoderSettings that produces
the bytes, so the declaration cannot drift from the tile.
That channel is advisory, not enforcing: a packed archive lets an
under-capable reader refuse the dataset at open, but nothing makes an HTTP
client read /metadata.json before fetching a tile. This is why serve
inverts the offline default for every re-typing feature: --compact-times
and --partial-triangles are both opt-in here and both on by default in
stt-build (the latter via its --no-partial-triangles escape hatch) — so the
out-of-the-box server emits the shape an older decoder reads correctly, and the
byte-saving shape is something an operator turns on once they control their
clients.
Byte parity is therefore scoped by encoder settings, not format version: a
served tile is byte-identical to the offline tile of a build carrying the same
encoder flags. Out of the box that is a
stt-build --no-compact-times --no-partial-triangles build — the two defaults
the two sides disagree on.
Non-4326 source geometry is served via --source-srid <SRID> (mirroring
stt-build --source-srid at ingest): every per-tile query reprojects the
stored geometry to 4326 before both the bbox filter and the WKB
projection (DuckDB: ST_Transform(geom, 'EPSG:<srid>', 'EPSG:4326') with
always_xy; PostGIS: ST_Transform(ST_SetSRID(geom, <srid>), 4326) — the
exact ingest expressions), and the startup metadata extent is reprojected the
same way, so advertised bounds and served tiles are always lon/lat. The
trade-off: the per-row transform bypasses a plain spatial index on the raw
column, so tile queries scan without index help — on PostGIS a functional
index on the transform expression restores index use; on either engine,
storing 4326 remains the fast path.
9. Relationship to the rest of the spec#
- The
GET /tiles/…body is one uncompressed tile payload — the same layer-frame bytes a packed archive stores zstd-compressed per tile (§3.4.3). stt-serveproduces nomanifest.jsonand is out of scope forstt-validateand the directory/paging conformance requirements — those apply to the packed container this server does not write.- See
sidecar-assets.mdfor the unrelated notion of sidecar (non-tile) files in a scene bundle —stt-servehas no sidecar concept; every response is either a tile or the dataset descriptor.