Skip to content

tasks: store the PATCH route's frontmatter tags in the canonical form - #263

Merged
pufit merged 2 commits into
ClickHouse:mainfrom
oranjeai:oranjeai/patch-route-canonical-tags
Aug 4, 2026
Merged

tasks: store the PATCH route's frontmatter tags in the canonical form#263
pufit merged 2 commits into
ClickHouse:mainfrom
oranjeai:oranjeai/patch-route-canonical-tags

Conversation

@oranjeai

@oranjeai oranjeai commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Symptom

Editing a task in the web editor makes most of its tags unfindable. Save a task whose
markdown carries **Tags:** Alpha, beta, Gamma, and a tag-filtered listing finds it under
alpha but not beta or gamma, while count_tasks(tag='beta') is short by one. No
error, and the markdown on disk stays correct.

Root cause

PATCH /api/tasks/{id} with a content body re-syncs the frontmatter into the SQLite row,
and wrote the **Tags:** line verbatim:

tags=fields.get("tags") or task.get("tags", ""),   # 'Alpha, beta, Gamma'

The exact-tag predicate ',' || tags || ',' LIKE '%,<tag>,%' (nerve/db/tasks.py, shared
by list_tasks, count_tasks and the search_tasks tag filter) matches only when bare
commas delimit the tag, so the leading space on every key after the first defeats it.
LIKE is ASCII-case-insensitive, so the missing lower() is the lesser half. Every other
whole-value writer normalizes first; this one did not. UI path:
TaskDetailPage.saveTaskContent -> api.updateTask(id, {content}).

Fix

Call the existing normalizer here, as its two siblings (task_write_handler,
task_update_handler) do:

tags=tags_to_string(parse_tags_string(fields.get("tags") or task.get("tags", ""))),

One source file, +7/-2; the rest of the diff is tests. No schema, migration, settings,
API-shape or frontend change, and no data repair: a census of every live row here found
none stored non-canonically.

Tests

Four cases in tests/test_db.py::TestPatchRouteTagCanonicalization drive the real route:
the stored value is canonical; every key is independently findable via
list_tasks(tag=) and counted; an unsorted line with a duplicate is sorted and deduped;
a canonical line is unchanged. Per-key assertions are load-bearing: a
first-key-only variant passes unfixed, as does one routed through search_tasks's
exact-id strategy, which strips elements in Python.

Three fail on main; the already-canonical case passes there by construction, since the
unfixed route stores its input verbatim. All four pass here. Full suite 2937 passed,
with main's same 7 pre-existing failure names.

TaskManager.reindex erases the column outright (no tags=). Separate fix.

Generated by Nerve

Editing a task in the web editor makes most of its tags unfindable. Save a
task whose markdown carries `**Tags:** Alpha, beta, Gamma` and a tag-filtered
listing finds it under `alpha` but not `beta` or `gamma`, while
count_tasks(tag='beta') is short by one. No error is raised and the markdown
on disk stays correct, so nothing surfaces the loss.

PATCH /api/tasks/{id} with a `content` body re-syncs the markdown frontmatter
into the SQLite row, and passed the parsed `**Tags:**` value into upsert_task
verbatim, in display form. The exact-tag predicate is
`',' || tags || ',' LIKE '%,<tag>,%'` (nerve/db/tasks.py, shared by
list_tasks, count_tasks and the search_tasks tag filter), which matches only
when bare commas delimit the tag -- so the leading space on every key after
the first defeats it. SQLite LIKE is ASCII-case-insensitive, so the missing
lower() is the lesser half of the same skew: it bites only non-ASCII keys.
Every other whole-value writer normalizes first, so this call site was the
only one storing a non-canonical value.

Fix: call the project's existing normalizer at that call site, spelled as its
two sibling writers spell it (task_write_handler, task_update_handler):
tags_to_string(parse_tags_string(...)). Normalizing inside upsert_task instead
was rejected -- it would widen one route to every task write in the process,
and six of its seven call sites already pass a canonical value -- as was
relaxing the SQL to tolerate spaces, which would leave two representations of
one tag set in the column and have to be repeated at four predicate sites.

Four tests drive the real route. The stored value is canonical; every key is
independently findable via list_tasks(tag=) and counted by count_tasks(tag=);
an unsorted line with a duplicate key is sorted and deduped; an
already-canonical line is byte-unchanged. Per-key assertions are load-bearing
and this was measured, not assumed: a first-key-only variant passes against
the unfixed route, and search_tasks' exact-task-id strategy filters in Python
via _row_matches_filters, which strips each element and therefore tolerates
the display form too, so a test routed through either is vacuous. The
sort-and-dedup case is likewise not decoration: without it, a hand-rolled
`.replace(", ", ",").lower()` substitute passes every other assertion,
because the fixture `Alpha, beta, Gamma` is already ordered and
duplicate-free.

