Skip to content

offload: extract_llm and extract_llm_sweep share the cg:res: namespace with no component tag, so a depth-gate bypass can rest on another component's decision #197

Description

@amiddavid

The problem

extract_llm and extract_llm_sweep share the cg:res: result namespace, and the key carries no component tag:

  • resultKey(session, id) = cg:res:<session>:<id> — session and content hash only (components/offload/state.go:46).
  • Both components derive id from extract.ContentKey(content) (extract_llm.go, extract_sweep.go).
  • The shipped housellm preset runs extract_llm immediately before extract_llm_sweep in one pipeline (config/config.go:436).

So a cg:res: record written by extract_llm for content M is indistinguishable, to the sweep, from one the sweep wrote itself — and vice versa.

Why that matters

Both components use a hit on that key to bypass the cache-tail depth gate. The reasoning is sound only for a record the component itself wrote: "this session already sent these bytes, so replaying them is byte-identical at any depth". On another component's record the premise does not hold — the sweep would splice its own shape descriptor at arbitrary depth on the strength of bytes it never sent, and the stored value is a different projection entirely.

Reachability: not established, in either direction

Worth being explicit, because the obvious mechanism turns out to be wrong. The suggested path was extract_llm's replay apply() failing for want of a reserve slot, leaving M verbatim for the sweep to find — but tryMark/markToken never consult the store, and the replay path's commitRefresh never refuses, so apply declines only on the marker-inclusive never-worse check.

Reaching the state needs M present verbatim, at cached depth, with a live cg:res: from extract_llm and no marker on it. That has not been constructed. It may be unreachable today.

What is certain is that the invariant the code relies on is not expressible by the key, so it holds by accident of the current pipeline shapes rather than by construction — and the next pipeline or ordering change re-opens the question without anything failing.

Mitigating factor

The replacement each component writes is a pure function of (content, config), so a replay emits deterministic bytes whichever component decided. The harm is therefore about which projection gets spliced and at what depth, not about non-determinism.

Options

  1. Namespace the record per componentcg:res:<component>:<session>:<id>. Makes the claim true by construction. Costs cross-component reuse of session-scoped results, which nothing currently relies on (the cross-session reuse is a separate namespace, cg:xres:, which is already keyed with the model and config).
  2. Store a component tag in cachedResult and check it on read. One field, backward-tolerant (a missing tag reads as empty), and preserves the option of deliberate sharing later.
  3. Establish unreachability and document it. Cheapest now, and the reviewer's argument against it is the strong one: it has to be re-established after every pipeline change, and nothing fails when it stops being true.

Option 2 looks best: one field, no behaviour change for records written after it, and the invariant becomes checkable.

How it was found

Reviewing #188, where a comment on the sweep's replay path asserted the stronger claim ("the hit says this session already sent these bytes"). The comment has been corrected to state what the hit actually proves; this issue is the underlying design point, which predates #188 — both components have always shared the namespace.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions