Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ jobs:
node tests/deep-checks.cjs
node tests/project-checks.cjs
node tests/cloud-sync.spec.cjs
node tests/atomic-restoration.cjs
BROWSER=${{ matrix.browser }} node tests/atomic-restoration-browser.cjs
BROWSER=${{ matrix.browser }} node tests/storage-multitab.cjs
- name: Dossier reconversion, écoles, budget et compatibilité
env:
BROWSER: ${{ matrix.browser }}
Expand Down
45 changes: 45 additions & 0 deletions NOESIS_FINAL_MILE_STORAGE_20261024.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# NOESIS — multi-tab writes and failed restoration verification

This branch is stacked on #26 and does not modify its source branch.

Confirmed before correction: two instances of the actual atomic functions loaded the
same V3 baseline; the second overwrote the first without refusal. A second red test
showed that a readback failure after writing a replacement left the replacement in
persistent storage despite reporting failure.

Corrections are confined to existing persistence and its safety notice:
- One native exclusive Web Lock grants a writer lease for a document lifetime. Other
tabs retain memory and export capability, but cannot commit imports, resets or edits.
A failed or unsupported lock never falls back to unsafe concurrent writes.
- Before writes, compare the stored canonical bytes with the loaded/confirmed baseline.
An external change blocks further writes rather than being adopted or overwritten.
- Manual/cloud restore establishes a confirmed source first. If replacement readback
fails, restore the previous exact bytes only when the exclusive writer still sees its
own replacement. If this rollback also fails or another value appears, block writes
and preserve canonical/legacy keys for diagnosis. No key is deleted.
- Invalid canonical progression/Daily Five and unparseable historical storage cannot
be overwritten by empty defaults. Canonical business sanitizers run strictly.
- Cloud snapshots are refused for unconfirmed or secondary-tab memory.

Regression evidence: exact production functions in the VM; native Web Locks, real
localStorage, two pages, simultaneous edits, rejected import, exported unsaved work,
complete business/Daily Five comparison after closure, corrupted canonical bytes, quota
and readback failure in both required browser jobs. No Google or Supabase connection.
The local browser installation failed; GitHub CI on the exact head is required before
claiming browser success. Independent device and production states remain unverified.

Limits: this is a cooperative lock, not protection from an XSS/hostile same-origin script
or an old version that ignores the protocol. Close old tabs before a reviewed upgrade.
A secondary tab requires export and reload after the writer closes; it never silently
promotes stale memory. Pagehide releases the lease; BFCache returns remain read-only.
A persistent storage failure cannot promise automatic rollback; the visible blocked
state is a recovery incident, not a success. Preserve all keys and independent exports.

Rollback: retain V3 canonical bytes, both legacy mirrors and full export before any
approved code downgrade. Do not assume old code reads V3 or that mirrors are current
when a storage warning occurred. Recover in a separate origin/profile, compare every
business field, then decide. No migration, data reset or personal activation here.

An additional red regression proves unsupported legacy topic IDs were silently dropped
before migration. Legacy business readers now refuse invalid/unknown data, retain original
bytes and block migration. Both browsers cover this historical preservation case.
102 changes: 87 additions & 15 deletions app.js

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions docs/NOESIS_CONSOLIDATED_RESTORE_20261024.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# NOESIS — consolidation de la restauration locale

Auteur : Bryan Fiarda. Relevé : 24 octobre 2026 (Europe/Paris).

## Portée et filiation

Cette branche part de main `21a4ad7a4f79d03ecd88d5bc97fb212472fb0c43` et
intègre ensemble les corrections de #26 `300f11f86566b4bd353da0d791fbfbc932a03216`
et #27 `706e204040676f9022eadb0db175e34d68961966`. #26 est ancêtre de #27.
Les deux lignées sont conservées dans les parents du commit consolidé ; aucune
branche source n'est réécrite. Ne pas fusionner #26 seule.

Le code applicatif final est identique à #27. Le schéma canonique reste
`{version:3,main,daily}` sous `reconversion-control.snapshot.v3` ; imports et
exports métier conservent leurs formats. Le verrou exclusif coopératif,
la comparaison de base, le rollback vérifié et le refus de migration d'un
historique inconnu accompagnent obligatoirement l'instantané atomique.
Il n'y a pas de nouvelle fonctionnalité, de refonte ou d'activation Supabase.

## Conservation du main et comparaison avec #30

