feat(tasks): task_kind gains 'spike' — the investigation, not the change (#3099, milestone 312 step 5)
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>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""Real-Postgres test that the CHECK actually accepts 'spike' (0091).
|
||||
|
||||
Rule 36 exists because the value and the constraint can drift apart: the
|
||||
code starts writing a new kind while the database still refuses it, and
|
||||
nothing catches it until a write fails in front of someone. A mock cannot
|
||||
show that — it has no CHECK — so the constraint gets its own real-DB test,
|
||||
the same way migration 0090's nullability did.
|
||||
|
||||
The negative half matters as much as the positive one. A test that only
|
||||
proves 'spike' is accepted would also pass against a table with NO
|
||||
constraint at all, which is the other way this goes wrong.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def owner_id():
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "spike_owner")
|
||||
uid = owner.id
|
||||
await s.commit()
|
||||
return uid
|
||||
|
||||
|
||||
async def _write(uid: int, kind: str) -> int:
|
||||
async with async_session() as s:
|
||||
note = Note(
|
||||
user_id=uid, title=f"kind {kind}", body="", is_task=True,
|
||||
status="todo", task_kind=kind,
|
||||
)
|
||||
s.add(note)
|
||||
await s.commit()
|
||||
return note.id
|
||||
|
||||
|
||||
async def test_a_spike_can_be_written(owner_id):
|
||||
note_id = await _write(owner_id, "spike")
|
||||
async with async_session() as s:
|
||||
assert (await s.get(Note, note_id)).task_kind == "spike"
|
||||
|
||||
|
||||
async def test_the_older_kinds_still_write(owner_id):
|
||||
"""0091 widens the whitelist; it must not narrow it by accident.
|
||||
|
||||
'plan' is retired — plans are milestones since 0066 — but historical
|
||||
plan-tasks still carry it, and a row that cannot be rewritten is a row
|
||||
that cannot be edited, restored, or migrated.
|
||||
"""
|
||||
for kind in ("work", "issue", "plan"):
|
||||
note_id = await _write(owner_id, kind)
|
||||
async with async_session() as s:
|
||||
assert (await s.get(Note, note_id)).task_kind == kind
|
||||
|
||||
|
||||
async def test_an_unknown_kind_is_still_refused(owner_id):
|
||||
"""The half that proves a constraint is there at all.
|
||||
|
||||
Without this, every assertion above would pass just as happily against a
|
||||
table whose CHECK had been dropped and never re-added — which is exactly
|
||||
the failure rule 36 is written against.
|
||||
"""
|
||||
with pytest.raises(IntegrityError):
|
||||
await _write(owner_id, "investigation")
|
||||
Reference in New Issue
Block a user