feat(projection): add workplan retirement columns, and record the migration gap
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

Adds projection_retired_at, projection_retired_reason and derived_from_commit.
Retirement cannot be expressed by deletion — hub-native records reference
workplans with RESTRICT — nor by `status`, since an archived workplan was closed
by its owner while a retired one is simply no longer derived by the forge. Those
are different facts and must not share a field.

Discovered while applying this: central's schema is two revisions behind the
code it runs. review_contracts does not exist there although its migration ships
in the serving image, and there is no migration mechanism at all — bare uvicorn
CMD, no chart-declared job. Recorded as STATE-WP-0083-T07, which now blocks T03.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
This commit is contained in:
tegwick 2026-08-25 23:49:13 +02:00
parent fd0d0d537b
commit 89ff2b2ea3
3 changed files with 95 additions and 0 deletions

View file

@ -53,6 +53,16 @@ class Workplan(Base, TimestampMixin):
index=True,
)
backing_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Projection provenance and retirement (ADR-012 decisions 2 and 7).
# A retired record is one the forge no longer derives. It is not deleted:
# hub-native records reference it with RESTRICT and must survive. It is not
# `archived` either — that says the owner closed the work, which is a
# different fact.
projection_retired_at: Mapped["datetime | None"] = mapped_column( # noqa: F821
DateTime(timezone=True), nullable=True, index=True
)
projection_retired_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
derived_from_commit: Mapped[str | None] = mapped_column(String(40), nullable=True)
backing_relative_path: Mapped[str | None] = mapped_column(Text, nullable=True)
backing_archived: Mapped[bool | None] = mapped_column(nullable=True)
backing_synced_at: Mapped[datetime | None] = mapped_column(

View file

@ -0,0 +1,49 @@
"""workplan projection retirement (STATE-WP-0083-T03)
A forge-derived projection must be able to say that a record no longer derives
from the forge. It cannot say so by deleting the row: progress events, tasks,
decisions and review contracts reference workplans with ON DELETE RESTRICT, and
those hub-native records must survive (ADR-010 decision 4, ADR-012 decision 7 as
amended 2026-08-25).
Nor can it say so through `status`. A workplan that is `archived` was closed by
its owner; one that no longer derives is a different fact, and overloading the
same field would destroy the distinction exactly where it matters.
Revision ID: d1a2b3c4e5f6
Revises: c9e5a1b3d7f2
"""
from alembic import op
import sqlalchemy as sa
revision = "d1a2b3c4e5f6"
down_revision = "c9e5a1b3d7f2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"workplans",
sa.Column("projection_retired_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"workplans",
sa.Column("projection_retired_reason", sa.String(length=255), nullable=True),
)
op.add_column(
"workplans",
sa.Column("derived_from_commit", sa.String(length=40), nullable=True),
)
op.create_index(
"ix_workplans_projection_retired_at",
"workplans",
["projection_retired_at"],
)
def downgrade() -> None:
op.drop_index("ix_workplans_projection_retired_at", table_name="workplans")
op.drop_column("workplans", "derived_from_commit")
op.drop_column("workplans", "projection_retired_reason")
op.drop_column("workplans", "projection_retired_at")

View file

@ -214,3 +214,39 @@ heading. That is not a foundation for deletion.
Acceptance: task rows carry their canonical record id; the task diff for
`whitehat-security` reports clean; `the-custodian`'s task diff falls to something
manual inspection confirms.
## Restore a migration mechanism for central
```task
id: STATE-WP-0083-T07
status: todo
priority: high
```
**Central's schema is behind the code it is running, and nothing detects it.**
Discovered 2026-08-25 while preparing the retirement columns.
Central is at revision `b8d4f0a2c6e1`; the chain is
`b8d4f0a2c6e1 → c9e5a1b3d7f2 → d1a2b3c4e5f6`. The `review_contracts` table does
not exist on central even though the migration creating it ships inside the image
currently serving traffic. The code and the database disagree silently.
There is no migration mechanism. The image `CMD` is bare `uvicorn`, the Helm
chart declares no migration job, and the only evidence migrations ever ran was an
ad-hoc `state-hub-alembic-init` Job created outside the chart — deleted earlier
the same day as a retired-forge leftover. Deleting a completed Job removed a
historical record rather than a working mechanism, since Jobs do not re-run, but
it also removed the last visible sign that this gap existed.
This blocks `T03`: the retirement columns cannot reach central without it.
Needed: migrations as a declared part of the release — a chart-managed job or
init container that runs `alembic upgrade head` before the API serves — and a
check that surfaces a schema/code mismatch instead of leaving it silent. A
deployment that can run against a schema it was not built for is the same class
of defect as a projection that cannot name its source commit.
Acceptance: central reaches `head`; `review_contracts` exists; the mechanism is
declared in the chart; a mismatch between code and schema is reported rather than
tolerated.