Shared Client Contract

Status: implemented in src/contract/ (ticket honua-sdk-js-23).

The shared contract is the protocol-neutral vocabulary every Honua data adapter speaks. It exists so cross-protocol code — exploration views, visual builders, and the server SourceBinding/MapPackage exporters — can be written once against Dataset / Source / Query / Result / MapBinding rather than re-litigating the surface in each ticket.

Goals (and non-goals)

Module layout

src/contract/
├── index.ts                 # barrel — re-exports types and source factories
├── spatial-aggregation.ts   # indexed aggregation request/response metadata
├── tiles.ts                 # dynamic query tile descriptors, cache keys, identity
├── types.ts                 # protocol, capability, source, dataset, query, result
└── source.ts                # createDataset + built-in adapters

Public entrypoint: @honua/sdk-js/contract (also re-exported from the top-level @honua/sdk-js and @honua/sdk-js/honua barrels).

Cross-SDK binding policy

This JS contract is also the draft semantic anchor for the Python and .NET SDKs. The SDKs should align behavior and vocabulary while keeping language-native names and casing. For example, the same operation may surface as queryAll() in TypeScript, query_all() in Python, and QueryAllAsync() in .NET.

The binding policy and cross-SDK fixture pack are documented in sdk-surface-alignment.md. The JSON fixture pack lives under test/fixtures/sdk-contract/; it is intentionally language-neutral so downstream SDKs can consume the same protocol, capability, result, unsupported-capability, and degraded-result scenarios.

Canonical nouns

Type What it is
Protocol One of twenty identifiers — shared canonical gRPC FeatureService transport (grpc), five GeoServices service types (geoservices-feature-service, geoservices-map-service, geoservices-image-service, geoservices-geometry-service, geoservices-gp-service), OGC API + STAC adapters (ogc-features, ogc-tiles, ogc-maps, ogc-records, stac), wfs, wms, wmts, odata, static-data adapters (pmtiles, geoparquet), plus three MapLibre-native sources (maplibre-vector, maplibre-raster, maplibre-geojson).
Capability A coarse-grained per-source operation capability (query, queryAggregate, spatialAggregate, queryExtent, queryObjectIds, queryRelated, applyEdits, attachments, render, tiles, sql, stream, pbf, image, geometry, geoprocess, processes). The canonical Source surface standardizes the query / edit / related / attachment / object-id subset today; spatialAggregate, image / geometry / geoprocess / processes are negotiated for indexed analytics, Source.protocol() escape hatches, and for the IJobRun-based OGC API Processes runner because their request shapes are too protocol-specific to belong on the unified query envelope. Top-level connect() is product discovery, not an operation on one Source, and is tracked as a discovery support claim instead.
Capabilities ReadonlySet<Capability>. Set membership = first-party protocol support, whether the caller consumes it through a canonical Source method or the typed protocol escape hatch. Under strict (default) a missing capability throws HonuaCapabilityNotSupportedError. Under degraded only call sites with a defined fallback proceed (today: OGC queryAggregate and queryExtent); every other missing capability still throws.
SourceLocator Protocol-specific endpoint info (url, serviceId, layerId, collectionId, tileMatrixSetId, styleId, typeName, entitySet, taskName). Field-compatible with the server SourceBinding.locator; tileMatrixSetId / styleId carry OGC API Tiles / Maps route hints for downstream SourceBinding work tracked in source-binding-alignment.md.
SourceDescriptor { id, protocol, locator, capabilities, schema?, attribution? }. The serializable identity of one source.
Source<T> Runtime handle. Methods: query, queryAll, queryAggregate, queryExtent, stream, queryObjectIds, applyEdits, queryRelated, attachments (namespace), protocol (typed escape hatch; adapter is the legacy alias).
Dataset Logical grouping of sources sharing identity. Methods: source(id), sourceIds(), isCompatible(), supportsFeature().
Query<T> The v1 compatibility envelope { where?, spatialFilter?, outFields?, orderBy?, pagination?, aggregation?, returnGeometry?, outSr?, signal? }; where is deprecated and source-native. New filters use the typed query-planner AST.
Result<T> { features, exceededTransferLimit, totalCount?, aggregateRows?, extent?, fields?, degraded? }.
SpatialAggregationRequest / SpatialAggregationResult Indexed spatial aggregation contract for large result sets. Requests carry where, spatialFilter, viewport, zoom/index-resolution hints, opaque index selection, summary specs (category, histogram, range, count, sum, avg, min, max), and optional groupBy. Results carry opaque indexed cells, grouped/totals summaries, backend index metadata, widget metadata, and progressive loading state.
EditEnvelope<T> { adds?, updates?, deletes?, rollbackOnFailure?, signal? }. Each add / update is a CanonicalFeature<T> (attributes + optional geometry + optional id).
EditResult { added, updated, deleted, degraded? } — one EditOutcome per requested operation.
EditWorkflowSession<T> Form/edit session over one Source: projects field metadata and domains, exposes relationship / attachment capability states, stages feature and attachment edits, runs optimistic hooks, and returns a normalized EditWorkflowSubmitResult.
RelatedQuery / RelatedResult<T> Canonical related-records request and response. Adapters that lack relationships (OGC, OData, ImageServer) throw rather than return empty groups.
AttachmentApi Namespace returned by Source.attachments. Methods: query, list, add, update, delete. Adapters that do not advertise attachments throw HonuaCapabilityNotSupportedError from each method so the namespace property is always present and capability negotiation stays uniform.
MapBinding { sourceId, layerIds, style?, minzoom?, maxzoom? }. Maps onto MapPackage.sourceBindings + MapPackage.mapSpec server-side. The @honua/sdk-js/runtime module consumes a full MapPackage on the client — see maplibre-runtime.md.
QueryTileSourceDescriptor Typed dynamic vector/query tile descriptor. It binds a canonical Source or source id to tile endpoint metadata, query/projection cache identity, fallback policy, and feature identity hooks. Runtime MapLibre helpers and the canonical /query-tiles server contract live in dynamic-query-tiles.md.

