import enum from datetime import date, datetime from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso class TaskStatus(str, enum.Enum): todo = "todo" in_progress = "in_progress" done = "done" cancelled = "cancelled" class TaskPriority(str, enum.Enum): none = "none" low = "low" medium = "medium" high = "high" class TaskKind(str, enum.Enum): """What KIND of work a task is. Mirrors CHECK notes_task_kind_check. Every value the COLUMN may hold, including `plan`. That is deliberate: plans became milestones in 0066, but historical plan-tasks still carry the value and must stay readable and writable. Refusing to MINT a new plan is a door policy (see the create/update task tools), not a statement about what the column accepts — conflating the two would make old rows unwritable, which is how a retired value turns into corrupt data. """ work = "work" issue = "issue" spike = "spike" plan = "plan" class Note(Base, TimestampMixin, SoftDeleteMixin): __tablename__ = "notes" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True ) title: Mapped[str] = mapped_column(Text, default="") body: Mapped[str] = mapped_column(Text, default="") description: Mapped[str | None] = mapped_column(Text, nullable=True) tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list) parent_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True ) # Provenance: the task/feature an issue arose from. Distinct from parent_id # (sub-task hierarchy) — this is "what spawned this". Only meaningful for # issues; nullable for every record. arose_from_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True ) project_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True ) milestone_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("milestones.id", ondelete="SET NULL"), nullable=True ) status: Mapped[str | None] = mapped_column(Text, nullable=True) priority: Mapped[str | None] = mapped_column(Text, nullable=True) due_date: Mapped[date | None] = mapped_column(Date, nullable=True) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) recurrence_rule: Mapped[dict | None] = mapped_column(JSONB, nullable=True) recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # Note type — 'note' (default) or 'process' (a stored process). Task-ness is # tracked by `status`, not here. (person/place/list entity types removed 2026-07.) note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note") # Task sub-kind — what KIND of work this is, not how it is going: # work (default) — ships a change # issue — corrective; something was broken (0065) # spike — time-boxed, and its output is KNOWLEDGE rather than a change; # it succeeds by producing an answer, and nothing ships (0091) # plan — retired since 0066 (plans are milestones), kept in the CHECK # so historical plan-tasks stay writable # Only meaningful when the note is a task (status is not None); ordinary # notes keep the 'work' default and ignore it. Orthogonal to note_type # (which is the note/entity axis). CHECK notes_task_kind_check (rule 36). task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work") # Queryable structured fields for typed records — currently snippets, whose # name/language/signature/locations live here so they can be INDEXED. The # body keeps the same facts in readable markdown and remains what gets # embedded; this is a mirror for querying, not the source of truth for # display — and it is DERIVED, so every path that writes a snippet's body # rewrites it too (services/snippets.recompose_data, called from # notes.update_note). 0070 left it NULL on existing rows and # snippets.backfill_snippet_data filled them at startup; readers still fall # back to parsing the body when it is absent (snippet_fields). data: Mapped[dict | None] = mapped_column(JSONB, nullable=True) # The three fields that tell a CONSTRAINT apart from a NORM (milestone # 317, migration 0092) — the same trio `rules` carries, and for the same # reason. A norm is a decision: no truth value, changes only when its # author changes it. A constraint asserts a fact about someone else's # software and goes false with nobody watching. Only constraints get a # check. # # `verify_with` is how to check it is still true; `expires_when` is the # STATE that ends it, deliberately not a date — constraints expire when # the ground moves, not on a schedule. `verified_at` NULL means never # checked and sorts FIRST in the sweep: unexamined outranks # examined-long-ago. # # These sit on `notes`, so every kind of row in this table has them, but # only non-task, non-snippet records are OFFERED them (gated in # services/notes.py). A task's decay is its status — a done issue records # what happened and cannot go false — and a snippet already carries a # richer, location-aware verdict in `data.verification`. On those rows # these stay null, which is also what they mean. # # Most notes should leave all three empty. A null `verify_with` is not a # gap; it is the marker for "this is a decision, there is nothing to go # and check", and the sweep is only worth reading while that holds. verify_with: Mapped[str | None] = mapped_column(Text, nullable=True) expires_when: Mapped[str | None] = mapped_column(Text, nullable=True) verified_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) __table_args__ = ( Index("ix_notes_tags", "tags", postgresql_using="gin"), Index("ix_notes_status", "status"), Index("ix_notes_title", "title"), Index("ix_notes_user_id", "user_id"), Index("ix_notes_project_id", "project_id"), Index("ix_notes_milestone_id", "milestone_id"), Index("ix_notes_note_type", "note_type"), Index("ix_notes_arose_from_id", "arose_from_id"), # Containment queries into `data` — e.g. which snippets name a given # repo/path in their locations. See migration 0070. Index("ix_notes_data_gin", "data", postgresql_using="gin"), ) @property def is_task(self) -> bool: return self.status is not None def to_dict(self) -> dict: return { "id": self.id, "title": self.title, "body": self.body, "description": self.description, "tags": self.tags or [], "parent_id": self.parent_id, "arose_from_id": self.arose_from_id, "project_id": self.project_id, "milestone_id": self.milestone_id, "status": self.status, "priority": self.priority, "due_date": iso(self.due_date), "started_at": iso(self.started_at), "completed_at": iso(self.completed_at), "recurrence_rule": self.recurrence_rule, "recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at), "is_task": self.is_task, "note_type": self.note_type or "note", "task_kind": self.task_kind, # Serialized unconditionally, like every other field a given row # kind may not use (recurrence, started_at, the task fields). The # DERIVED "last_verified" label is the one that appears only when # a check exists — a raw projection of the row should not make a # client branch on which keys are present. "verify_with": self.verify_with or "", "expires_when": self.expires_when or "", "verified_at": iso(self.verified_at), "created_at": iso(self.created_at), "updated_at": iso(self.updated_at), }