Aller au contenu

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.yamlarchitectural_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).