Skip to content

PyCharter — Validate LinkML bindings at contract publish (SEM-2)

Status: implemented · Created: 2026-06-09 · Audience: maintainers · Resolves: SEM-2 · Companion: linkml-as-schema-substrate.md

Why

A contract may declare a LinkML shape schema via domain_schema: <name>:<version> and bind fields to a LinkML class + slot (LinkMLSlotTarget). Today that reference is parsed as a string and never resolved at publish: a binding to class: Order, slot: id is accepted whether or not that class/slot exists in the referenced schema. The validation loop is open — you can publish a contract whose shape bindings are typos or point at slots a newer schema version removed, and nothing complains until a downstream consumer pulls the LinkML serialization (Flow B of the integration seam) and chokes on it.

The checker already exists and is tested:

Piece Location Does
validate_linkml_bindings(contract, registry) semantic/contracts/_linkml_validation.py Walks each binding's primary.linkml_slot + concept_scheme; emits MISSING_SCHEMA / MISSING_CLASS / MISSING_SLOT issues
LinkMLBindingValidationError(PublicFacingError) same Message authored in-tree → safe as an HTTP detail
LinkMLSchemaRegistry.resolve("name:version") semantic/linkml/_registry.py Loads a published schema snapshot from data/vocabulary/versions/

The gap is wiring, not logic. validate_linkml_bindings is already threaded into the contract loader (semantic/contracts/loader.py:94) — but only fires when the caller passes a linkml_schema_registry, and no production caller does (grep: every reference is the loader threading its own parameter). So in practice the check runs nowhere. The two publish routes do no LinkML validation at all.

SEM-2 closes the loop at the authoring boundary: when a contract with a domain_schema is published, resolve the schema and reject bindings that don't resolve — fail fast with an actionable error instead of accepting a broken shape.

Decision

  1. Gate at publish. When a contract being published declares a domain_schema, run validate_linkml_bindings against a resolved LinkMLSchemaRegistry. Under block any issue → HTTP 422, publish rejected, nothing persisted (mirroring the loader's raise_for_issues() → ContractLoadError). Ships default warn (log-only) for one release, then flips to block — see the "Decision (locked)" section below.
  2. Additive — scoped to contracts that carry LinkML slot bindings. The runtime gate is the presence of a field-bindings artifact (contract.field_bindings_id is not None): validate_linkml_bindings only flags bindings whose primary.linkml_slot is set, and a contract without a field-bindings artifact has none, so it's skipped before the view is even assembled. (We gate on the artifact, not a top-level domain_schema field — domain_schema lives inside the field-bindings payload and the assembled view doesn't reliably surface it at top level; a contract carrying linkml_slot targets always has a field-bindings artifact, so the two coincide in practice.) The legacy concept-centric binding path is unaffected. create_from_existing_artifacts never links a field-bindings artifact, so it can't carry LinkML bindings and is intentionally not wired.
  3. Scope SEM-2 to the LinkML class/slot check — the documented gap. The broader validate_contract_bundle (which also runs concept-binding rules needing known_concepts / deprecated_concepts from the concept store) is a fast-follow, not this card: it adds a concept-store read to the publish path and a separate block/warn decision. SEM-2 calls the focused validate_linkml_bindings directly so publish stays dependency-light.

Integration point

There are effectively two persist functions in api/services/_contract_create.py, because the editor's "Save as new version" fork funnels into one of them:

Entry point Service fn Route
Create from existing artifacts create_from_existing_artifacts (:46) POST /contracts/create-from-artifacts (contracts.py:75)
Create mixed (new + referenced) create_mixed (:278) POST /contracts/create-mixed (contracts.py:123)
Fork a new version fork_contract_version (:181) → builds a ContractCreateMixedRequest → create_mixed POST /contracts/fork (contracts.py:189)

Gating create_from_existing_artifacts and create_mixed therefore covers all three routes, fork included.

