Skip to content

PyCharter — Namespace-Scoped Contract Identity (NS)

Status: implemented (NS-101…110, #455–#470; follow-ups in BACKLOG.md) · Created: 2026-07-13 · Audience: maintainers · Depends on: kubernetes-style-manifests.md (KM-1) · Related: contract-immutable-versions.md

Why

KM-1 declares the artifact identity to be (metadata.namespace, metadata.name, metadata.version). For the TH-envelope kinds this is real today: streaming, pipeline, and bridge configs fold the namespace into their name column on unwrap (metadata: {name: orders, namespace: example} → stream_name = "example/orders"), so their store uniqueness effectively covers all three parts.

Data contracts never got the namespace half. The data_contracts table has no namespace column and its unique constraint is (name, version) (uq_data_contracts_name_version). The namespace lives only inside the metadata JSON payload, and shared/name_validator.py documents the current stance explicitly: namespaces are "virtual folder paths, not identifiers." Contract names also cannot adopt the example/orders folding as-is — NAME_PATTERN (^[a-z0-9_]+$) rejects slashes and hyphens, and the bare name is baked into API routes, store ABC signatures, seed keys, and the UI.

Consequence: two contracts both named order in different namespaces collide in every backend. As the seed catalog grows (multiple examples already ship an order-shaped contract) and multi-team use arrives, the KM-1 identity needs to be true for contracts too.

The name/title dedup (2026-07-13) already made metadata.name the single identity carried by contract manifests; this proposal completes the tuple.

Decisions

NS-1 — The contract identity is (namespace, name, version)

namespace is nullable; NULL means the default/global namespace and is distinct from every named namespace. name keeps NAME_PATTERN (no slashes); namespace keeps NAMESPACE_PATTERN (slash-delimited segments, hyphens allowed). The manifest metadata.namespace is authoritative on ingest — the document is the source of truth, exactly as KM-1 planned.

NS-2 — Storage: a real namespace column, not name-folding

Add data_contracts.namespace (String(255), nullable) and widen the unique constraint to (namespace, name, version). Rejected alternative: folding namespace/name into the name column like the TH kinds — it requires loosening NAME_PATTERN, rewrites every stored name, and leaks the folded form into API paths and UI labels. Contracts are the relational hub of the schema (six artifact FKs hang off data_contracts); a column is the contained change.

  • SQL (SQLite / Postgres): Alembic migration — add column, backfill from each row's metadata payload namespace key, drop uq_data_contracts_name_version, create uq_data_contracts_ns_name_version.
  • MongoDB: extend the data_contracts compound index the same way; backfill in the migration script.
  • In-memory / Redis: key tuples gain the namespace part.

NS-3 — Lookup semantics: name-only stays valid while unambiguous

Store ABC methods (get_contract, get_json_schema, …) gain a keyword namespace: str | None = None:

  • namespace given → exact (namespace, name, version) match.
  • namespace omitted → match on name; if exactly one namespace holds that name, return it (today's behaviour, so every existing caller keeps working); if several do, raise AmbiguousContractError listing the candidate namespaces.

This keeps the migration incremental: callers adopt the parameter where ambiguity is possible, everything else stays source-compatible.

NS-4 — API surface

Contract routes gain an optional namespace query parameter (or accept namespace/name in the path where a name is already path-encoded — pick one, do not support both). Response models expose namespace as a first-class field instead of burying it in the metadata payload. Ambiguous name-only lookups return 409 with the candidate list.

NS-5 — UI

SelectedContract.namespace already carries the value; it becomes part of the load/save identity rather than a browse-folder label. The save dialog's namespace field writes metadata.namespace; the file browser keeps grouping by it (no visual change). Editor API calls pass the namespace through.

NS-6 — Ingest and seeds

The seed loader and API ingest key on (metadata.namespace, metadata.name, version) from the manifest envelope. Existing seed rows backfill from their metadata payload. pycharter migrate --stores re-stamps nothing — this is a store-key change, not a file-format change; manifests already carry the namespace.

NS-7 — Non-goals

  • The TH kinds keep their folded namespace/name columns; harmonizing them onto real columns is a separate (optional) follow-up.
  • Artifact tables (json_schemas, coercion_rules, …) stay keyed by (title, version) + contract FK; artifact reuse remains namespace-agnostic by design (a shared artifact may be referenced from contracts in different namespaces).
  • No namespace-level ACLs / multi-tenancy semantics — namespaces scope identity, nothing else.

NS-8 — Retire metadata.title as identity: canonical metadata carries name

Maintainer direction (2026-07-13): dedupe name/title properly — the canonical sections stop anchoring identity on metadata.title.

  • Canonical metadata carries name (the identity slug) instead of title. Wrap/unwrap become symmetric field copies — the title-derivation, fallback-name ("contract"), and unwrap-synthesis machinery added by the 2026-07-13 dedup is deleted.
  • title survives as an optional, display-only metadata key (like status): free-form text, never read for identity by any code path. Seeds with genuine display titles (Market Trade) keep them; a title equal to the name is simply not written.
  • Store writers source the metadata_records.title column (NOT NULL, unique (title, version)) from the contract name instead of the payload title — no silent artifact renames via the metadata_for_<name>_<version> fallback. Column rename to name is optional and can ride the NS-2 migration.
  • ODCS codec maps ODCS name ↔ metadata.name (today: metadata.title with an "unknown" fallback); the KM-7 round-trip gate moves in the same change.
  • Read compatibility: a read-time migration step maps legacy metadata.title → metadata.name when name is absent, so old files and old stored payloads keep loading. Tolerant readers (docs generator, semantic extraction, API validators) switch to name-first with title fallback.
  • UI: SelectedContract.title / legacy SchemaListItem.title become display conveniences fed from name (or the optional display title when present).

NS-9 — Artifact layer: immutable global pool, name-based identity

Maintainer decisions (2026-07-14), superseding NS-7's "artifact reuse remains namespace-agnostic by design" wording — the delete-cascade hazard recorded on the NS-102 card contradicted it:

  • metadata_records is the contract's identity record. It carries and is keyed by (namespace, name, version), moving in lockstep with data_contracts. Cross-contract metadata sharing stops existing; same-contract cross-version reuse (fork) stays.
  • The other four artifacts (json_schemas, coercion_rules, validation_rules, field_bindings) are an immutable global pool (git-blob model, per contract-immutable-versions.md): own names and versions, no namespace column, any contract may point at them globally. Collision rule: identical content → no-op (idempotent re-save); different content → conflict error. The atomic save path's silent overwrite dies.
  • Auto-derived artifact names are namespace-prefixed for inline payloads from a namespaced contract (logistics_order_coercion_rules; / and - normalize to _), so contract twins never collide by default. Explicit names are honored verbatim.
  • name identifies, title displays — everywhere. All artifact identity columns rename title → name in one migration wave; API reuse fields become *_name with *_title kept as deprecated aliases.
  • Delete safety: the four pool artifacts' data_contract_id FKs become nullable provenance with ondelete=SET NULL — deleting a contract never destroys pool artifacts other contracts or versions reference. metadata_records keeps CASCADE (it is the contract). Orphaned-artifact GC is an explicit non-goal (follow-up).

Execution: cards NS-107 (schema + store), NS-108 (API surface), NS-109 (docs + close-out) on the program board.

Execution sketch (post-approval)

  1. NS-8 title retirement — canonical metadata name replaces the title identity anchor (read-time title → name migration step); wrap/unwrap simplified; store writers, ODCS codec, tolerant readers, seeds, and UI display fallbacks move together. Ships first: it simplifies the format layer every later phase builds on.
  2. NS-2 migration + store ABC — column, constraint, backfill, the namespace= keyword with ambiguity handling, backend parity tests (in-memory / SQLite / Postgres / MongoDB / Redis).
  3. NS-6 ingest — seed loader + parse/upload paths pass the envelope namespace; seed round-trip tests updated.
  4. NS-4 API — routes + response models + OpenAPI docs; 409 ambiguity contract test.
  5. NS-5 UI — thread SelectedContract.namespace through load/save and editor API calls; save dialog copy.
  6. Docs — docs/reference/ schema docs + changelog; PR flags the configuration/schema change per /config-change.

Risk register

Risk Mitigation
Existing rows whose metadata payload lacks namespace Backfill leaves NULL (global namespace) — identical to today's behaviour.
Callers that assume name-only uniqueness NS-3 keeps name-only lookups working until a real collision exists; the error names the namespaces so the fix is mechanical.
Backend drift (Mongo index vs SQL constraint) Backend-parity tests assert the same collision is rejected on every backend.
UI sending stale/empty namespace on save Save path treats empty string as NULL; round-trip test pins it.

Acceptance

  • Two contracts named order in namespaces ecommerce and logistics coexist on every backend; each loads, builds, and validates by (namespace, name, version).
  • Name-only lookup of an unambiguous contract behaves exactly as before the change; an ambiguous one fails with the candidate list.
  • No code path reads metadata.title for identity; a legacy document carrying only title still loads (read-time migration), and a display title that differs from the name round-trips untouched.
  • Every existing seed file ingests unchanged; ./scripts/ci.sh green.