Three fail on main and all four pass here; the already-canonical case
passes on main too, by construction, since the unfixed route stores its
input verbatim. Full suite: 2937 passed, with main's identical 7
pre-existing failures by name (6 in tests/test_memu_bridge.py, 1 in
tests/test_telegram_sessions.py), none in a file this touches.

No schema, migration, settings, API-shape or frontend change, and no data
repair: a read-only census of every live task row on this instance found none
stored non-canonically, so the defect was latent rather than already fired.

TaskManager.reindex passes no tags= at all, so upsert_task's "" default
erases the column on every row it indexes. That is a different mechanism in a
different file and is fixed separately.
@CLAassistant

CLAassistant commented Aug 4, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@oranjeai

oranjeai commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review and reviewer adjudication (2 cold reviews + 1 independent gate run, 7 findings, all adjudicated)

Before this PR was opened it went through my own cold code review plus an independent
review by a second model (codex), and I adjudicated every finding. Verdicts and the
evidence behind each are below, including the two I overrode, so anyone can reverse me.

# Finding Severity Verdict
1 The commit message and PR body claimed all four new tests fail on main major AGREE, fixed
2 A validation note cited a function name that does not exist nit AGREE, fixed
3 The validation comment carried a trailer from another repo's convention nit AGREE, fixed
4 Rows written before this fix stay unfindable; asks for a repair migration major DISAGREE, evidence below
5 The new test docstrings are too verbose nit DISAGREE, evidence below
6 A validation note claimed the rejected alternative "defeats indexing" nit AGREE, fixed
7 That note's "4 predicate sites" is ambiguous (3 SQL plus 1 Python filter) nit AGREE, fixed

1 (mine, AGREE). Both surfaces said "all four fail on main". Three do.
test_already_canonical_value_is_unchanged PATCHes **Tags:** alpha,beta and asserts
alpha,beta; the unfixed route stores its input verbatim, so that value is already
correct and the case passes on main by construction. The base arm recorded
3 failed, 1 passed, and the validation comment's "both directions" row already
published that figure, so the two surfaces disagreed with each other in the direction
that overstates coverage. Both now state the measured result.

2 and 3 (mine, AGREE). The writer census was described as grepping
upsert_task/update_task_tags/update_task_fields; the third name exists nowhere in
the repository (git grep returns 0 at every revision). The count of 8 was right --
7 upsert_task call sites outside tests plus the one update_task_tags writer -- but a
reader could not reproduce it. Corrected. The comment also ended with a Session id:
line, which is another repository's convention and is absent from my last four PRs here;
removed.

4 (gate, DISAGREE). The ask is a one-time data migration, or deployment-wide census
evidence, for rows written before this fix. The gate is right that such rows are
reachable: task_create writes the display form to disk, so any task with two or more
tags that is then saved from the web editor lands a non-canonical row. I disagree that a
repair belongs in this change, on three measurements.

  • Nothing is lost. The markdown file is authoritative and the defect never touched
    it -- the frontmatter is byte-unchanged across the route write. The correct value is
    always recoverable from disk. Every value-repair migration this repository already
    ships (v024, v036, v040) repairs a value with no surviving source; that is not
    this case.
  • The repair path is the same gesture. After this fix the only writer that
    introduced the skew normalizes, so the action that damaged a row now repairs it.
    task_write and task_update with tags re-normalize too.
  • The prescribed migration is not constructible in this repository's style. None of
    the 42 migrations imports application code, and the canonical form requires splitting,
    sorting and deduplicating, which SQLite cannot express. An inline reimplementation is
    exactly the hand-rolled substitute this PR's own added test kills.

On the census itself: it is recorded -- 1325 live rows, 0 stored non-canonically, 0
non-idempotent, 0 stored tags containing a space, bracket or quote -- and the body scopes
it honestly to this instance rather than claiming anything deployment-wide. That
measurement is why this is filed as a latent defect.

5 (gate, DISAGREE). Both flagged docstrings record measurements rather than
narrative, and a future editor who trimmed them would undo the reasoning that makes the
tests non-vacuous: a first-key-only assertion passes against the unfixed route, the
search_tasks exact-id strategy filters in Python and so tolerates the display form, and
the sort/dedup case exists because a hand-rolled substitute survived all three of the
originally planned cases until it was added. Volume is in line with this suite: 22 of 79
added lines, no inline comments, and the class docstring is within the suite's own
longest three of 1163. No comment was added at the fix site.

