canned-prompts/service
tegwick 8a1a2426d5 Service fixes found by the first real deployment
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
2026-09-08 08:56:59 +02:00
..
migrations Service fixes found by the first real deployment 2026-09-08 08:56:59 +02:00
src/canned_prompts_service Service fixes found by the first real deployment 2026-09-08 08:56:59 +02:00
tests Service fixes found by the first real deployment 2026-09-08 08:56:59 +02:00
tools CANP-WP-0006 T06: container image and smoke checks 2026-09-06 21:25:48 +02:00
.dockerignore CANP-WP-0006 T06: container image and smoke checks 2026-09-06 21:25:48 +02:00
alembic.ini CANP-WP-0006 T04: publish API 2026-09-06 20:30:00 +02:00
Dockerfile CANP-WP-0006 T06: container image and smoke checks 2026-09-06 21:25:48 +02:00
Makefile CANP-WP-0006 T06: container image and smoke checks 2026-09-06 21:25:48 +02:00
pyproject.toml Service fixes found by the first real deployment 2026-09-08 08:56:59 +02:00
README.md Service fixes found by the first real deployment 2026-09-08 08:56:59 +02:00

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=&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.

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.py read database_url rather than resolved_database_url, so the migration could not run where the credential is a file;
  • SET ROLE opened 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 00010002), 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.