feat(issues): S1 schema — issue task_kind, System entity, associations
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m5s

First slice of the Issues + Systems feature (spec #825, plan #819 T2).

Schema (migration 0065):
- task_kind CHECK expands work|plan -> work|plan|issue (same-change, rule 36)
- notes.arose_from_id: optional self-FK for issue->originating-task provenance
  (distinct from parent_id sub-task hierarchy)
- systems: per-project, self-describing (name + description) subsystem/area
- record_systems: M2M join linking any note/task/issue to systems (mutable)

Models: System + RecordSystem; note.py gains arose_from_id (+ index, to_dict).
Service services/systems.py: CRUD, archive, soft-delete, set/list associations,
records-for-system, open-issue count — all gated via services/access.py project
permissions (rule 78, no bare-owner filters). Unit tests lock the ACL gating;
the migration is exercised by CI's integration lane (alembic upgrade head).

is_task stays a derived property (status is not None) — unchanged. T1 (typing-
axis rationalization) intentionally NOT bundled; this only adds the enum value.

Refs plan 825 (S1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 22:53:51 -04:00
parent 88106309f4
commit b91c447b0b
6 changed files with 468 additions and 3 deletions
+103
View File
@@ -0,0 +1,103 @@
"""issues + systems: task_kind=issue, systems, record_systems, arose_from_id
Revision ID: 0065
Revises: 0064
Create Date: 2026-06-14
Adds the corrective-work 'issue' task_kind (same-change CHECK expand per the
'new CHECK-enum values need a same-change migration' rule), a per-project
self-describing System entity, a many-to-many record<->system join (any
note/task/issue), and an issue->originating-task provenance FK.
"""
from alembic import op
import sqlalchemy as sa
revision = "0065"
down_revision = "0064"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. task_kind gains 'issue' (corrective work). DROP+ADD the CHECK in the
# same change that introduces the value.
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.create_check_constraint(
"notes_task_kind_check", "notes", "task_kind IN ('work','plan','issue')",
)
# 2. Provenance: an issue can point back at the task/feature it arose from.
# Distinct from parent_id (sub-task hierarchy).
op.add_column("notes", sa.Column("arose_from_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"fk_notes_arose_from_id", "notes", "notes",
["arose_from_id"], ["id"], ondelete="SET NULL",
)
op.create_index("ix_notes_arose_from_id", "notes", ["arose_from_id"])
# 3. systems: per-project, reusable, self-describing subsystem/area.
op.create_table(
"systems",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"project_id", sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("name", sa.Text(), nullable=False, server_default=""),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("color", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
op.create_index("ix_systems_project_id", "systems", ["project_id"])
op.create_check_constraint(
"systems_status_check", "systems", "status IN ('active','archived')",
)
# 4. record_systems: M2M join — any note/task/issue <-> system.
op.create_table(
"record_systems",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"note_id", sa.Integer(),
sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"system_id", sa.Integer(),
sa.ForeignKey("systems.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint("note_id", "system_id", name="uq_record_systems_note_system"),
)
op.create_index("ix_record_systems_note_id", "record_systems", ["note_id"])
op.create_index("ix_record_systems_system_id", "record_systems", ["system_id"])
def downgrade() -> None:
op.drop_table("record_systems")
op.drop_table("systems")
op.drop_index("ix_notes_arose_from_id", table_name="notes")
op.drop_constraint("fk_notes_arose_from_id", "notes", type_="foreignkey")
op.drop_column("notes", "arose_from_id")
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.create_check_constraint(
"notes_task_kind_check", "notes", "task_kind IN ('work','plan')",
)