Compare commits

..
12 Commits
Author SHA1 Message Date
bvandeusen 056c7c75da A task's kind is correctable — the Kind select stops lying (#3129)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 15s
2026-08-27 18:05:16 -04:00
bvandeusen 63036ed52e merge: bring main's rebased history into dev after PR #132
PR #132 was rebase-merged, so main carries rewritten copies of dev's
milestone-312 commits under new SHAs. The merge base stayed at 02c1e37, so
a dev->main merge tried to replay all eight already-landed commits and
collided in the two test files both sides had touched.

Resolved by taking dev's version of each: dev's copy is main's content plus
the #3129 additions, verified as a strict superset before resolving rather
than assumed. The merged tree is identical to dev's — asserted below, not
eyeballed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	tests/test_integration_task_kind_spike.py
#	tests/test_mcp_tool_tasks_kind.py
2026-08-27 18:00:32 -04:00
bvandeusenandClaude Opus 5 2e39dca9cf fix(tasks): import minted_kind by name — a stubbed service must not stub the guard (#3129)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / integration (push) Successful in 23s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 31s
CI 4682: test_create_task_issue_sets_kind_provenance_and_systems asserted
task_kind == "issue" and got a MagicMock. That test patches the whole
notes_svc module to keep the database out, so reaching validation through
`notes_svc.minted_kind(...)` handed back a mock — the guard approved
anything and returned nothing real.

The product was wrong, not the test. minted_kind is pure validation, not a
service call, so it is imported by name. A test that stubs the service to
avoid I/O now keeps the guard intact, which is the behaviour you want from
a guard: the only way to disable it should be to say so explicitly.

That test now exercises the real validation, so it doubles as the guard
against this recurring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:57:06 -04:00
bvandeusenandClaude Opus 5 69d93898d9 fix(tasks): a task's kind is correctable — the Kind select stops lying (#3129)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 48s
CI & Build / Build & push image (push) Skipped
`kind` was accepted at CREATE on both doors and dropped at UPDATE on both:
update_task had no such parameter, and the REST PATCH allow-list never read
the field. So a task filed under the wrong kind could never be corrected.

The frontend made it worse by looking like it worked. TaskEditorView binds a
Kind select, marks the form dirty, and HAS ALWAYS SENT `kind` in the update
payload — the store even types it. The route ignored it, returned 200, the
view optimistically updated, the toast said "Task saved", and the old value
came back on reload. Silent success, same class as #2709.

Found by trying to re-file #3126 as a spike after deploying 0091. It could
not be done; the task had to be recreated as #3128 and the original
cancelled.

One seam, not two doors. `minted_kind()` lives in services/notes.py because
the REST route cannot import an MCP tool module and a second spelling of the
list is how the doors would come to disagree. Both create and update route
through it, so a bogus kind is now a readable error rather than a
CheckViolationError surfacing as a 500.

TaskKind joins TaskStatus and TaskPriority as a real enum, and update_note
validates task_kind exactly as it already validated those two — the field
had been reaching setattr through the hasattr guard with no validation at
all, unnoticed only because no door ever offered it.

The `-> plan` question #3129 raised is answered in code rather than left
implicit: MINTABLE_KINDS is work/issue/spike, deliberately NARROWER than the
column's CHECK. `plan` stays a valid stored value because historical
plan-tasks carry it and must stay writable; it is simply not a value any
door hands out, and the refusal names start_planning because a caller
reaching for it wants a plan. The whitelist and the policy answer different
questions and are not the same list.

Every new test reads the value BACK. One that only asserted the call
succeeded would have passed against the broken code — the route returned 200
while dropping the field, which is how this survived long enough to be found
by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:55:04 -04:00
bvandeusenandClaude Opus 5 15659e2c57 fix(tests): a Note's is_task cannot be set — status is what makes one (#3099)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 17s
The spike CHECK tests constructed Note(is_task=True). `is_task` is a derived
read-only property — `status is not None` — so SQLAlchemy raised
"property 'is_task' of 'Note' object has no setter" before any row reached
the database. All three failed for that, not for anything about migration
0091; the other 80 integration tests passed, including 0090's.

status="todo" is what makes a note a task. Noted inline, since the field
appears in to_dict output and reads like an ordinary column from there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:05:07 -04:00
bvandeusenandClaude Opus 5 88e9c0b0bd feat(tasks): task_kind gains 'spike' — the investigation, not the change (#3099, milestone 312 step 5)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 32s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 31s
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>
2026-08-27 12:02:41 -04:00
bvandeusenandClaude Opus 5 c83bedf3be feat(rules): the check is editable, visible, and sweepable in the UI (#3098, milestone 312 step 4)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m13s
CI & Build / Build & push image (push) Successful in 37s
Rule 27 — the milestone was backend-only until this. Four surfaces:

RULE EDITOR — verify_with and expires_when under a legend that asks the
actual question ("Can this rule go stale?") and says empty is the normal
answer, because most rules are decisions and a form that implies a missing
field would get them filled in out of tidiness. When the SAVED rule carries
a check, the stamp shows with Still true / No longer true beside it. The
stamp reads the stored value, not the draft: an unsaved edit to the textarea
has not been run against anything.

SWEEP PANE — its own surface, not a filter on the rule list. That list can
only ever show one topic of one rulebook, and a rule that has gone false
belongs to no one rulebook; filtering it would under-report, which is the
failure this whole surface exists to catch. Reached from the rulebook list,
below the rulebooks, because that is where you go to look at rules.

RULE ROWS — a chip only on rules carrying a check, so its presence is the
signal. PROJECT RULES TAB — the check shows beside `why` when a rule has
one, read-only: that tab is the project's view of what binds it.

NO AGE-GRADED COLOUR anywhere, deliberately. The sweep is already ordered by
urgency, so a red/amber ramp would restate the ordering AND require an
invented "stale after N days" threshold — a magic number nobody could defend
and the first thing to go out of date. --fs-overdue is error red and reserved
for a broken promise like a missed due date; a verification age is not one,
and colouring it that way makes a rule someone just wrote look broken. Only
"never" is marked, because it is categorically different from a date rather
than a worse one — and it is marked by weight, not hue.

An empty sweep says "Nothing to check", not nothing: good news must not read
as a broken page.

Two chips (tier, then verification) turned out byte-identical, so .rule-chip
moves to rules-shared.css and snippet #2906 is updated to match rather than
left describing a file that has moved on. Its header comment counted the
panes it served; that count went stale the moment a fourth arrived, so it no
longer counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:53:35 -04:00
bvandeusenandClaude Opus 5 9d7485df2d docs(rules): a project rule is shaped differently, not just scoped differently (milestone 312)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 28s
The three surfaces already agree on WHERE a rule goes — the using-scribe
skill's "Where a new rule goes" section and both tool docstrings frame it
as one question, who should this bind. What they did not say is that the
two homes want differently SHAPED rules, and one deferral was actively
misleading.

`create_project_rule` said `tier: "always_on" or "conditional" — see
create_rule`. That imports a bar calibrated for a different blast radius.
On a rulebook rule always_on means every session in every project, so the
test is severe: the trigger must be nameless. A project rule is already
scoped by construction, so always_on costs only that project's sessions —
and being specific, which the family test treats as the signal for
conditional, is what project rules are FOR. The instance's own data says
so: rules 78, 115 and 119 are all project rules and all always_on.

Not zero bar, a different one: conditional is right when the rule is about
one AREA of a large project, because forty always-on rules on one project
reproduces locally the preload bloat milestone 307 fixed globally.

Also:
- create_rule now says to write the general form WITHOUT hedging for
  exceptions — a project needing to narrow it writes its own and links
  with overrides/elaborates. A rulebook rule padded with "unless…" for two
  projects is two project rules that were never written. Only the project
  side mentioned that relationship; the side that benefits from it did not.
- arose_from_id: reach for it harder on a project rule, which usually comes
  from one traceable incident in the repo, where a family rule is more
  often a standing preference with no single origin.
- system_ids is worth setting on a project rule too — it is what lets a
  conditional one arrive with its area.
- when_to_apply no longer claims to "decide" the tier here, which stopped
  being true one entry down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:29:10 -04:00
bvandeusenandClaude Opus 5 410d616c22 feat(rules): the staleness sweep — which standing rules assert a fact nobody has confirmed (#3097, milestone 312 step 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 35s
The query the last two steps were storage for. `rules_due_for_verification`
returns every rule carrying a `verify_with`, ordered by `verified_at` ASC
NULLS FIRST, each row carrying the check IN FULL — the opposite call from
rule_brief, because the reader is about to go and run it.

NULLS FIRST is the ordering this turns on. Postgres sorts NULLs last on an
ASC ordering, which would put the rules nobody has ever confirmed BEHIND
every rule someone once looked at. Exactly backwards: a claim with no
evidence at all outranks an old one.

Rules with no check never appear, and that is the property that keeps the
list worth reading. Most rules are decisions — no truth value, nothing to go
and check. If they appeared here the sweep would be the rulebook.

`mark_rule_verified(rule_id, still_true)` closes the loop, asymmetrically:
passing writes a stamp, FAILING WRITES NOTHING. There is no "verified false"
state because a rule whose check failed is not in a special condition, it is
wrong — and recording the failure as a flag would let it sit there being
false with the sweep satisfied that someone had looked. So it stays at the
top until someone corrects or retires it, and the response says so.

An unrecognised `tier` filter raises rather than falling back. _valid_tier's
silent always_on default is right for a WRITE — a typo should leave a rule
binding — and wrong for a FILTER, where the same fallback quietly answers a
different question and returns a short list that reads as good news.

Deliberately NOT filterable by project: a project reaches rules through
project scope, subscriptions, always-on rulebooks and exclusions, and a
filter missing one of those paths would UNDER-report — the exact failure
this surface exists to prevent. Said so in the docstring rather than
shipping a half-correct filter.

Ownership-scoped like every other rule read (owned rulebook, or owned
project), in ONE statement with an OR across the XOR rather than two queries
merged in Python, so the ordering is the database's and cannot disagree with
itself. Note that rules have no sharing ACL in this schema — no rule_shares,
no rulebook_shares — so there is no wider set for access.py to consult here.

Also fixes a test title that had been lying for ten tools: "all sixteen
tools" asserted 26. The number now lives only in the assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 10:49:47 -04:00
bvandeusenandClaude Opus 5 469b43f222 test(rules): the kwargs assertion learns about clear (#3096, milestone 312 step 2)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 37s
test_update_rule_only_sends_non_default_fields pins that the MCP door
forwards only what the caller actually gave. `clear` is now always
forwarded — an empty tuple is "clear nothing", a value rather than an
absent argument — so the expected kwargs gained it. The property under
test is unchanged: everything left at its default still stays out.

Two tests added beside it while the shape is in view: naming a field for
clearing reaches the service as `clear`, and the check fields are
forwarded when given.

CI 4630 otherwise green — the integration lane ran all six of the new
real-Postgres cases (72 selected, was 66) and applied 0089 -> 0090.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:31:30 -04:00
bvandeusenandClaude Opus 5 c61925be76 feat(rules): the write path carries a rule's check, and empty finally means empty (#3096, milestone 312 step 2)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 45s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 29s
verify_with / expires_when now reach a rule through both doors and come back
on every read. The open question this step existed to settle was how to
UNSET a nullable field, and the answer is one convention per door:

- MCP: "" still means "leave unchanged" — an agent filling three fields must
  not wipe the other five — so clearing is explicit, clear_fields=["..."].
  Naming the field is the one form that cannot happen by accident.
- REST: a cleared form input arrives as "", and the service normalises "" to
  NULL for every nullable rule column, so an emptied input does what it looks
  like it does.

Two idioms, one outcome, and the normalisation is what makes the step-3 sweep
correct: `verify_with IS NOT NULL` would otherwise be true for every rule ever
touched through the UI, and the sweep would list the whole rulebook and mean
nothing. to_dict renders "" and NULL identically, so this is only visible
against a real column — hence the integration module rather than a mock.

Editing verify_with drops verified_at. A stamp certifies A CHECK, not a rule;
reword the check and the old stamp vouches for something that no longer
exists. Safe direction, same asymmetry as _valid_tier: a rule wrongly listed
as due costs one look, a rule wrongly vouched for costs the thing the sweep
exists to catch. Editing anything else leaves the stamp alone, or a rulebook
tidy-up would reset every constraint and the ordering would carry nothing.

Reads: rule_brief attaches `last_verified` ONLY to a rule that carries a
check — its presence is the signal, and it says both "this asserts a fact
that can go false" and "here is how long ago anyone confirmed it". "never"
rather than null, per #2483. The check text itself stays in get_rule; a
listing needs to know which rules can rot, not how to test them. Search hits
carry the full trio, since a hit is exactly the moment someone is about to
act on a rule.

Also folds in the #3078 finding, which had been sitting as a note: create_rule
now teaches that when_to_apply is the retrieval surface and must carry the
SYMPTOM — the words you would type while stuck — not just the situation.

fake_rule gains the three fields as None for the reason the helper already
documents one line up: unnamed, verify_with is a truthy MagicMock and every
stand-in rule would claim a check it does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:28:03 -04:00
bvandeusenandClaude Opus 5 e08e999406 feat(rules): a rule can carry its own check — verify_with, expires_when, verified_at (#3095, milestone 312 step 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / integration (push) Successful in 28s
CI & Build / Build & push image (push) Successful in 25s
A rulebook holds two kinds of row in one table. A NORM is a decision: no
truth value, changes only when its author changes it, and they know they
did. A CONSTRAINT asserts a fact about someone else's software, and goes
false with nobody present. Milestone 307's audit found nine stale sites;
every one was a constraint, and not one norm had rotted.

Three nullable columns so a rule can say how to check itself. expires_when
is a STATE, 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
to come: unexamined outranks examined-long-ago. Most rules set none of the
three; a null verify_with is the marker for "this is a decision, there is
nothing to go and check," and it only reads that way while it stays honest.

Nothing is backfilled and nothing is indexed. A migration cannot invent a
check any more than 0088 could invent a trigger, and the sweep reads a whole
rulebook — hundreds of rows, on operator demand, never on a request path.

Also, in the backup service the fields had to pass through:

- Restore now remaps arose_from_id through note_id_map. It has been exported
  since 0088 and silently dropped on the way back in ever since, so every
  restore lost every rule's provenance link.
- _dt_or_none, because _dt substitutes now() for an absent value. That is
  right for created_at/updated_at and wrong here: a rule nobody ever checked
  would restore looking freshly checked and fall to the bottom of the sweep
  it should top.

Column additions do not move BACKUP_VERSION; only new sections do, as when
0088 added when_to_apply/tier/arose_from_id to the same helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:44:13 -04:00
7 changed files with 170 additions and 2 deletions
+3
View File
@@ -55,6 +55,9 @@ export const useTasksStore = defineStore("tasks", () => {
async function updateTask(
id: number,
// IssueFields carries `kind`, which the PATCH route now reads. It has
// always been SENT by the task editor; until #3129 the route dropped it
// and the save reported success while changing nothing.
data: Partial<
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
> & IssueFields
+15 -1
View File
@@ -23,6 +23,11 @@ from scribe.mcp.tools import systems as systems_tools
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import notes as notes_svc
# Imported by NAME, not reached through notes_svc: minted_kind is pure
# validation, not a service call, and a test that stubs the service module to
# avoid the database would otherwise stub the validation too — turning a
# guard into a MagicMock that approves anything.
from scribe.services.notes import minted_kind
from scribe.services import planning as planning_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
@@ -194,7 +199,7 @@ async def create_task(
milestone_id=milestone_id or None,
parent_id=parent_id or None,
tags=tags,
task_kind=kind,
task_kind=minted_kind(kind),
arose_from_id=arose_from_id or None,
)
if system_ids:
@@ -214,6 +219,7 @@ async def update_task(
milestone_id: int = 0,
system_ids: list[int] | None = None,
arose_from_id: int = 0,
kind: str = "",
) -> dict:
"""Update an existing Scribe task. Only explicitly provided fields are changed.
@@ -233,6 +239,12 @@ async def update_task(
(set-semantics). None = leave unchanged; [] = clear all.
arose_from_id: Provenance (issue → originating task). 0 = leave unchanged,
-1 = clear, positive = set.
kind: Re-file this task as 'work', 'issue' or 'spike'. Omit (empty) to
leave unchanged. Correcting a kind is ordinary — what a task turns
out to BE is often clear only once it is under way, and a piece of
work that becomes an investigation should say so. 'plan' is
refused: plans are milestones (start_planning), and the value
survives only so historical plan-tasks stay writable.
"""
uid = current_user_id()
fields: dict = {}
@@ -258,6 +270,8 @@ async def update_task(
fields["arose_from_id"] = None
elif arose_from_id:
fields["arose_from_id"] = arose_from_id
if kind:
fields["task_kind"] = minted_kind(kind)
note = await notes_svc.update_note(uid, task_id, **fields)
if note is None:
raise ValueError(f"task {task_id} not found")
+18
View File
@@ -23,6 +23,24 @@ class TaskPriority(str, enum.Enum):
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"
+11
View File
@@ -13,6 +13,7 @@ from scribe.services.notes import (
list_notes,
update_note,
)
from scribe.services.notes import minted_kind as notes_minted_kind
from scribe.services.note_usage import record_pulled
from scribe.services.planning import start_planning as svc_start_planning
from scribe.services.recurrence import calculate_next_due, validate_recurrence_rule
@@ -239,6 +240,16 @@ async def update_task_route(task_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
# Re-filing a task's kind. The editor has always SENT this field and the
# route silently dropped it — reporting "Task saved" and reverting on the
# next load (#3129). Validated at the same layer as status and priority so
# an unrecognised value is a 400 rather than a database CHECK violation.
if "kind" in data:
try:
fields["task_kind"] = notes_minted_kind(data["kind"])
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
for key in ("project_id", "milestone_id", "parent_id", "arose_from_id"):
if key in data:
fields[key] = data[key]
+46 -1
View File
@@ -5,7 +5,7 @@ from datetime import date, datetime, timezone
from sqlalchemy import func, or_, select, text
from scribe.models import async_session
from scribe.models.note import Note, TaskPriority, TaskStatus
from scribe.models.note import Note, TaskKind, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
@@ -366,6 +366,37 @@ async def get_or_create_note_by_title(user_id: int, title: str) -> Note:
return await create_note(user_id, title=title)
# Kinds a caller may MINT. Narrower than what the COLUMN holds: `plan` is a
# valid stored value — historical plan-tasks carry it and must stay writable —
# but plans became milestones in 0066, so no door hands out a new one. The
# CHECK whitelist and this policy answer different questions, which is why
# they are deliberately not the same list.
MINTABLE_KINDS = ("work", "issue", "spike")
def minted_kind(kind: str) -> str:
"""Validate a kind a caller is asking to WRITE, or raise saying why.
Lives here rather than in either door so both share one copy: the REST
route cannot import an MCP tool module, and a second spelling of this
list is how the two doors would come to disagree.
Raises rather than falling back to 'work'. A silently-corrected kind is
the defect this exists to end (#3129: the editor's Kind select reported
success and changed nothing), and a caller naming a kind we do not know
has a wrong idea that an error corrects and a default hides.
"""
if kind in MINTABLE_KINDS:
return kind
if kind == "plan":
raise ValueError(
"kind='plan' is retired — plans are milestones. Call "
"start_planning(project_id, title) to begin one. Existing "
"plan-tasks keep the value and stay editable."
)
raise ValueError(f"kind must be one of {MINTABLE_KINDS}, got {kind!r}")
async def update_note(user_id: int, note_id: int, **fields: object) -> Note | None:
async with async_session() as session:
result = await session.execute(
@@ -391,6 +422,20 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
value = TaskPriority(value).value
except ValueError:
raise ValueError(f"Invalid priority: {value!r}. Must be one of: {[p.value for p in TaskPriority]}")
elif key == "task_kind" and isinstance(value, str):
# Same shape as status/priority above, and for the same
# reason: a kind the column will refuse should fail here with
# a readable message, not as a CheckViolationError from the
# database. Before this, `task_kind` reached setattr through
# the hasattr guard with no validation at all — but no door
# ever offered it, so a task's kind was write-once (#3129).
try:
value = TaskKind(value).value
except ValueError:
raise ValueError(
f"Invalid kind: {value!r}. Must be one of: "
f"{[k.value for k in TaskKind]}"
)
elif key == "tags" and isinstance(value, list):
value = _normalize_tags(value)
setattr(note, key, value)
+27
View File
@@ -72,3 +72,30 @@ async def test_an_unknown_kind_is_still_refused(owner_id):
"""
with pytest.raises(IntegrityError):
await _write(owner_id, "investigation")
async def test_a_kind_can_be_corrected_after_the_fact(owner_id):
"""The write-once bug, asserted against a real column (#3129).
The read-back is the whole test. A version that only asserted the update
call succeeded would have passed against the broken code — the route
returned 200 while dropping the field, which is exactly how this survived
long enough to be found by hand.
"""
from scribe.services import notes as notes_svc
note_id = await _write(owner_id, "work")
await notes_svc.update_note(owner_id, note_id, task_kind="spike")
async with async_session() as s:
assert (await s.get(Note, note_id)).task_kind == "spike"
async def test_an_invalid_kind_is_refused_before_the_database(owner_id):
"""A readable ValueError, not a CheckViolationError surfacing as a 500."""
from scribe.services import notes as notes_svc
note_id = await _write(owner_id, "work")
with pytest.raises(ValueError, match="Invalid kind"):
await notes_svc.update_note(owner_id, note_id, task_kind="investigation")
async with async_session() as s:
assert (await s.get(Note, note_id)).task_kind == "work"
+50
View File
@@ -47,3 +47,53 @@ async def test_create_task_passes_spike():
from scribe.mcp.tools.tasks import create_task
await create_task(title="Can the runner be given a bash shell?", kind="spike")
assert mock.call_args.kwargs["task_kind"] == "spike"
@pytest.mark.asyncio
async def test_update_task_can_re_file_a_kind():
"""A task's kind must be CORRECTABLE, not write-once (#3129).
What a piece of work turns out to be is often clear only once it is under
way. Before this, `update_task` had no `kind` parameter at all and the
REST route dropped the field the editor was already sending — the save
reported success and reverted on reload.
"""
mock = AsyncMock(return_value=fake_note(task_kind="spike"))
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock):
from scribe.mcp.tools.tasks import update_task
await update_task(task_id=1, kind="spike")
assert mock.call_args.kwargs["task_kind"] == "spike"
@pytest.mark.asyncio
async def test_update_task_leaves_kind_alone_when_not_given():
""""" means leave unchanged, as it does for every other field here."""
mock = AsyncMock(return_value=fake_note())
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock):
from scribe.mcp.tools.tasks import update_task
await update_task(task_id=1, title="renamed")
assert "task_kind" not in mock.call_args.kwargs
@pytest.mark.asyncio
async def test_minting_a_plan_is_refused_at_both_doors():
"""`plan` is a valid stored value and NOT a mintable one.
Historical plan-tasks carry it and must stay writable, so the column
keeps accepting it — but plans became milestones in 0066, so no door
hands out a new one. The error names start_planning rather than just
refusing, because a caller reaching for kind='plan' wants a plan.
"""
from scribe.services.notes import minted_kind
with pytest.raises(ValueError, match="start_planning"):
minted_kind("plan")
@pytest.mark.asyncio
async def test_an_unknown_kind_raises_rather_than_defaulting():
"""A silently-corrected kind is the defect this whole fix exists to end."""
from scribe.services.notes import minted_kind
with pytest.raises(ValueError, match="kind must be one of"):
minted_kind("investigation")