Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/backend/alembic/versions/0032_scan_kind_sbom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""scan_kind += sbom

Revision ID: 0032
Revises: 0031
Created: 2026-06-13

Phase: sbom-ingest (prereq schema)
Kind: schema
Forward-only: yes

What:
- Add ``sbom`` to the ``scan_kind`` Postgres enum.

Why:
- External CycloneDX SBOM ingest (a separate follow-up PR) creates Scan rows
whose source is an uploaded SBOM rather than a source-tree or container
scan. The ``scan_kind`` enum (created in mig 0003) is native Postgres, so a
Scan INSERT with kind ``sbom`` is rejected at the DB layer until the type
accepts it. This revision adds the value up front so the ingest PR ships
purely as application code. The model tuple ``SCAN_KIND_VALUES`` and the
wire ``ScanKind`` Literal are updated in the same PR; a parity test
(tests/unit/test_catalog_contracts.py) keeps the three in lockstep.

Backfill:
- None. Additive enum value; existing rows unaffected.

Downgrade:
- Forward-only per CLAUDE.md §6. Postgres cannot drop an enum value without a
full type rebuild (and that would orphan any row already tagged ``sbom``);
we never remove emitted kinds.
"""

from __future__ import annotations

from collections.abc import Sequence

from alembic import op

revision: str = "0032"
down_revision: str | None = "0031"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# PG 12+ permits ALTER TYPE ... ADD VALUE inside a transaction (the new
# value just cannot be USED in the same transaction — we only add it here).
# IF NOT EXISTS keeps the migration idempotent across partial re-runs.
op.execute("ALTER TYPE scan_kind ADD VALUE IF NOT EXISTS 'sbom'")


def downgrade() -> None:
# Forward-only (CLAUDE.md §6). Removing an enum value requires a type
# rebuild and risks orphaning rows; we never drop emitted kinds.
pass
2 changes: 1 addition & 1 deletion apps/backend/models/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
# name= with create_type=False so SQLAlchemy never tries to (re)create them.

PROJECT_VISIBILITY_VALUES = ("team", "organization")
SCAN_KIND_VALUES = ("source", "container")
SCAN_KIND_VALUES = ("source", "container", "sbom")
SCAN_STATUS_VALUES = ("queued", "running", "succeeded", "failed", "cancelled")
VULN_SEVERITY_VALUES = ("critical", "high", "medium", "low", "info", "unknown")
# Vulnerability finding status — Phase 2 ships the full 7-state set up front
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/schemas/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def _measure_metadata_depth(value: Any, *, _level: int = 0) -> int:
# Literal here local — drift would surface immediately as a mypy error in the
# service layer when it casts to the model column.
ProjectVisibility = Literal["team", "organization"]
ScanKind = Literal["source", "container"]
ScanKind = Literal["source", "container", "sbom"]
ScanStatus = Literal["queued", "running", "succeeded", "failed", "cancelled"]


Expand Down
20 changes: 20 additions & 0 deletions apps/backend/tests/unit/test_catalog_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,26 @@ def test_dispatcher_builders_cover_every_dispatcher_kind() -> None:
assert set(_BUILDERS.keys()) == dispatcher_kinds


# ---------------------------------------------------------------------------
# Scan kind vocabulary — drift guard (testing-standards rule 2)
# ---------------------------------------------------------------------------


def test_scan_kind_enum_matches_schema_literal() -> None:
"""DB enum tuple (models) and the wire Literal (schemas) must be identical.

``scan_kind`` lives in three places: the native Postgres enum (migration
0003, extended by 0032 for ``sbom``), ``SCAN_KIND_VALUES`` (the SQLAlchemy
binding), and the ``ScanKind`` Literal (the request/response contract). A
value added to one without the others either rejects valid input at the API
boundary or rejects a valid INSERT at the DB — this test fails first.
"""
from models.scan import SCAN_KIND_VALUES
from schemas.scan import ScanKind

assert set(SCAN_KIND_VALUES) == set(typing.get_args(ScanKind))


# ---------------------------------------------------------------------------
# Obligation kind vocabulary — H-9 guard
# ---------------------------------------------------------------------------
Expand Down
Loading