A spike is a shape the other kinds cannot hold. `work` ships a change; `issue` fixes something broken. A spike is time-boxed and its output is KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the end of it. Filing one as `work` makes a finished investigation look like an abandoned change, which is why the distinction earns a value rather than a convention. It is also the record a failed check asks for. This milestone gave rules a verify_with; when one fails the rule is wrong, and the next move is often to go and find out what replaced it. notes.arose_from_id already exists (0065), so constraint -> spike provenance needed no schema at all — only a docstring saying it is there. Rule 36: the value and the widened CHECK land in the same migration, DROP then ADD, exactly as 0065 did for 'issue'. The two whitelists live in one tuple each so upgrade and downgrade cannot disagree about what the list was on either side. The downgrade demotes existing spikes to 'work' first — lossy, deliberately, because the alternative is a downgrade that fails on real data, and one that says what it did beats one that cannot run. 'plan' stays whitelisted though retired: historical plan-tasks carry it, and a row that cannot be rewritten cannot be edited, restored or migrated. The integration test asserts both halves. A test that only proved 'spike' is accepted would pass just as happily against a table whose CHECK had been dropped and never re-added — which is the other way rule 36's failure happens — so an unknown kind is asserted to still raise. Not in scope, deliberately: any special lifecycle, time-box enforcement, or gating relationship. It is a kind, not a workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
125 lines
5.6 KiB
Python
125 lines
5.6 KiB
Python
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 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. NULL on every row written before migration 0070, so readers fall
|
|
# back to parsing the body (see services/snippets.snippet_fields).
|
|
data: Mapped[dict | None] = mapped_column(JSONB, 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,
|
|
"created_at": iso(self.created_at),
|
|
"updated_at": iso(self.updated_at),
|
|
}
|