From 89ff2b2ea38dace2c8510e363756d5ccc99f1ae9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 25 Aug 2026 23:49:13 +0200 Subject: [PATCH] feat(projection): add workplan retirement columns, and record the migration gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006 --- api/models/workplan.py | 10 ++++ ...b3c4e5f6_workplan_projection_retirement.py | 49 +++++++++++++++++++ ...-WP-0083-forge-derived-projection-reset.md | 36 ++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 migrations/versions/d1a2b3c4e5f6_workplan_projection_retirement.py diff --git a/api/models/workplan.py b/api/models/workplan.py index 7fcf51f..f3f9f2a 100644 --- a/api/models/workplan.py +++ b/api/models/workplan.py @@ -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( diff --git a/migrations/versions/d1a2b3c4e5f6_workplan_projection_retirement.py b/migrations/versions/d1a2b3c4e5f6_workplan_projection_retirement.py new file mode 100644 index 0000000..632a816 --- /dev/null +++ b/migrations/versions/d1a2b3c4e5f6_workplan_projection_retirement.py @@ -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") diff --git a/workplans/STATE-WP-0083-forge-derived-projection-reset.md b/workplans/STATE-WP-0083-forge-derived-projection-reset.md index cc3b1ce..c172537 100644 --- a/workplans/STATE-WP-0083-forge-derived-projection-reset.md +++ b/workplans/STATE-WP-0083-forge-derived-projection-reset.md @@ -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.