Skip to content

Commit 551d4b6

Browse files
fix: refuse documents that are not the source's own cut
refresh_source reads len(documents) as the first index the source no longer uses. That is only true when the documents are that source's entire cut, so another source's documents — or a filtered slice of this one's — would delete live chunks and then add documents belonging to something else. IDs are now checked against chunk_id(source, position) before anything is deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0edd868 commit 551d4b6

2 files changed

Lines changed: 44 additions & 0 deletions

File tree

packages/moss-chunking/src/ingest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ async def refresh_source(
7878
the add's `MutationResult`, which the caller can wait on in turn — or `None`
7979
when there was nothing to add.
8080
81+
`documents` must be `source`'s entire cut, in order — `chunk_document`'s
82+
output for that source and nothing else. A mismatched ID raises before
83+
anything is deleted, since the whole reconciliation is arithmetic on
84+
`len(documents)` and the wrong list would delete live chunks.
85+
8186
Passing no documents deletes every chunk for `source`, which is how a deleted
8287
file is removed from the index.
8388
@@ -86,6 +91,21 @@ async def refresh_source(
8691
"""
8792
docs = list(documents)
8893

94+
# Everything below reads `len(docs)` as "the first index this source no
95+
# longer uses", which is only true if these documents really are this
96+
# source's whole cut. Handed another source's documents, or a filtered slice
97+
# of this one's, that arithmetic would delete live chunks and then add
98+
# documents that do not belong to `source` — a destructive way to discover a
99+
# mistaken argument. `chunk_document` output passes this by construction.
100+
for position, doc in enumerate(docs):
101+
expected = chunk_id(source, position)
102+
if doc.id != expected:
103+
raise ValueError(
104+
f"documents[{position}] has id {doc.id!r}, expected {expected!r}: "
105+
f"refresh_source replaces everything under {source!r}, so it needs "
106+
"that source's chunks, all of them, in cut order"
107+
)
108+
89109
stale: list[str] = []
90110
start = len(docs)
91111
while start <= MAX_CHUNK_INDEX:

packages/moss-chunking/tests/test_ingest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,30 @@ async def test_a_tail_longer_than_one_probe_window_is_still_cleared():
155155
assert len(deleted(client)) == 599
156156

157157

158+
async def test_documents_from_another_source_are_refused_before_anything_is_deleted():
159+
"""The wrong argument would otherwise be discovered destructively."""
160+
client = FakeClient(existing=ids_for("notes.md", 5))
161+
docs = [DocumentInfo(id=doc_id, text="fresh") for doc_id in ids_for("other.md", 2)]
162+
163+
with pytest.raises(ValueError, match="expected 'notes.md#chunk-0000'"):
164+
await refresh_source(client, "idx", "notes.md", docs)
165+
166+
assert client.calls == []
167+
assert client.existing == set(ids_for("notes.md", 5))
168+
169+
170+
async def test_a_gapped_document_list_is_refused():
171+
"""A filtered list would make `len(docs)` mean the wrong index."""
172+
client = FakeClient(existing=ids_for("notes.md", 5))
173+
kept = [chunk_id("notes.md", 0), chunk_id("notes.md", 2)]
174+
docs = [DocumentInfo(id=doc_id, text="fresh") for doc_id in kept]
175+
176+
with pytest.raises(ValueError, match=r"documents\[1\]"):
177+
await refresh_source(client, "idx", "notes.md", docs)
178+
179+
assert client.calls == []
180+
181+
158182
def test_the_client_surface_this_module_calls_actually_exists():
159183
"""The fake client above cannot catch a method that the real one lacks.
160184

0 commit comments

Comments
 (0)