Les neuf fichiers modifiés sur main par #28/#29 restent identiques à ce main :
README.md, course-operations.js, docs/content-refresh-20261024.md,
drive-learning.js, english.js, learning.js, project-plan.js,
tests/deep-checks.cjs et tests/learning.spec.cjs.

La référence #30 est `9b887cc03c8244b4112eda5747033b90574df9ec`, arbre
`38b5b090eb022876c9009777d29fe08add15457d`. Elle n'est ni fusionnée ni utilisée
comme parent de cette branche. Les différences attendues sont exclusivement :

- absence de sa note temporaire docs/NOESIS_CURRENT_MAIN_INTEGRATION_20261024.md ;
- présente note de consolidation et procédure de décision ;
- deux assertions supplémentaires du test navigateur atomique : aucune annonce
d'instantané vérifié après quota ou relecture perdue ;
- une assertion supplémentaire du contrôleur cloud : aucune annonce synchronisée
après restauration locale refusée.

Tous les autres fichiers doivent être identiques à #30, notamment app.js, les
tests existants, le workflow de test et tous les ajouts de #28/#29. Les assertions
existantes sont conservées. Les nouvelles assertions renforcent la preuve du
refus de faux succès ; elles ne corrigent aucun nouveau comportement applicatif.

## Acceptation sur le SHA consolidé exact

La CI doit vérifier provenance, Chromium et WebKit sur la tête courante, sans
réutiliser le résultat historique de #30. Contrôles inclus : instantané et
migration V3, progression/Daily Five, quotas, relecture perdue/rollback,
idempotence, clés historiques inconnues, import malformé ou surdimensionné,
Web Locks natifs, onglet secondaire, écritures simultanées, mémoire exportable,
fermeture/réouverture, crypto et courses cloud simulées, cours et navigation.
Chromium exécute en plus le service worker réel ; l'étape WebKit correspondante
reste non applicable selon le workflow existant. Aucun test iPhone physique ou
service Supabase réel n'est revendiqué.

Commandes locales indépendantes du navigateur :

```bash
node --check app.js
node tests/atomic-restoration.cjs
node tests/deep-checks.cjs
node tests/project-checks.cjs
node tests/cloud-sync.spec.cjs
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_pages*.py'
```

La matrice navigateur doit utiliser l'image Playwright verrouillée de la CI,
sans changement de moteur ou assouplissement pour compenser un environnement
local indisponible. Les fixtures restent synthétiques.

## Publication : condition bloquante distincte

Le workflow `.github/workflows/pages.yml` écoute la fin réussie des tests sur un
push main. Il vérifie SHA et PR fusionnée puis appelle actions/deploy-pages.
Les contrôles de provenance empêchent une mauvaise provenance, pas une
publication qui n'a pas été autorisée humainement.

L'environnement github-pages relu via API publique contient uniquement
`branch_policy`, aucun required_reviewers ni règle d'approbation personnalisée,
avec `can_admins_bypass:true`. La barrière d'approbation n'est donc pas présente.
main est déclaré non protégé et la liste des rulesets est vide ; le détail
administratif de branch protection n'est pas lisible par le connecteur.

**BLOQUÉ : aucune fusion tant qu'une barrière effective et toutes les voies de
publication n'ont pas été vérifiées, ou qu'un GO de déploiement distinct et
précis n'a pas été accordé.** L'environnement, les permissions, secrets et
workflows de publication restent inchangés. La CI de PR ne doit rien publier.
Les webhooks et intégrations non lisibles ne sont pas réputés absents.

## Retour arrière et récupération

Avant fusion : conserver les SHA et branches sources ; laisser la PR ouverte
si une condition manque. Le main et les données restent inchangés.

Après une future fusion autorisée : vérifier le commit main, sa CI et l'absence
de publication sans GO ; ne pas ajouter un commit direct sur main. Une
régression se traite par une nouvelle PR ciblée et validée, en gardant la
barrière de publication. Un revert global du code antérieur à V3 est dangereux
sans lecteur de récupération : les miroirs historiques peuvent être anciens.

Un incident de données exige de conserver intégralement la clé canonique V3,
les deux miroirs, l'export complet et les éventuelles saisies seulement en
mémoire. Ne pas supprimer des clés, réinstaller, vider Safari ou remettre un
instantané vide. Récupérer dans un profil/origine isolé, comparer progression,
Daily Five, révisions, labs, preuves, contrôles et carrière, puis fermer et
rouvrir avant toute décision. Un retour de code ne restaure aucune donnée.

