/** * lib/ba-sensitivity.ts — THE closed vocabulary of DATA SENSITIVITY, at the * ENTITY grain (doctrine 2026-10-03). * * One bullet in the entity block of `entité.md`, right after the definition * cartouche: * * - **Sensibilité** : — [, …][ — décision client du AAAA-MM-JJ : ] * * Levels (closed, ranked): `public` < `interne` (the default when nothing is * written) < `confidentiel` < `restreint`. Categories (closed, nLPD art. 5 * let. c + RGPD): `personnel`, `personnel-sensible`, `sante`, `juridique`, * `rh`, `financier`, `commercial`, `securite`. * * The same bullet written BEFORE the first `### ENT-` heading is the * document default every entity inherits (the `**Portée**` mechanism); a * `composant` with no line of its own inherits its aggregate root's level * (one hop through its owning `*→1` / `1→1` same-module relation). * * WHY THE ENTITY GRAIN, NEVER AN ATTRIBUTE COLUMN: the generated READ * surfaces carry every user field (`lib/field-read-surface.ts`) and the * platform has no field-level masking — the socle protects HR data by * SPLITTING the confidential attributes into their own entity with its own * permission family (HrEmployee vs the remuneration / payroll / contract * tables). A per-attribute tag would be a promise the code cannot keep. So a * sensitive-looking attribute inside an `interne` entity is the SPLIT * doctrine's subject (DM-037): declare the entity, or extract the attribute * into a dedicated composant with its own section + permission. * * `**Personne** : mandatory|optional` implies the category `personnel` — * never a level (every existing Employee / Client would otherwise trip the * guardrails; the HR directory is `interne` on the platform, only its * satellite tables are confidential). * * The `décision client` on THIS line is the classification's escape hatch * (silences DM-036 / DM-037 — a dated decision, the DM-022 precedent: an * undated « decision » is a habit, not a decision). The decision to EXPOSE a * confidential entity to third parties lives on the `**API externe**` line * (lib/external-api-catalog, DM-038). * * No import of `ba-entities` on purpose (structural types, like * `ba-entity-cartouche`): `ba-entities` and `code-pattern-grammar` import * THIS module, so an import back would cycle. * * Consumers: lib/ba-entities (parses the bullet), code-pattern-grammar (the * near-miss scanners skip it), audit-ba DM-036..038 / RBAC-011..012 / * XD-009 / CODE-009, create-rbac/derive-lookup-grants (pending * confirmation on a sensitive producer), ba-extension-requests (access * requests carry the owner's level), ba-develop/derive-sensitivity-specs * (the pagespec `sensitivity` key), scaffold-business / scaffold-extension- * search (search surfaces restricted to the display field), audit-dev-api * DEV-API-035, audit-dev-external-api DEV-XAPI-015, documentation extract-doc. */ import { z } from 'zod' import { splitWords } from './capability-catalog.js' import { singularize } from './string-utils.js' // --------------------------------------------------------------------------- // Vocabulary // --------------------------------------------------------------------------- export const SENSITIVITY_LEVELS = ['public', 'interne', 'confidentiel', 'restreint'] as const export type SensitivityLevel = (typeof SENSITIVITY_LEVELS)[number] /** The level of an entity that declares nothing, anywhere. */ export const DEFAULT_SENSITIVITY_LEVEL: SensitivityLevel = 'interne' export const SENSITIVITY_CATEGORY_KEYS = [ 'personnel', 'personnel-sensible', 'sante', 'juridique', 'rh', 'financier', 'commercial', 'securite', ] as const export type SensitivityCategory = (typeof SENSITIVITY_CATEGORY_KEYS)[number] export interface SensitivityLevelEntry { level: SensitivityLevel /** 0..3 — comparisons go through `rankOf`, never through the array index. */ rank: number /** What the level MEANS for the guardrails (one line, French — the docs carry it verbatim). */ meaning: string } export const SENSITIVITY_LEVEL_ENTRIES: readonly SensitivityLevelEntry[] = [ { level: 'public', rank: 0, meaning: 'exposable à des tiers sans décision supplémentaire (API externe, endpoint anonyme) — jamais une donnée personnelle ni un attribut sensible' }, { level: 'interne', rank: 1, meaning: 'défaut quand rien n\'est écrit — accessible aux acteurs de l\'application selon rbac.md, aucun garde-fou supplémentaire' }, { level: 'confidentiel', rank: 2, meaning: 'accès justifié par un cas d\'usage (besoin d\'en connaître), grants lookup dérivés à valider par un humain, API externe sur décision client datée' }, { level: 'restreint', rank: 3, meaning: 'nominatif — jamais d\'API externe, jamais d\'acteur externe, jamais la recherche globale' }, ] export interface SensitivityCategoryEntry { key: SensitivityCategory /** Human label (French). */ label: string /** The legal / business frame the category answers to. */ legalBasis: string /** What typically lands there (the docs carry it verbatim). */ examples: string } export const SENSITIVITY_CATEGORIES: readonly SensitivityCategoryEntry[] = [ { key: 'personnel', label: 'Données personnelles', legalBasis: 'nLPD art. 5 let. a / RGPD art. 4', examples: 'identité, coordonnées, date de naissance, numéro AVS' }, { key: 'personnel-sensible', label: 'Données personnelles sensibles', legalBasis: 'nLPD art. 5 let. c / RGPD art. 9', examples: 'opinions religieuses, philosophiques, politiques ou syndicales, origine raciale ou ethnique, sphère intime, données génétiques ou biométriques, mesures d\'aide sociale' }, { key: 'sante', label: 'Santé', legalBasis: 'nLPD art. 5 let. c / RGPD art. 9', examples: 'diagnostic, certificat médical, motif d\'absence maladie, handicap, grossesse' }, { key: 'juridique', label: 'Juridique', legalBasis: 'nLPD art. 5 let. c (poursuites et sanctions) / secret professionnel', examples: 'poursuites ou sanctions pénales et administratives, dossier disciplinaire, litiges, casier' }, { key: 'rh', label: 'Ressources humaines', legalBasis: 'CO art. 328b / nLPD', examples: 'contrat de travail, évaluation, entretien annuel, motifs d\'absence, mesures RH' }, { key: 'financier', label: 'Financier', legalBasis: 'CO art. 958f / secret d\'affaires', examples: 'rémunération, fiche de paie, coordonnées bancaires (IBAN), comptabilité' }, { key: 'commercial', label: 'Secret d\'affaires', legalBasis: 'LCD art. 6 / secret d\'affaires', examples: 'marges, prix d\'achat, conditions fournisseurs, pipeline stratégique' }, { key: 'securite', label: 'Sécurité', legalBasis: 'OWASP / secret technique', examples: 'mots de passe, clés, jetons, secrets — ne devraient JAMAIS être dans une entité métier (le socle les protège via IDataProtector)' }, ] /** The Zod SSOT every spec / pagespec carrier imports — never re-typed. */ export const EntitySensitivitySchema = z .object({ level: z.enum(SENSITIVITY_LEVELS), categories: z.array(z.enum(SENSITIVITY_CATEGORY_KEYS)).default([]), }) .passthrough() export type EntitySensitivity = { level: SensitivityLevel; categories: SensitivityCategory[] } export function rankOf(level: SensitivityLevel): number { return SENSITIVITY_LEVEL_ENTRIES.find((e) => e.level === level)?.rank ?? 1 } /** ≥ `confidentiel` — the threshold every guardrail keys on. */ export function isSensitive(s: { level: SensitivityLevel } | SensitivityLevel | null | undefined): boolean { const level = typeof s === 'string' ? s : s?.level return level !== undefined && rankOf(level) >= rankOf('confidentiel') } export function isRestricted(s: { level: SensitivityLevel } | SensitivityLevel | null | undefined): boolean { const level = typeof s === 'string' ? s : s?.level return level === 'restreint' } export function isPublic(s: { level: SensitivityLevel } | SensitivityLevel | null | undefined): boolean { const level = typeof s === 'string' ? s : s?.level return level === 'public' } // --------------------------------------------------------------------------- // The bullet — line test + RHS parser // --------------------------------------------------------------------------- /** The bullet label, as a line test (FR + EN label). */ export const SENSITIVITY_LINE_RE = /^[-*]\s*\*\*\s*(?:sensibilit[eé]|sensitivity)\s*\*\*\s*:/i /** True for a `**Sensibilité**` bullet line — the near-miss scanners skip it * (its `décision client du …` facet is not a decided-code declaration). */ export function isSensitivityLine(line: string): boolean { return SENSITIVITY_LINE_RE.test(line.trim()) } /** NFD-strip accents + lowercase + trim (comparisons only; local on purpose). */ function fold(s: string): string { return s .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/’/g, "'") .toLowerCase() .trim() } const LEVEL_ALIASES: Record = { public: 'public', publique: 'public', publiques: 'public', interne: 'interne', internes: 'interne', internal: 'interne', confidentiel: 'confidentiel', confidentielle: 'confidentiel', confidentiels: 'confidentiel', confidentielles: 'confidentiel', confidential: 'confidentiel', restreint: 'restreint', restreinte: 'restreint', restreints: 'restreint', restreintes: 'restreint', restricted: 'restreint', } const CATEGORY_ALIASES: Record = { 'personnel': 'personnel', 'personnelles': 'personnel', 'personnelle': 'personnel', 'personal': 'personnel', 'donnees personnelles': 'personnel', 'pii': 'personnel', 'personnel-sensible': 'personnel-sensible', 'personnel sensible': 'personnel-sensible', 'personnelles sensibles': 'personnel-sensible', 'donnees personnelles sensibles': 'personnel-sensible', 'sensitive-personal': 'personnel-sensible', 'special': 'personnel-sensible', 'sante': 'sante', 'health': 'sante', 'medical': 'sante', 'medicale': 'sante', 'juridique': 'juridique', 'legal': 'juridique', 'penal': 'juridique', 'rh': 'rh', 'hr': 'rh', 'ressources humaines': 'rh', 'human resources': 'rh', 'financier': 'financier', 'financiere': 'financier', 'financieres': 'financier', 'financiers': 'financier', 'finance': 'financier', 'financial': 'financier', 'commercial': 'commercial', 'commerciale': 'commercial', 'commerciales': 'commercial', 'secret d\'affaires': 'commercial', 'business': 'commercial', 'securite': 'securite', 'security': 'securite', 'secret': 'securite', 'secrets': 'securite', } export interface SensitivityDecision { /** `AAAA-MM-JJ` — null when the mention carries no real date (the near-miss). */ date: string | null /** Free text after the `:` — null when absent. */ reason: string | null } export interface ParsedSensitivity { /** Closed vocabulary — null when the first segment is not a known level. */ level: SensitivityLevel | null /** The first segment verbatim (diagnostics). */ levelRaw: string categories: SensitivityCategory[] /** Tokens that matched no known category — surfaced, never silently dropped. */ unknownCategories: string[] /** The `décision client` facet — null when the line carries no such mention. */ decision: SensitivityDecision | null /** A « décision client » mention WITHOUT a date: the DM-022 precedent, an err. */ decisionUndated: boolean } const DECISION_RE = /d[ée]cision\s+client(?:\s+du\s+(\d{4}-\d{2}-\d{2}))?\s*(?::\s*(.*))?$/i /** * Parse the RIGHT-HAND SIDE of the bullet — the shape `lib/ba-entities.ts` * hands over. Segments are split on ` — ` / ` – ` / ` - ` like the * `**API externe**` line: ` — — décision client du …`. */ export function parseSensitivityValue(rhs: string): ParsedSensitivity { const segments = rhs .trim() .split(/[—–]|(?:\s-\s)/) .map((s) => s.trim()) .filter((s) => s !== '') const levelRaw = segments[0] ?? '' const level = LEVEL_ALIASES[fold(levelRaw).replace(/\.$/, '')] ?? null const categories: SensitivityCategory[] = [] const unknownCategories: string[] = [] let decision: SensitivityDecision | null = null let decisionUndated = false for (const seg of segments.slice(1)) { const d = DECISION_RE.exec(seg) if (d) { const date = d[1] ?? null const reason = (d[2] ?? '').trim() || null decision = { date, reason } if (date === null) decisionUndated = true continue } for (const token of seg.split(/[,;/]/).map((t) => t.trim()).filter(Boolean)) { const key = fold(token).replace(/\.$/, '') const cat = CATEGORY_ALIASES[key] if (cat) { if (!categories.includes(cat)) categories.push(cat) } else { unknownCategories.push(token) } } } return { level, levelRaw, categories, unknownCategories, decision, decisionUndated } } // --------------------------------------------------------------------------- // Resolution ladder — entity → aggregate root (composant) → document → default // --------------------------------------------------------------------------- /** The slice of `BaEntity` the resolver reads (structural on purpose). */ export interface SensitivityEntityLike { name: string classification?: string /** The `**Sensibilité**` RHS verbatim — entity line, else document line, else null. */ sensitivityRaw: string | null sensitivity: ParsedSensitivity | null /** Which line `sensitivityRaw` came from — null when none. */ sensitivitySource: 'entity' | 'document' | null /** The `**Personne**` RHS verbatim — null when absent. */ person: string | null relations: readonly { targetEntity: string; cardinality: string; scope: string }[] } export interface ResolvedSensitivity extends EntitySensitivity { /** Where the level came from. `declared` is true for 'entity' | 'document'. */ source: 'entity' | 'document' | 'root' | 'default' declared: boolean /** The root entity a composant inherited from (source 'root'), else null. */ inheritedFrom: string | null /** The dated decision carried by the DECLARING line, else null. */ decision: SensitivityDecision | null /** True when a line exists but its level is not in the vocabulary — the near-miss. */ nearMiss: boolean } const COMPOSANT_RE = /composant|component/i const ROOT_RE = /agr[ée]gat\s+racine|aggregate\s+root|racine/i const OWNING_CARDINALITY_RE = /^\s*(?:\*|1)\s*(?:→|->)\s*1\s*$/ function personImpliesPersonnel(person: string | null): boolean { if (!person) return false const head = fold(person).split(/[—\-–(:]/)[0]?.trim() ?? '' return head === 'mandatory' || head === 'optional' } function withPersonnel(categories: SensitivityCategory[], entity: SensitivityEntityLike): SensitivityCategory[] { if (!personImpliesPersonnel(entity.person) || categories.includes('personnel')) return categories return [...categories, 'personnel'] } function ownDeclaration(entity: SensitivityEntityLike): ResolvedSensitivity | null { if (entity.sensitivityRaw === null || entity.sensitivitySource === null) return null const parsed = entity.sensitivity ?? parseSensitivityValue(entity.sensitivityRaw) const level = parsed.level ?? DEFAULT_SENSITIVITY_LEVEL return { level, categories: withPersonnel([...parsed.categories], entity), source: entity.sensitivitySource, declared: true, inheritedFrom: null, decision: parsed.decision, nearMiss: parsed.level === null, } } /** * The resolution ladder. `siblings` = the other entities of the SAME * document (a composant inherits its aggregate root's level — one hop, the * root's own declaration only, never recursive). Pure. */ export function resolveEntitySensitivity( entity: SensitivityEntityLike, siblings: readonly SensitivityEntityLike[] = [], ): ResolvedSensitivity { const own = ownDeclaration(entity) if (own && own.source === 'entity') return own // A composant without its own line inherits its aggregate root's level — // the root's ENTITY line beats the document default, which beats nothing. if (COMPOSANT_RE.test(entity.classification ?? '')) { const owning = entity.relations.find( (r) => r.scope === 'same-module' && OWNING_CARDINALITY_RE.test(r.cardinality), ) const root = owning ? siblings.find((s) => s.name === owning.targetEntity && s !== entity) : undefined if (root && ROOT_RE.test(root.classification ?? '')) { const rootOwn = ownDeclaration(root) if (rootOwn && rootOwn.source === 'entity') { return { level: rootOwn.level, categories: withPersonnel([...rootOwn.categories], entity), source: 'root', declared: false, inheritedFrom: root.name, decision: null, nearMiss: false, } } } } if (own) return own // the document default return { level: DEFAULT_SENSITIVITY_LEVEL, categories: withPersonnel([], entity), source: 'default', declared: false, inheritedFrom: null, decision: null, nearMiss: false, } } // --------------------------------------------------------------------------- // Triggers — what LOOKS sensitive (DM-036 / DM-037, the lookup-label guard) // --------------------------------------------------------------------------- export interface SensitiveTrigger { /** Whole-token word (compound words joined without separator are tried too). */ word: string category: SensitivityCategory /** The least level the data this word names deserves. */ minLevel: SensitivityLevel } /** * Attribute-name triggers — whole-token over `splitWords`, accent-folded, * plus the joined compound (`BankAccount` → `bankaccount`). Deliberately * NOT in the list: bare `bonus`, `birth` / `naissance`, `contract`, `email` * — too frequent, and ordinary `personnel` data the `**Personne**` line * already classifies. */ export const SENSITIVE_ATTRIBUTE_TRIGGERS: readonly SensitiveTrigger[] = [ // financier — rémunération, bancaire, paie { word: 'salary', category: 'financier', minLevel: 'confidentiel' }, { word: 'salaire', category: 'financier', minLevel: 'confidentiel' }, { word: 'remuneration', category: 'financier', minLevel: 'confidentiel' }, { word: 'wage', category: 'financier', minLevel: 'confidentiel' }, { word: 'payroll', category: 'financier', minLevel: 'confidentiel' }, { word: 'paie', category: 'financier', minLevel: 'confidentiel' }, { word: 'iban', category: 'financier', minLevel: 'confidentiel' }, { word: 'bic', category: 'financier', minLevel: 'confidentiel' }, { word: 'bankaccount', category: 'financier', minLevel: 'confidentiel' }, { word: 'comptebancaire', category: 'financier', minLevel: 'confidentiel' }, { word: 'avs', category: 'personnel', minLevel: 'confidentiel' }, { word: 'ahv', category: 'personnel', minLevel: 'confidentiel' }, { word: 'ssn', category: 'personnel', minLevel: 'confidentiel' }, // sante { word: 'health', category: 'sante', minLevel: 'restreint' }, { word: 'sante', category: 'sante', minLevel: 'restreint' }, { word: 'medical', category: 'sante', minLevel: 'restreint' }, { word: 'diagnostic', category: 'sante', minLevel: 'restreint' }, { word: 'diagnosis', category: 'sante', minLevel: 'restreint' }, { word: 'handicap', category: 'sante', minLevel: 'restreint' }, { word: 'disability', category: 'sante', minLevel: 'restreint' }, { word: 'maladie', category: 'sante', minLevel: 'restreint' }, { word: 'grossesse', category: 'sante', minLevel: 'restreint' }, { word: 'pregnancy', category: 'sante', minLevel: 'restreint' }, // personnel-sensible — nLPD art. 5 let. c { word: 'religion', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'ethnic', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'ethnie', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'political', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'politique', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'syndicat', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'union', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'sexual', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'genetic', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'genetique', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'biometric', category: 'personnel-sensible', minLevel: 'restreint' }, { word: 'biometrique', category: 'personnel-sensible', minLevel: 'restreint' }, // juridique { word: 'criminal', category: 'juridique', minLevel: 'restreint' }, { word: 'penal', category: 'juridique', minLevel: 'restreint' }, { word: 'conviction', category: 'juridique', minLevel: 'restreint' }, { word: 'condamnation', category: 'juridique', minLevel: 'restreint' }, { word: 'sanction', category: 'juridique', minLevel: 'restreint' }, { word: 'disciplinaire', category: 'juridique', minLevel: 'restreint' }, { word: 'disciplinary', category: 'juridique', minLevel: 'restreint' }, { word: 'casier', category: 'juridique', minLevel: 'restreint' }, { word: 'litige', category: 'juridique', minLevel: 'restreint' }, // securite — never in a business entity { word: 'password', category: 'securite', minLevel: 'restreint' }, { word: 'passwordhash', category: 'securite', minLevel: 'restreint' }, { word: 'motdepasse', category: 'securite', minLevel: 'restreint' }, { word: 'secret', category: 'securite', minLevel: 'restreint' }, { word: 'apikey', category: 'securite', minLevel: 'restreint' }, { word: 'token', category: 'securite', minLevel: 'restreint' }, { word: 'privatekey', category: 'securite', minLevel: 'restreint' }, { word: 'pin', category: 'securite', minLevel: 'restreint' }, ] /** * Entity-name triggers — the names a confidential satellite typically * carries. Whole-token / joined-compound / singular-tolerant, never * substring. Bare `Evaluation`, `Contract`, `Account` are NOT here (too * broad); the compound forms are. */ export const SENSITIVE_ENTITY_NAME_TRIGGERS: readonly SensitiveTrigger[] = [ { word: 'Salary', category: 'financier', minLevel: 'confidentiel' }, { word: 'Salaire', category: 'financier', minLevel: 'confidentiel' }, { word: 'Remuneration', category: 'financier', minLevel: 'confidentiel' }, { word: 'Payroll', category: 'financier', minLevel: 'confidentiel' }, { word: 'Paie', category: 'financier', minLevel: 'confidentiel' }, { word: 'Payslip', category: 'financier', minLevel: 'confidentiel' }, { word: 'BulletinPaie', category: 'financier', minLevel: 'confidentiel' }, { word: 'FichePaie', category: 'financier', minLevel: 'confidentiel' }, { word: 'BankAccount', category: 'financier', minLevel: 'confidentiel' }, { word: 'CompteBancaire', category: 'financier', minLevel: 'confidentiel' }, { word: 'MedicalRecord', category: 'sante', minLevel: 'restreint' }, { word: 'DossierMedical', category: 'sante', minLevel: 'restreint' }, { word: 'CertificatMedical', category: 'sante', minLevel: 'restreint' }, { word: 'Sanction', category: 'juridique', minLevel: 'restreint' }, { word: 'Disciplinary', category: 'juridique', minLevel: 'restreint' }, { word: 'Disciplinaire', category: 'juridique', minLevel: 'restreint' }, { word: 'Litige', category: 'juridique', minLevel: 'restreint' }, { word: 'Litigation', category: 'juridique', minLevel: 'restreint' }, { word: 'CasierJudiciaire', category: 'juridique', minLevel: 'restreint' }, { word: 'Appraisal', category: 'rh', minLevel: 'confidentiel' }, { word: 'PerformanceReview', category: 'rh', minLevel: 'confidentiel' }, { word: 'EntretienAnnuel', category: 'rh', minLevel: 'confidentiel' }, { word: 'EvaluationAnnuelle', category: 'rh', minLevel: 'confidentiel' }, { word: 'EmploymentContract', category: 'rh', minLevel: 'confidentiel' }, { word: 'ContratTravail', category: 'rh', minLevel: 'confidentiel' }, { word: 'ContratDeTravail', category: 'rh', minLevel: 'confidentiel' }, ] /** Probe forms of a name: each word, each adjacent word PAIR joined * (`BankAccountNumber` → `bankaccount`), the whole joined compound — * singular-tolerant, all folded. Never a substring inside a word. */ function probeForms(candidate: string): Set { const out = new Set() const words = splitWords(candidate).map(fold) const add = (form: string): void => { if (form === '') return out.add(form) out.add(singularize(form)) } for (const w of words) add(w) for (let i = 0; i + 1 < words.length; i++) add(`${words[i]}${words[i + 1]}`) add(words.join('')) return out } /** Whole-token match of an ATTRIBUTE name against the attribute triggers (`Secretary` ≠ `secret`). */ export function matchSensitiveAttribute(attributeName: string): SensitiveTrigger | undefined { const forms = probeForms(attributeName) return SENSITIVE_ATTRIBUTE_TRIGGERS.find((t) => forms.has(fold(t.word))) } /** Same discipline over an ENTITY name (`EmployeeSalary`, `Salaires`, `DossierMedical` → hit; `SalesOrder` → no). */ export function matchSensitiveEntityName(entityName: string): SensitiveTrigger | undefined { const forms = probeForms(entityName) return SENSITIVE_ENTITY_NAME_TRIGGERS.find((t) => forms.has(fold(t.word))) } /** `FK`, `clé étrangère`, `foreign key` or a `→ Target` arrow in a Contraintes cell. */ const REFERENCE_CONSTRAINT_RE = /\bFK\b|cl[ée]\s+[ée]trang[èe]re|foreign\s+key|→/i /** * A REFERENCE column — a FK (`FichePaieId` Guid, `FK → FichePaie`, the column * a `*→1` relation names) — never carries the value it points at: its name * repeats the TARGET's, and the target is judged on its own line. Without * this, every consumer of a `*Paie*` / `*Salaire*` / `*Iban*` entity tripped * DM-036 on a module storing nothing but a Guid (end-to-end replay of * 2026-10-03) — the very case CODE-009 / RBAC-012 already cover. */ export function isReferenceAttribute( a: { name: string; type?: string; constraints?: string }, referenceColumns: ReadonlySet = new Set(), ): boolean { if (referenceColumns.has(fold(a.name))) return true if (a.constraints !== undefined && REFERENCE_CONSTRAINT_RE.test(a.constraints)) return true return fold(a.type ?? '').startsWith('guid') && /Id$/.test(a.name) && fold(a.name) !== 'id' } /** * The attributes of an entity that trip a trigger — each with the trigger that * matched. Reference columns are skipped (`isReferenceAttribute`); pass the FK * names the entity's relations declare so a non-Guid FK is skipped too. */ export function sensitiveAttributesOf( attributes: readonly { name: string; type?: string; constraints?: string }[], referenceColumns: readonly string[] = [], ): Array<{ attribute: string; trigger: SensitiveTrigger }> { const refs = new Set(referenceColumns.map(fold)) const out: Array<{ attribute: string; trigger: SensitiveTrigger }> = [] for (const a of attributes) { if (isReferenceAttribute(a, refs)) continue const trigger = matchSensitiveAttribute(a.name) if (trigger) out.push({ attribute: a.name, trigger }) } return out } // --------------------------------------------------------------------------- // Renderers — the ONE remedy wording + the drift-locked vocabulary table // --------------------------------------------------------------------------- /** Render the canonical bullet — the ONE remedy wording. */ export function renderSensitivityLine( level: SensitivityLevel, categories: readonly SensitivityCategory[] = [], decision?: { date: string; reason?: string | null } | null, ): string { const cats = SENSITIVITY_CATEGORY_KEYS.filter((k) => categories.includes(k)) const catPart = cats.length > 0 ? ` — ${cats.join(', ')}` : '' const decisionPart = decision ? ` — décision client du ${decision.date}${decision.reason ? ` : ${decision.reason}` : ''}` : '' return `- **Sensibilité** : ${level}${catPart}${decisionPart}` } /** Short human form `confidentiel — rh, personnel` (evidence, blocks, badges). */ export function formatSensitivity(s: EntitySensitivity): string { return s.categories.length > 0 ? `${s.level} — ${s.categories.join(', ')}` : s.level } /** * The vocabulary tables carried inline by the docs (`sensitivity-vocab:v1` * marker) — `lib/__tests__/sensitivity-vocab-drift.test.ts` pins every * carrier to this rendering byte for byte. */ export function renderSensitivityVocabTable(): string { const levels = [ '| Niveau | Rang | Signification |', '|---|---|---|', ...SENSITIVITY_LEVEL_ENTRIES.map((e) => `| \`${e.level}\` | ${e.rank} | ${e.meaning} |`), ] const categories = [ '| Catégorie | Libellé | Base légale | Exemples |', '|---|---|---|---|', ...SENSITIVITY_CATEGORIES.map((c) => `| \`${c.key}\` | ${c.label} | ${c.legalBasis} | ${c.examples} |`), ] return [...levels, '', ...categories].join('\n') }