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¶
- Gate at publish. When a contract being published declares a
domain_schema, runvalidate_linkml_bindingsagainst a resolvedLinkMLSchemaRegistry. Underblockany issue → HTTP 422, publish rejected, nothing persisted (mirroring the loader'sraise_for_issues()→ContractLoadError). Ships defaultwarn(log-only) for one release, then flips toblock— see the "Decision (locked)" section below. - 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_bindingsonly flags bindings whoseprimary.linkml_slotis 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-leveldomain_schemafield —domain_schemalives inside the field-bindings payload and the assembled view doesn't reliably surface it at top level; a contract carryinglinkml_slottargets always has a field-bindings artifact, so the two coincide in practice.) The legacy concept-centric binding path is unaffected.create_from_existing_artifactsnever links a field-bindings artifact, so it can't carry LinkML bindings and is intentionally not wired. - Scope SEM-2 to the LinkML class/slot check — the documented gap. The
broader
validate_contract_bundle(which also runs concept-binding rules needingknown_concepts/deprecated_conceptsfrom 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 focusedvalidate_linkml_bindingsdirectly 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):
domain_schemalives 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_artifactslinks one purely by id/title.- The assembled
ContractMetadataview comes frombuild_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 Nonegate, 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 whosedata/vocabulary/versions/registry isn't yet populated don't start 422-ing on every re-save/fork of an existingdomain_schemacontract.block: any finding → 422, publish rejected. Flip the default here once staging confirms the registry is populated and no unexpectedMISSING_SCHEMAwarnings 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_formatstill dispatches; SEM-2 only tightens the LinkML branch).
Testing¶
Route-level (tests/unit/api/test_contracts_* + a seeded registry fixture):
domain_schemaset, binding to a real class+slot → 201, persisted.- Binding to a non-existent class → 422,
LinkMLBindingValidationErrordetail names the class, nothing persisted. - Binding to a non-existent slot on a real class → 422.
- No
domain_schema→ 201 (gate skipped; legacy path unchanged). - Unresolvable
domain_schema→ 422 underblock; 201 + WARNING log underwarn(parametrized on the env switch). - Parity across all three entry points: the same bad binding is rejected via
create_from_existing_artifacts,create_mixed, andfork_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¶
- Land behind
PYCHARTER_LINKML_PUBLISH_VALIDATION=warndefault. - Confirm no unexpected
MISSING_SCHEMAwarnings in staging (registry populated). - Flip default to
block; document the env switch indocs/reference/. - Fast-follow: load-path registry wiring + (separately)
validate_contract_bundleconcept-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.