{"record":{"id":"562d0e6ac69cccc1","repo":"JuliusBrussee/caveman","slug":"assembly-slot-json-stringify-slotid-changed-af","errorCode":null,"errorMessage":"assembly slot ${JSON.stringify(slotId)} changed after being declared stable","messagePattern":"assembly slot (.+?) changed after being declared stable","errorType":"exception","errorClass":"AssemblyStabilityError","httpStatus":null,"severity":"error","filePath":"packages/sdk/typescript/src/index.ts","lineNumber":1831,"sourceCode":"    assemblyHashLedgers.set(cave, ledger);\n  }\n  const normalized = options.slots.map((slot) => {\n    if (!slot.id || ids.has(slot.id)) throw new Error(`assembly slot id must be non-empty and unique: ${JSON.stringify(slot.id)}`);\n    ids.add(slot.id);\n    if (![\"stable\", \"session\", \"volatile\"].includes(slot.stability)) {\n      throw new Error(`assembly slot ${JSON.stringify(slot.id)} has unknown stability ${JSON.stringify(slot.stability)}`);\n    }\n    let contentJSON: string;\n    try {\n      contentJSON = canonicalJSON(slot.content);\n    } catch {\n      throw new Error(`assembly slot ${JSON.stringify(slot.id)} content is not JSON-serializable`);\n    }\n    const contentHash = sha256Hex(contentJSON);\n    if (slot.stability !== \"volatile\") {\n      const key = `${options.sessionId}\\0${slot.id}`;\n      const previous = ledger.get(key);\n      if (previous !== undefined && previous !== contentHash) throw new AssemblyStabilityError(slot.id);\n      ledger.set(key, contentHash);\n    }\n    return { ...slot, content: JSON.parse(contentJSON) as unknown };\n  });\n\n  const ordered = ([\"stable\", \"session\", \"volatile\"] as const)\n    .flatMap((stability) => normalized.filter((slot) => slot.stability === stability));\n  const prefixSlots = ordered.filter((slot) => slot.stability !== \"volatile\");\n  const volatileSlots = ordered.filter((slot) => slot.stability === \"volatile\");\n\n  let request: Record<string, unknown>;\n  let prefixBytes: string;\n  let breakpoints: string[] = [];\n\n  if (provider === \"anthropic\") {\n    const toolsSlot = prefixSlots.find((slot) => slot.id === \"tools\" && Array.isArray(slot.content));\n    const systemSlots = prefixSlots.filter((slot) => slot !== toolsSlot);\n    const system = systemSlots.map((slot) => ({ type: \"text\", text: assemblyText(slot.content) }));","sourceCodeStart":1813,"sourceCodeEnd":1849,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/766dce6b1394ebb56a3090748d5a0240a5aefb36/packages/sdk/typescript/src/index.ts#L1813-L1849","documentation":"Thrown as AssemblyStabilityError when a slot declared stable or session produces a different canonical content hash than a previous assemble() call with the same sessionId on the same Cave instance (in-process WeakMap ledger keyed `sessionId\\0slotId`). The guard exists because stable prefixes are reused for provider prompt caching — mutating them silently would poison the cache.","triggerScenarios":"Editing the \"system\" or \"tools\" slot between turns while reusing sessionId; appending to a stable slot each turn (ever-growing system prompt); a tools list marked stable but rebuilt from mutable state that drifted; same Cave reused across conversations with colliding sessionIds.","commonSituations":"Prompt templates with timestamps or rolling context accidentally inside a stable slot; tool catalogs filtered per-user but declared stable; sessionId not actually unique per conversation so ledgers collide.","solutions":["Mark content that evolves per turn as \"volatile\" — only truly immutable content gets stable/session","When stable content legitimately changes, mint a new sessionId (new conversation/epoch) so the ledger resets","Audit stable slots for hidden nondeterminism (timestamps, random ids, iteration order of non-canonical structures)","Catch AssemblyStabilityError and re-assemble with a fresh sessionId as a runtime escape hatch (see tryCatchPattern)"],"exampleFix":"// before\nslots.push({ id: \"system\", stability: \"stable\", content: `Assistant. Today is ${new Date().toISOString()}.` }); // changes every call → throws on turn 2\n\n// after\nslots.push({ id: \"system\", stability: \"stable\", content: \"Assistant.\" });\nslots.push({ id: \"clock\", stability: \"volatile\", content: new Date().toISOString() });","handlingStrategy":"type-guard","validationCode":"function assertStableSlotImmutable(previousJSON: string | undefined, slot: AssemblySlot): void {\n  if (slot.stability === \"volatile\" || previousJSON === undefined) return;\n  if (JSON.stringify(slot.content) !== previousJSON) {\n    throw new Error(`slot ${slot.id} would violate its stability declaration`);\n  }\n}","typeGuard":"import { AssemblyStabilityError } from \"@caveman-ai/sdk\";\nfunction isAssemblyStabilityError(e: unknown): e is AssemblyStabilityError {\n  return e instanceof AssemblyStabilityError || (e instanceof Error && e.name === \"AssemblyStabilityError\");\n}","tryCatchPattern":"try {\n  assembly = cave.assemble({ provider, model, sessionId, slots });\n} catch (e) {\n  if (isAssemblyStabilityError(e)) {\n    // Legitimate content change → new conversation epoch resets the ledger\n    assembly = cave.assemble({ provider, model, sessionId: `${sessionId}#${++epoch}`, slots });\n  } else throw e;\n}","preventionTips":["Only mark content stable if it is byte-identical for the whole session — everything else is volatile","Audit stable slots for hidden nondeterminism (timestamps, random ids, locale formatting)","Hash stable slot content in dev builds and assert equality per turn before assemble() does"],"tags":["assembly","caching","validation"],"backgroundTag":"cache-key-mismatch","analyzedSha":"766dce6b1394ebb56a3090748d5a0240a5aefb36","analyzedAt":"2026-08-18T03:14:35.516Z","contentChangedAt":"2026-08-18T03:14:35.516Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}