# Relationships

> How Noryo links two transactions of the same workspace through a refund_of, reversal_of or transfer_pair relationship, and how to read its role, its corroborations and its abstentions.

## What a relationship is

A relationship (`Relationship`) is an economic link between two transactions of the same workspace. It is rendered in the `relationships` array of the creation (`Enrichment`, see [Enrich a transaction](https://developer.noryo.app/en-GB/transaction-intelligence/enrich-a-transaction)) and of the read-back (`StoredEnrichment`, see [Read an enrichment](https://developer.noryo.app/en-GB/transaction-intelligence/read-an-enrichment)).

A relationship is private to the workspace: it never leaves it and never feeds the entity graph, because a link between two private transactions is not a global truth. What does live in the graph: [Counterparties and entities](https://developer.noryo.app/en-GB/transaction-intelligence/counterparties-and-entities).

**Schema `Relationship`**

An economic link between two transactions of the same workspace. It never leaves that workspace and never feeds the entity graph: a relationship between two private transactions is not a global truth.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | string | yes | `refund_of`: the purchase took place, then the money came back; both entries remain. `reversal_of`: the original entry is erased; nothing happened. `transfer_pair`: two legs of a single movement. Values: `refund_of`, `reversal_of`, `transfer_pair`. |
| `role` | string | yes | The role of this transaction. `peer` for a symmetric relationship, where neither source nor target means anything. Values: `source`, `target`, `peer`. |
| `symmetric` | boolean | yes | — |
| `counterpart` | string | yes | The other transaction, by its enrichment identifier. Pattern: `^enr_[0-9a-f-]{36}$`. |
| `match_strength` | object | yes | This is not a probability. It is the share of the corroborating signals available for this type that are actually present. `calibration` is `non_calibree` as long as no ground truth has certified a quantified reading of this number. |
| `match_strength.value` | number · 0.001–1 | yes | The denominator depends on the type: 5 for `refund_of`, 4 for `reversal_of`, 2 for `transfer_pair`. A `0.5` on a transfer pair (1/2) and a `0.5` on a reversal (2/4) are not the same strength. Read `corroborations` alongside. `present` only counts signals from the set specific to the type: a refund marker on a transfer pair stays visible in `evidence` but does not corroborate. |
| `match_strength.calibration` | string | yes | Always `non_calibree` in this version, and that is a constraint, not a convention: the value is a vocabulary in the database, the columns that carry it reference it, and a guard rejects any literal outside the vocabulary at every producer. When a real calibration exists, it will enter through a declared migration, together with the corpus that supports it. Values: `non_calibree`. |
| `match_strength.corroborations` | object | yes | — |
| `match_strength.corroborations.present` | integer ≥ 0 | yes | — |
| `match_strength.corroborations.possible` | integer ≥ 1 | yes | — |
| `evidence` | array<object> | yes | The signals the relationship rests on, preconditions included. A relationship that cannot say why it exists is not created. |
| `evidence[].code` | string | yes | — |
| `evidence[].value` | string | — | — |
| `evidence[].value_seconds` | integer | — | For `within_window`: the measured gap, in seconds. A duration, not a confidence. |
| `window_seconds` | integer \| null | — | The window of the relationship type, in seconds. A policy constant, not a measurement. |
| `detector_version` | string | yes | — |
| `status` | string | yes | Values: `active`, `supersedee`, `invalidee`. |
| `created_at` | string · date-time | — | — |

## The three types

The contract defines three values for `type`, no others.

| `type` | Contract definition | `symmetric` | Possible roles |
|---|---|---|---|
| `refund_of` | The purchase took place, then the money came back: both entries remain. | `false` | `source` (the refund), `target` (the purchase) |
| `reversal_of` | The original entry is erased: nothing happened. | `false` | `source`, `target` (oriented relationship; the contract shows no example) |
| `transfer_pair` | Two legs of a single movement. | `true` | `peer` on both sides |

A relationship is not a transaction type: `resolution.transaction_type` says what the entry is on its own, the relationship says which other one it is tied to (see [Transaction types](https://developer.noryo.app/en-GB/transaction-intelligence/transaction-types)).

## The role of this transaction

`role` is the role of the transaction you are reading, never the other one's: an enrichment carrying `type: "refund_of"` and `role: "source"` is the refund, and `counterpart` points to the purchase. The same link, read from the purchase, carries `role: "target"`.

For `transfer_pair`, neither source nor target means anything: both legs carry `role: "peer"` and `symmetric: true`.

`counterpart` is the enrichment identifier of the other transaction (`enr_…`), readable directly:

```bash
curl https://api.noryo.app/transactions/v1/enrichments/enr_3f2504e0-4f89-41d3-9a0c-0305e82c3301 \
  -H "Authorization: Bearer $NORYO_API_KEY"
```

> **Note** An enrichment can be read back for 30 days; beyond that, reading `counterpart` returns `404`, like any expired identifier. See [Sandbox limitations](https://developer.noryo.app/en-GB/transaction-intelligence/sandbox-limitations).

## What makes a relationship possible

The detector only reads what you send: `signals.used` lists, in every creation response (`Enrichment`), the received signals passed to the engine (to the identity resolver, or to the store for series, relationships and observation coverage), without singling out the ones used for relationships. In the contract's refund example it contains `descriptor`, `direction`, `occurred_at` and `amount`: send the amount and the date as soon as you have them.

Two fields matter in particular:

- `account_hint` and `counterparty_hint`: without both, a transfer pair cannot be identified, because the only signal that designates it is "my account is your counterparty, and your counterparty is my account";
- `external_transaction_id`: your own transaction identifier, which stops two submissions of the same transaction from being matched to each other.

> **Warning** The IBANs in `account_hint` and `counterparty_hint` are reduced on entry to their country and bank code: the account number does not enter the system, so it cannot leave it. Sending them makes `transfer_pair` possible without exposing an account.

## Corroborations and match strength

`evidence` lists the signals the relationship rests on, preconditions included. A relationship that cannot say why it exists is not created. Each element carries a `code`; `within_window` also carries `value_seconds`, the measured gap in seconds: a duration, not a confidence.

`match_strength` is not a probability: it is the share of the corroborating signals this type can produce that are actually present:

- `value` is `present / possible`, rounded to three decimal places;
- `corroborations.present` and `corroborations.possible` render the numerator and the denominator;
- `calibration` is `non_calibree`: no statistical calibration is available.

The denominator depends on the type: five possible signals for `refund_of`, four for `reversal_of`, two for `transfer_pair`. A `0.5` on a transfer pair (one signal out of two) and a `0.5` on a reversal (two out of four) are not the same strength. `present` only counts signals from the set specific to the type: a refund marker on a transfer pair stays visible in `evidence` but does not corroborate.

This score class is called `evidence_ratio`. It is comparable between two relationships of the same `type` and the same `detector_version`, and never with `series[].match_strength`, which shares the name but has another denominator. The classes are described in [Score semantics](https://developer.noryo.app/en-GB/transaction-intelligence/score-semantics).

`window_seconds` is the window of the type, in seconds: a policy constant, not a measurement, to be read from the response rather than hard-coded.

### Example

A refund attached to its purchase, as the contract shows it:

```json
{
  "type": "refund_of",
  "role": "source",
  "symmetric": false,
  "counterpart": "enr_3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "match_strength": {
    "value": 0.8,
    "calibration": "non_calibree",
    "corroborations": { "present": 4, "possible": 5 }
  },
  "evidence": [
    { "code": "same_counterparty_entity" },
    { "code": "refund_marker" },
    { "code": "within_window", "value_seconds": 259200 },
    { "code": "resolved_type_refund" }
  ],
  "window_seconds": 2592000,
  "detector_version": "rel_v1",
  "status": "active",
  "created_at": "2026-09-12T09:00:00Z"
}
```

Read `corroborations`: `present` is 4 out of `possible` 5, so `value` is `0.8`, nothing more. The `evidence` list is not that count: it also carries the preconditions.

## Why a relationship appears on read-back

A relationship links two transactions, and the second does not exist yet when the first is enriched. Monday's purchase cannot know about Friday's refund: its creation response carries `relationships: []`. That response is immutable: a replay with the same `Idempotency-Key` returns it exactly as it was, without the link.

Reading back, on the other hand, carries the link: `GET /transactions/v1/enrichments/{enrichment_id}` returns a `StoredEnrichment` with its `relationships` array. Read back on Friday, Monday's purchase carries `refund_of` with `role: "target"`; the refund carried it from its creation with `role: "source"`. Keep `enrichment_id` and read back for the current state.

## When nothing is concluded

Abstention is a result, not a silence: "no relationship" and "two equally plausible candidates" are two different things. That is what `relationships_policy` (`RelationshipsPolicy`) is for. It is rendered at creation (`Enrichment`) and absent from the read-back (`StoredEnrichment`).

**Schema `RelationshipsPolicy`**

Abstention is a result, not a silence. "No relationship" and "two equally plausible candidates" are two different things; a client that cannot tell them apart will conclude the first.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `detector_version` | string | yes | — |
| `calibration` | string | yes | Values: `non_calibree`. |
| `unresolved` | array<object> | yes | Every detector that did not conclude says why. For a relationship: `candidates_at_least` (never the exact count, which would be an oracle on other people's transactions). For the series (`type = series`): `occurrences`, and a series outcome. A concluded relationship never appears here: it is in `relationships`. An earlier version wrongly listed the `resolved` and `already_known` outcomes here. |
| `unresolved[].type` | string | yes | — |
| `unresolved[].outcome` | string | yes | Values: `ambiguous`, `single_occurrence`, `no_recurrence_interval`, `no_series_key`, `excluded_by_relation`, `ambiguous_membership`. |
| `unresolved[].candidates_at_least` | integer ≥ 1 | — | — |
| `unresolved[].occurrences` | integer ≥ 0 | — | — |

`unresolved` lists every detector that did not conclude, with its reason:

- for a relationship, `type` names the relationship type, `outcome` is `ambiguous` and `candidates_at_least` gives a floor, never the exact count, which would be an oracle on other people's transactions;
- for the series, `type` is `series`, `occurrences` counts the occurrences seen and `outcome` carries a series outcome; one of them, `excluded_by_relation`, names a relationship as the reason (see [Recurring series](https://developer.noryo.app/en-GB/transaction-intelligence/recurring-series)).

A concluded relationship never appears in `unresolved`: it is in `relationships`. Read the two together:

| `relationships` | `unresolved` (relationships) | Reading |
|---|---|---|
| Empty | Empty | No relationship established, no ambiguity met. |
| Empty | One `ambiguous` entry | Several equally plausible candidates: Noryo abstains. |
| One relationship | Empty | Link established; its signals are in `evidence`. |

## Status and version

The contract declares three values for `status`: `active`, `supersedee` and `invalidee`; the contract's examples are `active`. `detector_version` names the detector that produced the relationship: if you keep a `match_strength` value, keep `detector_version` next to it, because the same value, under another version, may no longer measure the same thing. `created_at` timestamps the relationship.

---

Canonical page: https://developer.noryo.app/en-GB/transaction-intelligence/relationships
