Ten more test files: issue, milestone, rule, decision and snippet numbers
come out of docstrings, comments and messages; the preference door states
what a preference is instead of quoting the framing; the JSON reader says
why jq is not assumed instead of what every hook used to do; the backup
round trip's column warning no longer claims the import side is unguarded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI caught the naming work reaching for Postgres from the unit lane, and the
connection error was the symptom of a real design fault rather than a test
gap: reading the title BEFORE the delete put a live query on the delete path,
so a lookup that failed would have stopped the delete happening.
That is a decoration breaking its payload — the same mistake just fixed in
rules_etag, made again two commits later. Every title lookup now fails open:
delete_task, delete_note, delete_milestone, delete_snippet and rule_history
lose the name, never the operation.
The five unit tests mock the lookup rather than reaching for a database, and
delete_note gains one asserting the delete still happens when the lookup
raises — the behaviour, not just the absence of a crash.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rules path's three lessons (#3096), inherited:
EMPTY MEANS NULL. The sweep's whole signal is `verify_with IS NULL` = "this is
a decision, there is nothing to go and check". A "" that is not NULL makes a
norm look like a constraint nobody has verified — and never-checked sorts
FIRST, so it would sit at the top of the sweep forever.
CLEARING IS EXPLICIT. At the MCP door "" means "leave this alone", so an agent
updating a body does not wipe a check it was never asked about — which leaves
no value meaning "remove it". `clear` names the field, and naming it cannot
happen by accident. The REST door, where a cleared form input arrives as "",
reaches the same place through normalisation: two idioms, one outcome.
THE STAMP CERTIFIES A CHECK, NOT A RECORD. Rewrite or clear `verify_with` and
`verified_at` is dropped, so the note re-enters the sweep. A note wrongly
listed as due costs one look; a note wrongly vouched for costs exactly what
the sweep exists to catch. `verified_at` is also no longer settable through an
ordinary edit — a stamp says somebody performed THIS check, and minting one
from a write that ran no check is the one thing that would make the sweep lie.
And one this path adds: not every record may carry a check. A task's decay is
its status — a done issue records what happened rather than asserting
something that can go false — and a snippet already has verify_snippet, which
compares its recorded location and code against the repo and expires its own
verdict. Both are refused with a message naming the alternative, never dropped
silently (minted_kind's reasoning, #3129), and the gate lives at the service
so the two doors cannot come to disagree.
Written as an INVARIANT over the resulting record, not a filter on which
fields were passed. That is what catches the sideways route — a checked note
being turned into a task, a write that names no check at all and would sail
past any per-field gate.
The MCP docstrings carry the norm-vs-constraint test, because at that door the
docstring IS the contract and a field described only as "how to verify this"
gets filled in on every note. Step 5 does this properly across the instruction
surfaces; this is the minimum that stops the field being misused on arrival.
tests/helpers gains `drive_update_note` — the patch stack for driving
update_note, written twice before this and now once. The note-shaped fakes
gain the trio explicitly, for fake_note's own stated reason: an unset
attribute is a truthy MagicMock, and a truthy verify_with reads as a check
that is there.
The shape ledger showed the same test scaffolding defined over and over:
_bind_user x12 (byte-identical), _dispose_engine x10 in three wordings,
_no_supersession x3, _make_mock_session x7 in three subsets, a get-or-create
User helper x2 (+3 inlined), and fifteen hand-rolled MagicMock note factories
each re-explaining the same "an auto-MagicMock attribute is truthy" hazard
(note 2109).
Now: conftest.py carries _bind_user / _dispose_engine / _no_supersession as
opt-in fixtures (pytestmark = usefixtures(...) per module, so unit tests pay
nothing), and tests/helpers.py carries make_mock_session(), ensure_user() and
fake_note(**attrs) — the hazard documented once, real values on every
attribute the product reads. Call sites were rewritten by AST so titles with
dashes and commas survived; the three SimpleNamespace _note stand-ins that
only feed a single function stay local.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI failed on 8d9e96c — eight tests in test_mcp_tool_notes.py, all
"Connect call failed (127.0.0.1, 5432)".
The proximate cause is that `_attach_supersession` runs on every note
read/write and those are unit tests of the tool layer with no database. But the
test failure exposed a worse decision underneath it.
I had written the two directions as two service calls, so every `get_note`
made TWO extra round trips plus TWO ACL checks — on the hottest path in the
product — to save a two-line partition in Python. That is the wrong trade
whether or not a test noticed.
`get_relations` replaces both: one OR query, one ACL check, partitioned by
which column holds the note's id. Its test asserts `execute.await_count == 1`,
so the collapse can't quietly come apart later.
The tests then get an autouse stub rather than the code getting a swallow. The
tool genuinely has a new dependency; hiding that behind a try/except to keep
unit tests green would be arranging for the code to lie about what it does.
This file already records the same hazard for note 2109, so the stub sits next
to that precedent.
Added the test that matters, which the first pass missed: a superseded record
still surfaces, so an agent WILL read stale material — and it must arrive with
a plain-language warning, not just a numeric field to notice. Also pinned that
both keys are ABSENT rather than present-and-empty when there are no relations.
Refs #278
The injected menu tells the agent to open any hit with get_note(id), and
that menu can list a collaborator's record reached through a shared
project. The fetch was still owner-only, so those lines answered
"not found" — for a record the same user opens fine in the browser.
The agent path was strictly narrower than the web path for the same id.
Same boundary miss as #2093: the list side was widened for sharing, the
fetch side wasn't. Both tools now resolve through get_note_for_user,
apply the trash filter themselves (permission resolution says nothing
about liveness), and attach describe_provenance so a shared record
arrives marked as someone else's rather than passing as the caller's.
routes/tasks.py's parent-title lookup had the same narrowness: a shared
subtask rendered as an orphan when its parent was equally shared.
Four fetch tests across three files were patching notes_svc.get_note and
had to be retargeted — note 2109's third sub-case, caught by grepping
tests/ for the old name before pushing rather than by CI (#2159).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
#755 Phase 5. create_note / create_task now BLOCK a near-duplicate instead of
silently inserting: they return {"duplicate": true, "existing_id", message}
pointing at the record to UPDATE. Fights store bloat and stale competing copies
that semantic search (RAG) would otherwise resurface for reconciliation. A
force=true override creates anyway for genuinely-distinct records.
- services/dedup.py: find_duplicate_note — two signals, scoped to owner + same
project + same kind: (1) normalized-title exact match (cheap, always); (2)
semantic cosine ≥ 0.90 but ONLY when body ≥ 200 chars (short/title-only
embeddings false-positive — the pre-pivot lesson). Project-less (orphan)
records compare only to other orphans on BOTH signals (orphan_only on the
semantic call) — they're not matched across every project.
- Gate wired into the MCP create_note/create_task tools (the LLM write path)
with force override; _INSTRUCTIONS documents the duplicate response + force.
- Opt-in by design: the service helper is only called from the interactive
create tools. Internal/programmatic creates (recurrence spawn, imports) go
straight through services.create_note and are NOT gated — a recurring task
spawning its next same-titled instance must not be blocked.
- Scope v1: MCP tools only. REST/web (human CRUD, needs a UI affordance) and
create_rule (not a RAG surface; _INSTRUCTIONS already steer it) are follow-ups.
- tests: dedup service (title/semantic/body-gate/type-filter) + tool gate
(blocks, force bypasses) for notes and tasks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renames src/fabledassistant -> src/scribe and all imports, plus the
default DB name and DB user/password (fabled -> scribe) in config +
compose. 952 refs / 154 files. Reverses the old 'internal name stays
fabledassistant' convention.
Code-only: live databases are still physically named 'fabledassistant'.
Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the
DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git
host (fabledsword), MCP (fabled-git) and the image name (fabledscribe)
are intentionally unchanged.
ruff check src/ clean locally; CI (typecheck + pytest) is the gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MCP clients see tools namespaced by the server's local name already
(mcp__<server>__<tool>), so the fable_ prefix on every tool name was
redundant and ate tokens in the model's tool list.
Tools renamed (34 total):
fable_search → search
fable_list_notes / get_note / create_note / update_note / delete_note → list_notes / ...
fable_list_tasks / get_task / create_task / update_task / add_task_log → list_tasks / ...
fable_list_projects / get_project / create_project / update_project → list_projects / ...
fable_list_milestones / create_milestone / update_milestone → list_milestones / ...
fable_list_events / create_event / get_event / update_event / delete_event → list_events / ...
fable_list_tags → list_tags
fable_get_recent → get_recent
fable_list_persons / create_person / update_person → list_persons / ...
fable_list_places / create_place / update_place → list_places / ...
fable_list_lists / create_list / update_list → list_lists / ...
Also rebranded in MCP scope:
FastMCP("fable", ...) → FastMCP("scribe", ...)
auth realm "fable-mcp" → "scribe-mcp"
ASGI scope key fable_user_id → scribe_user_id
ContextVar label fable_mcp_user_id → scribe_mcp_user_id
Tool docstrings "in Fable" / "Fable task" → "in Scribe" / "Scribe task"
Server _INSTRUCTIONS prose
Deliberately kept:
- The internal Python package name `fabledassistant` (per project naming
convention — internal stays).
- "Fabled Scribe" as the official product/brand name (page footer,
smtp_from_name default).
- References to the legacy `fable-mcp/` standalone package in docstrings
explaining what we ported from — accurate until that directory is
deleted in Phase 10.
Client impact: existing MCP registrations need
claude mcp remove <name> && claude mcp add ...
once with a freshly-copied snippet from Settings → MCP Access. Claude
Code then re-discovers tools on connect — old conversations that
referenced fable_* tool names will see "tool not found" on those calls
until updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five tools wrapping services/notes.py with is_task=False. Signatures
mirror the existing fable-mcp note tool contracts so Claude usage is
unchanged.
Key behavior the tests pin down:
- list_notes repackages (rows, total) tuple into {notes, total}
- tag=""/search_text="" are "no filter" sentinels
- update_note ONLY sends non-default fields to the service (the
main risk: a default empty string overwriting real data)
- tags=[] is an explicit clear; tags=None is "leave unchanged"
- project_id=0 on create => orphan; on update => leave unchanged
(preserved limitation from existing fable-mcp)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>