> ## Documentation Index
> Fetch the complete documentation index at: https://docs.get-exo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Surfacing contradictions in review

> Put the conflicts in someone's own belief history in front of a human, then close one by superseding the side that lost.

Most memory systems resolve conflicts silently: the newest write wins and nobody finds out that a decision reversed. Exo surfaces the conflict instead and leaves the judgement to you. This guide wires that into a review step, from the event that fires to the supersession that closes it.

## Before you start

<Columns cols={3}>
  <Card title="A key with write" icon="key" href="/scopes">
    Listing needs `read`. Closing one needs `graph:write`, which a `write` key grants.
  </Card>

  <Card title="Some history" icon="clock" href="/guides/ingesting">
    A contradiction needs two beliefs that disagree. One import is rarely enough.
  </Card>

  <Card title="A place to show them" icon="inbox" href="/guides/reacting-to-change">
    A review queue, a Slack channel, or a pull-request comment.
  </Card>
</Columns>

## The shortest call that works

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl "https://api.get-exo.com/v1/contradictions?pageSize=25" \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import httpx

  r = httpx.get(
      "https://api.get-exo.com/v1/contradictions",
      headers={"X-Exo-API-Key": EXO_KEY},
      params={"pageSize": 25},
  )
  page = r.json()
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const r = await fetch(
    "https://api.get-exo.com/v1/contradictions?pageSize=25",
    { headers: { "X-Exo-API-Key": process.env.EXO_KEY! } },
  );
  const page = await r.json();
  ```
</CodeGroup>

```json 200 OK theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "data": [
    {
      "id": "cc_51a0f8d3b62e947c",
      "a": "usage-based only",
      "b": "seat plus usage",
      "tension": 0.55,
      "status": "open",
      "detectedAt": "2026-07-14T09:12:03Z"
    },
    {
      "id": "cc_4d92e07a3b6f15c8",
      "a": "Mobile is the 2026 priority",
      "b": "Defer mobile until the API ships",
      "tension": 0.75,
      "status": "open",
      "detectedAt": "2026-07-11T16:40:55Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

## Reading a contradiction

`a` and `b` are the two poles. What they contain depends on the kind of conflict:

| Kind       | `a` and `b` hold                                                | Typical `tension` |
| ---------- | --------------------------------------------------------------- | ----------------- |
| Reversal   | The from and to concepts, such as `"Postgres"` and `"DynamoDB"` | 0.55              |
| Retraction | The before and after belief text                                | 0.75              |

`tension` is a 0 to 1 salience computed when you read, not stored. Retractions score higher than reversals because dropping a belief outright is a stronger signal than changing your mind about which option wins. Sort by it to put the sharpest conflicts at the top of a review queue.

<Warning>
  **Every contradiction comes back with `status: "open"`.** v1 stores no resolution state, so `?status=resolved` returns an empty page by design rather than an error. Do not build a UI that waits for a contradiction to flip to `resolved`: it never will. Track closure on your side, and close the underlying conflict by superseding a belief.
</Warning>

Note also that a `Contradiction` carries **no node ids**. `a` and `b` are text. Closing one therefore takes an extra step: find the node behind the losing side first.

## The review loop

<Steps>
  <Step title="Get told, do not poll" icon="radio">
    Subscribe to `contradiction.detected` rather than sweeping the list on a timer. Register a webhook so the review still happens when nobody has the dashboard open.

    ```bash theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    curl -X POST https://api.get-exo.com/v1/webhooks \
      -H "X-Exo-API-Key: $EXO_KEY" \
      -H "Content-Type: application/json" \
      -d '{
            "url": "https://hooks.example.com/exo/contradictions",
            "eventTypes": ["contradiction.detected"]
          }'
    ```

    `contradiction.detected` is a person-scoped event: it reaches only endpoints registered by the person whose beliefs conflict. See [Reacting to change](/guides/reacting-to-change).
  </Step>

  <Step title="Page the open list" icon="list">
    Deliveries are thin by design, so fetch current state rather than trusting the payload. Page with `nextCursor` until `hasMore` is false.

    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl "https://api.get-exo.com/v1/contradictions?pageSize=100&cursor=$CURSOR" \
        -H "X-Exo-API-Key: $EXO_KEY" \
        -H "X-Exo-Subject: customer_6412"
      ```

      ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      def open_contradictions(subject: str):
          cursor = None
          while True:
              page = httpx.get(
                  "https://api.get-exo.com/v1/contradictions",
                  headers={"X-Exo-API-Key": EXO_KEY, "X-Exo-Subject": subject},
                  params={"pageSize": 100, **({"cursor": cursor} if cursor else {})},
              ).json()
              yield from page["data"]
              if not page["hasMore"]:
                  return
              cursor = page["nextCursor"]
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      async function* openContradictions(subject: string) {
        let cursor: string | null = null;
        for (;;) {
          const qs = new URLSearchParams({ pageSize: "100" });
          if (cursor) qs.set("cursor", cursor);
          const page = await (
            await fetch(`https://api.get-exo.com/v1/contradictions?${qs}`, {
              headers: {
                "X-Exo-API-Key": process.env.EXO_KEY!,
                "X-Exo-Subject": subject,
              },
            })
          ).json();
          yield* page.data;
          if (!page.hasMore) return;
          cursor = page.nextCursor;
        }
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Show a human both sides" icon="git-compare">
    Render `a` and `b` side by side with `detectedAt` and `tension`. Do not pick a winner automatically. The whole value of surfacing a conflict is that a person decides, and the person usually knows something the graph does not.

    A useful review card reads: *"On 11 July you held `Mobile is the 2026 priority`. On 14 July you held `Defer mobile until the API ships`. Which one still stands?"*
  </Step>

  <Step title="Find the node behind the losing side" icon="search">
    `GET /v1/recall/graph/search` does a full-text match over stored chunk text and returns node ids, which is what the supersede call needs.

    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl -G https://api.get-exo.com/v1/recall/graph/search \
        -H "X-Exo-API-Key: $EXO_KEY" \
        --data-urlencode "q=Mobile is the 2026 priority" \
        --data-urlencode "limit=5"
      ```

      ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      hits = httpx.get(
          "https://api.get-exo.com/v1/recall/graph/search",
          headers={"X-Exo-API-Key": EXO_KEY},
          params={"q": contradiction["a"], "limit": 5},
      ).json()
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      const qs = new URLSearchParams({ q: contradiction.a, limit: "5" });
      const hits = await (
        await fetch(`https://api.get-exo.com/v1/recall/graph/search?${qs}`, {
          headers: { "X-Exo-API-Key": process.env.EXO_KEY! },
        })
      ).json();
      ```
    </CodeGroup>

    A query with no usable search terms returns an empty list rather than an error, so guard for the empty case before you index into it. `GET /v1/graph/nodes?q=` is the alternative when you want a title substring match with cursor pagination instead.
  </Step>

  <Step title="Supersede the side that lost" icon="check">
    `POST /v1/graph/nodes/{id}/supersede` records `old -> superseded_by -> new`. The old belief is not deleted: it stays in the chain, and authority moves to the successor.

    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl -X POST https://api.get-exo.com/v1/graph/nodes/ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:f38b0a4d7e19/supersede \
        -H "X-Exo-API-Key: $EXO_KEY" \
        -H "Content-Type: application/json" \
        -d '{
              "content": "Mobile is deferred until the API ships. Revisit in Q1.",
              "confidence": 1.0,
              "reason": "Reviewed 2026-07-30, confirmed by the product council"
            }'
      ```

      ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      r = httpx.post(
          f"https://api.get-exo.com/v1/graph/nodes/{loser_node_id}/supersede",
          headers={"X-Exo-API-Key": EXO_KEY},
          json={
              "content": "Mobile is deferred until the API ships. Revisit in Q1.",
              "confidence": 1.0,
              "reason": "Reviewed 2026-07-30, confirmed by the product council",
          },
      )
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      const r = await fetch(
        `https://api.get-exo.com/v1/graph/nodes/${loserNodeId}/supersede`,
        {
          method: "POST",
          headers: {
            "X-Exo-API-Key": process.env.EXO_KEY!,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            content: "Mobile is deferred until the API ships. Revisit in Q1.",
            confidence: 1.0,
            reason: "Reviewed 2026-07-30, confirmed by the product council",
          }),
        },
      );
      ```
    </CodeGroup>

    ```json 200 OK theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    {
      "oldNodeId": "ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:f38b0a4d7e19",
      "newNodeId": "ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:5a2e8c1f0d63",
      "edgeId": "api:ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:f38b0a4d7e19:ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:5a2e8c1f0d63:contradicts",
      "created": true,
      "newAuthority": 0.9
    }
    ```

    Send exactly one of `content` (create a successor from text) or `nodeId` (link an already-owned node as the successor). Sending both, or neither, is a validation error. Both nodes must belong to the effective subject; anything else is a 404.
  </Step>
</Steps>

## Choosing a confidence

`confidence` drives the authority transfer. The successor inherits the higher of the two authorities, and the superseded belief decays by `1 - confidence`.

| `confidence`    | Effect on the old belief             | Use when                                              |
| --------------- | ------------------------------------ | ----------------------------------------------------- |
| `1.0` (default) | Fully demoted                        | The reviewer is certain the old position is dead      |
| `0.7`           | Partly demoted, still carries weight | The new position is current but the old one had merit |
| `0.3`           | Barely demoted                       | You are recording a shift in emphasis, not a reversal |

Reach for a value below 1.0 when the old belief may still be right in some context. That is what keeps the chain honest rather than turning supersession into a soft delete.

## Contradictions inline with retrieval

You do not have to run a separate review to see conflicts. `POST /v1/retrieve` returns the ones bearing on the current query in the same response as the answer:

```bash theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
curl -X POST https://api.get-exo.com/v1/retrieve \
  -H "X-Exo-API-Key: $EXO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "What is our mobile plan?", "includeContradictions": true}'
