{"record":{"id":"42d4cea418dfe72b","repo":"JuliusBrussee/caveman","slug":"prefix-replacement-incomplete-entry","errorCode":null,"errorMessage":"prefix replacement: incomplete entry","messagePattern":"prefix replacement: incomplete entry","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"proxy/internal/store/prefix_cache.go","lineNumber":45,"sourceCode":"\tif !ok {\n\t\treturn nil, \"\", false\n\t}\n\t// Touch for LRU eviction only. A failed touch changes nothing the caller can\n\t// observe this turn, so it is logged and swallowed rather than turned into a miss.\n\tif _, err := s.db.Exec(`UPDATE prefix_replacements SET last_used_at = ? WHERE original_sha256 = ?`, prefixCacheNow(), key); err != nil && s.logger != nil {\n\t\ts.logger.Warn(\"prefix replacement touch failed\", \"error\", err)\n\t}\n\treturn replacement, handle, true\n}\n\n// RememberReplacement durably records original→replacement and returns the\n// authoritative bytes for that original. Storage is first-write-wins: if another\n// in-flight request already stored a replacement for the same block, that one is\n// returned and the caller forwards it, so two requests can never put two different\n// prefixes on the wire for one logical message.\nfunc (s *Store) RememberReplacement(scope string, original, replacement []byte, handle string) ([]byte, error) {\n\tif scope == \"\" || len(original) == 0 || len(replacement) == 0 || handle == \"\" {\n\t\treturn nil, errors.New(\"prefix replacement: incomplete entry\")\n\t}\n\tkey := prefixCacheKey(scope, original)\n\tnow := prefixCacheNow()\n\tif _, err := s.db.Exec(\n\t\t`INSERT INTO prefix_replacements (original_sha256, handle, replacement, created_at, last_used_at)\n\t\t VALUES (?,?,?,?,?)\n\t\t ON CONFLICT(original_sha256) DO UPDATE SET last_used_at=excluded.last_used_at`,\n\t\tkey, handle, replacement, now, now,\n\t); err != nil {\n\t\treturn nil, fmt.Errorf(\"prefix replacement put: %w\", err)\n\t}\n\tstored, _, ok := s.readReplacement(key)\n\tif !ok {\n\t\treturn nil, errors.New(\"prefix replacement: entry unreadable after write\")\n\t}\n\ts.evictPrefixReplacements()\n\treturn stored, nil\n}","sourceCodeStart":27,"sourceCodeEnd":63,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/766dce6b1394ebb56a3090748d5a0240a5aefb36/proxy/internal/store/prefix_cache.go#L27-L63","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Guard the call: only invoke RememberReplacement when scope != \"\" && len(original) > 0 && len(replacement) > 0 && handle != \"\".","If the no-replacement path reaches this code, skip the cache write entirely — original bytes forwarded verbatim need no cached replacement.","Add a unit test for the pass-through/no-compression branch proving the store write is skipped."],"exampleFix":"// before\nstored, err := s.store.RememberReplacement(scope, original, replacement, handle) // called on every turn\n\n// after\nif scope == \"\" || len(original) == 0 || len(replacement) == 0 || handle == \"\" {\n\treturn original, nil // nothing durable to record; forward original bytes\n}\nstored, err := s.store.RememberReplacement(scope, original, replacement, handle)","handlingStrategy":"validation","validationCode":"// Before RememberReplacement: every argument is load-bearing.\nfunc shouldCacheReplacement(scope string, original, replacement []byte, handle string) bool {\n\treturn scope != \"\" && len(original) > 0 && len(replacement) > 0 && handle != \"\"\n}\n\nif shouldCacheReplacement(scope, original, replacement, handle) {\n\tstored, err = store.RememberReplacement(scope, original, replacement, handle)\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Derive the scope string before entering the transform path so it can never be empty by accident.","Only record a replacement on turns that actually produced one (non-empty handle from the CCR step).","Unit-test the pass-through branch to prove no cache write is attempted with empty fields."],"tags":["prefix-cache","cache","input-validation","programmer-error"],"backgroundTag":"empty-required-parameter","analyzedSha":"766dce6b1394ebb56a3090748d5a0240a5aefb36","analyzedAt":"2026-08-18T03:14:35.516Z","contentChangedAt":"2026-08-18T03:14:35.516Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}