state-hub/api/models/intake.py
tegwick 88ba666c95
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
CUST-WP-0061-T01: intake work-record entity (stage 3)
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>
2026-07-21 00:27:45 +02:00

126 lines
4.7 KiB
Python

import enum
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from api.models.base import Base, TimestampMixin, new_uuid7
class IntakeLane(str, enum.Enum):
green = "green"
blue = "blue"
yellow = "yellow"
orange = "orange"
red = "red"
class IntakeStatus(str, enum.Enum):
open = "open"
vetted = "vetted"
routed = "routed"
closed = "closed"
class IntakeOutcome(str, enum.Enum):
promoted = "promoted"
declined = "declined"
absorbed = "absorbed"
OPEN_INTAKE_STATUSES = (IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed)
class Intake(Base, TimestampMixin):
"""A `kind: intake` work record — a spark: idea, finding, directive, or
request, per canon/standards/work-record-types_v0.1.md. Lifecycle:
open -> vetted -> routed -> closed(promoted|declined|absorbed).
Fresh entity per the founder-reviewed architecture draft (2026-07-20,
WorkOrchestrationArchitectureDraft.md §8 item 6): not a rename/reuse of
the legacy `suggestions` table.
"""
__tablename__ = "intakes"
__table_args__ = (
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",
),
CheckConstraint(
"(status != 'closed') OR (outcome IS NOT NULL)",
name="ck_intakes_closed_requires_outcome",
),
CheckConstraint(
"(outcome != 'promoted') OR (promoted_to IS NOT NULL)",
name="ck_intakes_promoted_requires_promoted_to",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid7
)
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True, index=True
)
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, index=True
)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
lane: Mapped[IntakeLane] = mapped_column(
Enum(IntakeLane, name="intakelane"), nullable=False, default=IntakeLane.green
)
status: Mapped[IntakeStatus] = mapped_column(
Enum(IntakeStatus, name="intakestatus"),
nullable=False,
default=IntakeStatus.open,
index=True,
)
outcome: Mapped[IntakeOutcome | None] = mapped_column(
Enum(IntakeOutcome, name="intakeoutcome"), nullable=True
)
origin: Mapped[str | None] = mapped_column(String(200), nullable=True)
origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
promoted_to: Mapped[str | None] = mapped_column(String(200), nullable=True)
source_repo_path: Mapped[str | None] = mapped_column(
Text, nullable=True, doc="Repo-relative path of the source file this record was authored in."
)
routed_note: Mapped[str | None] = mapped_column(Text, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
notes: Mapped[list["IntakeNote"]] = relationship(
"IntakeNote",
back_populates="intake",
lazy="selectin",
order_by="IntakeNote.created_at",
cascade="all, delete-orphan",
)
class IntakeNote(Base):
__tablename__ = "intake_notes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid7)
intake_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("intakes.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
intake: Mapped["Intake"] = relationship("Intake", back_populates="notes")