@@ -132,6 +132,176 @@ descriptives (`mime_type`, `folder_url`, `file_name`, `width`, `height`).
132132
133133---
134134
135+ ## ADR-025 — Interface sémantique snake_case : schema.org sans guillemets SQL
136+
137+ ** Statut** : Adopté
138+
139+ ### Contexte
140+
141+ Les vues sémantiques exposaient des alias camelCase entre guillemets doubles
142+ (` "givenName" ` , ` "datePublished" ` , ` "@type" ` ). Cette convention crée trois
143+ frictions opérationnelles :
144+
145+ 1 . ** Guillemets obligatoires dans toute requête SQL** : `SELECT "givenName" FROM
146+ identity.v_person` — l'oubli d'un guillemet provoque une erreur silencieuse
147+ (colonne non trouvée, ou pire : résolution vers une colonne système).
148+ 2 . ** Caractère ` @ ` illégal** comme identifiant SQL nu : ` "@type" ` ne peut jamais
149+ être utilisé sans guillemets.
150+ 3 . ** Friction avec les ORM et drivers** : la majorité des drivers (psycopg3, JDBC,
151+ node-postgres) retournent les colonnes en minuscules par défaut, ce qui force
152+ soit des mappings explicites, soit des guillemets systématiques.
153+
154+ ### Décision
155+
156+ Toutes les vues sémantiques utilisent désormais le ** snake_case PostgreSQL natif** ,
157+ sans guillemets. Le vocabulaire schema.org est préservé dans les noms de colonnes
158+ par translittération directe : ` givenName → given_name ` , `datePublished →
159+ published_at` , ` articleBody → article_body`.
160+
161+ ### Règles de translittération
162+
163+ | Règle | Exemple schema.org | Alias vue |
164+ | ----- | ------------------ | --------- |
165+ | camelCase → snake_case | ` givenName ` | ` given_name ` |
166+ | Suffixe ` _at ` pour TIMESTAMPTZ | ` datePublished ` | ` published_at ` |
167+ | Suffixe ` _cents ` pour INT8 monétaire | ` price ` | ` price_cents ` |
168+ | Suffixe ` _id ` pour FK | ` authorId ` | ` author_id ` |
169+ | Suffixe ` _code ` pour codes numériques | ` currencyCode ` | ` currency_code ` |
170+ | Miroir du nom physique quand identique | ` is_readable ` | ` is_readable ` (pas ` is_accessible_for_free ` ) |
171+ | ` @type ` → ` doc_type ` / ` org_type ` | ` @type ` | ` doc_type ` , ` org_type ` |
172+
173+ ### Exceptions documentées — refus de ` address_country ` pour ` country_code `
174+
175+ La suggestion d'aliaser ` country_code ` en ` address_country ` est refusée.
176+ ` address_country ` évoque une valeur textuelle ("France"), alors que le type
177+ physique est ` SMALLINT ` contenant un code ISO 3166-1 numérique (250).
178+ Un alias trompeur sur le type crée des erreurs de comparaison applicatives
179+ (` WHERE address_country = 'FR' ` ne fonctionnerait pas sur un SMALLINT).
180+ Le nom ` country_code ` est conservé dans la table et dans la vue — il est
181+ auto-documenté : "c'est un code, pas un nom de pays".
182+
183+ ### Colonne ` content.identity.name ` → ` headline `
184+
185+ Renommage du nom physique, pas seulement de l'alias. ` name ` est ambigu dans
186+ le contexte d'un article (est-ce le titre, le nom de fichier, le nom d'auteur ?).
187+ ` headline ` est le terme exact schema.org/Article. Le renommage s'étend à
188+ ` content.revision.snapshot_name → snapshot_headline ` pour la cohérence du
189+ composant de versioning.
190+
191+ ### Colonnes ` org.org_legal `
192+
193+ ` vat_number → vat_id ` : le suffixe ` _id ` signale un identifiant externe (non
194+ une valeur calculée). Cohérent avec ` duns ` et ` siret ` . Ajout de `legal_name
195+ VARCHAR(128)` : raison sociale officielle, distincte du nom commercial dans
196+ ` org.org_identity.name ` .
197+
198+ ### Impact applicatif
199+
200+ Les consommateurs existants de l'API doivent adapter leurs sélections :
201+ ` "givenName" ` → ` given_name ` , ` "headline" ` → ` headline ` (idem), etc. Ce
202+ changement est une rupture de contrat intentionnelle, effectuée en phase R&D
203+ avant toute mise en production.
204+
205+ ---
206+
207+ ## ADR-024 — Fragmentation geo, soft delete RGPD et compliance de production
208+
209+ ** Statut** : Adopté
210+
211+ ### Contexte
212+
213+ Quatre vecteurs de risque identifiés avant mise en production européenne :
214+
215+ 1 . ` geo.place_core ` mélange spine spatial (coordonnées KNN) et adresse postale (logistique).
216+ 2 . ` org.org_legal.vat_number VARCHAR(15) ` trop court pour les identifiants fiscaux internationaux.
217+ 3 . Absence de mécanisme de droit à l'oubli (RGPD art. 17) et de traçabilité du consentement.
218+ 4 . Absence de mention de droits dans les métadonnées médias (risque légal d'exploitation d'images).
219+
220+ ---
221+
222+ ### 1. Séparation ` geo.place_core ` / ` geo.postal_address ` (ECS spatial vs logistique)
223+
224+ ** Problème** : ` geo.place_core ` mixait coordonnées GPS et adresse postale dans le même tuple.
225+ Les requêtes géospatiales (KNN ` <-> ` , ` ST_DWithin ` ) ne nécessitent que ` id + coordinates ` ,
226+ mais chargeaient systématiquement ` street ` , ` locality ` , ` region ` , ` country ` , ` postal_code ` .
227+
228+ ** Décision** : fragmentation en deux composants 1:1 :
229+
230+ | Composant | Sémantique schema.org | Contenu | Fréquence |
231+ | ------------------- | ---------------------- | ------------------------------------------- | --------- |
232+ | ` geo.place_core ` | ` Place ` | id, name, elevation, type_id, coordinates | Hot |
233+ | ` geo.postal_address ` | ` PostalAddress ` | country_code, locality, region, street, postal_code | Warm |
234+
235+ ** Gain de densité** :
236+
237+ | État | Bytes/tuple | Tuples/page |
238+ | ------------ | ----------- | ----------- |
239+ | Avant (mixte)| ~ 211 B | ~ 38 |
240+ | Après (spine)| ~ 26–46 B | ~ 179–317 |
241+
242+ Les requêtes KNN sur 500 000 lieux ne chargent plus aucun octet postal.
243+
244+ ** ` country_code SMALLINT ` (ISO 3166-1 numérique)** : cohérent avec ` currency_code `
245+ d'ADR-022. 2 bytes pass-by-value vs ` CHAR(2) ` ou ` VARCHAR(2) ` varlena avec en-tête
246+ de 4 bytes. Le mapping vers le code alphabétique (` 250 → "FR" ` ) est délégué à
247+ l'applicatif — table de lookup statique, zéro accès base de données.
248+
249+ ---
250+
251+ ### 2. ` vat_number VARCHAR(32) ` (compliance internationale)
252+
253+ ` VARCHAR(15) ` couvrait les numéros TVA européens (max 15 chars incluant le préfixe
254+ pays). Les identifiants fiscaux hors UE (GSTIN indien 15 chars avec format strict,
255+ CNPJ brésilien 18 chars avec ponctuation, CFE mexicain 13 chars) nécessitent
256+ davantage de marge. ` VARCHAR(32) ` absorbe tous les formats connus sans coût physique
257+ (varlena : seul le contenu réel est stocké).
258+
259+ ---
260+
261+ ### 3. Soft delete RGPD : ` anonymized_at ` + procédure ` anonymize_person `
262+
263+ ** Problème** : un ` DELETE ` physique d'une entité casserait les FK vers
264+ ` commerce.transaction_core ` — les commandes passéesdeviendraient incohérentes.
265+ Une suppression logicielle par colonne booléenne (` is_deleted ` ) serait ambiguë
266+ et difficile à auditer.
267+
268+ ** Décision** : colonne ` anonymized_at TIMESTAMPTZ NULL ` dans ` identity.entity ` (spine).
269+
270+ - ` NULL ` = entité active.
271+ - Non-NULL = anonymisation exécutée, timestamp d'audit RGPD irréversible.
272+
273+ La procédure ` identity.anonymize_person(p_entity_id) ` (SECURITY DEFINER) efface
274+ en une transaction atomique :
275+
276+ | Composant | Action |
277+ | -------------------------- | -------------------------------------------------- |
278+ | ` identity.entity ` | ` anonymized_at = now() ` |
279+ | ` identity.person_identity ` | Tous les champs nominatifs → NULL |
280+ | ` identity.person_contact ` | email, phone, fax, url → NULL |
281+ | ` identity.person_biography ` | Dates et lieux → NULL |
282+ | ` identity.person_content ` | Textes personnels → NULL, media_id → NULL |
283+ | ` identity.account_core ` | username/slug → ` user_<id> ` (non nominatif) |
284+ | ` identity.auth ` | password_hash → ` 'ANONYMIZED' ` , is_banned → true |
285+ | ` identity.group_to_account ` | Suppression (appartenance = donnée de traçabilité) |
286+
287+ L'entité physique subsiste dans ` identity.entity ` avec son ` id ` — toutes les FK
288+ ` commerce.transaction_core.client_entity_id ` restent valides.
289+
290+ ** ` tos_accepted_at TIMESTAMPTZ NULL ` ** dans ` identity.account_core ` : timestamp
291+ d'acceptation des CGU. NULL = non encore accepté. Placé en première colonne
292+ (TIMESTAMPTZ 8 bytes, ADR-004) pour zéro padding.
293+
294+ ---
295+
296+ ### 4. ` copyright_notice VARCHAR(255) ` dans ` content.media_content `
297+
298+ Placé dans ` media_content ` (cold path, BASSE fréquence) et non dans ` media_core `
299+ (hot path, HAUTE fréquence). Un ` SELECT ` sur les listings d'articles ne charge
300+ jamais la mention de droits — elle n'est projetée que lors de l'affichage complet
301+ d'un média ou de la génération d'une page légale.
302+
303+ ---
304+
135305## ADR-023 — Éclatement de l'agrégat de commande en composants ECS 1:1
136306
137307** Statut** : Adopté
@@ -884,4 +1054,4 @@ au même titre que les `REVOKE` qui les suivent.
8841054
8851055---
8861056
887- * Architecture ECS/DOD · PostgreSQL 18 · Projet Marius · 23 décisions*
1057+ * Architecture ECS/DOD · PostgreSQL 18 · Projet Marius · 25 décisions*
0 commit comments