Aller au contenu

PD-284 — Agent Developer : seal-orchestrator (C7)

1. Résumé du module

Le module seal-orchestrator (C7) coordonne le flux A complet de déclenchement d'un scellement urgent : POST /seals/urgentGET /seals/{id}/status → initialisation store → ouverture SSE. Il orchestre les interactions entre C3 (SSE client), C4 (event processor), C5 (store), C6 (API client), C11 (notifications) et C12 (secure storage).

Fichier : src/seal/orchestrator.ts

2. Interfaces implémentées

SealOrchestrator (interface publique)

export interface SealOrchestrator {
  triggerUrgentSeal(documentId: DocumentId): Promise<void>;
  dispose(): void;
  readonly isActive: boolean;
}

SealOrchestratorConfig (configuration injectée)

export interface SealOrchestratorConfig {
  readonly getAuthToken: () => Promise<string>;
  readonly sealConfig?: Partial<SealConfig>;
  readonly showToast: (message: string) => void;
  readonly sendTerminalNotification: (sealId: SealId, state: "SEALED" | "FAILED_TIMEOUT") => void;
  readonly onSensitiveArtifact?: (sealId: SealId, key: string, value: string) => Promise<void>;
}

Factory

export function createSealOrchestrator(config: SealOrchestratorConfig): SealOrchestrator;

3. Couverture des invariants du code contract

Invariant Mécanisme implémenté Ligne(s) clé
CA-284-05: Séquence stricte POST → GET → store → SSE executeOrchestration() exécute les 5 étapes séquentiellement, chaque étape bloquante avant la suivante executeOrchestration steps 1-5
R-02: GET échoue → toast + init RECEIVED + SSE try/catch autour de getSealStatus, fallback initialState = "RECEIVED", SSE ouvert quand même Step 2 du executeOrchestration
SEC-02: Debounce 2s DEBOUNCE_DELAY_MS = 2000, vérification now - lastTriggerTimestamp < DEBOUNCE_DELAY_MS triggerUrgentSeal()
Disable optimistic bouton setLoading(true) immédiat dans executeOrchestration, consommé par C8 via selectIsLoading Step 0
C11: Notification terminale isTerminalState(event.status)config.sendTerminalNotification() dans onEventApplied buildProcessorCallbacks
Cleanup SSE/polling à dispose dispose() appelle cleanupSSE() (disconnect SSE client) + eventProcessor.dispose() dispose()
FORBIDDEN: SSE avant GET SSE n'est ouvert qu'à l'étape 5 (startSSE), après étape 2 (GET) et 3 (init store) Séquence steps 2-5
FORBIDDEN: Sauter GET Le GET est toujours appelé ; seul le résultat est ignoré en cas d'échec (R-02) Step 2 try/catch
FORBIDDEN: Double orchestration même document Guard activeDocumentId === documentId → return immédiat triggerUrgentSeal()
FORBIDDEN: Listeners actifs après unmount dispose() invoqué par useEffect cleanup nettoie tout dispose()

4. Flux détaillé

4.1 triggerUrgentSeal(documentId)

  1. Guard double orchestration : si activeDocumentId === documentId → retour immédiat + telemetry duplicate_orchestration_blocked
  2. Debounce SEC-02 : si Date.now() - lastTriggerTimestamp < 2000ms → retour immédiat
  3. Cleanup précédent : disconnect SSE client + dispose event processor
  4. Délègue à executeOrchestration()

4.2 executeOrchestration(documentId) — séquence stricte

Étape Action Composant Condition de passage
0 purgeStaleSealArtifacts() C12 Learning PD-283/PD-262
1 postUrgentSeal(documentId, token)sealId C6 Succès POST
1b registerSealId(sealId) C12 Best-effort
2 getSealStatus(sealId, token)initialState C6 Si échec → R-02 fallback RECEIVED
3 initSeal(sealId, documentId, initialState) C5 Toujours
4 new SealEventProcessor(sealId, initialState, callbacks) C4 Toujours
5 startSSE(sealId) — crée SSEClient et connect C3 Toujours (même si GET échoué)

4.3 Gestion des artefacts sensibles

Quand l'event processor (C4) applique un événement TSA_SEALED, l'orchestrateur stocke tsa_token_ref dans SecureStore via C12. Mécanisme :

C4.onEventApplied(event TSA_SEALED)
  → C7 appelle storeSensitiveArtifact(sealId, "tsa_token_ref", value)
  → erreur catch → logControlledError (non bloquant)

4.4 États terminaux

Quand isTerminalState(event.status) est vrai (SEALED ou FAILED_TIMEOUT) : 1. config.sendTerminalNotification(sealId, status) → C11 2. cleanupSSE() → disconnect SSE, arrêt polling 3. L'orchestration est considérée terminée (mais activeSealId reste défini pour empêcher une nouvelle orchestration identique)

4.5 Resynchronisation (gap sequence_number)

Quand C4 détecte un gap persistant après la fenêtre de grâce 200ms : 1. C4 appelle onResyncRequired(sealId) 2. L'orchestrateur appelle getSealStatus(sealId) via C6 3. Recalibre C4 via eventProcessor.resyncFromServer() 4. Met à jour C5 via applyEvent() avec un événement synthétique construit depuis la réponse GET

4.6 Gestion des erreurs

Situation Comportement Effet store
POST 4xx Toast message backend, pas de carte setError(msg), setLoading(false)
POST 5xx Toast "Erreur serveur, réessayez" setError(msg), setLoading(false)
POST network Toast message erreur réseau setError(msg), setLoading(false)
GET échoue post-POST (R-02) Toast info, init RECEIVED, SSE quand même initSeal(RECEIVED)
Resync GET échoue Toast "Resynchronisation échouée" Pas de mutation
SecureStore échoue Log telemetry, non bloquant Pas de mutation

5. Dépendances directes

Module Import Usage
C1 (seal-types) Types, asSealId, DEFAULT_SEAL_CONFIG Types branded, config
C2 (state-machine) isTerminalState Détection état terminal
C3 (sse-client) createSSEClient, SSEClient Transport SSE/polling
C4 (event-processor) SealEventProcessor Dédup, reorder, validation
C5 (seal-store) useSealStore State management
C6 (api-client) postUrgentSeal, getSealStatus, SealApiError Appels API
C12 (secure-storage) purgeStaleSealArtifacts, registerSealId, storeSensitiveArtifact Sécurité artefacts
C13 (telemetry) logControlledError Journalisation

6. Helper statusResponseToSealEvent

Convertit une SealStatusResponse (GET) en SealEvent synthétique pour réutiliser store.applyEvent(). Le switch exhaustif sur response.status garantit que tous les champs obligatoires par état sont mappés. Les champs event_id et sequence_number sont mis à 0 car le GET status ne les fournit pas.

7. Patterns d'utilisation (SealDetailScreen — C14)

const orchestratorRef = useRef<SealOrchestrator | null>(null);

useEffect(() => {
  orchestratorRef.current = createSealOrchestrator({
    getAuthToken: () => authStore.getToken(),
    showToast: (msg) => Toast.show({ text: msg, duration: 5000 }),
    sendTerminalNotification: (sealId, state) => {
      notificationService.sendSealNotification(sealId, state);
    },
  });
  return () => orchestratorRef.current?.dispose();
}, []);

const handleUrgentPress = useCallback(() => {
  orchestratorRef.current?.triggerUrgentSeal(documentId);
}, [documentId]);

8. Matrice de couverture tests contractuels

Test ID Mécanisme vérifié Composant Niveau
TC-NOM-04 Séquence POST → GET → SSE C7 Integration
TC-NOM-05 Transitions via C4 + C2 C7 (câblage) Integration
TC-NOM-06 Terminal SEALED → notification C7 → C11 Integration
TC-NOM-07 Terminal FAILED_TIMEOUT → notification C7 → C11 Integration
TC-NOM-08 Failover SSE → polling (via C3) C7 (câblage C3) Integration
TC-NOM-09 Retour SSE depuis polling (via C3) C7 (câblage C3) Integration
TC-NOM-17 Resync GET sur gap (C4 → C7) C7 performResync Integration
TC-ERR-01 POST 4xx → toast + pas de carte C7 error handling Integration
TC-ERR-02 POST 5xx → toast + pas de carte C7 error handling Integration
TC-INV-10 Aucun endpoint hors contrat C7 (seuls POST/GET/SSE utilisés) Review

9. Décisions architecturales

architectural_decisions:
  - decision: "Factory pattern (createSealOrchestrator) plutôt que classe"
    rationale: "Cohérent avec C3 (createSSEClient). Encapsulation via closure, pas de `this` binding issues en React."
    alternatives_considered: ["Classe OOP avec méthodes", "Hook custom useOrchestrator"]
    trade_offs: "Pas de possibilité d'héritage (pas nécessaire). Le hook wrapper est documenté mais non implémenté (C14 le fera)."

  - decision: "Accès store via useSealStore.getState() (hors React)"
    rationale: "L'orchestrateur est un objet JavaScript pur, pas un composant React. Zustand getState() est la méthode documentée pour l'accès hors composant."
    alternatives_considered: ["Passer le store en paramètre", "Utiliser un EventEmitter"]
    trade_offs: "Couplage direct au singleton Zustand. Acceptable car le store est unique par app."

10. Hypothèses

  • HT-09 : PD-80 est idempotent sur POST /seals/urgent. Le guard activeDocumentId empêche les doubles côté client mais ne protège pas contre un crash/reload. L'idempotence backend est critique.
  • Le getAuthToken() fournit toujours un token valide non expiré. Si le token expire pendant l'orchestration, les erreurs API sont traitées comme des erreurs standard (toast + retry possible).
  • Le sendTerminalNotification callback est synchrone et non bloquant (délègue à C11 en interne).