diff --git a/CannedPromptFormat-v0.1.md b/CannedPromptFormat-v0.1.md index a316f58..a32ad18 100644 --- a/CannedPromptFormat-v0.1.md +++ b/CannedPromptFormat-v0.1.md @@ -119,42 +119,8 @@ 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 -: -``` - -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. @@ -636,12 +602,6 @@ Published `@` 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 -`@` 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; @@ -672,12 +632,6 @@ 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. @@ -721,70 +675,6 @@ Conceptually, a registry stores immutable package versions keyed by: @ ``` -### 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 `@` 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/ -└── / - └── / - └── / -``` - The reference implementation uses the filesystem layout: ```text @@ -799,7 +689,6 @@ For example: ```text registry/ -├── registry.yaml └── practice/ └── pqrst-estimate/ └── 0.1.0/ @@ -807,9 +696,6 @@ 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: @@ -829,14 +715,6 @@ 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 -`:` 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; diff --git a/README.md b/README.md index 47c35ad..35dec48 100644 --- a/README.md +++ b/README.md @@ -39,15 +39,6 @@ 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 @@ -76,24 +67,6 @@ 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. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 437bb44..fff90f3 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -13,7 +13,7 @@ | task | CANP-WP-0001-T01 | done | — | workplans/CANP-WP-0001-statehub-bootstrap.md | | task | CANP-WP-0001-T02 | done | — | workplans/CANP-WP-0001-statehub-bootstrap.md | | task | CANP-WP-0001-T03 | done | — | workplans/CANP-WP-0001-statehub-bootstrap.md | -| task | CANP-WP-0002-T01 | done | — | workplans/CANP-WP-0002-format-open-questions.md | +| task | CANP-WP-0002-T01 | todo | — | workplans/CANP-WP-0002-format-open-questions.md | | task | CANP-WP-0002-T02 | todo | — | workplans/CANP-WP-0002-format-open-questions.md | | task | CANP-WP-0002-T03 | todo | — | workplans/CANP-WP-0002-format-open-questions.md | | task | CANP-WP-0002-T04 | todo | — | workplans/CANP-WP-0002-format-open-questions.md | diff --git a/reference/README.md b/reference/README.md index 4e0592d..1fe1360 100644 --- a/reference/README.md +++ b/reference/README.md @@ -30,35 +30,18 @@ Default locations: ~/.canned-prompts/registry ``` -A **registry** stores packages flat, because an id is unambiguous within one -registry: +Catalog and registry both store packages as: ```text -///... -``` - -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 -////... +///... ``` For example: ```text -~/.canned-prompts/catalog/house/practice/pqrst-estimate/0.1.0/ -~/.canned-prompts/catalog/local/practice/pqrst-estimate/0.1.0/ +~/.canned-prompts/catalog/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 `:`. -A bare id installed from more than one registry is reported as ambiguous -rather than resolved by guessing. - ## Design choices - YAML manifest via PyYAML. @@ -67,11 +50,7 @@ rather than resolved by guessing. - 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, and - print qualified `:` 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. +- `search`, `show`, `resolve`, and `render` operate on catalog packages. - 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 diff --git a/reference/canned_prompts.py b/reference/canned_prompts.py index ac9c448..3a55179 100755 --- a/reference/canned_prompts.py +++ b/reference/canned_prompts.py @@ -20,9 +20,6 @@ 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") @@ -219,72 +216,6 @@ 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 `:` 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): @@ -292,14 +223,8 @@ def id_path(store: Path, package_id: str) -> Path: return store.joinpath(*parts) -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 package_path(store: Path, package_id: str, version: str) -> Path: + return id_path(store, package_id) / version def parse_semver(value: str) -> tuple[int, int, int, str]: @@ -309,80 +234,24 @@ 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_at(base: Path) -> list[str]: +def versions_for(store: Path, package_id: str) -> list[str]: + base = id_path(store, package_id) 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 pick_version(base: Path, label: str, version: str | None) -> Path: +def resolve_installed(store: Path, package_id: str, version: str | None) -> Path: if version: - path = base / version - if not (path / "prompt.yaml").is_file(): - raise CannedPromptError(f"package not found: {label}@{version}") + path = package_path(store, package_id, version) + if not path.is_dir(): + raise CannedPromptError(f"package not found: {package_id}@{version}") return path - versions = versions_at(base) + versions = versions_for(store, package_id) if not versions: - 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 `:` 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 + raise CannedPromptError(f"package not found: {package_id}") + return package_path(store, package_id, versions[0]) def copy_immutable(src: Path, dst: Path, what: str) -> None: @@ -392,72 +261,51 @@ def copy_immutable(src: Path, dst: Path, what: str) -> None: shutil.copytree(src, dst) -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 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 cmd_add(args: argparse.Namespace) -> None: src = Path(args.path) manifest = validate_package(src) catalog = Path(args.catalog).expanduser() - registry = check_registry_name(args.as_registry) - dst = catalog_package_path(catalog, registry, manifest["id"], manifest["version"]) + dst = package_path(catalog, manifest["id"], manifest["version"]) copy_immutable(src.resolve(), dst, "catalog package") - print(f"added {registry}:{manifest['id']}@{manifest['version']} -> {dst}") + print(f"added {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() - 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"]) + dst = package_path(registry, manifest["id"], manifest["version"]) copy_immutable(src.resolve(), dst, "published package") - label = f"{name}:{manifest['id']}" if name else manifest["id"] - print(f"published {label}@{manifest['version']} -> {dst}") + print(f"published {manifest['id']}@{manifest['version']} -> {dst}") def cmd_install(args: argparse.Namespace) -> None: registry = Path(args.registry).expanduser() catalog = Path(args.catalog).expanduser() - 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) + src = resolve_installed(registry, args.id, args.version) manifest = validate_package(src) - dst = catalog_package_path(catalog, name, manifest["id"], manifest["version"]) + dst = package_path(catalog, manifest["id"], manifest["version"]) copy_immutable(src, dst, "catalog package") - print(f"installed {name}:{manifest['id']}@{manifest['version']} -> {dst}") + print(f"installed {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 registry, _, manifest in iter_catalog(catalog): + for _, manifest in iter_packages(catalog): haystack = " ".join( [ str(manifest.get("id", "")), @@ -469,14 +317,13 @@ 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["registry"], m["id"], parse_semver(m["version"]))) + matches.sort(key=lambda m: (m["id"], parse_semver(m["version"])), reverse=False) if args.json: print(json.dumps(matches, indent=2, ensure_ascii=False)) return @@ -484,13 +331,13 @@ def cmd_search(args: argparse.Namespace) -> None: print("no matches") return for item in matches: - print(f"{item['registry']}:{item['id']}@{item['version']} {item['name']}") + print(f"{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)) @@ -621,7 +468,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) @@ -644,7 +491,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) @@ -691,13 +538,6 @@ 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") diff --git a/reference/tests/test_canned_prompts.py b/reference/tests/test_canned_prompts.py index 245e1d1..458ab49 100644 --- a/reference/tests/test_canned_prompts.py +++ b/reference/tests/test_canned_prompts.py @@ -220,119 +220,3 @@ 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) diff --git a/workplans/CANP-WP-0002-format-open-questions.md b/workplans/CANP-WP-0002-format-open-questions.md index 625bbe2..2e7c8ba 100644 --- a/workplans/CANP-WP-0002-format-open-questions.md +++ b/workplans/CANP-WP-0002-format-open-questions.md @@ -117,7 +117,7 @@ Delivered: ```task id: CANP-WP-0002-T02 -status: done +status: todo priority: high state_hub_task_id: "a52cd41e-2bcc-50ee-96a1-d645a06df922" ``` @@ -126,54 +126,12 @@ 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. -**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 - `:` 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. +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). ## Prompt composition and inheritance