CANP-WP-0002 T02: registry-scoped identity and namespace ownership
Section 17 already defined what a registry does when the same id@version is republished; nothing defined what a *consumer* does. That is where namespace conflict actually bites — in the catalog, after installing from two registries. Identity is now registry-scoped: an id names a package within a registry, the way a path names a file within a repository. A local-first format with no signing, no federation and no central authority cannot enforce global uniqueness, and an unenforceable guarantee is worse than none — it invites consumers to conflate two packages that merely share a name. A qualified `<registry>:<id>` reference distinguishes them, and `:` is now barred from ids so the separator stays available. Installing the same id from two registries is therefore not a conflict. The catalog is namespaced by registry and keeps both. Ownership is registry policy, not package data. An optional `registry.yaml` names a registry and records namespace claims. Those claims are explicitly descriptive — a filesystem registry cannot authenticate a publisher, and `publish` says so rather than implying it checked. Keeping the claim out of packages leaves artifacts free of unverifiable assertions of authority, and means package semantics do not change when a hosted registry appears later. Spec: 3.2 (registry-scoped identity, qualified references), 17 (immutability scoped to a registry), 20.1 and 20.2 (new), 18 (registry-manifest validation), 21 (qualified references, reserved `local` name). Reference CLI: parse_reference, check_registry_name, read_registry_manifest, registry_name, namespace_policy; registry_package_path and catalog_package_path split; resolve_installed reports ambiguity and returns the source registry; iter_catalog; `add --as`; closed-namespace warning on publish. Tests 11 -> 21. The catalog layout changed. An existing catalog is detected and reported with instructions rather than failing as "package not found". Signing, trust scoring and federation remain non-goals and were not touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM Assistant: claude-code Assistant-Model: opus Assistant-Process: 388925@bnt-lap001 Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
This commit is contained in:
parent
4a8422e1aa
commit
ea70a59610
6 changed files with 533 additions and 45 deletions
|
|
@ -220,3 +220,119 @@ inputs:
|
|||
)
|
||||
with pytest.raises(cp.CannedPromptError, match="cannot also reference"):
|
||||
cp.validate_package(pkg)
|
||||
|
||||
|
||||
MINIMAL = """\
|
||||
format: canned-prompt/v0.1
|
||||
id: practice/thing
|
||||
name: Thing
|
||||
version: 1.0.0
|
||||
summary: A thing.
|
||||
template: prompt.md
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: false
|
||||
default: hi
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_reference() -> None:
|
||||
assert cp.parse_reference("practice/thing") == (None, "practice/thing")
|
||||
assert cp.parse_reference("house:practice/thing") == ("house", "practice/thing")
|
||||
with pytest.raises(cp.CannedPromptError, match="malformed reference"):
|
||||
cp.parse_reference("house:")
|
||||
|
||||
|
||||
def test_registry_name_falls_back_to_basename(tmp_path: Path) -> None:
|
||||
registry = tmp_path / "upstream"
|
||||
registry.mkdir()
|
||||
assert cp.registry_name(registry) == "upstream"
|
||||
|
||||
|
||||
def test_registry_name_from_manifest(tmp_path: Path) -> None:
|
||||
registry = tmp_path / "some-dir"
|
||||
registry.mkdir()
|
||||
(registry / "registry.yaml").write_text(
|
||||
"format: canned-prompt-registry/v0.1\nname: house\n", encoding="utf-8"
|
||||
)
|
||||
assert cp.registry_name(registry) == "house"
|
||||
|
||||
|
||||
def test_registry_manifest_rejects_bad_format(tmp_path: Path) -> None:
|
||||
registry = tmp_path / "r"
|
||||
registry.mkdir()
|
||||
(registry / "registry.yaml").write_text(
|
||||
"format: something-else\nname: house\n", encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(cp.CannedPromptError, match="unsupported registry format"):
|
||||
cp.read_registry_manifest(registry)
|
||||
|
||||
|
||||
def test_registry_manifest_rejects_bad_policy(tmp_path: Path) -> None:
|
||||
registry = tmp_path / "r"
|
||||
registry.mkdir()
|
||||
(registry / "registry.yaml").write_text(
|
||||
"format: canned-prompt-registry/v0.1\n"
|
||||
"name: house\n"
|
||||
"namespaces:\n practice:\n policy: maybe\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(cp.CannedPromptError, match="policy must be"):
|
||||
cp.read_registry_manifest(registry)
|
||||
|
||||
|
||||
def test_namespace_policy(tmp_path: Path) -> None:
|
||||
registry = tmp_path / "r"
|
||||
registry.mkdir()
|
||||
(registry / "registry.yaml").write_text(
|
||||
"format: canned-prompt-registry/v0.1\n"
|
||||
"name: house\n"
|
||||
"namespaces:\n practice:\n owner: Ada\n policy: closed\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert cp.namespace_policy(registry, "practice/thing")[0] == "closed"
|
||||
assert cp.namespace_policy(registry, "scratch/thing")[0] == "open"
|
||||
|
||||
|
||||
def install_into(catalog: Path, registry: str) -> None:
|
||||
"""Place a package in the catalog under a given registry name."""
|
||||
dst = cp.catalog_package_path(catalog, registry, "practice/thing", "1.0.0")
|
||||
dst.mkdir(parents=True)
|
||||
(dst / "prompt.yaml").write_text(MINIMAL, encoding="utf-8")
|
||||
(dst / "prompt.md").write_text("{{ greeting }}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def test_same_id_from_two_registries_coexists(tmp_path: Path) -> None:
|
||||
catalog = tmp_path / "catalog"
|
||||
install_into(catalog, "house")
|
||||
install_into(catalog, "upstream")
|
||||
assert cp.catalog_registries(catalog) == ["house", "upstream"]
|
||||
|
||||
package_dir, registry = cp.resolve_installed(catalog, "house:practice/thing", None)
|
||||
assert registry == "house"
|
||||
assert package_dir.is_dir()
|
||||
|
||||
|
||||
def test_bare_id_in_two_registries_is_ambiguous(tmp_path: Path) -> None:
|
||||
catalog = tmp_path / "catalog"
|
||||
install_into(catalog, "house")
|
||||
install_into(catalog, "upstream")
|
||||
with pytest.raises(cp.CannedPromptError, match="more than one registry"):
|
||||
cp.resolve_installed(catalog, "practice/thing", None)
|
||||
|
||||
|
||||
def test_bare_id_in_one_registry_resolves(tmp_path: Path) -> None:
|
||||
catalog = tmp_path / "catalog"
|
||||
install_into(catalog, "house")
|
||||
_, registry = cp.resolve_installed(catalog, "practice/thing", None)
|
||||
assert registry == "house"
|
||||
|
||||
|
||||
def test_legacy_catalog_layout_is_reported(tmp_path: Path) -> None:
|
||||
catalog = tmp_path / "catalog"
|
||||
legacy = catalog / "practice" / "thing" / "1.0.0"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "prompt.yaml").write_text(MINIMAL, encoding="utf-8")
|
||||
(legacy / "prompt.md").write_text("{{ greeting }}\n", encoding="utf-8")
|
||||
with pytest.raises(cp.CannedPromptError, match="pre-registry-scoped"):
|
||||
cp.resolve_installed(catalog, "practice/thing", None)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue