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:
tegwick 2026-09-06 01:14:54 +02:00
parent 4a8422e1aa
commit ea70a59610
6 changed files with 533 additions and 45 deletions

View file

@ -119,8 +119,42 @@ Rules for v0.1:
- `/`, `-`, `_`, and `.` MAY be used;
- whitespace MUST NOT be used;
- the ID MUST NOT contain `..` path traversal segments;
- `:` MUST NOT be used, so that it remains available as the registry separator
in a qualified reference (below);
- registry implementations MUST treat the ID as logical metadata rather than an unchecked filesystem path.
##### Identity is registry-scoped
An `id` names a package **within a registry**, the way a path names a file
within a repository. CPF v0.1 does **not** claim that an id is globally unique:
`practice/pqrst-estimate` obtained from two different registries may be two
different packages, and a consumer that draws on more than one registry MUST
keep track of which registry each package came from.
This follows from what the format actually guarantees. A local-first format
with no signing, no federation and no central authority (all explicit
non-goals) cannot enforce global uniqueness, and a guarantee that cannot be
enforced is worse than none — it invites consumers to conflate two packages
that merely share a name.
Where a consumer must distinguish them, a **qualified reference** names the
registry:
```text
<registry>:<id>
```
For example:
```text
house:practice/pqrst-estimate
upstream:practice/pqrst-estimate
```
An unqualified id is acceptable wherever it is unambiguous for that consumer.
A tool that finds the same id in more than one registry MUST report the
ambiguity rather than choosing for the caller.
#### `name`
Human-readable display name.
@ -602,6 +636,12 @@ Published `<id>@<version>` pairs SHOULD be immutable.
A registry SHOULD reject publication of a package when the same ID/version already exists with different contents unless an explicit administrative override mechanism exists.
Immutability is scoped to a registry, because identity is (§ 3.2). The same
`<id>@<version>` held by two registries is two packages, and a consumer holding
both is not in a conflict — it is holding two things whose names happen to
coincide. Only a repeat publication *within one registry* violates
immutability.
Suggested versioning guidance:
- PATCH: wording/metadata correction with intended behavior unchanged;
@ -632,6 +672,12 @@ A v0.1 validator SHOULD verify at least:
A validator SHOULD additionally warn when a package intended for publication
declares an inline derivation prompt (§ 6.1).
A validator that is given a **registry** rather than a package SHOULD verify,
when `registry.yaml` is present, that `format` is
`canned-prompt-registry/v0.1`, that `name` is a non-empty string usable in a
qualified reference, and that each `namespaces` entry declares a `policy` of
`open` or `closed`.
## 19. Security requirements
Prompt packages are content, not trusted code.
@ -675,6 +721,70 @@ Conceptually, a registry stores immutable package versions keyed by:
<id>@<version>
```
### 20.1 Registry identity and namespace ownership
A registry MAY declare itself with a `registry.yaml` at its root:
```yaml
format: canned-prompt-registry/v0.1
name: house
description: Internal prompt registry.
namespaces:
practice:
owner: Ada Example
policy: closed
scratch:
policy: open
```
Fields:
| Field | Required | Meaning |
|---|---:|---|
| `format` | yes | MUST be `canned-prompt-registry/v0.1` |
| `name` | yes | Short registry name, used in qualified references (§ 3.2) |
| `description` | no | Human-readable explanation |
| `namespaces` | no | Mapping of namespace to its claim |
Within `namespaces`, `owner` is a human-readable claim and `policy` is
`closed` (only the owner publishes) or `open` (anyone may). Both are
descriptive: a filesystem registry has no way to authenticate a publisher, and
authentication, signing and trust scoring are all explicit non-goals.
The file is **optional**. A bare directory remains a valid registry; a consumer
that finds no manifest SHOULD take the registry's name from how it was
addressed — for the reference implementation, the registry directory's
basename.
**Ownership is a property of the registry, not of the package.** A package
never declares who owns its namespace. This keeps the claim where it can
actually be acted on — the registry decides what it admits — and keeps
packages free of unverifiable assertions of authority, consistent with § 19's
position that a package is content rather than a trusted actor. It also means
package semantics do not change when a filesystem registry is later replaced
by a hosted one.
A registry SHOULD refuse to publish into a `closed` namespace it does not
consider the publisher to own. How it decides is registry policy and is
outside this specification.
### 20.2 Consumer-side layout
A consumer drawing on more than one registry MUST keep packages from different
registries distinct, because identity is registry-scoped (§ 3.2). Installing
the same `<id>@<version>` from two registries is not an error and MUST NOT
overwrite: both are retained and addressed by qualified reference.
The reference implementation stores its catalog as:
```text
catalog/
└── <registry name>/
└── <id path>/
└── <version>/
```
The reference implementation uses the filesystem layout:
```text
@ -689,6 +799,7 @@ For example:
```text
registry/
├── registry.yaml
└── practice/
└── pqrst-estimate/
└── 0.1.0/
@ -696,6 +807,9 @@ registry/
└── prompt.md
```
A registry's own layout is not namespaced by registry name: within one
registry, an id is unambiguous by definition.
## 21. Reference CLI semantics
The v0.1 reference tool uses two stores:
@ -715,6 +829,14 @@ publish PATH validate and copy a package into a filesystem registry
install ID copy a package version from the registry into the catalog
```
Every command that takes an `ID` accepts either a bare id or a qualified
`<registry>:<id>` reference (§ 3.2). A bare id that matches packages installed
from more than one registry is reported as ambiguous, listing the candidates,
rather than resolved by guessing.
`add` takes a package from a path rather than from a registry, so it files the
package under the reserved registry name `local`.
The reference tool never calls a model, so its `resolve` handles supplied
values and static defaults only and reports any input whose derived default it
cannot satisfy. `render` performs the same resolution and then substitutes;

View file

@ -39,6 +39,15 @@ python canned_prompts.py publish ../examples/pqrst-estimate
python canned_prompts.py install practice/pqrst-estimate --version 0.1.0
```
Packages added from a path are filed under the registry name `local`; packages
installed from a registry are filed under that registry's name. `search` prints
qualified references:
```text
house:practice/pqrst-estimate@0.1.0 PQRST Estimate
local:practice/pqrst-estimate@0.1.0 PQRST Estimate
```
By default the reference tool uses:
```text
@ -67,6 +76,24 @@ This reference tool never calls a model, so it resolves supplied values and
static defaults only, and reports anything it cannot derive instead of
rendering a prompt with a silent hole in it.
## Registries and identity
An id names a package *within a registry* (`CannedPromptFormat-v0.1.md`
§ 3.2). The same id obtained from two registries may be two different
packages, so the catalog keeps them apart and a bare id that matches more than
one is reported as ambiguous rather than guessed. Qualify it when you need to:
```bash
python canned_prompts.py render house:practice/pqrst-estimate --set ...
```
A registry may describe itself with an optional `registry.yaml` naming it and
recording which namespaces are claimed and under what policy. Those claims are
descriptive: a filesystem registry cannot authenticate a publisher, and
signing and trust scoring are explicit non-goals. Ownership lives with the
registry rather than in the package, so no package carries an unverifiable
assertion of authority.
## Deliberate limitations
This seed has no hosted registry, model execution, authentication, network access, dependency resolver, or social features. `publish` and `install` operate on a filesystem registry so that the package semantics can be tested before infrastructure is built around them.

View file

@ -30,18 +30,35 @@ Default locations:
~/.canned-prompts/registry
```
Catalog and registry both store packages as:
A **registry** stores packages flat, because an id is unambiguous within one
registry:
```text
<store>/<id path>/<version>/...
<registry>/<id path>/<version>/...
```
A **catalog** is namespaced by registry, because identity is registry-scoped
(§ 3.2) and the same id may be installed from more than one place:
```text
<catalog>/<registry name>/<id path>/<version>/...
```
For example:
```text
~/.canned-prompts/catalog/practice/pqrst-estimate/0.1.0/
~/.canned-prompts/catalog/house/practice/pqrst-estimate/0.1.0/
~/.canned-prompts/catalog/local/practice/pqrst-estimate/0.1.0/
```
A registry's name comes from its optional `registry.yaml`, and otherwise from
its directory basename. `add` takes a package from a path rather than a
registry, so it files it under `local` (override with `--as`).
Commands that take an ID accept a bare id or a qualified `<registry>:<id>`.
A bare id installed from more than one registry is reported as ambiguous
rather than resolved by guessing.
## Design choices
- YAML manifest via PyYAML.
@ -50,7 +67,11 @@ For example:
- Published versions are immutable by default.
- `install` copies from registry to catalog.
- `add` copies a package directly to catalog.
- `search`, `show`, `resolve`, and `render` operate on catalog packages.
- `search`, `show`, `resolve`, and `render` operate on catalog packages, and
print qualified `<registry>:<id>` references.
- An optional `registry.yaml` names a registry and records namespace claims.
`publish` warns when a namespace is declared `closed` — it cannot
authenticate a publisher, and says so rather than implying it checked.
- Static input defaults are applied; **derived** defaults (§ 6.1) are not. This
tool never calls a model, so a derived default is satisfied only by its
static fallback `value`. Without one, `resolve` reports the input as

