CUST-WP-0061-T01: intake work-record entity (stage 3)
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 1m23s

Fresh hub entity per the founder-reviewed decision (not a suggestions
rename-bridge): kind: intake per canon/standards/work-record-types_v0.1.md,
lifecycle open -> vetted -> routed -> closed(promoted|declined|absorbed).

- api/models/base.py::new_uuid7 -- dependency-free RFC 9562 UUIDv7
  generator (48-bit ms timestamp, version/variant bits, random remainder);
  existing tables keep new_uuid (UUIDv4) unchanged, this is opt-in for new
  work-record entities per the identity-layering canon
- api/models/intake.py: Intake + IntakeNote ORM models, mirroring
  Decision's shape (topic/workplan/repo scope, lane, status, outcome,
  promoted_to back-link); CHECK constraints enforce scope-required,
  closed-requires-outcome, promoted-requires-promoted_to at the DB level
- migrations/a7c3e9f1b4d2: intakes + intake_notes tables, 3 enum types
- api/routers/intake.py: list/create/get/patch + /route + /close + /notes
  actions, mirroring decisions.py's pattern (409 on invalid transitions,
  progress event on close)
- api/schemas/intake.py: Pydantic create/update/route/close/note schemas
- mcp_server/server.py: create_intake, list_intakes, route_intake,
  close_intake tool wrappers
- tests/test_intake.py: 12 tests against the real Postgres test DB
  (create/list/scope-validation, full lifecycle incl. 409s and the
  promoted-requires-promoted_to constraint, notes, UUIDv7 verification)

Verified live against the running dev API + DB (not just pytest): applied
the migration, restarted the MCP server, and ran a full create -> route ->
close cycle over the real REST endpoints. No regressions: full existing
suite (test_routers_core, test_suggestions, test_mcp_smoke,
test_mcp_write_tools, test_mcp_registration, test_consistency_check,
test_consistency_sweep) all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-21 00:27:45 +02:00
parent 4541f1d6fc
commit 88ba666c95
9 changed files with 789 additions and 1 deletions

View file

@ -0,0 +1,100 @@
"""add intakes + intake_notes tables (CUST-WP-0061-T01, work-record stage 3)
Revision ID: a7c3e9f1b4d2
Revises: f0a1b2c3d4e5
Create Date: 2026-07-21
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import UUID
revision = "a7c3e9f1b4d2"
down_revision = "f0a1b2c3d4e5"
branch_labels = None
depends_on = None
intakelane = postgresql.ENUM(
"green", "blue", "yellow", "orange", "red",
name="intakelane",
create_type=False,
)
intakestatus = postgresql.ENUM(
"open", "vetted", "routed", "closed",
name="intakestatus",
create_type=False,
)
intakeoutcome = postgresql.ENUM(
"promoted", "declined", "absorbed",
name="intakeoutcome",
create_type=False,
)
def upgrade() -> None:
intakelane.create(op.get_bind(), checkfirst=True)
intakestatus.create(op.get_bind(), checkfirst=True)
intakeoutcome.create(op.get_bind(), checkfirst=True)
op.create_table(
"intakes",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("topic_id", UUID(as_uuid=True), sa.ForeignKey("topics.id", ondelete="SET NULL"), nullable=True),
sa.Column("workplan_id", UUID(as_uuid=True), sa.ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True),
sa.Column("repo_id", UUID(as_uuid=True), sa.ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("lane", intakelane, nullable=False, server_default="green"),
sa.Column("status", intakestatus, nullable=False, server_default="open"),
sa.Column("outcome", intakeoutcome, nullable=True),
sa.Column("origin", sa.String(length=200), nullable=True),
sa.Column("origin_ref", sa.String(length=200), nullable=True),
sa.Column("promoted_to", sa.String(length=200), nullable=True),
sa.Column("source_repo_path", sa.Text(), nullable=True),
sa.Column("routed_note", sa.Text(), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint(
"topic_id IS NOT NULL OR workplan_id IS NOT NULL OR repo_id IS NOT NULL",
name="ck_intakes_topic_or_workplan_or_repo",
),
sa.CheckConstraint(
"(status != 'closed') OR (outcome IS NOT NULL)",
name="ck_intakes_closed_requires_outcome",
),
sa.CheckConstraint(
"(outcome != 'promoted') OR (promoted_to IS NOT NULL)",
name="ck_intakes_promoted_requires_promoted_to",
),
)
op.create_index("ix_intakes_topic_id", "intakes", ["topic_id"])
op.create_index("ix_intakes_workplan_id", "intakes", ["workplan_id"])
op.create_index("ix_intakes_repo_id", "intakes", ["repo_id"])
op.create_index("ix_intakes_status", "intakes", ["status"])
op.create_index("ix_intakes_origin_ref", "intakes", ["origin_ref"])
op.create_table(
"intake_notes",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("intake_id", UUID(as_uuid=True), sa.ForeignKey("intakes.id", ondelete="CASCADE"), nullable=False),
sa.Column("author", sa.String(length=100), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_intake_notes_intake_id", "intake_notes", ["intake_id"])
def downgrade() -> None:
op.drop_index("ix_intake_notes_intake_id", table_name="intake_notes")
op.drop_table("intake_notes")
op.drop_index("ix_intakes_origin_ref", table_name="intakes")
op.drop_index("ix_intakes_status", table_name="intakes")
op.drop_index("ix_intakes_repo_id", table_name="intakes")
op.drop_index("ix_intakes_workplan_id", table_name="intakes")
op.drop_index("ix_intakes_topic_id", table_name="intakes")
op.drop_table("intakes")
intakeoutcome.drop(op.get_bind(), checkfirst=True)
intakestatus.drop(op.get_bind(), checkfirst=True)
intakelane.drop(op.get_bind(), checkfirst=True)