Three defects the test suite could not have caught, because each needed a real cluster, a mounted secret, or a live PostgreSQL. env.py read database_url rather than resolved_database_url. `configured` was true because a file was set, and the field it then read was empty — so Alembic received an empty URL and the migration could never run in the cluster. The value is also now escaped for ConfigParser interpolation, since a `%` in a generated password would otherwise raise at credential rotation, which is the worst time to find out. SET ROLE opened an implicit transaction that Alembic then nested inside rather than owning, so it never committed and leaving the connection block rolled everything back. Alembic logged "Running upgrade" for every revision against a database that stayed empty. SET ROLE is session-scoped, so committing immediately ends the implicit transaction without discarding the role. A missing optional publish-token file was treated as a hard failure. The absence is the documented read-only posture — the secret is mounted optional and deliberately not issued — so treating it as a fault turned an intended state into a 500 rather than the 503 that explains it. `required` now separates the two cases: a missing database URL still fails loudly, because there the silence would hide a real fault. Migrations also assume the durable owner role rather than creating objects as the leased migration login, per the rapp-postgres database-owner boundary. The role name is validated against an identifier pattern because SET ROLE cannot be parameterised. Service tests 36 -> 47. 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 |
||
|---|---|---|
| .. | ||
| migrations | ||
| src/canned_prompts_service | ||
| tests | ||
| tools | ||
| .dockerignore | ||
| alembic.ini | ||
| Dockerfile | ||
| Makefile | ||
| pyproject.toml | ||
| README.md | ||
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.
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
Credentials may arrive as files rather than environment variables —
CANNED_PROMPTS_DATABASE_URL_FILE and CANNED_PROMPTS_PUBLISH_TOKEN_FILE —
which is how they are supplied in the cluster. An env var holding a password is
visible in kubectl describe, in crash dumps, and to anything that can read
/proc; a mounted secret should stay a file. When both forms are set the file
wins, because a rotated secret must take effect rather than be shadowed by a
stale env var.
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 <registry>:<id>@<version>, 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 |
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=®istry= |
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.
Publish API
POST /packages takes {registry, source, files}, where files is the same
shape GET /archives returns — so an archive round-trips into a publish
without translation, and a mirror is a GET followed by a POST.
Validation is the reference implementation's, applied to the posted files
materialized in a temporary directory. Only reserved paths and
manifest-referenced files are stored (§ 2); path traversal is refused.
Re-publishing identical content is accepted, different content under the same
<id>@<version> is 409 (§ 17).
What identity means here
A single shared bearer token (CANNED_PROMPTS_PUBLISH_TOKEN), proving the
caller is the operator of this service — not per-publisher identity. Every
holder of the token is indistinguishable.
With no token configured the service is read-only. That is the correct default rather than an inconvenience: § 20.1 asks a registry to refuse publication into a closed namespace it does not consider the publisher to own, and an unauthenticated service considers nobody to own anything.
Namespace claims (§ 20.1) live in namespace_claims and are enforced here,
which a filesystem registry cannot do at all — but only as precisely as the
identity behind them. A closed namespace is protected from anonymous callers;
it cannot be attributed among several publishers. Until per-publisher identity
exists, a claim's owner is documentation rather than an access decision, and
the code says so where it matters.
Image and smoke
make image # builds from the repo root; reference/ is a real dependency
make smoke BASE=http://... # service-level checks, exits non-zero on failure
The image is a two-stage python:3.12-slim build carrying no build toolchain,
running as a non-root user, writing nothing to disk — its state is the database.
It deliberately does not run migrations on start-up: a schema change is a
deployment step with its own rollback, not something that races between replicas.
tools/smoke.py asserts what can be known about a running service: liveness,
readiness, fleet health shape, the migration revision, and that the index and
registry are queryable. It is stdlib-only so it runs inside the runtime image,
and it exits non-zero so a deployment gate can call it directly.
Cluster-level checks a rapp contract also names — NetworkPolicies present,
external secrets ready, private-Service-only, live image digest match — are
properties of the deployment rather than of this process, and belong to
rapp-canned-prompts.
--expect-migration matters: without it the check can only confirm the schema
is stamped at all, and it says so rather than implying it verified the head.
Deployed
Running on railiance01 since 2026-09-08 via rapp-canned-prompts, image tag
0.1.4, schema at alembic 0002, read-only by design.
Four defects surfaced during that first rollout that the test suite could not have caught, because each needed a real cluster, a mounted secret, or a live PostgreSQL:
env.pyreaddatabase_urlrather thanresolved_database_url, so the migration could not run where the credential is a file;SET ROLEopened an implicit transaction that Alembic then nested inside rather than owning, so every revision logged as applied and was rolled back — success reported against an empty database;- a missing optional publish-token file was treated as a hard failure, so the documented read-only posture returned 500 instead of an explanatory 503;
- and separately in the rapp, an egress NetworkPolicy that did not select the migration Job at all.
Each now has a regression test. The transaction one is the most worth knowing: touching an Alembic connection before Alembic does changes who owns the transaction, and the failure is silent in both directions.
Status
CANP-WP-0006 is complete: skeleton and health surface, tenant-keyed schema
(migrations 0001–0002), read and publish APIs, HTTP registries in the
reference CLI, and a verified container image with smoke checks.
Not done, and needed before rapp-canned-prompts: the image has been built
and verified locally but never pushed. rapp.yaml pins
upstream_components.version to a digest from the fleet's registry, and that
digest only exists once the image is published — an operator action needing
registry credentials.
Per-publisher identity remains deferred, and is the main thing between this and a registry several people can publish to.