canned-prompts/service/README.md

161 lines
7.1 KiB
Markdown
Raw Normal View History

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
2026-09-06 19:57:47 +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`.
```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 `<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` |
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
2026-09-06 20:23:25 +02:00
## 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.
CANP-WP-0006 T04: 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. A test verifies the round-trip by digest rather than asserting it. 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 (section 2), path traversal is refused, identical re-publishes are accepted, and different content under the same id@version is 409 (section 17). The identity mechanism, stated plainly rather than implied: a single shared bearer token proving the caller is the operator of this service. It is not per-publisher identity — every token holder is indistinguishable — and auth.py says so where someone might otherwise assume more. With no token configured the service is read-only. That is the correct default rather than an inconvenience: section 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 live in namespace_claims (migration 0002) and are enforced here, which a filesystem registry cannot do at all — but only as precisely as the identity allows. A closed namespace is protected from anonymous callers; it cannot be attributed among several publishers. Per-publisher identity is deferred and is the main thing between this and a registry several people can publish to. Migration hygiene found while adding 0002: autogenerate proposed an ALTER COLUMN TYPE on package_files.package_version_id, because the foreign key's type was left to inference and compared as a variant against a reflected plain type. SQLite cannot alter a column type, so 0002 failed halfway — table created, revision unstamped, the partially-applied state that is worst to debug later. Fixed at the cause: the column is typed explicitly, and 0001 was corrected rather than patched over, which is legitimate only because it has never run outside this repo's tests. alembic check now reports no drift. Health tests now compute the expected migration head from the script directory instead of hardcoding it, so adding a migration cannot fail them spuriously. Service tests 22 -> 33; reference 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
2026-09-06 20:30:00 +02:00
## 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.
CANP-WP-0006 T06: container image and smoke checks Two-stage python:3.12-slim build with no toolchain in the runtime layer, running non-root (uid 10001) and writing nothing to disk — its state is the database. reference/ is a real build input, because the service delegates validation to it so the two cannot disagree about what a valid package is. Migrations deliberately do not run at 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, migration revision, and that the index and registry are queryable. stdlib only, so it runs inside the runtime image; non-zero exit, so a deployment gate can call it directly. Verified by running it, not by inspection: the container starts, all six checks pass against it, the reference CLI installs a package from it over HTTP, and the checks fail correctly against a wrong --expect-migration — so migration-at-head is a real check rather than a decorative one. Without --expect-migration the check can only confirm the schema is stamped at all, and says so rather than implying it verified the head. 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 and belong to rapp-canned-prompts. The digest rapp.yaml would pin does not exist yet. The image was built locally and verified, but never pushed; that digest exists only once the image is published to the fleet registry, which needs credentials and is outward-facing enough not to do unasked. The follow-on sequence for rapp-canned-prompts is recorded in the workplan. CANP-WP-0006 is finished. Service tests 33, reference 105. 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-06 21:25:48 +02:00
## Image and smoke
```bash
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.
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
2026-09-06 19:57:47 +02:00
## Status
CANP-WP-0006 T06: container image and smoke checks Two-stage python:3.12-slim build with no toolchain in the runtime layer, running non-root (uid 10001) and writing nothing to disk — its state is the database. reference/ is a real build input, because the service delegates validation to it so the two cannot disagree about what a valid package is. Migrations deliberately do not run at 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, migration revision, and that the index and registry are queryable. stdlib only, so it runs inside the runtime image; non-zero exit, so a deployment gate can call it directly. Verified by running it, not by inspection: the container starts, all six checks pass against it, the reference CLI installs a package from it over HTTP, and the checks fail correctly against a wrong --expect-migration — so migration-at-head is a real check rather than a decorative one. Without --expect-migration the check can only confirm the schema is stamped at all, and says so rather than implying it verified the head. 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 and belong to rapp-canned-prompts. The digest rapp.yaml would pin does not exist yet. The image was built locally and verified, but never pushed; that digest exists only once the image is published to the fleet registry, which needs credentials and is outward-facing enough not to do unasked. The follow-on sequence for rapp-canned-prompts is recorded in the workplan. CANP-WP-0006 is finished. Service tests 33, reference 105. 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-06 21:25:48 +02:00
`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.
CANP-WP-0006 T04: 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. A test verifies the round-trip by digest rather than asserting it. 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 (section 2), path traversal is refused, identical re-publishes are accepted, and different content under the same id@version is 409 (section 17). The identity mechanism, stated plainly rather than implied: a single shared bearer token proving the caller is the operator of this service. It is not per-publisher identity — every token holder is indistinguishable — and auth.py says so where someone might otherwise assume more. With no token configured the service is read-only. That is the correct default rather than an inconvenience: section 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 live in namespace_claims (migration 0002) and are enforced here, which a filesystem registry cannot do at all — but only as precisely as the identity allows. A closed namespace is protected from anonymous callers; it cannot be attributed among several publishers. Per-publisher identity is deferred and is the main thing between this and a registry several people can publish to. Migration hygiene found while adding 0002: autogenerate proposed an ALTER COLUMN TYPE on package_files.package_version_id, because the foreign key's type was left to inference and compared as a variant against a reflected plain type. SQLite cannot alter a column type, so 0002 failed halfway — table created, revision unstamped, the partially-applied state that is worst to debug later. Fixed at the cause: the column is typed explicitly, and 0001 was corrected rather than patched over, which is legitimate only because it has never run outside this repo's tests. alembic check now reports no drift. Health tests now compute the expected migration head from the script directory instead of hardcoding it, so adding a migration cannot fail them spuriously. Service tests 22 -> 33; reference 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
2026-09-06 20:30:00 +02:00
CANP-WP-0006 T06: container image and smoke checks Two-stage python:3.12-slim build with no toolchain in the runtime layer, running non-root (uid 10001) and writing nothing to disk — its state is the database. reference/ is a real build input, because the service delegates validation to it so the two cannot disagree about what a valid package is. Migrations deliberately do not run at 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, migration revision, and that the index and registry are queryable. stdlib only, so it runs inside the runtime image; non-zero exit, so a deployment gate can call it directly. Verified by running it, not by inspection: the container starts, all six checks pass against it, the reference CLI installs a package from it over HTTP, and the checks fail correctly against a wrong --expect-migration — so migration-at-head is a real check rather than a decorative one. Without --expect-migration the check can only confirm the schema is stamped at all, and says so rather than implying it verified the head. 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 and belong to rapp-canned-prompts. The digest rapp.yaml would pin does not exist yet. The image was built locally and verified, but never pushed; that digest exists only once the image is published to the fleet registry, which needs credentials and is outward-facing enough not to do unasked. The follow-on sequence for rapp-canned-prompts is recorded in the workplan. CANP-WP-0006 is finished. Service tests 33, reference 105. 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-06 21:25:48 +02:00
**Per-publisher identity** remains deferred, and is the main thing between this
and a registry several people can publish to.