Source capability profiles

Experimental. @honua/sdk-js/source-capabilities is the canonical v2 claimed/observed/effective capability evaluator and source support-check surface. Its evaluator-only graph does not perform discovery or execution; the heavier GeoServices/OData integration is isolated in @honua/sdk-js/source-capability-discovery.

The v2 model keeps three statements separate:

Only matching supported claim and observation can become effective support. An unsupported claim or observation wins. Missing, expired, or failed optional discovery remains unknown.

Validate once, evaluate repeatedly

Static ingestion is deliberately separate from dynamic evaluation. Create one versioned, content-addressed CapabilityEvidenceProfile when discovery changes, cache or transport that envelope, then reuse it for cheap policy evaluations:

import {
  createCapabilityEvidenceProfile,
  evaluateCapabilityProfile,
  type CapabilityEvidenceEntry,
} from "@honua/sdk-js/source-capabilities";

const entries: readonly CapabilityEvidenceEntry[] = [
  {
    id: "query",
    claimed: "supported",
    observed: "supported",
    evidence: [
      {
        kind: "protocol-default",
        truth: "supported",
        reference: "ogc-api-features:core",
        sourceFingerprint: "sha256:a2c9cb525692cf2e224b088147f1b23ae99bce3c974ba023ab4898f28bc79aa8",
      },
      {
        kind: "conformance",
        truth: "supported",
        reference: "ogcapi-features:conf/core",
        observedAt: "2026-07-13T12:00:00Z",
        expiresAt: "2026-07-20T12:00:00Z",
        sourceFingerprint: "sha256:a2c9cb525692cf2e224b088147f1b23ae99bce3c974ba023ab4898f28bc79aa8",
      },
    ],
    authorizationScopes: ["dataset:parcels:read"],
    constraints: {
      outputFormats: ["application/geo+json"],
      pagination: { modes: ["offset"], maxPageSize: 10_000 },
    },
  },
];

// Heavy validation, CRS/PROJJSON checks, normalization, and static hashing run once.
const sourceEndpoint = {
  endpoint: "https://example.test/ogc/features/collections/parcels",
  protocol: "ogc-features",
  sourceId: "parcels",
} as const;
const evidenceProfile = createCapabilityEvidenceProfile(entries, { sourceEndpoint });

// Repeat evaluation is synchronous and does not revalidate static CRS definitions.
const profile = evaluateCapabilityProfile(evidenceProfile, {
  evaluatedAt: "2026-07-14T12:00:00Z",
  environment: "browser",
  authorization: { grantedScopes: ["dataset:parcels:read"] },
});

profile.entries[0]?.effective; // "supported"
profile.evaluatedAt;           // "2026-07-14T12:00:00Z"
profile.validUntil;            // "2026-07-20T12:00:00Z"

Source support checks and narrowing

Dataset.source() and connect().source() return a CapabilityAwareSource. Its synchronous supports() method accepts built-in and reverse-DNS extension identifiers. Only an exact effective: "supported" decision passes; unknown, unsupported, policy-disabled, peer-unavailable, authorization-required, and authorization-denied decisions all fail closed:

const source = dataset.source("parcels")!;

if (source.supports("query")) {
  // `source` is narrowed to SourceWithCapability<Attributes, "query"> here.
  await executeQueryOnlyWorkflow(source);
}

const decision = source.capabilityProfile?.entries.find((entry) => entry.id === "query");
console.log(decision?.effective, decision?.reasons);

An attached profile must be evaluated or parsed by the same SDK instance and its sourceFingerprint must match the descriptor's schemaV2 fingerprint. This prevents an internally valid profile from being replayed against a different schema identity. Focused discovery also reconstructs canonical GeoServices layer or OData entity-set coordinates from the descriptor and binds the profile to their endpoint fingerprint. Cache parsing must continue to supply both expected source coordinates as described below.

For built-in identifiers, the legacy ReadonlySet is derived as the intersection of effective support and the adapter's existing capability maximum. Consequently metadata and policy can downgrade an operation but a profile cannot promote behavior the adapter does not implement. Supported extension identifiers are available through supports() and are intentionally absent from the built-in legacy set.

Sources without a v2 profile retain their exact legacy set behavior: supports() delegates built-in checks to that set and returns false for extension identifiers. Existing third-party SourceResolver implementations do not need to add the method; the dataset decorates extensible results in place and uses a behavior-preserving facade for non-extensible results.

