Protocol × Capability Matrix
Status: generated from config/support-manifest.v1.json; do not edit this section by hand.
Native (✓) claims mirror the default capability set per protocol; per-source
metadata may narrow them at runtime. Client-fallback (◐) claims are explicit
opt-in paths and are not protocol defaults. An absent operation is explicitly
unsupported; capability misses throw HonuaCapabilityNotSupportedError
rather than returning empty data.
✓supported with native execution◐supported through an explicit client fallbackβbeta◇experimental†deprecatedFfacade-required—unsupported
| Capability | gRPC | GS Feature | GS Map | GS Image | GS Geometry | GS GP | OGC Features | OGC Tiles | OGC Maps | OGC Records | STAC | WFS | WMS | WMTS | OData | PMTiles | GeoParquet | MapLibre vector | MapLibre raster | MapLibre GeoJSON |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
query |
✓ | ✓ | ✓ | ✓ | — | — | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | — | ◇ | — | — | — |
queryAggregate |
✓ | ✓ | ✓ | — | — | — | ◐ | — | — | — | — | — | — | — | — | — | ◇ | — | — | — |
spatialAggregate |
— | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
queryExtent |
✓ | ✓ | ✓ | ✓ | — | — | ◐ | — | — | — | — | ✓ | — | — | — | — | — | — | — | — |
queryObjectIds |
✓ | ✓ | ✓ | ✓ | — | — | ✓ | — | — | ✓ | ✓ | ✓ | — | — | ✓ | — | — | — | — | — |
queryRelated |
— | ✓ | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
applyEdits |
✓ | ✓ | — | — | — | — | ✓ | — | — | — | — | ✓ | — | — | ✓ | — | — | — | — | — |
attachments |
— | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
render |
— | — | ✓ | ✓ | — | — | — | ✓ | ✓ | — | — | — | ✓ | ✓ | — | — | — | ✓ | ✓ | — |
tiles |
— | ◐ | ✓ | ✓ | — | — | — | ✓ | — | — | — | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | — |
sql |
— | ✓ | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
stream |
✓ | ✓ | ✓ | — | — | — | ✓ | — | — | ✓ | ✓ | ✓ | — | — | ✓ | — | ◇ | — | — | — |
pbf |
— | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
image |
— | — | — | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
geometry |
— | — | — | — | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
geoprocess |
— | — | — | — | — | ✓ | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
processes |
— | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
Generated claim evidence
Every non-unsupported operation claim maps to executable evidence and a freshness policy in the manifest.
| Protocol | Operations | Status | Environment | Execution mode | Evidence |
|---|---|---|---|---|---|
grpc |
query, queryAggregate, queryExtent, queryObjectIds, applyEdits, stream |
supported |
honua-facade |
native |
fixture: contract-conformance conformance: grpc-conformance |
geoservices-feature-service |
query, queryAggregate, queryExtent |
supported |
protocol-adapter |
native |
fixture: contract-conformance |
geoservices-feature-service |
queryObjectIds, queryRelated, applyEdits, attachments |
supported |
protocol-adapter |
native |
fixture: geoservices-conformance |
geoservices-feature-service |
sql |
supported |
protocol-adapter |
native |
fixture: core-client-fixtures |
geoservices-feature-service |
stream |
supported |
protocol-adapter |
native |
fixture: contract-conformance fixture: honua-surface-fixtures |
geoservices-feature-service |
pbf |
supported |
protocol-adapter |
native |
fixture: core-client-fixtures |
geoservices-feature-service |
tiles |
supported |
protocol-adapter |
client-fallback |
fixture: query-tiles-fixtures |
geoservices-map-service |
query, queryAggregate, queryExtent |
supported |
protocol-adapter |
native |
fixture: contract-conformance fixture: honua-surface-fixtures |
geoservices-map-service |
queryObjectIds, queryRelated |
supported |
protocol-adapter |
native |
fixture: geoservices-conformance fixture: honua-surface-fixtures |
geoservices-map-service |
render |
supported |
protocol-adapter |
native |
fixture: honua-surface-fixtures |
geoservices-map-service |
tiles |
supported |
protocol-adapter |
native |
fixture: connect-fixtures fixture: tile-layer-fixtures |
geoservices-map-service |
sql |
supported |
protocol-adapter |
native |
fixture: honua-surface-fixtures |
geoservices-map-service |
stream |
supported |
protocol-adapter |
native |
fixture: contract-conformance fixture: honua-surface-fixtures |
geoservices-image-service |
query, queryExtent, queryObjectIds |
supported |
protocol-adapter |
native |
fixture: geoservices-conformance |
geoservices-image-service |
image, render |
supported |
protocol-adapter |
native |
fixture: geoservices-conformance |
geoservices-image-service |
tiles |
supported |
protocol-adapter |
native |
fixture: geoservices-conformance |
geoservices-geometry-service |
geometry |
supported |
protocol-adapter |
native |
fixture: geoservices-conformance |
geoservices-gp-service |
geoprocess |
supported |
protocol-adapter |
native |
fixture: geoservices-conformance fixture: geoprocessing-job-run-fixtures fixture: process-runner-fixtures |
ogc-features |
query, queryObjectIds, applyEdits, stream |
supported |
protocol-adapter |
native |
fixture: ogc-features-fixtures |
ogc-features |
queryAggregate, queryExtent |
supported |
protocol-adapter |
client-fallback |
fixture: contract-conformance |
ogc-tiles |
render, tiles |
supported |
protocol-adapter |
native |
fixture: ogc-tiles-fixtures |
ogc-maps |
render |
supported |
protocol-adapter |
native |
fixture: ogc-maps-fixtures |
ogc-records |
query, queryObjectIds, stream |
supported |
protocol-adapter |
native |
fixture: ogc-records-fixtures |
stac |
query, queryObjectIds, stream |
supported |
protocol-adapter |
native |
fixture: stac-fixtures |
wfs |
query, queryExtent, queryObjectIds, applyEdits, stream |
supported |
protocol-adapter |
native |
fixture: wfs-fixtures |
wms |
render, tiles, query |
supported |
protocol-adapter |
native |
fixture: wms-fixtures |
wmts |
render, tiles |
supported |
protocol-adapter |
native |
fixture: wmts-fixtures |
odata |
query, queryObjectIds, stream, applyEdits |
supported |
protocol-adapter |
native |
fixture: odata-fixtures |
pmtiles |
tiles |
supported |
client-only |
native |
fixture: pmtiles-fixtures |
geoparquet |
query, queryAggregate, stream |
experimental |
client-only |
native |
fixture: geoparquet-fixtures |
maplibre-vector |
render |
supported |
client-only |
native |
fixture: maplibre-automatic-source-fixtures integration: maplibre-automatic-browser |
maplibre-vector |
tiles |
supported |
client-only |
native |
fixture: maplibre-automatic-source-fixtures |
maplibre-raster |
render |
supported |
client-only |
native |
fixture: maplibre-raster-source-fixtures integration: maplibre-automatic-browser |
maplibre-raster |
tiles |
supported |
client-only |
native |
fixture: maplibre-raster-source-fixtures |
MapLibre-native and PMTiles sources are included in the generated table even
though they do not flow through the Source.query path. GeoParquet is the
experimental DuckDB-WASM-backed Source from @honua/sdk-js/geoparquet;
envelope filters push down to ST_Intersects, while non-envelope filters reduce
to their bounding box and are reported through Result.degraded.
maplibre-vector and maplibre-raster are executable descriptor strategies
with release-gated renderer coverage. maplibre-geojson remains a reserved
protocol identifier with no positive default capability: the runtime can load
an inline MapLibre type: "geojson" source, but the automatic/app descriptor
path does not implement that protocol today.
spatialAggregate is an indexed analytics capability rather than the
field-statistics queryAggregate path. No protocol receives it by default. A
source may advertise it only after backend metadata confirms an indexed
aggregation implementation for that source.
Notes by protocol
gRPC FeatureService
Canonical transport shared across the SDKs and generated from the
geospatial-grpc FeatureService definitions. The default capability set is
query, queryAggregate, queryExtent, queryObjectIds, applyEdits, and
stream. In JS, gRPC-Web is selected on HonuaClient with
transport: "grpc-web"; it is not exposed as a Source.protocol("grpc")
adapter handle.
GeoServices Feature Service
First-class. Aggregations set outStatistics, groupByFieldsForStatistics,
and returnGeometry=false as top-level fields on the translated
QueryFeaturesRequest so both the REST serializer and the gRPC-Web
adapter pick them up (they are not stashed in extraParams, which the
gRPC path would silently drop). Pagination uses resultOffset /
resultRecordCount. Streaming wraps
HonuaFeatureLayer.queryFeaturesStream; the adapter derives pageSize
from Query.pagination.limit so source.stream({ pagination: { limit } })
yields pages of at most limit rows instead of the core helper's
default 2000. pbf is supported when the server returns f=pbf; the
contract accepts both encodings transparently.
GeoServices Map Service / Map Layer
Same query semantics as Feature Service for the layers it exposes
(including the root-level aggregation encoding and pagination.limit
→ pageSize bridge for stream()). render and tiles come from
the service-level export endpoints. applyEdits and attachments are
not supported because the Map Service endpoint is read-only —
Source.applyEdits() and Source.attachments.* throw
HonuaCapabilityNotSupportedError so a mixed-source app does not
silently drop the edits. Source.queryObjectIds() and
Source.queryRelated() round-trip through the same canonical envelopes
as the FeatureServer adapter.
GeoServices Image Service
The Image Service adapter wraps Honua Server's ImageServer endpoints
(see feature-server-matrix.md's sibling
image-server-matrix.md). Source.query() returns the raster catalog
as canonical features (one row per raster, footprint geometry on each).
Source.queryAll() drains pages from the catalog endpoint internally
(resultOffset / resultRecordCount) and uses a limit + 1 lookahead
row to stamp exceededTransferLimit: true when the cap is hit, mirroring
the FeatureServer / OGC queryAll semantics. Source.queryExtent() and
Source.queryObjectIds() reuse the same catalog endpoint with the
standard GeoServices shaping flags (returnExtentOnly, returnIdsOnly).
Tile URLs come from
Source.protocol("geoservices-image-service").tileUrl(level, row, col).
exportImage, identify, legend live on the same
HonuaImageService typed escape hatch — these protocol-specific
operations are not on the canonical Source because their request
shapes (mosaic rule, rendering rule, pixel size, raster function chains)
are ImageServer-specific. The wrapper accepts both GET (params on the
query string) and POST (form-encoded body) per request; POST is the
correct mode when payload size or proxy URL limits would truncate a
GET URL. The catalog endpoint does not honor Query.spatialFilter,
Query.orderBy, or Query.outFields, so the adapter rejects those
fields explicitly rather than silently widening the result; use
Query.where to constrain the catalog or move to a FeatureServer
source for richer query semantics. applyEdits, attachments,
queryRelated, queryAggregate, and stream are intentionally absent
from the default capability set; the canonical methods throw
HonuaCapabilityNotSupportedError rather than silently no-op.
GeoServices Geometry Service
Geometry Service is a stateless utility — it does not host features —
so the canonical query family throws on every method. The default
capabilities advertise only geometry. Operations
(buffer, simplify, project, intersect, union, clip,
difference) live behind
Source.protocol("geoservices-geometry-service") on a
HonuaGeometryService instance whose request shapes match the routes
in honua-server/docs/gis/geometry-service-matrix.md. The wrapper
targets the EndpointRegistry prefix
/rest/services/Utilities/Geometry/GeometryServer/<op> (the canonical
Esri Utilities path); POST requests submit form-encoded bodies (the
default), GET keeps params in the query string. Operations the server
does not implement (autoComplete, convexHull, cut, densify,
etc.) intentionally have no wrapper.
GeoServices GP Service
GP Services run async tasks rather than hosting features. The default
capabilities advertise only geoprocess; the canonical
query family throws. Task lifecycle — submitJob, jobStatus,
cancelJob, jobResult — lives behind
Source.protocol("geoservices-gp-service") on a
HonuaGeoprocessingService instance. The service id and task name come
from SourceLocator.serviceId / SourceLocator.taskName so a single
descriptor uniquely identifies a task without leaking task parameters
into the canonical descriptor shape. createDataset rejects descriptors
that advertise geoprocess without a locator.taskName because Honua
Server publishes the lifecycle routes only under
/rest/services/<serviceId>/GPServer/<taskName>/....
GeoServices GPServer also participates in the unified process runner:
client.geoprocessingRunner(serviceId, taskName) returns a
HonuaProcessRunner that submits GP parameters and exposes the same
IJobRun lifecycle as OGC API Processes and geospatial-grpc
ProcessService clients.
Geospatial gRPC Process Service
The open honua-io/geospatial-grpc protocol defines
geospatial.v1.ProcessService with ValidatePlan, DryRunPlan,
ExecutePlan, ExecutePlanStream, SubmitJob, GetJob,
GetJobResult, and CancelJob. The JS SDK keeps the adapter structural
because generated process proto lives outside this package today:
client.geospatialGrpcProcessRunner(processServiceClient) accepts a
generated Connect client and normalizes JobState values onto
IJobRun. JOB_STATE_COMPLETED maps to successful,
JOB_STATE_FAILED to failed, JOB_STATE_CANCELLED to dismissed,
and draft/clarification/validated/approval states map to accepted.
OGC API Tiles
Render-only adapter. tiles and render are the first-party capabilities; the
canonical Source.query* family throws HonuaCapabilityNotSupportedError
because the conformance class is tile-fetch, not feature-query. The
canonical tile path is /collections/{id}/tiles/{tms}/{z}/{y}/{x};
tile-matrix-set discovery uses /tileMatrixSets and /tileMatrixSets/{id}.
Styled-tile access (OGC /styles/{styleId}/tiles/...) is part of the
standard but is not exposed by honua-server today, so the SDK does not
synthesize that route.
Source.adapter("ogc-tiles") returns a HonuaOgcTileset bound to
(collectionId, tileMatrixSetId) when both are set in the locator; when
only collectionId is set, the adapter falls back to the root
HonuaOgcTiles handle so callers can discover which tile-matrix-sets
the server advertises before rebinding.
OGC API Maps
Render-only adapter. render is the first-party capability; same
escape-hatch model as Tiles. Source.adapter("ogc-maps") returns either
the dataset-level HonuaOgcMaps (when locator.collectionId is unset)
or a HonuaOgcCollectionMap bound to the descriptor's collection +
optional style. The wire path is
/maps[/collections/{id}][/styles/{styleId}]/map; bbox / crs / format
flow through query parameters. The format field is normalized to the
server's short-name token (png, jpeg, jpg, tiff, tif) before
it is written to f=; media-type aliases (image/png, etc.) are
accepted for ergonomics and translated. The public request envelope has
no filter field because honua-server's Maps request model has none.
OGC API Processes
No Source adapter — Processes is a job runner, not a queryable source.
HonuaClient.ogcProcesses().execute(...) returns the canonical
IJobRun<T> (the same interface every other long-running operation in
the SDK speaks). The implementation polls /jobs/{jobId} until
terminal, fetches /jobs/{jobId}/results on successful, and maps
failure terminals onto JobSnapshot.error: it prefers
statusInfo.exception (OGC Processes Part 1 vocabulary) and falls back
to statusInfo.message so honua-server's single-message failure text
still surfaces through HonuaJobFailedError.message. cancel() issues
DELETE /jobs/{jobId} and is idempotent only on the documented benign
paths: 404 (job gone) returns the cached status; 409 with problem-details
title "Cannot dismiss completed job" triggers a follow-up GET and
returns the authoritative terminal status — but only if the poll confirms
a terminal state, otherwise the original 409 is rethrown. The
non-benign 409 titles "Dismiss could not be confirmed" (backend
dismissal unconfirmed) and "Cancellation not supported" (backend
lacks dismissal capability) are rethrown verbatim. The canonical
processes capability is part of CAPABILITIES and is returned by
negotiateOgcCapabilities("ogc-processes", conformance); it is
intentionally absent from PROTOCOL_DEFAULT_CAPABILITIES because there
is no ogc-processes Source protocol.
OGC API Records
Metadata-catalog adapter. query, queryObjectIds, and stream are
first-party capabilities over /ogc/records/collections/{catalogId}/items.
The direct client.ogcRecords() surface exposes Records-specific query
parameters (q, type, externalIds, datetime, bbox, ids,
profile) and raw response access for HTML/JSON/profile negotiation.
The canonical Source.query path maps Query.where to CQL2 filter
and spatialFilter envelopes to Records bbox; aggregation, edits,
attachments, related records, and extent-only queries are not advertised.
Records describes metadata about resources and remains separate from STAC
asset search and Honua admin/control-plane metadata APIs.
STAC API
STAC piggy-backs on OGC API Features for items but adds a
cross-collection /search endpoint. The canonical Source.query uses
/search (GET by default; opt into POST with usePost: true). Both
the GET and POST paths serialize intersects (as JSON on GET, raw on
POST) and fields (as a CSV with - prefixes on GET, structured on
POST) so caller-supplied geometry constraints and selections are not
silently dropped. spatialFilter translates to STAC bbox only —
intersects geometry support requires CQL2 and is left to a downstream
extension. Paging follows the server's rel=next link: honua-server
emits ?offset=N on the href and the adapter parses that numeric
offset; non-Honua STAC servers that emit an opaque ?next=… token
remain supported as a fallback. Query.pagination.offset propagates
through to the STAC offset parameter on the initial request.
queryAggregate and queryExtent are not advertised. STAC's
collection-scoping is handled via locator.collectionId; the adapter
forwards it as the collections=[id] parameter on the wire.
OGC API Features
query, queryObjectIds, applyEdits, stream are first-party.
OGC has no batch edit endpoint, so applyEdits fans out to per-item
createItem / replaceItem / deleteItem calls and forwards
EditEnvelope.signal to every request — aborting the signal cancels
every operation that has not yet been issued, while operations already
in flight resolve into per-item failures on the returned EditResult.
queryAll() requests limit + 1 rows from itemsAll() when the caller
caps the result with Query.pagination.limit so the adapter can stamp
exceededTransferLimit: true when more records exist (mirroring the
GeoServices lookahead-row pattern). queryAggregate is degraded —
Source.query({ aggregation }) aggregates client-side over the returned
page, while Source.queryAggregate() drains every page first and then
aggregates. Both stamp a queryAggregate DegradedReason on the
Result so downstream views can flag the number as non-authoritative.
queryExtent is also degraded: an unfiltered queryExtent() with no
outSr returns the collection metadata's extent.spatial.bbox[0]
shortcut, while a filtered request (where or spatialFilter) — or
any request that sets outSr — drains itemsAll() and computes the
bbox client-side over matching features. The outSr carve-out exists
because the metadata bbox is frozen in the collection's native CRS
(typically CRS84) and the OGC /collections/{id} endpoint does not
accept a target CRS, so reusing the shortcut when the caller asked for
a different CRS would silently return the wrong coordinates.
queryExtent returns { extent, count? } and does not carry a
degraded array. Only spatialFilter.geometryType = "esriGeometryEnvelope" is translated (to the OGC bbox query param);
other geometry types would require CQL2, which the adapter does not
yet emit, so they throw rather than silently drop the constraint.
Likewise, only spatialRel values of esriSpatialRelIntersects or
esriSpatialRelEnvelopeIntersects are accepted — the OGC bbox
parameter is defined as an envelope-intersects predicate (OGC
17-069r4 §7.15.3), so contains/within/crosses/etc. throw rather
than silently widen to bbox semantics. queryRelated, attachments,
and pbf are out-of-scope for the OGC standard.
WFS
First-party WFS 2.0 adapter (wfsSource); see wfs.md for
the full reference. query / queryAll / stream
all route through GetFeature after a one-time GetCapabilities
negotiation; Query.where compiles to FES 2.0 (comparison, IN,
BETWEEN, LIKE, IS NULL, boolean combinators, parenthesization), and
Query.spatialFilter becomes either a KVP bbox= for envelope-only
requests or a <fes:Filter> for everything else (envelope, point,
polygon, polyline). Filters longer than ~7 KB switch to POST GetFeature
with the <fes:Filter> body. Anything richer than the supported subset
(subqueries, function calls, vendor extensions, curves / surfaces)
throws HonuaCapabilityNotSupportedError("query") rather than ship a
silent partial filter — callers reach the wire through
Source.protocol("wfs").
WFS propertyName= drops every property the caller does not list,
including the geometry column, so Query.outFields and
Query.returnGeometry are resolved together: an outFields list with
returnGeometry !== false appends the geometry property
(the_geom by default) before the projection lands on the wire so
geometry survives; returnGeometry === false paired with an
outFields list emits exactly the requested fields (no geometry); a
returnGeometry === false request without an outFields list throws
HonuaCapabilityNotSupportedError("query") because WFS cannot
suppress geometry without enumerating non-geometry properties.
queryExtent prefers the per-feature-type WGS84BoundingBox from
GetCapabilities for unfiltered requests so no extra HTTP traffic is
issued; filtered or outSr-bearing requests drain every matching
page (2000 features per page) and compute the bbox client-side,
ignoring caller pagination, Query.outFields, and
Query.returnGeometry so geometry is preserved on every drained
page and the returned extent covers the full matching set.
queryObjectIds has no interoperable server-side ids-only mode, so the
adapter drains the matching set in 2000-feature pages and projects each
GeoJSON id. The drain strips Query.outFields and
Query.returnGeometry (the GeoJSON id is read from each feature's
top-level field, so neither knob affects the result) so the request
cannot push the geometry property onto the wire and a caller-supplied
returnGeometry: false cannot trip the field-projection guard.
Query.pagination.limit caps the global id count (callers can stop
the drain without learning the server's page size) and
Query.pagination.offset chooses where the drain starts.
pagination.limit === 0 is treated as an explicit zero cap across
query, stream, and queryObjectIds (each short-circuits before
the wire call); queryAll still issues a single 1-row lookahead so
exceededTransferLimit can flip when more records exist — matching
the withPagingBounds / applyQueryAllLimit semantics shared with
GeoServices and OGC Features.
Content negotiation prefers application/geo+json /
application/json when the server's OperationsMetadata
advertises it; if only GML is offered the canonical query() throws
and callers reach the GML payload through Source.protocol("wfs"). GML
decoding is intentionally out of scope. applyEdits builds a single
<wfs:Transaction> POST body (<wfs:Insert> / <wfs:Update> /
<wfs:Delete>) and surfaces the per-handle InsertResults IDs onto
EditOutcome.id. Each <wfs:Insert> is stamped with a stable
handle="add-N" (1-based, matching envelope.adds order) and the
returned <wfs:Feature handle="…"> buckets are indexed by that
handle, so reordered or omitted (releaseAction="SOME" partial
failure) buckets never misassign IDs to the wrong envelope.adds[i];
inserts whose handle is missing from the response surface as
{ success: false }. The handle attribute is informational in WFS
2.0, so when no <wfs:Feature> carries one the adapter falls back
to the legacy positional pairing rather than dropping every id.
rollbackOnFailure drives the transaction releaseAction (ALL vs
SOME). Updates whose id is undefined / null are filtered out
before the transaction body is built and surface as per-item failures
({ success: false, error: { code: 400, description: "update.id is required" } }) so an unaddressed <wfs:Update> can never reach the
server; if every operation in the envelope is absent or malformed the
wire round-trip is skipped.
Stored-query discovery (ListStoredQueries) and execution
(GetFeature?storedquery_id=...) are reachable through
Source.protocol("wfs")!.root.storedQuery(id).execute({ parameters }).
Stored queries that advertise only GML (e.g. Honua Server's
urn:ogc:def:query:OGC-WFS::GetFeatureById) cannot be projected onto
the canonical Source.query() envelope; the canonical surface throws
HonuaCapabilityNotSupportedError("query") and points the caller at
the protocol escape hatch.
Locking (LockFeature / GetFeatureWithLock) is not exposed in the
canonical surface; callers that need it reach the wire through
Source.protocol("wfs").
The capabilities XML walker refuses any document declaring
<!DOCTYPE> or <!ENTITY> to defend against XXE-class attacks.
WFS Result.totalCount populates from the numberMatched GeoJSON
field; exceededTransferLimit is set when numberMatched > features.length.
WMS
First-party WMS 1.3.0 adapter. render and tiles come from GetMap;
query is supported through GetFeatureInfo with a point spatial
filter (Query.spatialFilter.geometryType === "esriGeometryPoint"). The
adapter constructs a 1×1 render envelope around the requested point,
asks for INFO_FORMAT=application/json, and decodes the JSON response
into the canonical Result<T> envelope. The wire CRS is derived from
the spatial filter geometry's spatialReference (latestWkid first,
then wkid, then wkt) and falls back to CRS:84 (the WMS 1.3.0
longitude/latitude code that preserves the canonical (x, y) axis
order). Query.outSr is intentionally not consulted on this path
because it is the output spatial reference, not the input CRS for
GetFeatureInfo. Non-point queries throw
HonuaCapabilityNotSupportedError("query", "wms", id) because WMS has
no spatial-rel semantics for envelopes / polygons; raw multi-pixel
GetFeatureInfo lives behind Source.protocol("wms").featureInfo().
Other canonical Query fields that GetFeatureInfo cannot honor are
rejected up front rather than silently dropped: query({ aggregation })
throws HonuaCapabilityNotSupportedError("queryAggregate", ...), and
Query.where / Query.outFields / Query.orderBy /
Query.pagination.offset / Query.returnGeometry === false /
Query.outSr throw typed Error messages so a mixed-source caller
cannot get an unfiltered, reprojected, or differently-shaped result.
Query.outSr fails fast because honua-server's WMS GetFeatureInfo
projects the response in the request CRS itself and exposes no
separate output-SR knob — callers that need a specific projection
must stamp the spatial filter geometry's spatialReference with the
desired CRS (the wire CRS is derived from there) or reproject the
result client-side. Query.pagination.limit is honored — it maps to
FEATURE_COUNT on the wire.
Source.protocol("wms-layer") is registered only when
locator.typeName parses to a single non-empty layer token because
HonuaWmsLayer is a single-layer handle (its describe() resolves
exactly one <Layer> from the parsed Capabilities). Multi-layer
composites (typeName: "a,b") keep Source.protocol("wms-layer")
unset and route through the service-level Source.protocol("wms")
handle, which can target the composite verbatim via
featureInfo() / map().
Styled-map selection enumerates per-layer styles from
HonuaWms.capabilities() and is bound on the layer handle (layer.map,
layer.featureInfo) via the style parameter or descriptor
locator.styleId. Dimension handling (TIME / ELEVATION) flows
through the typed WmsMapRequest envelope; defaults come from
Capabilities, request overrides go on the wire. honua-server does not
implement GetLegendGraphic today, so the adapter raises
HonuaCapabilityNotSupportedError from legend() when the parsed
Capabilities advertise no <GetLegendGraphic> request element. The
gating always runs: when the caller does not pre-supply
options.capabilities, the handle lazy-loads them once via
getWmsCapabilities and caches the in-flight promise on the instance
so repeat legend() calls reuse the same fetch (transient failures
clear the cache so the next call retries). The HonuaWms parser
extracts each <Layer>'s own <Name>, <Title>, <CRS>,
<BoundingBox>, <Style>, and <Dimension> from the layer's direct
children only — descendant <Layer> subtrees are stripped before
metadata reads so child fields never leak upward into the parent (or,
through ancestor-merge, sideways into sibling layers). CRS axis
order is honored per WMS 1.3 §6.7.3.2 — EPSG:4326 is swapped to
(lat, lon) on the wire while CRS:84 and EPSG:3857 keep canonical
(x, y) tuples. MapLibre integration ships through
buildWmsRasterSourceSpec(descriptor), which emits a raster source
spec with a pre-baked KVP tiles template using MapLibre's runtime
{bbox-epsg3857} / {width} / {height} placeholders.
WMTS
First-party WMTS 1.0.0 adapter. Render-only — Source.query() throws
because WMTS GetFeatureInfo is keyed on tile pixels (which doesn't fit
the canonical Query.spatialFilter). Capabilities expose advertised
TileMatrixSets through the typed surface; honua-server only advertises
WebMercatorQuad today and the parser-driven design tolerates additional
sets without a client refactor. Protocol escape hatches:
Source.protocol("wmts") returns the service handle,
Source.protocol("wmts-layer") a layer-bound handle, and
Source.protocol("wmts-tileset") a (layer × style × TMS) tileset handle.
fetchWmtsTile defaults to the RESTful route ({layer}/{style}/{tms}/{z}/{y}/{x}.{ext})
because it is a single string substitution per tile and skips
URLSearchParams; mode: "kvp" is opt-in for KVP-only proxies.
The Format MIME → RESTful .ext mapping is canonical and shared
between the wire client and the MapLibre helper via
wmtsExtensionForFormat (src/core/wms-types.ts): image/png →
png, image/jpeg / image/jpg → jpeg, image/webp → webp;
unknown formats fall back to png. The same caller-supplied format
therefore lands on the same path extension whether the URL is composed
by fetchWmtsTile or buildWmtsRasterSourceSpec.
request.extraParams is honored on both routing modes — under
mode: "kvp" keys are merged into the query string verbatim; under
the default RESTful route the path-encoded WMTS keys (LAYER,
STYLE, TILEMATRIXSET, TILEMATRIX, TILEROW, TILECOL,
FORMAT, INFOFORMAT, I, J, plus SERVICE / VERSION /
REQUEST) take precedence and any conflicting extraParams keys
(case-insensitive) are dropped so the same URL never carries the
value twice. buildWmtsRasterSourceSpec(descriptor) emits a
MapLibre raster source spec using the same RESTful path with
{z}/{y}/{x} placeholders.
OData
First-party adapter: query, queryObjectIds, stream, applyEdits.
Tabular query with $filter, $select, $orderby, $top, $skip,
$expand maps cleanly onto Query.where, outFields, orderBy,
pagination. Query.where accepts SQL-92 / OData $filter text;
the adapter rewrites a small intersection (IS NULL → eq null,
<> → ne, = → eq, plus the SQL comparison operators >= /
<= / > / < → ge / le / gt / lt) and rejects operators
the parity matrix documents as unsupported (has, in, any,
all, cast, isof) rather than letting them reach the wire.
The rewrite tokenizes single-quoted string literals (with ''
escape sequences preserved) so values like NAME = 'A=B' or
NAME = 'has' or NAME = 'A>B' round-trip unchanged — rewrites
and unsupported-operator detection only run against non-literal
spans.
Query.outFields lowers onto OData's $select for plain field
names and $expand for navigation paths. Plain entries
(["STATE", "ACRES"]) become $select=STATE,ACRES. Dotted entries
identify a navigation property and a sub-field — ["Owner.name"]
becomes $expand=Owner($select=name), multiple sub-fields under
the same navigation share one expand entry
($expand=Owner($select=name,email)), and multi-level dotted paths
nest as $expand=Owner($expand=address($select=street)). The same
outFields list can mix plain and dotted entries; each goes to the
matching query option so a server that distinguishes $select from
$expand honors both.
The descriptor locator resolves to the entity-set request path one of
two ways. SDK callers that pass locator.entitySet (e.g. "Parcels"
or the navigation path "Layers(1)/Features") win unchanged. Bindings
produced by Honua Server only carry url, serviceId, and layerId,
so when entitySet is absent the adapter derives the canonical
layer-scoped path Layers(<layerId>)/Features from locator.layerId.
A descriptor that has neither field is rejected with
createDataset: source "<id>" (odata) requires locator.entitySet or locator.layerId. The resolved token is the wire path used for entity
requests; CSDL metadata lookups (capabilities, key fields, schema)
key on the unqualified entity-set name instead — for navigation
paths that is the trailing segment, exposed as
HonuaOdataEntitySet.entitySetName so the lookup still hits the
<EntitySet Name="…"> entry the server emits.
Query.spatialFilter translates to a geo.intersects / geo.distance
predicate against the geometry column. Only esriSpatialRelIntersects
/ esriSpatialRelEnvelopeIntersects (intersects) and
esriSpatialRelDistance (distance, requires spatialFilter.distance)
are accepted; other relations throw rather than silently widen. The
WKT literal carries the input geometry's SRID (resolved from
spatialFilter.geometry.spatialReference.{wkid|latestWkid} first,
then the metadata-declared SRID on the selected geometry column —
matched by name when the descriptor or metadata pinned a column, never
borrowed across columns when an entity type declares more than one
spatial field); Query.outSr is a request for the output CRS and
is not stamped onto the input WKT (the OData server has no
request-side output-CRS knob — output geometry comes back in the
column's declared SRID). The geometry
column resolves from SourceDescriptor.schema.fields first
(prefers a field whose canonical type === "esriFieldTypeGeometry"),
otherwise from the lazy $metadata probe (the first property typed
Edm.Geography / Edm.Geometry per CSDL parsing). For spatial
filters the adapter throws when neither declares one rather than
guess at the column name; the same resolved name is reused by
returnGeometry === false $select trimming and the row-to-feature
geometry split, so a schema-declared spatial column named anything
other than Geometry / Geography / Shape is honored end-to-end.
Query.returnGeometry === false keeps the geometry column off the
wire: when outFields is set the resolved geometry column is
filtered out of $select; when outFields is unset the adapter
derives $select from $metadata to include only non-spatial
columns. The canonical name guesses (Geometry / Geography /
Shape) are kept as a fallback for the $select filter only when
neither the descriptor schema nor $metadata declares a spatial
column. As a defensive backstop, the canonical Result drops
geometry from each feature even if the server still emitted it.
Server-driven pagination ($skiptoken via @odata.nextLink) is
consumed transparently inside queryAll() and stream(). queryAll
honors Query.pagination.limit with the GeoServices/OGC limit + 1
lookahead pattern (sent on the wire as $top = limit + 1) so
exceededTransferLimit: true is stamped accurately when the cap is
hit. As a belt-and-braces signal, @odata.count > limited.length
also sets the flag for servers that respect $top exactly but report
a higher matched-row total. The lookahead is added by the canonical
Source.queryAll only — the lower-level HonuaOdataEntitySet.queryAll
treats params.top as an exact hard cap (including top === 0,
which collects zero rows) so callers that already added a lookahead
row do not double-count. queryObjectIds projects the metadata key
field through $select, drains the matching set, and slices the
resulting id list to Query.pagination.limit (no lookahead row is
needed because the result is ids, not features, and there is no
exceededTransferLimit flag to stamp on FeatureId[]); a
pagination.limit === 0 cap collapses to an empty array.
applyEdits routes adds → POST /<entitySet>, updates → PATCH /<entitySet>(<key>) with the full canonical body, deletes → DELETE /<entitySet>(<key>). PUT is not issued — Honua Server's OData v4
parity matrix lists PUT as unsupported (PATCH-only); the documented
PATCH with full body path matches PUT replacement semantics on the
canonical surface. Composite-key entities address rows by passing the
pre-formatted key expression on deletes: FeatureId[] (e.g.
"LayerId=1,ObjectId=3") or by populating each key field on
updates[].attributes. Layer-scoped paths
(Layers(<n>)/Features) carry the parent key (LayerId) in the URL
itself, so callers can — and should — pass a bare ObjectId on
feature.id or deletes: FeatureId[]; the adapter strips LayerId
from the entity-set key parens to produce
/odata/Layers(<n>)/Features(<objectId>) directly.
When EditEnvelope.rollbackOnFailure === true AND $metadata
advertises Capabilities.BatchSupported, the adapter collapses the
envelope into a single $batch request whose every operation carries
the same atomicityGroup token. Honua Server's ODataBatchHandler
groups requests by request.AtomicityGroup and rolls the entire
group back when any operation fails. The per-operation outcomes are
threaded back into the canonical EditOutcome buckets in the
original order. When $batch is not advertised, the adapter
degrades to per-call edits and stamps a degraded[] entry citing
applyEdits so downstream views can flag the result as non-atomic.
queryAggregate, queryExtent, queryRelated, and attachments are
intentionally absent from the canonical surface. Aggregation lives
behind Source.protocol("odata").apply(...) ($apply driver),
extent computation is a caller-side concern when the entity exposes
Edm.Geography, related navigation goes through $expand on the
escape-hatch query() (or protocol("odata").raw(...)), and
attachments are not in the parity matrix.
OData is the first adapter to implement automatic
metadata-driven capability intersection. The lazy $metadata fetch
on first method call parses Capabilities.* annotations and
intersects against the descriptor's declared Capabilities; declared
capabilities the service explicitly advertises as false raise
HonuaCapabilityNotSupportedError with both protocol and reason in
the message. The CSDL parser accepts both annotation shapes — inline
inside <EntitySet> and sibling <Annotations Target="<schema>.<container>/<entitySet>"> blocks (the form Honua
Server emits next to the EntityContainer) — and parses the OData
v4 capability vocabulary including Insertable / Updatable /
Deletable / Searchable / Filterable / Selectable /
Expandable / Countable plus the generic Supported property
records use for ChangeTracking. Other adapters (GeoServices
supportsStatistics, OGC conformsTo) follow the same pattern but
currently rely on caller-supplied caps; the contract doc tracks this
as the precedent for that future work.
Dialect-specific $batch, $apply, $search, and $deltatoken
operations live behind Source.protocol("odata") on a
HonuaOdataEntitySet instance:
metadata({ refresh? })— cached, SDK-shaped projection of$metadata(entitySets,keys,fields,capabilities).batch(operations, { atomicity })— JSON$batchenvelope. Passatomicity: "all"to stamp the sameatomicityGrouptoken on every request item so the server runs them as one change-set with rollback on any failure (per OData v4 §11.7.7.3 and Honua Server'sODataBatchHandler, which groups byrequest.AtomicityGroup). Default behavior ("none") leaves the field unset so each request runs independently.apply(transformations)—$applydriver (aggregate/groupby/filter/compute) returning{ rows, totalCount? }.search(text, params)—$searchdriver returning aHonuaOdataPage<T>.delta({ since? })— async-iterable$deltatokenchange feed; the final page carriesdeltaLinkfor the caller to persist.raw(method, path, init?)— last-resort passthrough that still flows throughHonuaClient.pipelineFetch(auth headers, retry, timeout, interceptors, telemetry, normalizedHonuaHttpError,init.signalpropagation for caller-driven cancellation).
All HTTP traffic — including $metadata discovery and raw()
passthrough — is routed through HonuaClient.pipelineFetch /
pipelineRequestJson rather than the GeoServices-shaped
HonuaClient.request() helper, so the OData wire request never
carries the f=json parameter (Honua Server's OData validators
reject it as InvalidQueryOption). Auth headers, interceptors,
retry, timeout, and normalized error mapping all apply to OData the
same way they do to every other adapter.
Library posture is recorded in
decisions/odata-library-selection.md.
PMTiles
Tiles-only. A PMTiles archive is a single immutable file (raster or vector
tile pyramid) on any static host or object storage. The default capability
set is { tiles }, so the entire canonical query family
(query / queryAll / queryExtent / queryObjectIds / applyEdits /
stream) throws HonuaCapabilityNotSupportedError — an archive has no
feature-query surface. Archive metadata (bounds, min/max zoom, and vector
layer names) is inspected through the typed escape hatch:
Source.protocol("pmtiles").describe() (or the standalone
describePmtilesArchive(url) helper), which returns a normalized
PmtilesArchiveDescription.
The pmtiles package is an optional peer dependency imported lazily — a
build that never inspects or renders a PMTiles archive pays no cost. The
MapLibre runtime auto-registers the pmtiles:// protocol on map attach
(loadMapPackage), so a pmtiles MapPackage source binding renders with no
manual addProtocol wiring; see pmtiles.md.
Multi-source negotiation
When more than one source participates in a composition, the
composition supports a capability only when every participating
source supports it. Use intersectCapabilities from
@honua/sdk-js/contract to compute that weakest set:
import {
PROTOCOL_DEFAULT_CAPABILITIES,
intersectCapabilities,
} from "@honua/sdk-js/contract";
const weakest = intersectCapabilities([
{ capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"] },
{ capabilities: PROTOCOL_DEFAULT_CAPABILITIES.wms },
]);
weakest.has("query"); // true
weakest.has("applyEdits"); // false — WMS lacks edits
The helper is structurally typed on { capabilities } so it accepts
both SourceDescriptor[] (declared capabilities) and live Source[]
(post-metadata-negotiation capabilities — the OData adapter, for
example, intersects the descriptor's declared set with $metadata
flags on first capability-gated method).
Promising a capability the weakest source lacks is the worst possible
mixed-source failure mode (silent wrong result). The full guide and
the per-operation partitioning pattern live in
composition.md.
Maintaining the matrix
When you add or remove a capability for any protocol:
- Update
PROTOCOL_DEFAULT_CAPABILITIESinsrc/contract/types.ts. - Update this table.
- Update the conformance scenario in
test/contract/so the canonical tests fail the right way for the new shape. - Per-source downgrades are the caller's responsibility today: pass an
intersected
Capabilitiesset onSourceDescriptor.capabilities. When automatic metadata-driven downgrades land in the adapter constructors, document the override in the adapter's source file and add a unit test covering the intersected set. - Adapters that emit
Result.degraded[]should populateDegradedReason.sourceIdwithdescriptor.idso mixed-source fan-outs can attribute the degradation back to the source that emitted it.