View file

@ -20,6 +20,9 @@ from typing import Any, Iterable
import yaml
FORMAT = "canned-prompt/v0.1"
REGISTRY_FORMAT = "canned-prompt-registry/v0.1"
LOCAL_REGISTRY = "local"
REGISTRY_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
PLACEHOLDER_RE = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+].*)?$")
REQUIRED_FIELDS = ("format", "id", "name", "version", "summary", "template")
@ -216,6 +219,72 @@ def validate_package(package_dir: Path) -> dict[str, Any]:
return manifest
def check_registry_name(name: str) -> str:
if not isinstance(name, str) or not REGISTRY_NAME_RE.match(name):
raise CannedPromptError(
f"invalid registry name: {name!r}; expected letters, digits, '.', '-' or '_'"
)
return name
def parse_reference(reference: str) -> tuple[str | None, str]:
"""Split a qualified `<registry>:<id>` reference (§ 3.2)."""
if ":" not in reference:
return None, reference
registry, _, package_id = reference.partition(":")
if not registry or not package_id:
raise CannedPromptError(f"malformed reference: {reference!r}")
return check_registry_name(registry), package_id
def read_registry_manifest(registry: Path) -> dict[str, Any] | None:
"""Read an optional registry.yaml (§ 20.1). A bare directory is still valid."""
manifest_path = registry / "registry.yaml"
if not manifest_path.is_file():
return None
try:
data = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise CannedPromptError(f"invalid YAML in {manifest_path}: {exc}") from exc
if not isinstance(data, dict):
raise CannedPromptError("registry.yaml must contain a mapping")
if data.get("format") != REGISTRY_FORMAT:
raise CannedPromptError(f"unsupported registry format: {data.get('format')!r}")
check_registry_name(data.get("name"))
namespaces = data.get("namespaces") or {}
if not isinstance(namespaces, dict):
raise CannedPromptError("registry namespaces must be a mapping")
for namespace, claim in namespaces.items():
if not isinstance(claim, dict):
raise CannedPromptError(f"namespace {namespace!r} must map to a mapping")
policy = claim.get("policy", "open")
if policy not in ("open", "closed"):
raise CannedPromptError(
f"namespace {namespace!r}: policy must be 'open' or 'closed'"
)
return data
def registry_name(registry: Path) -> str:
"""A registry's name: its manifest's, else its directory basename (§ 20.1)."""
manifest = read_registry_manifest(registry)
if manifest:
return manifest["name"]
return check_registry_name(registry.expanduser().resolve().name)
def namespace_policy(registry: Path, package_id: str) -> tuple[str, dict[str, Any] | None]:
manifest = read_registry_manifest(registry)
if not manifest:
return "open", None
namespace = package_id.split("/")[0] if "/" in package_id else package_id
claim = (manifest.get("namespaces") or {}).get(namespace)
if not claim:
return "open", None
return claim.get("policy", "open"), claim
def id_path(store: Path, package_id: str) -> Path:
parts = package_id.split("/")
if any(part in ("", ".", "..") for part in parts):
@ -223,8 +292,14 @@ def id_path(store: Path, package_id: str) -> Path:
return store.joinpath(*parts)
def package_path(store: Path, package_id: str, version: str) -> Path:
return id_path(store, package_id) / version
def registry_package_path(registry: Path, package_id: str, version: str) -> Path:
"""A registry's own layout is flat: an id is unambiguous within a registry."""
return id_path(registry, package_id) / version
def catalog_package_path(catalog: Path, registry: str, package_id: str, version: str) -> Path:
"""The catalog is namespaced by registry (§ 20.2)."""
return id_path(catalog / check_registry_name(registry), package_id) / version
def parse_semver(value: str) -> tuple[int, int, int, str]:
@ -234,24 +309,80 @@ def parse_semver(value: str) -> tuple[int, int, int, str]:
return (int(match.group(1)), int(match.group(2)), int(match.group(3)), value)
def versions_for(store: Path, package_id: str) -> list[str]:
base = id_path(store, package_id)
def versions_at(base: Path) -> list[str]:
if not base.is_dir():
return []
versions = [p.name for p in base.iterdir() if p.is_dir() and (p / "prompt.yaml").is_file()]
return sorted(versions, key=parse_semver, reverse=True)
def resolve_installed(store: Path, package_id: str, version: str | None) -> Path:
def pick_version(base: Path, label: str, version: str | None) -> Path:
if version:
path = package_path(store, package_id, version)
if not path.is_dir():
raise CannedPromptError(f"package not found: {package_id}@{version}")
path = base / version
if not (path / "prompt.yaml").is_file():
raise CannedPromptError(f"package not found: {label}@{version}")
return path
versions = versions_for(store, package_id)
versions = versions_at(base)
if not versions:
raise CannedPromptError(f"package not found: {package_id}")
return package_path(store, package_id, versions[0])
raise CannedPromptError(f"package not found: {label}")
return base / versions[0]
def resolve_in_registry(registry: Path, package_id: str, version: str | None) -> Path:
return pick_version(id_path(registry, package_id), package_id, version)
def catalog_registries(catalog: Path) -> list[str]:
if not catalog.is_dir():
return []
return sorted(p.name for p in catalog.iterdir() if p.is_dir())
def legacy_catalog_entry(catalog: Path, package_id: str) -> bool:
"""True if the pre-registry-scoped catalog layout holds this id."""
try:
base = id_path(catalog, package_id)
except CannedPromptError:
return False
return bool(versions_at(base))
def resolve_installed(
catalog: Path, reference: str, version: str | None
) -> tuple[Path, str]:
"""Resolve a bare id or a qualified `<registry>:<id>` against the catalog.
Returns the package directory and the registry it was installed from. A
bare id present in more than one registry is ambiguous and is reported, not
guessed (§ 3.2).
"""
registry, package_id = parse_reference(reference)
if registry is not None:
matches = [registry] if versions_at(id_path(catalog / registry, package_id)) else []
else:
matches = [
name
for name in catalog_registries(catalog)
if versions_at(id_path(catalog / name, package_id))
]
if not matches:
if registry is None and legacy_catalog_entry(catalog, package_id):
raise CannedPromptError(
f"{package_id} is stored in the pre-registry-scoped catalog layout; "
"catalogs are now namespaced by registry (§ 20.2). Re-add or "
"re-install the package, or move it under a registry directory"
)
raise CannedPromptError(f"package not found: {reference}")
if len(matches) > 1:
raise CannedPromptError(
f"{package_id} is installed from more than one registry: "
+ ", ".join(f"{name}:{package_id}" for name in matches)
+ " — qualify the reference"
)
name = matches[0]
return pick_version(id_path(catalog / name, package_id), f"{name}:{package_id}", version), name
def copy_immutable(src: Path, dst: Path, what: str) -> None:
@ -261,51 +392,72 @@ def copy_immutable(src: Path, dst: Path, what: str) -> None:
shutil.copytree(src, dst)
def iter_packages(store: Path) -> Iterable[tuple[Path, dict[str, Any]]]:
if not store.exists():
return
for manifest_path in store.rglob("prompt.yaml"):
package_dir = manifest_path.parent
try:
manifest = validate_package(package_dir)
except CannedPromptError:
continue
yield package_dir, manifest
def iter_catalog(catalog: Path) -> Iterable[tuple[str, Path, dict[str, Any]]]:
for name in catalog_registries(catalog):
for manifest_path in (catalog / name).rglob("prompt.yaml"):
package_dir = manifest_path.parent
try:
manifest = validate_package(package_dir)
except CannedPromptError:
continue
yield name, package_dir, manifest
def cmd_add(args: argparse.Namespace) -> None:
src = Path(args.path)
manifest = validate_package(src)
catalog = Path(args.catalog).expanduser()
dst = package_path(catalog, manifest["id"], manifest["version"])
registry = check_registry_name(args.as_registry)
dst = catalog_package_path(catalog, registry, manifest["id"], manifest["version"])
copy_immutable(src.resolve(), dst, "catalog package")
print(f"added {manifest['id']}@{manifest['version']} -> {dst}")
print(f"added {registry}:{manifest['id']}@{manifest['version']} -> {dst}")
def cmd_publish(args: argparse.Namespace) -> None:
src = Path(args.path)
manifest = validate_package(src)
registry = Path(args.registry).expanduser()
dst = package_path(registry, manifest["id"], manifest["version"])
name = registry_name(registry) if registry.is_dir() else None
policy, claim = namespace_policy(registry, manifest["id"]) if registry.is_dir() else ("open", None)
if policy == "closed":
owner = (claim or {}).get("owner")
suffix = f" (owner: {owner})" if owner else ""
print(
f"warning: namespace {manifest['id'].split('/')[0]!r} is declared closed"
f"{suffix}; this tool cannot authenticate a publisher",
file=sys.stderr,
)
dst = registry_package_path(registry, manifest["id"], manifest["version"])
copy_immutable(src.resolve(), dst, "published package")
print(f"published {manifest['id']}@{manifest['version']} -> {dst}")
label = f"{name}:{manifest['id']}" if name else manifest["id"]
print(f"published {label}@{manifest['version']} -> {dst}")
def cmd_install(args: argparse.Namespace) -> None:
registry = Path(args.registry).expanduser()
catalog = Path(args.catalog).expanduser()
src = resolve_installed(registry, args.id, args.version)
name = registry_name(registry)
qualifier, package_id = parse_reference(args.id)
if qualifier is not None and qualifier != name:
raise CannedPromptError(
f"reference names registry {qualifier!r} but --registry is {name!r}"
)
src = resolve_in_registry(registry, package_id, args.version)
manifest = validate_package(src)
dst = package_path(catalog, manifest["id"], manifest["version"])
dst = catalog_package_path(catalog, name, manifest["id"], manifest["version"])
copy_immutable(src, dst, "catalog package")
print(f"installed {manifest['id']}@{manifest['version']} -> {dst}")
print(f"installed {name}:{manifest['id']}@{manifest['version']} -> {dst}")
def cmd_search(args: argparse.Namespace) -> None:
catalog = Path(args.catalog).expanduser()
query = args.query.lower()
matches: list[dict[str, str]] = []
for _, manifest in iter_packages(catalog):
for registry, _, manifest in iter_catalog(catalog):
haystack = " ".join(
[
str(manifest.get("id", "")),
@ -317,13 +469,14 @@ def cmd_search(args: argparse.Namespace) -> None:
if query in haystack:
matches.append(
{
"registry": registry,
"id": manifest["id"],
"version": manifest["version"],
"name": manifest["name"],
"summary": manifest["summary"],
}
)
matches.sort(key=lambda m: (m["id"], parse_semver(m["version"])), reverse=False)
matches.sort(key=lambda m: (m["registry"], m["id"], parse_semver(m["version"])))
if args.json:
print(json.dumps(matches, indent=2, ensure_ascii=False))
return
@ -331,13 +484,13 @@ def cmd_search(args: argparse.Namespace) -> None:
print("no matches")
return
for item in matches:
print(f"{item['id']}@{item['version']} {item['name']}")
print(f"{item['registry']}:{item['id']}@{item['version']} {item['name']}")
print(f" {item['summary']}")
def cmd_show(args: argparse.Namespace) -> None:
catalog = Path(args.catalog).expanduser()
package_dir = resolve_installed(catalog, args.id, args.version)
package_dir, _ = resolve_installed(catalog, args.id, args.version)
manifest = validate_package(package_dir)
if args.json:
print(json.dumps(manifest, indent=2, ensure_ascii=False))
@ -468,7 +621,7 @@ def render_template(template: str, values: dict[str, Any]) -> str:
def cmd_render(args: argparse.Namespace) -> None:
catalog = Path(args.catalog).expanduser()
package_dir = resolve_installed(catalog, args.id, args.version)
package_dir, _ = resolve_installed(catalog, args.id, args.version)
manifest = validate_package(package_dir)
template_path = safe_relative_file(package_dir, manifest["template"], "template")
raw = supplied_values(args.set_values)
@ -491,7 +644,7 @@ def cmd_render(args: argparse.Namespace) -> None:
def cmd_resolve(args: argparse.Namespace) -> None:
catalog = Path(args.catalog).expanduser()
package_dir = resolve_installed(catalog, args.id, args.version)
package_dir, _ = resolve_installed(catalog, args.id, args.version)
manifest = validate_package(package_dir)
raw = supplied_values(args.set_values)
resolution = resolve_inputs(manifest, raw)
@ -538,6 +691,13 @@ def build_parser() -> argparse.ArgumentParser:
add = sub.add_parser("add", help="add a package directory to the local catalog")
add.add_argument("path")
add.add_argument("--catalog", default=str(default_catalog()))
add.add_argument(
"--as",
dest="as_registry",
default=LOCAL_REGISTRY,
metavar="REGISTRY",
help=f"catalog registry name to file the package under (default: {LOCAL_REGISTRY})",
)
add.set_defaults(func=cmd_add)
search = sub.add_parser("search", help="search the local catalog")

View file

@ -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)

View file

@ -117,7 +117,7 @@ Delivered:
```task
id: CANP-WP-0002-T02
status: todo
status: done
priority: high
state_hub_task_id: "a52cd41e-2bcc-50ee-96a1-d645a06df922"
```
@ -126,12 +126,54 @@ An id like `practice/pqrst-estimate` has a namespace prefix with no owner. Two
authors publishing `practice/…` into one registry collide today; `add` and
`publish` only refuse an exact `id@version` that already exists.
Decide, for § 3.2 and § 20: what a namespace means, who may claim one, how a
filesystem registry records the claim, and what a consumer does on conflict.
Leaning: keep v0.1's local-first stance — namespace ownership is a *registry
policy*, declared by the registry rather than by the package — so package
semantics do not change when a hosted registry appears later.
Signing and trust scoring stay out of scope (§ 23, INTENT non-goals).
**Sharpened during the work.** § 17 already said what a *registry* does on
collision; nothing said what a *consumer* does. That is where the conflict
actually bites — in the catalog, after installing from two registries.
**Decisions (operator, 2026-09-06):**
- *Identity is registry-scoped.* An id names a package within a registry, the
way a path names a file within a repository. The same id from two registries
may be two different packages. 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.
- *Ownership is registry policy.* An optional `registry.yaml` names the
registry and records namespace claims (`owner`, `policy: open | closed`).
Packages never assert who owns their namespace, keeping unverifiable
authority claims out of artifacts (§ 19) and leaving package semantics
unchanged when a filesystem registry is later replaced by a hosted one.
- *Installing the same id from two registries is allowed, not a conflict.* The
catalog is namespaced by registry, so both are retained. This follows from
registry-scoped identity rather than working around it: two packages that
merely share a name were never in conflict to begin with.
Delivered:
1. § 3.2 gains "Identity is registry-scoped" — the reasoning, the qualified
reference form, and a ban on `:` in ids so the separator stays available.
A tool finding one id in several registries MUST report the ambiguity.
2. § 17 scopes immutability to a registry, consistent with identity.
3. § 20.1 (new) specifies the optional `registry.yaml`: `format`, `name`,
`description`, `namespaces`. Claims are explicitly descriptive — a
filesystem registry cannot authenticate a publisher. A bare directory
remains a valid registry, named by its basename.
4. § 20.2 (new) requires consumers to keep registries distinct and documents
the reference catalog layout.
5. § 18 gains registry-manifest validation; § 21 documents qualified
references and the reserved `local` registry name.
6. `reference/canned_prompts.py`: `parse_reference`, `check_registry_name`,
`read_registry_manifest`, `registry_name`, `namespace_policy`, split
`registry_package_path` / `catalog_package_path`, a `resolve_installed`
that reports ambiguity and returns the source registry, `iter_catalog`,
`--as` on `add`, a publish-time warning on closed namespaces, and a
specific error for the pre-registry-scoped catalog layout. Tests 11 → 21.
**Migration note:** the catalog layout changed. An existing catalog from
before this change 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.
## Prompt composition and inheritance