Dynamic query tile server contract

Dynamic query tiles have a server-side contract in addition to the client descriptor. The canonical route prefix is /query-tiles, with TileJSON, vector tile, and feature-detail routes under /query-tiles/sources/{sourceId}. The contract defines request parameters for filters, projection fields, output spatial reference, tile matrix set, extent, simplification tolerance, max features, cache partitioning, and cache busting.

@honua/sdk-js/contract exports route builders, parser helpers, response types, and the QUERY_TILE_SERVER_CONTRACT_VERSION constant. The reusable fixture lives at test/fixtures/sdk-contract/query-tile-server.v1.json and is intended for Honua server implementation repos to consume in their own conformance tests.

Capability negotiation

Two policies, declared at createDataset({ capabilityPolicy }):

The capability matrix lives in protocol-capability-matrix.md. Callers must pass the capability set they want enforced via SourceDescriptor.capabilities — per-source overrides (e.g. downgrading queryAggregate on a Feature Service whose metadata reports supportsStatistics: false) are the caller's responsibility for the GeoServices, OGC, STAC, WFS, and WMS adapters today. OData is the first adapter to implement automatic metadata-driven downgrades: the entity-set adapter lazily fetches $metadata on the first capability-gated method, parses Capabilities.* annotations, and intersects the declared capability set with what the server advertises. See protocol-capability-matrix.md under OData for the implementation details. Other adapters (GeoServices supportsStatistics, OGC conformsTo) follow the same pattern as follow-up work.

The registry is intentionally broader than the current Source method list so downstream adapter tickets can negotiate render / tiles / sql / queryObjectIds / spatialAggregate / etc. without inventing a second capability vocabulary. spatialAggregate has no default protocol support today; sources should advertise it only when source-specific metadata confirms an indexed aggregation backend. Apps must treat returned cell ids and index.model.id as opaque, so H3, Quadbin, or a provider-specific grid can drive the same widgets.

For multi-source compositions, use intersectCapabilities from @honua/sdk-js/contract to compute the weakest capability set across the participating sources before fanning a call out — the rule plus the partition-then-intersect pattern for per-operation reasoning lives in composition.md. Adapters that emit Result.degraded[] populate DegradedReason.sourceId from descriptor.id so a fan-out can attribute each degradation back to the exact source that triggered it.

Source factory

import { createDataset, type SourceDescriptor } from "@honua/sdk-js/contract";

const dataset = createDataset({
  id: "parcels",
  client,
  capabilityPolicy: "strict",
  sources: [
    {
      id: "parcels-fs",
      protocol: "geoservices-feature-service",
      locator: { url: "...", serviceId: "Parcels", layerId: 0 },
      capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"],
    },
  ] satisfies SourceDescriptor[],
});

