Aller au contenu

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_TRANSITIONS conforme à §5.7
  • La classe SealStateMachine avec validation stricte et journalisation
  • La fonction utilitaire canTransition() pour vérification sans effet de bord
  • Le type TransitionLogEntry pour 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 de transition() 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).