Limites : verrou coopératif, anciennes pages pouvant ignorer le protocole,
stockage définitivement indisponible, absence de preuve iPhone physique.
Fermer les anciennes pages lors d'une future mise à jour approuvée, après copie
indépendante vérifiée. Aucun Supabase personnel ni donnée réelle dans cette PR.
23 changes: 23 additions & 0 deletions docs/NOESIS_RCCR_ATOMIC_RESTORE_20261023.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# NOESIS-RCC-001 — Restauration atomique (proposition technique)
Date : 23 octobre 2026 · Base `f3dddaf591b515eeee103538b33f199a1db955c5` · Aucune fusion ni publication autorisée.

## Défaillance historique
La restauration cloud et l'import écrivaient `reconversion-control.daily-five.v1` et `reconversion-control.state.v1` séparément. Les exceptions de `storageSet()` étaient masquées. Une erreur de quota lors de la seconde écriture permettait un état mêlé au rechargement, avec acquittement possible par `cloud-ui.js`.

## Changement proposé
- Le nouvel instantané local `reconversion-control.snapshot.v3` est un objet `{version:3,main,daily}`, écrit par **un seul** `localStorage.setItem`, puis relu intégralement avant accusé.
- Toutes les sauvegardes ordinaires migrent d'abord vers cet instantané atomique. Dès son existence, `load()` et `dailyLoad()` l'utilisent tous les deux, quel que soit l'état des anciennes clés.
- L'import JSON et `cloudRestore()` valident les données puis confirment cet instantané **avant** de remplacer l'état en mémoire ; si la lecture/écriture échoue, ils refusent l'accusé de succès.
- Les anciennes clés ne sont pas purgées pendant migration/restauration. Elles restent des miroirs de compatibilité **non autoritaires** ; leur écriture peut échouer sans invalider l'instantané déjà confirmé. Une application ancienne peut lire ces miroirs sans connaître leur statut : ne pas déployer deux versions concurrentes sur la même origine.
- Si un instantané V3 existe mais n'est pas lisible ou valide, aucune écriture ne le remplace ; une bannière indique le blocage pour éviter un effacement silencieux.
- La réinitialisation volontaire en interface n'efface les clés historiques qu'**après** validation du nouvel instantané vide.

## Recette et réserves
`node tests/atomic-restoration.cjs` (appelé depuis la CI Chromium/WebKit) : erreurs de quota, relecture perdue, migration, état historique, reprise et idempotence. Les vérifications atomiques fonctionnent avec la **fonction réelle extraite de app.js** et `localStorage` synthétique à fautes injectées ; elles ne prouvent pas le comportement d'un iPhone physique.

Autres contrôles : exécuter `node tests/cloud-ui-races.cjs`, `node tests/cloud-sync.spec.cjs`, l'import JSON et les tests du navigateur. Les résultats CI du SHA de la PR doivent être lus avant revue. Les erreurs de stockage multi-onglets et les conflits simultanés entre onglets ne sont **pas** résolus par cette PR : localStorage ne fournit aucun CAS inter-onglets. Prévoir une phase distincte avec verrou exclusif et retour utilisateur explicite plutôt qu'un dernier écrivain silencieux.

Une restauration physique, une migration d'origine, l'activation Supabase et une publication nécessitent toujours des autorisations distinctes. Aucun jeton, donnée personnelle ou backend réel n'est touché par cette PR.

