Aller au contenu

PD-284 — Agent Developer : Module seal-types

Livrable : src/types/seal.ts

Résumé

Ce module définit l'ensemble des types TypeScript pour la story PD-284 (UX scellement urgent et suivi temps réel). Il couvre :

  • Les branded types SealId et DocumentId (pas de string nu — invariant du code contract)
  • L'union littérale exhaustive SealState des 7 états PD-80
  • Les types d'événements SSE avec champs conditionnels par état (§5.12)
  • Les types de configuration, dégradation, artefacts expert
  • Les regex de validation pour chaque champ format (§5.6)

Décisions architecturales

architectural_decisions:
  - decision: "Branded types via intersection `string & { readonly __brand: 'X' }`"
    rationale: "Empêche l'inversion silencieuse SealId/DocumentId à la compilation. Pattern standard TypeScript sans runtime overhead."
    alternatives_considered:
      - "Opaque types via module-level Symbol  trop complexe pour l'usage"
      - "Newtype via class wrapper  overhead runtime inutile"
    trade_offs:
      - "Nécessite des fonctions factory `asSealId()` / `asDocumentId()` pour créer les valeurs"
      - "Pas de validation runtime dans le branded type lui-même (validation Zod séparée)"

Code source

// src/types/seal.ts
// PD-284 — Types TypeScript : états, événements SSE, payloads, branded types

// =============================================================================
// Branded types (INV contract : pas de string nu pour seal_id / document_id)
// =============================================================================

export type SealId = string & { readonly __brand: 'SealId' };
export type DocumentId = string & { readonly __brand: 'DocumentId' };

export function asSealId(value: string): SealId {
  return value as SealId;
}

export function asDocumentId(value: string): DocumentId {
  return value as DocumentId;
}

// =============================================================================
// SealState — Union littérale exhaustive des 7 états PD-80 (§5.7)
// =============================================================================

export const SEAL_STATES = [
  'RECEIVED',
  'QUEUED_PRIORITY',
  'TSA_PENDING',
  'TSA_SEALED',
  'ANCHOR_PENDING',
  'SEALED',
  'FAILED_TIMEOUT',
] as const;

export type SealState = (typeof SEAL_STATES)[number];

export const TERMINAL_STATES: readonly SealState[] = [
  'SEALED',
  'FAILED_TIMEOUT',
] as const;

// =============================================================================
// DegradationFlag — Valeurs contractuelles (§5.3)
// =============================================================================

export const DEGRADATION_FLAGS = ['none', 'delayed', 'critical'] as const;

export type DegradationFlag = (typeof DEGRADATION_FLAGS)[number];

// =============================================================================
// Types dédiés avec regex de validation (§5.6)
// =============================================================================

export const SEAL_VALIDATION_PATTERNS = {
  UUID_V4: /^[0-9a-fA-F-]{36}$/,
  HASH_DOCUMENT: /^[a-f0-9]{64}$/,
  TSA_TOKEN_REF: /^[A-Za-z0-9:_-]{1,256}$/,
  MERKLE_ROOT: /^[a-f0-9]{64}$/,
  MERKLE_PROOF_ELEMENT: /^[a-f0-9]{64}$/,
  BLOCKCHAIN_TX_HASH: /^0x[a-f0-9]{64}$/,
  PROOF_PACKAGE_URL: /^https:\/\/.+$/,
  EVENT_ID: /^[1-9][0-9]{0,18}$/,
  SEQUENCE_NUMBER: /^[1-9][0-9]{0,18}$/,
} as const;

// =============================================================================
// Typed wrappers pour champs format §5.6
// =============================================================================

/** SHA-256 hex, 64 chars, lowercase (§5.6) */
export type HashDocument = string & { readonly __brand: 'HashDocument' };

/** ASCII alphanumérique + `:_-`, 1..256 chars (§5.6) — artefact SENSIBLE */
export type TsaTokenRef = string & { readonly __brand: 'TsaTokenRef' };

/** Hex 64 chars lowercase (§5.6) */
export type MerkleRoot = string & { readonly __brand: 'MerkleRoot' };

/** Hex 64 chars lowercase par élément (§5.6) */
export type MerkleProofElement = string & { readonly __brand: 'MerkleProofElement' };

/** Hex préfixé 0x, 66 chars total (§5.6) */
export type BlockchainTxHash = string & { readonly __brand: 'BlockchainTxHash' };

/** URL HTTPS, 1..2048 chars (§5.6) */
export type ProofPackageUrl = string & { readonly __brand: 'ProofPackageUrl' };

/** ISO-8601 UTC timestamp (§5.6) */
export type TsaTimestamp = string & { readonly __brand: 'TsaTimestamp' };

/** Position dans la queue d'attente (entier positif) */
export type PositionInQueue = number & { readonly __brand: 'PositionInQueue' };

/** Raison d'échec du scellement (état FAILED_TIMEOUT) */
export type FailureReason = string & { readonly __brand: 'FailureReason' };

// =============================================================================
// AccountType — Enum pour l'éligibilité bouton urgent (§5.6)
// =============================================================================

export const ACCOUNT_TYPES = ['minor', 'standard', 'premium', 'enterprise'] as const;

export type AccountType = (typeof ACCOUNT_TYPES)[number];

// =============================================================================
// Événement SSE — Champs communs + champs conditionnels par état (§5.12)
// =============================================================================

/** Champs communs à TOUS les événements SSE */
interface SealEventBase {
  event_id: number;
  sequence_number: number;
  seal_id: SealId;
  status: SealState;
  timestamp: string;
  degradation_flag?: DegradationFlag;
  document_id: DocumentId;
}

/** RECEIVED : hash_document obligatoire */
interface SealEventReceived extends SealEventBase {
  status: 'RECEIVED';
  hash_document: HashDocument;
}

/** QUEUED_PRIORITY : hash_document + position_in_queue obligatoires */
interface SealEventQueuedPriority extends SealEventBase {
  status: 'QUEUED_PRIORITY';
  hash_document: HashDocument;
  position_in_queue: PositionInQueue;
}

/** TSA_PENDING : hash_document obligatoire */
interface SealEventTsaPending extends SealEventBase {
  status: 'TSA_PENDING';
  hash_document: HashDocument;
}

/** TSA_SEALED : hash_document + tsa_token_ref + tsa_timestamp obligatoires */
interface SealEventTsaSealed extends SealEventBase {
  status: 'TSA_SEALED';
  hash_document: HashDocument;
  tsa_token_ref: TsaTokenRef;
  tsa_timestamp: TsaTimestamp;
}

/** ANCHOR_PENDING : hash_document + merkle_root + merkle_proof[] obligatoires */
interface SealEventAnchorPending extends SealEventBase {
  status: 'ANCHOR_PENDING';
  hash_document: HashDocument;
  merkle_root: MerkleRoot;
  merkle_proof: MerkleProofElement[];
}

/** SEALED : tous les artefacts probatoires obligatoires */
interface SealEventSealed extends SealEventBase {
  status: 'SEALED';
  hash_document: HashDocument;
  merkle_root: MerkleRoot;
  merkle_proof: MerkleProofElement[];
  blockchain_tx_hash: BlockchainTxHash;
  proof_package_url: ProofPackageUrl;
}

/** FAILED_TIMEOUT : hash_document + failure_reason obligatoires */
interface SealEventFailedTimeout extends SealEventBase {
  status: 'FAILED_TIMEOUT';
  hash_document: HashDocument;
  failure_reason: FailureReason;
}

/** Union discriminée par `status` — couvre exhaustivement les 7 états §5.12 */
export type SealEvent =
  | SealEventReceived
  | SealEventQueuedPriority
  | SealEventTsaPending
  | SealEventTsaSealed
  | SealEventAnchorPending
  | SealEventSealed
  | SealEventFailedTimeout;

// =============================================================================
// SealStatusResponse — Réponse GET /seals/{id}/status
// =============================================================================

export type SealStatusResponse = SealEvent;

// =============================================================================
// ExpertFields — Champs affichables en mode expert par état (§5.4)
// =============================================================================

export interface ExpertFields {
  hash_document?: HashDocument;
  tsa_token_ref?: TsaTokenRef;
  tsa_timestamp?: TsaTimestamp;
  merkle_root?: MerkleRoot;
  merkle_proof?: MerkleProofElement[];
  blockchain_tx_hash?: BlockchainTxHash;
  proof_package_url?: ProofPackageUrl;
}

/** Map définissant quels champs expert sont disponibles par état */
export const EXPERT_FIELDS_BY_STATE: Record<SealState, readonly (keyof ExpertFields)[]> = {
  RECEIVED: ['hash_document'],
  QUEUED_PRIORITY: ['hash_document'],
  TSA_PENDING: ['hash_document'],
  TSA_SEALED: ['hash_document', 'tsa_token_ref', 'tsa_timestamp'],
  ANCHOR_PENDING: ['hash_document', 'merkle_root', 'merkle_proof'],
  SEALED: ['hash_document', 'merkle_root', 'merkle_proof', 'blockchain_tx_hash', 'proof_package_url'],
  FAILED_TIMEOUT: ['hash_document'],
};

// =============================================================================
// SealConfig — Paramètres numériques contractuels (§5.8)
// =============================================================================

export interface SealConfig {
  /** Max SSE reconnect attempts before polling failover (default: 3) */
  sse_reconnect_max_attempts_before_polling: number;
  /** Base delay for SSE reconnect backoff in seconds (default: 1) */
  sse_reconnect_base_delay: number;
  /** Backoff multiplier (default: 2) */
  sse_reconnect_backoff_factor: number;
  /** Max cumulative backoff delay in seconds before immediate failover (default: 30) */
  sse_reconnect_max_cumulative_delay: number;
  /** Grace window in ms for sequence reordering (default: 200) */
  sequence_reorder_grace_window: number;
  /** Polling interval in seconds after SSE failover (default: 5) */
  polling_interval_after_sse_failover: number;
  /** Push notification title max length (default: 50) */
  push_title_max_length: number;
  /** Push notification body max length (default: 100) */
  push_body_max_length: number;
}

