Metadata-Version: 2.5
Name: entity-core
Version: 0.2.0
Summary: Brique de résolution d'entités (CDC-ENTITY-CORE-v1, refondue ADR-074) — un résolveur fail-closed sur un canon REÇU (référentiel de tenants, instantané JSON), jamais embarqué. Niveau plateforme / stateless.
Author: Holding MAPFARM
License: Proprietary
Keywords: canon,entites,multitenant,normalisation,referentiel
Requires-Python: >=3.11
Provides-Extra: test
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# entity-core

**Brique de résolution d'entités** — la **sémantique** de l'identité d'entité (normalisation
fail-closed, alias, marque → propriétaire, garde de cohérence), appliquée à un canon **reçu**.
Toutes les briques de la plateforme (iaos · corpus · harvest · factory · cockpit · `fin-core` ·
`client-core` · `catalog-core`) l'appellent pour résoudre une désignation d'entité, au lieu de
re-coder leur propre liste.

Spécifiée par `CDC-ENTITY-CORE-v1` (`docs/`), **refondue par ADR-074** (dépôt `iaos`,
2026-09-12) : *l'entité est une donnée du tenant, jamais une constante du produit.*

> **v0.1 → v0.2, rupture voulue.** La v0.1 embarquait le canon d'un tenant particulier (six
> entités, leurs alias, `harvest → hold`) : toute instance du produit — y compris celle vendue à
> un tiers — portait cet organigramme et refusait le sien. La v0.2 ne connaît **aucun nom**. Les
> symboles de module `CANON`, `normalize`, `is_entity`, `resolve_marque_owner` **n'existent
> plus** : un consommateur qui ne reçoit pas de canon ne compile pas — c'est le cliquet.

> **Niveau plateforme / stateless.** Aucune base, aucun réseau, aucune dépendance. Ce qui reste
> de la v0.1 : *stateless* (le canon est un argument) et *offline* (un instantané JSON remplace le
> canon embarqué). Ce qui tombe : *global* — le canon de la holding n'est pas celui du produit.

## Principes (non négociables — CDC §5/§6)

| # | Règle |
|---|---|
| R1 / P4 | **Une seule sémantique** de résolution : `entity-core`. Aucune liste re-codée par dépôt — et **aucune liste dans la brique**. |
| R2 | Toute frontière d'entrée d'entité **normalise via un `Canon`** (route, RAG, INSERT, allow-list). |
| R3 / G2 | **Fail-closed** : forme inconnue → **REFUS** ; jamais de devinette ni de pass-through. Un canon vide refuse tout. |
| G1 | Un canon reçu **incohérent** (alias vers une non-entité, marque qui est aussi une entité, snapshot d'une autre version) est refusé **à la construction**. |
| G3 | **Typo non bénie** : toute faute → refus, **jamais** d'alias ; correction à la source (le référentiel). |
| R4 / P2 | **Stateless / plateforme** : pas de données par app, pas de cache caché — le consommateur décide d'où vient le canon et quand il le rafraîchit. |
| R7 | Une forme **connue mais hors axe entité** (marque, société, socle) est refusée par `normalize` avec sa raison ; une marque se résout par `resolve_marque_owner`. |

## Le canon — d'où il vient

```python
from entity_core import Canon

# 1. Depuis le référentiel de tenants (iaos : tables `tenants` + `entity_aliases`).
#    L'appelant filtre sur SON tenant ; la brique ne connaît pas les tenants.
canon = Canon.from_rows(
    tenants=[  # company_id|slug, kind, is_entity, display_name?
        {"company_id": "siege", "kind": "internal", "is_entity": True},
        {"company_id": "ops",   "kind": "internal", "is_entity": True},
        {"company_id": "socle", "kind": "internal", "is_entity": False, "display_name": "socle doc"},
        {"company_id": "client-a", "kind": "external"},   # ignoré : pas dans le canon d'un autre
    ],
    aliases=[  # alias, slug, kind? ∈ {alias (défaut), marque}
        {"alias": "operations", "slug": "ops"},
        {"alias": "brand-x", "slug": "siege", "kind": "marque"},
    ],
)

# 2. Depuis un instantané JSON (consommateurs offline : snapshot versionné dans le dépôt,
#    régénéré depuis GET /api/iaos/v1/tenants).
canon = Canon.from_json(open("canon.json").read())
open("canon.json", "w").write(canon.to_json())   # déterministe, clés triées

# 3. En clair (tests, fixtures).
canon = Canon(entities={"siege", "ops"}, aliases={"operations": "ops"},
              marques={"brand-x": "siege"}, non_entities={"acme": "société"})

# 4. Le canon d'une instance qui ne s'est pas encore déclarée : tout est refusé.
canon = Canon.empty()
```

## Usage

```python
from entity_core import assert_canon
from entity_core.errors import NotAnEntity, UnknownEntity

canon.normalize("Operations")          # → "ops"
canon.normalize("ops")                 # → "ops" (idempotent)
canon.is_entity("brand-x")             # → False (marque : hors axe entité)
canon.resolve_marque_owner("brand-x")  # → "siege"

canon.normalize("opss")                # UnknownEntity  [R3] — typo : refus, jamais d'alias
canon.normalize("acme")                # NotAnEntity    [R7] — société, avec sa raison
canon.normalize("brand-x")             # NotAnEntity    [R7] — « utiliser resolve_marque_owner »

assert_canon(canon, ["siege", "operations"])   # True | CanonDivergence [G4] (CI rouge)
```

`Canon` est immuable (`__slots__`, mappings figés), comparable et hachable par contenu.

## Installation (registre PyPI Gitea)

```bash
pip install --index-url https://git.it.iaos.cc/api/packages/mapfarm/pypi/simple/ entity-core==0.2.0
# ou, build OFFLINE souverain : pip install --no-deps ./vendor/entity-core
```

## Développement

```bash
make install   # pip install -e ".[test]"
make lint      # ruff check + ruff format --check
make test      # pytest — unitaires purs, aucune base
make build     # sdist + wheel
```

Publication : workflow `publish-entity-core.yml` (`workflow_dispatch`) après bump de `version`.
