You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
save_semantic_cache silently overwrites another file's legitimate cache when a subagent mis-attributes source_file to a file outside the current chunk #1757
A note on how this report was written: I'm not a professional developer,
and English isn't my first language. I used my AI coding assistant (Claude,
via Claude Code) to investigate this and write up the report below, based on
something we actually hit while using the graphify skill in a real
project. I checked the existing issue tracker first to make sure this
wasn't already reported. Happy to answer follow-up questions, most likely
with the assistant's help.
Summary
On an incremental re-extraction (/graphify --update, or a full run where some
files are cache-hits and others aren't), a semantic-extraction subagent can
emit a node whose source_file points at a file that is not in that
subagent's dispatched file list — typically a file merely mentioned by path
in the prose of a file the subagent actually processed. save_semantic_cache
groups incoming nodes by source_file and writes each group as that file's
complete cache entry, so this one stray node silently overwrites the
target file's real, previously-cached extraction with a 1-node fragment.
This is a different trigger from #582 (AST/semantic cache path collision on
files processed by both extractors in the same run) — here the clobbered
file isn't being processed at all in the current dispatch batch. It's also
not caught by #880's validator, since the field isn't missing or empty; it's
a plausible-looking path string that just doesn't belong to the current chunk.
Mechanism
SKILL.md Step B1 splits uncached files into chunks of 20-25 files and
dispatches one subagent per chunk (Step B2). Each subagent's prompt
includes only its own FILE_LIST.
The subagent is free to emit any string as a node's source_file. If a
file in its FILE_LIST references another corpus file by path in its
text (a doc citing another doc, a citation-style cross-reference, etc.),
the subagent can attribute the resulting node to that other file's path
instead of the file it was actually extracting from.
Step B3 merges all chunks' nodes and calls save_semantic_cache(nodes, edges, hyperedges) (graphify/cache.py).
That function groups nodes by source_file and, for each group, writes
(os.replace) a complete cache entry for that path — no check that the
group represents all of that file's nodes, no check that the file was
even part of the current dispatch, no merge with whatever is already
cached there.
If the referenced file already has a full, correct cache entry from a
previous run (the normal case for --update), that entry is now replaced
by the single stray node. The next graphify update will read this
corrupted 1-node cache back as if it were the complete extraction for
that file.
Silent, deferred data loss on incremental workflows specifically — the
failure mode #582 documents ("adjacent issues already filed made this region
look addressed") applies here too. In our case, a single re-extraction run
(~26 changed files out of ~130) clobbered 17 unrelated, unchanged files'
cache entries this way. We caught it only because we happened to diff
per-file node counts against a pre-run snapshot; nothing in normal usage
would have surfaced it, and it would have persisted (and compounded on
subsequent --update runs) otherwise.
feat: emit '<external>' sentinel for cross-corpus source_file #880 validates that source_file is present and non-empty (or the <external> sentinel). A stray-but-well-formed path string like research/other_doc.md passes that check fine — the bug is that the
value, while syntactically valid, doesn't correspond to a file the
current subagent/chunk was actually given.
Prevent partial-chunk overwrite in --update: pre-build node-count assertion + build_merge() helper #479's node-count guard operates on the whole graph before a build()/build_merge() write. It doesn't help here because the
corruption happens one layer down, at the per-file cache-write step
during extraction, before build() is ever called — and because losing
17 files' worth of nodes out of a 1,300+ node graph doesn't necessarily
make the total shrink below the previous run's total.
Suggested fix
At minimum, save_semantic_cache (or the SKILL.md orchestration immediately
before calling it) should reject/quarantine any node whose source_file is
not a member of the file list that was actually dispatched for extraction in
the current run — e.g. pass the full set of chunked FILE_LISTs (or the
uncached-files list) into save_semantic_cache and skip writing a cache
entry for any source_file outside that set, logging a warning instead of
silently trusting it. Nodes referencing an out-of-scope file could still be
kept in the graph (they're often legitimate cross-references) — the fix is
specifically to stop them from being used as the cache-write key for that
file.
A stronger version: track, per source_file, whether the current run's node
group is a complete re-extraction (all of that file's content was chunked
this run) vs. an incidental single-node mention, and only allow a full
replace in the former case — otherwise merge into the existing entry rather
than replacing it.
Environment
graphifyy 0.8.36
Semantic extraction via SKILL.md Part B2 (Claude Code Agent-tool
subagents, subagent_type="general-purpose", model="sonnet") — no GEMINI_API_KEY/GOOGLE_API_KEY set, so no headless backend involved.
We keep a pre-save snapshot of the cache (.graphify_cached.json, already
produced by Step B0) and, after save_semantic_cache runs, compare the
number of files it reports saving against the number of files we actually
intended to (re-)extract. If more files were touched than expected, we
restore the extras from the B0 snapshot. This is a manual, per-run check —
not something the library enforces.
Summary
On an incremental re-extraction (
/graphify --update, or a full run where somefiles are cache-hits and others aren't), a semantic-extraction subagent can
emit a node whose
source_filepoints at a file that is not in thatsubagent's dispatched file list — typically a file merely mentioned by path
in the prose of a file the subagent actually processed.
save_semantic_cachegroups incoming nodes by
source_fileand writes each group as that file'scomplete cache entry, so this one stray node silently overwrites the
target file's real, previously-cached extraction with a 1-node fragment.
This is a different trigger from #582 (AST/semantic cache path collision on
files processed by both extractors in the same run) — here the clobbered
file isn't being processed at all in the current dispatch batch. It's also
not caught by #880's validator, since the field isn't missing or empty; it's
a plausible-looking path string that just doesn't belong to the current chunk.
Mechanism
SKILL.mdStep B1 splits uncached files into chunks of 20-25 files anddispatches one subagent per chunk (Step B2). Each subagent's prompt
includes only its own
FILE_LIST.source_file. If afile in its
FILE_LISTreferences another corpus file by path in itstext (a doc citing another doc, a citation-style cross-reference, etc.),
the subagent can attribute the resulting node to that other file's path
instead of the file it was actually extracting from.
save_semantic_cache(nodes, edges, hyperedges)(graphify/cache.py).That function groups nodes by
source_fileand, for each group, writes(
os.replace) a complete cache entry for that path — no check that thegroup represents all of that file's nodes, no check that the file was
even part of the current dispatch, no merge with whatever is already
cached there.
previous run (the normal case for
--update), that entry is now replacedby the single stray node. The next
graphify updatewill read thiscorrupted 1-node cache back as if it were the complete extraction for
that file.
whole-graph level (Prevent partial-chunk overwrite in --update: pre-build node-count assertion + build_merge() helper #479) if the corpus is large enough that losing one
file's nodes doesn't drop the total node count below the previous run's
total.
Impact
Silent, deferred data loss on incremental workflows specifically — the
failure mode #582 documents ("adjacent issues already filed made this region
look addressed") applies here too. In our case, a single re-extraction run
(~26 changed files out of ~130) clobbered 17 unrelated, unchanged files'
cache entries this way. We caught it only because we happened to diff
per-file node counts against a pre-run snapshot; nothing in normal usage
would have surfaced it, and it would have persisted (and compounded on
subsequent
--updateruns) otherwise.Why this isn't caught by existing fixes
graphify updateon mixed code+docs corpora #582 fixes AST-vs-semantic cache namespace collision for filesprocessed by both extractors in the same run. Our case has no AST
extractor involved at all — it's semantic-only, and the clobbered file
isn't in the current dispatch batch in any form.
source_fileis present and non-empty (or the<external>sentinel). A stray-but-well-formed path string likeresearch/other_doc.mdpasses that check fine — the bug is that thevalue, while syntactically valid, doesn't correspond to a file the
current subagent/chunk was actually given.
build()/build_merge()write. It doesn't help here because thecorruption happens one layer down, at the per-file cache-write step
during extraction, before
build()is ever called — and because losing17 files' worth of nodes out of a 1,300+ node graph doesn't necessarily
make the total shrink below the previous run's total.
Suggested fix
At minimum,
save_semantic_cache(or the SKILL.md orchestration immediatelybefore calling it) should reject/quarantine any node whose
source_fileisnot a member of the file list that was actually dispatched for extraction in
the current run — e.g. pass the full set of chunked
FILE_LISTs (or theuncached-files list) into
save_semantic_cacheand skip writing a cacheentry for any
source_fileoutside that set, logging a warning instead ofsilently trusting it. Nodes referencing an out-of-scope file could still be
kept in the graph (they're often legitimate cross-references) — the fix is
specifically to stop them from being used as the cache-write key for that
file.
A stronger version: track, per source_file, whether the current run's node
group is a complete re-extraction (all of that file's content was chunked
this run) vs. an incidental single-node mention, and only allow a full
replace in the former case — otherwise merge into the existing entry rather
than replacing it.
Environment
graphifyy0.8.36subagents,
subagent_type="general-purpose",model="sonnet") — noGEMINI_API_KEY/GOOGLE_API_KEYset, so no headless backend involved.unrelated cached files' entries corrupted.
Our workaround (not a library fix)
We keep a pre-save snapshot of the cache (
.graphify_cached.json, alreadyproduced by Step B0) and, after
save_semantic_cacheruns, compare thenumber of files it reports saving against the number of files we actually
intended to (re-)extract. If more files were touched than expected, we
restore the extras from the B0 snapshot. This is a manual, per-run check —
not something the library enforces.