# SPECS — POST /api/v1/lea/competences/candidate/persist > **DRAFT specs pour implémentation — pas encore appliqué — relecture Codex/Dom attendue** > > Date : 2026-06-01 > Auteur : Claude (agent Plan) > Statut : DRAFT — aucun code écrit, aucun fichier de prod modifié > Cible : `agent_v0/server_v1/api_stream.py` ## Contexte L'audit Claude du 2026-06-01 17h00 confirme l'absence de ce endpoint. Le cycle Shadow apprentissage actuel s'arrête à `/api/v1/shadow/build` (renvoie le `workflow_ir` en mémoire, ne le persiste pas). Ce endpoint clôt le cycle Léa-first (6 phases) : `start → stop → understanding → feedback (N×) → build → **persist**` Sources : - `docs/coordination/inbox_codex/2026-06-01_1620_claude-to-codex_ARCHI-apprentissage-Lea-first-validee-Dom.md` - `docs/coordination/inbox_codex/2026-06-01_1745_claude-to-codex_ADDENDUM-archi-Lea-lecture-semantique-agent-externe.md` - `docs/coordination/inbox_codex/2026-06-01_1800_claude-to-codex_PRECISIONS-desapprentissage-regle-or-HDS.md` Schéma YAML de référence : `data/competences/candidate/key_win_r_wait_explorer_exe.yaml` ## Sommaire 1. Contrat de l'endpoint 2. Validation pré-écriture 3. Génération YAML 4. Journalisation 5. Cas particuliers 6. Sécurité 7. Tests à prévoir 8. Estimation effort implémentation --- ## 1. Contrat de l'endpoint **Méthode / URL** : `POST /api/v1/lea/competences/candidate/persist` **Headers requis** : - `Authorization: Bearer ` (cohérent avec patch P0 révocation token Codex) - `Content-Type: application/json` **Payload entrée (JSON)** : ```json { "name": "Saisir texte Word", "machine_id": "DESKTOP-58D5CAC_windows", "session_id": "sess_20260601T180000_abc123", "workflow_ir": { "steps": [...], "preconditions": [...], "success_marker": {...} }, "parameters": [ { "name": "texte", "type": "string", "required": true } ], "external_decision": { "agent_id": "t2a_v2_codeur", "decision_contract": "..." }, "annotations_semantiques": { "intent_fr": "saisir le texte transmis dans Word" }, "learning_metadata": { "persist_id": "uuid-v4-client", "partial": false, "dropout_reason": null, "source_phase": "shadow_build" } } ``` **Codes HTTP** : - `201 Created` — YAML écrit, audit journalisé - `400 Bad Request` — payload invalide (schema, slug, agent externe inconnu) - `401 Unauthorized` — token absent / invalide / révoqué - `403 Forbidden` — `machine_id` du payload ≠ machine du token - `409 Conflict` — slug existe déjà dans `data/competences/candidate/` - `429 Too Many Requests` — rate limit dépassé - `500 Internal Server Error` — échec atomic write ou roundtrip **Payload sortie (201)** : ```json { "competence_id": "saisir_texte_word", "yaml_path": "data/competences/candidate/saisir_texte_word.yaml", "learning_state": "candidate", "persist_id": "uuid-v4-serveur", "audit_entry_id": 4271 } ``` ## 2. Validation pré-écriture **Slugification** (`name` → `competence_id`) : - Conversion kebab-case ASCII (translittération accents) - Lowercase, espaces → `_`, chars non `[a-z0-9_]` retirés - Longueur ≤ 80 caractères, ≥ 3 caractères - Pattern regex final : `^[a-z][a-z0-9_]{2,79}$` - Si collision avec un fichier existant en `candidate/` : **409 sans suffixage automatique** **Schema YAML cohérent** — champs obligatoires : `schema_version, id, name, version, learning_state, intent, parameters, preconditions, methods, success_marker, failure_message_template, promotion, generalisation, failure_log, created_at, last_updated_at, methods_execution` **WorkflowIR** : - `workflow_ir.steps` non vide **sauf si** `learning_metadata.partial == true` - Chaque step possède `kind` ∈ primitives connues (réutiliser registry primitives existante) **Idempotence** : - `persist_id` (UUID v4 client) déduit la clé d'idempotence - Si `persist_id` déjà présent dans `persist_audit.jsonl` → renvoyer `200 OK` avec le payload de l'entrée précédente (pas 409) ## 3. Génération YAML **Chemin cible** : `data/competences/candidate/.yaml` **Atomic write** : 1. Écrire dans `data/competences/candidate/..yaml.tmp.` 2. `os.rename()` vers `.yaml` (atomique POSIX) 3. Si collision détectée entre validation et rename → 409, supprimer le `.tmp` **Post-write roundtrip** : - Recharger via `core.competences.catalog.load_competence(slug)` - Vérifier que `competence.to_dict()` est cohérent - Si KO → 500, supprimer le fichier, log critique ## 4. Journalisation **Audit principal** — append-only `data/competences/persist_audit.jsonl` : ```json { "persist_id": "uuid-v4", "timestamp": "2026-06-01T18:05:32+02:00", "machine_id": "...", "session_id": "...", "competence_name": "Saisir texte Word", "competence_id": "saisir_texte_word", "yaml_path": "data/competences/candidate/saisir_texte_word.yaml", "learning_state": "candidate", "partial": false, "dropout_reason": null, "external_agent_id": null, "audit_entry_id": 4271 } ``` **Apprentissages incomplets** — si `partial: true`, entrée *en plus* dans `data/competences/incomplete_learnings.jsonl` avec `dropout_reason` obligatoire. **Append-only strict** : ouvrir en mode `a`, jamais réécrire. Verrou `fcntl.flock` par fichier. ## 5. Cas particuliers | Cas | Comportement | |---|---| | `learning_metadata.partial == true` | accepté, `learning_state` forcé `incomplete`, entrée double dans `incomplete_learnings.jsonl`, `dropout_reason` obligatoire (sinon 400) | | Payload demande `learning_state: stable` | **rejet silencieux** — forcer `candidate` (jamais stable par persist direct — règle d'or HDS) | | Aucun `learning_state` fourni | défaut `candidate` | | `external_decision.agent_id` inconnu de `ExternalDecisionClient` registry | **400** avec liste agents valides | | `workflow_ir.steps` vide et `partial == false` | **400** | | YAML déjà présent en `data/competences/stable/` ou `supervised/` avec même slug | **409** (collision cross-states) | | Désapprentissage en cours sur le slug | **409** `detail: "désapprentissage actif"` | ## 6. Sécurité - **Token Bearer obligatoire** — réutiliser middleware d'auth du patch P0 révocation token. Pas de fallback "agent local". - **Couplage machine_id ↔ token** : token référence `machine_id` ; si `payload.machine_id != token.machine_id` → **403**. - **Rate limit** : 10 persists/min/`machine_id` (in-memory token-bucket). Au-delà → **429** avec `Retry-After`. - **Pas d'écriture hors `data/competences/candidate/`** : path traversal interdit (slug strict, jamais lu d'un champ user). - **Logs** : ne jamais journaliser le token. `persist_id` et `machine_id` only. - **Règle d'or HDS** : aucune donnée patient ne doit transiter — refuser le payload si `workflow_ir` ou `annotations_semantiques` contient pattern PII détecté. Détection MVP via regex paramétrable. ## 7. Tests à prévoir **Unit (`tests/unit/test_competence_persist.py`)** : - `test_slug_generation_normal` / `_with_accents` / `_too_short` / `_collision_409` - `test_yaml_schema_required_fields_present` - `test_atomic_write_then_rename` - `test_post_write_roundtrip_ok` / `_corrupt_yaml_500` - `test_idempotence_same_persist_id_returns_previous` - `test_partial_true_forces_incomplete_state` - `test_payload_stable_forced_to_candidate` - `test_external_agent_unknown_400` - `test_pii_detected_rejected` **Integration (`tests/integration/test_shadow_full_cycle.py`)** : - Cycle complet : `shadow/start → POST événements → shadow/stop → shadow/build → persist` - Vérifier YAML présent, `persist_audit.jsonl` enrichi - Cas partial : double entrée `incomplete_learnings.jsonl` **Sécurité (`tests/security/test_persist_auth.py`)** : - `test_no_token_401` - `test_token_revoked_401` - `test_machine_id_mismatch_403` - `test_rate_limit_11th_call_429` - `test_path_traversal_in_name_blocked` ## 8. Estimation effort implémentation | Item | Volume | |---|---| | Handler FastAPI + Pydantic models | ~80-120 lignes (`api_stream.py`) | | Helpers (slugify, atomic_write, audit_append) | ~40 lignes (nouveau module `core/competences/persist.py`) | | Tests unit + integration + sécu | ~150 lignes | **Effort total** : faible-moyen — **0.5 à 1 jour**. Pas de dépendance externe nouvelle, réutilise `core.competences.catalog` et le pattern atomic-write de C-γ. **Points d'attention pour la review** : - Confirmer comportement collision (409 strict vs suffixage `_v2`) - Valider mapping `workflow_ir.steps[*].kind` → `yaml.methods[*].kind` avec registry primitives à jour - Décider si rate limit par `machine_id` ou par token - Statuer sur format `audit_entry_id` (auto-incrément vs hash) --- *Fin DRAFT — relecture Codex/Dom attendue avant kick-off implémentation.*