JuliusBrussee/caveman · error

prefix replacement: incomplete entry

Error message

prefix replacement: incomplete entry

What it means

Store.RememberReplacement durably binds original request bytes to their replacement under a SHA-256 key derived from scope+original (first-write-wins). All four arguments are load-bearing: scope separates transform plans, replacement is what must go on the wire, and handle is the CCR recovery reference disclosed to clients. Any empty scope, empty original/replacement bytes, or empty handle is rejected as an incomplete entry rather than written as a useless cache row.

Source

Thrown at proxy/internal/store/prefix_cache.go:45

	if !ok {
		return nil, "", false
	}
	// Touch for LRU eviction only. A failed touch changes nothing the caller can
	// observe this turn, so it is logged and swallowed rather than turned into a miss.
	if _, err := s.db.Exec(`UPDATE prefix_replacements SET last_used_at = ? WHERE original_sha256 = ?`, prefixCacheNow(), key); err != nil && s.logger != nil {
		s.logger.Warn("prefix replacement touch failed", "error", err)
	}
	return replacement, handle, true
}

// RememberReplacement durably records original→replacement and returns the
// authoritative bytes for that original. Storage is first-write-wins: if another
// in-flight request already stored a replacement for the same block, that one is
// returned and the caller forwards it, so two requests can never put two different
// prefixes on the wire for one logical message.
func (s *Store) RememberReplacement(scope string, original, replacement []byte, handle string) ([]byte, error) {
	if scope == "" || len(original) == 0 || len(replacement) == 0 || handle == "" {
		return nil, errors.New("prefix replacement: incomplete entry")
	}
	key := prefixCacheKey(scope, original)
	now := prefixCacheNow()
	if _, err := s.db.Exec(
		`INSERT INTO prefix_replacements (original_sha256, handle, replacement, created_at, last_used_at)
		 VALUES (?,?,?,?,?)
		 ON CONFLICT(original_sha256) DO UPDATE SET last_used_at=excluded.last_used_at`,
		key, handle, replacement, now, now,
	); err != nil {
		return nil, fmt.Errorf("prefix replacement put: %w", err)
	}
	stored, _, ok := s.readReplacement(key)
	if !ok {
		return nil, errors.New("prefix replacement: entry unreadable after write")
	}
	s.evictPrefixReplacements()
	return stored, nil
}

View on GitHub (pinned to 766dce6b13)

Solutions

  1. Guard the call: only invoke RememberReplacement when scope != "" && len(original) > 0 && len(replacement) > 0 && handle != "".
  2. If the no-replacement path reaches this code, skip the cache write entirely — original bytes forwarded verbatim need no cached replacement.
  3. Add a unit test for the pass-through/no-compression branch proving the store write is skipped.

Example fix

// before
stored, err := s.store.RememberReplacement(scope, original, replacement, handle) // called on every turn

// after
if scope == "" || len(original) == 0 || len(replacement) == 0 || handle == "" {
	return original, nil // nothing durable to record; forward original bytes
}
stored, err := s.store.RememberReplacement(scope, original, replacement, handle)
Defensive patterns

Strategy: validation

Validate before calling

// Before RememberReplacement: every argument is load-bearing.
func shouldCacheReplacement(scope string, original, replacement []byte, handle string) bool {
	return scope != "" && len(original) > 0 && len(replacement) > 0 && handle != ""
}

if shouldCacheReplacement(scope, original, replacement, handle) {
	stored, err = store.RememberReplacement(scope, original, replacement, handle)
}

Prevention

When it happens

Trigger: Forwarding code calls RememberReplacement unconditionally: with handle "" on a turn where no CCR handle was produced, with empty replacement bytes when the transform emitted nothing, with empty original (nothing to replace), or with scope "" when the plan/transform scope was never derived before the call.

Common situations: Gateway paths that record 'whatever happened' after a pass-through turn (no compression means no handle and no replacement); refactors that move scope computation after the cache write; tests exercising the no-replacement branch for the first time.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@766dce6b13 (2026-08-18). Data as JSON: /api/errors/42d4cea418dfe72b. Report an issue: GitHub.