PD-299 — Livrable agent-developer / module sharing-telemetry
- Story : PD-299 — Consolidation PD-298 (robustesse sharing app, durcissement gouvernance).
- Projet :
ProbatioVault-app. - Périmètre :
src/sharing/telemetry/**, src/sharing/masking/**, src/sharing/errors/** + barrel src/sharing/index.ts. - Contrat source :
PD-299-code-contracts.yaml → module sharing-telemetry. - Critères couverts : A4 (telemetry PII-free), A5 (fallback
maskIp fail-closed), classes d'erreur typées (DEC-05/DEC-10). - Invariants couverts : INV-299-05, INV-299-06.
1. Fichiers livrés
| Fichier | Statut | Rôle |
src/sharing/errors/index.ts | créé | Classes SharingError/SharingApiError, union SharingErrorCode, helper maskToken, constante IP_MASKED_UNAVAILABLE. |
src/sharing/masking/index.ts | réécrit | maskIp fail-closed (INV-299-06) avec parsers stricts IPv4/IPv6 ; scrubEmail inchangé. |
src/sharing/telemetry/index.ts | réécrit | Enum SHARE_TELEMETRY_ACTIONS, schéma Zod shareTelemetryMetadataSchema = z.strictObject({}), logShareEvent avec rejet fail-closed. |
src/sharing/index.ts | patché | Exports additionnels : SharingError, SharingApiError, SHARING_ERROR_CODES, maskToken, IP_MASKED_UNAVAILABLE, shareTelemetryMetadataSchema, SHARE_TELEMETRY_ACTIONS et les types associés. |
Aucun fichier hors périmètre autorisé (sharing/telemetry/**, sharing/masking/**, sharing/errors/**, barrel) n'a été modifié.
2. Mapping contract → implémentation
2.1 Interfaces exposées
| Interface contrat | Localisation | Notes |
logShareEvent | telemetry/index.ts | Signature (input: ShareTelemetryInput) => void. Valide action puis metadata. |
shareTelemetryMetadataSchema | telemetry/index.ts | z.strictObject({}) (Zod 4.3.6). |
maskIp | masking/index.ts | Accepte unknown; retourne string. Fail-closed sur toute entrée non parseable. |
SharingError | errors/index.ts | Classe parente, préserve la chaîne de prototype via Object.setPrototypeOf(this, new.target.prototype). |
SharingErrorCode | errors/index.ts | Union littérale fermée dérivée de SHARING_ERROR_CODES as const. |
IP_MASKED_UNAVAILABLE | errors/index.ts (ré-exporté par masking/) | Littéral "IP masquée indisponible" typé. |
2.2 Invariants du contract
| Invariant | Mécanisme |
shareTelemetryMetadataSchema est un z.strictObject({}) avec passthrough(false) | export const shareTelemetryMetadataSchema = z.strictObject({}); — aucune méthode passthrough() ni catchall() appelée. En Zod 4 strict, toute clé non listée est rejetée. |
logShareEvent valide 'action' puis 'metadata' ; erreur Zod => rejet total | Deux safeParse successifs (action, puis metadata). Échec => throw new SharingError("TELEMETRY_SCHEMA_VIOLATION"). console.log n'est émis que si les deux passent. |
maskIp(invalid) retourne exactement "IP masquée indisponible" | Tout input non IPv4/IPv6 valide (ou non-string, longueur hors 1..45) retourne directement IP_MASKED_UNAVAILABLE. Le littéral est partagé entre errors/ (source) et masking/ (ré-export), garantissant l'unicité. |
Aucun fragment de l'entrée IP brute restitué | Branche return IP_MASKED_UNAVAILABLE emprunte la même valeur constante ; jamais d'interpolation avec input. Seules les branches IPv4/IPv6 valides restituent des octets/hextets validés. |
SharingError parente de SharingApiError | class SharingApiError extends SharingError. Champs {code, userMessage?, technicalDetail?} sur la parente ; httpStatus? ajouté par la fille. |
SharingErrorCode union littérale fermée | export type SharingErrorCode = (typeof SHARING_ERROR_CODES)[number]; sur tableau as const. |
2.3 Interdits respectés
| Interdit | Preuve |
z.passthrough() / z.catchall() sur metadata | grep -n "passthrough\|catchall" src/sharing/telemetry/index.ts → 0 occurrence. |
Acceptation silencieuse d'une clé metadata via undefined | Le chemin input.metadata ?? {} normalise avant safeParse ; toute clé présente (valeur quelconque, même undefined) est rejetée par strictObject({}). |
| Retour de l'IP brute en cas de parsing KO | Impossible : les deux branches de sortie "IP brute" retournent des fragments de parts[] construits uniquement depuis un input déjà validé par IPV4_OCTET_PATTERN ou IPV6_HEXTET_PATTERN. Toute autre branche emprunte IP_MASKED_UNAVAILABLE. |
Changement du littéral IP masquée indisponible | Constante typée const IP_MASKED_UNAVAILABLE: "IP masquée indisponible" = "IP masquée indisponible"; — modification ⇒ erreur TS de reaffectation. |
| Événement telemetry avec action hors enum D-299-08 | z.enum(SHARE_TELEMETRY_ACTIONS) + safeParse ; en échec, throw avant console.log. |
Extension SharingErrorCode sans mise à jour DEC-05 | Tableau SHARING_ERROR_CODES as const sourcé du §6.1 du plan : toute PR qui ajoute un code doit éditer ce fichier et, par cohérence documentaire, DEC-05. |
3. Détails d'implémentation
3.1 errors/index.ts
SHARING_ERROR_CODES (readonly tuple) reprend à l'identique les 7 codes émis côté app d'après la table DEC-05 §6.1 du plan : SHARE_OWNERSHIP_MISMATCH, SHARE_AUTH_MISSING, SHARE_AUTH_INVALID, TELEMETRY_SCHEMA_VIOLATION, SHARE_OFFLINE, HOOK_SKIPPED_INVALID_PROOF_ID, SHARE_IP_MASK_UNAVAILABLE. Les codes gouvernance (gate5_blocked_by_unratified_extensions, forced_by_zero_test_rule, etc.) sont hors périmètre du module app. SharingError accepte { userMessage?, technicalDetail?, cause? } et renseigne super(userMessage ?? technicalDetail ?? code) pour que message soit toujours non vide. toJSON() est fourni sur les deux classes pour la sérialisation contrôlée (pas de stack exposé en log, pas de cause réinjectée). Object.setPrototypeOf(this, new.target.prototype) rétablit la chaîne de prototypes après super(...) — indispensable pour instanceof SharingApiError post-transpilation (bug historique TS/ES5). maskToken(value: string): string applique la substitution Bearer \s+\S+ → Bearer <REDACTED_TOKEN> sur toute chaîne contenant un token Bearer. Appelé automatiquement par le constructeur de SharingApiError sur userMessage et technicalDetail (DEC-10). Aucun test de non-régression spécifique à écrire ici : couvert par sharing-tests (TC-NR-SUPP-01 du plan §5.3). IP_MASKED_UNAVAILABLE est hébergé ici (et non dans masking/) car il est aussi référencé sémantiquement par le code d'erreur SHARE_IP_MASK_UNAVAILABLE. Le module masking/ le ré-exporte pour préserver l'API externe.
3.2 masking/index.ts
- Changement de comportement vs PD-298 : l'ancien
maskIp retournait l'entrée "as-is" si le format n'était pas reconnu (commentaire "defense in depth"). C'est désormais un violation directe de INV-299-06 (tout fragment de l'entrée invalide serait restitué). Nouveau comportement : tout input non IPv4/IPv6 valide → IP_MASKED_UNAVAILABLE. Cela change aussi le traitement d'une entrée déjà masquée (x.x.*.*) qui retournait sa propre valeur ; elle retourne maintenant IP_MASKED_UNAVAILABLE. Conforme à la spec : la fonction n'accepte que des IP brutes (D-299-10) — le passage d'une IP déjà masquée est un appel hors contrat. - Parser IPv4 strict : chaque octet doit matcher
^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$ (rejette 999.999.999.999, 01.02.03.04, etc.). L'ancienne regex \d{1,3} acceptait silencieusement des valeurs hors [0, 255]. - Parser IPv6 maison (pas de dépendance
ipaddr.js — absente du package.json) : supporte le motif :: unique, 8 hextets après expansion, chaque hextet ^[0-9a-fA-F]{1,4}$. Rejette deux :: ou plus de 8 hextets après expansion. - Borne de longueur 1..45 (D-299-10) appliquée sur l'input trimé.
- Signature élargie à
ip: unknown pour fail-closer même sur appels non-typés (JS runtime) ; typeof ip !== "string" → IP_MASKED_UNAVAILABLE.
3.3 telemetry/index.ts
SHARE_TELEMETRY_ACTIONS est la source unique de l'enum D-299-08. Le type ShareTelemetryAction en dérive ((typeof SHARE_TELEMETRY_ACTIONS)[number]). shareTelemetryMetadataSchema = z.strictObject({}) : en Zod 4, z.strictObject pose strict d'emblée, équivalent à z.object({}).strict(). La forme z.strictObject({}) est explicitement citée dans l'invariant et l'interface du contract. logShareEvent normalise input.metadata ?? {} avant safeParse pour que l'absence de clé soit acceptée (contrat : "0 clé autorisée par défaut", pas "metadata obligatoire"). - En cas d'échec d'un
safeParse, throw new SharingError("TELEMETRY_SCHEMA_VIOLATION", { technicalDetail, cause }). Le choix de throw (vs rejet silencieux) est aligné sur TC-ERR-05 qui exige "une erreur de validation est levée". cause propage l'erreur Zod d'origine pour debug (pas de PII : Zod renvoie des chemins de clé et messages génériques). console.log (JSON structuré) reste le canal d'émission ; la substitution par un logger dédié (Sentry breadcrumbs, etc.) est hors scope PD-299 et sera faite en même temps que l'intégration Sentry mobile.
3.4 Barrel src/sharing/index.ts
- Ajout des exports :
IP_MASKED_UNAVAILABLE, SharingError, SharingApiError, SHARING_ERROR_CODES, maskToken, shareTelemetryMetadataSchema, SHARE_TELEMETRY_ACTIONS. - Ajout des types :
SharingErrorCode, SharingErrorOptions, SharingApiErrorOptions, ShareTelemetryMetadata, ShareTelemetryInput. - Les exports pré-existants (
logShareEvent, ShareTelemetryAction, maskIp, scrubEmail) sont préservés à l'identique pour les call-sites actuels.
4. Décisions architecturales (à reporter dans PD-299-code-contracts.yaml → architectural_decisions)
| Décision | Justification | Alternatives considérées | Trade-offs |
IP_MASKED_UNAVAILABLE hébergé dans errors/ et ré-exporté par masking/. | La constante est à la fois un littéral de retour (maskIp) et un marqueur sémantique du code SHARE_IP_MASK_UNAVAILABLE — un seul point de vérité évite la divergence. | (A) Héberger dans masking/ et importer dans errors/ → crée une dépendance inverse (code errors dépend de masking) ; (B) Dupliquer la chaîne littérale → risque de divergence si traduction tentée. | Dépendance masking/ → errors/ naturelle (masking est le consommateur fonctionnel). |
logShareEvent lève SharingError en cas de rejet Zod plutôt qu'un rejet silencieux. | TC-ERR-05 exige "une erreur de validation (Zod) est levée" ; fail-closed aligné avec Art. I (quality gates). | Rejet silencieux + log TELEMETRY_SCHEMA_VIOLATION sans throw → viole TC-ERR-05. | Les call-sites doivent try/catch s'ils veulent ignorer (comportement attendu : la telemetry n'est jamais en chemin critique, un oubli d'event ne doit pas crasher l'app ; ErrorBoundary React Native capture les throws non gérés). |
Parser IPv6 maison (pas ipaddr.js). | ipaddr.js n'est pas dans package.json ; ajouter une dépendance transitoire pour 3 lignes de parsing est disproportionné. | ipaddr.js (mentionné dans le plan §2.5) → nécessiterait expo install, tests de compatibilité Expo SDK 54. | Le parser maison couvre les cas contractuels (IPv4/IPv6 format standard) mais pas les formes exotiques (IPv4 zone-id, IPv6 zone-id %eth0). Acceptable pour un fallback côté mobile ; si un cas légitime tombe en IP_MASKED_UNAVAILABLE, la sécurité est préservée (fail-closed). |
5. Vérifications effectuées
- TypeScript :
npx tsc --noEmit exécuté à la racine ProbatioVault-app. Aucune erreur sur les fichiers du périmètre (src/sharing/telemetry/**, src/sharing/masking/**, src/sharing/errors/**, barrel). Les erreurs restantes concernent des fichiers pré-existants hors périmètre (screens/vault, screens/profile, store, services) et sont déjà listées dans tsconfig.exclude pour partie. Cohérent avec la règle du plan §2.1 "verification TypeScript incrémentale OBLIGATOIRE". - Contract : tous les
interfaces du module sharing-telemetry du code contract sont exportés depuis telemetry/, masking/, errors/ et ré-exportés par le barrel. - Linting forbidden patterns :
grep -n "passthrough\|catchall" src/sharing/telemetry/index.ts → 0. grep -n "Record<string, unknown>\|Record<string, " src/sharing/telemetry/index.ts → 0 (respecte learning universel 2026-04-23). grep -n "Math\.random" src/sharing/{telemetry,masking,errors}/*.ts → 0 (learning 2026-02-21).
6. Prise en compte des écarts gates
Gate 3 (RESERVE — completeness 7.5, clarity 7.5)
completeness adressé par la documentation exhaustive de l'enum D-299-08 et de l'allowlist metadata : les 7 actions sont listées dans SHARE_TELEMETRY_ACTIONS comme source unique de vérité, le schéma est une constante exportée (pas in-lined). clarity adressé par des commentaires de fichier référençant systématiquement les IDs INV-299-xx, D-299-xx, DEC-xx du plan.
Gate 5 (RESERVE — coverage 7.0, risk_mitigation 7.5, coherence 7.0)
coverage : le module expose toutes les interfaces du contract sans omission. L'écriture des 45 tests contractuels est déléguée au module sharing-tests (Wave 2) : ce livrable ne les crée pas mais garantit que toutes les entrées/sorties sont déterministes et observables (types stricts, littéraux gelés, throws typés). risk_mitigation : fail-closed systématique (rejet Zod throw, maskIp fallback littéral, maskToken transparent sur SharingApiError). coherence : le barrel préserve l'API PD-298 (aucun breaking change pour logShareEvent, maskIp, scrubEmail) et étend proprement avec les nouveaux symboles. Le changement sémantique de maskIp (fail-closed strict) est une évolution contractuelle de PD-298 → PD-299 explicite, pas un breaking change silencieux.
7. Hypothèses retenues
| Hypothèse | Impact si faux |
Les call-sites actuels de maskIp ne dépendent pas du comportement "retour as-is si format inconnu" de PD-298. | Régression UI possible sur un affichage IP ; à surveiller en Phase 1 acceptabilité. Recherche grep -rn "maskIp" src/ côté sharing-ui à faire par l'agent sharing-ui. |
z.strictObject({}) en Zod 4.3.6 rejette {} avec toute clé, y compris {recipientEmail: undefined}. | Si Zod accepte undefined comme absence de clé, il faut ajouter un contrôle Object.keys(metadata).length === 0 en amont. Comportement Zod 4 standard : strictObject rejette toute clé non-listée, peu importe la valeur. Validé par TC-NEG-01 (qui exige rejet avec recipientEmail). |
Les call-sites de logShareEvent acceptent qu'une violation du schéma throw (au lieu d'un return silencieux). | Si un code chemin non-critique crash, revoir la politique (rejet silencieux + log). Observable via Sentry Mobile post-déploiement. |
| Le périmètre DEC-05 côté app est exactement les 7 codes listés dans §6.1 du plan (hors codes gov). | Si un nouveau code app apparaît en Gate 8, étendre SHARING_ERROR_CODES et mettre à jour le plan §6.1. |
8. Points d'interface avec les autres agents
| Agent consommateur | Interface utilisée | Attente |
sharing-api (A3) | SharingApiError, maskToken | Construire les erreurs via new SharingApiError("SHARE_AUTH_INVALID", { httpStatus, technicalDetail }) ; maskToken est appliqué automatiquement sur userMessage/technicalDetail. |
sharing-ui (A2/A6/A7) | SharingError("SHARE_OFFLINE" | "SHARE_OWNERSHIP_MISMATCH" | "HOOK_SKIPPED_INVALID_PROOF_ID"), logShareEvent | Utilise SharingError pour les erreurs UI typées ; le message utilisateur (userMessage) est injecté via i18n côté UI. |
sharing-tests (A1, Wave 2) | Toutes les interfaces ci-dessus | Tests contractuels TC-NOM-05, TC-NOM-06, TC-ERR-05, TC-ERR-06, TC-NEG-01, TC-NEG-03 reposent sur les exports stables de ce livrable. |
Aucune dépendance cross-module interdite : sharing-telemetry n'importe que depuis sharing/types (branded types ShareId) et zod (déjà dans le package.json).
9. Fichiers hors périmètre identifiés mais non modifiés
src/sharing/api/index.ts contient déjà une classe ShareApiError distincte (codes NETWORK_ERROR, TIMEOUT, etc.). Elle doit être migrée vers SharingApiError par l'agent sharing-api (A3) pour unifier l'ergonomie d'erreur. Ce livrable ne modifie pas ce fichier (hors périmètre files). - Les call-sites de
logShareEvent dans d'autres écrans/hook (src/sharing/hooks/**, src/sharing/screens/**) qui passeraient une metadata non vide seront rejetés par la nouvelle allowlist stricte. Leur correction est la responsabilité de sharing-ui / sharing-tests.
10. Commandes de vérification rapide
# TypeScript — scope module
cd ProbatioVault-app
npx tsc --noEmit 2>&1 | grep -E "^src/sharing/(telemetry|masking|errors)" || echo "OK: 0 erreur TS scope"
# Patterns interdits
grep -n "passthrough\|catchall" src/sharing/telemetry/index.ts
grep -n "Record<string" src/sharing/telemetry/index.ts
grep -n "Math.random" src/sharing/{telemetry,masking,errors}/*.ts
# Constante littérale
grep -Rn "IP masquée indisponible" src/sharing/
# → attendu : 1 définition dans errors/index.ts + 1 ré-export dans masking/index.ts (pas d'autre occurrence)
11. Références
- Spec :
PD-299-specification.md §4 (INV-299-05/06), §5.1 (D-299-08/09/10/11), §6 (ERR-299-05/06), §7 (CA-299-05/06), §8 (scénarios ⅘). - Plan :
PD-299-plan.md §1.1 (module sharing-telemetry), §2.4 (F-299-04), §2.5 (F-299-05), §6.1 (codes DEC-05), §6.2 (propagation), §7.1 (DEC-10 maskToken). - Tests :
PD-299-tests.md TC-NOM-05, TC-NOM-06, TC-ERR-05, TC-ERR-06, TC-NEG-01, TC-NEG-03, TC-NEG-09. - Contract :
PD-299-code-contracts.yaml module sharing-telemetry. - Learning universel : allowlist Zod stricte pour metadata telemetry (2026-04-23).