CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 33s
CI & Build / Python tests (push) Failing after 45s
CI & Build / Build & push image (push) Skipped
The sibling of migration 0090, one table over. Same distinction: a NORM is a decision with no truth value; a CONSTRAINT asserts a fact about someone else's software and goes false with nobody watching. Notes hold far more constraints than rules do and hold them longer — a cross-project reference asserting what a signing service does on a duplicate upload is believed by every project that reads it, and nothing in the record says when anyone last looked. note_supersessions only fires once a human has already believed it. Three nullable columns, no backfill, no index. The index margin is thinner than 0090's — thousands of note rows against hundreds of rules — so the comment says to decide it in step 3 against a real query plan rather than guessing here. The columns land on every row in `notes`, but only non-task, non-snippet records will be OFFERED them (gated at the service in step 2): a task's decay is its status, and a snippet already carries a richer location-aware verdict in data.verification. A schema-level gate would have meant a CHECK across three columns to say what the write path says in two lines. Backup carries the trio (v11), with `verified_at` restored through _dt_or_none — _dt substitutes now(), which would restore every never-checked note as checked at the moment of the restore, inverting the one signal the sweep reads. Found while doing that, NOT fixed here, and now pinned by a test: `_note_rows` carries 16 of the `notes` table's 26 columns. note_type, task_kind, arose_from_id, the recurrence pair, the lifecycle stamps, description and data have all been missing for a long time, so a restore flattens every snippet and process into a plain note and every issue and spike into `work`. The coverage guard cannot see it — it checks TABLES, not columns, which is #2293's failure mode one level down. #3182 tracks it; arose_from_id needs the second id-remapping pass parent_id gets, which is why it is not a drive-by fix.
182 lines
8.5 KiB
Python
182 lines
8.5 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 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),
|
|
}
|