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>
This commit is contained in:
parent
4541f1d6fc
commit
88ba666c95
9 changed files with 789 additions and 1 deletions
|
|
@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse
|
|||
from api.database import engine
|
||||
from api.events import shutdown_publisher
|
||||
from api.services.write_idempotency import WriteIdempotencyMiddleware
|
||||
from api.routers import decisions, extension_points, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import decisions, extension_points, intake, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
|
||||
from api.routers import token_events
|
||||
from api.routers import interface_changes
|
||||
|
|
@ -114,6 +114,7 @@ app.include_router(workstream_dependencies.router)
|
|||
app.include_router(workstream_dependencies.workplan_router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(decisions.router)
|
||||
app.include_router(intake.router)
|
||||
app.include_router(extension_points.router)
|
||||
app.include_router(technical_debt.router)
|
||||
app.include_router(progress.router)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ from api.models.workstream import Workstream
|
|||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.task import Task, TaskStatus, TaskPriority
|
||||
from api.models.decision import Decision, DecisionType, DecisionStatus
|
||||
from api.models.intake import (
|
||||
Intake,
|
||||
IntakeNote,
|
||||
IntakeLane,
|
||||
IntakeStatus,
|
||||
IntakeOutcome,
|
||||
)
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.extension_point import ExtensionPoint, EPStatus
|
||||
from api.models.technical_debt import TechnicalDebt, TDStatus
|
||||
|
|
@ -54,6 +61,7 @@ __all__ = [
|
|||
"WorkstreamDependency",
|
||||
"Task", "TaskStatus", "TaskPriority",
|
||||
"Decision", "DecisionType", "DecisionStatus",
|
||||
"Intake", "IntakeNote", "IntakeLane", "IntakeStatus", "IntakeOutcome",
|
||||
"ProgressEvent",
|
||||
"ExtensionPoint", "EPStatus",
|
||||
"TechnicalDebt", "TDStatus",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
|
@ -24,3 +26,28 @@ class TimestampMixin:
|
|||
|
||||
def new_uuid() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
def new_uuid7() -> uuid.UUID:
|
||||
"""Generate a UUIDv7 (RFC 9562): 48-bit big-endian ms timestamp, version
|
||||
and variant bits, remaining bits random. Time-sortable, so primary keys
|
||||
generated with this helper order chronologically without a separate
|
||||
created_at index lookup — the identity layering canon
|
||||
(work-record-types_v0.1.md) calls this out as the primary internal key
|
||||
for new work-record entities.
|
||||
|
||||
Dependency-free (no uuid7 in stdlib before Python 3.14, no third-party
|
||||
lib added for a ~15-line, non-cryptographic layout).
|
||||
"""
|
||||
unix_ts_ms = int(time.time() * 1000)
|
||||
rand = int.from_bytes(os.urandom(10), "big")
|
||||
rand_a = (rand >> 62) & 0x0FFF # top 12 bits of the 80 random bits
|
||||
rand_b = rand & 0x3FFFFFFFFFFFFFFF # bottom 62 bits
|
||||
value = (
|
||||
(unix_ts_ms << 80)
|
||||
| (0x7 << 76) # version 7
|
||||
| (rand_a << 64)
|
||||
| (0x2 << 62) # variant 10
|
||||
| rand_b
|
||||
)
|
||||
return uuid.UUID(int=value)
|
||||
|
|
|
|||
126
api/models/intake.py
Normal file
126
api/models/intake.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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")
|
||||
173
api/routers/intake.py
Normal file
173
api/routers/intake.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.intake import Intake, IntakeNote, IntakeOutcome, IntakeStatus
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.intake import (
|
||||
IntakeClose,
|
||||
IntakeCreate,
|
||||
IntakeNoteCreate,
|
||||
IntakeRead,
|
||||
IntakeRoute,
|
||||
IntakeUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/intakes", tags=["intakes"])
|
||||
|
||||
_ALLOWED_ROUTE_FROM = {IntakeStatus.open, IntakeStatus.vetted}
|
||||
_ALLOWED_CLOSE_FROM = {IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed}
|
||||
|
||||
|
||||
def _reject_status(intake: Intake, allowed: set[IntakeStatus], action: str) -> None:
|
||||
if intake.status not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
f"Cannot {action} intake in status '{intake.status.value}'; "
|
||||
f"allowed from: {sorted(s.value for s in allowed)}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[IntakeRead])
|
||||
async def list_intakes(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
repo_id: uuid.UUID | None = None,
|
||||
status_: IntakeStatus | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Intake]:
|
||||
q = select(Intake)
|
||||
if topic_id:
|
||||
q = q.where(Intake.topic_id == topic_id)
|
||||
if workplan_id:
|
||||
q = q.where(Intake.workplan_id == workplan_id)
|
||||
if repo_id:
|
||||
q = q.where(Intake.repo_id == repo_id)
|
||||
if status_:
|
||||
q = q.where(Intake.status == status_)
|
||||
q = q.order_by(Intake.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_intake(
|
||||
body: IntakeCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = Intake(**body.model_dump())
|
||||
session.add(intake)
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
||||
|
||||
@router.get("/{intake_id}", response_model=IntakeRead)
|
||||
async def get_intake(
|
||||
intake_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
return intake
|
||||
|
||||
|
||||
@router.patch("/{intake_id}", response_model=IntakeRead)
|
||||
async def update_intake(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(intake, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
||||
|
||||
@router.post("/{intake_id}/route", response_model=IntakeRead)
|
||||
async def route_intake(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeRoute,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
"""Move an intake into `routed` — eligible for the promotion transition."""
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
_reject_status(intake, _ALLOWED_ROUTE_FROM, "route")
|
||||
|
||||
intake.status = IntakeStatus.routed
|
||||
if body.routed_note:
|
||||
intake.routed_note = body.routed_note
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
||||
|
||||
@router.post("/{intake_id}/close", response_model=IntakeRead)
|
||||
async def close_intake(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeClose,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
"""Close an intake with an outcome. `outcome=promoted` requires
|
||||
`promoted_to` (the canonical id of the record it became) — this is
|
||||
normally called by the promotion transition (CUST-WP-0061-T03), not by
|
||||
hand, but a manual close (declined/absorbed, or a promotion recorded
|
||||
after the fact) is supported directly."""
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
_reject_status(intake, _ALLOWED_CLOSE_FROM, "close")
|
||||
|
||||
intake.status = IntakeStatus.closed
|
||||
intake.outcome = body.outcome
|
||||
intake.closed_at = datetime.now(tz=timezone.utc)
|
||||
if body.promoted_to:
|
||||
intake.promoted_to = body.promoted_to
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
|
||||
event = ProgressEvent(
|
||||
topic_id=intake.topic_id,
|
||||
workplan_id=intake.workplan_id,
|
||||
event_type="intake_closed",
|
||||
summary=f"Intake closed ({body.outcome.value}): {intake.title}",
|
||||
detail={
|
||||
"intake_id": str(intake.id),
|
||||
"outcome": body.outcome.value,
|
||||
"promoted_to": body.promoted_to,
|
||||
"note": body.note,
|
||||
},
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
|
||||
return intake
|
||||
|
||||
|
||||
@router.post("/{intake_id}/notes", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def add_intake_note(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeNoteCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
note = IntakeNote(intake_id=intake.id, author=body.author, content=body.content)
|
||||
session.add(note)
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
88
api/schemas/intake.py
Normal file
88
api/schemas/intake.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from api.models.intake import IntakeLane, IntakeOutcome, IntakeStatus
|
||||
|
||||
|
||||
class IntakeCreate(BaseModel):
|
||||
topic_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
lane: IntakeLane = IntakeLane.green
|
||||
origin: str | None = None
|
||||
origin_ref: str | None = None
|
||||
source_repo_path: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def scope_required(self) -> "IntakeCreate":
|
||||
if self.topic_id is None and self.workplan_id is None and self.repo_id is None:
|
||||
raise ValueError("At least one of topic_id, workplan_id, or repo_id must be set")
|
||||
return self
|
||||
|
||||
|
||||
class IntakeUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
lane: IntakeLane | None = None
|
||||
status: IntakeStatus | None = None
|
||||
origin: str | None = None
|
||||
origin_ref: str | None = None
|
||||
routed_note: str | None = None
|
||||
|
||||
|
||||
class IntakeRoute(BaseModel):
|
||||
"""Move an intake from open/vetted into routed — the state that makes
|
||||
it eligible for the promotion transition (CUST-WP-0061-T03)."""
|
||||
|
||||
routed_note: str | None = None
|
||||
|
||||
|
||||
class IntakeClose(BaseModel):
|
||||
outcome: IntakeOutcome
|
||||
promoted_to: str | None = None
|
||||
note: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def promoted_requires_target(self) -> "IntakeClose":
|
||||
if self.outcome == IntakeOutcome.promoted and not self.promoted_to:
|
||||
raise ValueError("outcome=promoted requires promoted_to")
|
||||
return self
|
||||
|
||||
|
||||
class IntakeNoteCreate(BaseModel):
|
||||
content: str
|
||||
author: str | None = None
|
||||
|
||||
|
||||
class IntakeNoteRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
author: str | None = None
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class IntakeRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
lane: IntakeLane
|
||||
status: IntakeStatus
|
||||
outcome: IntakeOutcome | None = None
|
||||
origin: str | None = None
|
||||
origin_ref: str | None = None
|
||||
promoted_to: str | None = None
|
||||
source_repo_path: str | None = None
|
||||
routed_note: str | None = None
|
||||
closed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
notes: list[IntakeNoteRead] = []
|
||||
Loading…
Add table
Add a link
Reference in a new issue