Focused GeoServices and OData discovery

Use the separate integration entrypoint when discovery should attach a validated SourceSchemaV2 and an evaluated capability profile in one pass:

import { connectWithSourceCapabilities } from "@honua/sdk-js/source-capability-discovery";

async function openParcels() {
  const connection = await connectWithSourceCapabilities(
    {
      endpoint: "https://example.test/arcgis/rest/services/Public/Parcels/FeatureServer/0",
      protocol: "auto",
      authorizationScopeFingerprint: "role:viewer:v1",
    },
    {
      evaluatedAt: new Date().toISOString(),
      environment: "browser",
      policy: { deny: ["applyEdits"] },
    },
  );

  const source = connection.source();
  if (source.supports("query")) return source.queryAll();
  return undefined;
}

The integration is intentionally certified only for GeoServices FeatureServer / MapServer and OData v4 in this slice. Other discovery families remain on their protocol rollout issues. Discovery caches retain raw metadata evidence and SourceSchemaV2, never evaluated truth. Every cache hit reconstructs the canonical descriptor endpoint, rebuilds the static profile, and reapplies the current clock, freshness window, policy, runtime environment, peers, and authorization context. The default observation lifetime is five minutes; applications may set observationTtlMs explicitly.

sourceCapabilityEndpointIdentity(descriptor) is exported from both capability subpaths for cache replay verification. Nested GeoServices service folders are segment-encoded, already-resolved service URLs are checked for service/layer contradictions, and OData entity sets are bound as distinct resource paths. Query strings, fragments, URL user-info, credentials, and contradictory coordinates fail closed before hashing.

entries and nested set-like values are sorted, deduplicated, cloned, and deeply frozen. Their normative order is the lexicographic unsigned-byte order of each element's UTF-8 RFC 8785 canonical JSON, never locale order or JavaScript's UTF-16 default .sort(). Omitted constraint sets mean unknown or unbounded; explicit empty arrays mean observed none and remain present. Arrays nested inside caller extension JSON are order-bearing and are preserved. supportedCrs is capped at 64 definitions per capability and validated through the complete resolved-CRS and pinned official PROJJSON v0.7 boundary during evidence-profile creation.

The endpoint fingerprint uses the honua:capability-source-endpoint:1.0 domain over a normalized HTTP(S) scheme/host/path plus protocol and optional source id. Endpoint query strings, fragments, URL user-info, credential-shaped path content, and credential-shaped source ids are rejected; only the digest is retained. The evidence fingerprint uses the honua:capability-evidence:1.0 domain and binds that endpoint digest, the SourceSchemaV2 fingerprint, and semantic evidence. Observation and expiry instants remain in transport but are excluded from semantic identity. The evaluated honua:capabilities:1.0 fingerprint binds the evidence/source coordinates to normalized dynamic context and effective state/reason codes; evaluatedAt and validUntil remain auditable transport state but do not churn identity while the effective decision is unchanged. Repeat evaluation does not walk full static PROJJSON documents.

Cache and transport boundaries

Cache CapabilityEvidenceProfile, never CapabilityProfile. Effective truth expires and must be recomputed with current dynamic state after each cache read:

const cached = parseCapabilityEvidenceProfile(await evidenceCache.get(sourceId), {
  expectedSourceFingerprint: currentSourceSchema.fingerprint,
  expectedSourceEndpoint: currentSourceEndpoint,
});

const current = evaluateCapabilityProfile(cached, {
  evaluatedAt: new Date().toISOString(),
  policy: currentPolicy,
  environment: currentEnvironment,
  availablePeers: currentlyLoadedPeers,
  authorization: currentAuthorization,
});

Use serializeCapabilityEvidenceProfile and parseCapabilityEvidenceProfile for static evidence transport. Both require the exact kind/version/key set and recompute the semantic fingerprint. All evidence in one profile belongs to one SourceSchemaV2 identity and one normalized endpoint identity. A sole evidence sourceFingerprint is promoted to the required envelope field; otherwise creation requires an explicit sourceFingerprint. Creation always requires credential-free sourceEndpoint coordinates and transports only their digest. Mixed schema fingerprints are rejected. Cached parsing accepts paired expectedSourceFingerprint and expectedSourceEndpoint values and rejects same-schema evidence replayed from another endpoint; supplying only one coordinate fails closed. Use verifyCapabilityEvidenceProfileSource with both coordinates for the same check on an already parsed in-memory cache value.