Two facts make the placement non-trivial (corrected after reading the code):

  1. domain_schema lives inside the field-bindings artifact payload (db/models/field_bindings.py:37 — "Field mapping payload (field_bindings, domain_schema, …)"), not a contract column. The two paths supply bindings differently: create_mixed/fork carry a new payload (or reuse the base's by reference); create_from_existing_artifacts links one purely by id/title.
  2. The assembled ContractMetadata view comes from build_contract_from_db (_contract_read.py:158) — i.e. it's reconstructed from the persisted rows, not held in hand mid-create.

Chosen placement — validate at flush, before commit. Both services already own their transaction (db.add → db.commit). SEM-2 splits that into db.add → db.flush() (rows visible in-session, uncommitted) → build_contract_from_db(new_contract.id, db) → _validate_linkml_bindings_for_publish → db.commit() on pass, db.rollback() on fail. Reading back through build_contract_from_db gives one uniform ContractMetadata regardless of how the bindings were supplied (new payload vs. referenced id), so both paths — and the new/referenced binding cases — share exactly one validation call. The flush-not-commit ordering keeps a rejected publish from ever persisting.

⚠️ This adjusts the commit sequencing of two load-bearing contract-create services (two live POST routes + the editor save path). The change is additive (flush before the existing commit; rollback on a new failure branch) and guarded by the cheap field_bindings_id is not None gate, but it touches persist-critical code and warrants explicit review sign-off before implementation.

The shared helper:

def _validate_linkml_bindings_for_publish(
    contract: ContractMetadata,
    registry: LinkMLSchemaRegistry,
    *,
    mode: str,  # "warn" | "block" | "off"
) -> None:
    if mode == "off" or contract.domain_schema is None:
        return
    result = validate_linkml_bindings(contract, registry)
    if result.is_valid:
        return
    if mode == "warn":
        logger.warning("LinkML binding issues (warn mode): %s", result.summary())
        return
    try:
        result.raise_for_issues()
    except LinkMLBindingValidationError as exc:
        raise_logged_unprocessable_from_exception(
            logger,
            exc,
            log_event="contract_linkml_bindings_invalid",
            fallback_detail="Contract LinkML bindings are invalid",
        )

LinkMLBindingValidationError is a PublicFacingError, so its message (authored in _linkml_validation.py, listing the offending class/slot) is the audited-safe detail the helper surfaces — actionable for the author, no schema internals leaked.

Sourcing the registry

Add a FastAPI dependency mirroring the store singletons (api/dependencies/store.py):

# api/dependencies/linkml_registry.py
def get_linkml_schema_registry() -> LinkMLSchemaRegistry:
    global _instance
    if _instance is None:
        _instance = LinkMLSchemaRegistry()  # default data/vocabulary/versions/
    return _instance

The route passes it into the service. The default versions_dir (data/vocabulary/versions/) is already the published-ontology location, so no new config is required; an env override (PYCHARTER_LINKML_VERSIONS_DIR) can be added later if deployments relocate it.

Decision (locked 2026-06-09) — rollout posture

An env switch PYCHARTER_LINKML_PUBLISH_VALIDATION = warn (default) | block | off governs the gate:

  • warn (default, ship first): findings are logged at WARNING and the publish proceeds — a migration window so environments whose data/vocabulary/versions/ registry isn't yet populated don't start 422-ing on every re-save/fork of an existing domain_schema contract.
  • block: any finding → 422, publish rejected. Flip the default here once staging confirms the registry is populated and no unexpected MISSING_SCHEMA warnings appear.
  • off: pre-SEM-2 behavior (escape hatch).

The switch is read once per publish via a small _publish_validation_mode() helper (default "warn"). The mode applies uniformly to all issue kinds — warn does not selectively block MISSING_CLASS / MISSING_SLOT, keeping the rollout story a single lever. (A later refinement could always-block resolved- schema mismatches; deferred to keep SEM-2 simple.)

Reconcile with the load-time check

The loader can already run this check but is never handed a registry. Out of scope for SEM-2 (publish is the authoritative gate — catch it where it's authored), but a natural fast-follow: thread get_linkml_schema_registry() into the API read/parse path so reads are defended too (defense-in-depth). Tracked as a follow-up, not a blocker.

Migration posture

  • No domain_schema → byte-identical behavior. The dominant case is untouched.
  • Not retroactive. Existing contracts with broken LinkML bindings are only rejected on their next publish/fork — no background sweep, no forced migration.
  • Legacy concept-centric bindings stay fully supported (auto-detection in _parse_new_format still dispatches; SEM-2 only tightens the LinkML branch).

Testing

Route-level (tests/unit/api/test_contracts_* + a seeded registry fixture):

  1. domain_schema set, binding to a real class+slot → 201, persisted.
  2. Binding to a non-existent class → 422, LinkMLBindingValidationError detail names the class, nothing persisted.
  3. Binding to a non-existent slot on a real class → 422.
  4. No domain_schema → 201 (gate skipped; legacy path unchanged).
  5. Unresolvable domain_schema → 422 under block; 201 + WARNING log under warn (parametrized on the env switch).
  6. Parity across all three entry points: the same bad binding is rejected via create_from_existing_artifacts, create_mixed, and fork_contract_version.

Plus a service-level unit test of _validate_linkml_bindings_for_publish with a constructed ContractMetadata to keep the LinkML logic covered independently of the HTTP layer.

Rollout

  1. Land behind PYCHARTER_LINKML_PUBLISH_VALIDATION=warn default.
  2. Confirm no unexpected MISSING_SCHEMA warnings in staging (registry populated).
  3. Flip default to block; document the env switch in docs/reference/.
  4. Fast-follow: load-path registry wiring + (separately) validate_contract_bundle concept-binding rules at publish.

Non-goals

  • Does not validate concept meaning bindings (known/deprecated concepts) — fast-follow via validate_contract_bundle.
  • Does not require contracts to declare a domain_schema.
  • Does not change codegen, the loader's public signature, or any wire shape.