PyCharter — Contract Foundation Unification¶
Status: implemented (CF-1…CF-7, CF-3b, CF-7b); the loader-entry-point CF-5 is folded into ARCH-1 in BACKLOG.md · Created: 2026-05-29 · Audience: every contributor
The contract subsystem currently carries two parallel models, three field-binding shapes, two relationship enums, four ownership representations, three field-path formats, two severity vocabularies, and two locations for "data quality." This is the result of accreting features (DG-1 bindings, DQC, ODCS export, LinkML loop) without periodic consolidation. The result: new users hit shape mismatches on first touch, and contributors cannot tell which form is canonical.
This proposal collapses all of it. Six CF items, no aliases, no
deprecation shims, no transitional accept-both code paths. Old
shapes are deleted. Migration of contract artifacts already on disk is
handled by _format_migrations (read-time upgrade); no user-facing
back-compat is required because the system owns every consumer in this
repo and migrates them atomically with the model.
This is the contract-side analogue of the T1–T10 terminology overhaul: same principle, applied to data shapes instead of names.
Scope¶
Six items. Each lands as its own atomic change set with its own tests green; phasing below sequences them by dependency.
| ID | Title | Touches |
|---|---|---|
| CF-1 | One canonical contract model | The biggest. Collapses semantic.contracts.ContractConfig into db.schemas.DataContractSchema. |
| CF-2 | One severity + one quality-dimension enum | Every rule type (governance, coercion, validation, quality, extraction). |
| CF-3 | One ownership model with named roles | OwnershipBlock, OwnershipComponent, the 6-field ownership metadata block. |
| CF-4 | One field-path format (JSONPath) | Every artifact that references a field — coercion, validation, bindings, quality rules. |
| CF-6 | One relationship enum with context subsets | BindingRelationship + RelationshipType → one Relationship. |
| CF-7 | Delete legacy DQ locations | metadata.data_quality (legacy block) + metadata.governance_rules.business_rules removed from schema; migrator stays. |
(CF-5, "single contract-loader entry point", is deferred. The user elected to skip it; loader sprawl is real but does not block the foundation work.)
Why now¶
Three forcing functions converged:
- DQC just landed. Phases 0–6 added a top-level
data_qualitysection + ODCSsla_properties. The legacymetadata.data_qualityblock +metadata.governance_rules.business_rulesblock stayed accepted "for now." This proposal closes that loop (CF-7). - Templates ship to users. The session that produced this
proposal found the bundled templates fail
parse_contracton first touch because top-level identity (name/status/description) was rejected. The two-model split (DataContractSchema vs ContractConfig) was the root cause — templates were authored against ContractConfig conventions but parsed by DataContractSchema. - External integrations expanding. Pyintake handshake (CR-1 through CR-5), MCP surface (P5-007), Theme D wiring all consume the contract model. Every consumer added against a split foundation multiplies the cost of unification later.
Principles¶
These hold across all six items:
- One name per concept. No
concept/concept_id/concept_keytriplets; notarget/target_field/columntriplets; noteam/steward/ownertriplets. - One shape per artifact. No "the YAML may be either form X or form Y." The migrator upgrades on read; the validator rejects anything not in canonical form.
- Typed enums, not free strings. Severity, dimension,
relationship, role — all named enums with
extra="forbid"-style strictness. Free strings allowed only insidecustomPropertiesbags (ODCS forward-compat). - JSONPath is the canonical address. A field is referenced one
way in every artifact section:
$.field(top-level) or$.parent.child(nested). No dotted (orders.field), no bare (field). The normalizer becomes a one-shot migrator, not a permanent input mode. - No back-compat code in the runtime. Anything the old shape could express either has a canonical equivalent in the new shape or is deleted. Format-version migrations (DG-2) handle artifacts on disk; the runtime only sees the new shape.
Decisions¶
CF-1 — One canonical contract model¶
Decision: pycharter.db.schemas._data_contract_schema.DataContractSchema
is the single canonical contract Pydantic model. pycharter.semantic.contracts.models.ContractConfig
and its supporting models (OwnershipBlock, GovernanceRule,
CoercionRule, ValidationRule, ContractMetadataBlock,
AIUsagePolicy) are deleted as parallel definitions.
The capabilities ContractConfig added (frozen typed objects,
ai_usage_policy, extraction_policy, domain_schema,
bound_field_count / referenced_ontologies accessors) move onto the
canonical model:
ai_usage_policyandextraction_policybecome typed fields onMetadataComponent(or new top-level if the semantic split matters).domain_schemabecomes a typed top-level field onDataContractSchema(currently only carried inside ContractConfig).- Accessor methods (
bound_field_count,referenced_ontologies) become functions inpycharter.contract_parseror class methods onContractMetadata(the runtime view object). - Sub-models that were richer in ContractConfig (
PrimaryBinding,AnnotationBinding,FieldBinding) move intodb/schemas/_data_contract_validators.pyand replace the existingFieldBindingValidator— see CF-4 for the field-binding shape decision.
The rule sub-shapes consolidate to the DataContractSchema form (dict-of-field-keyed-rules), not the ContractConfig form (list-of-rule-objects). Rationale: the dict form is denser, direct-keyed by field, and is what every template + every seed file already uses. Migrating from list-of-CoercionRule to dict-of-rules is purely internal — no artifact change.
After CF-1:
- One Python module declares the contract:
pycharter.db.schemas. - One runtime view object:
ContractMetadata(returned byparse_contract). semantic/contracts/shrinks to thin functions that operate onContractMetadata(validation against a vocabulary, ontology resolution, etc.) — no parallel model.
CF-2 — One severity, one quality-dimension enum¶
Decision: Two new public enums in pycharter.shared.enums:
class Severity(StrEnum):
INFO = "info"
WARNING = "warning"
ERROR = "error"
FATAL = "fatal"
class QualityDimension(StrEnum):
ACCURACY = "accuracy"
COMPLETENESS = "completeness"
CONFORMITY = "conformity"
CONSISTENCY = "consistency"
COVERAGE = "coverage"
TIMELINESS = "timeliness"
UNIQUENESS = "uniqueness"
Every severity field across the codebase (governance, coercion,
validation, quality, extraction, drift detector) uses Severity
verbatim. No warn (use warning), no fail (use fatal). The
existing QualityDimension in db.schemas._quality_models moves to
shared.enums and is the only definition.
Touchpoints (cited so the work is fully scoped):
db.schemas._quality_models.QualitySeverity— replace with re-export.semantic.contracts.models(governance/coercion/validation severity) — deleted by CF-1; new model usesSeverity.semantic.extraction_policy.ExtractionPolicy.severity— change toSeverity(wasLiteral["info", "warn", "error"]).quality.profile_drift.evaluate_drift(drift severity) — typed field usesSeverity.
CF-3 — One ownership model with named roles¶
Decision: Replace the 6-key flat ownership block + the two
parallel Pydantic models with a single Ownership model:
class OwnershipRole(StrEnum):
BUSINESS_OWNER = "business_owner"
BUSINESS_SME = "business_sme"
TECHNICAL_OWNER = "technical_owner"
TECHNICAL_SME = "technical_sme"
SUPPORT_LEAD = "support_lead"
CONTRACT_OWNER = "contract_owner"
DATA_STEWARD = "data_steward"
class Ownership(BaseModel):
roles: dict[OwnershipRole, list[str]] = Field(default_factory=dict)
additional_info: dict[str, Any] = Field(default_factory=dict)
YAML form:
ownership:
roles:
business_owner: [quant-desk]
business_sme: [portfolio-management]
technical_owner: [data-engineering, platform-team]
technical_sme: [risk-team]
support_lead: [compliance-ops]
contract_owner: [data-engineering, quant-desk]
data_steward: [governance-team]
Maps cleanly to the existing owners table (each entry is an owner
id; the role becomes a column on a join table
contract_ownership_assignments). The migrator turns the legacy
six-key flat block into the new roles: map in one read-time
upgrade step.
OwnershipBlock.team / OwnershipBlock.steward become entries in the
roles map (technical_owner and data_steward respectively).
Namespace.owner (semantic) is out of scope — it's an unrelated
single-owner identifier and stays as-is.
CF-4 — One field-path format (JSONPath)¶
Decision: Every field reference in every contract artifact is a
JSONPath expression rooted at $. There is no shorthand, no
dotted form, no bare-name form.
Affected:
| Section | Before | After |
|---|---|---|
coercion_rules.rules keys |
order_id: coerce_to_string |
$.order_id: coerce_to_string |
validation_rules.rules keys |
order_id: {...} |
$.order_id: {...} |
data_quality.rules[].column |
column: order_id |
column: $.order_id |
data_quality.rules[].columns |
[a, b] |
[$.a, $.b] |
field_bindings keys |
$.order_id (already correct) |
$.order_id (unchanged) |
sla_properties[].element |
orders.created_at |
$.created_at |
Field-binding relationships[].target_field |
$.field (already correct) |
unchanged |
pycharter.semantic.concepts.normalizer.to_jsonpath and
normalize_field_bindings become the one-shot upgrade step inside
_format_migrations. After upgrade, the parser refuses non-JSONPath
references with a clear error ("field path must be a JSONPath
expression starting with $").
This makes the artifact uniform with the field-bindings convention
already enforced by FieldBindingsComponent.validate_jsonpath_keys.
CF-6 — One relationship enum with context subsets¶
Decision: Merge BindingRelationship and RelationshipType into a
single Relationship enum. Add a RelationshipContext policy that
declares which subset is valid where. Duplicate-meaning members
collapse to one canonical name.
class Relationship(StrEnum):
# SKOS / mapping family
EQUIVALENT_TO = "equivalent_to" # collapses BindingRelationship.EXACT_MATCH
CLOSE_MATCH = "close_match"
RELATED_TO = "related_to"
BROADER = "broader"
NARROWER = "narrower"
# Identity / reference family
SAME_AS = "same_as"
REFERENCES = "references"
# Derivation family
DERIVED_FROM = "derived_from"
IMPLEMENTS = "implements"
DESCRIBES = "describes"
# Structural family
IS_A = "is_a"
HAS = "has"
DEPENDS_ON = "depends_on"
PRECEDES = "precedes"
CAUSES = "causes"
GOVERNS = "governs"
class RelationshipContext(StrEnum):
FIELD_BINDING = "field_binding" # subset valid on binding annotations
CONCEPT_GRAPH = "concept_graph" # subset valid on graph edges
_CONTEXT_ALLOWED = {
RelationshipContext.FIELD_BINDING: {
Relationship.EQUIVALENT_TO,
Relationship.CLOSE_MATCH,
Relationship.RELATED_TO,
Relationship.BROADER,
Relationship.NARROWER,
Relationship.DERIVED_FROM,
Relationship.SAME_AS,
},
RelationshipContext.CONCEPT_GRAPH: {
Relationship.IS_A,
Relationship.HAS,
Relationship.DEPENDS_ON,
Relationship.PRECEDES,
Relationship.CAUSES,
Relationship.GOVERNS,
Relationship.DERIVED_FROM,
Relationship.SAME_AS,
Relationship.RELATED_TO,
Relationship.EQUIVALENT_TO,
Relationship.REFERENCES,
Relationship.IMPLEMENTS,
Relationship.DESCRIBES,
},
}
exact_match → equivalent_to is the one collapse. Rationale:
equivalent_to is the OWL-canonical name and the one that survives
in the merged graph use case. Rename only — same semantics.
Validators (FieldBindingRelationshipValidator.validate_type,
schema_loader.validate_relationship_type) consult the relevant
RelationshipContext allowlist instead of two separate enums.
CF-7 — Delete legacy data-quality locations¶
Decision: Hard-delete the legacy slots from the model:
- Remove the
data_qualityfield fromMetadataComponent(the oldDataQualityComponentwithaccuracy/sla/total_records). - Remove
DataQualityComponent,SLAThresholdsComponent,MonitoringScheduleComponent,AlertingComponentfromdb/schemas/_data_contract_schema.py. The top-levelDataQualitySection(in_quality_models.py) is the only data-quality model. - Change
GovernanceRulesComponentfromextra="allow"toextra="forbid"with an explicit field list (data_retention,pii_fields,sensitivity,access_control,dependencies,data_feed_association,indexes).business_rulesis no longer accepted. - The
pycharter.contract_migrations.v1.migrate_contract_v0_to_v1helper stays — it's the read-time migration path for any contract that still has the legacy block. It is wired into_format_migrationsso every read upgrades transparently.
Templates already shipped in the previous session. CF-7 closes the loop on the model itself.
Phasing¶
Three phases. CF-1 ships first because every other CF either sits on top of it (CF-2, CF-3, CF-4, CF-7) or is independent (CF-6).
Phase 1 (atomic) — CF-1 + CF-7¶
Land together. Both touch the same Pydantic models; landing them in one PR keeps the model file in a coherent state. Migration helper ensures every read still works.
Acceptance:
- pycharter.semantic.contracts.models no longer declares a
ContractConfig; the import either disappears or re-exports the
canonical model.
- DataContractSchema is the only contract-shape Pydantic model in
the repo.
- metadata.data_quality and metadata.governance_rules.business_rules
are rejected by the validator.
- migrate_contract_v0_to_v1 is wired into _format_migrations and
runs on every parse_contract read.
- All existing tests pass (DQC, contract-store, semantic, runtime).
Phase 2 (parallel) — CF-3, CF-4¶
Both are shape changes on top of CF-1. They do not interact (one is ownership; the other is field-path format). Land in either order.
Acceptance:
- One Ownership model with roles: dict[OwnershipRole, list[str]]
is the only ownership representation. The six-key flat block is a
one-shot read-time upgrade step.
- Every field reference in every artifact section is JSONPath
rooted at $. Templates, seed YAMLs, and parser-side acceptance
all enforce this; the dotted/bare normalizer is a migration-only
helper.
Phase 3 (parallel) — CF-2, CF-6¶
Vocabulary unification. Independent of each other and of Phases ½ in the model sense, but conceptually finish the foundation.
Acceptance:
- Every severity field uses the Severity enum from
pycharter.shared.enums.
- Every quality-dimension field uses QualityDimension from
pycharter.shared.enums.
- One Relationship enum exists. BindingRelationship and
RelationshipType are deleted. Context allowlists enforce subset
validity.
Out of scope¶
- CF-5 (single contract-loader entry point). Deferred.
- Exception hierarchy unification (
PyCharterErrorvsPyCharterSemanticError). Not part of the contract foundation — separate sweep if undertaken. Namespace.ownercollapse intoOwnership.roles. Different concept (namespace authority vs contract role); keep separate.- OpenAPI / route-path renames. The HTTP surface is governed by the DG-5 stability policy; renames there pay a heavier breakage cost without contract-side benefit.
- External-system ID formats (FIX feed, OMS, etc.). Out of scope — these are inputs to the system, not part of the contract foundation.
Risk register¶
| Risk | Likelihood | Mitigation |
|---|---|---|
| CF-1 PR too big to review | Medium | Split into "delete ContractConfig" + "move policy fields to canonical" if needed; both still in one phase. |
| Migration miss on some on-disk contract | Low | _format_migrations chain is well-trodden (DG-2 pattern). Add a one-time CI step that round-trips every fixture YAML in tests/ and data/seed/ through the migrator. |
| Downstream consumer pinned to an old shape | Low | Repo-internal consumers only at this point; pyintake handshake is DTO-mediated (integration-dtos.linkml.yaml) and unaffected. |
| Field-path migration noise | Medium | The migrator logs one WARNING per applied upgrade rule; collect a count after the merge to see if any artifact in the wild still trips it. |
Acceptance for the program (CF complete when)¶
pycharter.db.schemas._data_contract_schema.DataContractSchemais the only Pydantic model defining a contract shape in the repo. No alternative module exports a class fitting that role.- Every YAML in
data/templates/contract/,data/seed/contracts.yaml,samples/,examples/, andtests/parses cleanly viaparse_contractafter the migrator runs. - Every severity / quality-dimension / relationship / ownership-role string in the codebase is a typed enum member, not a free string.
- Every field reference in every artifact section is JSONPath.
metadata.data_qualityandmetadata.governance_rules.business_rulesare no longer accepted by the validator.- The
BACKLOG.mdOpen Items index shows all CF rows checked.
Addendum (2026-06) — Pipeline canonical unification¶
Status: landed (separate from the CF-1–CF-7 contract program above).
Pipeline authoring now uses a single pipeline.yaml envelope:
format_version, slash-qualifiedid,metadata.namespace,settings,variables, and asteps:list only.- Section-based configs (
extract/transform/loadfiles) and legacyeditor_foldersbrowse tables are removed. - Step-body fragments live under
data/templates/pipeline/snippets/for copy-paste into canonical pipelines.
See the consolidated plan at docs_pipeline_dqc_unify and
Pipeline templates.
Addendum — CF-5 JSONata field references (separate epic)¶
This proposal originally deferred CF-5 as “single contract-loader entry point.” A later program (CF-5 JSONata) targets a different problem: one field-reference language across coercion, validation, bindings, DQC, and SLA artifacts.
| Artifact | Today (CF-4) | CF-5 JSONata target |
|---|---|---|
Rule keys (column, columns, SLA element) |
JSONPath $.field |
JSONata $field |
type: expression bodies |
Informal bare names (not executed) | JSONata predicates |
Status: deferred. Interim docs in
Data quality and contract template
README; expression examples carry # CF-5 pending comments until the
executor and format migration (contract v6 → v7) land.