CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema

The foundation of the hosted registry, in canned-prompts so rapp.yaml gets
ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership
stays out of the operations repo.

Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic,
PostgreSQL, in service/ with its own environment. reference/ is deliberately
untouched: it is the format's conformance witness and stays dependency-light,
and the service is a separate consumer of the same package semantics.

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 per RailianceAppDeploymentGuide.md: unauthenticated /healthz and
/readyz, plus /state/health for fleet consistency. /healthz deliberately checks
nothing beyond the process being up, so a database blip does not restart pods;
/readyz asks the database something it can fail to answer.

Migration 0001 creates package_versions, package_files and index_entries, every
one carrying a tenant key per business-app-service-contract section 1.3 — the
service is single-tenant today, and the key is present so a later consolidation
is a data copy rather than a rewrite. A test asserts every table in the metadata
is tenant-keyed, so adding an unkeyed table fails the suite rather than being
discovered at consolidation time. Uniqueness is (tenant, registry, package_id,
version): registry-scoped because identity is, tenant-scoped so two tenants may
hold the same id.

The schema keeps the format's three things distinct — an immutable package
version, its files as content rather than parsed rows, and an index entry
recording how a version arrived here.

Fixes a bug its own test caught: check_readiness first caught every failure in
one except and reported "database unreachable", so an unmigrated but perfectly
reachable database sent an operator to credentials and networking when the fix
was alembic upgrade. Connectivity and schema are now checked separately.

Service tests 11 passing; reference tests unaffected at 99.

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 19:57:47 +02:00
parent 65a86acdc2
commit ae52931be5
15 changed files with 701 additions and 3 deletions

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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 `<id>@<version>`
(§ 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 `<registry>:<id>@<version>` 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))

View file

@ -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()