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
|
|
@ -119,8 +119,42 @@ Rules for v0.1:
|
||||||
- `/`, `-`, `_`, and `.` MAY be used;
|
- `/`, `-`, `_`, and `.` MAY be used;
|
||||||
- whitespace MUST NOT be used;
|
- whitespace MUST NOT be used;
|
||||||
- the ID MUST NOT contain `..` path traversal segments;
|
- 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.
|
- 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`
|
#### `name`
|
||||||
|
|
||||||
Human-readable display 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.
|
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:
|
Suggested versioning guidance:
|
||||||
|
|
||||||
- PATCH: wording/metadata correction with intended behavior unchanged;
|
- 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
|
A validator SHOULD additionally warn when a package intended for publication
|
||||||
declares an inline derivation prompt (§ 6.1).
|
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
|
## 19. Security requirements
|
||||||
|
|
||||||
Prompt packages are content, not trusted code.
|
Prompt packages are content, not trusted code.
|
||||||
|
|
@ -675,6 +721,70 @@ Conceptually, a registry stores immutable package versions keyed by:
|
||||||
<id>@<version>
|
<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:
|
The reference implementation uses the filesystem layout:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -689,6 +799,7 @@ For example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
registry/
|
registry/
|
||||||
|
├── registry.yaml
|
||||||
└── practice/
|
└── practice/
|
||||||
└── pqrst-estimate/
|
└── pqrst-estimate/
|
||||||
└── 0.1.0/
|
└── 0.1.0/
|
||||||
|
|
@ -696,6 +807,9 @@ registry/
|
||||||
└── prompt.md
|
└── 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
|
## 21. Reference CLI semantics
|
||||||
|
|
||||||
The v0.1 reference tool uses two stores:
|
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
|
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
|
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
|
values and static defaults only and reports any input whose derived default it
|
||||||
cannot satisfy. `render` performs the same resolution and then substitutes;
|
cannot satisfy. `render` performs the same resolution and then substitutes;
|
||||||
|
|
|
||||||
27
README.md
27
README.md
|
|
@ -39,6 +39,15 @@ python canned_prompts.py publish ../examples/pqrst-estimate
|
||||||
python canned_prompts.py install practice/pqrst-estimate --version 0.1.0
|
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:
|
By default the reference tool uses:
|
||||||
|
|
||||||
```text
|
```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
|
static defaults only, and reports anything it cannot derive instead of
|
||||||
rendering a prompt with a silent hole in it.
|
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
|
## 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -30,18 +30,35 @@ Default locations:
|
||||||
~/.canned-prompts/registry
|
~/.canned-prompts/registry
|
||||||
```
|
```
|
||||||
|
|
||||||
Catalog and registry both store packages as:
|
A **registry** stores packages flat, because an id is unambiguous within one
|
||||||
|
registry:
|
||||||
|
|
||||||
```text
|
```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:
|
For example:
|
||||||
|
|
||||||
```text
|
```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
|
## Design choices
|
||||||
|
|
||||||
- YAML manifest via PyYAML.
|
- YAML manifest via PyYAML.
|
||||||
|
|
@ -50,7 +67,11 @@ For example:
|
||||||
- Published versions are immutable by default.
|
- Published versions are immutable by default.
|
||||||
- `install` copies from registry to catalog.
|
- `install` copies from registry to catalog.
|
||||||
- `add` copies a package directly 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
|
- 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
|
tool never calls a model, so a derived default is satisfied only by its
|
||||||
static fallback `value`. Without one, `resolve` reports the input as
|
static fallback `value`. Without one, `resolve` reports the input as
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ from typing import Any, Iterable
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
FORMAT = "canned-prompt/v0.1"
|
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*}}")
|
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*)(?:[-+].*)?$")
|
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")
|
REQUIRED_FIELDS = ("format", "id", "name", "version", "summary", "template")
|
||||||
|
|
@ -216,6 +219,72 @@ def validate_package(package_dir: Path) -> dict[str, Any]:
|
||||||
return manifest
|
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:
|
def id_path(store: Path, package_id: str) -> Path:
|
||||||
parts = package_id.split("/")
|
parts = package_id.split("/")
|
||||||
if any(part in ("", ".", "..") for part in parts):
|
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)
|
return store.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
def package_path(store: Path, package_id: str, version: str) -> Path:
|
def registry_package_path(registry: Path, package_id: str, version: str) -> Path:
|
||||||
return id_path(store, package_id) / version
|
"""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]:
|
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)
|
return (int(match.group(1)), int(match.group(2)), int(match.group(3)), value)
|
||||||
|
|
||||||
|
|
||||||
def versions_for(store: Path, package_id: str) -> list[str]:
|
def versions_at(base: Path) -> list[str]:
|
||||||
base = id_path(store, package_id)
|
|
||||||
if not base.is_dir():
|
if not base.is_dir():
|
||||||
return []
|
return []
|
||||||
versions = [p.name for p in base.iterdir() if p.is_dir() and (p / "prompt.yaml").is_file()]
|
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)
|
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:
|
if version:
|
||||||
path = package_path(store, package_id, version)
|
path = base / version
|
||||||
if not path.is_dir():
|
if not (path / "prompt.yaml").is_file():
|
||||||
raise CannedPromptError(f"package not found: {package_id}@{version}")
|
raise CannedPromptError(f"package not found: {label}@{version}")
|
||||||
return path
|
return path
|
||||||
versions = versions_for(store, package_id)
|
versions = versions_at(base)
|
||||||
if not versions:
|
if not versions:
|
||||||
raise CannedPromptError(f"package not found: {package_id}")
|
raise CannedPromptError(f"package not found: {label}")
|
||||||
return package_path(store, package_id, versions[0])
|
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:
|
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)
|
shutil.copytree(src, dst)
|
||||||
|
|
||||||
|
|
||||||
def iter_packages(store: Path) -> Iterable[tuple[Path, dict[str, Any]]]:
|
def iter_catalog(catalog: Path) -> Iterable[tuple[str, Path, dict[str, Any]]]:
|
||||||
if not store.exists():
|
for name in catalog_registries(catalog):
|
||||||
return
|
for manifest_path in (catalog / name).rglob("prompt.yaml"):
|
||||||
for manifest_path in store.rglob("prompt.yaml"):
|
package_dir = manifest_path.parent
|
||||||
package_dir = manifest_path.parent
|
try:
|
||||||
try:
|
manifest = validate_package(package_dir)
|
||||||
manifest = validate_package(package_dir)
|
except CannedPromptError:
|
||||||
except CannedPromptError:
|
continue
|
||||||
continue
|
yield name, package_dir, manifest
|
||||||
yield package_dir, manifest
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_add(args: argparse.Namespace) -> None:
|
def cmd_add(args: argparse.Namespace) -> None:
|
||||||
src = Path(args.path)
|
src = Path(args.path)
|
||||||
manifest = validate_package(src)
|
manifest = validate_package(src)
|
||||||
catalog = Path(args.catalog).expanduser()
|
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")
|
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:
|
def cmd_publish(args: argparse.Namespace) -> None:
|
||||||
src = Path(args.path)
|
src = Path(args.path)
|
||||||
manifest = validate_package(src)
|
manifest = validate_package(src)
|
||||||
registry = Path(args.registry).expanduser()
|
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")
|
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:
|
def cmd_install(args: argparse.Namespace) -> None:
|
||||||
registry = Path(args.registry).expanduser()
|
registry = Path(args.registry).expanduser()
|
||||||
catalog = Path(args.catalog).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)
|
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")
|
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:
|
def cmd_search(args: argparse.Namespace) -> None:
|
||||||
catalog = Path(args.catalog).expanduser()
|
catalog = Path(args.catalog).expanduser()
|
||||||
query = args.query.lower()
|
query = args.query.lower()
|
||||||
matches: list[dict[str, str]] = []
|
matches: list[dict[str, str]] = []
|
||||||
for _, manifest in iter_packages(catalog):
|
for registry, _, manifest in iter_catalog(catalog):
|
||||||
haystack = " ".join(
|
haystack = " ".join(
|
||||||
[
|
[
|
||||||
str(manifest.get("id", "")),
|
str(manifest.get("id", "")),
|
||||||
|
|
@ -317,13 +469,14 @@ def cmd_search(args: argparse.Namespace) -> None:
|
||||||
if query in haystack:
|
if query in haystack:
|
||||||
matches.append(
|
matches.append(
|
||||||
{
|
{
|
||||||
|
"registry": registry,
|
||||||
"id": manifest["id"],
|
"id": manifest["id"],
|
||||||
"version": manifest["version"],
|
"version": manifest["version"],
|
||||||
"name": manifest["name"],
|
"name": manifest["name"],
|
||||||
"summary": manifest["summary"],
|
"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:
|
if args.json:
|
||||||
print(json.dumps(matches, indent=2, ensure_ascii=False))
|
print(json.dumps(matches, indent=2, ensure_ascii=False))
|
||||||
return
|
return
|
||||||
|
|
@ -331,13 +484,13 @@ def cmd_search(args: argparse.Namespace) -> None:
|
||||||
print("no matches")
|
print("no matches")
|
||||||
return
|
return
|
||||||
for item in matches:
|
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']}")
|
print(f" {item['summary']}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_show(args: argparse.Namespace) -> None:
|
def cmd_show(args: argparse.Namespace) -> None:
|
||||||
catalog = Path(args.catalog).expanduser()
|
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)
|
manifest = validate_package(package_dir)
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(manifest, indent=2, ensure_ascii=False))
|
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:
|
def cmd_render(args: argparse.Namespace) -> None:
|
||||||
catalog = Path(args.catalog).expanduser()
|
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)
|
manifest = validate_package(package_dir)
|
||||||
template_path = safe_relative_file(package_dir, manifest["template"], "template")
|
template_path = safe_relative_file(package_dir, manifest["template"], "template")
|
||||||
raw = supplied_values(args.set_values)
|
raw = supplied_values(args.set_values)
|
||||||
|
|
@ -491,7 +644,7 @@ def cmd_render(args: argparse.Namespace) -> None:
|
||||||
|
|
||||||
def cmd_resolve(args: argparse.Namespace) -> None:
|
def cmd_resolve(args: argparse.Namespace) -> None:
|
||||||
catalog = Path(args.catalog).expanduser()
|
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)
|
manifest = validate_package(package_dir)
|
||||||
raw = supplied_values(args.set_values)
|
raw = supplied_values(args.set_values)
|
||||||
resolution = resolve_inputs(manifest, raw)
|
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 = sub.add_parser("add", help="add a package directory to the local catalog")
|
||||||
add.add_argument("path")
|
add.add_argument("path")
|
||||||
add.add_argument("--catalog", default=str(default_catalog()))
|
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)
|
add.set_defaults(func=cmd_add)
|
||||||
|
|
||||||
search = sub.add_parser("search", help="search the local catalog")
|
search = sub.add_parser("search", help="search the local catalog")
|
||||||
|
|
|
||||||
|
|
@ -220,3 +220,119 @@ inputs:
|
||||||
)
|
)
|
||||||
with pytest.raises(cp.CannedPromptError, match="cannot also reference"):
|
with pytest.raises(cp.CannedPromptError, match="cannot also reference"):
|
||||||
cp.validate_package(pkg)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ Delivered:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CANP-WP-0002-T02
|
id: CANP-WP-0002-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "a52cd41e-2bcc-50ee-96a1-d645a06df922"
|
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
|
authors publishing `practice/…` into one registry collide today; `add` and
|
||||||
`publish` only refuse an exact `id@version` that already exists.
|
`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
|
**Sharpened during the work.** § 17 already said what a *registry* does on
|
||||||
filesystem registry records the claim, and what a consumer does on conflict.
|
collision; nothing said what a *consumer* does. That is where the conflict
|
||||||
Leaning: keep v0.1's local-first stance — namespace ownership is a *registry
|
actually bites — in the catalog, after installing from two registries.
|
||||||
policy*, declared by the registry rather than by the package — so package
|
|
||||||
semantics do not change when a hosted registry appears later.
|
**Decisions (operator, 2026-09-06):**
|
||||||
Signing and trust scoring stay out of scope (§ 23, INTENT non-goals).
|
|
||||||
|
- *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
|
## Prompt composition and inheritance
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue