# How healing works

What Kaabist does when an element your program points at moves, how confident it has to be, and why a person approves every change to a program.

When a site is redesigned and a locator stops matching, Kaabist searches the failed page for the element your program meant, scores how sure it is, and files the change as a proposal. A confident match can carry the running job to the end, but only a person approving the proposal changes your program.

## When to use it

Healing is always on; there is nothing to enable. This page matters when a job parks with the reason `drift`, when a result carries a `healed_locator_used` or `fill_rate_drop` warning, or when the review queue has `relocate` proposals waiting for you. Read it before you approve one, so you know what the confidence number measures and what approving with and without `promote` does.

Healing does not cover a page that is wrong rather than moved: a login page instead of a dashboard is `unknown_page` or `session_expired`, and an overlay in the way is an [obstacle](https://docs.kaab.ist/concepts/obstacles).

## The idea

Sites get redesigned. A price that lived in `div.price` ends up in `span[data-col="amount"]` behind two new wrappers, a product card stops being an `<li>` and becomes an `<article>`, a build tool rehashes every class name in the stylesheet. None of that changes what the page *says*, and that is the basis of healing: **the values survive the redesign, so the values are how the element is found again.**

Healing never runs on a job that succeeded. It runs on the failure path, and on the statistics that notice a field has stopped filling before any job fails.

## The reference it heals against

Kaabist remembers what "correct" looked like. When a program version is created from the builder, or a field is confirmed in the picker, and on the **first successful run** of a program version, it stores for every field and every step target: a fingerprint of the element that matched (its tag, its identity-bearing attributes, its text, five ancestors, where it sat among its siblings), the raw string it printed and the value your parser made of it, and the page itself. That bundle is the ground truth every later search is measured against. A later run never overwrites it, because a reference that moves with the page is not a reference.

The same first successful run adds more stable candidate locators to any target whose stored list has fewer than three, so a redesign that breaks the first candidate often leaves another standing. See [Locators](https://docs.kaab.ist/concepts/locators).

## Where healing runs

There are two paths, and which one applies depends on what broke.

- **A step's target** (the element a `click`, `fill`, `hover` or similar acts on) that stops resolving is healed **inside the job**, against the page captured at the moment of failure, within half a second.
- **An extract field** that stops filling does not fail the job: it comes back `null` with a warning. Kaabist watches the fill rate of every field of a published version against its trailing seven-day baseline; when it drops (by 30 points by default), or when a version's [canary](https://docs.kaab.ist/guides/drift-and-canaries) fails, the version is flagged, every job of that version carries a `fill_rate_drop` warning, and the healer service looks for the relocation and files it as a proposal before you ask.

The healer service also takes over when an inline heal was not enough and your organisation has LLM credits enabled: it looks at the parked job again with a larger time budget and a model.

## How the search works

Four deterministic stages run over the page that failed:

1. **The locators you already have.** A target stores an ordered candidate list. A redesign that broke the first two may have left the third standing.
2. **Tree comparison.** Every element of a plausible tag is scored against the stored fingerprint: how alike the paths, attributes, text and neighbours are.
3. **Value-shape anchors.** For a typed field (money, a number, a date), Kaabist looks for text that parses to the same type *and agrees about magnitude*: the same currency, within a factor of ten, within a year. For a string, a Levenshtein ratio of 0.8 or better. A cell under a heading the old page also knew scores higher than one that stands alone.
4. **Lists.** A repeating structure is healed as a structure: the item pattern is found by what repeats around a candidate, and how consistently the list's own fields resolve across its items is part of the score.

An optional fifth stage asks a language model, only if your organisation has enabled LLM credits. It is never trusted: it may reorder candidates the deterministic stages already found, or suggest selector strings, and **every selector it writes is resolved against the page before it counts for anything.** A selector that matches nothing, or matches four hundred things, is discarded.

With Kaabist's model, each call is charged to your organisation as model tokens, at the [model's rates](https://docs.kaab.ist/reference/plans#what-a-credit-buys), and shows in the ledger against the job it healed. The model is skipped, and the heal stays deterministic, when your balance could not cover a call. Your own model, and jobs run with a test key, are not charged.

Without a model (the platform's model switched off with `llm_enabled`, and no model of your own connected), healing is not disabled: the four deterministic stages run as above, inline and for drift and canary failures, with the same confidence thresholds and the same proposals. Two things are skipped: the fifth stage, and the healer service's second look at a parked job. A redesign the deterministic stages cannot place with enough confidence parks the job and waits for you instead.

## Confidence

Every candidate gets a number on `[0, 1]`, a weighted mix of five terms:

| Weight | Term | What it measures |
|---|---|---|
| 35 % | structural similarity | how alike the new element's fingerprint is to the stored one (1 for a stored candidate that resolved) |
| 30 % | value-shape agreement | whether what the new element prints agrees with the stored sample: 1 when it parses to the field's type **and** its magnitude agrees (a number within 10×, a list within 50 % of its length, a string at a 0.8 ratio, money in the same currency, a date within a year); 0.5 when it only parses to the type; 0 when it does not parse |
| 15 % | uniqueness | 1 when the new locator matches one element, `1/n` when it matches `n` |
| 10 % | stability rank | how durable the new locator is: a test id ranks above a role, a role above a class, a class above a position |
| 10 % | list consistency | inside a list, the fraction of items in which the field still resolves; 1 outside a list |

Parsing in the value-shape term uses the field's own configuration, including its `locale`, so "does this read as money" means exactly what it means when the field is extracted. A part number such as `SKU: HP-1037` does not count as a price, even though it contains a number of the right size.

### What happens at each level

| Confidence | In the job | In the review queue |
|---|---|---|
| **0.90 or more** | the new locator is used **for the rest of this job**, and the result carries a `healed_locator_used` warning | a `relocate` proposal is filed |
| **0.50 to 0.90** | the job parks as `needs_attention` with the reason `drift`, the proposal attached to `attention.proposals` | a `relocate` proposal is filed |
| **below 0.50** | the job parks as `needs_attention: drift` with the evidence it has | nothing is filed: a guess that weak is not worth a reviewer's minute |

**No confidence promotes anything.** A relocation at 0.90 or above changes one run: your program is unchanged, and the next job starts from the same broken locator until somebody approves the proposal. A machine that could silently rewrite what your program means would be one you could not audit.

## Proposals

A proposal is a machine-generated change waiting for a person. Healing files `relocate` proposals; the same review queue holds every other kind.

| `kind` | What it proposes |
|---|---|
| `relocate` | new candidate locators for a field or a step target, from the healer |
| `dismiss` | a way to close an overlay on this site (see [Obstacles](https://docs.kaab.ist/concepts/obstacles)), from the obstacle layer or a takeover |
| `insert_steps` | steps recorded while a person [took over](https://docs.kaab.ist/panel/takeover) a parked job |
| `parser_config` | a change to how a field is parsed, such as its locale |
| `api_discovery` | a JSON endpoint the page called, which a field could read instead of the markup |

`source` says what produced it: `heuristic` (the deterministic stages), `llm`, `takeover` or `canary`. `status` is `pending` until somebody decides, then `approved` or `rejected`; a proposal nobody decided becomes `expired`.

### What a proposal shows you

Enough to answer one question without opening the site: *is this the same element?*

| `payload` key | |
|---|---|
| `old` / `new` | the candidate locators, before and after |
| `samples` | the raw strings each one read, and what the field's parser made of them |
| `fingerprints` | the stored element's description, the new one's, and how alike they are |
| `bboxes` | where each element sat, so a screenshot can be read against them |
| `screenshots` | the page when the ground truth was taken, and the page that failed |
| `evidence` | one entry per stage that contributed, with the terms of the confidence |

`evidence` keeps the runners-up as well, so "this element scored 0.91 and the next one 0.88" is visible. Which keys are filled depends on what produced the proposal; a client should tolerate any of them being absent.

### Deciding

`GET /v1/proposals?status=pending&program=shop-price&kind=relocate` lists what is waiting. Then:

```http
POST /v1/proposals/prp_01J…/approve
```

```json
{ "promote": true, "notes": "price moved into the new amount column" }
```

| Decision | Effect | Scope |
|---|---|---|
| approve, neither flag | the change applies to the parked job waiting on it | one job |
| approve with `promote: true` | a new **published** version of the program carries the change; the alias `latest` moves to it, `production` does not | the program |
| approve with `remember: "site"` | a `dismiss` becomes a recipe on your site profile, used by every program of your organisation on that hostname | the hostname |
| reject, with an optional `reason` | nothing changes; the decision is recorded | none |

`POST /v1/proposals/{id}/edit` with `{ "new": { … } }` replaces the proposed change before anybody approves it. It is accepted for `relocate` and `parser_config`, the two kinds whose suggestion is a value a reviewer can sensibly correct. The edit is applied to the program and linted before it is stored, so an edit that would break the program is refused at once rather than at approval.

A job parked `needs_attention` carries its proposals inline, and `POST /v1/jobs/{id}/resume` with `{ "apply": "prp_…", "promote": true }` decides and resumes in one call. Without `promote`, the change applies to that run and nothing else.

Every approval is recorded in the audit log with who decided: a person, or the API key their automation used.

### Who may approve

Deciding a proposal needs the `owner`, `admin`, `developer` or `reviewer` role in the panel, or an API key with the `run` scope. A `viewer` can read proposals but not decide them. A coding agent connected over MCP needs the `kaab:approve` scope. See [Accounts and roles](https://docs.kaab.ist/concepts/accounts-and-roles).

### Expiry

A proposal attached to a parked job expires with the park, seven days after the job parked. A proposal the healer service filed from statistics or a canary waits thirty days. When a person takes over a parked job and the recording shows what actually works, the machine's pending guess for the same target is retired as `expired`, not `rejected`, because nobody decided it was wrong.

## Worked example

A shop ships a redesign that moves every element and keeps every value: before it, a product page prints its price as `<div class="price">1.337,40 TL</div>`; after it, the same amount sits in `.price-wrap > .price-inner > span[data-col=amount]`, and the `price` class is gone.

1. A program written against the old design hovers the price before reading the page:

   ```json
   {
     "kaab": "1",
     "options": { "max_tier": "browser", "prefer_tier": "browser" },
     "steps": [
       { "id": "open", "goto": "https://shop.example.com/p/1" },
       { "id": "reach-price", "hover": ".price" },
       { "id": "read", "extract": { "title": "h1", "sku": { "selector": ".sku", "regex": "SKU:\\s*([A-Z0-9-]+)" } } }
     ]
   }
   ```

   Published as `shop-price`, its first job succeeds, and that run stores the ground truth for `.price`: the element, its text `1.337,40 TL`, and the page.

2. The shop ships the redesign. The next job's `reach-price` step finds nothing for `.price`. Healing searches the failed page: tree comparison finds the new `span`, and its text parses to the same amount in the same currency. Depending on the score, the job either finishes with a `healed_locator_used` warning on `steps[1]`, or parks with `status: "needs_attention"` and `reason: "drift"`. Either way a `relocate` proposal targeting `steps[1]` is in the queue, with `samples` showing `1.337,40 TL` on both sides.

3. A reviewer opens it under **Review** in the panel (described below) or lists it with the API, and approves it with `promote: true`. The program now has version 2, published, with the new candidates for that step, and `latest` points at it.

4. The next job runs version 2 and succeeds with no `healed_locator_used` warning: the program itself has changed, not one run.

### TypeScript

```ts
// What healing found and is waiting on a person: the old and new locator, and how sure it is.
let pending = 0;
for await (const proposal of kaab.proposals.list({ status: "pending", limit: 25 })) {
  pending++;
  console.log(
    `${proposal.id} ${proposal.kind} ${proposal.target} confidence ${proposal.confidence} (${proposal.source})`,
  );
  // Then: kaab.proposals.approve(proposal.id, { promote: true }) or .reject(id, { reason })
  if (pending >= 25) break;
}
console.log(`${pending} pending proposal(s)`);
```

### In the panel

**Review** lists proposals with filters for status, kind, source and program. Opening one shows its kind and target as the title and the program version it was proposed for. The page has sections for the page itself (the old and new screenshots side by side, with the element highlighted on each), the locators (old and new candidates with their stability ranks), the samples (the raw string and the parsed value before and after), the proposed change, and the evidence per stage. **Approve** opens a confirmation with a **Promote** switch ("publish a new version of the program with this change; the alias latest moves to it") and, for a dismissal, **Remember for this site**, plus optional notes. **Reject** asks for an optional reason. A decided proposal shows who decided, with which flags, and the version it was published as.

## Pitfalls

- **Approving without `promote` fixes one job.** The next job fails the same way. Promote when the change is right for the program, not only for the run in front of you.
- **`promote` moves `latest`, not `production`.** Jobs and schedules that run `my-program@production` keep the old version until you move the alias. That is deliberate: check the new version, then move `production`.
- **A program with no successful run cannot be healed.** There is no ground truth to compare against, so the job parks with a detail that says so. Run a new program once against a page that works.
- **A field with no `sample` heals with less evidence.** The value-shape term needs to know what the field used to print. The picker writes `sample` for you; add one by hand to a field you wrote yourself.
- **`evaluate` steps are opaque to healing.** Kaabist cannot see what a script did with the page, so it cannot heal around it.
- **`healed_locator_used` is not a success to ignore.** It means the run was carried by a guess. Review the proposal the same day.

## Reference

- [Approve a proposal](https://docs.kaab.ist/reference/api/proposals/approve-proposal), [reject](https://docs.kaab.ist/reference/api/proposals/reject-proposal), [edit](https://docs.kaab.ist/reference/api/proposals/edit-proposal), [list](https://docs.kaab.ist/reference/api/proposals/list-proposals)
- [Resume a job](https://docs.kaab.ist/reference/api/jobs/resume-job)
- [Proposal schema](https://docs.kaab.ist/reference/api/schemas/proposal)
- [Attention reasons](https://docs.kaab.ist/reference/codes#attention-reasons) and [warning codes](https://docs.kaab.ist/reference/codes#warnings)
- The `proposal.created` and `proposal.decided` [events](https://docs.kaab.ist/reference/events)
- [`kaab proposals approve`](https://docs.kaab.ist/reference/cli/kaab-proposals-approve)
- [Drift detection and canaries](https://docs.kaab.ist/guides/drift-and-canaries)
