diff --git a/service/README.md b/service/README.md index 3c03ed4..254c67b 100644 --- a/service/README.md +++ b/service/README.md @@ -89,9 +89,42 @@ 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 +`@` 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. + ## Status -`CANP-WP-0006` T01–T03 are done: skeleton, health surface, tenant-keyed schema -and migration `0001`, and the read API. The publish API, HTTP registry client -and container image (T04–T06) are not built yet. T06 produces the image digest -that `rapp-canned-prompts` needs to pin. +`CANP-WP-0006` T01–T04 are done: skeleton, health surface, tenant-keyed schema +(migrations `0001`–`0002`), and the read and publish APIs. The HTTP registry +client in the CLI and the container image (T05–T06) are not built yet. T06 +produces the image digest that `rapp-canned-prompts` needs to pin. + +Per-publisher identity is deferred, and is the main thing standing between this +and a service that several people can publish to. diff --git a/service/alembic.ini b/service/alembic.ini index bd0ac5a..7677af3 100644 --- a/service/alembic.ini +++ b/service/alembic.ini @@ -1,6 +1,7 @@ [alembic] script_location = migrations prepend_sys_path = src +path_separator = os [loggers] keys = root diff --git a/service/migrations/versions/0001_tenant_keyed_package_store.py b/service/migrations/versions/0001_tenant_keyed_package_store.py index 152ca89..93b4742 100644 --- a/service/migrations/versions/0001_tenant_keyed_package_store.py +++ b/service/migrations/versions/0001_tenant_keyed_package_store.py @@ -60,7 +60,7 @@ def upgrade() -> None: op.create_table('package_files', sa.Column('id', BigIntPK, autoincrement=True, nullable=False), sa.Column('tenant', sa.String(length=64), nullable=False), - sa.Column('package_version_id', sa.BigInteger(), nullable=False), + sa.Column('package_version_id', BigIntPK, 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'), diff --git a/service/migrations/versions/0002_namespace_claims.py b/service/migrations/versions/0002_namespace_claims.py new file mode 100644 index 0000000..3586847 --- /dev/null +++ b/service/migrations/versions/0002_namespace_claims.py @@ -0,0 +1,39 @@ +"""namespace claims + +Registry namespace ownership (section 20.1). Descriptive on a filesystem +registry, which cannot authenticate a publisher; enforced here, which can — +though only as strongly as the identity behind it. + +Revision ID: 0002 +Revises: 0001 +""" +from alembic import op +import sqlalchemy as sa + +BigIntPK = sa.BigInteger().with_variant(sa.Integer, "sqlite") + +revision = '0002' +down_revision = '0001' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('namespace_claims', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), autoincrement=True, nullable=False), + sa.Column('tenant', sa.String(length=64), nullable=False), + sa.Column('registry', sa.String(length=128), nullable=False), + sa.Column('namespace', sa.String(length=256), nullable=False), + sa.Column('policy', sa.String(length=16), nullable=False), + sa.Column('owner', sa.String(length=256), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant', 'registry', 'namespace', name='uq_namespace_claim') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('namespace_claims') + # ### end Alembic commands ### diff --git a/service/src/canned_prompts_service/api.py b/service/src/canned_prompts_service/api.py index 0566a9c..58c22b5 100644 --- a/service/src/canned_prompts_service/api.py +++ b/service/src/canned_prompts_service/api.py @@ -72,5 +72,5 @@ def create_app(settings: Settings | None = None, engine: Engine | None = None) - "migration": readiness.migration, } - app.include_router(make_router(get_session, get_tenant)) + app.include_router(make_router(get_session, get_tenant, settings)) return app diff --git a/service/src/canned_prompts_service/auth.py b/service/src/canned_prompts_service/auth.py new file mode 100644 index 0000000..f44c57e --- /dev/null +++ b/service/src/canned_prompts_service/auth.py @@ -0,0 +1,54 @@ +"""Publisher identity. + +What this is, stated plainly so nobody mistakes it for more: a single shared +bearer token proving the caller is *the operator of this service*. It is not +per-publisher identity — every holder of the token is indistinguishable. + +That limit decides how § 20.1 namespace claims are enforced here. A `closed` +namespace can be protected from anonymous callers, which a filesystem registry +cannot do at all, but it cannot be attributed to one of several publishers. +Per-publisher identity is deferred; until it exists, a claim's `owner` is +documentation rather than an access decision. + +With no token configured the service is read-only. Refusing writes is the +correct default for a registry that cannot tell who is calling — § 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. +""" + +from __future__ import annotations + +import hmac + +from fastapi import HTTPException, Request + +from .settings import Settings + + +class PublishDenied(HTTPException): + pass + + +def require_publisher(request: Request, settings: Settings) -> str: + """Return the publisher identity, or refuse.""" + if not settings.publish_token: + raise PublishDenied( + status_code=503, + detail=( + "publishing is not configured: this service has no publisher " + "identity, so it cannot tell who is calling and refuses writes " + "rather than accepting anonymous publishes (§ 20.1)" + ), + ) + + header = request.headers.get("authorization", "") + scheme, _, credential = header.partition(" ") + if scheme.lower() != "bearer" or not credential: + raise PublishDenied( + status_code=401, detail="publishing requires an Authorization: Bearer token" + ) + # compare_digest so a wrong token cannot be recovered by timing the reply. + if not hmac.compare_digest(credential, settings.publish_token): + raise PublishDenied(status_code=403, detail="token not accepted") + return settings.publisher_name diff --git a/service/src/canned_prompts_service/models.py b/service/src/canned_prompts_service/models.py index b0cf643..565f149 100644 --- a/service/src/canned_prompts_service/models.py +++ b/service/src/canned_prompts_service/models.py @@ -91,8 +91,11 @@ class PackageFile(Base): id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True) tenant: Mapped[str] = mapped_column(String(64), nullable=False) + # Typed explicitly with the same variant as the target key. Left to + # inference, autogenerate compares a variant against a reflected plain type + # and proposes a spurious ALTER COLUMN on every future migration. package_version_id: Mapped[int] = mapped_column( - ForeignKey("package_versions.id", ondelete="CASCADE"), nullable=False + BigIntPK, 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) @@ -130,3 +133,24 @@ class IndexEntry(Base): DateTime(timezone=True), nullable=False, default=utcnow ) last_seen_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class NamespaceClaim(Base): + """A registry's namespace ownership claim (§ 20.1). + + On a filesystem registry these claims are descriptive, because nothing can + authenticate a publisher. A service *can*, so here they are enforced — which + is only as strong as the identity behind them; see `auth.py`. + """ + + __tablename__ = "namespace_claims" + __table_args__ = ( + UniqueConstraint("tenant", "registry", "namespace", name="uq_namespace_claim"), + ) + + id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True) + tenant: Mapped[str] = mapped_column(String(64), nullable=False) + registry: Mapped[str] = mapped_column(String(128), nullable=False) + namespace: Mapped[str] = mapped_column(String(256), nullable=False) + policy: Mapped[str] = mapped_column(String(16), nullable=False, default="open") + owner: Mapped[str | None] = mapped_column(String(256)) diff --git a/service/src/canned_prompts_service/routes.py b/service/src/canned_prompts_service/routes.py index d119cc5..da55e8a 100644 --- a/service/src/canned_prompts_service/routes.py +++ b/service/src/canned_prompts_service/routes.py @@ -10,13 +10,17 @@ swallow a neighbouring segment. from __future__ import annotations import base64 +import tempfile +from pathlib import Path from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from . import store -from .store import Ambiguous, NotFound, Reference, StoreError +from .auth import require_publisher +from .settings import Settings +from .store import Ambiguous, Conflict, NotFound, Reference, StoreError router = APIRouter() @@ -28,9 +32,31 @@ def as_http(exc: StoreError) -> HTTPException: # 409, not 300: the request is answerable, but only once the caller says # which registry they meant. return HTTPException(status_code=409, detail=str(exc)) + if isinstance(exc, Conflict): + return HTTPException(status_code=409, detail=str(exc)) return HTTPException(status_code=400, detail=str(exc)) +def decode_files(payload: dict[str, Any]) -> dict[str, bytes]: + """Accept the same shape `/archives` returns, so an archive round-trips.""" + files: dict[str, bytes] = {} + for path, item in (payload or {}).items(): + if not isinstance(item, dict): + raise HTTPException(400, f"{path}: expected an object with text or base64") + if "text" in item: + files[path] = str(item["text"]).encode("utf-8") + elif "base64" in item: + try: + files[path] = base64.b64decode(str(item["base64"]), validate=True) + except Exception as exc: # noqa: BLE001 + raise HTTPException(400, f"{path}: invalid base64") from exc + else: + raise HTTPException(400, f"{path}: expected text or base64") + if not files: + raise HTTPException(400, "no files supplied") + return files + + def version_payload(row: Any, *, manifest: bool = False) -> dict[str, Any]: payload = { "registry": row.registry, @@ -50,9 +76,60 @@ def version_payload(row: Any, *, manifest: bool = False) -> dict[str, Any]: return payload -def make_router(get_session, get_tenant) -> APIRouter: +def make_router(get_session, get_tenant, settings: Settings) -> APIRouter: api = APIRouter() + @api.post("/packages", status_code=201) + def publish( + request: Request, + payload: dict[str, Any] = Body(...), + session: Session = Depends(get_session), + tenant: str = Depends(get_tenant), + ) -> dict[str, Any]: + publisher = require_publisher(request, settings) + + registry = str(payload.get("registry") or "").strip() + if not registry: + raise HTTPException(400, "registry is required") + files = decode_files(payload.get("files")) + source = str(payload.get("source") or f"published by {publisher}") + + with tempfile.TemporaryDirectory() as tmp: + try: + package_dir = store.materialize(files, Path(tmp)) + manifest_id = store.peek_id(package_dir) + except StoreError as exc: + raise as_http(exc) from exc + except Exception as exc: # noqa: BLE001 — malformed package + raise HTTPException(400, f"invalid package: {exc}") from exc + + # § 20.1 enforced, not advised: unlike a filesystem registry, a + # service can refuse. It can only refuse as precisely as its + # identity allows — see auth.py. + claim = store.claim_for(session, tenant, registry, manifest_id) + if claim is not None and claim.policy == "closed": + owner = claim.owner or "unspecified" + if publisher != (claim.owner or publisher): + raise HTTPException( + 403, + f"namespace {store.namespace_of(manifest_id)!r} in registry " + f"{registry!r} is closed (owner: {owner})", + ) + + try: + version = store.ingest( + session, tenant, registry, package_dir, source=source, method="publish" + ) + session.commit() + except StoreError as exc: + session.rollback() + raise as_http(exc) from exc + except Exception as exc: # noqa: BLE001 + session.rollback() + raise HTTPException(400, f"invalid package: {exc}") from exc + + return version_payload(version) + @api.get("/packages") def list_packages( q: str = Query("", description="Substring over id, name, summary and tags"), diff --git a/service/src/canned_prompts_service/settings.py b/service/src/canned_prompts_service/settings.py index 9aa8b3d..b6946a1 100644 --- a/service/src/canned_prompts_service/settings.py +++ b/service/src/canned_prompts_service/settings.py @@ -18,6 +18,12 @@ class Settings(BaseSettings): tenant: str = "default" service_name: str = "canned-prompts" + # A single shared token, so the identity it proves is "the operator of this + # service" and nothing finer. Unset means the service is read-only; see + # auth.py for why that is the right default rather than an inconvenience. + publish_token: str = "" + publisher_name: str = "operator" + @property def configured(self) -> bool: return bool(self.database_url) diff --git a/service/src/canned_prompts_service/store.py b/service/src/canned_prompts_service/store.py index 19bf42b..91bd37c 100644 --- a/service/src/canned_prompts_service/store.py +++ b/service/src/canned_prompts_service/store.py @@ -248,3 +248,39 @@ def index_entries(session: Session, tenant: str) -> list[IndexEntry]: session.scalars(select(IndexEntry).where(IndexEntry.tenant == tenant)) ) return sorted(rows, key=lambda r: (r.registry, r.package_id, cp.parse_semver(r.version))) + + +def namespace_of(package_id: str) -> str: + return package_id.split("/")[0] if "/" in package_id else package_id + + +def claim_for(session: Session, tenant: str, registry: str, package_id: str): + from .models import NamespaceClaim + + return session.scalar( + select(NamespaceClaim).where( + NamespaceClaim.tenant == tenant, + NamespaceClaim.registry == registry, + NamespaceClaim.namespace == namespace_of(package_id), + ) + ) + + +def materialize(files: dict[str, bytes], destination: Path) -> Path: + """Write a posted package to disk so the reference validator can read it.""" + for relative, content in files.items(): + if relative.startswith("/") or ".." in Path(relative).parts: + raise StoreError(f"unsafe path in package: {relative}") + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + return destination + + +def peek_id(package_dir: Path) -> str: + """The declared id, needed before validation to check namespace policy.""" + manifest = cp.read_manifest(package_dir) + package_id = manifest.get("id") + if not isinstance(package_id, str) or not package_id.strip(): + raise StoreError("manifest declares no id") + return package_id diff --git a/service/tests/test_health.py b/service/tests/test_health.py index 655f2fb..97a8f88 100644 --- a/service/tests/test_health.py +++ b/service/tests/test_health.py @@ -12,6 +12,7 @@ from pathlib import Path import pytest from alembic import command from alembic.config import Config +from alembic.script import ScriptDirectory from fastapi.testclient import TestClient from canned_prompts_service.api import create_app @@ -21,6 +22,13 @@ from canned_prompts_service.settings import Settings ROOT = Path(__file__).resolve().parents[1] +def expected_head() -> str: + """Computed, not hardcoded: a new migration must not fail these tests.""" + config = Config(str(ROOT / "alembic.ini")) + config.set_main_option("script_location", str(ROOT / "migrations")) + return ScriptDirectory.from_config(config).get_current_head() + + def migrated_url(tmp_path: Path) -> str: url = f"sqlite:///{tmp_path / 'svc.db'}" config = Config(str(ROOT / "alembic.ini")) @@ -70,7 +78,7 @@ def test_readyz_fails_when_the_schema_is_not_migrated(tmp_path: Path) -> None: 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" + assert body["migration"] == expected_head() def test_state_health_matches_the_fleet_shape(ready_client: TestClient) -> None: @@ -78,7 +86,7 @@ def test_state_health_matches_the_fleet_shape(ready_client: TestClient) -> None: assert body["status"] == "ok" assert body["service"] == "canned-prompts" assert body["db"] == "connected" - assert body["migration"] == "0001" + assert body["migration"] == expected_head() def test_state_health_degrades_rather_than_lying() -> None: diff --git a/service/tests/test_publish_api.py b/service/tests/test_publish_api.py new file mode 100644 index 0000000..26b9c29 --- /dev/null +++ b/service/tests/test_publish_api.py @@ -0,0 +1,145 @@ +"""Publish API (CANP-WP-0006 T04).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from canned_prompts_service.api import create_app +from canned_prompts_service.db import make_engine, make_session_factory +from canned_prompts_service.models import NamespaceClaim +from canned_prompts_service.settings import Settings + +REPO = Path(__file__).resolve().parents[2] +TOKEN = "test-token" +AUTH = {"Authorization": f"Bearer {TOKEN}"} + + +def package_payload(name: str, registry: str = "local") -> dict: + """Built from the archive shape, so an archive round-trips into a publish.""" + source_dir = REPO / "examples" / name + import canned_prompts as cp + + manifest = cp.validate_package(source_dir) + files = { + rel: {"text": (source_dir / rel).read_text(encoding="utf-8")} + for rel in sorted(cp.package_members(source_dir, manifest)) + } + return {"registry": registry, "source": f"examples/{name}", "files": files} + + +@pytest.fixture() +def writable(db_url: str) -> TestClient: + settings = Settings(database_url=db_url, publish_token=TOKEN) + return TestClient(create_app(settings, make_engine(db_url))) + + +def test_unconfigured_service_refuses_writes(client: TestClient) -> None: + """No publisher identity means the service cannot tell who is calling.""" + response = client.post("/packages", json=package_payload("house-style")) + assert response.status_code == 503 + assert "publishing is not configured" in response.json()["detail"] + + +def test_publish_requires_a_token(writable: TestClient) -> None: + response = writable.post("/packages", json=package_payload("house-style")) + assert response.status_code == 401 + + +def test_wrong_token_is_refused(writable: TestClient) -> None: + response = writable.post( + "/packages", + json=package_payload("house-style"), + headers={"Authorization": "Bearer nope"}, + ) + assert response.status_code == 403 + + +def test_publish_then_read_back(writable: TestClient) -> None: + response = writable.post("/packages", json=package_payload("house-style"), headers=AUTH) + assert response.status_code == 201 + assert response.json()["reference"] == "local:practice/house-style@0.1.1" + + body = writable.get("/packages/local:practice/house-style@0.1.1").json() + assert body["type"] == "fragment" + assert writable.get("/index").json()["count"] == 1 + + +def test_archive_round_trips_into_a_publish(writable: TestClient) -> None: + """What GET /archives returns is exactly what POST /packages accepts.""" + writable.post("/packages", json=package_payload("house-style"), headers=AUTH) + archive = writable.get("/archives/practice/house-style@0.1.1").json() + + republished = writable.post( + "/packages", + json={"registry": "mirror", "source": "round-trip", "files": archive["files"]}, + headers=AUTH, + ) + assert republished.status_code == 201 + assert republished.json()["content_digest"] == archive["content_digest"] + + +def test_identical_republish_is_accepted(writable: TestClient) -> None: + payload = package_payload("house-style") + assert writable.post("/packages", json=payload, headers=AUTH).status_code == 201 + assert writable.post("/packages", json=payload, headers=AUTH).status_code == 201 + + +def test_different_content_under_the_same_version_is_a_conflict(writable: TestClient) -> None: + """§ 17: a published id@version is immutable within a registry.""" + payload = package_payload("house-style") + writable.post("/packages", json=payload, headers=AUTH) + payload["files"]["prompt.md"]["text"] += "\nsomething different\n" + response = writable.post("/packages", json=payload, headers=AUTH) + assert response.status_code == 409 + assert "already exists with different content" in response.json()["detail"] + + +def test_invalid_package_is_rejected(writable: TestClient) -> None: + payload = package_payload("house-style") + payload["files"]["prompt.yaml"]["text"] = "format: canned-prompt/v9.9\nid: x/y\n" + response = writable.post("/packages", json=payload, headers=AUTH) + assert response.status_code == 400 + + +def test_path_traversal_is_refused(writable: TestClient) -> None: + payload = package_payload("house-style") + payload["files"]["../escape.txt"] = {"text": "no"} + assert writable.post("/packages", json=payload, headers=AUTH).status_code == 400 + + +def test_closed_namespace_refuses_a_publisher_it_cannot_attribute( + db_url: str, session: Session +) -> None: + """§ 20.1 enforced rather than advised — as far as identity allows.""" + session.add( + NamespaceClaim( + tenant="default", registry="local", namespace="practice", + policy="closed", owner="someone-else", + ) + ) + session.commit() + client = TestClient( + create_app(Settings(database_url=db_url, publish_token=TOKEN), make_engine(db_url)) + ) + response = client.post("/packages", json=package_payload("house-style"), headers=AUTH) + assert response.status_code == 403 + assert "is closed" in response.json()["detail"] + + +def test_open_namespace_publishes(db_url: str, session: Session) -> None: + session.add( + NamespaceClaim( + tenant="default", registry="local", namespace="practice", policy="open" + ) + ) + session.commit() + client = TestClient( + create_app(Settings(database_url=db_url, publish_token=TOKEN), make_engine(db_url)) + ) + assert client.post( + "/packages", json=package_payload("house-style"), headers=AUTH + ).status_code == 201 diff --git a/workplans/CANP-WP-0006-hosted-registry-service.md b/workplans/CANP-WP-0006-hosted-registry-service.md index 6e8ca10..8ea3f09 100644 --- a/workplans/CANP-WP-0006-hosted-registry-service.md +++ b/workplans/CANP-WP-0006-hosted-registry-service.md @@ -155,7 +155,7 @@ models and the same migration. ```task id: CANP-WP-0006-T04 -status: todo +status: done priority: high state_hub_task_id: "929932b6-931b-5409-a648-b6814f07f0b4" ``` @@ -170,6 +170,38 @@ filesystem registry a service *can* authenticate a publisher. Note what identity mechanism it uses; if there is none yet, say so and refuse writes rather than accepting anonymous publishes into a closed namespace. +**Done.** `POST /packages` takes the same `files` shape `GET /archives` +returns, so an archive round-trips into a publish without translation and a +mirror is a GET followed by a POST — verified by a test, not just asserted. + +**The identity mechanism, stated plainly.** A single shared bearer token +proving the caller is *the operator of this service*. Not per-publisher +identity: every token holder is indistinguishable. With no token configured the +service is read-only, which is the right 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 live in `namespace_claims` (migration `0002`) and are enforced, +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. Until per-publisher identity +exists, a claim's `owner` is documentation rather than an access decision, and +`auth.py` says so rather than letting the code imply more than it delivers. + +**Handed forward:** per-publisher identity 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, +leaving the table created and the 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. + ## HTTP registry in the reference CLI ```task