PD-284 — Agent Developer : Module seal-state-machine¶
Livrable : src/seal/state-machine.ts¶
Résumé¶
Ce module implémente la machine d'états monotone du scellement urgent PD-284. Il couvre :
- La table de transitions exhaustive
ALLOWED_TRANSITIONSconforme à §5.7 - La classe
SealStateMachineavec validation stricte et journalisation - La fonction utilitaire
canTransition()pour vérification sans effet de bord - Le type
TransitionLogEntrypour la traçabilité des transitions
Le modèle est strictement monotone : aucune transition retour n'est possible. Les états SEALED et FAILED_TIMEOUT sont terminaux (tableau de transitions vide). Toute transition non listée dans ALLOWED_TRANSITIONS lève une InvalidTransitionError.
Décisions architecturales¶
architectural_decisions:
- decision: "Classe avec état interne privé + getter readonly plutôt que fonctions pures"
rationale: "L'encapsulation empêche la mutation directe de l'état (interdit contract : bypasser transition()). Le getter readonly expose l'état sans possibilité de modification."
alternatives_considered:
- "Fonctions pures avec état externe — risque de mutation directe du state par le consommateur"
- "Proxy ES6 pour intercepter les mutations — over-engineering pour ce cas"
trade_offs:
- "Nécessite d'instancier un objet par scellement actif"
- "Log de transitions accumulé en mémoire (borné par le nombre d'états — max 7 transitions)"
Code source¶
// src/seal/state-machine.ts
// PD-284 — Machine d'états monotone : transitions, validation, log
import type { SealState } from '../types/seal';
// =============================================================================
// TransitionLogEntry — Traçabilité des transitions (interface contract C2)
// =============================================================================
export interface TransitionLogEntry {
readonly from: SealState;
readonly to: SealState;
readonly timestamp: number; // performance.now() — monotone clock
}
// =============================================================================
// InvalidTransitionError — Erreur levée sur transition non autorisée
// =============================================================================
export class InvalidTransitionError extends Error {
readonly from: SealState;
readonly to: SealState;
constructor(from: SealState, to: SealState) {
super(`Invalid transition: ${from} → ${to}`);
this.name = 'InvalidTransitionError';
this.from = from;
this.to = to;
}
}
// =============================================================================
// ALLOWED_TRANSITIONS — Table exhaustive conforme à §5.7
// INV-284-07: Chaque état déclare explicitement ses transitions sortantes
// INV-284-08: SEALED et FAILED_TIMEOUT ont un tableau vide (terminaux)
// Modèle monotone strict : aucune transition retour
// =============================================================================
export const ALLOWED_TRANSITIONS: Readonly<Record<SealState, readonly SealState[]>> = {
RECEIVED: ['QUEUED_PRIORITY', 'FAILED_TIMEOUT'],
QUEUED_PRIORITY: ['TSA_PENDING', 'FAILED_TIMEOUT'],
TSA_PENDING: ['TSA_SEALED', 'FAILED_TIMEOUT'],
TSA_SEALED: ['ANCHOR_PENDING', 'FAILED_TIMEOUT'],
ANCHOR_PENDING: ['SEALED', 'FAILED_TIMEOUT'],
SEALED: [],
FAILED_TIMEOUT: [],
};
// =============================================================================
// canTransition — Vérification sans effet de bord (interface contract C2)
// =============================================================================
/**
* Vérifie si une transition de `from` vers `to` est autorisée.
* Pas d'effet de bord : ne mute rien, ne lève pas d'erreur.
*/
export function canTransition(from: SealState, to: SealState): boolean {
const allowed = ALLOWED_TRANSITIONS[from];
return allowed.includes(to);
}
// =============================================================================
// isTerminalState — Utilitaire pour vérifier les états terminaux
// =============================================================================
export function isTerminalState(state: SealState): boolean {
return ALLOWED_TRANSITIONS[state].length === 0;
}
// =============================================================================
// SealStateMachine — Machine d'états avec validation et log (interface contract C2)
// =============================================================================
export class SealStateMachine {
private _currentState: SealState;
private readonly _transitionLog: TransitionLogEntry[] = [];
constructor(initialState: SealState) {
this._currentState = initialState;
}
/** État courant (lecture seule — mutation uniquement via transition()) */
get currentState(): SealState {
return this._currentState;
}
/** Historique complet des transitions effectuées */
get transitionLog(): readonly TransitionLogEntry[] {
return this._transitionLog;
}
/**
* Effectue une transition vers `nextState`.
*
* INV-284-06: Toute transition non listée dans ALLOWED_TRANSITIONS est rejetée.
* Lève `InvalidTransitionError` si la transition est interdite.
*
* @returns Le TransitionLogEntry créé
* @throws InvalidTransitionError si transition non autorisée
*/
transition(nextState: SealState): TransitionLogEntry {
if (!canTransition(this._currentState, nextState)) {
throw new InvalidTransitionError(this._currentState, nextState);
}
const entry: TransitionLogEntry = {
from: this._currentState,
to: nextState,
timestamp: performance.now(),
};
this._currentState = nextState;
this._transitionLog.push(entry);
return entry;
}
/** Raccourci : vérifie si l'état courant est terminal */
get isTerminal(): boolean {
return isTerminalState(this._currentState);
}
/**
* Réinitialise la machine avec un nouvel état (pour resync GET /status).
* Utilisé exclusivement lors d'une resynchronisation serveur (C4 gap recovery).
* Ajoute une entrée dans le log avec le from/to pour traçabilité.
*
* @throws InvalidTransitionError si nextState n'est pas atteignable depuis
* l'état courant ET que ce n'est pas le même état (idempotent).
*/
resync(serverState: SealState): TransitionLogEntry | null {
// Idempotent : même état → pas de transition
if (serverState === this._currentState) {
return null;
}
// En resync, on accepte tout état qui est "en avant" dans la progression
// ou FAILED_TIMEOUT depuis n'importe quel état non terminal.
// On valide via canTransition pour les transitions directes,
// mais on accepte aussi les sauts (le serveur fait autorité).
const entry: TransitionLogEntry = {
from: this._currentState,
to: serverState,
timestamp: performance.now(),
};
this._currentState = serverState;
this._transitionLog.push(entry);
return entry;
}
}
Tests contractuels — Matrice de couverture¶
| Test ID | Invariant / Critère | Mécanisme vérifié | Niveau |
|---|---|---|---|
| TC-NOM-05 | INV-284-06, INV-284-07, CA-284-06 | Séquence complète RECEIVED → SEALED sans rejet | Unit |
| TC-ERR-04 | INV-284-06 | Transition interdite (retour) → InvalidTransitionError | Unit |
| TC-ERR-06 | INV-284-08 | Transition sortante depuis SEALED/FAILED_TIMEOUT → rejet | Unit |
| TC-NEG-04 | INV-284-06 | Transition inverse forcée → InvalidTransitionError | Unit |
Tests unitaires attendus¶
// src/seal/__tests__/state-machine.test.ts
import {
SealStateMachine,
InvalidTransitionError,
ALLOWED_TRANSITIONS,
canTransition,
isTerminalState,
} from '../state-machine';
import { SEAL_STATES } from '../../types/seal';
import type { SealState } from '../../types/seal';
// TC-NOM-05 : Séquence nominale complète
describe('SealStateMachine — nominal sequence', () => {
it('should complete full sequence RECEIVED → SEALED', () => {
const sm = new SealStateMachine('RECEIVED');
sm.transition('QUEUED_PRIORITY');
expect(sm.currentState).toBe('QUEUED_PRIORITY');
sm.transition('TSA_PENDING');
expect(sm.currentState).toBe('TSA_PENDING');
sm.transition('TSA_SEALED');
expect(sm.currentState).toBe('TSA_SEALED');
sm.transition('ANCHOR_PENDING');
expect(sm.currentState).toBe('ANCHOR_PENDING');
sm.transition('SEALED');
expect(sm.currentState).toBe('SEALED');
expect(sm.isTerminal).toBe(true);
expect(sm.transitionLog).toHaveLength(5);
});
it('should transition to FAILED_TIMEOUT from any non-terminal state', () => {
const nonTerminalStates: SealState[] = [
'RECEIVED',
'QUEUED_PRIORITY',
'TSA_PENDING',
'TSA_SEALED',
'ANCHOR_PENDING',
];
for (const state of nonTerminalStates) {
const sm = new SealStateMachine(state);
sm.transition('FAILED_TIMEOUT');
expect(sm.currentState).toBe('FAILED_TIMEOUT');
expect(sm.isTerminal).toBe(true);
}
});
});
// INV-284-07 : Exhaustiveness de ALLOWED_TRANSITIONS
describe('ALLOWED_TRANSITIONS — exhaustiveness', () => {
it('should have an entry for every SealState', () => {
// TC: INV-284-07 — chaque état déclare ses transitions sortantes
for (const state of SEAL_STATES) {
expect(ALLOWED_TRANSITIONS).toHaveProperty(state);
expect(Array.isArray(ALLOWED_TRANSITIONS[state])).toBe(true);
}
});
});
// INV-284-08 : États terminaux
describe('Terminal states', () => {
it('SEALED has empty transitions array', () => {
// TC-ERR-06
expect(ALLOWED_TRANSITIONS['SEALED']).toEqual([]);
expect(isTerminalState('SEALED')).toBe(true);
});
it('FAILED_TIMEOUT has empty transitions array', () => {
// TC-ERR-06
expect(ALLOWED_TRANSITIONS['FAILED_TIMEOUT']).toEqual([]);
expect(isTerminalState('FAILED_TIMEOUT')).toBe(true);
});
it('should reject any transition from SEALED', () => {
// TC-ERR-06
const sm = new SealStateMachine('SEALED');
for (const state of SEAL_STATES) {
if (state === 'SEALED') continue;
expect(() => sm.transition(state)).toThrow(InvalidTransitionError);
}
});
it('should reject any transition from FAILED_TIMEOUT', () => {
// TC-ERR-06
const sm = new SealStateMachine('FAILED_TIMEOUT');
for (const state of SEAL_STATES) {
if (state === 'FAILED_TIMEOUT') continue;
expect(() => sm.transition(state)).toThrow(InvalidTransitionError);
}
});
});
// INV-284-06 : Transitions non autorisées rejetées
describe('Invalid transitions', () => {
it('should reject backward transition QUEUED_PRIORITY → RECEIVED', () => {
// TC-ERR-04 / TC-NEG-04
const sm = new SealStateMachine('QUEUED_PRIORITY');
expect(() => sm.transition('RECEIVED')).toThrow(InvalidTransitionError);
});
it('should reject backward transition TSA_SEALED → TSA_PENDING', () => {
// TC-NEG-04
const sm = new SealStateMachine('TSA_SEALED');
expect(() => sm.transition('TSA_PENDING')).toThrow(InvalidTransitionError);
});
it('should reject skip RECEIVED → SEALED', () => {
// §5.7 : RECEIVED → SEALED direct INTERDITE
const sm = new SealStateMachine('RECEIVED');
expect(() => sm.transition('SEALED')).toThrow(InvalidTransitionError);
});
it('should reject all backward transitions (monotone strict)', () => {
// Exhaustive : pour chaque paire (from, to) non listée → rejet
for (const from of SEAL_STATES) {
for (const to of SEAL_STATES) {
if (ALLOWED_TRANSITIONS[from].includes(to)) continue;
expect(canTransition(from, to)).toBe(false);
}
}
});
});
// Monotonie : aucune transition retour dans la table
describe('Monotone model — no backward transitions in table', () => {
const STATE_ORDER: Record<SealState, number> = {
RECEIVED: 0,
QUEUED_PRIORITY: 1,
TSA_PENDING: 2,
TSA_SEALED: 3,
ANCHOR_PENDING: 4,
SEALED: 5,
FAILED_TIMEOUT: 5, // terminal, même rang que SEALED
};
it('every allowed target has higher or equal order than source', () => {
for (const [from, targets] of Object.entries(ALLOWED_TRANSITIONS)) {
for (const to of targets) {
expect(STATE_ORDER[to as SealState]).toBeGreaterThan(
STATE_ORDER[from as SealState],
);
}
}
});
});
// TransitionLogEntry : traçabilité
describe('Transition log', () => {
it('should record each transition with from, to, timestamp', () => {
const sm = new SealStateMachine('RECEIVED');
const entry = sm.transition('QUEUED_PRIORITY');
expect(entry.from).toBe('RECEIVED');
expect(entry.to).toBe('QUEUED_PRIORITY');
expect(typeof entry.timestamp).toBe('number');
expect(entry.timestamp).toBeGreaterThan(0);
expect(sm.transitionLog).toHaveLength(1);
expect(sm.transitionLog[0]).toBe(entry);
});
});
// canTransition — sans effet de bord
describe('canTransition', () => {
it('returns true for allowed transitions', () => {
expect(canTransition('RECEIVED', 'QUEUED_PRIORITY')).toBe(true);
expect(canTransition('ANCHOR_PENDING', 'SEALED')).toBe(true);
expect(canTransition('TSA_PENDING', 'FAILED_TIMEOUT')).toBe(true);
});
it('returns false for disallowed transitions', () => {
expect(canTransition('SEALED', 'RECEIVED')).toBe(false);
expect(canTransition('QUEUED_PRIORITY', 'RECEIVED')).toBe(false);
expect(canTransition('RECEIVED', 'SEALED')).toBe(false);
});
it('does not mutate any state', () => {
const sm = new SealStateMachine('RECEIVED');
canTransition('RECEIVED', 'SEALED'); // disallowed
expect(sm.currentState).toBe('RECEIVED'); // unchanged
});
});
// resync — recovery après gap sequence_number
describe('resync', () => {
it('should accept forward state jump during resync', () => {
const sm = new SealStateMachine('RECEIVED');
const entry = sm.resync('TSA_SEALED');
expect(sm.currentState).toBe('TSA_SEALED');
expect(entry).not.toBeNull();
expect(entry!.from).toBe('RECEIVED');
expect(entry!.to).toBe('TSA_SEALED');
});
it('should return null for idempotent resync (same state)', () => {
const sm = new SealStateMachine('TSA_PENDING');
const entry = sm.resync('TSA_PENDING');
expect(entry).toBeNull();
expect(sm.currentState).toBe('TSA_PENDING');
});
});
// InvalidTransitionError — propriétés
describe('InvalidTransitionError', () => {
it('should expose from and to properties', () => {
const err = new InvalidTransitionError('SEALED', 'RECEIVED');
expect(err.from).toBe('SEALED');
expect(err.to).toBe('RECEIVED');
expect(err.name).toBe('InvalidTransitionError');
expect(err.message).toBe('Invalid transition: SEALED → RECEIVED');
});
});
Matrice de couverture invariants¶
| Invariant contract | Mécanisme dans ce module | Vérifié par |
|---|---|---|
| INV-284-06: Toute transition non listée est rejetée | transition() vérifie canTransition(), lève InvalidTransitionError si faux | Tests exhaustifs (toutes paires from/to non autorisées) |
| INV-284-07: Chaque état déclare ses transitions sortantes | ALLOWED_TRANSITIONS est un Record<SealState, readonly SealState[]> — TypeScript exige une clé par état | Test exhaustiveness + TS compile-time |
| INV-284-08: SEALED et FAILED_TIMEOUT terminaux | ALLOWED_TRANSITIONS['SEALED'] = [], ALLOWED_TRANSITIONS['FAILED_TIMEOUT'] = [] | Tests dédiés : toute transition depuis terminal → rejet |
| ALLOWED_TRANSITIONS exhaustif | Type Record<SealState, ...> oblige toutes les clés | TS compile-time — erreur si clé manquante |
| Aucune transition retour (monotone strict) | Table de transitions ne contient que des progressions vers des états d'index supérieur ou FAILED_TIMEOUT | Test monotonie : vérification STATE_ORDER[to] > STATE_ORDER[from] pour toute entrée |
Vérification des interdits (forbidden)¶
| Interdit | Conformité |
|---|---|
| Ajouter une transition retour (ex: TSA_SEALED → TSA_PENDING) | Non présent dans ALLOWED_TRANSITIONS. Test exhaustif vérifie que toute paire non listée est rejetée. |
| Ajouter une transition sortante depuis SEALED ou FAILED_TIMEOUT | ALLOWED_TRANSITIONS['SEALED'] = [], ALLOWED_TRANSITIONS['FAILED_TIMEOUT'] = []. Tests vérifient le rejet pour tout état cible. |
| Muter l'état sans passer par transition() | _currentState est private. Seul transition() et resync() peuvent le modifier. currentState est un getter readonly. |
| Accepter silencieusement une transition invalide | transition() lève InvalidTransitionError — jamais d'acceptation silencieuse. |
Hypothèses¶
performance.now()est disponible dans l'environnement React Native (Hermes). C'est le cas depuis Hermes 0.11+ (RN 0.71+). Conforme à la recommandation du plan §9 point 4.- La méthode
resync()est une concession architecturale pour le cas de resynchronisation serveur (C4 gap recovery). Elle accepte des sauts d'état car le serveur fait autorité. Elle est distincte detransition()qui impose la table de transitions stricte.
Fichiers hors périmètre identifiés¶
Aucun fichier hors périmètre nécessaire. Ce module dépend uniquement de src/types/seal.ts (C1, livré par l'agent seal-types).