const parcels = dataset.source("parcels-fs")!;
// Deprecated source-native compatibility text during semantic compiler adoption.
const result = await parcels.query({ where: "STATE = 'CA'", pagination: { limit: 100 } });

The built-in resolver handles geoservices-feature-service, geoservices-map-service, geoservices-image-service, geoservices-geometry-service, geoservices-gp-service, ogc-features, ogc-tiles, ogc-maps, stac, wfs, wms, wmts, and odata. MapLibre-native sources register through CreateDatasetOptions.resolveSource. OGC API Processes is a job runner rather than a queryable source — reach it through HonuaClient.ogcProcesses().execute(...) (returns the canonical IJobRun<T>) instead of Dataset.source().

The five GeoServices factories cover the surface published in honua-server/docs/gis/geoservices-rest-parity.md:

The OGC API and STAC factories cover docs/ogc-api.md:

The WMS / WMTS factories cover the OGC web-map services per docs/protocol-capability-matrix.md:

docs/wfs.md documents the WFS 2.0 factory in the same shape:

The OData factory wraps an OData v4 entity set behind the canonical surface:

Edit Workflow Sessions

createEditSession({ source, kind, feature, metadata, optimistic }) is the SDK-level workflow contract for form and editor surfaces. It is intentionally layered over Source.applyEdits, Source.queryRelated, and Source.attachments rather than replacing the protocol adapters.

The session resolves fields from source.descriptor.schema.fields plus caller-supplied domains, validates required / read-only / length / coded-value / range constraints before submit, and exposes capabilities() so unsupported applyEdits, attachments, and queryRelated states are explicit and testable. Relationship reads use session.queryRelated(...); attachment reads and staged add / update / delete mutations use the same source attachment namespace.

submit() sends the feature edit envelope first, then applies staged attachment mutations against the committed feature id when the backend returns one. Optimistic hooks run in this order: optimistic.apply before the remote edit, optimistic.commit on full success, and optimistic.rollback for failed or partial feature / attachment results. EditWorkflowSubmitResult.failures normalizes per-row errors from GeoServices, OGC Features, WFS, and OData into validation, conflict, capability, transport, server, or partial-failure. When a version / ETag / updated-at field is present (or supplied in metadata.conflict), conflict failures carry that information so hosts can present reload / merge / overwrite workflows without parsing protocol-specific error text.

createEditSketchWorkflow(...) wraps the same session contract with UI-independent sketch state. It exposes explicit support for point, line, polygon, rectangle, circle, and buffer tools; dirty state; undo / redo / discard; attachment staging; validation; submit; and an optional annotation persistence hook that runs only after a successful edit commit. Unsupported sketch, attachment, relationship, conflict, or annotation persistence capabilities remain visible in the returned snapshot instead of being hidden by component state.

Source.queryAll() and Source.stream() drain every page the server returns — the built-in adapters override the core helpers' 100-page default so a large queryAll() is not silently truncated. Callers who want a hard cap should paginate with Query.pagination (offset skips ahead; limit clips, and its meaning depends on the method):

Compatibility gating

Dataset.isCompatible() calls HonuaClient.checkCompatibility() once and caches the result. Dataset.supportsFeature(feature) proxies to HonuaClient.supportsFeature for fine-grained checks. Both reuse the existing compatibility-gate workflow — no new wire calls were introduced.

Protocol escape hatch

Source.protocol("geoservices-feature-service") returns the underlying HonuaFeatureLayer instance (or undefined for the wrong kind). The accessor name is protocol because it surfaces protocol-specific operations — raw where, raw outFields, GeoServices calculate / validateSQL / replica / queryBins / getEstimates — that the canonical Source surface intentionally does not expose. The adapter() method is preserved as a legacy alias for callers written against the original ticket-23 surface; it returns the same instance. The AdapterTypeMap interface uses TypeScript declaration merging so adapter tickets can plug in their own kind → instance type mapping without touching this file. grpc is a canonical protocol id for the shared FeatureService transport and fixture pack; it is consumed through the client gRPC-Web transport rather than a Source.protocol("grpc") adapter handle in this package.

declare module "@honua/sdk-js/contract" {
  interface AdapterTypeMap {
    "my-protocol": MyProtocolLayer;
  }
}

