# Relations

> Comment Noryo relie deux transactions d'un même workspace par une relation refund_of, reversal_of ou transfer_pair, et comment lire son rôle, ses corroborations et ses abstentions.

## Ce qu'est une relation

Une relation (`Relationship`) est un lien économique entre deux transactions du même workspace. Elle est rendue dans le tableau `relationships` de la création (`Enrichment`, voir [Enrichir une transaction](https://developer.noryo.app/fr-FR/transaction-intelligence/enrich-a-transaction)) et de la relecture (`StoredEnrichment`, voir [Relire un enrichissement](https://developer.noryo.app/fr-FR/transaction-intelligence/read-an-enrichment)).

Une relation est privée au workspace : elle ne le quitte jamais et n'alimente jamais le graphe d'entités, car un lien entre deux transactions privées n'est pas une vérité globale. Ce qui vit dans le graphe : [Contreparties et entités](https://developer.noryo.app/fr-FR/transaction-intelligence/counterparties-and-entities).

**Schéma `Relationship`**

Un lien économique entre deux transactions du même workspace. Il ne quitte jamais ce workspace et n'alimente jamais le graphe d'entités : une relation entre deux transactions privées n'est pas une vérité globale.

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `type` | string | oui | `refund_of` : l'achat a eu lieu, puis l'argent est revenu — les deux écritures subsistent. `reversal_of` : l'écriture d'origine est effacée, il ne s'est rien passé. `transfer_pair` : deux jambes d'un seul mouvement. Valeurs : `refund_of`, `reversal_of`, `transfer_pair`. |
| `role` | string | oui | Le rôle de cette transaction. `peer` pour une relation symétrique, où ni source ni cible ne veut rien dire. Valeurs : `source`, `target`, `peer`. |
| `symmetric` | boolean | oui | — |
| `counterpart` | string | oui | L'autre transaction, par son identifiant d'enrichissement. Motif : `^enr_[0-9a-f-]{36}$`. |
| `match_strength` | object | oui | Ce n'est pas une probabilité. C'est la part des signaux corroborants disponibles pour ce type qui sont effectivement présents. `calibration` vaut `non_calibree` tant qu'aucune vérité terrain n'a certifié une lecture chiffrée de ce nombre. |
| `match_strength.value` | number · 0.001–1 | oui | Le dénominateur dépend du type : 5 pour `refund_of`, 4 pour `reversal_of`, 2 pour `transfer_pair`. Un `0.5` de paire de virement (1/2) et un `0.5` de contre-passation (2/4) ne sont pas la même force. Lisez `corroborations` à côté. `present` ne compte que les signaux de l'ensemble propre au type : un marqueur de remboursement sur une paire de virement reste visible dans `evidence` mais ne corrobore pas. |
| `match_strength.calibration` | string | oui | Toujours `non_calibree` dans cette version, et c'est une contrainte, pas une convention : la valeur est un vocabulaire en base, les colonnes qui la portent y font référence, et une garde refuse tout littéral hors vocabulaire chez chaque producteur. Le jour où une calibration réelle existe, elle entrera par une migration déclarée, avec le corpus qui la défend. Valeurs : `non_calibree`. |
| `match_strength.corroborations` | object | oui | — |
| `match_strength.corroborations.present` | integer ≥ 0 | oui | — |
| `match_strength.corroborations.possible` | integer ≥ 1 | oui | — |
| `evidence` | array<object> | oui | Les signaux qui fondent la relation, y compris les préconditions. Une relation qui ne peut pas dire pourquoi elle existe n'est pas créée. |
| `evidence[].code` | string | oui | — |
| `evidence[].value` | string | — | — |
| `evidence[].value_seconds` | integer | — | Pour `within_window` : l'écart mesuré, en secondes. Une durée, pas une confiance. |
| `window_seconds` | integer \| null | — | La fenêtre du type de relation, en secondes. Une constante de politique, pas une mesure. |
| `detector_version` | string | oui | — |
| `status` | string | oui | Valeurs : `active`, `supersedee`, `invalidee`. |
| `created_at` | string · date-time | — | — |

## Les trois types

Le contrat définit trois valeurs pour `type`, aucune autre.

| `type` | Définition du contrat | `symmetric` | Rôles possibles |
|---|---|---|---|
| `refund_of` | L'achat a eu lieu, puis l'argent est revenu : les deux écritures subsistent. | `false` | `source` (le remboursement), `target` (l'achat) |
| `reversal_of` | L'écriture d'origine est effacée : il ne s'est rien passé. | `false` | `source`, `target` (relation orientée ; le contrat n'en montre pas d'exemple) |
| `transfer_pair` | Deux jambes d'un seul mouvement. | `true` | `peer` des deux côtés |

Une relation n'est pas un type de transaction : `resolution.transaction_type` dit ce qu'est l'écriture seule, la relation dit à quelle autre elle est liée (voir [Types de transaction](https://developer.noryo.app/fr-FR/transaction-intelligence/transaction-types)).

## Le rôle de cette transaction

`role` est le rôle de la transaction que vous lisez, jamais celui de l'autre : un enrichissement qui porte `type: "refund_of"` et `role: "source"` est le remboursement, et `counterpart` désigne l'achat. Le même lien, lu depuis l'achat, porte `role: "target"`.

Pour `transfer_pair`, ni source ni cible n'a de sens : les deux jambes portent `role: "peer"` et `symmetric: true`.

`counterpart` est l'identifiant d'enrichissement de l'autre transaction (`enr_…`), relisible directement :

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

> **Note** Un enrichissement est relisible 30 jours ; au-delà, relire `counterpart` rend `404`, comme tout identifiant expiré. Voir [Limites du sandbox](https://developer.noryo.app/fr-FR/transaction-intelligence/sandbox-limitations).

## Ce qui rend une relation possible

Le détecteur ne lit que ce que vous envoyez : `signals.used` liste, dans chaque réponse de création (`Enrichment`), les signaux reçus transmis au moteur (au résolveur d'identité, ou à la base pour les séries, les relations et la couverture d'observation), sans distinguer ceux qui ont servi aux relations. Dans l'exemple de remboursement du contrat, il contient `descriptor`, `direction`, `occurred_at` et `amount` : envoyez le montant et la date dès que vous les avez.

Deux champs comptent en particulier :

- `account_hint` et `counterparty_hint` : sans les deux, une paire de virement ne peut pas être identifiée, car le seul signal qui la désigne est « mon compte est ta contrepartie, et ta contrepartie est mon compte » ;
- `external_transaction_id` : votre propre identifiant de transaction, qui empêche deux envois d'une même transaction de se rapprocher l'un de l'autre.

> **Attention** Les IBAN de `account_hint` et de `counterparty_hint` sont réduits dès l'entrée à leur pays et leur code banque : le numéro de compte n'entre pas dans le système, donc ne peut pas en sortir. Les envoyer rend `transfer_pair` possible sans exposer un compte.

## Corroborations et force d'appariement

`evidence` liste les signaux qui fondent la relation, préconditions comprises. Une relation qui ne peut pas dire pourquoi elle existe n'est pas créée. Chaque élément porte un `code` ; `within_window` porte aussi `value_seconds`, l'écart mesuré en secondes : une durée, pas une confiance.

`match_strength` n'est pas une probabilité : c'est la part des signaux corroborants que ce type sait produire qui sont effectivement présents :

- `value` vaut `present / possible`, arrondi à trois décimales ;
- `corroborations.present` et `corroborations.possible` rendent le numérateur et le dénominateur ;
- `calibration` vaut `non_calibree` : aucune calibration statistique n'est disponible.

Le dénominateur dépend du type : cinq signaux possibles pour `refund_of`, quatre pour `reversal_of`, deux pour `transfer_pair`. Un `0.5` sur une paire de virement (un signal sur deux) et un `0.5` sur une annulation (deux sur quatre) ne sont pas la même force. `present` ne compte que les signaux de l'ensemble propre au type : un marqueur de remboursement sur une paire de virement reste visible dans `evidence` mais ne corrobore pas.

Cette classe de score s'appelle `evidence_ratio`. Elle se compare entre deux relations du même `type` et de la même `detector_version`, jamais avec `series[].match_strength`, qui porte le même nom mais un autre dénominateur. Les classes sont décrites dans [Sémantique des scores](https://developer.noryo.app/fr-FR/transaction-intelligence/score-semantics).

`window_seconds` est la fenêtre du type, en secondes : une constante de politique, pas une mesure, à lire dans la réponse plutôt qu'à coder en dur.

### Exemple

Un remboursement rattaché à son achat, tel que le contrat le montre :

```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"
}
```

Lisez `corroborations` : `present` vaut 4 sur `possible` 5, donc `value` vaut `0.8`, rien de plus. La liste `evidence` n'est pas ce compte : elle porte aussi les préconditions.

## Pourquoi une relation apparaît à la relecture

Une relation relie deux transactions, et la seconde n'existe pas encore quand la première est enrichie. L'achat du lundi ne peut pas connaître le remboursement du vendredi : sa réponse de création porte `relationships: []`. Cette réponse est immuable : un rejeu avec la même `Idempotency-Key` la rend telle quelle, sans le lien.

La relecture, elle, porte le lien : `GET /transactions/v1/enrichments/{enrichment_id}` rend un `StoredEnrichment` avec son tableau `relationships`. Relu le vendredi, l'achat du lundi porte `refund_of` avec `role: "target"` ; le remboursement l'a porté dès sa création avec `role: "source"`. Conservez `enrichment_id` et relisez pour l'état courant.

## Quand rien n'est conclu

L'abstention est un résultat, pas un silence : « aucune relation » et « deux candidats également plausibles » sont deux choses différentes. C'est le rôle de `relationships_policy` (`RelationshipsPolicy`), rendu à la création (`Enrichment`) et absent de la relecture (`StoredEnrichment`).

**Schéma `RelationshipsPolicy`**

L'abstention est un résultat, pas un silence. « aucune relation » et « deux candidats également plausibles » sont deux choses différentes ; un client qui ne peut pas les distinguer conclura la première.

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `detector_version` | string | oui | — |
| `calibration` | string | oui | Valeurs : `non_calibree`. |
| `unresolved` | array<object> | oui | Chaque détecteur qui n'a pas conclu dit pourquoi. Pour une relation : `candidates_at_least` (jamais le compte exact — il ferait un oracle sur les transactions des autres). Pour la série (`type = series`) : `occurrences`, et une issue de série. Une relation conclue n'y figure jamais : elle est dans `relationships`. Une version antérieure y rangeait à tort les issues `resolved` et `already_known`. |
| `unresolved[].type` | string | oui | — |
| `unresolved[].outcome` | string | oui | Valeurs : `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` liste chaque détecteur qui n'a pas conclu, avec sa raison :

- pour une relation, `type` nomme le type de relation, `outcome` vaut `ambiguous` et `candidates_at_least` donne un plancher, jamais le compte exact, qui ferait un oracle sur les transactions des autres ;
- pour la série, `type` vaut `series`, `occurrences` compte les occurrences vues et `outcome` porte une issue de série ; l'une d'elles, `excluded_by_relation`, nomme une relation comme motif (voir [Séries récurrentes](https://developer.noryo.app/fr-FR/transaction-intelligence/recurring-series)).

Une relation conclue ne figure jamais dans `unresolved` : elle est dans `relationships`. Lisez les deux ensemble :

| `relationships` | `unresolved` (relations) | Lecture |
|---|---|---|
| Vide | Vide | Aucune relation établie, aucune ambiguïté rencontrée. |
| Vide | Une entrée `ambiguous` | Plusieurs candidats également plausibles : Noryo s'abstient. |
| Une relation | Vide | Lien établi ; ses signaux sont dans `evidence`. |

## Statut et version

Le contrat déclare trois valeurs pour `status` : `active`, `supersedee` et `invalidee` ; les exemples du contrat sont `active`. `detector_version` nomme le détecteur qui a produit la relation : si vous conservez une valeur de `match_strength`, conservez `detector_version` à côté, car la même valeur, sous une autre version, peut ne plus mesurer la même chose. `created_at` horodate la relation.

---

Page canonique : https://developer.noryo.app/fr-FR/transaction-intelligence/relationships
