# Review

A link that rests on little evidence can be a wrong match. Tilores can put such
links into a review queue, where a person or an AI agent decides them. A
reviewer can also put an entity into the queue by hand.

## Enable the Review

The review needs three things.

1. In `deployment/tilores/main.tf`, enable the review resources in the
   `tilores` module and deploy:

   ```
   module "tilores" {
     # other parameters
     enable_review = true
   }
   ```

2. In the rule configuration, add a `review` block:

   ```json
   "review": {
     "maxWeakRules": 1,
     "strongRuleIDs": ["R2_EMAIL"],
     "joinStrategy": "defer"
   }
   ```

   | Option | Meaning |
   |---|---|
   | `maxWeakRules` | A link is weak if at most this many rules matched. The default `0` makes no link weak. |
   | `strongRuleIDs` | Rules that are enough on their own. A link that matched one of them is never weak. |
   | `joinStrategy` | `merge` (default): join the records first and review the join afterwards. `defer`: keep the records apart until the review decides. |

3. Give the client the privileges `tilores/query.review` (read the queue and
   the decisions) and `tilores/mutation.review` (create, claim, release and
   resolve cases). See [Authorization](authorization.md).

## Review Cases

A review case lists pairs of records, called links, and the rules that
matched them.

| Case | Raised by | Asks |
|---|---|---|
| Detected | a weak link with the `merge` strategy | The records are joined. Does the join stand? |
| Deferred | a weak link with the `defer` strategy | The records are apart. The candidate has `deferred: true`. Should they be joined? |
| One entity | `createReviewCase(input: {entityID: ...})` | Is this entity a wrong merge? The case has no links. |
| Two entities | `createReviewCase(input: {entityID: ..., otherEntityID: ...})` | Are these two entities, which no rule connects, the same? |

Asking twice for the same entities returns the case that is already waiting.

## Decide a Case

1. List the queue with `reviewCases`, oldest case first.
2. Claim a case with `claimReviewCase`. The claim locks every entity that the
   records of the case are in now, and returns a `lock`. If `stale` is `true`,
   the case asks nothing any more and nothing is locked.
3. Resolve the case with `resolveReviewCase`, or give it back with
   `releaseReviewCase`.

```graphql
mutation {
  claimReviewCase(input: {
    id: "4f1c..."
    actor: {kind: HUMAN, id: "jane@example.com", name: "Jane"}
  }) {
    lock
    stale
    case { candidates { links { a b rules { id } } } }
  }
}
```

```graphql
mutation {
  resolveReviewCase(input: {
    id: "4f1c..."
    lock: "review:..."
    actor: {kind: HUMAN, id: "jane@example.com", name: "Jane"}
    verdicts: [{a: "customer-2:0", b: "customer-1:0", keep: false}]
    reason: "different date of birth"
  }) {
    triggered
  }
}
```

Every link of the case needs a verdict. A verdict can also name any other two
records of the locked entities, for example to remove a wrong edge next to the
flagged one, or to create an edge that no rule found.

| Verdict | An edge connects the two records | Effect |
|---|---|---|
| `keep: false` | yes | The edge is removed. Records that are no longer connected split off. |
| `keep: false` | no | Recorded only. |
| `keep: true` | yes | Recorded only. |
| `keep: true` | no | An edge is added: with the rules of the link, or as a `MANUAL` edge for any other pair. Two entities are joined, unless the [consistency rules](rules/consistency.md) keep them apart. |

The change is applied asynchronously. `triggered` tells whether there was a
change to apply. A `MANUAL` edge stays when its records are updated, and is
removed when one of its records is deleted or a later review rejects the pair.

## Automatic Decisions

Tilores rechecks every case that an ingest raises. It resolves the case if
each link has an answer:

* The newest past verdict for the same pair of records answers the link,
  whatever rules matched. So a decision still holds when a decided record is
  submitted again.
* For a deferred link without a past verdict: if other links joined the two
  records in the meantime, the link is kept. If one of the records is gone, the
  link is rejected. These two verdicts come from the data, so they do not
  answer later cases.

Such a decision has the actor kind `PROCESS` and the name "Automatic recheck".
A case with a link that has no answer waits for a person, and so does every
case created by hand.

## History

`reviewDecisions` returns the decisions about a set of records, newest first.
Ask with the records of an entity, because entity IDs change when entities
merge or split.

```graphql
query {
  reviewDecisions(input: {recordIDs: ["customer-1", "customer-2"]}) {
    decisions { id decidedAt actor { kind name } reason verdicts { a b keep } }
  }
}
```

## Errors

| Error | When | What to do |
|---|---|---|
| `tilores.entity_busy` | Claim: another operation holds an entity of the case, for example an ingest or another reviewer. | Try again a moment later. |
| `tilores.review_claim_conflict` | Resolve: you do not hold the current claim. | Claim the case again. |
| `tilores.invalid_request` | The request is incomplete or names something that does not exist. | Fix the request. |
| `tilores.review_disabled` | Create a case while the rule configuration has no `review` block. | Enable the review. |