```

This is the version worth wiring into an agent: before it acts on a belief, it can check whether that belief is contested. `includeContradictions` defaults to `true`, so set it to `false` only when you want the cheaper call.

## When it goes wrong

| What you see                                  | Why                                                                                      | What to do                                                                                                                   |
| --------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `?status=resolved` returns an empty page      | v1 stores no resolution lifecycle                                                        | Expected. Track closure on your side. See [Contradiction](/concepts/contradiction)                                           |
| The list is empty on a rich graph             | Contradictions need two beliefs that actually conflict, detected by the background cycle | Confirm content landed with `GET /v1/jobs`, then give the cycle time. See [How a memory is made](/concepts/memory-lifecycle) |
| `recall/graph/search` returns nothing for `a` | The pole text is a concept label, not a verbatim chunk                                   | Search a distinctive phrase from the belief instead, or list nodes by domain                                                 |
| `404 node_not_found` on supersede             | The node is not owned by the effective subject                                           | Check `X-Exo-Subject`. Cross-subject nodes are 404, never a leak. See [node\_not\_found](/errors/node_not_found)             |
| `422 validation_error` on supersede           | Both `content` and `nodeId` were sent, or neither                                        | Send exactly one. See [validation\_error](/errors/validation_error)                                                          |
| The same contradiction reappears              | You superseded a different node than the one behind the pole                             | Confirm with `GET /v1/graph/nodes/{id}/history` that the chain records the belief you meant                                  |

## Next

<Columns cols={2}>
  <Card title="Contradiction" icon="git-compare" href="/concepts/contradiction">
    The object itself, why `status` is always open, and how tension is derived.
  </Card>

  <Card title="Belief and supersession" icon="git-branch" href="/concepts/belief-and-supersession">
    Authority transfer, and reading a chain from `GET /v1/graph/nodes/{id}/history`.
  </Card>
</Columns>
