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.

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.limitpageSize 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/pngpng, image/jpeg / image/jpgjpeg, image/webpwebp; 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 NULLeq 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:

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:

  1. Update PROTOCOL_DEFAULT_CAPABILITIES in src/contract/types.ts.
  2. Update this table.
  3. Update the conformance scenario in test/contract/ so the canonical tests fail the right way for the new shape.
  4. Per-source downgrades are the caller's responsibility today: pass an intersected Capabilities set on SourceDescriptor.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.
  5. Adapters that emit Result.degraded[] should populate DegradedReason.sourceId with descriptor.id so mixed-source fan-outs can attribute the degradation back to the source that emitted it.