CANP-WP-0006 T03: read API

Search, versions, manifests, archives and the index, over HTTP.

The route shape is the decision worth recording. Package ids contain `/`, so
the obvious /packages/{id}/{version} is ambiguous under a greedy path
parameter. Rather than invent an HTTP-specific identifier, the routes speak the
format's own <registry>:<id>@<version> syntax and parse it — `:` and `@` are
both legal in a path segment, and each route keeps a distinct prefix so
greediness cannot swallow a neighbouring one. The API therefore exercises
section 3.2's reference notation instead of working around it.

A bare id present in more than one registry returns 409 with the candidates,
never a guess. 409 rather than 300 because the request is answerable once the
caller says which registry they meant. Omitting a version applies section
17.1's selector rules, so a prerelease is never chosen implicitly.

Validation is delegated to reference/, installed into the service environment
rather than reimplemented. One validator means the service and the CLI cannot
disagree about what a valid package is; a service accepting something the CLI
rejects would be the divergence this project exists to prevent. Importing it is
not changing it — reference/ stays the dependency-light conformance witness.

Storage keeps the format's distinctions: an immutable package version, its
files as content rather than parsed rows, and an index entry recording arrival.
Only reserved paths and manifest-referenced files are stored (section 2), and a
re-publish of identical content is accepted while different content under the
same id@version is a conflict (section 17).

Handles a real test-vs-production difference: SQLite autoincrements INTEGER
PRIMARY KEY only, never BIGINT, so the SQLite-backed tests could not insert a
row. BigInteger().with_variant(Integer, "sqlite") keeps BIGINT on PostgreSQL
while letting the tests exercise the same models and migration.

Verified live against a seeded store holding this repo's examples and four
helix-forge prompt packages. Service tests 11 -> 22.

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 20:23:25 +02:00
parent d5782d4d04
commit 5f47da8036
15 changed files with 641 additions and 11 deletions

View file

@ -0,0 +1,6 @@
Metadata-Version: 2.4
Name: canned-prompts-reference
Version: 0.1.0
Summary: Tiny filesystem reference CLI for Canned Prompt Format v0.1
Requires-Python: >=3.10
Requires-Dist: PyYAML<7,>=6.0

View file

@ -0,0 +1,10 @@
README.md
canned_prompts.py
pyproject.toml
canned_prompts_reference.egg-info/PKG-INFO
canned_prompts_reference.egg-info/SOURCES.txt
canned_prompts_reference.egg-info/dependency_links.txt
canned_prompts_reference.egg-info/entry_points.txt
canned_prompts_reference.egg-info/requires.txt
canned_prompts_reference.egg-info/top_level.txt
tests/test_canned_prompts.py

View file

@ -0,0 +1,2 @@
[console_scripts]
canned-prompts = canned_prompts:main

View file

@ -0,0 +1 @@
PyYAML<7,>=6.0

View file

@ -0,0 +1 @@
canned_prompts

View file

@ -66,8 +66,32 @@ hold the same id.
| `package_files` | the package's files as content, not parsed into rows |
| `index_entries` | how a version arrived here (§ 20.3): source, method, `included_at` never overwritten, `last_seen_at` |
## Read API
Routes address packages with the format's own reference syntax
(`<registry>:<id>@<version>`, § 3.2 and § 17) rather than a second one invented
for HTTP. A package id contains `/`, so the reference is captured as a greedy
path and parsed; each route keeps a distinct prefix so the greediness cannot
swallow a neighbouring segment.
| Route | Returns |
|---|---|
| `GET /packages?q=&registry=` | search over id, name, summary and tags |
| `GET /packages/{id}` | the versions of an id |
| `GET /packages/{id}@{version}` | one manifest |
| `GET /archives/{id}@{version}` | the package's files, text or base64 |
| `GET /index` | § 20.3 entries: source, method, `included_at`, `last_seen_at` |
A bare id present in more than one registry returns **409 with the candidates**,
never a guess (§ 3.2). 409 rather than 300 because the request is answerable —
once the caller says which registry they meant.
Asking for a package without naming a version applies § 17.1's selector rules,
so a prerelease is never chosen implicitly.
## Status
`CANP-WP-0006` T01 and T02 are done: skeleton, health surface, tenant-keyed
schema and migration `0001`. The read API, publish API, HTTP registry client and
container image (T03T06) are not built yet.
`CANP-WP-0006` T01T03 are done: skeleton, health surface, tenant-keyed schema
and migration `0001`, and the read API. The publish API, HTTP registry client
and container image (T04T06) are not built yet. T06 produces the image digest
that `rapp-canned-prompts` needs to pin.

View file

@ -11,6 +11,10 @@ Revises:
from alembic import op
import sqlalchemy as sa
# Matches models.BigIntPK: BIGINT on PostgreSQL, INTEGER on SQLite so the
# test database autoincrements the same way production does.
BigIntPK = sa.BigInteger().with_variant(sa.Integer, "sqlite")
revision = '0001'
down_revision = None
branch_labels = None
@ -20,7 +24,7 @@ depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('index_entries',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('id', BigIntPK, autoincrement=True, nullable=False),
sa.Column('tenant', sa.String(length=64), nullable=False),
sa.Column('registry', sa.String(length=128), nullable=False),
sa.Column('package_id', sa.String(length=512), nullable=False),
@ -36,7 +40,7 @@ def upgrade() -> None:
sa.UniqueConstraint('tenant', 'registry', 'package_id', 'version', name='uq_index_entry')
)
op.create_table('package_versions',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('id', BigIntPK, autoincrement=True, nullable=False),
sa.Column('tenant', sa.String(length=64), nullable=False),
sa.Column('registry', sa.String(length=128), nullable=False),
sa.Column('package_id', sa.String(length=512), nullable=False),
@ -54,7 +58,7 @@ def upgrade() -> None:
)
op.create_index('ix_package_versions_tenant_id', 'package_versions', ['tenant', 'package_id'], unique=False)
op.create_table('package_files',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('id', BigIntPK, autoincrement=True, nullable=False),
sa.Column('tenant', sa.String(length=64), nullable=False),
sa.Column('package_version_id', sa.BigInteger(), nullable=False),
sa.Column('path', sa.String(length=1024), nullable=False),

View file

@ -16,7 +16,8 @@ from fastapi import FastAPI, Response
from sqlalchemy.engine import Engine
from . import __version__
from .db import check_readiness, make_engine
from .db import check_readiness, make_engine, make_session_factory
from .routes import make_router
from .settings import Settings, get_settings
@ -28,6 +29,18 @@ def create_app(settings: Settings | None = None, engine: Engine | None = None) -
app = FastAPI(title="canned-prompts registry", version=__version__)
app.state.settings = settings
app.state.engine = engine
app.state.session_factory = make_session_factory(engine) if engine is not None else None
def get_session():
if app.state.session_factory is None:
raise RuntimeError("no database configured")
with app.state.session_factory() as session:
yield session
def get_tenant() -> str:
# Single-tenant deployment today; every query is still scoped, so
# consolidation is a data copy rather than a rewrite (contract § 1.3).
return settings.tenant
@app.get("/healthz")
def healthz() -> dict[str, str]:
@ -59,4 +72,5 @@ def create_app(settings: Settings | None = None, engine: Engine | None = None) -
"migration": readiness.migration,
}
app.include_router(make_router(get_session, get_tenant))
return app

View file

