Skip to content

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

Description

@castflow

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Nothing surfaces this — no warning, no error, no shrink-guard trip at the
    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 --update runs) otherwise.

Why this isn't caught by existing fixes

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.
  • Incremental run: ~130-file corpus (markdown), ~26 files uncached, 17
    unrelated cached files' entries corrupted.
  • Windows 11.

Our workaround (not a library fix)

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions