"""task_kind gains 'spike' — the investigation, not the change (milestone 312 step 5) Revision ID: 0091 Revises: 0090 Create Date: 2026-08-27 A spike is a task shape the others cannot hold. `work` ships a change; `issue` fixes something broken. A spike is time-boxed and its output is KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the end of it. "Find out whether the runner can be given a bash shell" is not work, and filing it as work makes a finished investigation look like an abandoned change. It is the record a failed check asks for. Milestone 312 gave rules a `verify_with`; when one of those fails, the rule is wrong and the next move is often to go and find out what replaced it. `notes.arose_from_id` already exists (0065), so that constraint -> spike link needs no further schema. Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the widened constraint land in the SAME migration — DROP then ADD, exactly as 0065 did when it introduced 'issue'. Adding the value and constraining it later leaves a window where the database accepts anything. 'plan' stays in the list though it is retired (plans are milestones since 0066): historical plan-tasks still carry it, and dropping it from the whitelist would make old rows unwritable. """ from alembic import op revision = "0091" down_revision = "0090" branch_labels = None depends_on = None # One tuple so the upgrade and the downgrade cannot disagree about what the # list was on either side of this migration. _KINDS_AFTER = ("work", "plan", "issue", "spike") _KINDS_BEFORE = ("work", "plan", "issue") # Restated rather than imported from 0088, which has the same helper. A # migration is a snapshot: it must keep working when the code around it has # moved on, so it never imports from live modules or from its siblings. Six # duplicated lines are the price of that, and the cheap half of the bargain. def _in_list(values: tuple[str, ...]) -> str: return "task_kind IN (" + ", ".join(f"'{v}'" for v in values) + ")" def upgrade() -> None: op.drop_constraint("notes_task_kind_check", "notes", type_="check") op.create_check_constraint( "notes_task_kind_check", "notes", _in_list(_KINDS_AFTER), ) def downgrade() -> None: # Any row already filed as a spike would violate the narrowed constraint, # so they are demoted to 'work' first. Lossy and deliberately so: the # alternative is a downgrade that fails on real data, which is worse than # a downgrade that says what it did. op.execute("UPDATE notes SET task_kind = 'work' WHERE task_kind = 'spike'") op.drop_constraint("notes_task_kind_check", "notes", type_="check") op.create_check_constraint( "notes_task_kind_check", "notes", _in_list(_KINDS_BEFORE), )