@ -21,6 +21,7 @@ import datetime as dt
from sqlalchemy import (
BigInteger,
DateTime,
Integer,
ForeignKey,
Index,
LargeBinary,
@ -37,6 +38,12 @@ def utcnow() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
# SQLite autoincrements INTEGER PRIMARY KEY only, never BIGINT. The variant
# keeps BIGINT on PostgreSQL, where this actually runs, while letting the
# SQLite-backed tests exercise the same models.
BigIntPK = BigInteger().with_variant(Integer, "sqlite")
class PackageVersion(Base):
"""One immutable `<registry>:<id>@<version>` within one tenant."""
@ -48,7 +55,7 @@ class PackageVersion(Base):
Index("ix_package_versions_tenant_id", "tenant", "package_id"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True)
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
registry: Mapped[str] = mapped_column(String(128), nullable=False)
package_id: Mapped[str] = mapped_column(String(512), nullable=False)
@ -82,7 +89,7 @@ class PackageFile(Base):
Index("ix_package_files_tenant", "tenant"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True)
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
package_version_id: Mapped[int] = mapped_column(
ForeignKey("package_versions.id", ondelete="CASCADE"), nullable=False
@ -107,7 +114,7 @@ class IndexEntry(Base):
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True)
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
registry: Mapped[str] = mapped_column(String(128), nullable=False)
package_id: Mapped[str] = mapped_column(String(512), nullable=False)

View file

@ -0,0 +1,136 @@
"""Read API.
Routes address packages with the format's own reference syntax
(`<registry>:<id>@<version>`, § 3.2 and § 17) rather than inventing a second
one. A package id contains `/`, so the reference is captured as a greedy path
and parsed here; each route keeps a distinct prefix so the greediness cannot
swallow a neighbouring segment.
"""
from __future__ import annotations
import base64
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from . import store
from .store import Ambiguous, NotFound, Reference, StoreError
router = APIRouter()
def as_http(exc: StoreError) -> HTTPException:
if isinstance(exc, NotFound):
return HTTPException(status_code=404, detail=str(exc))
if isinstance(exc, Ambiguous):
# 409, not 300: the request is answerable, but only once the caller says
# which registry they meant.
return HTTPException(status_code=409, detail=str(exc))
return HTTPException(status_code=400, detail=str(exc))
def version_payload(row: Any, *, manifest: bool = False) -> dict[str, Any]:
payload = {
"registry": row.registry,
"id": row.package_id,
"version": row.version,
"reference": f"{row.registry}:{row.package_id}@{row.version}",
"name": row.name,
"summary": row.summary,
"type": row.package_type,
"license": row.license,
"tags": row.tags.split() if row.tags else [],
"content_digest": row.content_digest,
"published_at": row.published_at.isoformat() if row.published_at else None,
}
if manifest:
payload["manifest"] = row.manifest
return payload
def make_router(get_session, get_tenant) -> APIRouter:
api = APIRouter()
@api.get("/packages")
def list_packages(
q: str = Query("", description="Substring over id, name, summary and tags"),
registry: str | None = Query(None),
session: Session = Depends(get_session),
tenant: str = Depends(get_tenant),
) -> dict[str, Any]:
rows = store.search(session, tenant, q, registry)
return {"count": len(rows), "packages": [version_payload(r) for r in rows]}
@api.get("/packages/{reference:path}")
def get_package(
reference: str,
session: Session = Depends(get_session),
tenant: str = Depends(get_tenant),
) -> dict[str, Any]:
"""Without `@version`, the versions of an id; with it, one manifest."""
parsed = Reference.parse(reference)
try:
if parsed.version is None:
rows = store.versions_of(session, tenant, parsed)
return {
"id": parsed.package_id,
"registry": rows[0].registry,
"versions": [version_payload(r) for r in rows],
}
row = store.get_version(session, tenant, parsed)
return version_payload(row, manifest=True)
except StoreError as exc:
raise as_http(exc) from exc
@api.get("/archives/{reference:path}")
def get_archive(
reference: str,
session: Session = Depends(get_session),
tenant: str = Depends(get_tenant),
) -> dict[str, Any]:
"""The package's files. Text where it decodes, base64 otherwise."""
try:
row = store.get_version(session, tenant, Reference.parse(reference))
except StoreError as exc:
raise as_http(exc) from exc
files: dict[str, Any] = {}
for item in sorted(row.files, key=lambda f: f.path):
try:
files[item.path] = {"text": item.content.decode("utf-8")}
except UnicodeDecodeError:
files[item.path] = {"base64": base64.b64encode(item.content).decode()}
return {
"reference": f"{row.registry}:{row.package_id}@{row.version}",
"content_digest": row.content_digest,
"files": files,
}
@api.get("/index")
def get_index(
session: Session = Depends(get_session),
tenant: str = Depends(get_tenant),
) -> dict[str, Any]:
rows = store.index_entries(session, tenant)
return {
"count": len(rows),
"entries": [
{
"registry": r.registry,
"id": r.package_id,
"version": r.version,
"source": r.source,
"method": r.method,
"declared_author": r.declared_author,
"declared_source": r.declared_source,
"license": r.license,
"included_at": r.included_at.isoformat() if r.included_at else None,
"last_seen_at": r.last_seen_at.isoformat() if r.last_seen_at else None,
}
for r in rows
],
}
return api

View file

@ -0,0 +1,250 @@
"""Package storage and queries.
Validation is delegated to the reference implementation rather than
reimplemented. One validator means the service and the CLI cannot disagree
about what a valid package is and a service that silently accepted something
the CLI rejects would be the divergence this whole project exists to prevent.
Importing it is not the same as changing it; `reference/` stays the
dependency-light conformance witness.
"""
from __future__ import annotations
import datetime as dt
import hashlib
from dataclasses import dataclass
from pathlib import Path
import canned_prompts as cp
import yaml
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import IndexEntry, PackageFile, PackageVersion, utcnow
class StoreError(Exception):
"""A request the store refuses, with an operator-readable reason."""
class NotFound(StoreError):
pass
class Ambiguous(StoreError):
"""A bare id matching more than one registry (§ 3.2): report, never guess."""
def __init__(self, package_id: str, registries: list[str]) -> None:
self.package_id = package_id
self.registries = registries
super().__init__(
f"{package_id} is present in more than one registry: "
+ ", ".join(f"{r}:{package_id}" for r in registries)
+ " — qualify the reference"
)
class Conflict(StoreError):
pass
@dataclass(frozen=True)
class Reference:
"""A `<registry>:<id>@<version>` reference (§ 3.2, § 17)."""
registry: str | None
package_id: str
version: str | None
@classmethod
def parse(cls, raw: str) -> "Reference":
rest, _, version = raw.partition("@")
registry, package_id = cp.parse_reference(rest)
return cls(registry, package_id, version or None)
def digest_files(files: dict[str, bytes]) -> str:
"""Content digest over the package's files, order-independent."""
hasher = hashlib.sha256()
for path in sorted(files):
hasher.update(path.encode("utf-8"))
hasher.update(b"\0")
hasher.update(files[path])
hasher.update(b"\0")
return f"sha256:{hasher.hexdigest()}"
def read_package_files(package_dir: Path, manifest: dict) -> dict[str, bytes]:
"""Reserved paths and manifest-referenced files only (§ 2)."""
members = cp.package_members(package_dir, manifest)
return {rel: (package_dir / rel).read_bytes() for rel in sorted(members)}
def ingest(
session: Session,
tenant: str,
registry: str,
package_dir: Path,
source: str,
method: str,
) -> PackageVersion:
"""Validate a package and store it. Shared by publish and by fixtures."""
manifest = cp.validate_package(package_dir)
files = read_package_files(package_dir, manifest)
digest = digest_files(files)
existing = session.scalar(
select(PackageVersion).where(
PackageVersion.tenant == tenant,
PackageVersion.registry == registry,
PackageVersion.package_id == manifest["id"],
PackageVersion.version == manifest["version"],
)
)
if existing is not None:
# § 17: a published id@version is immutable within a registry. Identical
# content is a harmless re-publish; different content is a conflict.
if existing.content_digest == digest:
record_index_entry(session, tenant, registry, manifest, source, method)
return existing
raise Conflict(
f"{registry}:{manifest['id']}@{manifest['version']} already exists "
"with different content; publish a new version (§ 17)"
)
version = PackageVersion(
tenant=tenant,
registry=registry,
package_id=manifest["id"],
version=manifest["version"],
name=manifest.get("name", ""),
summary=str(manifest.get("summary", "")).strip(),
package_type=manifest.get("type", "template"),
license=manifest.get("license"),
tags=" ".join(str(t) for t in (manifest.get("tags") or [])),
manifest=yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
content_digest=digest,
files=[
PackageFile(tenant=tenant, path=path, content=content)
for path, content in files.items()
],
)
session.add(version)
record_index_entry(session, tenant, registry, manifest, source, method)
session.flush()
return version
def record_index_entry(
session: Session, tenant: str, registry: str, manifest: dict, source: str, method: str
) -> IndexEntry:
"""§ 20.3. `included_at` is first arrival and is never overwritten."""
provenance = manifest.get("provenance") or {}
entry = session.scalar(
select(IndexEntry).where(
IndexEntry.tenant == tenant,
IndexEntry.registry == registry,
IndexEntry.package_id == manifest["id"],
IndexEntry.version == manifest["version"],
)
)
if entry is not None:
entry.last_seen_at = utcnow()
return entry
entry = IndexEntry(
tenant=tenant,
registry=registry,
package_id=manifest["id"],
version=manifest["version"],
source=source,
method=method,
declared_author=str(provenance.get("author")) if provenance.get("author") else None,
declared_source=str(provenance.get("source")) if provenance.get("source") else None,
license=manifest.get("license"),
)
session.add(entry)
return entry
def search(
session: Session, tenant: str, query: str = "", registry: str | None = None
) -> list[PackageVersion]:
statement = select(PackageVersion).where(PackageVersion.tenant == tenant)
if registry:
statement = statement.where(PackageVersion.registry == registry)
rows = list(session.scalars(statement))
if query:
needle = query.lower()
rows = [
row
for row in rows
if needle
in " ".join([row.package_id, row.name, row.summary, row.tags]).lower()
]
return sorted(rows, key=lambda r: (r.registry, r.package_id, cp.parse_semver(r.version)))
def resolve_registry(
session: Session, tenant: str, reference: Reference
) -> str:
"""Never guess which registry a bare id means (§ 3.2)."""
if reference.registry is not None:
return reference.registry
registries = sorted(
set(
session.scalars(
select(PackageVersion.registry).where(
PackageVersion.tenant == tenant,
PackageVersion.package_id == reference.package_id,
)
)
)
)
if not registries:
raise NotFound(f"package not found: {reference.package_id}")
if len(registries) > 1:
raise Ambiguous(reference.package_id, registries)
return registries[0]
def versions_of(session: Session, tenant: str, reference: Reference) -> list[PackageVersion]:
registry = resolve_registry(session, tenant, reference)
rows = list(
session.scalars(
select(PackageVersion).where(
PackageVersion.tenant == tenant,
PackageVersion.registry == registry,
PackageVersion.package_id == reference.package_id,
)
)
)
if not rows:
raise NotFound(f"package not found: {reference.package_id}")
return sorted(rows, key=lambda r: cp.parse_semver(r.version), reverse=True)
def get_version(session: Session, tenant: str, reference: Reference) -> PackageVersion:
rows = versions_of(session, tenant, reference)
if reference.version:
for row in rows:
if row.version == reference.version:
return row
raise NotFound(f"package not found: {reference.package_id}@{reference.version}")
# No version asked for: the § 17.1 selector rules apply, so a prerelease is
# never chosen implicitly.
chosen = cp.select_version([row.version for row in rows], None)
if chosen is None:
raise NotFound(
f"{reference.package_id}: only prerelease versions are available "
f"({', '.join(row.version for row in rows)}); name one exactly"
)
return next(row for row in rows if row.version == chosen)
def index_entries(session: Session, tenant: str) -> list[IndexEntry]:
rows = list(
session.scalars(select(IndexEntry).where(IndexEntry.tenant == tenant))
)
return sorted(rows, key=lambda r: (r.registry, r.package_id, cp.parse_semver(r.version)))

63
service/tests/conftest.py Normal file
View file

@ -0,0 +1,63 @@
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from canned_prompts_service.api import create_app
from canned_prompts_service.db import make_engine, make_session_factory
from canned_prompts_service.settings import Settings
from canned_prompts_service import store
ROOT = Path(__file__).resolve().parents[1]
REPO = ROOT.parent
@pytest.fixture()
def db_url(tmp_path: Path) -> str:
url = f"sqlite:///{tmp_path / 'svc.db'}"
config = Config(str(ROOT / "alembic.ini"))
config.set_main_option("script_location", str(ROOT / "migrations"))
config.set_main_option("sqlalchemy.url", url)
command.upgrade(config, "head")
return url
@pytest.fixture()
def session(db_url: str) -> Session:
with make_session_factory(make_engine(db_url))() as s:
yield s
@pytest.fixture()
def client(db_url: str) -> TestClient:
return TestClient(create_app(Settings(database_url=db_url), make_engine(db_url)))
@pytest.fixture()
def seeded(db_url: str) -> TestClient:
"""A store holding the repo's own example packages."""
engine = make_engine(db_url)
with make_session_factory(engine)() as s:
for name in ("house-style", "pqrst-estimate"):
store.ingest(s, "default", "local", REPO / "examples" / name,
source=f"examples/{name}", method="publish")
s.commit()
return TestClient(create_app(Settings(database_url=db_url), engine))
@pytest.fixture()
def two_registries(db_url: str, tmp_path: Path) -> TestClient:
"""The same id in two registries — § 3.2 ambiguity."""
engine = make_engine(db_url)
with make_session_factory(engine)() as s:
for registry in ("local", "house"):
store.ingest(s, "default", registry, REPO / "examples" / "house-style",
source=registry, method="publish")
s.commit()
return TestClient(create_app(Settings(database_url=db_url), engine))

View file

@ -0,0 +1,87 @@
"""Read API (CANP-WP-0006 T03)."""
from __future__ import annotations
from fastapi.testclient import TestClient
def test_search_returns_stored_packages(seeded: TestClient) -> None:
body = seeded.get("/packages").json()
assert body["count"] == 2
assert {p["id"] for p in body["packages"]} == {
"practice/house-style",
"practice/pqrst-estimate",
}
def test_search_matches_summary_and_tags(seeded: TestClient) -> None:
assert seeded.get("/packages", params={"q": "pqrst"}).json()["count"] == 1
assert seeded.get("/packages", params={"q": "fragment"}).json()["count"] == 1
assert seeded.get("/packages", params={"q": "nothing-matches"}).json()["count"] == 0
def test_versions_of_a_bare_id(seeded: TestClient) -> None:
body = seeded.get("/packages/practice/pqrst-estimate").json()
assert body["id"] == "practice/pqrst-estimate"
assert [v["version"] for v in body["versions"]] == ["1.0.0"]
def test_manifest_by_qualified_reference(seeded: TestClient) -> None:
"""The API speaks the format's own <registry>:<id>@<version> syntax."""
body = seeded.get("/packages/local:practice/pqrst-estimate@1.0.0").json()
assert body["reference"] == "local:practice/pqrst-estimate@1.0.0"
assert "PQRST" in body["manifest"]
assert body["content_digest"].startswith("sha256:")
def test_unknown_package_is_404(seeded: TestClient) -> None:
assert seeded.get("/packages/nope/missing").status_code == 404
def test_unknown_version_is_404(seeded: TestClient) -> None:
assert seeded.get("/packages/practice/house-style@9.9.9").status_code == 404
def test_ambiguous_bare_id_is_409_not_a_guess(two_registries: TestClient) -> None:
"""§ 3.2: report the candidates, never choose for the caller."""
response = two_registries.get("/packages/practice/house-style")
assert response.status_code == 409
detail = response.json()["detail"]
assert "more than one registry" in detail
assert "house:practice/house-style" in detail
assert "local:practice/house-style" in detail
def test_qualifying_resolves_the_ambiguity(two_registries: TestClient) -> None:
body = two_registries.get("/packages/house:practice/house-style").json()
assert body["registry"] == "house"
def test_archive_returns_the_packaged_files(seeded: TestClient) -> None:
body = seeded.get("/archives/practice/pqrst-estimate@1.0.0").json()
assert set(body["files"]) == {
"LICENSE",
"README.md",
"evals/canonical-fidelity.yaml",
"examples/basic.yaml",
"examples/with-rationale.yaml",
"prompt.md",
"prompt.yaml",
}
assert "PQRST-Estimate" in body["files"]["prompt.md"]["text"]
def test_archive_excludes_non_package_files(seeded: TestClient) -> None:
"""§ 2: only reserved paths and manifest-referenced files are stored."""
files = seeded.get("/archives/practice/pqrst-estimate@1.0.0").json()["files"]
assert not any(path.startswith(".") for path in files)
def test_index_records_arrival(seeded: TestClient) -> None:
body = seeded.get("/index").json()
assert body["count"] == 2
entry = next(e for e in body["entries"] if e["id"] == "practice/pqrst-estimate")
assert entry["method"] == "publish"
assert entry["source"] == "examples/pqrst-estimate"
assert entry["declared_source"] == "~/pqrst-practice/PqrstPrompt.md"
assert entry["included_at"] is not None

View file

@ -114,7 +114,7 @@ consolidation time. Uniqueness is `(tenant, registry, package_id, version)`.
```task
id: CANP-WP-0006-T03
status: todo
status: done
priority: high
state_hub_task_id: "0ae11e65-68b0-543c-b8fa-f3188a4d239a"
```
@ -127,6 +127,30 @@ state_hub_task_id: "0ae11e65-68b0-543c-b8fa-f3188a4d239a"
Ambiguity is reported, never guessed (§ 3.2): a bare id matching packages from
more than one registry returns the candidates, not a choice.
**Done**, with the route shape worth recording. Package ids contain `/`, so the
obvious `/packages/{id}/{version}` is ambiguous under a greedy path parameter.
Rather than invent an HTTP-specific identifier, the routes speak the format's
own `<registry>:<id>@<version>` syntax and parse it — `:` and `@` are both legal
in a path segment, each route keeps a distinct prefix, and the API therefore
tests § 3.2's reference notation rather than working around it.
Ambiguity returns **409 with the candidates**, not 300: the request is
answerable once the caller says which registry they meant. Omitting a version
applies § 17.1's selectors, so a prerelease is never chosen implicitly.
Validation is delegated to `reference/`, installed into the service
environment rather than reimplemented. One validator means the service and the
CLI cannot disagree about what a valid package is — a service accepting
something the CLI rejects would be exactly the divergence this project exists to
prevent. Importing it is not changing it; `reference/` stays the
dependency-light conformance witness.
**Test-vs-production difference found and handled.** SQLite autoincrements
`INTEGER PRIMARY KEY` only, never `BIGINT`, so the SQLite-backed tests could not
insert a row. `BigInteger().with_variant(Integer, "sqlite")` keeps `BIGINT` on
PostgreSQL, where this actually runs, while letting the tests exercise the same
models and the same migration.
## Publish API
```task