Use serializeCapabilityProfile and parseCapabilityProfile for evaluated diagnostics or audit transport. Parsing reconstructs the static evidence profile and replays evaluation; a forged effective state, reason, fingerprint, context, or freshness boundary is rejected. This is internal consistency, not current-source authorization: pass both expected source coordinates when parsing for current use. Semantic fingerprints intentionally exclude clock-only freshness state; fingerprints are content addresses, not signatures, so authenticate the surrounding channel when origin or timestamp authenticity matters. Only locally created profiles and profiles parsed with both expected source coordinates retain the private endpoint-binding proof required for attachment to a SourceDescriptor; an unbound audit parse cannot be replayed onto a source. Parsing verifies that a supplied evaluation instant reproduces the transported decision; it does not attest who supplied that clock input.

Sensitive caller data

Capability profiles are potentially sensitive. The SDK rejects common credential-bearing forms: raw/parameterized URL evidence references, authorization headers, JWTs/private keys, non-structural peer or scope values, and credential-named or credential-shaped extension metadata. Extension keys are tokenized recursively across camel-case and separator boundaries, including forms such as authorizationHeader, access_token_value, privateKeyPem, and reverse-DNS keys such as com.example.apiKey. Evidence references are bounded stable printable-ASCII identities; peers use npm package identity syntax; scopes use bounded colon/slash-delimited identifiers.

Those checks cannot prove that an arbitrary accepted string is not a secret. Supplying credentials, tokens, signed URLs, private endpoints, or confidential metadata is a prohibited caller precondition. Serialization is canonical but does not sanitize, redact, classify, or authorize caller data. Handle both static and evaluated serialized profiles according to the sensitivity of their inputs, and never publish or log them solely because parsing succeeded.

Metadata, conformance, and probe evidence requires both observedAt and an exclusive expiresAt. Omitting evaluatedAt produces evaluatedAt: null, validUntil: null, and freshness-not-evaluated. A fresh result reports the earliest matching expiry as its conservative validUntil. A stale or not-yet- current result uses its evaluation instant as the exclusive boundary.

Policy vocabulary

CapabilityEvaluationPolicy (allow/deny) is the canonical v2 evaluator policy. The two stable policy types remain supported adapters with narrower legacy responsibilities:

They are not peer v2 vocabularies. Integration layers translate them into a CapabilityEvaluationPolicy at the compatibility boundary; new v2 code should not overload or persist the legacy forms as evaluator policy.

Validation and limits

Every versioned envelope rejects unknown keys, duplicate JSON object names, undefined object members, non-finite numbers, unpaired UTF-16 surrogates, accessors, symbols, sparse/extended arrays, cycles, and non-plain objects before canonicalization. Inputs are snapshotted from own enumerable data properties without consulting inherited prototype fields.

Synchronous static ingestion is bounded to 256 entries, 64 evidence records per entry, 64 CRS definitions per constraint, 1,024 ordinary set values, a 2 MiB/65,536-node static envelope, 256 KiB extension graphs, and 128 KiB CRS graphs. Dynamic context is bounded to 512 KiB/8,192 nodes. Evaluated transport has a separate derived 8 MiB/196,608-node ceiling: it reserves the static projection, repeated bounded identifiers and reason prefixes, normalized context, and controlled headroom. Serializers and parsers enforce the same envelope-specific limits. Violations throw TypeError.

Effective states

State Meaning
supported Claim and observation support the operation and every dynamic gate passes.
unsupported The adapter claim or endpoint observation rejects the operation.
unknown Claim/observation is unknown, observation was not requested, or freshness cannot be established.
policy-disabled Current v2 evaluation policy excludes otherwise supported behavior.
peer-unavailable The current environment is ineligible or a required optional peer is absent.
authorization-required One or more stable scope identifiers are not currently granted.
authorization-denied The current principal is explicitly denied a required scope.

Every decision includes stable reason codes. Environment, peer, and scope failures suffix the code with the relevant structural identifier. That identifier is still caller-controlled and potentially sensitive; credentials remain prohibited in evidence references, peer ids, scope ids, and extension values.

Delivery boundary

This slice adds source.supports(), literal capability narrowing, verified profile attachment, and legacy/third-party compatibility. It does not yet wire GeoServices or OData discovery evidence into a v2 profile; that focused connection projection lands in the next slice. Existing sources without a profile retain their stable ReadonlySet capability behavior.