The shipped map covers geoservices-feature-serviceHonuaFeatureLayer, geoservices-map-serviceHonuaMapService, geoservices-map-layerHonuaMapLayer, geoservices-image-serviceHonuaImageService, geoservices-geometry-serviceHonuaGeometryService, geoservices-gp-serviceHonuaGeoprocessingService, ogc-featuresHonuaOgcFeatureCollection, ogc-tilesHonuaOgcTileset | HonuaOgcTiles, ogc-mapsHonuaOgcMaps | HonuaOgcCollectionMap, ogc-processesHonuaOgcProcesses, stacHonuaStacSearch, wmsHonuaWms, wms-layerHonuaWmsLayer, wmtsHonuaWmts, wmts-layerHonuaWmtsLayer, wmts-tilesetHonuaWmtsTileset, wfsHonuaWfsFeatureType, and odataHonuaOdataEntitySet. The WFS root handle (capabilities cache, stored-query discovery) is reachable through Source.protocol("wfs").root.

What downstream tickets must consume

  1. New protocol adapters must implement Source<T> and register either as a built-in case in buildBuiltInSource (the precedent followed by wmsSource / wmtsSource / wfsSource / odataSource) or through CreateDatasetOptions.resolveSource. They must declare their default capability set in PROTOCOL_DEFAULT_CAPABILITIES (this file owns that table — adapter PRs extend it).
  2. Visual builder, exploration, and server-export tickets must consume Dataset / Source / Query / Result / MapBinding rather than the per-class request shapes (QueryFeaturesRequest, etc.). Per-class shapes are still available via Source.adapter() for legacy paths.
  3. New error types must flow through HonuaError and isHonuaError. This ticket added HonuaCapabilityNotSupportedError and HonuaExplorationContextError. The first-party WMS / WMTS adapter ticket extended the union with HonuaWmsCapabilitiesParseError and HonuaWmtsCapabilitiesParseError so callers can classify XML parser failures through the same guard.

Async operations: IJobRun

Long-running server-side operations surface through the canonical IJobRun<T> interface in @honua/sdk-js/contract. OGC API Processes, GeoServices REST GPServer, and the open honua-io/geospatial-grpc ProcessService all normalize onto the same lifecycle vocabulary:

import type { IJobRun } from "@honua/sdk-js/contract";

const job: IJobRun = await client.ogcProcesses().execute({
  processId: "buffer",
  inputs: { feature: someGeoJson },
  mode: "async",
});

const unwatch = job.watch((snap) => {
  console.log(snap.status, snap.progress);
});
const { outputs } = await job.results();
unwatch();

For app code that should not know which protocol is behind a workflow, use HonuaProcessRunner:

const ogc = client.ogcProcessRunner();
const gp = client.geoprocessingRunner("Analysis", "OverlayFacilities");
const grpc = client.geospatialGrpcProcessRunner(processServiceClient);

await ogc.execute({ processId: "buffer", inputs });
await gp.execute({ inputs: gpParameters, resultNames: ["outputLayer"] });
await grpc.execute({ plan: executionPlan, context: executionContext });

createOgcProcessesAdapter, createGeoServicesGpAdapter, and createGeospatialGrpcProcessAdapter expose the same adapter contract for callers that construct protocol handles outside HonuaClient. The geospatial-grpc adapter is structural: it expects a generated ProcessService client with validatePlan, dryRunPlan, submitJob, getJob, getJobResult, and cancelJob, but this package does not vendor the honua-io/geospatial-grpc generated process proto directly.

IJobRun exposes id, type, status, progress, poll(), watch(), results(), and cancel(). The OGC API Processes 1.0 status vocabulary (accepted, running, successful, failed, dismissed) is canonical. GeoServices esriJobSubmitted / esriJobExecuting / esriJobSucceeded / esriJobFailed / esriJobCancelled and geospatial-grpc JobState values (JOB_STATE_RUNNING, JOB_STATE_COMPLETED, JOB_STATE_FAILED, JOB_STATE_CANCELLED, etc.) translate onto it. Failed OGC runs reject results() with HonuaJobFailedError, whose message is populated from the server's statusInfo.exception.message when present and falls back to statusInfo.message otherwise (to match honua-server's StatusInfo DTO, which exposes only message). cancel() is idempotent against the two documented benign paths: "job gone" (404) returns the cached status, and the terminal race (409 "Cannot dismiss completed job" from honua-server) triggers a follow-up GET and returns the authoritative terminal status — but only if the poll confirms a terminal state. honua-server also emits 409 for "Dismiss could not be confirmed" (backend dismissal unconfirmed) and "Cancellation not supported" (backend lacks cancel support); both rethrow as HonuaHttpError so callers can branch or retry instead of seeing a fabricated success. Submitted processes are typed as IJobRun<T>; HonuaOgcProcessJobRun is the implementation behind that interface and should not be the caller-facing contract.

