Skip to content

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. See contract-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_artifact in api/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_bindings rows are referenced by more than one contract in real databases? (A GROUP BY artifact_id HAVING count(*) > 1 over the FK columns.)
  • Does the UI editor actually expose "link to an existing artifact", or do the create_from_existing_artifacts / create_mixed endpoints only serve programmatic/import callers? (Audit ui/src for the affordance; the metadata POST responses' metadata_id/rule_id are 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_mixed with a single contract-create path that takes a whole document and calls store.save(name, version, document). Remove find_artifact / validate_artifact_ids artifact-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_session primitives 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 and upsert_field_bindings (the inspector's binding-only patch with expected_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

  1. Data migration rewrites production contract↔artifact links — back up, forward-only, validate row counts before/after.
  2. Behavior change: propagated edits via shared artifacts stop working. Must be a no-op in practice (Phase 0) or an accepted, changelogged break.
  3. REST/UI: the two create endpoints' artifact-id request fields go away; coordinate the UI types. Contract-create URLs + response field names stay.
  4. upsert_field_bindings stays — the inspector relies on it with revision; it is not a per-artifact writer in the create sense.
  5. 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_bindings and all per-artifact getters (stay public).