PyCharter — Contract Artifact Ownership (de-sharing)¶
Status: ⛔ WITHDRAWN (2026-06-08) — superseded by
contract-immutable-versions.md. ·
Created: 2026-06-08 · Audience: maintainers
Withdrawn. This proposal assumed artifact sharing was an accident worth migrating away. It is not — sharing immutable artifact versions across contract versions is a designed, intended feature (reuse the unchanged parts of a contract across its versions). De-sharing would destroy it. The real gap is that one path (
upsert_field_bindings) mutates an artifact in place, breaking immutability; once that is fixed (every edit creates a new version), sharing is provably safe and nothing needs de-sharing. Seecontract-immutable-versions.md. Kept for historical context only.
Why¶
The contract store is the one versioned store that could not fully adopt the
unified single-write-path (save/update) in PAC-2. The reason is
architectural, not cosmetic: contracts share artifacts.
Each data contract row (db/models/data_contract.py) links to its artifacts by
foreign key — json_schema_id, coercion_rules_id, validation_rules_id,
metadata_record_id, field_bindings_id — into separate artifact tables. Two
live REST flows create contracts by referencing existing artifact rows by id:
POST→contract_service.create_from_existing_artifacts(api/routes/v1/contracts.py:78) — links a new contract entirely to existing artifact ids (find_artifactinapi/services/contract_validation.py).POST→contract_service.create_mixed(contracts.py:127) —resolve_*each artifact: create a new one (via the per-artifact writer, returning its id) or reference an existing one by id.
So the per-artifact writers (store_json_schema, store_coercion_rules,
store_validation_rules, store_metadata, store_field_bindings) are
load-bearing: they create individual artifacts and return ids that the
create flows link onto contracts. The whole-contract save(document) always
creates fresh artifacts and cannot express "reuse artifact X across contracts
A and B". This is why PAC-2b (internalize the writers) is blocked.
The goal of this proposal: decide whether to make each contract own its
artifacts 1:1 (no sharing). If adopted, contracts serialize to a single
document, save/update become the only write path, the per-artifact writers
internalize, and the contract store matches the other three stores exactly.
The tradeoff (decide first)¶
Artifact sharing buys deduplication and propagated edits (edit a shared ruleset once, every contract referencing it sees the change). Ownership buys a simple, portable, single-document contract (the model every other store and the whole consolidation assume) at the cost of duplicated artifact rows and edit-once-per-contract.
This proposal is only worth executing if sharing is not a relied-upon feature. Before any code, run the audit in Phase 0; if production contracts genuinely share artifacts and users depend on propagated edits, ownership is the wrong call and PAC-2b should stay closed (contract store keeps its two-tier API).
Scope¶
Phase 0 — Audit (no code; gates everything)¶
- How many
schemas/coercion_rules/validation_rules/metadata_records/field_bindingsrows are referenced by more than one contract in real databases? (AGROUP BY artifact_id HAVING count(*) > 1over the FK columns.) - Does the UI editor actually expose "link to an existing artifact", or do
the
create_from_existing_artifacts/create_mixedendpoints only serve programmatic/import callers? (Auditui/srcfor the affordance; the metadata POST responses'metadata_id/rule_idare already confirmed UI-unused.) - Who calls the two endpoints (UI, MCP, importers, tests)?
Decision gate: if sharing is unused (or used only as an import optimization), proceed. Otherwise stop and keep the two-tier API.
Phase 1 — De-share existing data (Alembic migration)¶
- For every artifact row referenced by N>1 contracts, clone it so each contract points at its own copy; rewrite the FK. Reversible-by-design (the down migration cannot re-merge, so document it as forward-only). Run on a backup first; this rewrites production contract↔artifact links.
Phase 2 — Collapse the create flows¶
- Replace
create_from_existing_artifacts+create_mixedwith a single contract-create path that takes a wholedocumentand callsstore.save(name, version, document). Removefind_artifact/validate_artifact_idsartifact-id plumbing. - REST: keep the contract-create URL(s) and response field names; drop the
artifact-id request/response fields (confirmed UI-unused). Update
ui/src/lib/api/*types + any editor affordance found in Phase 0.
Phase 3 — Internalize the writers (the original PAC-2b)¶
store_json_schema/store_coercion_rules/store_validation_rules/store_metadata/store_field_bindings→ internal_save_*(the SQLAlchemy_save_*_in_sessionprimitives already exist from PAC-2a).- Migrate the remaining call sites (≈32 src + ≈31 test lines, plus docs) onto
save/update. Keep the per-artifact getters andupsert_field_bindings(the inspector's binding-only patch withexpected_revision) public. - Metadata POST routes return the contract identifier instead of the artifact UUID.
Phase 4 — Docs + changelog¶
- Document the ownership model; changelog the dropped sharing + the
artifact-id response/request removals; regenerate
_bundled_docs.
Risks / danger flags¶
- Data migration rewrites production contract↔artifact links — back up, forward-only, validate row counts before/after.
- Behavior change: propagated edits via shared artifacts stop working. Must be a no-op in practice (Phase 0) or an accepted, changelogged break.
- REST/UI: the two create endpoints' artifact-id request fields go away; coordinate the UI types. Contract-create URLs + response field names stay.
upsert_field_bindingsstays — the inspector relies on it withrevision; it is not a per-artifact writer in the create sense.- Reversibility: de-sharing is forward-only; the down migration can restore the schema but not re-merge cloned artifacts.
Out of scope¶
- The other three stores (already 1:1, already migrated).
upsert_field_bindingsand all per-artifact getters (stay public).