## Repli sûr
Ne pas supprimer la clé V3. En cas de régression, conserver **toutes** les valeurs exportées avec une copie chiffrée hors de l'appareil et préparer un lecteur/migrateur de récupération avant rollback. Un simple retour de version qui ignorerait V3 peut présenter des clés historiques plus anciennes : il **ne constitue pas** un rollback des données.
73 changes: 73 additions & 0 deletions tests/atomic-restoration-browser.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use strict";
const assert = require("node:assert/strict");
const {chromium,webkit} = require("playwright");
const {startServer} = require("./server.cjs");
const B = {chromium,webkit}[process.env.BROWSER || "chromium"];
if(!B)throw Error("Set BROWSER to chromium or webkit");
const CANON = "reconversion-control.snapshot.v3", LEGACY = "reconversion-control.state.v1";
const fixture = {version:2,progress:{dns:"mastered"},review:{},labs:{},proofs:{},labChecks:{},dailyFive:{days:{}}};
const buffer = {name:"synthetic-atomic-restore.json",mimeType:"application/json",buffer:Buffer.from(JSON.stringify(fixture))};
async function run(){
const server=await startServer(),browser=await B.launch({headless:true});
const page=await browser.newPage();
const dialogs=[]; page.on("dialog",async d=>{dialogs.push(d.message());await d.accept();});
try {
await page.goto(server.url + "/?noesis=fixture");
await page.locator('#nav [data-view="library"]').click();
await page.locator('[data-card="dns"] [data-prog="progress"]').click();
await page.locator('#nav [data-view="privacy"]').click();
const before=await page.evaluate(key=>localStorage.getItem(key),CANON);
assert(before && JSON.parse(before).main.progress.dns==="progress","seeded V3 must exist");
await page.evaluate(key=>{
const original=Storage.prototype.setItem;
Storage.prototype.setItem=function(k,v){if(k===key)throw new DOMException("synthetic quota","QuotaExceededError");return original.call(this,k,v);};
window.noesisRestore=()=>{Storage.prototype.setItem=original;};
},CANON);
await page.locator("#importer").setInputFiles(buffer);
await page.waitForFunction(()=>document.querySelector("#importer").value==="");
assert(dialogs.some(x=>/Import refusé/.test(x)),"quota must visibly refuse import");
assert(!dialogs.some(x=>/instantané complet vérifié/.test(x)),"failed quota import must never announce a verified snapshot");
assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),before,"failed commit cannot replace V3");
await page.reload();
assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"progress","old state must survive restart");
assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).daily.days&&typeof JSON.parse(localStorage.getItem(k)).daily.days,CANON),"object");
console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" failed manual import does not acknowledge/quota erase V3, including reload");
await page.locator('#nav [data-view="privacy"]').click();
await page.locator("#importer").setInputFiles(buffer);
await page.waitForFunction(()=>document.querySelector("#importer").value==="");
assert(dialogs.some(x=>/instantané complet vérifié/.test(x)),"successful import must be acknowledged");
await page.reload();
assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"mastered");
console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" successful import survives restart");
await page.locator('#nav [data-view="privacy"]').click();
const rollbackBefore=await page.evaluate(k=>localStorage.getItem(k),CANON);
await page.evaluate(key=>{
const set=Storage.prototype.setItem,get=Storage.prototype.getItem;let writes=0,fail=false;
Storage.prototype.setItem=function(k,v){const r=set.call(this,k,v);if(k===key&&++writes===2)fail=true;return r;};
Storage.prototype.getItem=function(k){if(k===key&&fail){fail=false;throw Error("synthetic lost readback");}return get.call(this,k);};
},CANON);
const rollbackDialogs=dialogs.length;
await page.locator("#importer").setInputFiles({...buffer,buffer:Buffer.from(JSON.stringify({...fixture,progress:{dns:"learn"}}))});
await page.waitForFunction(()=>document.querySelector("#importer").value==="");
assert(dialogs.slice(rollbackDialogs).some(x=>/Import refusé/.test(x)),"lost readback must refuse success");
assert(!dialogs.slice(rollbackDialogs).some(x=>/instantané complet vérifié/.test(x)),"failed readback must never announce a verified snapshot");
assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),rollbackBefore,"rollback restores every previous byte");
await page.reload();
assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),rollbackBefore,"confirmed rollback survives reload");
console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" lost readback restores complete prior state and rejects success");
await page.locator('#nav [data-view="privacy"]').click();
await page.evaluate(key=>{
const original=Storage.prototype.setItem;
Storage.prototype.setItem=function(k,v){if(k===key)throw new DOMException("synthetic legacy quota","QuotaExceededError");return original.call(this,k,v);};
},LEGACY);
const beforeLegacyDialogs=dialogs.length;
const fixture2={...fixture,progress:{dns:"learn"}};
await page.locator("#importer").setInputFiles({...buffer,buffer:Buffer.from(JSON.stringify(fixture2))});
await page.waitForFunction(()=>document.querySelector("#importer").value==="");
assert(dialogs.slice(beforeLegacyDialogs).some(x=>/instantané complet vérifié/.test(x)),"legacy mirror failure must not undermine canonical write");
await page.reload();
assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"learn");
console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" legacy mirror failure cannot split authoritative state");
}finally{await browser.close();await server.close();}
}
run().catch(e=>{console.error(e);process.exitCode=1;});
Loading
Loading