export const DEFAULT_SEAL_CONFIG: Readonly<SealConfig> = {
  sse_reconnect_max_attempts_before_polling: 3,
  sse_reconnect_base_delay: 1,
  sse_reconnect_backoff_factor: 2,
  sse_reconnect_max_cumulative_delay: 30,
  sequence_reorder_grace_window: 200,
  polling_interval_after_sse_failover: 5,
  push_title_max_length: 50,
  push_body_max_length: 100,
};

// Bornes de clamp (§5.8 — hors bornes → clamp)
export const SEAL_CONFIG_BOUNDS: Record<keyof SealConfig, { min: number; max: number }> = {
  sse_reconnect_max_attempts_before_polling: { min: 1, max: 10 },
  sse_reconnect_base_delay: { min: 0.5, max: 5 },
  sse_reconnect_backoff_factor: { min: 1, max: 3 },
  sse_reconnect_max_cumulative_delay: { min: 5, max: 120 },
  sequence_reorder_grace_window: { min: 50, max: 1000 },
  polling_interval_after_sse_failover: { min: 2, max: 60 },
  push_title_max_length: { min: 1, max: 50 },
  push_body_max_length: { min: 1, max: 100 },
};

// =============================================================================
// Transport mode — État du canal de communication
// =============================================================================

export type TransportMode = 'SSE' | 'POLLING' | 'DISCONNECTED';

// =============================================================================
// Tooltips contractuels (INV-284-04 — texte exact, non modifiable)
// =============================================================================

export const TOOLTIP_QUOTA_EXHAUSTED = 'Quota épuisé ce mois' as const;
export const TOOLTIP_URGENT_ACTIVE = 'Scellement urgent déjà en cours' as const;

// =============================================================================
// Telemetry event types (erreurs contrôlées — §3)
// =============================================================================

export interface SealTelemetryEvent {
  event_type: string;
  seal_id: SealId | null;
  timestamp: string;
  details: Record<string, unknown>;
}

// =============================================================================
// Deep-link schema (SEC-01 — whitelist interne uniquement)
// =============================================================================

export const SEAL_DEEP_LINK_SCHEME = 'probatiovault' as const;
export const SEAL_DEEP_LINK_PATTERN = /^probatiovault:\/\/seal\/[0-9a-fA-F-]{36}$/ as const;

// =============================================================================
// Étapes visuelles de la carte de progression (mapping §2.1b du plan)
// =============================================================================

export const VISUAL_STEPS = [
  'Capture',
  'Horodatage TSA',
  'Arbre Merkle',
  'Blockchain',
  'Scellé',
] as const;

export type VisualStep = (typeof VISUAL_STEPS)[number];

export const STATE_TO_VISUAL_STEP: Record<Exclude<SealState, 'FAILED_TIMEOUT'>, { step: VisualStep; index: number; completed: boolean }> = {
  RECEIVED: { step: 'Capture', index: 0, completed: false },
  QUEUED_PRIORITY: { step: 'Capture', index: 0, completed: false },
  TSA_PENDING: { step: 'Horodatage TSA', index: 1, completed: false },
  TSA_SEALED: { step: 'Horodatage TSA', index: 1, completed: true },
  ANCHOR_PENDING: { step: 'Blockchain', index: 3, completed: false },
  SEALED: { step: 'Scellé', index: 4, completed: true },
};

Matrice de couverture invariants

Invariant contract Mécanisme dans ce module Vérifié par
SealState est une union littérale exhaustive des 7 états PD-80 SEAL_STATES tuple + SealState union type Compilation TS — ajout/suppression d'état → erreur exhaustiveness
SealId et DocumentId sont des branded types Intersection string & { __brand } Compilation TS — impossible de passer un string nu
Tout champ format §5.6 a un type dédié avec regex Branded types + SEAL_VALIDATION_PATTERNS Consommateurs (C4, C6, C10) utilisent les regex pour validation Zod
failure_reason (FAILED_TIMEOUT) typé FailureReason branded type, champ obligatoire dans SealEventFailedTimeout TS compile-time — champ non optionnel
tsa_timestamp (TSA_SEALED) typé TsaTimestamp branded type, champ obligatoire dans SealEventTsaSealed TS compile-time — champ non optionnel
position_in_queue (QUEUED_PRIORITY) typé PositionInQueue branded type, champ obligatoire dans SealEventQueuedPriority TS compile-time — champ non optionnel

Vérification des interdits (forbidden)

Interdit Conformité
Utiliser string nu pour seal_id ou document_id SealId et DocumentId sont des branded types — un string nu ne compile pas
Ajouter un état non listé dans §5.7 SEAL_STATES est un tuple as const — tout ajout requiert modification explicite
Rendre optionnel un champ obligatoire par état (§5.12) L'union discriminée SealEvent déclare les champs additionnels sans ? par variant

Hypothèses

Aucune hypothèse supplémentaire au-delà de celles documentées dans le plan.

Fichiers hors périmètre identifiés

Aucun fichier hors périmètre nécessaire pour ce module. Le module seal-types est autonome (pas de dépendance interne).