Repairing state
Read older values safely by normalizing them before schema validation.
Stored data can outlive the code that created it. A field may have been renamed, an allowed value may have changed, or a stored object may be incomplete. A block's repair function turns that input into a candidate value for the current schema to validate.
{
"answerStyle":
"verbose"
}repair(raw)
"verbose"
→ "detailed"safeParse(repaired)
valid → value
invalid → initial{ tone: "detailed" }Repair runs in application code during a read. It does not ask an LLM to fix data; the model's correction loop is covered in Failure and recovery.
Start with an older value
The profile block currently accepts:
tone: z.enum(["concise", "detailed"])Suppose an earlier version used answerStyle and stored:
{ "answerStyle": "brief" }Without repair, this object fails the current schema and the read returns
initial. A repair function lets you explicitly map the older format to
the current one.
Define a tolerant repair function
Add repair to profileBlock in src/shared/state/profile.block.ts:
repair: (raw: unknown) => {
if (typeof raw !== "object" || raw === null) return raw;
const tone = "tone" in raw ? raw.tone : undefined;
const legacy = "answerStyle" in raw ? raw.answerStyle : undefined;
if (tone === "concise" || tone === "detailed") {
return { tone };
}
if (legacy === "brief") return { tone: "concise" };
if (legacy === "verbose") return { tone: "detailed" };
// Leave unrecognized data for the schema to reject.
return raw;
},The function preserves recognized current values and maps known older values. It does not invent a preference when the input is unrecognized. The schema remains the final check on what the read returns.
| Stored input | Repair result | Value returned by the read |
|---|---|---|
{ "tone": "detailed" } | Current value preserved | { "tone": "detailed" } |
{ "answerStyle": "verbose" } | { "tone": "detailed" } | { "tone": "detailed" } |
{ "answerStyle": "brief" } | { "tone": "concise" } | { "tone": "concise" } |
{ "tone": "unknown" } | Unrecognized input left unchanged | The block's initial value |
null | null | The block's initial value |
Understand the read sequence
- If no stored record exists, Keel returns
initialimmediately. - If a record exists and
repairis defined, Keel calls it with the stored value—even if that value already matches the schema. - Keel validates the resulting value with the block's schema.
- Successful validation returns the parsed value. Failed validation returns
initial.
Without a repair function, step 2 is skipped. Define an initial value that
satisfies your schema: the fallback is returned directly, rather than sent
through another validation pass.
Repair a read, not the database
readBlock does not save its repaired result. The stored record, revision,
and last-write timestamp remain unchanged. Reading the same older record
again will run repair again.
A later accepted reducer update can save a current-format value. If you need to rewrite existing records independently of turns, implement a migration through your storage layer.
The block definition's version does not automatically select or run
migrations. The current repair callback receives the raw value, not a source
version. The stored record's version is a write revision incremented on
updates; it is distinct from the block definition's version.
Keep repair predictable
- Accept arbitrary input, including nulls, arrays, and missing fields.
- Preserve valid current values. Repair also runs on those values.
- Map only formats you recognize; let validation handle the rest.
- Return a new value instead of mutating the input. The in-memory store returns object references, so mutation can alter stored data outside the normal update path.
- Keep repair synchronous and deterministic. Use tools for external work.
Thrown errors do not use the fallback
The schema fallback handles validation failures, not exceptions. If
repair itself throws, readBlock does not catch it or return initial.
During context assembly the runtime contains that exception as a typed
render-failure defect: the turn settles failed and refunded, and no raw
exception escapes runTurn. Check unfamiliar shapes before accessing
their fields so a strange record degrades to initial instead of failing
the turn.
Verify repair directly
After adding the repair function, this local example checks both the value returned to the application and the raw value still held in storage:
import { MemoryStore, readBlock } from "@keel-dev/core";
import { profileBlock } from "@app/shared/state/profile.block.ts";
const store = new MemoryStore();
store.seedRaw("thread-1", "profile", { answerStyle: "verbose" });
console.log(await readBlock(profileBlock, store, "thread-1"));
// { tone: "detailed" }
console.log((await store.getBlock("thread-1", "profile"))?.value);
// { answerStyle: "verbose" }readBlock and every store method return promises, so both checks are
awaited. seedRaw is a development helper on MemoryStore for seeding
older or invalid records.
Also try null, an unknown tone, and an already valid profile to check the
fallback and preservation paths.
Inspect in Studio
Seed an older profile in your local app's store, then run a turn in that same thread with an agent that reads the block. In Studio, inspect STATE and CONTEXT for the rendered preference.
With the legacy verbose value above, the reader should produce
User prefers detailed answers. The repair path does not currently emit a
dedicated repair or reset event, so use the direct check above to distinguish
a repaired value from a fallback. The rendered text alone may look identical.
Next: Storage and caching.
