diff --git a/.gitignore b/.gitignore index dc68d82..eeb9798 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ __pycache__/ *.py[cod] .venv/ + +# service +service/.venv/ +service/*.db diff --git a/service/README.md b/service/README.md new file mode 100644 index 0000000..f3ca9d8 --- /dev/null +++ b/service/README.md @@ -0,0 +1,73 @@ +# canned-prompts service + +The hosted registry and index (`CANP-WP-0006`). Product ownership lives here +alongside the specification and the reference CLI; deployment and operation will +belong to `rapp-canned-prompts` once there is an image digest to pin. + +This service **hosts** packages. It does not execute them — `INTENT.md`'s +deliberate boundary holds, so rendering stays deterministic and a `derive` +default remains a declaration the service does not satisfy. + +`reference/` is deliberately untouched by this. It is the format's conformance +witness and stays dependency-light; the service is a separate consumer of the +same package semantics. + +## Stack + +FastAPI, SQLAlchemy, Alembic, PostgreSQL — matching `state-hub` and +`sbom-nexus`. + +```bash +cd service +uv venv && uv pip install -e ".[dev]" +.venv/bin/python -m pytest -q + +export CANNED_PROMPTS_DATABASE_URL=postgresql+psycopg://... +.venv/bin/alembic upgrade head +.venv/bin/uvicorn canned_prompts_service.api:create_app --factory +``` + +`CANNED_PROMPTS_DATABASE_URL` has **no default**. A service that silently falls +back to a local database when its real one is misconfigured is worse than one +that refuses to start. + +## Health surface + +`RailianceAppDeploymentGuide.md` requires unauthenticated `/healthz` and +`/readyz`; `/state/health` matches the rest of the fleet. + +| Endpoint | Answers | Fails when | +|---|---|---| +| `/healthz` | is the process up | never — deliberately checks nothing else, so a database blip does not restart pods | +| `/readyz` | can it serve | no database configured, unreachable, or schema not migrated | +| `/state/health` | fleet-shaped status | same as `/readyz`, reported as `degraded` | + +Connectivity and schema are checked separately. Both mean not-ready, but they +send an operator to different places — credentials and network, or an unrun +migration — so collapsing them into one message would send people to the wrong +one. + +## Tenancy + +Every table holding owned data carries a `tenant` key from migration `0001`, +per `business-app-service-contract_v0.1` § 1.3. The service is deployed +single-tenant today; the key is present so a later consolidation is a data copy +rather than a rewrite, and no query may assume it is the only tenant (§ 1.4). + +Uniqueness is `(tenant, registry, package_id, version)` — registry-scoped +because identity is (§ 3.2), and tenant-scoped so two tenants may legitimately +hold the same id. + +## Schema + +| Table | Holds | +|---|---| +| `package_versions` | one immutable `:@`, its manifest, and indexed discovery fields | +| `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` | + +## 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 (T03–T06) are not built yet. diff --git a/service/alembic.ini b/service/alembic.ini new file mode 100644 index 0000000..bd0ac5a --- /dev/null +++ b/service/alembic.ini @@ -0,0 +1,26 @@ +[alembic] +script_location = migrations +prepend_sys_path = src + +[loggers] +keys = root + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/service/migrations/env.py b/service/migrations/env.py new file mode 100644 index 0000000..c336c13 --- /dev/null +++ b/service/migrations/env.py @@ -0,0 +1,49 @@ +"""Alembic environment. + +The database URL comes from settings, never from alembic.ini, so a migration +cannot be run against a different database than the service uses. +""" + +from __future__ import annotations + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from canned_prompts_service.db import Base +from canned_prompts_service.settings import get_settings +from canned_prompts_service import models # noqa: F401 — registers the tables + +config = context.config +target_metadata = Base.metadata + +settings = get_settings() +if settings.configured: + config.set_main_option("sqlalchemy.url", settings.database_url) + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/service/migrations/script.py.mako b/service/migrations/script.py.mako new file mode 100644 index 0000000..48bb912 --- /dev/null +++ b/service/migrations/script.py.mako @@ -0,0 +1,20 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +""" +from alembic import op +import sqlalchemy as sa + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/service/migrations/versions/0001_tenant_keyed_package_store.py b/service/migrations/versions/0001_tenant_keyed_package_store.py new file mode 100644 index 0000000..caab49d --- /dev/null +++ b/service/migrations/versions/0001_tenant_keyed_package_store.py @@ -0,0 +1,77 @@ +"""tenant-keyed package store + +Every table holding owned data carries `tenant` from this, the first migration, +per business-app-service-contract_v0.1 section 1.3 — so a later consolidation is +a data copy rather than a rewrite, and no business logic may assume it is the +only tenant (section 1.4). + +Revision ID: 0001 +Revises: +""" +from alembic import op +import sqlalchemy as sa + +revision = '0001' +down_revision = None +branch_labels = None +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('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), + sa.Column('version', sa.String(length=64), nullable=False), + sa.Column('source', sa.Text(), nullable=False), + sa.Column('method', sa.String(length=32), nullable=False), + sa.Column('declared_author', sa.String(length=256), nullable=True), + sa.Column('declared_source', sa.Text(), nullable=True), + sa.Column('license', sa.String(length=128), nullable=True), + sa.Column('included_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + 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('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), + sa.Column('version', sa.String(length=64), nullable=False), + sa.Column('name', sa.String(length=256), nullable=False), + sa.Column('summary', sa.Text(), nullable=False), + sa.Column('package_type', sa.String(length=32), nullable=False), + sa.Column('license', sa.String(length=128), nullable=True), + sa.Column('tags', sa.Text(), nullable=False), + sa.Column('manifest', sa.Text(), nullable=False), + sa.Column('content_digest', sa.String(length=71), nullable=False), + sa.Column('published_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant', 'registry', 'package_id', 'version', name='uq_package_version') + ) + 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('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), + sa.Column('content', sa.LargeBinary(), nullable=False), + sa.ForeignKeyConstraint(['package_version_id'], ['package_versions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('package_version_id', 'path', name='uq_package_file_path') + ) + op.create_index('ix_package_files_tenant', 'package_files', ['tenant'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_package_files_tenant', table_name='package_files') + op.drop_table('package_files') + op.drop_index('ix_package_versions_tenant_id', table_name='package_versions') + op.drop_table('package_versions') + op.drop_table('index_entries') + # ### end Alembic commands ### diff --git a/service/pyproject.toml b/service/pyproject.toml new file mode 100644 index 0000000..18ba2fa --- /dev/null +++ b/service/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "canned-prompts-service" +version = "0.1.0" +description = "Hosted registry and index service for Canned Prompt Format packages" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "sqlalchemy>=2.0.0", + "alembic>=1.14.0", + "pydantic>=2.10.0", + "pydantic-settings>=2.7.0", + "psycopg[binary]>=3.2.0", + "pyyaml>=6.0.3", +] + +[project.optional-dependencies] +dev = ["pytest>=8", "httpx>=0.28.0"] + +[tool.hatch.build.targets.wheel] +packages = ["src/canned_prompts_service"] diff --git a/service/src/canned_prompts_service/__init__.py b/service/src/canned_prompts_service/__init__.py new file mode 100644 index 0000000..3aa13a4 --- /dev/null +++ b/service/src/canned_prompts_service/__init__.py @@ -0,0 +1,11 @@ +"""Hosted registry and index service for Canned Prompt Format packages. + +Product ownership lives here, in `canned-prompts`, alongside the specification +and the reference CLI. Deployment and operation belong to `rapp-canned-prompts`. + +This service hosts packages. It does not execute them: `INTENT.md`'s deliberate +boundary holds, so rendering stays deterministic and a `derive` default remains +a declaration the service does not satisfy. +""" + +__version__ = "0.1.0" diff --git a/service/src/canned_prompts_service/api.py b/service/src/canned_prompts_service/api.py new file mode 100644 index 0000000..c51bf65 --- /dev/null +++ b/service/src/canned_prompts_service/api.py @@ -0,0 +1,62 @@ +"""HTTP surface. + +`RailianceAppDeploymentGuide.md` requires unauthenticated `/healthz` and +`/readyz`; `/state/health` matches `state-hub` and `sbom-nexus`. + +Liveness and readiness answer different questions and must not be aliased: +`/healthz` says the process is up, `/readyz` says it can serve — which means +asking the database something it can fail to answer. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI, Response +from sqlalchemy.engine import Engine + +from . import __version__ +from .db import check_readiness, make_engine +from .settings import Settings, get_settings + + +def create_app(settings: Settings | None = None, engine: Engine | None = None) -> FastAPI: + settings = settings or get_settings() + if engine is None and settings.configured: + engine = make_engine(settings.database_url) + + app = FastAPI(title="canned-prompts registry", version=__version__) + app.state.settings = settings + app.state.engine = engine + + @app.get("/healthz") + def healthz() -> dict[str, str]: + """Liveness: the process is running. Deliberately checks nothing else.""" + return {"status": "ok"} + + @app.get("/readyz") + def readyz(response: Response) -> dict[str, Any]: + """Readiness: the service can actually serve requests.""" + readiness = check_readiness(app.state.engine) + if not readiness.ready: + response.status_code = 503 + return { + "ready": readiness.ready, + "detail": readiness.detail, + "migration": readiness.migration, + } + + @app.get("/state/health") + def state_health(response: Response) -> dict[str, Any]: + readiness = check_readiness(app.state.engine) + if not readiness.ready: + response.status_code = 503 + return { + "status": "ok" if readiness.ready else "degraded", + "service": settings.service_name, + "version": __version__, + "db": "connected" if readiness.ready else readiness.detail, + "migration": readiness.migration, + } + + return app diff --git a/service/src/canned_prompts_service/db.py b/service/src/canned_prompts_service/db.py new file mode 100644 index 0000000..9f8cede --- /dev/null +++ b/service/src/canned_prompts_service/db.py @@ -0,0 +1,64 @@ +"""Database session and readiness. + +Readiness asks the database a question it can actually fail to answer. A probe +that cannot fail is a liveness probe wearing the wrong name. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + + +class Base(DeclarativeBase): + pass + + +def make_engine(database_url: str) -> Engine: + # future=True is the 2.0 default; pool_pre_ping keeps a recycled connection + # from surfacing as a request error after a database restart. + return create_engine(database_url, pool_pre_ping=True, future=True) + + +def make_session_factory(engine: Engine) -> sessionmaker[Session]: + return sessionmaker(bind=engine, expire_on_commit=False) + + +@dataclass(frozen=True) +class Readiness: + ready: bool + detail: str + migration: str | None = None + + +def check_readiness(engine: Engine | None) -> Readiness: + if engine is None: + return Readiness(False, "no database configured") + + # Connectivity and schema are checked separately on purpose. Both mean "not + # ready", but they tell an operator to do different things: one is + # credentials or network, the other is an unrun migration. Collapsing them + # into one message sends people to the wrong place. + try: + connection = engine.connect() + except Exception as exc: # noqa: BLE001 — any failure means not ready + return Readiness(False, f"database unreachable: {type(exc).__name__}") + + with connection: + try: + connection.execute(text("select 1")) + except Exception as exc: # noqa: BLE001 + return Readiness(False, f"database unreachable: {type(exc).__name__}") + try: + revision = connection.execute( + text("select version_num from alembic_version") + ).scalar_one_or_none() + except Exception: # noqa: BLE001 — reachable, but no alembic_version table + return Readiness(False, "schema not migrated") + + if revision is None: + return Readiness(False, "schema not migrated") + return Readiness(True, "ok", revision) diff --git a/service/src/canned_prompts_service/models.py b/service/src/canned_prompts_service/models.py new file mode 100644 index 0000000..2de75ba --- /dev/null +++ b/service/src/canned_prompts_service/models.py @@ -0,0 +1,125 @@ +"""Store schema. + +Tenant keying, from the first migration onward, per +`business-app-service-contract_v0.1` § 1.3. Every table holding owned data +carries `tenant`, and no query may assume it is the only tenant (§ 1.4). The +service is deployed single-tenant today; the key is here so a later +consolidation is a data copy rather than a rewrite. + +The format's three distinct things stay distinct: + +- a **package version** is immutable content addressed by `@` + (§ 17), scoped to a registry because identity is registry-scoped (§ 3.2); +- an **index entry** records how a version arrived in this store (§ 20.3); +- **files** are content, stored as blobs rather than parsed into rows. +""" + +from __future__ import annotations + +import datetime as dt + +from sqlalchemy import ( + BigInteger, + DateTime, + ForeignKey, + Index, + LargeBinary, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .db import Base + + +def utcnow() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc) + + +class PackageVersion(Base): + """One immutable `:@` within one tenant.""" + + __tablename__ = "package_versions" + __table_args__ = ( + UniqueConstraint( + "tenant", "registry", "package_id", "version", name="uq_package_version" + ), + Index("ix_package_versions_tenant_id", "tenant", "package_id"), + ) + + id: Mapped[int] = mapped_column(BigInteger, 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) + version: Mapped[str] = mapped_column(String(64), nullable=False) + + # Indexed manifest fields, for discovery without opening every package. + name: Mapped[str] = mapped_column(String(256), nullable=False) + summary: Mapped[str] = mapped_column(Text, nullable=False, default="") + package_type: Mapped[str] = mapped_column(String(32), nullable=False, default="template") + license: Mapped[str | None] = mapped_column(String(128)) + tags: Mapped[str] = mapped_column(Text, nullable=False, default="") + + # The manifest as published, so a consumer sees exactly what was stored. + manifest: Mapped[str] = mapped_column(Text, nullable=False) + content_digest: Mapped[str] = mapped_column(String(71), nullable=False) + published_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow + ) + + files: Mapped[list["PackageFile"]] = relationship( + back_populates="package_version", cascade="all, delete-orphan" + ) + + +class PackageFile(Base): + """A file belonging to a package version. Content, not parsed rows.""" + + __tablename__ = "package_files" + __table_args__ = ( + UniqueConstraint("package_version_id", "path", name="uq_package_file_path"), + Index("ix_package_files_tenant", "tenant"), + ) + + id: Mapped[int] = mapped_column(BigInteger, 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 + ) + path: Mapped[str] = mapped_column(String(1024), nullable=False) + content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + + package_version: Mapped[PackageVersion] = relationship(back_populates="files") + + +class IndexEntry(Base): + """How a package version arrived in this store (§ 20.3). + + Store metadata, deliberately separate from the package: `included_at` is + first arrival and is never overwritten; a re-record updates `last_seen_at`. + """ + + __tablename__ = "index_entries" + __table_args__ = ( + UniqueConstraint( + "tenant", "registry", "package_id", "version", name="uq_index_entry" + ), + ) + + id: Mapped[int] = mapped_column(BigInteger, 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) + version: Mapped[str] = mapped_column(String(64), nullable=False) + + source: Mapped[str] = mapped_column(Text, nullable=False) + method: Mapped[str] = mapped_column(String(32), nullable=False) + declared_author: Mapped[str | None] = mapped_column(String(256)) + declared_source: Mapped[str | None] = mapped_column(Text) + license: Mapped[str | None] = mapped_column(String(128)) + + included_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow + ) + last_seen_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/service/src/canned_prompts_service/settings.py b/service/src/canned_prompts_service/settings.py new file mode 100644 index 0000000..9aa8b3d --- /dev/null +++ b/service/src/canned_prompts_service/settings.py @@ -0,0 +1,27 @@ +"""Service configuration. + +Every value is settable from the environment so the container needs no config +file. `database_url` has no default on purpose: a service that silently falls +back to a local database when its real one is misconfigured is worse than one +that refuses to start. +""" + +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="CANNED_PROMPTS_", extra="ignore") + + database_url: str = "" + tenant: str = "default" + service_name: str = "canned-prompts" + + @property + def configured(self) -> bool: + return bool(self.database_url) + + +def get_settings() -> Settings: + return Settings() diff --git a/service/tests/test_health.py b/service/tests/test_health.py new file mode 100644 index 0000000..655f2fb --- /dev/null +++ b/service/tests/test_health.py @@ -0,0 +1,94 @@ +"""Health surface. + +The point of these tests is the negative cases. An endpoint that returns 200 +under every condition tells an orchestrator nothing, and the failure is silent +exactly when it matters. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from fastapi.testclient import TestClient + +from canned_prompts_service.api import create_app +from canned_prompts_service.db import check_readiness, make_engine +from canned_prompts_service.settings import Settings + +ROOT = Path(__file__).resolve().parents[1] + + +def migrated_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 ready_client(tmp_path: Path) -> TestClient: + url = migrated_url(tmp_path) + return TestClient(create_app(Settings(database_url=url), make_engine(url))) + + +def test_healthz_is_up_without_a_database() -> None: + """Liveness must not depend on the database, or a DB blip restarts pods.""" + client = TestClient(create_app(Settings(), None)) + assert client.get("/healthz").json() == {"status": "ok"} + + +def test_readyz_fails_without_a_database() -> None: + client = TestClient(create_app(Settings(), None)) + response = client.get("/readyz") + assert response.status_code == 503 + assert response.json()["ready"] is False + assert "no database" in response.json()["detail"] + + +def test_readyz_fails_when_the_database_is_unreachable(tmp_path: Path) -> None: + engine = make_engine("sqlite:////nonexistent/dir/does-not-exist.db") + client = TestClient(create_app(Settings(database_url="x"), engine)) + response = client.get("/readyz") + assert response.status_code == 503 + assert response.json()["ready"] is False + + +def test_readyz_fails_when_the_schema_is_not_migrated(tmp_path: Path) -> None: + """Reachable but unmigrated is not ready — it would 500 on the first query.""" + engine = make_engine(f"sqlite:///{tmp_path / 'empty.db'}") + client = TestClient(create_app(Settings(database_url="x"), engine)) + response = client.get("/readyz") + assert response.status_code == 503 + assert response.json()["detail"] == "schema not migrated" + + +def test_readyz_reports_the_migration_when_ready(ready_client: TestClient) -> None: + body = ready_client.get("/readyz").json() + assert body["ready"] is True + assert body["migration"] == "0001" + + +def test_state_health_matches_the_fleet_shape(ready_client: TestClient) -> None: + body = ready_client.get("/state/health").json() + assert body["status"] == "ok" + assert body["service"] == "canned-prompts" + assert body["db"] == "connected" + assert body["migration"] == "0001" + + +def test_state_health_degrades_rather_than_lying() -> None: + client = TestClient(create_app(Settings(), None)) + response = client.get("/state/health") + assert response.status_code == 503 + assert response.json()["status"] == "degraded" + + +def test_settings_have_no_database_fallback() -> None: + """Falling back to a local database when misconfigured hides the mistake.""" + assert Settings().database_url == "" + assert Settings().configured is False diff --git a/service/tests/test_schema.py b/service/tests/test_schema.py new file mode 100644 index 0000000..2873cc0 --- /dev/null +++ b/service/tests/test_schema.py @@ -0,0 +1,25 @@ +"""Schema obligations from business-app-service-contract_v0.1 § 1.3.""" + +from __future__ import annotations + +from canned_prompts_service import models + + +def test_every_owned_table_is_tenant_keyed() -> None: + for table in models.Base.metadata.sorted_tables: + assert "tenant" in table.c, f"{table.name} holds owned data without a tenant key" + + +def test_uniqueness_is_scoped_by_tenant_and_registry() -> None: + """Identity is registry-scoped (§ 3.2); two tenants may hold the same id.""" + constraint = next( + c for c in models.PackageVersion.__table__.constraints + if getattr(c, "name", "") == "uq_package_version" + ) + assert [c.name for c in constraint.columns] == ["tenant", "registry", "package_id", "version"] + + +def test_index_entry_records_arrival_not_authorship() -> None: + columns = models.IndexEntry.__table__.c + assert "included_at" in columns and "last_seen_at" in columns + assert "source" in columns and "method" in columns diff --git a/workplans/CANP-WP-0006-hosted-registry-service.md b/workplans/CANP-WP-0006-hosted-registry-service.md index 35fba89..79a9871 100644 --- a/workplans/CANP-WP-0006-hosted-registry-service.md +++ b/workplans/CANP-WP-0006-hosted-registry-service.md @@ -4,7 +4,7 @@ type: workplan title: "Hosted registry and index service" domain: agents repo: canned-prompts -status: proposed +status: active owner: codex topic_slug: practice created: "2026-09-06" @@ -51,7 +51,7 @@ the service does not satisfy. ```task id: CANP-WP-0006-T01 -status: todo +status: done priority: high state_hub_task_id: "6e4178f0-a431-59de-8bd1-fb5b2de95302" ``` @@ -68,11 +68,22 @@ Keep `reference/` untouched. It is the format's conformance witness and must stay dependency-light; the service is a separate consumer of the same package semantics. +**Done.** `service/` with a FastAPI factory, pydantic-settings config, and the +three endpoints. `CANNED_PROMPTS_DATABASE_URL` has no default, so a +misconfigured service refuses to start rather than quietly using a local +database. + +**Bug found by its own test.** `check_readiness` first caught every failure in +one `except` and reported "database unreachable". An unmigrated but perfectly +reachable database therefore reported a connection problem — sending an +operator to credentials and networking when the fix was `alembic upgrade`. +Connectivity and schema are now checked separately. + ## Tenant-keyed schema and first migration ```task id: CANP-WP-0006-T02 -status: todo +status: done priority: high state_hub_task_id: "bc0e4a49-63b1-5fa1-9ec9-5ed0f505baa1" ``` @@ -94,6 +105,11 @@ them: Store package files as content, not as rows per file. +**Done.** Migration `0001` creates `package_versions`, `package_files` and +`index_entries`; a test asserts every table in the metadata carries `tenant`, so +adding an unkeyed table fails the suite rather than being noticed at +consolidation time. Uniqueness is `(tenant, registry, package_id, version)`. + ## Read API ```task