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
namespacekey, dropuq_data_contracts_name_version, createuq_data_contracts_ns_name_version. - MongoDB: extend the
data_contractscompound 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:
namespacegiven → exact(namespace, name, version)match.namespaceomitted → match on name; if exactly one namespace holds that name, return it (today's behaviour, so every existing caller keeps working); if several do, raiseAmbiguousContractErrorlisting 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/namecolumns; 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 oftitle. 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. titlesurvives as an optional, display-only metadata key (likestatus): 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.titlecolumn (NOT NULL, unique(title, version)) from the contract name instead of the payload title — no silent artifact renames via themetadata_for_<name>_<version>fallback. Column rename tonameis optional and can ride the NS-2 migration. - ODCS codec maps ODCS
name↔metadata.name(today:metadata.titlewith 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.namewhennameis 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/ legacySchemaListItem.titlebecome display conveniences fed fromname(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_recordsis the contract's identity record. It carries and is keyed by(namespace, name, version), moving in lockstep withdata_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. nameidentifies,titledisplays — everywhere. All artifact identity columns renametitle → namein one migration wave; API reuse fields become*_namewith*_titlekept as deprecated aliases.- Delete safety: the four pool artifacts'
data_contract_idFKs become nullable provenance withondelete=SET NULL— deleting a contract never destroys pool artifacts other contracts or versions reference.metadata_recordskeeps 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)¶
- NS-8 title retirement — canonical metadata
namereplaces thetitleidentity anchor (read-timetitle → namemigration 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. - NS-2 migration + store ABC — column, constraint, backfill, the
namespace=keyword with ambiguity handling, backend parity tests (in-memory / SQLite / Postgres / MongoDB / Redis). - NS-6 ingest — seed loader + parse/upload paths pass the envelope namespace; seed round-trip tests updated.
- NS-4 API — routes + response models + OpenAPI docs; 409 ambiguity contract test.
- NS-5 UI — thread
SelectedContract.namespacethrough load/save and editor API calls; save dialog copy. - 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
orderin namespacesecommerceandlogisticscoexist 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.titlefor identity; a legacy document carrying onlytitlestill loads (read-time migration), and a display title that differs from the name round-trips untouched. - Every existing seed file ingests unchanged;
./scripts/ci.shgreen.