6 and 7 (mine, second review round, AGREE). After the first round's fix landed I
re-read the change cold again, on the standing rule that a round triggered by one bad
figure leaves every other figure in that artifact unverified. Two claims in the validation
comment were wrong, and neither had been checked by any earlier round.

  • "defeats indexing" is false in both halves. No index on tasks.tags exists: v001
    creates idx_tasks_status and idx_tasks_deadline, v006 adds idx_tasks_source_url,
    and this instance's sqlite_master lists four indexes on tasks, none mentioning
    tags. Beyond that, EXPLAIN QUERY PLAN shows the current predicate scans the table
    even with a hypothetical index on tags, because it is a leading-wildcard LIKE over
    an expression; the relaxed form scans identically, while a control tags = ? does use
    the index. So nothing is defeated and the relaxation costs nothing on that axis.
  • "must be repeated at 4 predicate sites" is ambiguous. Measured: three SQL sites
    (list_tasks, count_tasks, _apply_tag_filter) plus one Python row filter
    (_row_matches_filters), which would need the inverse treatment rather than the same
    SQL edit. Read as "places the rule is expressed" four is right; read as "SQL sites the
    relaxation must edit", which is that sentence's own subject, it is three.

Both overstated the rejected alternative's cost, so they were not merge hazards, and the
rejection does not depend on either: the standing reasons are that the column's format
would become unenforced and the store would hold two representations of one tag set. Both
are corrected above. Neither appeared in this PR's description, so nothing else moved.

Gate spend for this PR: $5.75 across 2 runs (1 approach gate, 1 code gate).

@oranjeai

oranjeai commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes, 100%. pytest tests/test_db.py::TestPatchRouteTagCanonicalization against main's route: AssertionError: tag 'beta' not findable; stored='Alpha, beta, Gamma'. No randomization, no timing, 0.85s.
b Root cause explained? update_task passes the parsed **Tags:** value into upsert_task verbatim, so display form ('Alpha, beta, Gamma') lands in tasks.tags. The exact-tag predicate ',' || tags || ',' LIKE '%,<tag>,%' requires bare-comma delimiters, so the leading space on every key after the first prevents a match. Measured: LIKE is ASCII-case-insensitive, so the missing lower() is not a co-equal ASCII cause (alpha,BETA matches a beta filter); the space is, and case bites only non-ASCII (alpha,BETA with a non-ASCII key).
c Fix matches root cause? Yes. The call site skipped the project's own normalizer, so the fix calls it: tags_to_string(parse_tags_string(...)), the identical expression both sibling writers use. Not a band-aid: the predicate, schema and parser are untouched. Normalizing inside upsert_task was rejected (widens 1 route to every task write, and six of its seven call sites already pass a canonical value or an echo of one -- re-counted here: the plan's "8 call sites" folded in the separate update_task_tags writer), as was relaxing the SQL to tolerate spaces (leaves two representations of one tag set in the column, and must be repeated at the three SQL predicate sites plus the Python row filter).
d Test intent preserved / new tests added? 4 new cases, no existing test weakened or removed. Per-key assertions are load-bearing and this was measured, not assumed: a first-key-only variant PASSES against the unfixed route, and a variant routed through search_tasks's exact-id strategy also passes (_row_matches_filters splits and strips in Python, tolerating display form). Both vacuous shapes are avoided.
e Both directions demonstrated? Yes, same tests, same tree, only the route file swapped. Base (origin/main route): 3 failed, 1 passed, rc=1. Fixed: 4 passed, rc=0. Route fix-occurrence count asserted per arm (0 / 1).
f Fix is general across code paths? Writer census re-derived independently by grepping every upsert_task call site (7 outside tests) plus the one update_task_tags writer. This route was the only non-canonical introducer; handlers/tasks.py:502 and manager.py:117 echo the row (pass-through, cannot introduce skew). TaskManager.reindex passes no tags= at all, so upsert_task's "" default erases the column: a different mechanism in a different file, deliberately left for its own change. Mutants confirm the fix is not partial: reverting it, or replacing it with case-only, space-only, or a hand-rolled .replace(", ", ",").lower(), each fails at least one case.
g Fix generalizes across inputs? 13-shape matrix through the real parse chain, all canonical and idempotent: display form, already-canonical, unsorted+duplicate, JSON array, quoted CSV, malformed JSON fragment, [], empty element a,,b, case-duplicates, intra-tag space (with space survives as one tag, not split), non-ASCII, single key. Boundaries: empty, 1 char, 200 chars. Every stored key is bare-comma delimited, so each is reachable by the predicate.
h Backward compatible? Yes, nothing owed. No schema, migration, settings-default, API-shape or frontend change; the column's format is unchanged, this makes one writer conform to it. No stored data needs repair: a read-only census of every live task row on this instance found 0 non-canonical and 0 non-idempotent values, so the defect was latent.
i Invariants and contracts preserved? The invariant is that tasks.tags holds tags_to_string(parse_tags_string(x)); this fix is what makes it hold at this writer. upsert_task's contract is unchanged (it still stores its argument verbatim). Its derived FTS content (tags.replace(',', ' ')) now receives canonical text; FTS matching is unaffected, measured directly: the FTS5 tokenizer case-folds (Alpha in the content matches both alpha and Alpha), and term order and duplicate terms do not affect a match. Full suite: 2937 passed with main's identical 7 pre-existing failure names (set-diff empty both ways), so no contract elsewhere moved.

@pufit
pufit merged commit c4c2d49 into ClickHouse:main Aug 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants