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/urgent → GET /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¶
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)¶
- Guard double orchestration : si
activeDocumentId === documentId→ retour immédiat + telemetryduplicate_orchestration_blocked - Debounce SEC-02 : si
Date.now() - lastTriggerTimestamp < 2000ms→ retour immédiat - Cleanup précédent : disconnect SSE client + dispose event processor
- 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
activeDocumentIdempê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
sendTerminalNotificationcallback est synchrone et non bloquant (délègue à C11 en interne).