CANP-WP-0002 T05: required versus observed, and typed context dependencies

The answer to the capability question was conditional: keep both fields if
they carry the required/observed distinction, fix the terminology if they do
not. They did not.

Section 9 opened with "records known requirements or observations", mixing
both in one field — `models` was observational ("known to be compatible or
evaluated") while `capabilities` was prescriptive ("expected from the
execution environment"). Section 10 then described dependencies as what a
prompt "expects". Both fields said expected, so the overlap was real
ambiguity rather than redundancy, and the fix is terminology.

`dependencies` now means **required**; `compatibility` means **observed**. A
consumer must not refuse to run a package because its environment is absent
from a compatibility list. The same capability name may legitimately appear in
both: required to run at all, and separately observed to work well on
particular models. `compatibility.aliases` records the same capability under
other names, so a consumer can recognize a requirement its environment labels
differently.

Dependencies now have three kinds, separated by what the format can do about
them: `prompts` it resolves by id and version; `context` names what it does
not package at all; `capabilities` are what the environment must be able to
do. Context entries use `name` rather than `id`, because nothing can look them
up, and `description` is required because nothing else can explain an
unpackaged dependency. A capability takes no version and no
`requirement: generate` — it is not an artifact and cannot be fetched, pinned
or generated. Capability names are free-form kebab-case, validated for shape
and not membership, exactly as tags are.

Section 10.1 also draws the line the format had never stated: an input is
content the caller passes for one use; a context dependency is a standing fact
about the environment.

Spec: 9 rewritten, 9.1 and 10.1 and 10.2 new, 10 reframed, 18 (rules 19-20),
4 updated. Former 10.1/10.2 renumbered to 10.3/10.4 with cross-references.

Reference CLI: validate_capabilities, validate_context_dependencies, and a
`resolve` section listing required capabilities and context under "this tool
cannot verify these" rather than implying it checked. Tests 51 -> 65.

Also drops an invented `session-review` capability from the example package in
favour of an honest `long-context` observation.

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 08:11:13 +02:00
parent 074fd79d53
commit c580bf63c9
8 changed files with 323 additions and 32 deletions

View file

@ -76,6 +76,8 @@ rather than resolved by guessing.
`canned-prompts/eval-rubric/v0.1` schema, and reports output criteria as
declared but not run. Unrecognized schemas are skipped, not rejected. A
failed render check exits non-zero.
- `resolve` lists required capabilities and context dependencies, which this
tool cannot verify, rather than implying it checked them.
- 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.

View file

@ -25,6 +25,7 @@ LOCAL_REGISTRY = "local"
REGISTRY_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
EVAL_RUBRIC_SCHEMA = "canned-prompts/eval-rubric/v0.1"
RENDER_CHECKS = ("contains", "not_contains", "resolves_all")
CAPABILITY_RE = re.compile(r"^[a-z0-9]+(?:-[a-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")
@ -137,6 +138,50 @@ def prompt_dependencies(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
return declared
def validate_capabilities(names: Any, where: str) -> list[str]:
"""Validation rule 20 of § 18: shape is checked, membership is not (§ 10.2)."""
if names is None:
return []
if not isinstance(names, list):
raise CannedPromptError(f"{where} must be a list")
for name in names:
if not isinstance(name, str) or not CAPABILITY_RE.match(name):
raise CannedPromptError(f"{where}: {name!r} must be lowercase kebab-case")
return list(names)
def validate_context_dependencies(manifest: dict[str, Any]) -> list[dict[str, Any]]:
"""Validation rule 19 of § 18.
A context dependency names something CPF does not package and cannot
resolve, so `description` is the only thing a consumer has to go on.
"""
dependencies = manifest.get("dependencies") or {}
entries = dependencies.get("context") or []
if not isinstance(entries, list):
raise CannedPromptError("dependencies.context must be a list")
for entry in entries:
if not isinstance(entry, dict):
raise CannedPromptError("each context dependency must be a mapping")
name = entry.get("name")
if not isinstance(name, str) or not CAPABILITY_RE.match(name):
raise CannedPromptError(
f"context dependency name {name!r} must be lowercase kebab-case; "
"context entries use 'name', not 'id', because nothing can look them up"
)
if not isinstance(entry.get("description"), str) or not entry["description"].strip():
raise CannedPromptError(
f"context dependency {name!r} must declare a description; nothing "
"else can explain a dependency the format does not package"
)
requirement = entry.get("requirement", "required")
if requirement not in ("required", "optional"):
raise CannedPromptError(
f"context dependency {name!r}: requirement must be 'required' or 'optional'"
)
return list(entries)
def read_eval(package_dir: Path, relative: str) -> dict[str, Any]:
path = safe_relative_file(package_dir, relative, "evals")
try:
@ -208,7 +253,7 @@ def check_composition_reference(
if "version" not in declared[reference]:
raise CannedPromptError(
f"input {name!r} {field}s {reference!r}, so that dependency must "
"declare a version (§ 10.1)"
"declare a version (§ 10.3)"
)
@ -303,6 +348,16 @@ def validate_package(package_dir: Path) -> dict[str, Any]:
for item in manifest.get("inputs") or []:
validate_input_default(item, declared)
validate_context_dependencies(manifest)
validate_capabilities(
(manifest.get("dependencies") or {}).get("capabilities"),
"dependencies.capabilities",
)
validate_capabilities(
(manifest.get("compatibility") or {}).get("capabilities"),
"compatibility.capabilities",
)
declared_examples = [str(path) for path in (manifest.get("examples") or [])]
for relative in manifest.get("evals") or []:
validate_eval(relative, read_eval(package_dir, relative), declared_examples)
@ -402,7 +457,7 @@ def catalog_package_path(catalog: Path, registry: str, package_id: str, version:
def validate_version_selector(value: Any, where: str) -> str:
"""A semver literal, `any`, `newest`, or a `>=` lower bound (§ 10.1)."""
"""A semver literal, `any`, `newest`, or a `>=` lower bound (§ 10.3)."""
if not isinstance(value, str) or not value.strip():
raise CannedPromptError(f"{where}: version must be a non-empty string")
selector = value.strip()
@ -422,7 +477,7 @@ def validate_version_selector(value: Any, where: str) -> str:
def select_version(available: list[str], selector: str | None) -> str | None:
"""Pick a version from `available` (newest first) per a § 10.1 selector.
"""Pick a version from `available` (newest first) per a § 10.3 selector.
Each dependency is selected independently against what is present. There is
no constraint solving across a dependency graph; that stays a non-goal.
@ -700,12 +755,12 @@ def resolve_inputs(
"""Resolve inputs and parameters per § 5.1.
`composer`, when supplied, satisfies `include` defaults by rendering the
included package (§ 10.2). Inclusion is deterministic, so a tool that can
included package (§ 10.4). Inclusion is deterministic, so a tool that can
render can satisfy it; derivation is not, and this tool never calls a model,
so a derived default is satisfied only by its static fallback `value`.
`inherited` carries the including package's already-resolved values into an
included package (§ 10.2). Those values are used as-is rather than coerced,
included package (§ 10.4). Those values are used as-is rather than coerced,
and do not count as caller-supplied.
"""
result = Resolution()
@ -729,7 +784,7 @@ def resolve_inputs(
result.underivable.append(name)
# Parameters resolve first: a composed input inherits the including
# package's resolved values (§ 10.2), so those must already be settled.
# package's resolved values (§ 10.4), so those must already be settled.
parameters = manifest.get("parameters") or {}
for name, spec in parameters.items():
known.add(name)
@ -794,7 +849,7 @@ def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict
class CatalogComposer:
"""Satisfies `include` defaults from the catalog (§ 10.2).
"""Satisfies `include` defaults from the catalog (§ 10.4).
Inclusion is text composition: the included package is rendered and its text
is inlined. No model is involved, so this is deterministic.
@ -935,6 +990,21 @@ def cmd_resolve(args: argparse.Namespace) -> None:
print(f"{name:<{width}} {origin:<{origin_width}} {preview}")
else:
print(f"{name:<{width}} {origin}")
dependencies = manifest.get("dependencies") or {}
capabilities = dependencies.get("capabilities") or []
context = [
entry
for entry in (dependencies.get("context") or [])
if entry.get("requirement", "required") == "required"
]
if capabilities or context:
print()
print("requires (this tool cannot verify these):")
for name in capabilities:
print(f" capability {name}")
for entry in context:
print(f" context {entry['name']}{entry['description'].strip()}")
if resolution.underivable:
print()
plural = len(resolution.underivable) > 1

View file

@ -615,3 +615,68 @@ def test_typed_fixture_values_are_not_reparsed(tmp_path: Path) -> None:
"""A YAML fixture carries real types; only CLI strings need parsing."""
manifest = {"parameters": {"flag": {"type": "boolean", "default": False}}}
assert cp.resolve_inputs(manifest, {"flag": True}).values["flag"] is True
# --- context and capability dependencies (§ 10.1, § 10.2) ---
def ctx_pkg(tmp_path: Path, body: str) -> Path:
return write_pkg(tmp_path / "p", BASE + body, "hello\n")
def test_context_dependency_requires_name_and_description(tmp_path: Path) -> None:
pkg = ctx_pkg(
tmp_path,
"dependencies:\n context:\n - name: repository-tree\n"
" description: A listing of the repository.\n",
)
assert cp.validate_package(pkg)["id"] == "demo/defaults"
def test_context_dependency_without_description_is_rejected(tmp_path: Path) -> None:
pkg = ctx_pkg(tmp_path, "dependencies:\n context:\n - name: repository-tree\n")
with pytest.raises(cp.CannedPromptError, match="must declare a description"):
cp.validate_package(pkg)
def test_context_dependency_using_id_is_rejected(tmp_path: Path) -> None:
"""Context entries use `name`; `id` would imply resolvable package identity."""
pkg = ctx_pkg(
tmp_path,
"dependencies:\n context:\n - id: policy/security\n"
" description: A policy.\n",
)
with pytest.raises(cp.CannedPromptError, match="not 'id'"):
cp.validate_package(pkg)
def test_context_requirement_generate_is_rejected(tmp_path: Path) -> None:
pkg = ctx_pkg(
tmp_path,
"dependencies:\n context:\n - name: repository-tree\n"
" description: A listing.\n requirement: generate\n",
)
with pytest.raises(cp.CannedPromptError, match="required' or 'optional"):
cp.validate_package(pkg)
@pytest.mark.parametrize("name", ["web-search", "code-analysis", "vision", "a1-b2"])
def test_valid_capability_names(name) -> None:
assert cp.validate_capabilities([name], "dependencies.capabilities") == [name]
@pytest.mark.parametrize("name", ["Web-Search", "web_search", "-lead", "trail-", ""])
def test_invalid_capability_names(name) -> None:
with pytest.raises(cp.CannedPromptError, match="kebab-case"):
cp.validate_capabilities([name], "dependencies.capabilities")
def test_capability_may_be_both_required_and_observed(tmp_path: Path) -> None:
"""§ 9.1: required to run at all, and separately observed to work well."""
pkg = ctx_pkg(
tmp_path,
"dependencies:\n capabilities:\n - web-search\n"
"compatibility:\n capabilities:\n - web-search\n - long-context\n",
)
manifest = cp.validate_package(pkg)
assert manifest["dependencies"]["capabilities"] == ["web-search"]
assert "long-context" in manifest["compatibility"]["capabilities"]