OGC API Tiles / Maps / Records / Processes / STAC

The first-party OGC adapters live alongside HonuaOgcFeatures:

Conformance area Entry point Source protocol Contract capabilities
OGC API Features client.ogcFeatures() / HonuaOgcFeatures ogc-features query, queryObjectIds, applyEdits, stream
OGC API Tiles client.ogcTiles() / HonuaOgcTiles, HonuaOgcTileset ogc-tiles render, tiles (tileset-bound when locator includes tileMatrixSetId; root discovery handle otherwise)
OGC API Maps client.ogcMaps() / HonuaOgcMaps, HonuaOgcCollectionMap ogc-maps render
OGC API Records client.ogcRecords() / HonuaOgcRecords, HonuaOgcRecordCollection ogc-records query, queryObjectIds, stream
OGC API Processes client.ogcProcesses() / HonuaOgcProcesses (no source — job runner) processes from conformance negotiation, not PROTOCOL_DEFAULT_CAPABILITIES
STAC API client.stac() / HonuaStacSearch stac query, queryObjectIds, stream

OGC API Tiles and OGC API Maps are render-only — their Source.query* methods throw, and renderers reach the underlying class through Source.adapter("ogc-tiles") / Source.adapter("ogc-maps"). OGC API Records and STAC both flow through the canonical Source.query() path, but Records is metadata catalog search and STAC is asset/item search. OGC API Processes does not register as a Source because its inputs are not queryable; it produces IJobRun<T> from execute(...).

WMS / WMTS web-map services

The first-party OGC web-map adapters share the contract surface:

Service Entry point Source protocol Contract capabilities
WMS 1.3.0 client.wms(serviceId) / HonuaWms, HonuaWmsLayer wms render, tiles, query (point-only GetFeatureInfo)
WMTS 1.0.0 client.wmts(serviceId) / HonuaWmts, HonuaWmtsLayer, HonuaWmtsTileset wmts render, tiles

Source.protocol("wms") returns the service handle and Source.protocol("wms-layer") a layer-bound handle (when locator.typeName is set). WMTS exposes three handles — Source.protocol("wmts") (service), "wmts-layer" (layer-bound), and "wmts-tileset" (layer × style × tile-matrix-set bound). MapLibre integration ships through the runtime helpers buildWmsRasterSourceSpec(descriptor) / buildWmtsRasterSourceSpec(descriptor) from @honua/sdk-js/runtime — they emit a raster source spec without forcing callers to hand-assemble a GetMap URL or RESTful tile template. The style-spec resolver createSources(client, style) (from @honua/sdk-js) and HonuaMap.getSource(name) both produce the same HonuaWms / HonuaWmsLayer / HonuaWmts / HonuaWmtsTileset handles when a style declares a honua-wms / honua-wmts source type — the shared src/style/wms-wmts-resolvers.ts module owns the URL parsing and layer / tileset selection rules so the two surfaces never diverge. The WMS LAYERS= / locator.typeName / spec.layers value is parsed through the canonical parseWmsLayerNames helper (in src/core/wms.ts); the layer-bound HonuaWmsLayer handle is only returned for a single non-empty token, while multi-layer composites ("a,b") stay on the service-level HonuaWms handle. See docs/protocol-capability-matrix.md for axis-order, dimension, legend, and TileMatrixSet notes.

OGC conformance class identifiers are intentionally kept internal. negotiateOgcCapabilities(protocol, conformsTo) from @honua/sdk-js/honua translates a server-advertised conformsTo[] list into a canonical Capabilities set; downstream callers that want to gate on a specific extension (CQL2, transactions, etc.) use hasOgcConformanceClass(...) with a substring match. No OGC conformance class name appears as a primary SDK type, per the ticket constraint.

Test coverage

Conformance fixtures under test/contract/ exercise the canonical surface against mock adapters for each protocol. Adding a new protocol adapter means adding a fixture there; the parametrized scenarios run unchanged.