Deterministic query planner

@honua/sdk-js/query-planner is the first production slice of the execution planner described by the north-star application-kernel decision. It turns the current protocol-neutral Query plus an already-discovered SourceDescriptor into a versioned, serializable IR and an immutable explain plan. Explaining is synchronous and side-effect free: it does not fetch metadata or rows, mutate a renderer, or execute the query.

The complete subpath is experimental while the remaining compiler and columnar slices land. The stable root promotes only the reviewed plan/execution subset used by the managed connection and MapLibre source workflows.

The stable Source.query() surface accepts a typed filter directly: Query.filter and Query.temporalFilter are lowered onto every protocol by the canonical compiler in src/contract/query-filter.ts (issue #947). Query.where remains deprecated source-native compatibility, and examples later in this guide that use it document the v1 compatibility compiler rather than the new-code filtering idiom.

The compilers in this subpath are a second, deliberately different lane, not a duplicate of that one:

canonical (Source.query()) this subpath
Schema none at query time; literals typed by the value written verifies a SourceSchemaV2, maps logical fields to physical paths
Evidence per-source capability profile narrows the operator/predicate allow list layer/conformance/FilterCapabilities evidence gates every node
CRS geometry passed through as the canonical spatial value executable CRS binding with axis-order verification
Failure throws HonuaCapabilityNotSupportedError naming the construct returns a structured unsupported result with a stable code and path
Identity participates in hashQueryPlan through the canonical query additionally fingerprints the query, request, schema, and capability evidence

Unifying them would mean giving up either the schema verification and structured fidelity reporting on this side, or the schema-free stability on the other. They are instead kept honest by an equivalence ledger, test/query-filter-lane-equivalence.test.ts, which compiles the same filter through both lanes and pins every row as identical or as a documented difference with its reason. The known differences today are identifier quoting, timestamp literal formatting for schema-typed date columns, case-insensitive LIKE (the canonical lane emits the portable UPPER() rewrite; this lane declines to assume collation), and the OGC during topology predicate on an instant-valued column.

Managed connection workflow

The reviewed planner subset is also wired into the lifecycle-owned root connection. This is the shortest feature-result workflow for applications that do not need to compose planner and executor options themselves:

import { createHonua } from "@honua/sdk-js";

const honua = createHonua();
const connection = await honua.connect({
  url: "https://example.test/ogc/features",
  protocol: "ogc-features",
  sourceId: "incidents",
});
const plan = await connection.explain({
  pagination: { limit: 100 },
});
const result = await connection.query(plan);

console.log(result.execution.plan.fingerprint, result.features.length);
await honua.dispose();

explain() returns the canonical serializable QueryExecutionPlanV1 rather than a facade-specific plan. query(query) plans once and executes through the same executor as query(plan). The connection binds its logical identity, selected source, schema, discovered capability evidence, authorization-scope digest, and policy into that plan. Accepted plans are integrity-checked and must still match the current connection observation; mutation, a foreign connection or scope, and refreshed schema/capability evidence fail rather than silently re-planning.

The returned feature result adds a credential-free terminal receipt containing plan identity, timings, hash-only provenance, discovery observation, structured diagnostics, and completion counts. Cancellation is combined with the kernel lifecycle and propagated through remote paging and bounded local fallback. Columnar and realtime plans are deliberately rejected here until their explicit engines own a corresponding result and cancellation contract.

Discovery metadata caching and query-result caching are separate truths. The managed connection has no implicit query-result cache, so its default plan cache decision is bypass even when discovery metadata was a cache hit or was revalidated. Callers may supply an explicit query-cache observation through the planning options. Discovery cacheStatus and retrievedAt stay in the execution receipt as observation evidence; clock-only refreshes do not change semantic plan identity, while descriptor, source, validator, capability evidence, or capability-policy changes do.

Typed semantic query AST

The experimental planner subpath also exposes the first typed semantic-query surface. It is additive: existing v1 plans and protocol compilers continue to consume the compatibility Query.where path until the compiler migration lands. New code can build immutable property/literal, comparison, boolean, null, list, range, pattern, spatial, and temporal nodes without choosing a wire dialect:

import { createSemanticQueryBuilder, defineSemanticQuery } from "@honua/sdk-js/query-planner";

interface Incident {
  id: number;
  status: string;
  score: number;
}

const q = createSemanticQueryBuilder<Incident, "ogc-features", "non-spatial">();
const semanticQuery = defineSemanticQuery(
  q.features({
    select: ["id", "status"] as const,
    filter: q.and(
      q.comparison("eq", q.property("status"), q.literal("active")),
      q.between(q.property("score"), 50, 100),
    ),
    sort: [{ field: "score", direction: "desc" }],
    page: { kind: "first", limit: 100 },
  }),
);

console.log(semanticQuery.filter);

parseSemanticQuery(untrusted, { schema, protocol }) is the JavaScript and JSON boundary. It reparses the supplied SourceSchemaV2, validates field existence, operator/type compatibility, closed domains, declared ranges, geometry and temporal eligibility, safe length and multiple-of constraints, projection/sort/group/metric fields, native payload form, and native dialect compatibility. Literal type admission reuses the canonical SourceSchemaV2 value semantics, including binary encodings, numeric precision, and temporal precision. Source-provided ECMA-262 field patterns remain metadata and are not executed at this untrusted boundary. Parsed queries are deeply frozen, with equivalent omitted defaults (case-sensitive patterns and native null ordering) normalized before hashing or interchange. Parsing is bounded by byte, node, depth, collection, and text limits; cyclic values, accessors, non-JSON prototypes, unknown members, blank identifiers or native text/XML payloads, invalid protocol options, and non-finite numbers fail closed.

Protocol-native filters remain explicit and dialect tagged. For example, an OGC source may carry cql2-json or cql2-text; it cannot carry geoservices-sql92. A native expression is an escape hatch, not a claim that the expression is protocol neutral.

The semantic surface is now compiled directly by the OGC API Features, WFS, DuckDB, and Honua gRPC compiler boundaries described below. Integration into the complete explain-plan executor remains a separate planner/facade concern.

Canonical bytes and query identity

canonicalSemanticQueryBytes() and hashSemanticQuery() first run the same bounded runtime/schema validation, then encode a versioned envelope with sorted object keys. Array order remains significant. The identity envelope always contains schema fingerprint, protocol, CRS-registry version, and policy version slots; unavailable values are explicit null, not omitted. Cancellation, realtime cursors, observation timestamps, and other volatile execution state are not members of the semantic AST.

import {
  canonicalSemanticQueryBytes,
  createSemanticQueryBuilder,
  hashSemanticQuery,
} from "@honua/sdk-js/query-planner";

interface Parcel {
  id: number;
  status: string;
}

const query = createSemanticQueryBuilder<Parcel, "ogc-features", "non-spatial">();
const request = query.features({
  select: ["id"] as const,
  filter: query.comparison("eq", query.property("status"), "active"),
});
const identity = {
  protocol: "ogc-features" as const,
  crsVersion: "epsg-db:2026.1",
  policyVersion: "query-policy:7",
};

console.log(canonicalSemanticQueryBytes(request, identity).byteLength);
console.log(hashSemanticQuery(request, identity));

Equivalent validated inputs produce identical UTF-8 bytes and a domain-separated SHA-256 hash. Changing schema, CRS, policy, protocol, field order, sort precedence, or another semantic member changes identity.

CQL2 JSON interchange

semanticFilterToCql2Json() and semanticFilterFromCql2Json() implement a strict, lossless supported subset of the OGC CQL2 JSON encoding. Import uses the same byte/node/depth/collection bounds and duplicate-name rejection as parseSemanticQuery; imported filters are runtime validated and deeply frozen.

import {
  createSemanticQueryBuilder,
  semanticFilterFromCql2Json,
  semanticFilterToCql2Json,
} from "@honua/sdk-js/query-planner";

interface Road {
  roadClass: string;
}

const query = createSemanticQueryBuilder<Road, "ogc-features", "non-spatial">();
const filter = query.like(query.property("roadClass"), "primary%", {
  caseSensitive: false,
});
const cql2 = semanticFilterToCql2Json(filter, { protocol: "ogc-features" });
const restored = semanticFilterFromCql2Json(cql2, { protocol: "ogc-features" });

console.log(cql2, restored);

The supported subset includes property/literal comparisons, and/or/not, null tests, lists, numeric ranges, case-sensitive and casei patterns, standard topological/non-wrapping-bbox predicates, and exact temporal predicates. CQL2 temporal timestamp literals and interval endpoints must use a UTC Z instant; offset timestamps fail closed rather than being normalized. Semantic during also fails closed: its property operand is instant-valued, while CQL2 T_DURING requires an interval-valued first operand. CQL2 carries spatial CRS outside its JSON expression, so spatial import/export requires an explicit executable filterCrs binding and verifies every operand against it. JSON-number-encoded decimal fields preserve their supported CQL2 scalar representation; string-encoded high-precision numbers remain unsupported. Compiler literals are limited to exact CQL2 scalar semantics: string, number, boolean, date, and timestamp. Logical binary, time, duration, JSON, collection, and structural fields are not re-labelled as CQL2 strings. Both import and export enforce the normative CQL2 JSON schema's two-member minimum for GeometryCollection. Distance extensions, native expressions, property-property comparisons, arithmetic/custom functions, measured geometry layouts, and wrapping bounding boxes fail closed rather than being weakened.

OGC API Features and WFS 2.0 semantic compilers

compileSemanticOgcApiFeaturesQuery() compiles the validated AST to canonical CQL2 JSON or escaped CQL2 text. It does not infer filtering support from the protocol name: the caller supplies the concrete collection's discovered /conformance values, and the compiler requires the exact Features Part 3, basic CQL2, encoding, and optional operator conformance classes used by the query. When both encodings are advertised, JSON is the deterministic default; an explicit preference is accepted only when that encoding was discovered. OGC API Features Core standardizes limit, but not a properties projection, sortby, or a caller-supplied numeric offset. The standards-strict semantic compiler therefore rejects select, sort, and offset paging at $.select, $.sort, and $.page.offset; it never emits those extension-shaped parameters without a future, explicit extension-evidence contract. First-page limit remains portable and is preserved.

compileSemanticWfsQuery() compiles the same semantic nodes to namespace-safe FES 2.0 XML. Its evidence is a normalized projection of the concrete WFS capabilities document: ad hoc query and sorting constraints, logical-operator presence, comparison/spatial/temporal operators and operands, and filter/output CRS identifiers. Every generated operator and GML operand must be advertised. Missing evidence returns an unsupported result at the exact query path; it never becomes an empty filter, TRUE, an envelope approximation, or a dropped relationship. Because FES capabilities can advertise operands per operator, the flat geometryOperands and temporalOperands evidence arrays are conservative, globally applicable safe intersections: an operand belongs in an array only when discovery proved it usable for every compiled operator that may consume that array. Flattening operator-local unions into these arrays is unsafe. WFS KVP sorting uses the normative ASC and DESC tokens. Singleton semantic and/or nodes compile directly to their child, so they do not require or emit a logical wrapper. WFS During remains supported for its instant-property and period-literal relationship. time-intersects remains unsupported for WFS: FES AnyInteracts requires a period-valued property, while the semantic temporal operand contract identifies an instant-valued date or timestamp field.

import type { SourceSchemaV2 } from "@honua/sdk-js/source-schema";
import {
  compileSemanticWfsQuery,
  createSemanticQueryBuilder,
} from "@honua/sdk-js/query-planner";

interface Parcel {
  id: number;
  status: string;
}

declare const schema: SourceSchemaV2;

const q = createSemanticQueryBuilder<Parcel, "wfs", "non-spatial">();
const query = q.features({
  select: ["id", "status"] as const,
  geometry: "omit",
  filter: q.and(
    q.comparison("eq", q.property("status"), "active"),
    q.isNull(q.property("id"), "is-not-null"),
  ),
});
const compilation = compileSemanticWfsQuery({
  query,
  schema,
  source: {
    typeName: "cad:Parcel",
    namespaces: { cad: "https://example.test/cadastre" },
  },
  capabilities: {
    version: "2.0.0",
    implementsAdHocQuery: true,
    implementsSorting: false,
    logicalOperators: true,
    comparisonOperators: ["PropertyIsEqualTo", "PropertyIsNull"],
    geometryOperands: [],
    spatialOperators: [],
    temporalOperands: [],
    temporalOperators: [],
    supportedFilterCrs: [],
    supportedOutputCrs: [],
  },
});

if (compilation.outcome === "compiled") {
  console.log(compilation.artifact.filter);
} else {
  console.warn(compilation.diagnostics[0]);
}

CQL2 identifiers and WFS ValueReference paths come only from the verified SourceSchemaV2 native mapping. CQL2 text rejects identifiers outside its normative grammar. FES accepts only a conservative QName/path subset, requires every prefix to have a caller-supplied namespace URI, and reserves the FES, GML, WFS, XML Schema, and XML prefixes. Literals remain structural values in CQL2 JSON, are escaped with the CQL2 control/quote rules in text, and use typed, XML-escaped fes:Literal elements in FES. GML geometry and temporal literals receive deterministic IDs.

Spatial values carry executable CRS bindings. The compilers reorder payload coordinates into known CRS-definition axis order only when axis directions and units match losslessly and stamp the resulting CRS URI. For OGC API Features, every non-default output crs and filter-crs additionally requires the explicit Part 2 CRS conformance class and the exact URI in the collection's discovered CRS metadata. An explicit exact-default output CRS is satisfied by omitting crs; it does not invent a Part 2 requirement. The only default identities that can be omitted are http://www.opengis.net/def/crs/OGC/1.3/CRS84 for two-dimensional coordinates and http://www.opengis.net/def/crs/OGC/0/CRS84h for three-dimensional coordinates; suffix matches, alternate ports, and lookalike hosts are not defaults. Unknown axes, coordinate epochs, required unit or datum transforms, mixed CQL2 filter CRS, and measured geometry or bounding-box layouts fail closed. No compiler relabels coordinates or treats a measure as a third spatial ordinate.

Artifacts bind canonical query identity to schema and normalized capability evidence with separate SHA-256 fingerprints, then fingerprint the complete wire-request preimage. Reordering capability arrays or namespace object keys does not change identity. Protocol-native escape hatches remain terminal and dialect matched: OGC accepts only advertised cql2-json/cql2-text, while WFS accepts only fes-2.0 XML. usesNativeFilter makes that trust-boundary choice visible to policy and diagnostics.

Deprecated raw where compatibility

The v1 Query.where member remains operational but is deprecated and explicitly source-native. legacyWhereToNativeFilter() is the migration bridge for text whose dialect is losslessly known: GeoServices SQL-92, CQL2 text, OData 4.0, or DuckDB SQL. WFS's parsed legacy grammar and Honua gRPC's JSON dialect are not mislabelled as raw text.

import { legacyWhereToNativeFilter } from "@honua/sdk-js/query-planner";

const compatibilityFilter = legacyWhereToNativeFilter(
  "geoservices-feature-service",
  "STATUS = 'OPEN'",
);

console.log(compatibilityFilter.dialect); // geoservices-sql92

Existing v1 planning still serializes where as { kind: "source-native", expression }, preserving compatibility while consumers migrate to semantic compiler artifacts. New code should use typed builders instead of the bridge.

Semantic protocol compilers

compileSemanticGeoServicesQuery(), compileSemanticOdataQuery(), compileSemanticDuckDbQuery(), and compileSemanticGrpcQuery() are pure compiler boundaries for the validated semantic AST. They return a discriminated result: compiled contains a frozen artifact and its fidelity; unsupported contains a stable code, exact query path, and bounded diagnostic. Malformed queries, schemas, resource handles, or source identities still throw HonuaQueryPlanningError before an artifact is created.

import type { SourceSchemaV2 } from "@honua/sdk-js/source-schema";
import {
  compileSemanticDuckDbQuery,
  createGeoParquetResourceHandle,
  createSemanticQueryBuilder,
} from "@honua/sdk-js/query-planner";

interface Incident {
  id: number;
  status: string;
}

declare const schema: SourceSchemaV2;

const q = createSemanticQueryBuilder<Incident, "geoparquet", "non-spatial">();
const query = q.features({
  select: ["id", "status"] as const,
  geometry: "omit",
  filter: q.comparison("eq", q.property("status"), "open"),
});
const resource = createGeoParquetResourceHandle({
  resolver: "io.honua.application",
  id: "incidents",
  authorizationContextId: "tenant:alpha",
  resourceVersion: "snapshot:7",
});
const compilation = compileSemanticDuckDbQuery({ query, schema, resource });

if (compilation.outcome === "compiled") {
  console.log(compilation.fidelity, compilation.artifact.sqlTemplate);
} else {
  console.warn(compilation.diagnostics[0]);
}

GeoServices artifacts target standardized SQL-92 and the layer query request vocabulary. Every public semantic field is resolved to its exact physical SourceSchemaV2.path; SQL identifiers are delimited, string literals use quote doubling, finite numbers never use exponent notation, and UTC temporal values become typed SQL literals only with exact layer field-type evidence. esriFieldTypeDate uses timezone-aware schema metadata and second-precision TIMESTAMP; esriFieldTypeDateOnly, esriFieldTypeTimeOnly, and esriFieldTypeTimestampOffset use their standardized DATE, TIME, and offset-bearing TIMESTAMP forms. Precision that the native type cannot carry fails closed. An unfiltered query omits where instead of inventing 1=1. Raw REST field parameters (outFields, ordering, grouping, and statistic operands) additionally require one delimiter-free Unicode identifier; a physical name that is safe only when SQL-delimited may be used in where, but never leaks into a comma-delimited request field. Geometry output emits explicit returnZ/returnM flags for xy, xyz, xym, and xyzm; unknown layout metadata fails closed. outSr is accepted only when it is identical to the source geometry CRS because this artifact has no explicit datum-transformation evidence. Spatial predicates compile to the separate geometry request parameters only when the schema identifies the target geometry property and executable CRS/layout, and the caller supplies the relationship advertised by the layer's supportedSpatialRelationships metadata. A spatial predicate inside OR or NOT, multiple spatial predicates, an implicit CRS transform, or an unadvertised relationship fails closed. orderByFields requires an explicit supportsAdvancedQueries: true; feature paging requires supportsPagination: true; statistics require supportsStatistics: true; and paged aggregate requests additionally require supportsPaginationOnAggregatedQueries: true. Missing or false metadata never becomes inferred support. See the GeoServices query request reference.

OData artifacts target the v4.0 URL conventions. Physical property paths retain their exact /-separated Unicode segments, so a public city field may map to Address/City without exposing that physical spelling in the semantic AST. String, decimal, integer, float, boolean, UUID, date/time, duration, null, list, range, and temporal predicates have deterministic v4 literal forms. in is lowered to an exact parenthesized equality disjunction because OData v4.0 has no portable in operator. Only LIKE shapes exactly expressible as startswith, endswith, or contains compile; _, interior %, case folding, and always-true wildcard patterns are rejected. See the OData v4 URL conventions.

import type { SourceSchemaV2 } from "@honua/sdk-js/source-schema";
import {
  compileSemanticOdataQuery,
  createSemanticQueryBuilder,
} from "@honua/sdk-js/query-planner";

interface Place {
  id: number;
  city: string;
}

declare const schema: SourceSchemaV2;

const odata = createSemanticQueryBuilder<Place, "odata", "non-spatial">();
const query = odata.features({
  select: ["id", "city"] as const,
  geometry: "omit",
  filter: odata.comparison("eq", odata.property("city"), "Honolulu"),
});
const compilation = compileSemanticOdataQuery({
  query,
  schema,
  source: { entitySet: "Places", sourceVersion: "metadata:42" },
});

if (compilation.outcome === "compiled") {
  console.log(compilation.artifact.filter, compilation.artifact.requestFingerprint);
}

OData spatial compilation additionally requires one exact, whitelisted OData v4 Edm.Geography*/Edm.Geometry* primitive type, executable EPSG CRS and xy layout metadata for the property, and explicit source evidence for geo.intersects. Filtering admits only the normative signatures geo.intersects(Edm.GeographyPoint, Edm.GeographyPolygon) and its Edm.Geometry equivalent. A Point property therefore precedes a Polygon literal; a Polygon property is emitted second after a Point literal. Bounding boxes are Polygon literals and can target only exact Point properties. Generic, line, multi, collection, mixed, or unknown filter-property kinds fail closed, while geometry output may still use the broader whitelisted spatial types. Geography requires angular CRS axes, Geometry requires linear CRS axes, and the literal CRS must match the property CRS byte-for-byte at the executable binding level; the compiler never inserts a transform. Other topological predicates and distance modes return path-addressed unsupported diagnostics. Portable $apply aggregation remains outside this compiler slice rather than approximating aggregate semantics.

Both request artifacts include schemaFingerprint, queryFingerprint, and a domain-separated requestFingerprint. The latter incorporates the exact request pre-image and an explicit null/version slot for source metadata, so cache and approval identities change when either semantics or source evidence changes. fieldMappings exposes the complete logical-to-physical trace used by the request in Unicode-scalar order, independent of host locale or ICU data. Protocol-native filters are accepted only with the matching geoservices-sql92 or odata-4.0 tag, must be bounded nonblank text without control characters, and set usesNativeFilter: true.

DuckDB artifacts contain a fixed honua-resource://resolve-at-execution placeholder, the opaque #587 resource handle, parameterized SQL, and ordered bind values. Raw locators and credentials never enter the SQL template. The outputGeometry contract accompanies every geometry-returning projection, even when no spatial predicate exists. It records the logical source field, physical source encoding, GeoJSON result field/encoding, executable CRS, and coordinate layout. The separate spatial array describes predicates only. Measured or unknown layouts fail closed because GeoJSON output cannot prove their semantics exact. Exact geometry predicates use DuckDB Spatial functions. A GeoParquet bbox-covering column may prefilter an exact bbox query, but the exact geometry predicate remains present to prevent false positives.

Reducing an arbitrary intersects operand to its envelope is available only with spatialStrategy: "bbox-envelope". That artifact has fidelity: "approximate", a spatial-envelope-reduction loss, and a spatial entry whose strategy is bbox-envelope; it is never presented as exact.

Honua gRPC artifacts are protobuf-runtime-free canonical descriptions of honua.v1.FeatureService/QueryFeatures. Projection, filter, sort, group, and statistic operands use public SourceSchemaV2 field names; physical storage paths never enter the request. Generated where text is limited to the server's executable grammar: ASCII public identifiers, simple comparisons or IS [NOT] NULL, and top-level AND. Nested safe AND nodes are flattened, and inclusive range/temporal intervals become paired comparisons without adding parentheses. String literals are SQL-quoted as data; numeric literals must fit the server's non-exponent decimal grammar and decimal range.

A request shape that cannot preserve the AST—such as OR, NOT, IN, case-insensitive LIKE, a fieldless statistic, a bbox without an envelope message, a coordinate epoch, or an unrepresentable CRS—returns an unsupported diagnostic instead of emitting a request the server parser will reject, manufacturing a polygon, dropping a node, or inserting a transform. The final where string also honors the server's 4,000-character and control-character admission limits.

Protocol-native filters remain explicit trust-boundary escape hatches. A DuckDB native expression must be tagged duckdb-sql; a Honua request fragment must be tagged honua-grpc, contain exactly one where member, and pass the same server grammar, public-field, length, and control-character checks before an artifact is created. Artifacts expose usesNativeFilter so policy and inspection code can distinguish native code from compiler-escaped semantic values.

None of the semantic compilers imports DuckDB, @bufbuild/protobuf, or Connect runtimes. Execution and integration into the complete explain plan belong to the planner/facade tranche (#530); these functions only produce deterministic, credential-free artifacts.

Remote pushdown

The remote compilers target existing GeoServices FeatureServer, OGC API Features /items, WFS 2.0 GetFeature, OData v4 entity-set query, DuckDB SQL over GeoParquet, and Honua gRPC FeatureService/QueryFeatures paths. The compiled request is included in the plan so diagnostics, CLIs, agents, and renderers can inspect the same decision before execution.

import { PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract";
import { executeQueryPlan, explainQuery } from "@honua/sdk-js/query-planner";

const descriptor = {
  id: "incidents",
  protocol: "geoservices-feature-service",
  locator: { url: "https://demo.honua.io", serviceId: "incidents", layerId: 0 },
  capabilities: PROTOCOL_DEFAULT_CAPABILITIES["geoservices-feature-service"],
} as const;

const plan = explainQuery({
  descriptor,
  sourceVersion: "2026-07-10",
  authorizationScope: ["data:read"],
  cache: {
    policy: "require-fresh",
    freshness: "stale",
    validator: { kind: "etag", value: currentEtag },
  },
  discovery: { state: "metadata", source: "catalog:incidents" },
  query: {
    where: "status = 'open'",
    aggregation: {
      groupBy: ["severity"],
      metrics: [{ fn: "count", field: "OBJECTID", alias: "incidents" }],
    },
  },
});

console.log(plan.fingerprint, plan.steps[0]?.compiled);
console.log(plan.bounds, plan.cache, plan.fidelity, plan.provenance);

// `source` is the matching Source from Dataset.source(...). Version and scope
// are repeated so execution can reject a stale or differently-authorized plan.
const execution = await executeQueryPlan(plan, source, {
  sourceVersion: "2026-07-10",
  authorizationScope: ["data:read"],
  discovery: { state: "metadata", source: "catalog:incidents" },
});
console.log(execution.result.aggregateRows);

For an OGC API Features source, the same explainQuery() call selects ogc-api-features-query-v1 from the descriptor protocol. Source-native Query.where is identified as CQL2 text; projection, sorting, pagination, CRS, and envelope-intersects filters compile to properties, sortby, limit/offset, crs, and bbox respectively:

This v1 compatibility compiler models source-native/vendor-extension request shapes and does not claim that properties, sortby, or numeric offset are OGC API Features Core parameters. New typed integrations should use compileSemanticOgcApiFeaturesQuery(), whose standards-strict behavior rejects those semantics until concrete extension evidence can be supplied.

import { PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract";
import { explainQuery } from "@honua/sdk-js/query-planner";

const ogcPlan = explainQuery({
  descriptor: {
    id: "parcels",
    protocol: "ogc-features",
    locator: { url: "https://example.test/ogc", collectionId: "parcels" },
    capabilities: PROTOCOL_DEFAULT_CAPABILITIES["ogc-features"],
  },
  query: {
    where: "status = 'active'",
    outFields: ["parcel_id", "owner"],
    orderBy: [{ field: "updated_at", direction: "desc" }],
    spatialFilter: {
      geometryType: "esriGeometryEnvelope",
      geometry: { xmin: -158.4, ymin: 20.5, xmax: -157.6, ymax: 21.8 },
    },
    pagination: { limit: 100 },
  },
});

const firstStep = ogcPlan.steps[0];
if (!firstStep || firstStep.engine !== "remote") throw new Error("Expected a remote query step");
console.log(firstStep.compiled);
// { compiler: "ogc-api-features-query-v1", collectionId: "parcels",
//   filter: "status = 'active'", filterLang: "cql2-text", ... }

The OGC compiler rejects non-envelope spatial filters, relationships other than envelope-intersects/intersects, non-EPSG:4326 or malformed bounds, portable geometry-suppression claims, and claimed remote aggregation. It never weakens those requests to a broader bbox query. Aggregation remains available through the explicit bounded-local policy below: the CQL2 filter and required-field projection stay remote while the exact aggregate runs only after the materialization ceilings pass.

WFS plans compile the supported SQL-92 subset and spatial predicates to FES 2.0 XML, and expose propertyName, sortBy, startIndex, count, and srsName. XML literals and identifiers go through the same escaping compiler used by the WFS adapter. The geometry property comes from the source identity (locator.geometryName, which the live adapter fills from DescribeFeatureType, or a descriptor geometry field); metadata-free plans compiled without either fall back to the reviewed the_geom default. OData plans translate the canonical predicate to $filter, including supported geo.intersects geometry, and expose $select/$expand, $orderby, $skip, and $top. Both compilers fail closed when exact translation is impossible. In particular, metadata-free planning requires explicit outFields before it can prove geometry suppression, and a descriptor geometry field before it can compile an OData spatial predicate. OData output-CRS requests, untranslatable SQL predicates, unterminated literals, contradictory WFS geometry projections, and WFS spatial filters that would misuse response outSr as an input-geometry label also fail closed. The planner never claims exact execution based on guessed or silently ignored protocol behavior. A descriptor WFS srsName remains transaction metadata and is not invented as a GetFeature response CRS.

Query.signal never enters the IR or fingerprint. Supply cancellation only to executeQueryPlan. Network-protocol source URLs in plan identity are stripped of credentials, query strings, and fragments. GeoParquet v1 planning rejects a credential-bearing locator entirely; use the opaque v2 path below. Pass stable authorization scope identifiers, not tokens.

Opaque GeoParquet resource identity

GeoParquetResourceHandleV1 is a versioned JSON value containing only a resolver namespace, logical resource id, stable non-secret authorization partition, and optional data revision. explainQuery() emits a 2.0 IR/plan and duckdb-sql-v2 template when geoparquetResource is present. Raw paths, globs, signed URLs, and expiry remain private to a lifecycle-scoped resolver registry and are injected only while the accepted plan executes:

import {
  createGeoParquetResourceRegistry,
  executeQueryPlan,
  explainQuery,
  queryPlanCacheKey,
  serializeQueryPlan,
} from "@honua/sdk-js/query-planner";

const resources = createGeoParquetResourceRegistry({ resolver: "io.honua.app-assets" });
const handle = resources.register({
  id: "parcels:current",
  authorizationContextId: "tenant:alpha/role:analyst",
  resourceVersion: "snapshot:42",
  sources: [privateSignedGeoParquetUrl],
  expiresAt: privateSignedGeoParquetExpiryMs,
});

const plan = explainQuery({
  descriptor: source.descriptor,
  geoparquetResource: handle,
  authorizationScope: ["data:read"],
  sourceVersion: "source:9",
  query: { where: "population > 10", pagination: { limit: 100 } },
});

// Both projections contain only the stable handle and placeholder SQL.
const persistedPlan = serializeQueryPlan(plan);
const cacheKey = queryPlanCacheKey(plan);

// The resolver's private source crosses directly into the GeoParquet adapter.
// Repeat the planning context so execution can reject drift.
const execution = await executeQueryPlan(plan, source, {
  authorizationContextId: "tenant:alpha/role:analyst",
  geoParquetResourceResolver: resources.resolver,
  authorizationScope: ["data:read"],
  sourceVersion: "source:9",
  signal,
});

// Clears all private locator material when the owning client/session ends.
resources.dispose();

Re-registering the same resolver/id/authorization-context/resource-version atomically rotates private credentials without changing the handle or its fingerprint. Resolution compares authorization context before invoking the resolver, enforces exact expiry at now >= expiresAt, supports pre-flight and in-flight cancellation, and copies resolver output through fixed source-count and UTF-8 size ceilings. Unknown, revoked, closed, or cross-context resources fail closed. Foreign resolver failures are rebuilt as fixed-message SDK errors; their messages, causes, contexts, and raw locators are never retained. GeoParquet adapter failures are likewise rebuilt as query.execution.resource-execution-failed. Cancellation remains an HonuaAbortError before, during, and after resolution.

resource.id, resourceVersion, and authorizationContextId are identity-bearing. Derive them only from stable, non-secret facts such as dataset revision, tenant, and role ids—never from a bearer token, API key, signed query string, or a hash of credential material. Their validators enforce bounded syntax, not arbitrary secret detection, so callers remain responsible for this boundary. The registry is ephemeral in-memory isolation, not encrypted storage. Resolved sources cross the redaction boundary and must never be logged, persisted, fingerprinted, placed in telemetry, or sent over a network API.

serializeQueryPlan(), parseQueryPlan(), hashQueryPlan(), and queryPlanCacheKey() validate the persistence boundary before returning. A v2 cache identity binds the resolver/id, authorization partition, data revision, scope, schema/source versions, query, and planner policy. Credential rotation does not change it, and no secret value is hashed. Serialization is synchronous and performs no resolver, filesystem, network, or DuckDB I/O. Validation does not treat the public SHA-256 fingerprint as authentication: it reconstructs the complete canonical v2 plan through the pure planner and compares every field, including the derived id, IR, steps, compiled template, warnings, and exact nested keys. A re-signed non-canonical projection is rejected with the same fixed redacted invalid-plan error.

Existing 1.0 GeoParquet IR and duckdb-sql-v1 artifacts remain supported only when every locator is credential-free. Query strings, fragments, user-info, and credential-bearing legacy addresses are rejected before planning, hashing, parsing, or migration. Upgrade an accepted v1 plan explicitly with migrateGeoParquetQueryPlanV1(legacyPlan, handle); migration reconstructs a v2 plan and does not copy the old locator. There is no implicit v1 rewrite.

DuckDB SQL and gRPC compilers

Opaque geoparquet sources compile through compileDuckDbQueryV2 to a deterministic duckdb-sql-v2 template over the fixed honua-resource://resolve-at-execution placeholder. The trusted GeoParquet adapter recompiles the same canonical query against the ephemeral resolved sources and consumes them without placing them in its metadata/profile cache. Credential-free legacy sources continue to use compileDuckDbQuery and duckdb-sql-v1. Both compilers reuse the same dependency-free, injection-safe SQL builder. Envelope spatial filters push down as ST_Intersects(..., ST_MakeEnvelope(...)) (or a GeoParquet 1.1 bbox-covering column when declared); non-envelope geometries are reduced to their bounding box and reported with bboxApproximated: true. Geometry is projected as GeoJSON via ST_AsGeoJSON. Output-CRS requests fail closed (no portable DuckDB reprojection), and a spatial filter without a resolvable geometry column is rejected. The geometry column and encoding come from locator.geoparquet.geometryColumn / geometryEncoding (default wkb) or a descriptor geometry field, so planning never needs a profiling round-trip.

grpc sources compile to honua-grpc-query-features-v1, a faithful, protobuf-free description of the honua.v1.FeatureService/QueryFeatures unary request. Field names and proto enum value names (SPATIAL_RELATIONSHIP_*, STATISTIC_TYPE_*) mirror the generated message, so the plan is a hashable pre-image of the wire request without importing the protobuf runtime.

Spatial aggregation

Spatial aggregation — grouped statistics constrained by a spatial predicate — has both a server-pushdown path and a bounded local/columnar path. GeoServices (outStatistics + groupByFieldsForStatistics + geometry) and gRPC (outStatistics + groupBy + spatialFilter) push the whole aggregation to the server (pushdown: "full"); DuckDB pushes it to the columnar engine as a GROUP BY with the spatial predicate in the WHERE clause. When a source cannot push aggregation down at all, the bounded degraded path (below) computes it locally after enforcing a row/byte ceiling. Histogram and time-series aggregation remain rejected rather than silently ignored.

Plan consumers

The plan is consumed, not re-derived. honua explain <ref> (CLI) builds a SourceDescriptor from flags and calls explainQuery, printing the stages, pushdown, per-protocol compiled request, fidelity, warnings, and fingerprint — with no server call, since planning is side-effect free. executeQueryPlan consumes the accepted plan for execution after verifying its fingerprint and source context.

For GeoParquet, the positional CLI value is an opaque resource id, never a locator:

honua explain parcels:current --protocol geoparquet \
  --resolver io.honua.app-assets \
  --authorization-context tenant:alpha/role:analyst \
  --resource-version snapshot:42 --json

Structured diagnostics

Every plan and every step carries diagnosticsVersion: "1.0" data. Step diagnostics sit beside the step's engine and pushdown decision so consumers do not have to infer safety or fidelity from prose:

cache is a deterministic decision, not a cache side effect. The caller may select bypass, prefer-cache, or require-fresh and report fresh, stale, or unknown evidence. The plan records an action (bypass, reuse, revalidate, or refresh) and reason. ETags, last-modified values, revisions, and precomputed fingerprints are normalized to a kind plus digest; raw validators do not enter serialized plans. Cache storage and conditional requests remain the responsibility of the plan consumer. bypass always normalizes to freshness: "unknown" with no validator, so irrelevant cache observations cannot fragment plan identity.

Result representation

executionMode answers when rows arrive (snapshot or delta). representation answers what shape they arrive in, and is selected by the planner rather than by a caller who already knows the columnar library exists:

Selection is metadata-only: it reads the declared source protocol and physical geometry encoding plus the caller's estimates, and never reads result data, constructs a worker, or performs I/O. The decision, its inputs, and its reason are recorded on plan.representation; the selected strategy is bound into plan.validity.representation and therefore into the plan fingerprint.

import { capabilities } from "@honua/sdk-js/contract";
import { COLUMNAR_REPRESENTATION_MIN_ROWS, explainQuery } from "@honua/sdk-js/query-planner";

const plan = explainQuery({
  descriptor: {
    id: "parcels",
    protocol: "geoparquet",
    locator: {
      url: "https://example.test/parcels.parquet",
      geoparquet: { geometryColumn: "geometry", geometryEncoding: "geoarrow-point", nativeDimensions: "xy" },
    },
    capabilities: capabilities(["query"]),
  },
  estimates: { rows: COLUMNAR_REPRESENTATION_MIN_ROWS },
  schemaVersion: "parcels-schema-3",
  sourceVersion: "parcels-source-9",
  authorizationScope: ["data:read"],
});

console.log(plan.representation.selected); // "columnar"
console.log(plan.representation.reason); // "workload-above-threshold"
console.log(plan.representation.available); // ["object", "columnar"]

Columnar is offered only where an executable producer exists. Today that is a GeoParquet source whose declared geometry encoding is geoarrow-point, geoarrow-linestring, or geoarrow-polygon, for a feature query that returns geometry. The multi-part geoarrow-multipoint / geoarrow-multilinestring / geoarrow-multipolygon encodings are deliberately excluded: this SDK's dependency-free GeoArrow mapping has no multi-part kind, and re-labelling their parts as a single-part column would misreport the geometry type.

Every outcome is explained rather than defaulted. representation.reason is one of explicit-pin, workload-above-threshold, workload-below-threshold, estimate-unavailable, protocol-not-columnar, encoding-not-columnar, aggregation-not-columnar, or geometry-not-requested. When columnar was genuinely on offer and object was chosen anyway, the plan also carries a columnar-representation-declined warning; a source that never offered columnar produces no warning, because nothing was declined.

A caller may pin the strategy with representation: "object" or representation: "columnar". An unsatisfiable columnar pin throws HonuaCapabilityNotSupportedError naming columnar-execution, the protocol, and the source — it never degrades to object results. For the same reason, executeQueryPlan (the object executor) refuses a columnar-selected plan instead of answering it with feature objects.

Plan-derived batch identity

columnarBatchIdentityFromPlan(plan, { observedAt }) produces the ColumnarBatchIdentityV1 a columnar-selected plan implies. planId is the plan's own validity.fingerprint; sourceId, sourceVersion, schemaVersion, and authorizationScope come from the plan's declared source identity and provenance fingerprints (never the raw scope); ordering is the canonical query's orderBy. Only freshness is supplied at execution, so planning stays observation-clock free and the plan fingerprint stays stable.

plan.fingerprint is deliberately not used as the planId: it also covers cost estimates, which change nothing about which rows come back. Two plans that differ only in their estimates are interchangeable for cache reuse, and share a cache entry; any change to source, schema, scope, query, policy, execution mode, or representation moves validity.fingerprint and therefore the cache key.

Calling it with an object plan throws HonuaCapabilityNotSupportedError, so a batch can never claim provenance the planner did not grant. The producer side — GeoparquetSourceHandle.queryColumnar() and createGeoParquetNativeGeometryBatch() in @honua/sdk-js/geoparquet — requires that identity and has no placeholder path.

Bounded degraded execution

Fallback is disabled by default. When a source can query features but cannot push down aggregation, local execution requires both capabilityPolicy: "degraded" and an explicit bounded-local budget:

const plan = explainQuery({
  descriptor,
  capabilityPolicy: "degraded",
  fallback: { mode: "bounded-local", maxRows: 5_000, maxBytes: 8_000_000 },
  estimates: { rows: 3_200, bytes: 4_100_000 },
  query: {
    where: "status = 'open'",
    aggregation: { metrics: [{ fn: "count", field: "OBJECTID" }] },
  },
});

The plan pushes filters and required-field projection to the server. Its logical input limit is maxRows; the compiled wire request exposes the adapter-owned maxRows + 1 overflow sentinel. Aggregation runs locally only after the row and byte ceilings pass. Planning rejects a known over-budget estimate. Execution rejects an overflow sentinel or transfer-limit response; it never silently reports a partial aggregate. maxRows is also capped by the SDK at MAX_LOCAL_MATERIALIZATION_ROWS.

Determinism and plan validity

Deliberate first-slice boundaries

This foundation does not close the full planner workstream. The typed semantic AST, temporal values, canonical identity, and CQL2 JSON interchange are now available, while the existing v1 compilers remain on their compatibility path. Follow-on slices must adopt the AST in those compilers, negotiate richer OGC filter support, and add spatial-binning aggregation (grid/hex), joins/composition, evidence-backed protocol cost models, execution-integrated cache storage, receipts, and richer renderer/MCP consumption. Structured cache/freshness decisions and snapshot/delta plan identity are available now; the planner does not store cache entries or persist live cursors. Histogram and time-series aggregation are rejected by the current compiler rather than silently ignored. The GeoServices, OGC API Features, WFS, OData, credential-free and opaque DuckDB SQL, gRPC, bounded columnar/worker paths, spatial-aggregation pushdown, and CLI plan consumer are now delivered.