fix(core): key search_index uniqueness on the row kind - #1444
Merged
Conversation
A relation's permalink is `from/type/to` with the relation type authored by the
user, so a note that says
- [decision] redis
- observations [[decision/redis]]
gives its observation and its relation the identical address. Keyed on the
permalink alone, Postgres upserted one row into the other and then broke the
FTS-chunk foreign key still pointing at the kind it had overwritten; SQLite,
whose search_index is an FTS5 virtual table with no unique index, kept both
under one address and dropped one of them on the next single-row index pass.
No reserved path segment closes it, because the colliding segment is the
author's own text.
Widen uniqueness rather than change the address. Permalinks are the user-facing
identifier -- memory:// URLs, relation targets, search results -- so moving them
would break links people have already written, and any marker segment an address
scheme could reserve is one an authored relation type can spell. The row kind is
already half of search_index's primary key `(id, type, project_id)`; the unique
index on `(permalink, project_id)` was the outlier, narrower than the table's own
notion of identity.
SEARCH_INDEX_ROW_KEY in models/search.py is the single definition now, used by
the Postgres DDL, both Postgres upsert conflict targets, the accepted-note
upsert, the shared delete-before-insert that SQLite relies on, and
delete_by_permalink -- which takes the kind that owns the address, so deleting
one note's relation row no longer takes another note's observation row with it.
Migration w6k7i8n9d0a1 replaces uix_search_index_permalink_project with
uix_search_index_permalink_type_project. Widening cannot fail on existing data
and no permalink changes. SQLite needs no schema change. The downgrade sheds the
rows the narrow index cannot admit, keeping entity over observation over
relation; search rows are derived state the next index pass rebuilds.
Closes #1437
Refs #1438
Signed-off-by: phernandez <paul@basicmachines.co>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
phernandez
added a commit
that referenced
this pull request
Sep 3, 2026
Both PRs added a migration off v5o6b7s8d9e0 and merged separately, so main had two heads (w6k7i8n9d0a1 and x7d8e9f0a1b2). Alembic refuses `upgrade head` with two heads, so every fresh database on main failed to initialize. Each PR was green because the suite builds schemas with create_all and stamps them; nothing ran the real upgrade against the merged graph. The first thing that did was the #1398 confirmation eval, which failed at `bm mcp` startup. - y8f9a0b1c2d3 is a no-op merge revision with both heads as parents. A merge, not a re-parent, so databases already stamped at either head still receive the other branch's changes. - tests/test_migration_graph.py asserts one head and that every revision is on the path from base to it. Verified failing on a92b1ac (names both heads) and passing here. - `just doctor` on this branch initializes a fresh database through `upgrade head` and passes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1437. Part of #1438.
The defect
A relation's permalink is
from/type/towith the relation type authored by the user, so a note that saysgives its observation and its relation the identical address —
…/observations/decision/redis— becauseobservationsis both the segment every observation permalink opens with and a relation type anyone can type. No reserved segment closes this: the colliding segment is the author's own text.Reproduced on
origin/main(da1ac946) before changing anything:IntegrityError—insert or update on table "search_index_fts_chunks" violates foreign key constraint … Key (search_index_id, search_index_type, project_id)=(1, observation, 1) is not present in table "search_index". The upsert overwrote the observation row (itstypechanged), so the chunk insert pointed at a row that no longer existed.index_itemthe delete-before-insert keyed on permalink alone destroyed the observation row outright. Nothing surfaced either way.Address or uniqueness — uniqueness, and why
Changing the address was rejected. Permalinks are the user-facing identifier:
memory://URLs, relation targets written into markdown, search results, the API. Moving them breaks links people have already written. And it does not even work — the trap is documented onOBSERVATION_SEGMENTinmodels/knowledge.py: a marker that survives content is reachable by content, and one that escapes content is erased by the normalization everymemory://lookup performs.Uniqueness is the outlier, so uniqueness is what moved.
search_index's primary key is already(id, type, project_id)— the row kind is half of the table's own notion of identity. The unique index on(permalink, project_id)was narrower than that. Widening it to(permalink, type, project_id)makes uniqueness agree with the primary key, keeps every existing permalink byte-identical, and still constrains the within-kind duplicates that #909/#929/SPEC-82 were about.One rule, one place
SEARCH_INDEX_ROW_KEYinmodels/search.pyis the single definition, following thefile_path_prefix_condition/note_type_filtersprecedent. Five call sites derive from it:index_item,bulk_index_items),accepted_note_search_repository.py) — this one had to move, sinceON CONFLICT (permalink, project_id)errors once the narrow index is gone,search_repository_base.index_item, which is the only rule SQLite has (its FTS5 virtual table carries no unique index at all),delete_by_permalink, which now takes the kind that owns the address. Deleting one note's relation row used to take another note's observation row with it, and nothing rebuilds a projection whose source row still exists — the orphan sweep only removes.w6k7i8n9d0a1Replaces
uix_search_index_permalink_projectwithuix_search_index_permalink_type_projecton Postgres. Widening a unique index cannot fail on existing data (every row satisfying the narrow key satisfies the wide one) and no permalink changes. The wide index is created before the narrow one is dropped. SQLite is a genuine no-op — its FTS5 table has no unique index to change.The downgrade must shed rows the narrow index cannot admit — exactly the pairs the upgrade made legal. It keeps one row per
(project_id, permalink), preferring entity over observation over relation (ORDER BY type, id); search rows are derived state the next index pass rebuilds, so shedding them is recoverable where a failed downgrade is not.Migration verified for real, against live databases
The test suite uses
create_all+stamp, so it never runs migrations. This was run manually.Postgres (
pgvector/pgvector:pg16, seeded with a project, an entity,search_indexrows and their FTS-chunk children):Fresh-install path (
base→headon an empty database) produces exactly:…which is byte-identical to what
CREATE_POSTGRES_SEARCH_INDEX_PERMALINKinmodels/search.pycreates (compared programmatically viapg_indexes.indexdef,IDENTICAL: True), so the migrated schema and the schema the test suite builds cannot drift.SQLite (file database seeded with all three row kinds, including a colliding pair):
Chain:
alembic heads→w6k7i8n9d0a1 (head), single head.alembic branchesshows only the pre-existingf8a9b2c3d4e5branchpoint already merged by6830751f5fb6. No dangling revisions.One pre-existing failure, reported rather than papered over:
alembic downgrade baseon Postgres is already broken at the previous headv5o6b7s8d9e0(constraint "fk_entity_project_id" of relation "entity" does not exist, from the9d9c1cb7d8f5/a1b2c3d4e5f6batch-mode downgrades). Verified independently of this branch. Not touched here;downgradeto the previous revision — the case that matters for this change — works.Tests
New tests fail on
mainand pass here, on both backends, verified by stashing the source hunks:test_observation_and_relation_sharing_an_address_keep_separate_rowsassert [('relation', 22)] == [('observation', 11), ('relation', 22)]— the observation row was gonetest_reindexing_one_kind_replaces_only_its_own_rowassert [('observation', 33)] == [('observation', 33), ('relation', 22)]test_delete_by_permalink_leaves_the_other_kind_at_that_addressThe SQLite assertions check the stored rows directly — that both kinds are present with their own ids — rather than merely that no error was raised, since silence was the original symptom there.
test_reindexing_one_kind_replaces_only_its_own_rowguards the other direction: widening the key must not become no key, so a second write of the same kind still replaces rather than duplicates.tests/services/test_search_service.py::test_note_whose_relation_spells_an_observation_address_indexes_bothis the issue's reproduction end-to-end throughindex_entity_markdown(thebulk_index_itemspath), asserting the two permalinks genuinely collide before asserting both rows survive.tests/test_search_index_row_kind_migration.pycovers the migration itself (100% line coverage on the revision).Verification run
uv run ruff check src tests test-int— All checks passeduv run ruff format --check .— 1103 files already formatteduv run ty check src tests test-int— All checks passedtests/repository tests/services tests/indexing tests/index tests/db— 2321 passed, 41 skippedtests/— 4575 passedBASIC_MEMORY_TEST_POSTGRES=1) same five directories — 2302 passed, 59 skipped, 1 failed:test_tokenizing_scales_linearly_with_query_length, a CPU-ratio timing assertion overrelaxation_word_tokens(a pure string function, no database, untouched here) that went red under three concurrent suites; passes in isolation🤖 Generated with Claude Code
https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp