CANP-WP-0002 T01: input defaults, static and derived

Closes the gap that made optional inputs unusable: rendering rule 5.1(4)
made any unresolved placeholder an error while inputs had no `default`, so
an input marked `required: false` and referenced from the template failed
every render in which the caller omitted it — including the spec's own
section 4 example.

Section 10 already carried the derivation mechanism (`requirement: generate`,
resolution deliberately undefined), so a derived default needed a binding
rather than a new concept: the input's default names a declared prompt
dependency.

Spec:
- 5.1 rewritten as "Resolution and rendering". Resolution may be
  non-deterministic and must report what it derived; rendering is
  deterministic and must not derive. A tool that handles only supplied
  values and static defaults is stated to be conforming.
- 6.1 (new) covers both declaration forms. Reference form is preferred, with
  the reason stated — an inline prompt is anonymous, so unversioned,
  unprovenanced and un-evaluable — and validators should warn when a
  published package derives inline.
- A derived default may declare a static fallback `value`. Without one the
  input stays unresolved, which is an error; derivation never silently
  yields empty content.
- 4, 10, 18 (rules 11-13), 19 (two new MUST NOTs), 21 updated accordingly.

Reference CLI:
- New `resolve` verb reporting the origin of every value.
- `Resolution` dataclass and `resolve_inputs`; `resolve_values` kept as a
  wrapper so existing callers are unaffected.
- `render` refuses with a specific error naming underivable inputs rather
  than substituting empty text.
- Tests 3 -> 11. Example package lifecycle re-verified end to end.

INTENT.md is unchanged: splitting resolve from render preserves success
criterion 4 (deterministic rendering) as written.

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 00:59:20 +02:00
parent 0caf065544
commit 169db25d25
6 changed files with 600 additions and 36 deletions

View file

@ -2,12 +2,13 @@
This is intentionally a **small reference implementation**, not the intended final architecture.
It demonstrates six verbs:
It demonstrates seven verbs:
```text
add PATH
search QUERY
show ID
resolve ID --set key=value
render ID --set key=value
install ID [--version VERSION]
publish PATH
@ -15,6 +16,11 @@ publish PATH
The implementation uses a local catalog plus a filesystem registry and performs no model calls.
`resolve` and `render` are separate because the specification separates them
(§ 5.1): resolution decides each value and may be non-deterministic, rendering
substitutes and always is. `resolve` prints where every value came from —
supplied, default, or fallback — before any prompt is produced.
## Stores
Default locations:
@ -44,6 +50,10 @@ For example:
- Published versions are immutable by default.
- `install` copies from registry to catalog.
- `add` copies a package directly to catalog.
- `search`, `show`, and `render` operate on catalog packages.
- `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
unresolved and `render` refuses rather than substituting empty text.
Use this implementation to challenge the format. Replace it once real usage reveals the right architecture.

View file

@ -13,6 +13,7 @@ import os
import re
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
@ -92,6 +93,78 @@ def declared_names(manifest: dict[str, Any]) -> set[str]:
return names
def input_default_kind(item: dict[str, Any]) -> str:
"""Classify an input's default as 'none', 'static', or 'derived' (§ 6.1)."""
if "default" not in item:
return "none"
default = item["default"]
if isinstance(default, dict) and "derive" in default:
return "derived"
return "static"
def prompt_dependency_ids(manifest: dict[str, Any]) -> set[str]:
dependencies = manifest.get("dependencies") or {}
if not isinstance(dependencies, dict):
raise CannedPromptError("dependencies must be a mapping")
prompts = dependencies.get("prompts") or []
if not isinstance(prompts, list):
raise CannedPromptError("dependencies.prompts must be a list")
ids: set[str] = set()
for entry in prompts:
if isinstance(entry, str):
ids.add(entry)
elif isinstance(entry, dict) and isinstance(entry.get("id"), str):
ids.add(entry["id"])
else:
raise CannedPromptError(
"each prompt dependency must be an id string or a mapping with a string id"
)
return ids
def validate_input_default(item: dict[str, Any], dependency_ids: set[str]) -> None:
"""Validation rules 11-13 of § 18."""
kind = input_default_kind(item)
if kind == "none":
return
name = item["name"]
if item.get("required", False):
raise CannedPromptError(
f"input {name!r} declares a default and required: true; a required "
"input is always supplied, so the default could never apply"
)
if kind == "static":
return
derive = item["default"]["derive"]
if isinstance(derive, str):
if not derive.strip():
raise CannedPromptError(f"input {name!r}: derive reference is empty")
if derive not in dependency_ids:
raise CannedPromptError(
f"input {name!r} derives from {derive!r}, which is not declared "
"in dependencies.prompts"
)
return
if isinstance(derive, dict):
prompt = derive.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
raise CannedPromptError(
f"input {name!r}: inline derive requires a non-empty prompt"
)
extra = sorted(set(derive) - {"prompt"})
if extra:
raise CannedPromptError(
f"input {name!r}: an inline derive cannot also reference a "
"dependency; unexpected keys: " + ", ".join(extra)
)
return
raise CannedPromptError(
f"input {name!r}: derive must be a dependency id or a mapping with a prompt"
)
def validate_package(package_dir: Path) -> dict[str, Any]:
package_dir = package_dir.resolve()
if not package_dir.is_dir():
@ -127,6 +200,11 @@ def validate_package(package_dir: Path) -> dict[str, Any]:
safe_relative_file(package_dir, relative, field)
names = declared_names(manifest)
dependency_ids = prompt_dependency_ids(manifest)
for item in manifest.get("inputs") or []:
validate_input_default(item, dependency_ids)
template = template_path.read_text(encoding="utf-8")
placeholders = set(PLACEHOLDER_RE.findall(template))
undeclared = sorted(placeholders - names)
@ -305,8 +383,23 @@ def supplied_values(pairs: list[str]) -> dict[str, str]:
return values
def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict[str, Any]:
resolved: dict[str, Any] = {}
@dataclass
class Resolution:
"""The outcome of § 5.1 resolution, separate from rendering."""
values: dict[str, Any] = field(default_factory=dict)
origins: dict[str, str] = field(default_factory=dict)
underivable: list[str] = field(default_factory=list)
def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Resolution:
"""Resolve inputs and parameters per § 5.1.
This tool never calls a model, so a derived default is satisfied only by its
static fallback `value`. An input with a derived default and no fallback is
reported as underivable rather than silently rendered as empty.
"""
result = Resolution()
known: set[str] = set()
inputs = manifest.get("inputs") or []
@ -314,23 +407,48 @@ def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict
name = item["name"]
known.add(name)
if name in raw_values:
resolved[name] = raw_values[name]
elif item.get("required", False):
result.values[name] = raw_values[name]
result.origins[name] = "supplied"
continue
if item.get("required", False):
raise CannedPromptError(f"missing required input: {name}")
kind = input_default_kind(item)
if kind == "static":
result.values[name] = item["default"]
result.origins[name] = "default"
elif kind == "derived":
default = item["default"]
if "value" in default:
result.values[name] = default["value"]
result.origins[name] = "fallback (not derived)"
else:
result.origins[name] = "unresolved (derived, no fallback)"
result.underivable.append(name)
else:
result.origins[name] = "unresolved (optional, no default)"
parameters = manifest.get("parameters") or {}
for name, spec in parameters.items():
known.add(name)
if name in raw_values:
resolved[name] = coerce_value(raw_values[name], spec)
result.values[name] = coerce_value(raw_values[name], spec)
result.origins[name] = "supplied"
elif "default" in spec:
resolved[name] = spec["default"]
result.values[name] = spec["default"]
result.origins[name] = "default"
else:
result.origins[name] = "unresolved (no default)"
unknown = sorted(set(raw_values) - known)
if unknown:
raise CannedPromptError("unknown values: " + ", ".join(unknown))
return resolved
return result
def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict[str, Any]:
return resolve_inputs(manifest, raw_values).values
def render_template(template: str, values: dict[str, Any]) -> str:
@ -354,11 +472,65 @@ def cmd_render(args: argparse.Namespace) -> None:
manifest = validate_package(package_dir)
template_path = safe_relative_file(package_dir, manifest["template"], "template")
raw = supplied_values(args.set_values)
values = resolve_values(manifest, raw)
rendered = render_template(template_path.read_text(encoding="utf-8"), values)
resolution = resolve_inputs(manifest, raw)
template = template_path.read_text(encoding="utf-8")
used = set(PLACEHOLDER_RE.findall(template))
blocked = sorted(used.intersection(resolution.underivable))
if blocked:
raise CannedPromptError(
"cannot render: this tool does not derive values, and these inputs "
"declare a derived default with no static fallback: "
+ ", ".join(blocked)
+ " — supply them with --set, or add a fallback `value` to the default"
)
rendered = render_template(template, resolution.values)
print(rendered, end="" if rendered.endswith("\n") else "\n")
def cmd_resolve(args: argparse.Namespace) -> None:
catalog = Path(args.catalog).expanduser()
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)
if args.json:
print(
json.dumps(
{
"values": resolution.values,
"origins": resolution.origins,
"underivable": resolution.underivable,
},
indent=2,
ensure_ascii=False,
)
)
return
width = max((len(name) for name in resolution.origins), default=0)
for name, origin in resolution.origins.items():
if name in resolution.values:
preview = str(resolution.values[name]).replace("\n", " ")
if len(preview) > 48:
preview = preview[:45] + "..."
print(f"{name:<{width}} {origin:<32} {preview}")
else:
print(f"{name:<{width}} {origin}")
if resolution.underivable:
print()
plural = len(resolution.underivable) > 1
print(
"note: "
+ ", ".join(resolution.underivable)
+ (" declare" if plural else " declares")
+ " a derived default this tool cannot satisfy; rendering will fail "
+ ("if the template uses them" if plural else "if the template uses it")
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="canned-prompts", description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
@ -388,6 +560,14 @@ def build_parser() -> argparse.ArgumentParser:
render.add_argument("--set", dest="set_values", action="append", default=[], metavar="NAME=VALUE")
render.set_defaults(func=cmd_render)
resolve = sub.add_parser("resolve", help="report how each input and parameter resolves")
resolve.add_argument("id")
resolve.add_argument("--version")
resolve.add_argument("--catalog", default=str(default_catalog()))
resolve.add_argument("--set", dest="set_values", action="append", default=[], metavar="NAME=VALUE")
resolve.add_argument("--json", action="store_true")
resolve.set_defaults(func=cmd_resolve)
install = sub.add_parser("install", help="install a package from a filesystem registry")
install.add_argument("id")
install.add_argument("--version")

View file

@ -51,3 +51,172 @@ def test_undeclared_placeholder_fails(package: Path) -> None:
(package / "prompt.md").write_text("{{ missing }}\n", encoding="utf-8")
with pytest.raises(cp.CannedPromptError, match="undeclared placeholders"):
cp.validate_package(package)
def write_pkg(pkg: Path, manifest: str, template: str = "{{ greeting }}\n") -> Path:
pkg.mkdir(exist_ok=True)
(pkg / "prompt.yaml").write_text(manifest, encoding="utf-8")
(pkg / "prompt.md").write_text(template, encoding="utf-8")
return pkg
BASE = """\
format: canned-prompt/v0.1
id: demo/defaults
name: Defaults
version: 1.0.0
summary: Exercise input defaults.
template: prompt.md
"""
def test_static_default_fills_optional_input(tmp_path: Path) -> None:
pkg = write_pkg(
tmp_path / "p",
BASE
+ """\
inputs:
- name: greeting
required: false
default: "hello there"
""",
)
manifest = cp.validate_package(pkg)
resolution = cp.resolve_inputs(manifest, {})
assert resolution.values["greeting"] == "hello there"
assert resolution.origins["greeting"] == "default"
assert cp.render_template("{{ greeting }}", resolution.values) == "hello there"
def test_supplied_value_overrides_static_default(tmp_path: Path) -> None:
pkg = write_pkg(
tmp_path / "p",
BASE
+ """\
inputs:
- name: greeting
required: false
default: "hello there"
""",
)
manifest = cp.validate_package(pkg)
resolution = cp.resolve_inputs(manifest, {"greeting": "hi"})
assert resolution.values["greeting"] == "hi"
assert resolution.origins["greeting"] == "supplied"
def test_derived_default_uses_static_fallback(tmp_path: Path) -> None:
pkg = write_pkg(
tmp_path / "p",
BASE
+ """\
dependencies:
prompts:
- id: context/greeting
version: 1.0.0
requirement: generate
inputs:
- name: greeting
required: false
default:
derive: context/greeting
value: "(none)"
""",
)
manifest = cp.validate_package(pkg)
resolution = cp.resolve_inputs(manifest, {})
assert resolution.values["greeting"] == "(none)"
assert resolution.origins["greeting"] == "fallback (not derived)"
assert resolution.underivable == []
def test_derived_default_without_fallback_is_underivable(tmp_path: Path) -> None:
pkg = write_pkg(
tmp_path / "p",
BASE
+ """\
dependencies:
prompts:
- id: context/greeting
version: 1.0.0
requirement: generate
inputs:
- name: greeting
required: false
default:
derive: context/greeting
""",
)
manifest = cp.validate_package(pkg)
resolution = cp.resolve_inputs(manifest, {})
assert resolution.underivable == ["greeting"]
assert "greeting" not in resolution.values
with pytest.raises(cp.CannedPromptError, match="unresolved placeholder"):
cp.render_template("{{ greeting }}", resolution.values)
def test_inline_derive_is_valid(tmp_path: Path) -> None:
pkg = write_pkg(
tmp_path / "p",
BASE
+ """\
inputs:
- name: greeting
required: false
default:
derive:
prompt: Produce a greeting suited to the audience.
value: "(none)"
""",
)
manifest = cp.validate_package(pkg)
assert cp.resolve_inputs(manifest, {}).values["greeting"] == "(none)"
def test_default_with_required_true_fails(tmp_path: Path) -> None:
pkg = write_pkg(
tmp_path / "p",
BASE
+ """\
inputs:
- name: greeting
required: true
default: "hello"
""",
)
with pytest.raises(cp.CannedPromptError, match="required: true"):
cp.validate_package(pkg)
def test_derive_reference_must_be_declared(tmp_path: Path) -> None:
pkg = write_pkg(
tmp_path / "p",
BASE
+ """\
inputs:
- name: greeting
required: false
default:
derive: context/greeting
""",
)
with pytest.raises(cp.CannedPromptError, match="not declared in dependencies"):
cp.validate_package(pkg)
def test_inline_derive_cannot_also_reference(tmp_path: Path) -> None:
pkg = write_pkg(
tmp_path / "p",
BASE
+ """\
inputs:
- name: greeting
required: false
default:
derive:
prompt: Produce a greeting.
id: context/greeting
""",
)
with pytest.raises(cp.CannedPromptError, match="cannot also reference"):
cp.validate_package(pkg)